This commit is contained in:
ponzischeme89
2026-08-22 18:40:50 +12:00
parent 9858e44710
commit 7d77700c7b
36 changed files with 480 additions and 119 deletions
+7
View File
@@ -51,6 +51,13 @@ contributors.
Third-party software
--------------------
Inter
-----
Memby bundles the Inter font family, copyright 2016 The Inter Project Authors
(https://github.com/rsms/inter), under the SIL Open Font License, Version 1.1.
The complete font licence is packaged at assets/licenses/inter_ofl.txt.
The Android application uses AndroidX, Jetpack Compose, Media3, Coil,
Retrofit, OkHttp, kotlinx.serialization, and other open-source libraries.
Their copyright notices and licence metadata remain in their respective
+1 -1
View File
@@ -62,7 +62,7 @@ val projectNoticeText =
rootProject.file("NOTICE").readText()
.replace("https://g.sublogue.com/admin/memby", membySourceUrl)
val defaultVersionName = "0.3.02"
val defaultVersionName = "0.3.03"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
@@ -0,0 +1,92 @@
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -109,6 +109,7 @@ data class RemoteFeatures(
data class RemotePresentation(
val navigationRailExpandedWidthDp: Int = 184,
val navigationContentShiftDp: Int = 112,
val fontFamily: String = "system",
)
@Serializable
@@ -7,6 +7,8 @@ import android.service.dreams.DreamService
import android.view.KeyEvent
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
@@ -18,6 +20,7 @@ import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryController
import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.ui.player.PlayerActivity
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.TrailerPlaybackRequest
@@ -60,7 +63,8 @@ class MembyDreamService : DreamService() {
setViewTreeSavedStateRegistryOwner(lifecycleOwner)
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
MembyTheme {
val remoteConfig by ServiceLocator.remoteConfig.activeFlow.collectAsState()
MembyTheme(fontFamilyName = remoteConfig.presentation.fontFamily) {
ScreensaverContent(
onPlay = ::launchTrailer,
onExit = { finish() },
@@ -0,0 +1,36 @@
package com.ponzischeme89.memby.ui
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
/** A restrained pull-back that gives large hero artwork some life without repeating. */
@Composable
internal fun Modifier.cinematicBackdropPullBack(
artworkKey: Any?,
alpha: () -> Float = { 1f },
): Modifier {
val scale = remember(artworkKey) { Animatable(CinematicBackdropInitialScale) }
LaunchedEffect(artworkKey) {
scale.animateTo(
targetValue = 1f,
animationSpec = tween(
durationMillis = CinematicBackdropPullBackMs,
easing = LinearOutSlowInEasing,
),
)
}
return graphicsLayer {
scaleX = scale.value
scaleY = scale.value
this.alpha = alpha().coerceIn(0f, 1f)
}
}
private const val CinematicBackdropInitialScale = 1.08f
private const val CinematicBackdropPullBackMs = 10_000
@@ -114,6 +114,7 @@ import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembySurface
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
import com.ponzischeme89.memby.ui.theme.MembyTrialTypography
import com.ponzischeme89.memby.ui.theme.ValueSeparator
// The detail page's names for the shared tokens. The neutrals used to be a shade darker
@@ -269,7 +270,7 @@ internal fun DetailBackdrop(
alignment = Alignment.TopCenter,
modifier = Modifier
.fillMaxSize()
.graphicsLayer { alpha = artworkAlpha().coerceIn(0f, 1f) },
.cinematicBackdropPullBack(artwork, artworkAlpha),
)
}
Box(
@@ -637,6 +638,7 @@ private fun DetailHero(
// height instead would re-flow the title, the synopsis and the action row on every frame
// of the transition — which is the "suddenly resize a focused item" failure, sixty times
// a second.
MembyTrialTypography {
Box(
Modifier
.fillMaxWidth()
@@ -838,6 +840,7 @@ private fun DetailHero(
}
}
}
}
/**
* The primary action and the circular secondary ones, plus the line that names them.
@@ -1369,6 +1372,7 @@ internal fun DetailFocusablePane(
@Composable
internal fun DetailMetaRows(rows: List<TechnicalSpec>, modifier: Modifier = Modifier, labelWidth: androidx.compose.ui.unit.Dp = 110.dp) {
MembyTrialTypography {
Column(modifier, verticalArrangement = Arrangement.spacedBy(9.dp)) {
rows.forEach { row ->
Row(Modifier.fillMaxWidth()) {
@@ -1378,6 +1382,7 @@ internal fun DetailMetaRows(rows: List<TechnicalSpec>, modifier: Modifier = Modi
}
}
}
}
/**
* A movie's landing pane: the whole synopsis, and the four credit lines worth leading with.
@@ -1394,6 +1399,7 @@ internal fun DetailOverviewPane(
modifier: Modifier = Modifier,
supportingText: String? = null,
) {
MembyTrialTypography {
DetailFocusablePane(focusRequester, modifier) {
Row(horizontalArrangement = Arrangement.spacedBy(48.dp)) {
Column(Modifier.weight(1.5f)) {
@@ -1427,6 +1433,7 @@ internal fun DetailOverviewPane(
}
}
}
}
/**
* The Details tab: catalogue facts on the left, what the file actually is on the right.
@@ -1451,6 +1458,7 @@ internal fun DetailDetailsPane(
val alreadySaid = rows.map { it.value }.toSet()
specs.filterNot { it.value in alreadySaid }
}
MembyTrialTypography {
DetailFocusablePane(focusRequester, modifier) {
if (rows.isEmpty() && specs.isEmpty()) {
Text(
@@ -1478,6 +1486,7 @@ internal fun DetailDetailsPane(
}
}
}
}
/**
* The Cast tab. A grid rather than the rail this replaced — see `ui/detail/CastGrid.kt`.
@@ -17,7 +17,7 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
setContent {
val remoteConfig by ServiceLocator.remoteConfig.activeFlow.collectAsStateWithLifecycle()
MembyTheme {
MembyTheme(fontFamilyName = remoteConfig.presentation.fontFamily) {
AppRoot(
remoteConfig = remoteConfig,
onCloseSettings = ::finish,
@@ -1,8 +1,5 @@
package com.ponzischeme89.memby.ui
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
@@ -10,14 +7,12 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
@@ -26,6 +21,7 @@ import coil.request.ImageRequest
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.ui.theme.MembySurface
import com.ponzischeme89.memby.ui.theme.MembyTrialTypography
/**
* The quiet surface between the focused title details and the scrolling shelves.
@@ -44,6 +40,7 @@ internal fun MetadataHero(
isContinueWatchingItem: Boolean = false,
modifier: Modifier = Modifier,
) {
MembyTrialTypography {
Box(
modifier = modifier
.clipToBounds()
@@ -91,6 +88,7 @@ internal fun MetadataHero(
)
}
}
}
@Composable
private fun MetadataHeroArtwork(
@@ -119,16 +117,6 @@ private fun MetadataHeroArtwork(
}
}
if (request == null) return
val artworkScale = remember(artwork) { Animatable(MetadataHeroArtworkInitialScale) }
LaunchedEffect(artwork) {
artworkScale.animateTo(
targetValue = 1f,
animationSpec = tween(
durationMillis = MetadataHeroArtworkZoomOutMs,
easing = LinearOutSlowInEasing,
),
)
}
Box(modifier) {
Box(
modifier = Modifier
@@ -143,10 +131,7 @@ private fun MetadataHeroArtwork(
alignment = Alignment.CenterEnd,
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
scaleX = artworkScale.value
scaleY = artworkScale.value
},
.cinematicBackdropPullBack(artwork),
)
Box(
Modifier.fillMaxSize().background(
@@ -173,6 +158,4 @@ private fun MetadataHeroArtwork(
}
private const val MetadataHeroArtworkCrossfadeMs = 180
private const val MetadataHeroArtworkInitialScale = 1.05f
private const val MetadataHeroArtworkZoomOutMs = 10_000
private const val MetadataHeroArtworkWidthFraction = 0.68f
@@ -32,9 +32,10 @@ annotation class TvPreview
@Composable
fun PreviewSurface(
alignment: Alignment = Alignment.Center,
fontFamilyName: String = "system",
content: @Composable () -> Unit,
) {
MembyTheme {
MembyTheme(fontFamilyName = fontFamilyName) {
Box(
modifier = Modifier
.fillMaxSize()
@@ -19,14 +19,12 @@ import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyAccentBright
import com.ponzischeme89.memby.ui.theme.MembyAccentInk
import com.ponzischeme89.memby.ui.theme.MembyAccentMuted
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
import com.ponzischeme89.memby.ui.theme.MembyHairline
import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembyOutline
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
/**
* One answer in a television settings panel. The panel owns its invariant chrome while
@@ -204,7 +202,7 @@ fun tvSettingsOptionView(
fun render(focused: Boolean) {
val fill = when {
focused -> MembyControlSurface.toArgb()
focused -> PlayerMenuFocusedSurface
prominent -> MembyAccentMuted.toArgb()
selected -> MembyAccentMuted.toArgb()
else -> android.graphics.Color.TRANSPARENT
@@ -243,7 +241,7 @@ fun applyTvSettingsPanelSurface(view: View) {
val context = view.context
view.background = roundedBackground(
context,
MembySurfaceRaised.toArgb(),
PlayerMenuBackground,
MembyOutline.toArgb(),
1,
MembyPanelCorner.value.toInt(),
@@ -259,10 +257,10 @@ private data class TvSettingsPanel(
private fun tvSettingsPanel(context: Context, title: String, description: String): TvSettingsPanel {
val root = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
setPadding(context.dp(28), context.dp(25), context.dp(28), context.dp(25))
setPadding(context.dp(24), context.dp(22), context.dp(24), context.dp(22))
background = roundedBackground(
context,
MembySurfaceRaised.toArgb(),
PlayerMenuBackground,
MembyOutline.toArgb(),
1,
MembyPanelCorner.value.toInt(),
@@ -272,7 +270,7 @@ private fun tvSettingsPanel(context: Context, title: String, description: String
root.addView(TextView(context).apply {
text = title
setTextColor(MembyOnSurface.toArgb())
textSize = 24f
textSize = 22f
typeface = Typeface.create("sans-serif", Typeface.BOLD)
})
if (description.isNotBlank()) {
@@ -304,7 +302,7 @@ private fun tvSettingsDialog(context: Context, onDismiss: () -> Unit): Dialog =
window?.apply {
setBackgroundDrawable(ColorDrawable(android.graphics.Color.TRANSPARENT))
addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
attributes = attributes.apply { dimAmount = 0.72f }
attributes = attributes.apply { dimAmount = 0.80f }
setGravity(Gravity.CENTER)
}
setOnDismissListener { onDismiss() }
@@ -320,7 +318,7 @@ private fun finishTvSettingsDialog(
val context = root.context
val frame = FrameLayout(context).apply {
setPadding(context.dp(8), context.dp(8), context.dp(8), context.dp(8))
addView(root, FrameLayout.LayoutParams(context.dp(560), ViewGroup.LayoutParams.WRAP_CONTENT))
addView(root, FrameLayout.LayoutParams(context.dp(480), ViewGroup.LayoutParams.WRAP_CONTENT))
}
dialog.setContentView(frame)
val scroll = options.parent as ScrollView
@@ -354,7 +352,7 @@ private fun tvSettingsAction(context: Context, label: String, primary: Boolean,
context,
when {
focused && primary -> MembyAccent.toArgb()
focused -> MembyControlSurface.toArgb()
focused -> PlayerMenuFocusedSurface
primary -> MembyAccentMuted.toArgb()
else -> android.graphics.Color.TRANSPARENT
},
@@ -385,3 +383,6 @@ private fun roundedBackground(
}
private fun Context.dp(value: Int): Int = (value * resources.displayMetrics.density).toInt()
private val PlayerMenuBackground = android.graphics.Color.BLACK
private val PlayerMenuFocusedSurface = 0xFF151719.toInt()
@@ -9,6 +9,7 @@ import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.ui.ResumableMediaCard
import com.ponzischeme89.memby.ui.responsiveRowCardWidth
import com.ponzischeme89.memby.ui.toResumableMediaCardModel
import com.ponzischeme89.memby.ui.theme.MembyTrialTypography
@Composable
fun ContinueWatchingCard(
@@ -46,6 +47,7 @@ fun ContinueWatchingCard(
primaryUrl = repository.primaryUrl(item, 480),
)
}
MembyTrialTypography {
ResumableMediaCard(
model = model,
width = width,
@@ -53,6 +55,7 @@ fun ContinueWatchingCard(
onFocused = onFocused,
onClick = onClick,
onLongClick = onLongClick,
modifier = modifier
modifier = modifier,
)
}
}
@@ -476,7 +476,7 @@ private fun MetadataTimeRemaining(
label,
color = if (colour.equals("white", ignoreCase = true)) Color.White else EmbyGreen,
fontSize = 12.sp,
lineHeight = 16.sp,
lineHeight = 18.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
)
@@ -81,6 +81,7 @@ import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembySurface
import com.ponzischeme89.memby.ui.theme.MembyTrialTypography
import com.ponzischeme89.memby.data.remoteconfig.BundledRemoteConfig
import com.ponzischeme89.memby.data.remoteconfig.NavigationLabels
import com.ponzischeme89.memby.data.remoteconfig.NavigationRemoteConfig
@@ -197,6 +198,7 @@ fun TvNavigationRail(
animationSpec = tween(160),
label = "navigation-rail-width",
)
MembyTrialTypography {
Box(
modifier = modifier
.width(TvRailCollapsedWidth)
@@ -363,6 +365,7 @@ fun TvNavigationRail(
}
}
}
}
private fun configuredNavigationLabel(
destination: BrowseDestination,
@@ -4553,7 +4553,7 @@ class PlayerActivity : ComponentActivity() {
title = "Playback options",
description = "Fine-tune this video without leaving playback.",
options = options,
onDismiss = { playerView?.showController() },
onDismiss = { restorePlayerControlFocus(androidx.media3.ui.R.id.exo_settings) },
)
}
@@ -4579,7 +4579,7 @@ class PlayerActivity : ComponentActivity() {
showManualPassthroughPicker(current.codecs)
},
),
onDismiss = { playerView?.showController() },
onDismiss = { restorePlayerControlFocus(androidx.media3.ui.R.id.exo_settings) },
)
}
@@ -4598,7 +4598,7 @@ class PlayerActivity : ComponentActivity() {
SurroundCodec.entries.filterIndexed { index, _ -> index in selected }.toSet(),
)
},
onDismiss = { playerView?.showController() },
onDismiss = { restorePlayerControlFocus(androidx.media3.ui.R.id.exo_settings) },
)
}
@@ -4632,7 +4632,7 @@ class PlayerActivity : ComponentActivity() {
},
)
},
onDismiss = { playerView?.showController() },
onDismiss = { restorePlayerControlFocus(androidx.media3.ui.R.id.exo_settings) },
)
}
@@ -5197,7 +5197,7 @@ class PlayerActivity : ComponentActivity() {
private fun hideSubtitleOverlay() {
subtitleOverlay?.visibility = View.GONE
playerView?.showController()
restorePlayerControlFocus(androidx.media3.ui.R.id.exo_subtitle)
}
/** A completed choice returns directly to the programme, without reopening transport. */
@@ -5354,12 +5354,31 @@ class PlayerActivity : ComponentActivity() {
)
subtitleAutoSelectionAttempted = true
}
if (trackType == C.TRACK_TYPE_AUDIO) playerView?.showController()
if (trackType == C.TRACK_TYPE_AUDIO) {
restorePlayerControlFocus(androidx.media3.ui.R.id.exo_settings)
}
},
)
},
onDismiss = { playerView?.showController() },
onDismiss = {
restorePlayerControlFocus(
if (trackType == C.TRACK_TYPE_TEXT) {
androidx.media3.ui.R.id.exo_subtitle
} else {
androidx.media3.ui.R.id.exo_settings
},
)
},
)
}
/** Reopens the transport and returns the remote to the control that launched a menu. */
private fun restorePlayerControlFocus(controlId: Int) {
val view = playerView ?: return
view.showController()
view.post {
if (view.findViewById<View>(controlId)?.requestFocus() != true) view.requestFocus()
}
}
@OptIn(UnstableApi::class)
@@ -5,6 +5,9 @@ import android.os.Build
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.ui.player.PlayerActivity
import com.ponzischeme89.memby.data.model.TrailerPlaybackRequest
import com.ponzischeme89.memby.ui.theme.MembyTheme
@@ -24,7 +27,8 @@ class ScreensaverActivity : ComponentActivity() {
window.addFlags(WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON)
}
setContent {
MembyTheme {
val remoteConfig by ServiceLocator.remoteConfig.activeFlow.collectAsStateWithLifecycle()
MembyTheme(fontFamilyName = remoteConfig.presentation.fontFamily) {
ScreensaverContent(
onPlay = { item ->
startActivity(
@@ -73,13 +73,13 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Button
import androidx.tv.material3.Icon
import androidx.tv.material3.LocalTextStyle
import androidx.tv.material3.Text
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ponzischeme89.memby.R
@@ -103,6 +103,7 @@ import com.ponzischeme89.memby.data.parseThemeColor
import com.ponzischeme89.memby.ui.PreviewSurface
import com.ponzischeme89.memby.ui.TvPreview
import com.ponzischeme89.memby.ui.WelcomeQuoteStyle
import com.ponzischeme89.memby.ui.theme.MembyTrialTypography
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyAccentBright
import com.ponzischeme89.memby.ui.theme.MembyAccentInk
@@ -694,6 +695,7 @@ fun SettingsSheet(
},
)
MembyTrialTypography {
Box(modifier = modifier.fillMaxSize()) {
if (overlay) {
Box(
@@ -755,6 +757,7 @@ fun SettingsSheet(
}
}
}
}
@Composable
internal fun SettingsPanelContent(
@@ -786,6 +789,7 @@ internal fun SettingsPanelContent(
contentScrollState.scrollTo(0)
}
MembyTrialTypography {
Row(
modifier = modifier
.then(if (overlay) Modifier.width(760.dp) else Modifier.fillMaxWidth())
@@ -1110,6 +1114,8 @@ internal fun SettingsPanelContent(
}
}
}
}
}
/**
* What the Settings rail prints beside "Memby Server" — the gateway's own build, with the Emby
@@ -1269,7 +1275,7 @@ private fun DeviceRenameDialog(
onValueChange = { if (it.length <= 80) name = it },
singleLine = true,
enabled = !busy,
textStyle = TextStyle(color = TextPrimary, fontSize = 18.sp),
textStyle = LocalTextStyle.current.copy(color = TextPrimary, fontSize = 18.sp),
cursorBrush = SolidColor(EmbyGreen),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { if (valid && !busy) onSave(name) }),
@@ -2,12 +2,34 @@ package com.ponzischeme89.memby.ui.theme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.em
import androidx.tv.material3.LocalTextStyle
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.darkColorScheme
import com.ponzischeme89.memby.R
internal enum class AppFontFamily {
SYSTEM,
INTER,
}
internal fun appFontFamily(value: String?): AppFontFamily = when (value?.trim()?.lowercase()) {
"inter" -> AppFontFamily.INTER
else -> AppFontFamily.SYSTEM
}
private val InterFontFamily = FontFamily(
Font(R.font.inter_regular, FontWeight.Normal),
Font(R.font.inter_medium, FontWeight.Medium),
Font(R.font.inter_semibold, FontWeight.SemiBold),
Font(R.font.inter_bold, FontWeight.Bold),
)
private val LocalTrialFontFamily = compositionLocalOf { FontFamily.SansSerif }
// The scheme is the same near-blacks the screens actually paint (see DesignTokens.kt), so
// a component that falls back to a theme colour lands on the surface it is sitting on
@@ -25,7 +47,10 @@ private fun embyColors() = darkColorScheme(
)
@Composable
fun MembyTheme(content: @Composable () -> Unit) {
fun MembyTheme(
fontFamilyName: String = "system",
content: @Composable () -> Unit,
) {
// Use Android's complete Roboto-backed sans family rather than pinning every label
// to sans-serif-medium. The generic family lets Compose select real regular,
// medium, semibold and bold faces from each Text's FontWeight, restoring hierarchy
@@ -41,9 +66,23 @@ fun MembyTheme(content: @Composable () -> Unit) {
letterSpacing = 0.006.em,
lineHeight = 1.22.em,
)
val trialFontFamily = when (appFontFamily(fontFamilyName)) {
AppFontFamily.SYSTEM -> FontFamily.SansSerif
AppFontFamily.INTER -> InterFontFamily
}
MaterialTheme(colorScheme = embyColors()) {
CompositionLocalProvider(LocalTextStyle provides appTextStyle) {
CompositionLocalProvider(
LocalTextStyle provides appTextStyle,
LocalTrialFontFamily provides trialFontFamily,
) {
content()
}
}
}
/** Applies the server-selected family only to the surfaces included in the visual trial. */
@Composable
fun MembyTrialTypography(content: @Composable () -> Unit) {
val textStyle = LocalTextStyle.current.copy(fontFamily = LocalTrialFontFamily.current)
CompositionLocalProvider(LocalTextStyle provides textStyle, content = content)
}
@@ -1,16 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- A simple outlined cog that matches Memby's other player glyphs. Kept app-owned so a
Media3 update cannot silently replace the settings entry with platform-era artwork. -->
<!-- A centred, even-odd cog that stays legible under the player button's tint. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/transparent"
android:pathData="M12,8.75a3.25,3.25 0,1 0,0 6.5a3.25,3.25 0,1 0,0 -6.5M19.1,13.35c0.05,-0.44 0.05,-0.88 0,-1.32l1.66,-1.29l-1.85,-3.2l-1.94,0.78a7.7,7.7 0,0 0,-1.15 -0.67L15.53,5.6h-3.7l-0.29,2.05c-0.4,0.18 -0.79,0.4 -1.15,0.67L8.45,7.54l-1.85,3.2l1.66,1.29a6.1,6.1 0,0 0,0 1.32L6.6,14.64l1.85,3.2l1.94,-0.78c0.36,0.27 0.75,0.49 1.15,0.67l0.29,2.05h3.7l0.29,-2.05c0.4,-0.18 0.79,-0.4 1.15,-0.67l1.94,0.78l1.85,-3.2z"
android:strokeColor="#FFFFFFFF"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:strokeWidth="1.7" />
android:fillColor="#FFFFFFFF"
android:fillType="evenOdd"
android:pathData="M19.43,12.98c0.04,-0.32 0.07,-0.65 0.07,-0.98s-0.03,-0.66 -0.07,-0.98l2.11,-1.65c0.19,-0.15 0.24,-0.42 0.12,-0.64l-2,-3.46c-0.12,-0.22 -0.37,-0.31 -0.60,-0.22l-2.49,1c-0.52,-0.40 -1.08,-0.73 -1.69,-0.98L14.5,2.42C14.47,2.18 14.25,2 14,2h-4c-0.25,0 -0.46,0.18 -0.49,0.42L9.13,5.07c-0.61,0.25 -1.18,0.58 -1.69,0.98l-2.49,-1c-0.23,-0.09 -0.48,0 -0.60,0.22l-2,3.46c-0.12,0.22 -0.07,0.49 0.12,0.64l2.11,1.65c-0.04,0.32 -0.08,0.66 -0.08,0.98s0.04,0.66 0.08,0.98l-2.11,1.65c-0.19,0.15 -0.24,0.42 -0.12,0.64l2,3.46c0.12,0.22 0.37,0.31 0.60,0.22l2.49,-1c0.51,0.40 1.08,0.73 1.69,0.98l0.38,2.65c0.03,0.24 0.24,0.42 0.49,0.42h4c0.25,0 0.47,-0.18 0.50,-0.42l0.38,-2.65c0.61,-0.25 1.17,-0.58 1.69,-0.98l2.49,1c0.23,0.09 0.48,0 0.60,-0.22l2,-3.46c0.12,-0.22 0.07,-0.49 -0.12,-0.64zM12,8.5a3.5,3.5 0,1 0,0 7a3.5,3.5 0,1 0,0 -7z" />
</vector>
@@ -4,7 +4,7 @@
the only lit thing on it. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#F7000000" />
<solid android:color="#FF000000" />
<stroke
android:width="1dp"
android:color="#1FFFFFFF" />
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -13,6 +13,7 @@ class MembyRemoteConfigTest {
assertEquals(0, bundled.configVersion)
assertEquals("Favourites", bundled.navigation.labels.favourites)
assertEquals(184, bundled.navigation.expandedWidthDp)
assertEquals("system", bundled.presentation.fontFamily)
}
@Test
@@ -31,6 +32,17 @@ class MembyRemoteConfigTest {
assertEquals(true, document.continueWatching.enabled)
assertEquals(50, document.search.maxResults)
assertEquals("automatic", document.ui.artworkStyle)
assertEquals("system", document.presentation.fontFamily)
}
@Test
fun interFontFamilyDecodesFromTheServerDocument() {
val raw = validDocument.replace(
"\"navigationContentShiftDp\": 120",
"\"navigationContentShiftDp\": 120, \"fontFamily\": \"inter\"",
)
assertEquals("inter", decodeRemoteConfig(raw)!!.presentation.fontFamily)
}
@Test
@@ -0,0 +1,72 @@
package com.ponzischeme89.memby.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.unit.dp
import androidx.test.core.app.ApplicationProvider
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.UserItemData
import com.ponzischeme89.memby.ui.components.media.ContinueWatchingCard
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class ContinueWatchingFontScreenshotTest {
@get:Rule
val compose = createComposeRule()
@Before
fun locator() {
ServiceLocator.init(ApplicationProvider.getApplicationContext())
}
@Test
fun `Inter keeps continue watching metadata within the card`() {
compose.setContent {
PreviewSurface(alignment = Alignment.TopStart, fontFamilyName = "inter") {
Box(Modifier.fillMaxSize().padding(48.dp)) {
ContinueWatchingCard(
item = BaseItem(
id = "episode-1",
name = "A Harbour at the Edge of Morning",
type = "Episode",
seriesName = "The Longest Northbound Winter",
parentIndexNumber = 3,
indexNumber = 7,
runTimeTicks = 52L * 60L * 10_000_000L,
userData = UserItemData(
playbackPositionTicks = 19L * 60L * 10_000_000L,
),
),
availableWidth = 864.dp,
portraitArtwork = false,
onFocused = {},
onClick = {},
onLongClick = {},
)
}
}
}
compose.onNodeWithText("The Longest Northbound Winter").assertExists()
compose.onNodeWithText("S03E07 · A Harbour at the Edge of Morning").assertExists()
compose.onRoot().captureRoboImage(
"build/screenshots/typography/continue-watching-inter.png",
)
}
}
@@ -279,7 +279,7 @@ class DetailPageScreenshotTest {
private fun capturePane(name: String, pane: @Composable () -> Unit) {
compose.setContent {
PreviewSurface(alignment = Alignment.TopStart) {
PreviewSurface(alignment = Alignment.TopStart, fontFamilyName = "inter") {
Box(
Modifier
.fillMaxWidth()
@@ -293,14 +293,14 @@ class DetailPageScreenshotTest {
private fun capture(name: String, content: @Composable () -> Unit) {
compose.setContent {
PreviewSurface(alignment = Alignment.TopStart) { content() }
PreviewSurface(alignment = Alignment.TopStart, fontFamilyName = "inter") { content() }
}
compose.onRoot().captureRoboImage("build/screenshots/detail-page/$name.png")
}
private fun captureTab(name: String, tab: String, content: @Composable () -> Unit) {
compose.setContent {
PreviewSurface(alignment = Alignment.TopStart) { content() }
PreviewSurface(alignment = Alignment.TopStart, fontFamilyName = "inter") { content() }
}
compose.onNodeWithText(tab).performClick()
compose.onRoot().captureRoboImage("build/screenshots/detail-page/$name.png")
@@ -165,7 +165,7 @@ class HomeMovieHeroScreenshotTest {
)
compose.setContent {
PreviewSurface(alignment = Alignment.TopStart) {
PreviewSurface(alignment = Alignment.TopStart, fontFamilyName = "inter") {
Box(Modifier.fillMaxSize()) {
Image(
bitmap = artwork,
@@ -262,7 +262,7 @@ class HomeMovieHeroScreenshotTest {
)
compose.setContent {
PreviewSurface(alignment = Alignment.TopStart) {
PreviewSurface(alignment = Alignment.TopStart, fontFamilyName = "inter") {
Box(Modifier.fillMaxSize()) {
Image(
bitmap = artwork,
@@ -293,7 +293,7 @@ class HomeMovieHeroScreenshotTest {
val railFocus = FocusRequester()
compose.setContent {
PreviewSurface(alignment = Alignment.TopStart) {
PreviewSurface(alignment = Alignment.TopStart, fontFamilyName = "inter") {
Row(Modifier.fillMaxSize()) {
TvNavigationRail(
selected = BrowseDestination.HOME,
@@ -179,7 +179,7 @@ class SettingsSheetScreenshotTest {
fun record(title: String, enabled: Boolean) {
if (enabled) onToggled(title)
}
PreviewSurface {
PreviewSurface(fontFamilyName = "inter") {
SettingsPanelContent(
state = SettingsPanelState(
showLogo = showLogo,
@@ -237,7 +237,10 @@ class SettingsSheetScreenshotTest {
) {
val firstFocus = remember { FocusRequester() }
LaunchedEffect(Unit) { firstFocus.requestFocus() }
PreviewSurface(alignment = if (overlay) Alignment.CenterEnd else Alignment.Center) {
PreviewSurface(
alignment = if (overlay) Alignment.CenterEnd else Alignment.Center,
fontFamilyName = "inter",
) {
SettingsPanelContent(
state = SettingsPanelState(
showLogo = true,
@@ -0,0 +1,19 @@
package com.ponzischeme89.memby.ui.theme
import org.junit.Assert.assertEquals
import org.junit.Test
class AppFontFamilyTest {
@Test
fun `system and inter are supported`() {
assertEquals(AppFontFamily.SYSTEM, appFontFamily("system"))
assertEquals(AppFontFamily.INTER, appFontFamily("inter"))
}
@Test
fun `missing and unknown values fall back to system`() {
assertEquals(AppFontFamily.SYSTEM, appFontFamily(null))
assertEquals(AppFontFamily.SYSTEM, appFontFamily(""))
assertEquals(AppFontFamily.SYSTEM, appFontFamily("something-new"))
}
}
+1
View File
@@ -62,6 +62,7 @@ var configurationCatalogue = []configurationDefinition{
{Key: "continueWatching.enabled", Name: "Continue Watching", Description: "Show the Continue Watching row.", Type: "boolean", Scopes: []string{"global", "user", "device"}, Default: true},
{Key: "continueWatching.showNextUp", Name: "Continue Watching: Next Up", Description: "Include an unstarted next episode in Continue Watching.", Type: "boolean", Scopes: []string{"global", "user", "device"}, Default: true},
{Key: "continueWatching.progressColour", Name: "Progress bar colour", Description: "Choose the progress bar treatment.", Type: "enum", Scopes: []string{"global", "user", "device"}, Default: "emby", Options: []string{"emby", "white"}},
{Key: "presentation.fontFamily", Name: "App font family", Description: "Choose the bundled font used in Membys typography trial areas.", Type: "enum", Scopes: []string{"global"}, Default: "system", Options: []string{"system", "inter"}},
{Key: "ratings.enabled", Name: "Ratings", Description: "Show ratings throughout the catalogue.", Type: "boolean", Scopes: []string{"global", "user", "device"}, Default: true},
{Key: "genres.enabled", Name: "Genres", Description: "Show genre browsing controls.", Type: "boolean", Scopes: []string{"global", "user", "device"}, Default: true},
{Key: "trailers.enabled", Name: "Trailers", Description: "Offer trailers where available.", Type: "boolean", Scopes: []string{"global", "device", "experimental"}, Default: true},
+14
View File
@@ -1,6 +1,7 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
@@ -75,6 +76,19 @@ func TestContinueWatchingIsServerControlledAndOnByDefault(t *testing.T) {
}
}
func TestAppFontFamilyIsAValidatedGlobalConfiguration(t *testing.T) {
definition, ok := configurationDefinitionFor("presentation.fontFamily")
if !ok || definition.Type != "enum" || len(definition.Scopes) != 1 || definition.Scopes[0] != "global" {
t.Fatalf("font family definition = %+v, found=%v", definition, ok)
}
if err := validateConfigurationValue(definition, json.RawMessage(`"inter"`)); err != nil {
t.Fatalf("Inter was rejected: %v", err)
}
if err := validateConfigurationValue(definition, json.RawMessage(`"unknown"`)); err == nil {
t.Fatal("unknown font family was accepted")
}
}
func TestClientCapabilitiesAreNormalizedAndBounded(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/v1/status", nil)
req.Header.Set("X-Memby-Capabilities", " Sonarr_Preroll_V1,server_features_v1,sonarr_preroll_v1,"+
+4
View File
@@ -107,6 +107,10 @@ func applyGlobalConfiguration(document *config.RemoteConfig, policy store.Featur
if v, ok := value.(string); ok {
document.ContinueWatching.ProgressColour = v
}
case "presentation.fontFamily":
if v, ok := value.(string); ok && (v == "system" || v == "inter") {
document.Presentation.FontFamily = v
}
case "home.heroRefreshSeconds":
if v, ok := value.(float64); ok {
document.Home.HeroRefreshSeconds = int(v)
+14
View File
@@ -1,11 +1,13 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/ponzischeme89/memby/server/internal/config"
"github.com/ponzischeme89/memby/server/internal/store"
)
func TestRemoteConfigSupportsETagRevalidationWithoutAuthentication(t *testing.T) {
@@ -31,3 +33,15 @@ func TestRemoteConfigSupportsETagRevalidationWithoutAuthentication(t *testing.T)
t.Fatalf("revalidation status = %d", second.Code)
}
}
func TestGlobalFontFamilyConfigurationIsDelivered(t *testing.T) {
document := config.DefaultRemoteConfig()
policy := store.DefaultFeaturePolicy()
policy.Values["presentation.fontFamily"] = json.RawMessage(`"inter"`)
applyGlobalConfiguration(&document, policy)
if document.Presentation.FontFamily != "inter" {
t.Fatalf("font family = %q, want inter", document.Presentation.FontFamily)
}
}
+5
View File
@@ -58,6 +58,7 @@ type RemoteConfigFeatures struct {
type RemoteConfigPresentation struct {
NavigationRailExpandedWidthDp int `json:"navigationRailExpandedWidthDp"`
NavigationContentShiftDp int `json:"navigationContentShiftDp"`
FontFamily string `json:"fontFamily"`
}
// The remainder of the document is deliberately declarative. A new server-side row or
@@ -160,6 +161,7 @@ func DefaultRemoteConfig() RemoteConfig {
Presentation: RemoteConfigPresentation{
NavigationRailExpandedWidthDp: 184,
NavigationContentShiftDp: 112,
FontFamily: "system",
},
Home: RemoteHomeConfig{
Sections: []string{"continue", "for-you", "favorites", "latest-movies"},
@@ -262,6 +264,9 @@ func validateRemoteConfig(document RemoteConfig) error {
if shift < 80 || shift > 160 || shift >= width {
return fmt.Errorf("navigationContentShiftDp must be between 80 and 160 and less than the rail width")
}
if document.Presentation.FontFamily != "system" && document.Presentation.FontFamily != "inter" {
return fmt.Errorf("fontFamily must be system or inter")
}
if err := validateRemoteConfigSections(document); err != nil {
return err
}
@@ -19,6 +19,9 @@ func TestRemoteConfigDefaultsAreComplete(t *testing.T) {
if !document.ContinueWatching.IncludeNextUp || len(document.Home.Sections) == 0 {
t.Fatalf("central defaults are incomplete: %+v", document)
}
if document.Presentation.FontFamily != "system" {
t.Fatalf("font family = %q, want system", document.Presentation.FontFamily)
}
}
func TestRemoteConfigDefaultsMissingNewSectionsForOlderDocuments(t *testing.T) {
@@ -47,6 +50,16 @@ func TestRemoteConfigRejectsMalformedAndUnsafeDocuments(t *testing.T) {
t.Fatal("unknown fields were accepted")
}
document = DefaultRemoteConfig()
document.Presentation.FontFamily = "comic-sans"
raw, err = json.Marshal(document)
if err != nil {
t.Fatal(err)
}
if _, err := loadRemoteConfig(string(raw)); err == nil {
t.Fatal("unknown font family was accepted")
}
document = DefaultRemoteConfig()
document.MinimumAppVersion = "0.3.0"
document.MaximumAppVersion = "0.2.54"