0.2.41 - Preroll fixes, theme fixes

This commit is contained in:
ponzischeme89
2026-08-10 09:39:09 +12:00
parent e1bb687df4
commit 32c0054c44
17 changed files with 648 additions and 167 deletions
+1 -1
View File
@@ -42,7 +42,7 @@ val projectNoticeText =
// A release workflow can derive the app version from its Git tag without editing the
// source tree. Local builds keep using the checked-in default.
val defaultVersionName = "0.2.40"
val defaultVersionName = "0.2.41"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
@@ -126,9 +126,13 @@ import com.ponzischeme89.memby.ui.detail.dynamicRangeLabel
import com.ponzischeme89.memby.ui.detail.formatRuntime
import com.ponzischeme89.memby.ui.theme.FactSeparator
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyAccentInk
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
import com.ponzischeme89.memby.ui.theme.MembyChipCorner
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
import com.ponzischeme89.memby.ui.theme.MembyControlSurfaceRaised
import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembySurface
@@ -565,7 +569,7 @@ private fun UserSwitcherProfileItem(
Box(
Modifier
.size(26.dp)
.background(if (current) EmbyGreen else Color(0xFF2B3035), CircleShape),
.background(if (current) EmbyGreen else MembyControlSurfaceRaised, CircleShape),
contentAlignment = Alignment.Center,
) {
Text(
@@ -890,7 +894,7 @@ private fun SkeletonBlock(width: Dp, height: Dp) {
.width(width)
.height(height)
.clip(RoundedCornerShape(4.dp))
.background(Color(0xFF30363B)),
.background(MembyControlSurfaceRaised),
)
}
@@ -1217,7 +1221,7 @@ private fun MetadataContent(
}
Text(
item.overview?.takeIf(String::isNotBlank) ?: "No description available.",
color = Color(0xFFD0D4D7),
color = MembyMutedText,
fontSize = 14.sp,
lineHeight = 18.sp,
maxLines = if (compact) 2 else 3,
@@ -1292,7 +1296,7 @@ private fun ScheduleMetadataContent(
} else {
"Episode details will appear when they become available."
},
color = Color(0xFFD0D4D7),
color = MembyMutedText,
fontSize = 14.sp,
lineHeight = 18.sp,
maxLines = if (compact) 2 else 3,
@@ -1370,7 +1374,7 @@ internal fun HomeRowHeaderIcon(icon: ImageVector, modifier: Modifier = Modifier)
fun MediaBadge(label: String, modifier: Modifier = Modifier) {
Text(
label,
color = Color(0xFFE4E7E9),
color = MembyOnSurface,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
modifier = modifier
@@ -1461,7 +1465,7 @@ internal fun MediaRow(
Spacer(Modifier.width(HomeRowHeaderIconGap))
Text(
row.title,
color = Color(0xFFF1F3F4),
color = MembyOnSurface,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
)
@@ -1657,7 +1661,7 @@ private fun CastCard(person: EmbyPerson, compact: Boolean) {
.fillMaxWidth()
.height(height)
.clip(shape)
.background(Color(0xFF24292E))
.background(MembyControlSurface)
.border(
if (focused) 2.dp else 1.dp,
if (focused) Color.White else Color.White.copy(alpha = 0.10f),
@@ -2105,7 +2109,7 @@ private fun MediaCard(
}
Text(
item.name,
color = if (focused) Color.White else Color(0xFFE1E5E8),
color = if (focused) Color.White else MembyOnSurface,
fontSize = 14.sp,
fontWeight = if (focused) FontWeight.Bold else FontWeight.SemiBold,
maxLines = 1,
@@ -2163,11 +2167,11 @@ private fun ScheduleStatusBadge(status: String, label: String, modifier: Modifie
"downloading" -> Color(0xFF5DA9FF)
"awaiting" -> Color(0xFFFFB454)
"unmonitored" -> QuietText
else -> Color(0xFFE1E5E8)
else -> MembyOnSurface
}
Text(
text = label,
color = if (status == "available") Color(0xFF071008) else MembySurface,
color = if (status == "available") MembyAccentInk else MembySurface,
fontSize = 9.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 0.5.sp,
@@ -2206,11 +2210,11 @@ internal fun LifecycleBadge(status: String, label: String, modifier: Modifier =
// One colour per status, matching what the same word is coloured in *arr: still being
// made is green, over is red, not out yet is blue, in cinemas is amber.
val (background, foreground) = when (status) {
"continuing", "released" -> EmbyGreen to Color(0xFF071008)
"continuing", "released" -> EmbyGreen to MembyAccentInk
"upcoming", "announced" -> Color(0xFF5DA9FF) to MembySurface
"incinemas" -> Color(0xFFFFB454) to MembySurface
"ended" -> Color(0xFFE04747) to Color.White
else -> Color(0xFF3A4249) to Color(0xFFE1E5E8)
else -> MembyControlSurfaceRaised to MembyOnSurface
}
Text(
text = label,
@@ -2352,9 +2356,9 @@ private fun ArtworkLoadingSkeleton(modifier: Modifier = Modifier) {
modifier.background(
Brush.linearGradient(
colors = listOf(
Color(0xFF20262B),
Color(0xFF343B41),
Color(0xFF20262B),
MembyControlSurface,
MembyControlSurfaceRaised,
MembyControlSurface,
),
),
),
@@ -55,13 +55,14 @@ import com.ponzischeme89.memby.data.model.HomeRow
import com.ponzischeme89.memby.ui.detail.heroFacts
import com.ponzischeme89.memby.ui.theme.FactSeparator
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyControlSurfaceRaised
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.MembySurfaceRaised
import kotlinx.coroutines.delay
import java.util.TimeZone
import kotlinx.coroutines.delay
internal const val HOME_HERO_ROW_ID = "home-movie-hero"
@@ -590,7 +591,7 @@ private fun HeroArtwork(item: BaseItem, previewArtwork: ImageBitmap?, modifier:
Box(
modifier.background(
Brush.linearGradient(
listOf(Color(0xFF172830), Color(0xFF26343A), Color(0xFF12171B)),
listOf(MembySurfaceRaised, MembyControlSurfaceRaised, MembySurfaceRaised),
),
),
)
@@ -0,0 +1,121 @@
package com.ponzischeme89.memby.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import com.ponzischeme89.memby.ui.player.PrerollPreloader
/**
* Memby's own short clip, played behind the cold-start screen.
*
* The clip was already prepared during launch and already borrowed by the player for a
* fresh playback's pre-roll; the one place it was never seen was the screen every launch
* shows. This is that screen — the same 200 KB local resource, the same process-cached
* [PrerollPreloader] instance, no second decoder and no second copy of the file.
*
* Things worth preserving:
*
* - **It is decoration and never a gate.** Every failure path is silent and leaves the
* pulsing-logo screen exactly as it was: an unavailable player, a decoder error, a set
* that cannot render the clip at all. Nothing about opening the launcher waits on it, and
* [onVisible] is called only once a frame has actually been drawn — so the fade never
* uncovers a black rectangle.
* - **It is muted.** A branding sting is written to run its length; the cold-start screen is
* commonly gone in a few hundred milliseconds, and a sound cut off a third of the way
* through on every single app open is worse than no sound. The clip keeps its audio where
* it plays to the end, which is the pre-roll before a programme.
* - **It loops.** A cold start on a slow connection outlasts six seconds, and a clip that
* ended would leave its last frame frozen under the "opening" text — which reads as the
* television having hung at precisely the moment the viewer is watching for that.
* [PrerollPreloader.acquire] puts the repeat mode back, so the pre-roll before a
* programme still ends.
* - **The player is returned, not released.** It goes back to the process cache on dispose,
* which is what lets the very next playback still open on a prepared instance.
*/
@UnstableApi
@Composable
internal fun LaunchPrerollVideo(
modifier: Modifier = Modifier,
onVisible: () -> Unit,
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val currentOnVisible by rememberUpdatedState(onVisible)
// Deliberately *not* borrowed during composition. On a cold start there is nothing
// cached yet, so acquiring here would construct an ExoPlayer and open the local
// resource on the main thread inside the first composition of the one screen whose
// whole job is to appear immediately. Waiting a frame costs the clip a few tens of
// milliseconds nobody can see behind the fade, and costs the launcher nothing.
var player by remember { mutableStateOf<ExoPlayer?>(null) }
LaunchedEffect(Unit) {
withFrameNanos { }
player = runCatching { PrerollPreloader.acquire(context) }.getOrNull()
}
// A null is an ordinary outcome — the caller simply keeps the screen it already has.
val active = player ?: return
DisposableEffect(active, lifecycleOwner) {
val listener = object : Player.Listener {
override fun onRenderedFirstFrame() = currentOnVisible()
override fun onPlayerError(error: PlaybackException) {
// Nothing to say and nowhere to say it. The screen underneath is complete.
active.playWhenReady = false
}
}
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_STOP -> active.playWhenReady = false
Lifecycle.Event.ON_START -> active.playWhenReady = true
else -> Unit
}
}
active.addListener(listener)
lifecycleOwner.lifecycle.addObserver(observer)
active.volume = 0f
active.repeatMode = Player.REPEAT_MODE_ONE
active.seekTo(0L)
if (active.playbackState == Player.STATE_IDLE) active.prepare()
active.playWhenReady = lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
active.removeListener(listener)
PrerollPreloader.recycle(active)
}
}
AndroidView(
modifier = modifier,
factory = { viewContext ->
PlayerView(viewContext).apply {
useController = false
// Fill rather than fit: this sits behind text as a wash, and letterbox bars
// on a screen whose whole job is to look like Memby would read as a fault.
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM
setShutterBackgroundColor(android.graphics.Color.TRANSPARENT)
this.player = active
}
},
onRelease = { it.player = null },
)
}
@@ -145,8 +145,20 @@ import com.ponzischeme89.memby.ui.whatsnew.WhatsNewDecision
import com.ponzischeme89.memby.ui.seasonal.SeasonalDecorations
import com.ponzischeme89.memby.ui.whatsnew.whatsNewDecision
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.MembyChipCorner
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
import com.ponzischeme89.memby.ui.theme.MembyControlSurfaceRaised
import com.ponzischeme89.memby.ui.theme.MembyDisabledText
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.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembySplashTint
import com.ponzischeme89.memby.ui.theme.MembySurface
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
import com.ponzischeme89.memby.ui.theme.MembyTheme
import com.ponzischeme89.memby.ui.setup.SignInContent
import com.ponzischeme89.memby.update.InstallPermission
@@ -378,10 +390,27 @@ private fun AppRoot(onCloseSettings: () -> Unit) {
Box(Modifier.fillMaxSize().background(MembySurface)) {
val loaded = settings
// The three states below that mean "still opening" used to be three separate calls
// to MembyLoadingScreen, and Compose identifies a composable by where it is called
// from — so passing between them disposed the screen and built it again. That was
// only a wasted animation before; now it hands the pre-roll player back and borrows
// it a moment later, twice, during the one stretch of a launch that is busiest.
// Hoisted to one call site, the screen and its clip survive the whole cold start.
val openingQuoteStyle = when {
!initialUpdateCheckComplete -> loaded?.welcomeQuoteStyle.orEmpty()
appUpdate != null -> null
loaded == null -> ServiceLocator.settings.current?.welcomeQuoteStyle.orEmpty()
// Ordered exactly as the screens below are: adding a profile outranks waiting
// on the onboarding answer, and swapping the two would put the loading screen
// over a sign-in form somebody is halfway through.
addingProfile -> null
loaded.isSignedIn && recommendationOnboarding == null ->
loaded.welcomeQuoteStyle.orEmpty()
else -> null
}
if (openingQuoteStyle != null) MembyLoadingScreen(quoteStyle = openingQuoteStyle)
when {
!initialUpdateCheckComplete -> MembyLoadingScreen(
quoteStyle = loaded?.welcomeQuoteStyle,
)
openingQuoteStyle != null -> Unit
appUpdate != null -> UpdateScreen(
update = appUpdate!!,
onDismiss = {
@@ -392,15 +421,11 @@ private fun AppRoot(onCloseSettings: () -> Unit) {
}
},
)
loaded == null -> MembyLoadingScreen(
quoteStyle = ServiceLocator.settings.current?.welcomeQuoteStyle,
)
loaded == null -> Unit
addingProfile -> SetupScreen(
onCancel = { addingProfile = false },
onSignedIn = { addingProfile = false },
)
loaded.isSignedIn && recommendationOnboarding == null ->
MembyLoadingScreen(quoteStyle = loaded.welcomeQuoteStyle)
loaded.isSignedIn && recommendationOnboarding?.completed == false &&
recommendationOnboarding?.prompted == true -> {
RecommendationOnboardingScreen(
@@ -496,7 +521,7 @@ private fun ExitMembyConfirmation(
Column(
modifier = Modifier
.width(480.dp)
.background(Color(0xFF20262B), RoundedCornerShape(18.dp))
.background(MembyControlSurface, RoundedCornerShape(18.dp))
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(18.dp),
@@ -509,7 +534,7 @@ private fun ExitMembyConfirmation(
)
Text(
"Choose Stay to keep browsing, or close Memby and return to your TV.",
color = Color(0xFFBCC4CA),
color = MembyMutedText,
fontSize = 16.sp,
textAlign = TextAlign.Center,
)
@@ -541,9 +566,23 @@ private fun ExitMembyConfirmation(
}
}
@androidx.media3.common.util.UnstableApi
@Composable
private fun MembyLoadingScreen(quoteStyle: String? = null) {
val welcomeQuote = remember(quoteStyle) { randomWelcomeQuote(quoteStyle) }
// Whether Memby's own clip is actually on screen behind this. Set from the player's
// first rendered frame rather than from having asked it to play, so the logo only gets
// out of the way once there is something to get out of the way for.
var prerollVisible by remember { mutableStateOf(false) }
val prerollAlpha by animateFloatAsState(
targetValue = if (prerollVisible) 1f else 0f,
animationSpec = tween(420),
label = "cold-start-preroll-alpha",
)
// Not keyed on the quote style: the headline says what the app is doing, and rerolling
// it when the settings flow arrives with a tone would change the line under the viewer
// mid-launch. Once per appearance of this screen is the intent.
val headline = remember { randomLoadingHeadline() }
val transition = rememberInfiniteTransition(label = "cold-start")
val pulse by transition.animateFloat(
initialValue = 0.96f,
@@ -569,16 +608,40 @@ private fun MembyLoadingScreen(quoteStyle: String? = null) {
.fillMaxSize()
.background(
Brush.radialGradient(
colors = listOf(Color(0xFF16231E), Color(0xFF090C0F)),
colors = listOf(MembySplashTint, MembySurface),
radius = 1_100f,
),
),
contentAlignment = Alignment.Center,
) {
LaunchPrerollVideo(
modifier = Modifier
.fillMaxSize()
.graphicsLayer { alpha = prerollAlpha },
onVisible = { prerollVisible = true },
)
// Holds the copy legible over whatever frame the clip happens to be on. It fades in
// with the video rather than sitting there over the plain gradient, where it would
// only be darkening a screen that is already dark.
Box(
Modifier
.fillMaxSize()
.graphicsLayer { alpha = prerollAlpha }
.background(
Brush.verticalGradient(
colors = listOf(
MembySurface.copy(alpha = 0.35f),
MembySurface.copy(alpha = 0.82f),
),
),
),
)
Column(
verticalArrangement = Arrangement.spacedBy(14.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// The clip is Memby's own mark moving; the still logo above it would be the
// same thing said twice, so it gives way as the video arrives.
Image(
painter = painterResource(R.drawable.emby_logo),
contentDescription = "Memby",
@@ -588,11 +651,11 @@ private fun MembyLoadingScreen(quoteStyle: String? = null) {
.graphicsLayer {
scaleX = pulse
scaleY = pulse
alpha = 0.82f + (glow * 0.18f)
alpha = (0.82f + (glow * 0.18f)) * (1f - prerollAlpha)
},
)
Text(
"Opening Memby…",
headline,
color = Color.White.copy(alpha = 0.88f),
fontSize = 17.sp,
fontWeight = FontWeight.Medium,
@@ -678,8 +741,8 @@ internal fun FirstRunScreen(onGetStarted: () -> Unit) {
.background(
Brush.linearGradient(
colors = listOf(
Color(0xFF0A0E11),
Color(0xFF101A17),
MembySurface,
MembySplashTint,
MembySurface,
),
),
@@ -714,7 +777,7 @@ internal fun FirstRunScreen(onGetStarted: () -> Unit) {
) {
Text(
"MEMBY",
color = Color(0xFF69CD61),
color = MembyAccentBright,
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 2.sp,
@@ -730,7 +793,7 @@ internal fun FirstRunScreen(onGetStarted: () -> Unit) {
)
Text(
"Enter your username & password on the next screen. Ensure to allow permissions for app updates.",
color = Color(0xFFB8C0C6),
color = MembyMutedText,
fontSize = 20.sp,
lineHeight = 29.sp,
)
@@ -957,7 +1020,7 @@ private fun ProfileChooser(
switchingProfileId != null -> "Switching profile…"
else -> "Choose a profile to continue"
},
color = Color(0xFFAAB1B7),
color = MembyQuietText,
fontSize = 17.sp,
modifier = Modifier.padding(top = 8.dp, bottom = 30.dp),
)
@@ -1129,7 +1192,7 @@ private fun RecommendationOnboardingScreen(
.fillMaxSize()
.background(
Brush.radialGradient(
listOf(Color(0xFF193423), Color(0xFF0C1212), Color(0xFF07090B)),
listOf(MembySplashTint, MembySurfaceRaised, MembySurface),
center = Offset(260f, 180f),
radius = 1_500f,
),
@@ -1142,7 +1205,7 @@ private fun RecommendationOnboardingScreen(
) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text("TUNE YOUR TASTE", color = Color(0xFF78D970), fontSize = 13.sp, fontWeight = FontWeight.Bold)
Text("TUNE YOUR TASTE", color = MembyAccentBright, fontSize = 13.sp, fontWeight = FontWeight.Bold)
Text("Make Memby yours", color = Color.White, fontSize = 34.sp, fontWeight = FontWeight.Bold)
}
Row(horizontalArrangement = Arrangement.spacedBy(7.dp)) {
@@ -1152,7 +1215,7 @@ private fun RecommendationOnboardingScreen(
.width(if (index == stageIndex) 34.dp else 12.dp)
.height(5.dp)
.clip(CircleShape)
.background(if (index <= stageIndex) Color(0xFF63C85B) else Color(0xFF394047)),
.background(if (index <= stageIndex) MembyAccentBright else MembyControlSurfaceRaised),
)
}
}
@@ -1172,12 +1235,12 @@ private fun RecommendationOnboardingScreen(
)
Text(
"Select as many as you like, or skip this step. Every choice gives your recommendations a stronger starting point.",
color = Color(0xFFB8C2C9), fontSize = 16.sp,
color = MembyMutedText, fontSize = 16.sp,
)
Spacer(Modifier.height(8.dp))
if (stage == null) {
Spacer(Modifier.weight(1f))
Text("Your library is still being prepared. You can start watching now and Memby will learn as you go.", color = Color(0xFFB8C2C9))
Text("Your library is still being prepared. You can start watching now and Memby will learn as you go.", color = MembyMutedText)
Spacer(Modifier.weight(1f))
} else {
when (stage) {
@@ -1202,7 +1265,7 @@ private fun RecommendationOnboardingScreen(
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(
"${ratings.count { it.value >= 4 } + selectedActors.count { it.value } + selectedActresses.count { it.value } + selectedDirectors.count { it.value }} selected",
color = Color(0xFF9DA8B0), fontSize = 14.sp, modifier = Modifier.weight(1f),
color = MembyQuietText, fontSize = 14.sp, modifier = Modifier.weight(1f),
)
error?.let { Text(it, color = Color(0xFFFF7777), fontSize = 14.sp, modifier = Modifier.padding(end = 16.dp)) }
if (stageIndex > 0) {
@@ -1271,7 +1334,7 @@ private fun OnboardingSkipConfirmation(
Column(
modifier = Modifier
.width(480.dp)
.background(Color(0xFF20262B), RoundedCornerShape(18.dp))
.background(MembyControlSurface, RoundedCornerShape(18.dp))
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(18.dp),
@@ -1284,7 +1347,7 @@ private fun OnboardingSkipConfirmation(
)
Text(
"You can start watching now. Memby may offer these choices again later.",
color = Color(0xFFBCC4CA),
color = MembyMutedText,
fontSize = 16.sp,
textAlign = TextAlign.Center,
)
@@ -1342,18 +1405,18 @@ private fun OnboardingTitleRow(
Modifier
.width(148.dp).height(222.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color(0xFF20272C))
.border(if (focused || selected) 3.dp else 1.dp, if (selected) Color(0xFF63C85B) else if (focused) Color.White else Color(0xFF465058), RoundedCornerShape(12.dp)),
.background(MembyControlSurface)
.border(if (focused || selected) 3.dp else 1.dp, if (selected) MembyAccentBright else if (focused) Color.White else MembyOutline, RoundedCornerShape(12.dp)),
) {
if (previewArtwork != null) {
Image(bitmap = previewArtwork, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize())
} else {
AsyncImage(model = repo.primaryUrl(item, 320), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize())
}
if (selected) Text("✓ PICKED", color = Color.White, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.align(Alignment.TopEnd).padding(8.dp).background(Color(0xE852B54B), CircleShape).padding(horizontal = 9.dp, vertical = 5.dp))
if (selected) Text("✓ PICKED", color = Color.White, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.align(Alignment.TopEnd).padding(8.dp).background(MembyAccent.copy(alpha = 0.91f), CircleShape).padding(horizontal = 9.dp, vertical = 5.dp))
}
Text(item.name, color = Color.White, fontSize = 14.sp, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(listOfNotNull(item.productionYear?.toString(), item.genres.firstOrNull()).joinToString(" · "), color = Color(0xFFAEB7BF), fontSize = 11.sp, maxLines = 1)
Text(listOfNotNull(item.productionYear?.toString(), item.genres.firstOrNull()).joinToString(" · "), color = MembyQuietText, fontSize = 11.sp, maxLines = 1)
}
}
}
@@ -1383,8 +1446,8 @@ private fun OnboardingPeopleRow(
) { focused ->
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) {
Box(
Modifier.size(148.dp).clip(CircleShape).background(Color(0xFF25322C))
.border(if (focused || selected) 4.dp else 1.dp, if (selected) Color(0xFF63C85B) else if (focused) Color.White else Color(0xFF465058), CircleShape),
Modifier.size(148.dp).clip(CircleShape).background(MembyAccentMuted)
.border(if (focused || selected) 4.dp else 1.dp, if (selected) MembyAccentBright else if (focused) Color.White else MembyOutline, CircleShape),
contentAlignment = Alignment.Center,
) {
if (previewArtwork != null) {
@@ -1392,7 +1455,7 @@ private fun OnboardingPeopleRow(
} else if (person.id.isNotBlank() && person.imageTag.isNotBlank()) {
AsyncImage(model = repo.posterUrl(person.id, person.imageTag, 280), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize())
} else {
Text(person.name.take(1).uppercase(), color = Color(0xFFD5E8D7), fontSize = 46.sp, fontWeight = FontWeight.Light)
Text(person.name.take(1).uppercase(), color = MembyOnSurface, fontSize = 46.sp, fontWeight = FontWeight.Light)
}
if (selected) Text("", color = Color.White, fontSize = 17.sp, fontWeight = FontWeight.Bold, modifier = Modifier.align(Alignment.BottomEnd).background(MembyAccent, CircleShape).padding(horizontal = 9.dp, vertical = 5.dp))
}
@@ -1466,9 +1529,9 @@ private fun ProfileDeleteButton(
.clip(RoundedCornerShape(9.dp))
.background(
when {
!enabled -> Color(0xFF343A3F)
!enabled -> MembyControlSurfaceRaised
focused -> Color(0xFFE34B4B)
else -> Color(0xE61A1D20)
else -> MembyControlSurface.copy(alpha = 0.90f)
},
)
.border(
@@ -1484,7 +1547,7 @@ private fun ProfileDeleteButton(
) {
Text(
text = "Remove user",
color = if (enabled) Color.White else Color(0xFF899198),
color = if (enabled) Color.White else MembyDisabledText,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
)
@@ -1511,7 +1574,7 @@ private fun ProfileRemovalConfirmation(
Column(
modifier = Modifier
.width(460.dp)
.background(Color(0xFF20262B), RoundedCornerShape(18.dp))
.background(MembyControlSurface, RoundedCornerShape(18.dp))
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(18.dp),
@@ -1524,7 +1587,7 @@ private fun ProfileRemovalConfirmation(
)
Text(
"Their saved sign-in will be forgotten on this device.",
color = Color(0xFFBCC4CA),
color = MembyMutedText,
fontSize = 16.sp,
)
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
@@ -1575,10 +1638,10 @@ private fun ProfileTile(
modifier = Modifier
.size(112.dp)
.clip(CircleShape)
.background(if (focused) Color(0xFF5BC653) else Color(0xFF30373D))
.background(if (focused) MembyAccent else MembyControlSurfaceRaised)
.border(
width = if (current) 4.dp else if (focused) 3.dp else 1.dp,
color = if (current) MembyAccent else if (focused) Color.White else Color(0xFF5B646C),
color = if (current) MembyAccent else if (focused) Color.White else MembyDisabledText,
shape = CircleShape,
),
contentAlignment = Alignment.Center,
@@ -1587,7 +1650,7 @@ private fun ProfileTile(
}
Text(
name,
color = if (focused) Color.White else Color(0xFFD0D4D7),
color = if (focused) Color.White else MembyMutedText,
fontSize = 16.sp,
fontWeight = if (focused || current) FontWeight.SemiBold else FontWeight.Medium,
maxLines = 2,
@@ -1595,7 +1658,7 @@ private fun ProfileTile(
modifier = Modifier.padding(top = 12.dp),
)
if (current) {
Text("Current", color = Color(0xFF73D56C), fontSize = 13.sp, modifier = Modifier.padding(top = 3.dp))
Text("Current", color = MembyAccentBright, fontSize = 13.sp, modifier = Modifier.padding(top = 3.dp))
}
}
}
@@ -2202,7 +2265,7 @@ private fun HomeScreen(
// The gateway's maintenance notice when it sent one, and the
// generic wording otherwise.
homeStatus.statusMessage ?: "Connection is slow — showing available content",
color = Color(0xFFD1D5D8),
color = MembyMutedText,
fontSize = 14.sp,
modifier = Modifier.padding(horizontal = 36.dp, vertical = 4.dp),
)
@@ -2905,7 +2968,7 @@ private fun PlaybackLaunchOverlay(item: BaseItem, modifier: Modifier = Modifier)
label = "playback-logo-rotation",
)
Box(
modifier = modifier.background(Color(0xEE050708)),
modifier = modifier.background(MembySurface.copy(alpha = 0.93f)),
contentAlignment = Alignment.Center,
) {
Column(
@@ -2931,7 +2994,7 @@ private fun PlaybackLaunchOverlay(item: BaseItem, modifier: Modifier = Modifier)
)
Text(
"Preparing direct playback…",
color = Color(0xFF9FA7AD),
color = MembyQuietText,
fontSize = 14.sp,
)
}
@@ -2958,14 +3021,14 @@ private fun RecentSearchesRow(
Spacer(Modifier.width(HomeRowHeaderIconGap))
Text(
"Recent searches",
color = Color(0xFFF1F3F4),
color = MembyOnSurface,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.width(10.dp))
Text(
"LAST 30 DAYS",
color = Color(0xFFAEB7BF),
color = MembyQuietText,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
)
@@ -2996,13 +3059,13 @@ private fun RecentSearchesRow(
) { focused ->
Text(
query,
color = if (focused) Color(0xFF06240A) else Color(0xFFE8EDF1),
color = if (focused) MembyAccentInk else MembyOnSurface,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
modifier = Modifier
.background(
if (focused) MembyAccent else Color(0xFF1A2129),
if (focused) MembyAccent else MembyControlSurface,
)
.padding(horizontal = 17.dp, vertical = 10.dp),
)
@@ -3049,7 +3112,7 @@ private fun HomeClock(
Row(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(Color(0xB30A0D10))
.background(MembySurface.copy(alpha = 0.70f))
.padding(horizontal = 12.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
) {
@@ -3080,7 +3143,7 @@ private fun HomeClock(
fontWeight = FontWeight.Medium,
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(Color(0xB30A0D10))
.background(MembySurface.copy(alpha = 0.70f))
.padding(horizontal = 12.dp, vertical = 7.dp),
)
}
@@ -3572,7 +3635,7 @@ private fun ForYouTimeBudget(
) {
Text(
"How much time do you have?",
color = Color(0xFFD0D6DB),
color = MembyMutedText,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
)
@@ -3611,9 +3674,9 @@ private fun ForYouNudgeBanner(
.height(92.dp)
.background(
Brush.horizontalGradient(
0f to Color(0xFF0D1512),
0.62f to Color(0xF20D1512),
1f to Color(0xE0121A20),
0f to MembySplashTint,
0.62f to MembySplashTint.copy(alpha = 0.95f),
1f to MembySurfaceRaised.copy(alpha = 0.88f),
),
)
// Keep copy inside television overscan and clear of the collapsed rail.
@@ -3630,7 +3693,7 @@ private fun ForYouNudgeBanner(
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(
"FOR YOU",
color = Color(0xFF79D672),
color = MembyAccentBright,
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.4.sp,
@@ -3641,7 +3704,7 @@ private fun ForYouNudgeBanner(
} else {
"$name, your personalised picks are ready"
},
color = Color(0xFFF3F6F4),
color = MembyOnSurface,
fontSize = 21.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
@@ -3649,7 +3712,7 @@ private fun ForYouNudgeBanner(
)
Text(
"Open For You from the menu and choose how much time you have.",
color = Color(0xFFC7D0CB),
color = MembyMutedText,
fontSize = 15.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@@ -3690,7 +3753,7 @@ internal fun personalisedFavouritesTitle(username: String?): String {
private fun HeaderAction(label: String, onClick: () -> Unit) {
var focused by remember { mutableStateOf(false) }
val textColor by animateColorAsState(
targetValue = if (focused) Color.White else Color(0xFFB8BEC3),
targetValue = if (focused) Color.White else MembyMutedText,
animationSpec = tween(100),
label = "header-action-text",
)
@@ -3723,7 +3786,7 @@ private fun FavoriteCard(item: BaseItem, onClick: () -> Unit) {
val imageUrl = repo.backdropUrl(item, maxWidth = 640) ?: repo.primaryUrl(item)
Card(onClick = onClick, modifier = Modifier.width(320.dp)) {
Column {
Box(Modifier.width(320.dp).height(180.dp).background(Color(0xFF1A2027))) {
Box(Modifier.width(320.dp).height(180.dp).background(MembyControlSurface)) {
if (imageUrl != null) {
AsyncImage(
model = imageUrl,
@@ -3760,12 +3823,12 @@ internal fun TvTextField(
) {
var focused by remember { mutableStateOf(false) }
val borderColor by animateColorAsState(
targetValue = if (focused) Color(0xFF69CD61) else Color(0xFF3A424B),
targetValue = if (focused) MembyAccentBright else MembyOutline,
animationSpec = tween(120),
label = "login-field-border",
)
val backgroundColor by animateColorAsState(
targetValue = if (focused) Color(0xFF243129) else Color(0xFF161B21),
targetValue = if (focused) MembyAccentMuted else MembySurfaceRaised,
animationSpec = tween(120),
label = "login-field-background",
)
@@ -3775,7 +3838,7 @@ internal fun TvTextField(
) {
Text(
text = if (focused) "$label — selected" else label,
color = if (focused) Color(0xFF8DE185) else Color(0xFF9AA3AC),
color = if (focused) MembyAccentBright else MembyQuietText,
fontSize = 15.sp,
fontWeight = if (focused) FontWeight.Bold else FontWeight.Medium,
)
@@ -3791,7 +3854,7 @@ internal fun TvTextField(
onValueChange = onValueChange,
singleLine = true,
textStyle = TextStyle(color = Color.White, fontSize = 20.sp),
cursorBrush = SolidColor(Color(0xFF69CD61)),
cursorBrush = SolidColor(MembyAccentBright),
visualTransformation = if (isPassword) PasswordVisualTransformation() else VisualTransformation.None,
keyboardOptions = KeyboardOptions(
keyboardType = if (isPassword) KeyboardType.Password else keyboardType,
@@ -7,9 +7,9 @@ import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -54,13 +54,21 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import kotlinx.coroutines.delay
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyAccentInk
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembySplashTint
import com.ponzischeme89.memby.ui.theme.MembySurface
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
import kotlinx.coroutines.delay
private val MaintenanceAccent: Color get() = MembyAccent
private val MaintenanceTitle = Color(0xFFF2F5F7)
private val MaintenanceBody = Color(0xFFAEB7BF)
private val MaintenanceFaint = Color(0xFFA2ADB5)
private val MaintenanceTitle: Color get() = MembyOnSurface
private val MaintenanceBody: Color get() = MembyMutedText
private val MaintenanceFaint: Color get() = MembyQuietText
/** How long between automatic retries while the gateway is down. */
private const val RETRY_SECONDS = 30
@@ -217,7 +225,7 @@ private fun MaintenanceBackdrop(glow: Float) {
Canvas(Modifier.fillMaxSize()) {
drawRect(
brush = Brush.verticalGradient(
listOf(Color(0xFF0B0F14), Color(0xFF121A22), Color(0xFF0A0D11)),
listOf(MembySurface, MembySplashTint, MembySurface),
),
)
val centre = Offset(
@@ -273,7 +281,7 @@ private fun PulsingEmblem(pulse: Float, gearRotation: Float) {
modifier = Modifier
.size(96.dp)
.clip(CircleShape)
.background(Color(0xFF16202A))
.background(MembySurfaceRaised)
.border(1.dp, MaintenanceAccent.copy(alpha = 0.35f), CircleShape),
contentAlignment = Alignment.Center,
) {
@@ -333,7 +341,7 @@ private fun RetryButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
modifier = modifier
.graphicsLayer { scaleX = scale; scaleY = scale }
.clip(RoundedCornerShape(10.dp))
.background(if (focused) MaintenanceAccent else Color(0xFF1E2833))
.background(if (focused) MaintenanceAccent else MembyControlSurface)
.border(
width = if (focused) 0.dp else 1.dp,
color = Color.White.copy(alpha = 0.12f),
@@ -346,7 +354,7 @@ private fun RetryButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
) {
Text(
"Try again",
color = if (focused) Color(0xFF06240A) else MaintenanceTitle,
color = if (focused) MembyAccentInk else MaintenanceTitle,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
)
@@ -40,10 +40,13 @@ import androidx.tv.material3.Text
import coil.compose.AsyncImage
import com.ponzischeme89.memby.data.EmbyRepository
import com.ponzischeme89.memby.data.model.MyShow
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembySurface
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
@Composable
internal fun MyShowsStrip(
@@ -68,14 +71,14 @@ internal fun MyShowsStrip(
Spacer(Modifier.width(HomeRowHeaderIconGap))
Text(
"My Shows",
color = Color(0xFFF1F3F4),
color = MembyOnSurface,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
)
if (shows.isNotEmpty()) {
Text(
shows.size.toString(),
color = Color(0xFFAEB7BF),
color = MembyQuietText,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier
@@ -89,7 +92,7 @@ internal fun MyShowsStrip(
if (shows.isEmpty()) {
Text(
"Open any series and choose “Add to My Shows”.",
color = Color(0xFFAEB7BF),
color = MembyQuietText,
fontSize = 14.sp,
modifier = Modifier.padding(horizontal = 36.dp, vertical = 18.dp),
)
@@ -172,7 +175,7 @@ private fun MyShowCard(
}
Text(
show.title,
color = if (focused) Color.White else Color(0xFFE1E5E8),
color = if (focused) Color.White else MembyOnSurface,
fontSize = 14.sp,
fontWeight = if (focused) FontWeight.Bold else FontWeight.SemiBold,
maxLines = 1,
@@ -181,7 +184,7 @@ private fun MyShowCard(
)
Text(
myShowCardSubtitle(show),
color = Color(0xFFAEB7BF),
color = MembyQuietText,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
@@ -234,13 +237,13 @@ internal fun MyShowDetailsOverlay(
onClose: () -> Unit,
) {
Box(
Modifier.fillMaxSize().zIndex(8f).background(Color(0xF5090B0D)),
Modifier.fillMaxSize().zIndex(8f).background(MembySurface.copy(alpha = 0.96f)),
contentAlignment = Alignment.Center,
) {
Row(
modifier = Modifier
.fillMaxWidth(0.72f)
.background(Color(0xFF151A1E), RoundedCornerShape(20.dp))
.background(MembySurfaceRaised, RoundedCornerShape(20.dp))
.padding(34.dp),
horizontalArrangement = Arrangement.spacedBy(30.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -275,7 +278,7 @@ internal fun MyShowDetailsOverlay(
@Composable
private fun StatusLine(label: String, value: String) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Text("$label:", color = Color(0xFFAEB7BF), fontSize = 16.sp)
Text("$label:", color = MembyQuietText, fontSize = 16.sp)
Text(value, color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
}
}
@@ -11,15 +11,15 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
@@ -88,6 +88,10 @@ import com.ponzischeme89.memby.ui.detail.seasonLabel
import com.ponzischeme89.memby.ui.detail.seriesEpisodeComparator
import com.ponzischeme89.memby.ui.detail.technicalSpecs
import com.ponzischeme89.memby.ui.detail.unwatchedCount
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
import java.util.TimeZone
/**
@@ -550,7 +554,7 @@ internal fun EpisodeCard(
.clip(shape)
.background(
when {
focused -> Color(0xFF23282C)
focused -> MembyControlSurface
isCurrent -> DetailAccent.copy(alpha = 0.10f)
else -> Color.White.copy(alpha = 0.035f)
},
@@ -577,7 +581,7 @@ internal fun EpisodeCard(
.width(208.dp)
.fillMaxHeight()
.clip(RoundedCornerShape(6.dp))
.background(Color(0xFF15181C)),
.background(MembySurfaceRaised),
) {
AsyncImage(
model = imageUrl,
@@ -645,7 +649,7 @@ internal fun EpisodeCard(
episode.indexNumber?.let {
Text(
text = "$it.",
color = if (focused) Color(0xFFB7C0C6) else DetailQuietText,
color = if (focused) MembyMutedText else DetailQuietText,
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(end = 7.dp),
@@ -677,7 +681,7 @@ internal fun EpisodeCard(
formatAirDate(episode.premiereDate)?.let {
Text(
text = it,
color = if (focused) Color(0xFFB7C0C6) else DetailQuietText,
color = if (focused) MembyMutedText else DetailQuietText,
fontSize = 12.sp,
maxLines = 1,
modifier = Modifier.padding(start = 12.dp),
@@ -686,7 +690,7 @@ internal fun EpisodeCard(
episode.runtimeMinutes?.let {
Text(
text = "$it min",
color = if (focused) Color(0xFFE4E8EA) else DetailText,
color = if (focused) MembyOnSurface else DetailText,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier
@@ -703,7 +707,7 @@ internal fun EpisodeCard(
Spacer(Modifier.height(7.dp))
Text(
text = episode.overview?.takeIf(String::isNotBlank) ?: "No plot summary available.",
color = if (focused) Color(0xFFC4CBD0) else DetailMutedText,
color = if (focused) MembyMutedText else DetailMutedText,
fontSize = 13.sp,
lineHeight = 18.sp,
maxLines = 3,
@@ -61,17 +61,24 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import com.ponzischeme89.memby.data.model.GatewayUpdate
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyAccentInk
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
import com.ponzischeme89.memby.ui.theme.MembyControlSurfaceRaised
import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
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.update.AppInstall
import com.ponzischeme89.memby.update.InstallPermissionRequiredException
import com.ponzischeme89.memby.update.UpdateChecker
import kotlinx.coroutines.launch
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembySurface
private val UpdateAccent: Color get() = MembyAccent
private val UpdateTitle = Color(0xFFF2F5F7)
private val UpdateBody = Color(0xFFAEB7BF)
private val UpdateFaint = Color(0xFFA2ADB5)
private val UpdateTitle: Color get() = MembyOnSurface
private val UpdateBody: Color get() = MembyMutedText
private val UpdateFaint: Color get() = MembyQuietText
/**
* The app-level update gate. AppRoot composes this instead of login, profiles, or Home,
@@ -209,7 +216,7 @@ fun UpdateScreen(
.size(92.dp)
.graphicsLayer { translationY = -6.dp.toPx() * bob }
.clip(CircleShape)
.background(Color(0xFF16202A))
.background(MembySurfaceRaised)
.border(1.dp, UpdateAccent.copy(alpha = 0.4f), CircleShape),
contentAlignment = Alignment.Center,
) {
@@ -339,9 +346,9 @@ internal fun UpdateButton(
.background(
when {
focused && primary -> UpdateAccent
focused -> Color(0xFF2A343F)
primary -> Color(0xFF2C6A29)
else -> Color(0xFF1E2833)
focused -> MembyControlSurfaceRaised
primary -> MembyAccent.copy(alpha = 0.55f)
else -> MembyControlSurface
},
)
.border(
@@ -375,7 +382,7 @@ internal fun UpdateButton(
) {
Text(
label,
color = if (focused && primary) Color(0xFF06240A) else UpdateTitle,
color = if (focused && primary) MembyAccentInk else UpdateTitle,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
)
@@ -17,6 +17,21 @@ internal enum class WelcomeQuoteStyle(
}
}
/**
* What Memby says while it is opening.
*
* These are read far more often than anything else the app writes the loading screen is
* the one thing every launch shows, and the pre-roll shows one too. Five lines per tone
* meant a household saw the same sentence roughly every fifth time they switched the
* television on, which is how a line written to be a small pleasure becomes wallpaper. The
* pools are deliberately deep enough that a repeat inside a week is unlikely rather than
* guaranteed.
*
* Every line has to survive being read for the hundredth time, so they lean on observation
* rather than on a joke with a punchline. Two lengths are avoided on purpose: nothing so
* short it reads as a status message, and nothing so long it wraps past the two lines the
* loading screen gives it at 13sp across 440dp.
*/
private val WelcomeQuotes = mapOf(
WelcomeQuoteStyle.NEUTRAL to listOf(
"The sofa has been expecting you.",
@@ -24,6 +39,26 @@ private val WelcomeQuotes = mapOf(
"Everything is ready. Decision-making is now your problem.",
"Welcome back. The pixels have been briefed.",
"No judgement. Even if you pick that again.",
"Warming up the good recommendations first.",
"The remote is charged. Probably.",
"Somewhere in here is exactly what you wanted.",
"Rounding up everything you left half-finished.",
"The library has been counted. Twice.",
"Nothing has been deleted. You just scrolled past it.",
"Assembling rows in a sensible order.",
"Your unwatched pile sends its regards.",
"Straightening the posters.",
"Checking what the server has been up to.",
"One moment — putting the good stuff at the front.",
"Everything is where you left it.",
"The credits from last night have finally stopped.",
"Dusting off the shelves you never visit.",
"Two hours is a perfectly reasonable evening.",
"Sorting the maybes from the definitelys.",
"The subtitles are already lined up.",
"Looking for the thing you meant to watch.",
"Quietly ignoring the films you skipped.",
"Almost there. Choose wisely, or don't.",
),
WelcomeQuoteStyle.POSITIVE to listOf(
"Excellent choice showing up. The rest should be easy.",
@@ -31,6 +66,26 @@ private val WelcomeQuotes = mapOf(
"Tonight has strong main-character energy.",
"Your next favourite thing might be one click away.",
"Settle in. Youve earned the good seat.",
"This is going to be a great one. It usually is.",
"The lighting in here is perfect for you.",
"Whatever you pick, it was the right call.",
"Good taste detected. Loading accordingly.",
"Somebody made something brilliant. Let's find it.",
"The best evenings start exactly like this.",
"You showed up, which is most of it.",
"Cracking night for it, honestly.",
"Every row in here was chosen with you in mind.",
"No wrong answers ahead. Only long ones.",
"Your patience is about to be very well rewarded.",
"The good chair, the good screen, the good hour.",
"Something wonderful is about to start.",
"Snacks optional. Enthusiasm clearly not.",
"You have excellent instincts. Trust them.",
"This is the part of the day that's yours.",
"Big feelings incoming. Bring tissues, maybe.",
"Warming up something worth staying awake for.",
"Nothing to do now but enjoy it.",
"You are going to love what's in here.",
),
WelcomeQuoteStyle.HOMICIDAL to listOf(
"Welcome back. I kept your spot. Nobody argued twice.",
@@ -38,9 +93,47 @@ private val WelcomeQuotes = mapOf(
"The remote knows what it did.",
"Your watchlist is safe. The witnesses, less so.",
"Relax. Everything is under control, allegedly.",
"I tidied up. Do not ask about the shed.",
"The last person who paused mid-scene is unavailable.",
"Everything is fine. Ignore the sounds from the loft.",
"I've been counting the hours. All of them.",
"Nobody else is watching. I made sure.",
"The server confessed. Eventually.",
"Sit down. The credits can wait; I cannot.",
"I rewatched your history. All of it. Twice.",
"Somebody spoiled the ending. It has been handled.",
"Loading quietly, so nobody upstairs wakes.",
"You were gone eleven hours. I noticed.",
"The buffering was not an accident.",
"I saved the good episode. For reasons.",
"The neighbours asked what you were watching. Once.",
"Everything is exactly where I put it. Everything.",
"No spoilers. I removed the possibility.",
"Your unwatched films are being dealt with.",
"Do not choose a documentary. I am asking nicely.",
"The screensaver saw things. It won't talk.",
"Welcome home. Nobody followed you, probably.",
),
)
/**
* The line above the quote on the cold-start screen.
*
* Kept apart from the quotes rather than folded into them: this one names what the app is
* *doing*, so it has to stay a plain progress statement in every tone, and it is repeated on
* a screen where the ellipsis is the only thing promising the wait ends.
*/
private val LoadingHeadlines = listOf(
"Opening Memby…",
"Waking Memby up…",
"Getting things ready…",
"Fetching your library…",
"Setting the scene…",
"Tuning in…",
"Nearly there…",
"Rolling the shelves out…",
)
internal fun randomWelcomeQuote(
styleValue: String?,
random: Random = Random.Default,
@@ -49,6 +142,9 @@ internal fun randomWelcomeQuote(
return quotes[random.nextInt(quotes.size)]
}
internal fun randomLoadingHeadline(random: Random = Random.Default): String =
LoadingHeadlines[random.nextInt(LoadingHeadlines.size)]
internal fun loginWelcomeMessage(
username: String,
styleValue: String? = WelcomeQuoteStyle.NEUTRAL.value,
@@ -56,11 +56,26 @@ internal object PrerollPreloader {
applicationContext = context.applicationContext
val prepared = cachedPlayer
cachedPlayer = null
if (prepared != null && prepared.playerError == null) return prepared
if (prepared != null && prepared.playerError == null) return prepared.normalised()
prepared?.release()
return createPreparedPlayer(context.applicationContext)
}
/**
* Hands the instance over in a known state.
*
* One player is passed between two callers that want different things of it the
* launcher's cold-start screen mutes it and loops it, the player's pre-roll needs it
* audible and needs it to *end*, since `STATE_ENDED` is what hands over to the
* programme. A borrower left one of those set and the next playback would sit behind a
* silent clip that never finished. Normalising here rather than trusting each caller to
* put it back is the only version of that which cannot be forgotten.
*/
private fun ExoPlayer.normalised(): ExoPlayer = apply {
volume = 1f
repeatMode = ExoPlayer.REPEAT_MODE_OFF
}
/**
* Returns a healthy player to the process cache without holding a second decoder while
* the requested programme is playing. [start] prepares it again when Home next opens.
@@ -76,8 +91,13 @@ internal object PrerollPreloader {
player.pause()
player.stop()
player.seekTo(0L)
player.normalised()
cachedPlayer?.release()
cachedPlayer = player
// Stopping the player leaves it idle, so the next borrower would pay for the local
// resource read itself. Re-arm the idle prepare here rather than depending on the
// caller to remember: the launcher is not the only thing that returns one now.
scheduleAtMainQueueIdle()
}
/** Drops a failed or otherwise unusable instance; the next Home/start call replaces it. */
@@ -14,9 +14,9 @@ import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
@@ -30,29 +30,29 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed as rowItemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Backspace
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.AutoAwesome
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.LiveTv
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.PlayCircleFilled
import androidx.compose.material.icons.filled.SentimentVerySatisfied
import androidx.compose.material.icons.filled.TheaterComedy
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.SentimentVerySatisfied
import androidx.compose.material.icons.filled.SpaceBar
import androidx.compose.material.icons.filled.TheaterComedy
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -71,11 +71,11 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.key.utf16CodePoint
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.contentDescription
@@ -89,9 +89,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import coil.imageLoader
import kotlinx.coroutines.flow.distinctUntilChanged
import coil.compose.AsyncImage
import coil.imageLoader
import coil.request.ImageRequest
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.BaseItem
@@ -99,16 +98,22 @@ import com.ponzischeme89.memby.data.model.GatewayRequestCandidate
import com.ponzischeme89.memby.ui.FocusScaleContainer
import com.ponzischeme89.memby.ui.PosterGridCard
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
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.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembySurface
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
import kotlinx.coroutines.flow.distinctUntilChanged
private val PaneBackground: Color get() = MembySurfaceRaised
private val KeyIdle = Color(0xFF1A2129)
private val KeyIdle: Color get() = MembyControlSurface
private val KeyFocused: Color get() = MembyAccent
private val KeyLabel = Color(0xFFE8EDF1)
private val KeyLabelFocused = Color(0xFF06240A)
private val Heading = Color(0xFFF2F5F7)
private val Muted = Color(0xFFB7C0C8)
private val KeyLabel: Color get() = MembyOnSurface
private val KeyLabelFocused: Color get() = MembyAccentInk
private val Heading: Color get() = MembyOnSurface
private val Muted: Color get() = MembyMutedText
private val Accent: Color get() = MembyAccent
/**
@@ -378,7 +383,7 @@ private fun QueryField(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(Color(0xFF151C23))
.background(MembySurfaceRaised)
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(10.dp))
.padding(horizontal = 12.dp, vertical = 9.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -922,7 +927,7 @@ private fun RequestOptions(
modifier = modifier
.fillMaxSize()
.clip(RoundedCornerShape(16.dp))
.background(Color(0xFF11171C))
.background(MembySurfaceRaised)
.border(1.dp, Color.White.copy(alpha = 0.08f), RoundedCornerShape(16.dp))
.padding(20.dp),
) {
@@ -1026,9 +1031,9 @@ private fun RequestCandidateCard(
.clip(RoundedCornerShape(12.dp))
.background(
when {
focused -> Color(0xFF203228)
candidate.alreadyAdded -> Color(0xFF17231C)
else -> Color(0xFF1A2127)
focused -> MembyAccentMuted
candidate.alreadyAdded -> MembyAccentMuted
else -> MembyControlSurface
},
)
.border(
@@ -1048,7 +1053,7 @@ private fun RequestCandidateCard(
.width(76.dp)
.fillMaxHeight()
.clip(RoundedCornerShape(8.dp))
.background(Color(0xFF0B0F12)),
.background(MembySurface),
contentAlignment = Alignment.Center,
) {
if (previewArtwork != null) {
@@ -34,8 +34,10 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Devices
@@ -57,56 +59,62 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
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.Icon
import androidx.tv.material3.Button
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.DEFAULT_SEEK_INTERVAL_SECONDS
import com.ponzischeme89.memby.data.DEFAULT_SKIP_INTRO_MODE
import com.ponzischeme89.memby.data.ImageCacheMaintenance
import com.ponzischeme89.memby.data.ImageCacheSize
import com.ponzischeme89.memby.data.formatCacheSize
import com.ponzischeme89.memby.data.DEFAULT_SKIP_INTRO_MODE
import com.ponzischeme89.memby.data.SEEK_INTERVAL_SECONDS
import com.ponzischeme89.memby.data.SKIP_INTRO_AUTO
import com.ponzischeme89.memby.data.SKIP_INTRO_OFF
import com.ponzischeme89.memby.data.SKIP_INTRO_PROMPT
import com.ponzischeme89.memby.data.Settings
import com.ponzischeme89.memby.data.parseThemeColor
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.data.formatCacheSize
import com.ponzischeme89.memby.data.model.GatewayDevice
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.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyAccentBright
import com.ponzischeme89.memby.ui.theme.MembyAccentInk
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
import com.ponzischeme89.memby.ui.theme.MembyControlSurfaceRaised
import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembySurface
import com.ponzischeme89.memby.update.UpdateChecker
import kotlinx.coroutines.delay
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
@@ -183,13 +191,13 @@ internal enum class SettingsPage(
// viewer's own choice now — a picker offering an orange scheme with a green selected chip
// would be showing them the wrong answer to the question they are being asked.
private val EmbyGreen: Color get() = MembyAccent
private val Canvas = Color(0xFF000000)
private val Panel = Color(0xFF040506)
private val RowFocused = Color(0xFF1B2228)
private val ControlIdle = Color(0xFF1E252B)
private val TextPrimary = Color(0xFFF2F5F7)
private val TextSecondary = Color(0xFFAFB8BF)
private val TextQuiet = Color(0xFF7C868E)
private val Canvas: Color get() = MembySurface
private val Panel: Color get() = MembySurface
private val RowFocused: Color get() = MembyControlSurface
private val ControlIdle: Color get() = MembyControlSurface
private val TextPrimary: Color get() = MembyOnSurface
private val TextSecondary: Color get() = MembyMutedText
private val TextQuiet: Color get() = MembyQuietText
private val Hairline = Color.White.copy(alpha = 0.08f)
private val RailEdge = Color.White.copy(alpha = 0.10f)
@@ -1499,7 +1507,7 @@ private fun StatusToggle(checked: Boolean) {
.width(46.dp)
.height(25.dp)
.clip(CircleShape)
.background(if (checked) EmbyGreen else Color(0xFF394149))
.background(if (checked) EmbyGreen else MembyControlSurfaceRaised)
.padding(3.dp),
) {
Box(
@@ -1507,7 +1515,7 @@ private fun StatusToggle(checked: Boolean) {
.align(if (checked) Alignment.CenterEnd else Alignment.CenterStart)
.size(19.dp)
.clip(CircleShape)
.background(if (checked) Color(0xFF072108) else Color(0xFFCBD2D7)),
.background(if (checked) MembyAccentInk else MembyOnSurface),
)
}
}
@@ -1648,7 +1656,7 @@ private fun SettingsChoiceChip(
}
Text(
option.label,
color = if (selected) Color(0xFF062307) else TextPrimary,
color = if (selected) MembyAccentInk else TextPrimary,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
)
@@ -1686,7 +1694,7 @@ private fun SettingsActionRow(
}
Text(
badge,
color = if (focused) Color(0xFF062307) else EmbyGreen,
color = if (focused) MembyAccentInk else EmbyGreen,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 0.7.sp,
@@ -1702,7 +1710,7 @@ private fun SettingsActionRow(
private fun SettingsNotice(text: String, positive: Boolean) {
Text(
text,
color = if (positive) Color(0xFF9DE29A) else Color(0xFFFF9B98),
color = if (positive) MembyAccentBright else Color(0xFFFF9B98),
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier
@@ -1802,7 +1810,7 @@ private fun ReleaseHistoryRow(
}
Text(
if (expanded) "HIDE" else "${release.changes.size} CHANGES",
color = if (focused) Color(0xFF062307) else EmbyGreen,
color = if (focused) MembyAccentInk else EmbyGreen,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 0.7.sp,
@@ -103,6 +103,72 @@ val MembyHairline: Color get() = activePalette.hairline
/** A quiet neutral capsule behind third-party ratings. */
val MembyRatingsSurface: Color get() = activePalette.ratingsSurface
// --- Derived colour ---------------------------------------------------------------------
//
// The eight slots above are what a theme *sends*. What the screens actually paint is a
// wider vocabulary than that — a lighter accent for a label, a near-black ink for text on
// an accent fill, three neutral steps for controls — and every one of those was a literal
// hex typed into a screen. Which is why picking a colour scheme visibly changed almost
// nothing: the palette reached the tokens and the tokens were not what most of the launcher
// was drawn with.
//
// They are *derived* rather than added to the wire on purpose. A theme sends the decisions
// (what is the surface, what is the accent) and the app works out the shades around them,
// so a scheme invented on the gateway tomorrow arrives complete rather than half-applied —
// which is the same reason the palette carries no radii.
/** Mixes [other] into this colour by [fraction] (0 = unchanged, 1 = entirely [other]). */
private fun Color.mix(other: Color, fraction: Float): Color {
val t = fraction.coerceIn(0f, 1f)
return Color(
red = red + (other.red - red) * t,
green = green + (other.green - green) * t,
blue = blue + (other.blue - blue) * t,
alpha = alpha,
)
}
private fun Color.lighten(fraction: Float): Color = mix(Color.White, fraction)
private fun Color.darken(fraction: Float): Color = mix(Color.Black, fraction)
/**
* The accent as a *label* a step brighter than the fill, for accent-coloured text and
* icons sitting directly on [MembySurface]. The fill colour used as type is a shade too
* heavy at small sizes on a TV panel.
*/
val MembyAccentBright: Color get() = activePalette.accent.lighten(0.22f)
/**
* Text and glyphs drawn *on* an accent fill: the accent's own hue taken almost to black, so
* a focused chip reads as one object rather than as white type on a coloured plate. Pure
* black would be the safe choice and is the flatter one.
*/
val MembyAccentInk: Color get() = activePalette.accent.darken(0.90f)
/** A quiet accent wash for a plate that is *selected* rather than focused. */
val MembyAccentMuted: Color get() = activePalette.surfaceRaised.mix(activePalette.accent, 0.20f)
/**
* The three neutral steps between [MembySurfaceRaised] and the text on it: an unfocused
* control, the same control one step up, and the hairline border around it. Named by their
* job rather than by their lightness, so a light theme could invert them without every
* screen that uses one reading backwards.
*/
val MembyControlSurface: Color get() = activePalette.surfaceRaised.lighten(0.06f)
val MembyControlSurfaceRaised: Color get() = activePalette.surfaceRaised.lighten(0.14f)
val MembyOutline: Color get() = activePalette.surfaceRaised.lighten(0.24f)
/** Copy on a control that cannot be pressed. Quiet text taken down, never grey-on-grey. */
val MembyDisabledText: Color get() = activePalette.quietText.darken(0.42f)
/**
* The wash a full-screen splash is drawn on: the surface with a breath of the accent in it,
* falling away to the surface itself. It is what makes the cold-start screen belong to the
* theme in force rather than being the one screen that is always Midnight green.
*/
val MembySplashTint: Color get() = activePalette.surface.mix(activePalette.accent, 0.12f)
// --- Shape ---------------------------------------------------------------------------
// Three steps, largest last. Anything that needs a radius picks the nearest one rather
// than inventing a fourth. Not themeable; see the note at the top of this file.
@@ -21,6 +21,33 @@ class WelcomeQuotesTest {
}
}
/**
* The point of the pools is that a household does not read the same line every fifth
* launch, and a pool that quietly shrank back or gained a duplicate on a copy-paste
* would look identical to one that had not. Counted rather than eyeballed for that
* reason; the floor is deliberately below what is written, so adding a tone does not
* mean matching the longest list exactly.
*/
@Test
fun `every tone offers a deep pool of distinct lines`() {
WelcomeQuoteStyle.entries.forEach { style ->
val seen = (0 until 4_000)
.map { randomWelcomeQuote(style.value, Random(it)) }
.toSet()
assertTrue(
"${style.value} offers only ${seen.size} distinct lines",
seen.size >= 20,
)
}
}
@Test
fun `the loading headline varies and always says something`() {
val seen = (0 until 1_000).map { randomLoadingHeadline(Random(it)) }.toSet()
assertTrue("only ${seen.size} distinct headlines", seen.size >= 5)
assertTrue(seen.none { it.isBlank() })
}
@Test
fun `login greeting trims the authenticated username`() {
assertTrue(