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 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, The Android application uses AndroidX, Jetpack Compose, Media3, Coil,
Retrofit, OkHttp, kotlinx.serialization, and other open-source libraries. Retrofit, OkHttp, kotlinx.serialization, and other open-source libraries.
Their copyright notices and licence metadata remain in their respective Their copyright notices and licence metadata remain in their respective
+1 -1
View File
@@ -62,7 +62,7 @@ val projectNoticeText =
rootProject.file("NOTICE").readText() rootProject.file("NOTICE").readText()
.replace("https://g.sublogue.com/admin/memby", membySourceUrl) .replace("https://g.sublogue.com/admin/memby", membySourceUrl)
val defaultVersionName = "0.3.02" val defaultVersionName = "0.3.03"
val membyVersionName: String = val membyVersionName: String =
(project.findProperty("memby.versionName") as String?) (project.findProperty("memby.versionName") as String?)
?.trim() ?.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( data class RemotePresentation(
val navigationRailExpandedWidthDp: Int = 184, val navigationRailExpandedWidthDp: Int = 184,
val navigationContentShiftDp: Int = 112, val navigationContentShiftDp: Int = 112,
val fontFamily: String = "system",
) )
@Serializable @Serializable
@@ -7,6 +7,8 @@ import android.service.dreams.DreamService
import android.view.KeyEvent import android.view.KeyEvent
import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry import androidx.lifecycle.LifecycleRegistry
@@ -18,6 +20,7 @@ import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryController import androidx.savedstate.SavedStateRegistryController
import androidx.savedstate.SavedStateRegistryOwner import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.ui.player.PlayerActivity import com.ponzischeme89.memby.ui.player.PlayerActivity
import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.TrailerPlaybackRequest import com.ponzischeme89.memby.data.model.TrailerPlaybackRequest
@@ -60,7 +63,8 @@ class MembyDreamService : DreamService() {
setViewTreeSavedStateRegistryOwner(lifecycleOwner) setViewTreeSavedStateRegistryOwner(lifecycleOwner)
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent { setContent {
MembyTheme { val remoteConfig by ServiceLocator.remoteConfig.activeFlow.collectAsState()
MembyTheme(fontFamilyName = remoteConfig.presentation.fontFamily) {
ScreensaverContent( ScreensaverContent(
onPlay = ::launchTrailer, onPlay = ::launchTrailer,
onExit = { finish() }, 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.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembySurface import com.ponzischeme89.memby.ui.theme.MembySurface
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
import com.ponzischeme89.memby.ui.theme.MembyTrialTypography
import com.ponzischeme89.memby.ui.theme.ValueSeparator import com.ponzischeme89.memby.ui.theme.ValueSeparator
// The detail page's names for the shared tokens. The neutrals used to be a shade darker // 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, alignment = Alignment.TopCenter,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.graphicsLayer { alpha = artworkAlpha().coerceIn(0f, 1f) }, .cinematicBackdropPullBack(artwork, artworkAlpha),
) )
} }
Box( Box(
@@ -637,6 +638,7 @@ private fun DetailHero(
// height instead would re-flow the title, the synopsis and the action row on every frame // 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 // of the transition — which is the "suddenly resize a focused item" failure, sixty times
// a second. // a second.
MembyTrialTypography {
Box( Box(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -838,6 +840,7 @@ private fun DetailHero(
} }
} }
} }
}
/** /**
* The primary action and the circular secondary ones, plus the line that names them. * The primary action and the circular secondary ones, plus the line that names them.
@@ -1369,6 +1372,7 @@ internal fun DetailFocusablePane(
@Composable @Composable
internal fun DetailMetaRows(rows: List<TechnicalSpec>, modifier: Modifier = Modifier, labelWidth: androidx.compose.ui.unit.Dp = 110.dp) { 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)) { Column(modifier, verticalArrangement = Arrangement.spacedBy(9.dp)) {
rows.forEach { row -> rows.forEach { row ->
Row(Modifier.fillMaxWidth()) { 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. * 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, modifier: Modifier = Modifier,
supportingText: String? = null, supportingText: String? = null,
) { ) {
MembyTrialTypography {
DetailFocusablePane(focusRequester, modifier) { DetailFocusablePane(focusRequester, modifier) {
Row(horizontalArrangement = Arrangement.spacedBy(48.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(48.dp)) {
Column(Modifier.weight(1.5f)) { 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. * 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() val alreadySaid = rows.map { it.value }.toSet()
specs.filterNot { it.value in alreadySaid } specs.filterNot { it.value in alreadySaid }
} }
MembyTrialTypography {
DetailFocusablePane(focusRequester, modifier) { DetailFocusablePane(focusRequester, modifier) {
if (rows.isEmpty() && specs.isEmpty()) { if (rows.isEmpty() && specs.isEmpty()) {
Text( Text(
@@ -1478,6 +1486,7 @@ internal fun DetailDetailsPane(
} }
} }
} }
}
/** /**
* The Cast tab. A grid rather than the rail this replaced — see `ui/detail/CastGrid.kt`. * 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) super.onCreate(savedInstanceState)
setContent { setContent {
val remoteConfig by ServiceLocator.remoteConfig.activeFlow.collectAsStateWithLifecycle() val remoteConfig by ServiceLocator.remoteConfig.activeFlow.collectAsStateWithLifecycle()
MembyTheme { MembyTheme(fontFamilyName = remoteConfig.presentation.fontFamily) {
AppRoot( AppRoot(
remoteConfig = remoteConfig, remoteConfig = remoteConfig,
onCloseSettings = ::finish, onCloseSettings = ::finish,
@@ -1,8 +1,5 @@
package com.ponzischeme89.memby.ui 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.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight 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.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -26,6 +21,7 @@ import coil.request.ImageRequest
import com.ponzischeme89.memby.ServiceLocator import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.ui.theme.MembySurface 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. * The quiet surface between the focused title details and the scrolling shelves.
@@ -44,6 +40,7 @@ internal fun MetadataHero(
isContinueWatchingItem: Boolean = false, isContinueWatchingItem: Boolean = false,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
MembyTrialTypography {
Box( Box(
modifier = modifier modifier = modifier
.clipToBounds() .clipToBounds()
@@ -91,6 +88,7 @@ internal fun MetadataHero(
) )
} }
} }
}
@Composable @Composable
private fun MetadataHeroArtwork( private fun MetadataHeroArtwork(
@@ -119,16 +117,6 @@ private fun MetadataHeroArtwork(
} }
} }
if (request == null) return 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) {
Box( Box(
modifier = Modifier modifier = Modifier
@@ -143,10 +131,7 @@ private fun MetadataHeroArtwork(
alignment = Alignment.CenterEnd, alignment = Alignment.CenterEnd,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.graphicsLayer { .cinematicBackdropPullBack(artwork),
scaleX = artworkScale.value
scaleY = artworkScale.value
},
) )
Box( Box(
Modifier.fillMaxSize().background( Modifier.fillMaxSize().background(
@@ -173,6 +158,4 @@ private fun MetadataHeroArtwork(
} }
private const val MetadataHeroArtworkCrossfadeMs = 180 private const val MetadataHeroArtworkCrossfadeMs = 180
private const val MetadataHeroArtworkInitialScale = 1.05f
private const val MetadataHeroArtworkZoomOutMs = 10_000
private const val MetadataHeroArtworkWidthFraction = 0.68f private const val MetadataHeroArtworkWidthFraction = 0.68f
@@ -32,9 +32,10 @@ annotation class TvPreview
@Composable @Composable
fun PreviewSurface( fun PreviewSurface(
alignment: Alignment = Alignment.Center, alignment: Alignment = Alignment.Center,
fontFamilyName: String = "system",
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
MembyTheme { MembyTheme(fontFamilyName = fontFamilyName) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .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.MembyAccentBright
import com.ponzischeme89.memby.ui.theme.MembyAccentInk import com.ponzischeme89.memby.ui.theme.MembyAccentInk
import com.ponzischeme89.memby.ui.theme.MembyAccentMuted 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.MembyHairline
import com.ponzischeme89.memby.ui.theme.MembyMutedText import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyOnSurface import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembyOutline import com.ponzischeme89.memby.ui.theme.MembyOutline
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
import com.ponzischeme89.memby.ui.theme.MembyQuietText 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 * One answer in a television settings panel. The panel owns its invariant chrome while
@@ -204,7 +202,7 @@ fun tvSettingsOptionView(
fun render(focused: Boolean) { fun render(focused: Boolean) {
val fill = when { val fill = when {
focused -> MembyControlSurface.toArgb() focused -> PlayerMenuFocusedSurface
prominent -> MembyAccentMuted.toArgb() prominent -> MembyAccentMuted.toArgb()
selected -> MembyAccentMuted.toArgb() selected -> MembyAccentMuted.toArgb()
else -> android.graphics.Color.TRANSPARENT else -> android.graphics.Color.TRANSPARENT
@@ -243,7 +241,7 @@ fun applyTvSettingsPanelSurface(view: View) {
val context = view.context val context = view.context
view.background = roundedBackground( view.background = roundedBackground(
context, context,
MembySurfaceRaised.toArgb(), PlayerMenuBackground,
MembyOutline.toArgb(), MembyOutline.toArgb(),
1, 1,
MembyPanelCorner.value.toInt(), MembyPanelCorner.value.toInt(),
@@ -259,10 +257,10 @@ private data class TvSettingsPanel(
private fun tvSettingsPanel(context: Context, title: String, description: String): TvSettingsPanel { private fun tvSettingsPanel(context: Context, title: String, description: String): TvSettingsPanel {
val root = LinearLayout(context).apply { val root = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL 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( background = roundedBackground(
context, context,
MembySurfaceRaised.toArgb(), PlayerMenuBackground,
MembyOutline.toArgb(), MembyOutline.toArgb(),
1, 1,
MembyPanelCorner.value.toInt(), MembyPanelCorner.value.toInt(),
@@ -272,7 +270,7 @@ private fun tvSettingsPanel(context: Context, title: String, description: String
root.addView(TextView(context).apply { root.addView(TextView(context).apply {
text = title text = title
setTextColor(MembyOnSurface.toArgb()) setTextColor(MembyOnSurface.toArgb())
textSize = 24f textSize = 22f
typeface = Typeface.create("sans-serif", Typeface.BOLD) typeface = Typeface.create("sans-serif", Typeface.BOLD)
}) })
if (description.isNotBlank()) { if (description.isNotBlank()) {
@@ -304,7 +302,7 @@ private fun tvSettingsDialog(context: Context, onDismiss: () -> Unit): Dialog =
window?.apply { window?.apply {
setBackgroundDrawable(ColorDrawable(android.graphics.Color.TRANSPARENT)) setBackgroundDrawable(ColorDrawable(android.graphics.Color.TRANSPARENT))
addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
attributes = attributes.apply { dimAmount = 0.72f } attributes = attributes.apply { dimAmount = 0.80f }
setGravity(Gravity.CENTER) setGravity(Gravity.CENTER)
} }
setOnDismissListener { onDismiss() } setOnDismissListener { onDismiss() }
@@ -320,7 +318,7 @@ private fun finishTvSettingsDialog(
val context = root.context val context = root.context
val frame = FrameLayout(context).apply { val frame = FrameLayout(context).apply {
setPadding(context.dp(8), context.dp(8), context.dp(8), context.dp(8)) 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) dialog.setContentView(frame)
val scroll = options.parent as ScrollView val scroll = options.parent as ScrollView
@@ -354,7 +352,7 @@ private fun tvSettingsAction(context: Context, label: String, primary: Boolean,
context, context,
when { when {
focused && primary -> MembyAccent.toArgb() focused && primary -> MembyAccent.toArgb()
focused -> MembyControlSurface.toArgb() focused -> PlayerMenuFocusedSurface
primary -> MembyAccentMuted.toArgb() primary -> MembyAccentMuted.toArgb()
else -> android.graphics.Color.TRANSPARENT 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 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.ResumableMediaCard
import com.ponzischeme89.memby.ui.responsiveRowCardWidth import com.ponzischeme89.memby.ui.responsiveRowCardWidth
import com.ponzischeme89.memby.ui.toResumableMediaCardModel import com.ponzischeme89.memby.ui.toResumableMediaCardModel
import com.ponzischeme89.memby.ui.theme.MembyTrialTypography
@Composable @Composable
fun ContinueWatchingCard( fun ContinueWatchingCard(
@@ -46,6 +47,7 @@ fun ContinueWatchingCard(
primaryUrl = repository.primaryUrl(item, 480), primaryUrl = repository.primaryUrl(item, 480),
) )
} }
MembyTrialTypography {
ResumableMediaCard( ResumableMediaCard(
model = model, model = model,
width = width, width = width,
@@ -53,6 +55,7 @@ fun ContinueWatchingCard(
onFocused = onFocused, onFocused = onFocused,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
modifier = modifier modifier = modifier,
) )
} }
}
@@ -476,7 +476,7 @@ private fun MetadataTimeRemaining(
label, label,
color = if (colour.equals("white", ignoreCase = true)) Color.White else EmbyGreen, color = if (colour.equals("white", ignoreCase = true)) Color.White else EmbyGreen,
fontSize = 12.sp, fontSize = 12.sp,
lineHeight = 16.sp, lineHeight = 18.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
maxLines = 1, 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.MembyPanelCorner
import com.ponzischeme89.memby.ui.theme.MembyQuietText import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembySurface 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.BundledRemoteConfig
import com.ponzischeme89.memby.data.remoteconfig.NavigationLabels import com.ponzischeme89.memby.data.remoteconfig.NavigationLabels
import com.ponzischeme89.memby.data.remoteconfig.NavigationRemoteConfig import com.ponzischeme89.memby.data.remoteconfig.NavigationRemoteConfig
@@ -197,6 +198,7 @@ fun TvNavigationRail(
animationSpec = tween(160), animationSpec = tween(160),
label = "navigation-rail-width", label = "navigation-rail-width",
) )
MembyTrialTypography {
Box( Box(
modifier = modifier modifier = modifier
.width(TvRailCollapsedWidth) .width(TvRailCollapsedWidth)
@@ -363,6 +365,7 @@ fun TvNavigationRail(
} }
} }
} }
}
private fun configuredNavigationLabel( private fun configuredNavigationLabel(
destination: BrowseDestination, destination: BrowseDestination,
@@ -4553,7 +4553,7 @@ class PlayerActivity : ComponentActivity() {
title = "Playback options", title = "Playback options",
description = "Fine-tune this video without leaving playback.", description = "Fine-tune this video without leaving playback.",
options = options, options = options,
onDismiss = { playerView?.showController() }, onDismiss = { restorePlayerControlFocus(androidx.media3.ui.R.id.exo_settings) },
) )
} }
@@ -4579,7 +4579,7 @@ class PlayerActivity : ComponentActivity() {
showManualPassthroughPicker(current.codecs) 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(), 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() { private fun hideSubtitleOverlay() {
subtitleOverlay?.visibility = View.GONE 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. */ /** A completed choice returns directly to the programme, without reopening transport. */
@@ -5354,12 +5354,31 @@ class PlayerActivity : ComponentActivity() {
) )
subtitleAutoSelectionAttempted = true 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) @OptIn(UnstableApi::class)
@@ -5,6 +5,9 @@ import android.os.Build
import android.view.WindowManager import android.view.WindowManager
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent 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.ui.player.PlayerActivity
import com.ponzischeme89.memby.data.model.TrailerPlaybackRequest import com.ponzischeme89.memby.data.model.TrailerPlaybackRequest
import com.ponzischeme89.memby.ui.theme.MembyTheme import com.ponzischeme89.memby.ui.theme.MembyTheme
@@ -24,7 +27,8 @@ class ScreensaverActivity : ComponentActivity() {
window.addFlags(WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON) window.addFlags(WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON)
} }
setContent { setContent {
MembyTheme { val remoteConfig by ServiceLocator.remoteConfig.activeFlow.collectAsStateWithLifecycle()
MembyTheme(fontFamilyName = remoteConfig.presentation.fontFamily) {
ScreensaverContent( ScreensaverContent(
onPlay = { item -> onPlay = { item ->
startActivity( startActivity(
@@ -73,13 +73,13 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.tv.material3.Button import androidx.tv.material3.Button
import androidx.tv.material3.Icon import androidx.tv.material3.Icon
import androidx.tv.material3.LocalTextStyle
import androidx.tv.material3.Text import androidx.tv.material3.Text
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ponzischeme89.memby.R 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.PreviewSurface
import com.ponzischeme89.memby.ui.TvPreview import com.ponzischeme89.memby.ui.TvPreview
import com.ponzischeme89.memby.ui.WelcomeQuoteStyle 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.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyAccentBright import com.ponzischeme89.memby.ui.theme.MembyAccentBright
import com.ponzischeme89.memby.ui.theme.MembyAccentInk import com.ponzischeme89.memby.ui.theme.MembyAccentInk
@@ -694,6 +695,7 @@ fun SettingsSheet(
}, },
) )
MembyTrialTypography {
Box(modifier = modifier.fillMaxSize()) { Box(modifier = modifier.fillMaxSize()) {
if (overlay) { if (overlay) {
Box( Box(
@@ -755,6 +757,7 @@ fun SettingsSheet(
} }
} }
} }
}
@Composable @Composable
internal fun SettingsPanelContent( internal fun SettingsPanelContent(
@@ -786,6 +789,7 @@ internal fun SettingsPanelContent(
contentScrollState.scrollTo(0) contentScrollState.scrollTo(0)
} }
MembyTrialTypography {
Row( Row(
modifier = modifier modifier = modifier
.then(if (overlay) Modifier.width(760.dp) else Modifier.fillMaxWidth()) .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 * 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 }, onValueChange = { if (it.length <= 80) name = it },
singleLine = true, singleLine = true,
enabled = !busy, enabled = !busy,
textStyle = TextStyle(color = TextPrimary, fontSize = 18.sp), textStyle = LocalTextStyle.current.copy(color = TextPrimary, fontSize = 18.sp),
cursorBrush = SolidColor(EmbyGreen), cursorBrush = SolidColor(EmbyGreen),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { if (valid && !busy) onSave(name) }), 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.Composable
import androidx.compose.runtime.CompositionLocalProvider 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.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.em import androidx.compose.ui.unit.em
import androidx.tv.material3.LocalTextStyle import androidx.tv.material3.LocalTextStyle
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.darkColorScheme 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 // 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 // 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 @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 // 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, // to sans-serif-medium. The generic family lets Compose select real regular,
// medium, semibold and bold faces from each Text's FontWeight, restoring hierarchy // 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, letterSpacing = 0.006.em,
lineHeight = 1.22.em, lineHeight = 1.22.em,
) )
val trialFontFamily = when (appFontFamily(fontFamilyName)) {
AppFontFamily.SYSTEM -> FontFamily.SansSerif
AppFontFamily.INTER -> InterFontFamily
}
MaterialTheme(colorScheme = embyColors()) { MaterialTheme(colorScheme = embyColors()) {
CompositionLocalProvider(LocalTextStyle provides appTextStyle) { CompositionLocalProvider(
LocalTextStyle provides appTextStyle,
LocalTrialFontFamily provides trialFontFamily,
) {
content() 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"?> <?xml version="1.0" encoding="utf-8"?>
<!-- A simple outlined cog that matches Memby's other player glyphs. Kept app-owned so a <!-- A centred, even-odd cog that stays legible under the player button's tint. -->
Media3 update cannot silently replace the settings entry with platform-era artwork. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:width="24dp"
android:height="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportWidth="24"
android:viewportHeight="24"> android:viewportHeight="24">
<path <path
android:fillColor="@android:color/transparent" android:fillColor="#FFFFFFFF"
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:fillType="evenOdd"
android:strokeColor="#FFFFFFFF" 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" />
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:strokeWidth="1.7" />
</vector> </vector>
@@ -4,7 +4,7 @@
the only lit thing on it. --> the only lit thing on it. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android" <shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle"> android:shape="rectangle">
<solid android:color="#F7000000" /> <solid android:color="#FF000000" />
<stroke <stroke
android:width="1dp" android:width="1dp"
android:color="#1FFFFFFF" /> 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(0, bundled.configVersion)
assertEquals("Favourites", bundled.navigation.labels.favourites) assertEquals("Favourites", bundled.navigation.labels.favourites)
assertEquals(184, bundled.navigation.expandedWidthDp) assertEquals(184, bundled.navigation.expandedWidthDp)
assertEquals("system", bundled.presentation.fontFamily)
} }
@Test @Test
@@ -31,6 +32,17 @@ class MembyRemoteConfigTest {
assertEquals(true, document.continueWatching.enabled) assertEquals(true, document.continueWatching.enabled)
assertEquals(50, document.search.maxResults) assertEquals(50, document.search.maxResults)
assertEquals("automatic", document.ui.artworkStyle) 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 @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) { private fun capturePane(name: String, pane: @Composable () -> Unit) {
compose.setContent { compose.setContent {
PreviewSurface(alignment = Alignment.TopStart) { PreviewSurface(alignment = Alignment.TopStart, fontFamilyName = "inter") {
Box( Box(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -293,14 +293,14 @@ class DetailPageScreenshotTest {
private fun capture(name: String, content: @Composable () -> Unit) { private fun capture(name: String, content: @Composable () -> Unit) {
compose.setContent { compose.setContent {
PreviewSurface(alignment = Alignment.TopStart) { content() } PreviewSurface(alignment = Alignment.TopStart, fontFamilyName = "inter") { content() }
} }
compose.onRoot().captureRoboImage("build/screenshots/detail-page/$name.png") compose.onRoot().captureRoboImage("build/screenshots/detail-page/$name.png")
} }
private fun captureTab(name: String, tab: String, content: @Composable () -> Unit) { private fun captureTab(name: String, tab: String, content: @Composable () -> Unit) {
compose.setContent { compose.setContent {
PreviewSurface(alignment = Alignment.TopStart) { content() } PreviewSurface(alignment = Alignment.TopStart, fontFamilyName = "inter") { content() }
} }
compose.onNodeWithText(tab).performClick() compose.onNodeWithText(tab).performClick()
compose.onRoot().captureRoboImage("build/screenshots/detail-page/$name.png") compose.onRoot().captureRoboImage("build/screenshots/detail-page/$name.png")
@@ -165,7 +165,7 @@ class HomeMovieHeroScreenshotTest {
) )
compose.setContent { compose.setContent {
PreviewSurface(alignment = Alignment.TopStart) { PreviewSurface(alignment = Alignment.TopStart, fontFamilyName = "inter") {
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
Image( Image(
bitmap = artwork, bitmap = artwork,
@@ -262,7 +262,7 @@ class HomeMovieHeroScreenshotTest {
) )
compose.setContent { compose.setContent {
PreviewSurface(alignment = Alignment.TopStart) { PreviewSurface(alignment = Alignment.TopStart, fontFamilyName = "inter") {
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
Image( Image(
bitmap = artwork, bitmap = artwork,
@@ -293,7 +293,7 @@ class HomeMovieHeroScreenshotTest {
val railFocus = FocusRequester() val railFocus = FocusRequester()
compose.setContent { compose.setContent {
PreviewSurface(alignment = Alignment.TopStart) { PreviewSurface(alignment = Alignment.TopStart, fontFamilyName = "inter") {
Row(Modifier.fillMaxSize()) { Row(Modifier.fillMaxSize()) {
TvNavigationRail( TvNavigationRail(
selected = BrowseDestination.HOME, selected = BrowseDestination.HOME,
@@ -179,7 +179,7 @@ class SettingsSheetScreenshotTest {
fun record(title: String, enabled: Boolean) { fun record(title: String, enabled: Boolean) {
if (enabled) onToggled(title) if (enabled) onToggled(title)
} }
PreviewSurface { PreviewSurface(fontFamilyName = "inter") {
SettingsPanelContent( SettingsPanelContent(
state = SettingsPanelState( state = SettingsPanelState(
showLogo = showLogo, showLogo = showLogo,
@@ -237,7 +237,10 @@ class SettingsSheetScreenshotTest {
) { ) {
val firstFocus = remember { FocusRequester() } val firstFocus = remember { FocusRequester() }
LaunchedEffect(Unit) { firstFocus.requestFocus() } 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( SettingsPanelContent(
state = SettingsPanelState( state = SettingsPanelState(
showLogo = true, 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.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.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: "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: "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: "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}, {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 package api
import ( import (
"encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "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) { func TestClientCapabilitiesAreNormalizedAndBounded(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/v1/status", nil) req := httptest.NewRequest(http.MethodGet, "/v1/status", nil)
req.Header.Set("X-Memby-Capabilities", " Sonarr_Preroll_V1,server_features_v1,sonarr_preroll_v1,"+ 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 { if v, ok := value.(string); ok {
document.ContinueWatching.ProgressColour = v document.ContinueWatching.ProgressColour = v
} }
case "presentation.fontFamily":
if v, ok := value.(string); ok && (v == "system" || v == "inter") {
document.Presentation.FontFamily = v
}
case "home.heroRefreshSeconds": case "home.heroRefreshSeconds":
if v, ok := value.(float64); ok { if v, ok := value.(float64); ok {
document.Home.HeroRefreshSeconds = int(v) document.Home.HeroRefreshSeconds = int(v)
+14
View File
@@ -1,11 +1,13 @@
package api package api
import ( import (
"encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"github.com/ponzischeme89/memby/server/internal/config" "github.com/ponzischeme89/memby/server/internal/config"
"github.com/ponzischeme89/memby/server/internal/store"
) )
func TestRemoteConfigSupportsETagRevalidationWithoutAuthentication(t *testing.T) { func TestRemoteConfigSupportsETagRevalidationWithoutAuthentication(t *testing.T) {
@@ -31,3 +33,15 @@ func TestRemoteConfigSupportsETagRevalidationWithoutAuthentication(t *testing.T)
t.Fatalf("revalidation status = %d", second.Code) 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 { type RemoteConfigPresentation struct {
NavigationRailExpandedWidthDp int `json:"navigationRailExpandedWidthDp"` NavigationRailExpandedWidthDp int `json:"navigationRailExpandedWidthDp"`
NavigationContentShiftDp int `json:"navigationContentShiftDp"` NavigationContentShiftDp int `json:"navigationContentShiftDp"`
FontFamily string `json:"fontFamily"`
} }
// The remainder of the document is deliberately declarative. A new server-side row or // The remainder of the document is deliberately declarative. A new server-side row or
@@ -160,6 +161,7 @@ func DefaultRemoteConfig() RemoteConfig {
Presentation: RemoteConfigPresentation{ Presentation: RemoteConfigPresentation{
NavigationRailExpandedWidthDp: 184, NavigationRailExpandedWidthDp: 184,
NavigationContentShiftDp: 112, NavigationContentShiftDp: 112,
FontFamily: "system",
}, },
Home: RemoteHomeConfig{ Home: RemoteHomeConfig{
Sections: []string{"continue", "for-you", "favorites", "latest-movies"}, Sections: []string{"continue", "for-you", "favorites", "latest-movies"},
@@ -262,6 +264,9 @@ func validateRemoteConfig(document RemoteConfig) error {
if shift < 80 || shift > 160 || shift >= width { if shift < 80 || shift > 160 || shift >= width {
return fmt.Errorf("navigationContentShiftDp must be between 80 and 160 and less than the rail 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 { if err := validateRemoteConfigSections(document); err != nil {
return err return err
} }
@@ -19,6 +19,9 @@ func TestRemoteConfigDefaultsAreComplete(t *testing.T) {
if !document.ContinueWatching.IncludeNextUp || len(document.Home.Sections) == 0 { if !document.ContinueWatching.IncludeNextUp || len(document.Home.Sections) == 0 {
t.Fatalf("central defaults are incomplete: %+v", document) 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) { func TestRemoteConfigDefaultsMissingNewSectionsForOlderDocuments(t *testing.T) {
@@ -47,6 +50,16 @@ func TestRemoteConfigRejectsMalformedAndUnsafeDocuments(t *testing.T) {
t.Fatal("unknown fields were accepted") 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 = DefaultRemoteConfig()
document.MinimumAppVersion = "0.3.0" document.MinimumAppVersion = "0.3.0"
document.MaximumAppVersion = "0.2.54" document.MaximumAppVersion = "0.2.54"