diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b622e0..dcb2989 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 0.2.41 — 2026-08-10 +- Added: Memby's own opening clip now plays while the app is starting up. +- Fixed: Colour schemes now repaint the whole app. The home screen, Settings, Search and the opening screen only followed a theme in part. +- Improved: A far wider range of welcome lines and opening messages, so the same one comes round much less often. +- Improved: The opening screen is no longer rebuilt as Memby moves between start-up steps. + ## 0.2.40 — 2026-08-10 - Improved: Video playback recovers more reliably from decoder failures and prolonged buffering. - Improved: Memby's short pre-roll is prepared in advance for faster playback starts. diff --git a/CLAUDE.md b/CLAUDE.md index f6ec78f..d0fa3f6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1192,6 +1192,31 @@ failure fallback; and the preroll player is stopped and parked at hand-off so it hardware decoder while HEVC content plays. Returning to Home prepares the same instance again in the next idle window rather than constructing one per title. +**The same clip plays behind the cold-start screen** (`ui/LaunchPreroll.kt`). It is the one +screen every launch shows and the clip was the one thing Memby owns that nobody ever saw +there. It borrows the *same* cached instance — no second decoder, no second copy of the +file — and hands it back on dispose, so the next playback still opens on a prepared player. +Things to preserve: it is **decoration and never a gate**, so every failure is silent and +leaves the pulsing-logo screen exactly as it was, and the logo only fades out on +`onRenderedFirstFrame` rather than on having asked the player to start; it is acquired +**after** `withFrameNanos`, because a cold start has nothing cached and constructing an +ExoPlayer inside the first composition of the screen that must appear immediately is the +cost the idle-handler prepare exists to avoid; it is muted and looped, where the pre-roll +before a programme is audible and must *end* — `PrerollPreloader.acquire`/`recycle` +normalise volume and repeat mode so a borrower cannot leave the next one wedged. And +`AppRoot` calls `MembyLoadingScreen` from **one** call site: the three states meaning "still +opening" were three, and Compose identifies a composable by where it is called from, so +moving between them disposed the screen and rebuilt it — which now means returning and +re-borrowing the player twice during the busiest stretch of a launch. + +**What it says while it is opening is `ui/WelcomeQuotes.kt`.** Twenty-five lines per tone +plus eight headlines, because this is the most-read copy in the app and five per tone meant +a household saw the same sentence roughly every fifth time they switched the set on. +`WelcomeQuotesTest` counts the distinct lines a pool yields: a pool that shrank back, or +gained a duplicate on a copy-paste, looks identical to one that did not. The headline is +kept apart from the quotes and is not keyed on the tone — it names what the app is *doing*, +and rerolling it when the settings flow arrives would change the line mid-launch. + **Next up / auto-advance.** 30 s before an episode ends, `PlayerActivity` slides up `player_next_up_banner.xml` and rolls into the next episode when it reaches zero (Settings → Playback turns it off; `Settings.autoPlayNextEpisode`). Which episode that is comes from @@ -1563,6 +1588,23 @@ and two secondary greys, which is visible the moment a detail page opens from a colour or radius belongs in the token file, or is a considered exception — not a fifth value. +**The eight slots a theme sends are not the vocabulary the screens paint with**, which is +why picking a colour scheme used to change almost nothing outside the detail pages. The +launcher, the cold-start screen, Settings, Search, the update and maintenance screens and +the two overlays were drawn with a hundred-odd literal hexes — a lighter green for a label, +a near-black ink for text on a green fill, three neutral steps for controls — none of which +the palette could reach. Those shades are now *derived* in `DesignTokens.kt` +(`MembyAccentBright`, `MembyAccentInk`, `MembyAccentMuted`, `MembyControlSurface`, +`MembyControlSurfaceRaised`, `MembyOutline`, `MembyDisabledText`, `MembySplashTint`). +Derived rather than added to the wire on purpose: a theme sends the *decisions* and the app +works out the shades around them, so a scheme invented on the gateway tomorrow arrives +complete rather than half-applied — the same reason the palette carries no radii. What +stays a literal is anything carrying meaning of its own: the red/amber/blue status colours, +the genre tiles, and the ratings providers' own brand colours. And a screen-local alias must +be `get()`, never `val` — `SettingsSheet`'s `Canvas`/`Panel`/`TextPrimary` were values, so +the settings page, which is where the theme is *chosen*, was the one screen that could never +repaint. + **Those colours are the server's answer, not constants.** Every token in `DesignTokens.kt` is now a `get()` over one process-wide `mutableStateOf(MembyPalette)`, and `applyMembyPalette` is what repaints the app. Two things follow and both are easy to undo: an alias must be a diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5a07e7e..62c1263 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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() diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt index a48889f..0b1b5b4 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt @@ -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, ), ), ), diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeMovieHero.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeMovieHero.kt index 4b6b1e5..060c2a8 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeMovieHero.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeMovieHero.kt @@ -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), ), ), ) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/LaunchPreroll.kt b/app/src/main/java/com/ponzischeme89/memby/ui/LaunchPreroll.kt new file mode 100644 index 0000000..e61bcff --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/LaunchPreroll.kt @@ -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(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 }, + ) +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt index 5635aae..8614aea 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt @@ -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, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MaintenanceScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MaintenanceScreen.kt index 3f029ef..cf0778f 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MaintenanceScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MaintenanceScreen.kt @@ -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, ) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MyShowsNotifications.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MyShowsNotifications.kt index 528fa87..078a04c 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MyShowsNotifications.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MyShowsNotifications.kt @@ -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) } } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/SeriesDetailsOverlay.kt b/app/src/main/java/com/ponzischeme89/memby/ui/SeriesDetailsOverlay.kt index d6434e0..a81d8af 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/SeriesDetailsOverlay.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/SeriesDetailsOverlay.kt @@ -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, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/UpdateScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/UpdateScreen.kt index 5a460fe..e100ce4 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/UpdateScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/UpdateScreen.kt @@ -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, ) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/WelcomeQuotes.kt b/app/src/main/java/com/ponzischeme89/memby/ui/WelcomeQuotes.kt index 2ea9713..57db2cd 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/WelcomeQuotes.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/WelcomeQuotes.kt @@ -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. You’ve 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, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/PrerollPreloader.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/PrerollPreloader.kt index 98980c1..7d333f9 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/PrerollPreloader.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/PrerollPreloader.kt @@ -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. */ diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt index b52b14d..1f8f8c2 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt @@ -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) { diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt b/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt index 3aa9d6a..9b35c1c 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt @@ -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, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/theme/DesignTokens.kt b/app/src/main/java/com/ponzischeme89/memby/ui/theme/DesignTokens.kt index 4cb674a..16a0947 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/theme/DesignTokens.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/theme/DesignTokens.kt @@ -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. diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/WelcomeQuotesTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/WelcomeQuotesTest.kt index b11c2ab..d21ce2a 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/WelcomeQuotesTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/WelcomeQuotesTest.kt @@ -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(