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
+6
View File
@@ -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 ## 0.2.40 — 2026-08-10
- Improved: Video playback recovers more reliably from decoder failures and prolonged buffering. - 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. - Improved: Memby's short pre-roll is prepared in advance for faster playback starts.
+42
View File
@@ -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 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. 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 **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 `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 → 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 colour or radius belongs in the token file, or is a considered exception — not a fifth
value. 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` **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 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 is what repaints the app. Two things follow and both are easy to undo: an alias must be a
+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 // 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. // source tree. Local builds keep using the checked-in default.
val defaultVersionName = "0.2.40" val defaultVersionName = "0.2.41"
val membyVersionName: String = val membyVersionName: String =
(project.findProperty("memby.versionName") as String?) (project.findProperty("memby.versionName") as String?)
?.trim() ?.trim()
@@ -126,9 +126,13 @@ import com.ponzischeme89.memby.ui.detail.dynamicRangeLabel
import com.ponzischeme89.memby.ui.detail.formatRuntime import com.ponzischeme89.memby.ui.detail.formatRuntime
import com.ponzischeme89.memby.ui.theme.FactSeparator import com.ponzischeme89.memby.ui.theme.FactSeparator
import com.ponzischeme89.memby.ui.theme.MembyAccent 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.MembyCardCorner
import com.ponzischeme89.memby.ui.theme.MembyChipCorner 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.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
import com.ponzischeme89.memby.ui.theme.MembyQuietText import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembySurface import com.ponzischeme89.memby.ui.theme.MembySurface
@@ -565,7 +569,7 @@ private fun UserSwitcherProfileItem(
Box( Box(
Modifier Modifier
.size(26.dp) .size(26.dp)
.background(if (current) EmbyGreen else Color(0xFF2B3035), CircleShape), .background(if (current) EmbyGreen else MembyControlSurfaceRaised, CircleShape),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Text( Text(
@@ -890,7 +894,7 @@ private fun SkeletonBlock(width: Dp, height: Dp) {
.width(width) .width(width)
.height(height) .height(height)
.clip(RoundedCornerShape(4.dp)) .clip(RoundedCornerShape(4.dp))
.background(Color(0xFF30363B)), .background(MembyControlSurfaceRaised),
) )
} }
@@ -1217,7 +1221,7 @@ private fun MetadataContent(
} }
Text( Text(
item.overview?.takeIf(String::isNotBlank) ?: "No description available.", item.overview?.takeIf(String::isNotBlank) ?: "No description available.",
color = Color(0xFFD0D4D7), color = MembyMutedText,
fontSize = 14.sp, fontSize = 14.sp,
lineHeight = 18.sp, lineHeight = 18.sp,
maxLines = if (compact) 2 else 3, maxLines = if (compact) 2 else 3,
@@ -1292,7 +1296,7 @@ private fun ScheduleMetadataContent(
} else { } else {
"Episode details will appear when they become available." "Episode details will appear when they become available."
}, },
color = Color(0xFFD0D4D7), color = MembyMutedText,
fontSize = 14.sp, fontSize = 14.sp,
lineHeight = 18.sp, lineHeight = 18.sp,
maxLines = if (compact) 2 else 3, 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) { fun MediaBadge(label: String, modifier: Modifier = Modifier) {
Text( Text(
label, label,
color = Color(0xFFE4E7E9), color = MembyOnSurface,
fontSize = 11.sp, fontSize = 11.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
modifier = modifier modifier = modifier
@@ -1461,7 +1465,7 @@ internal fun MediaRow(
Spacer(Modifier.width(HomeRowHeaderIconGap)) Spacer(Modifier.width(HomeRowHeaderIconGap))
Text( Text(
row.title, row.title,
color = Color(0xFFF1F3F4), color = MembyOnSurface,
fontSize = 20.sp, fontSize = 20.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
@@ -1657,7 +1661,7 @@ private fun CastCard(person: EmbyPerson, compact: Boolean) {
.fillMaxWidth() .fillMaxWidth()
.height(height) .height(height)
.clip(shape) .clip(shape)
.background(Color(0xFF24292E)) .background(MembyControlSurface)
.border( .border(
if (focused) 2.dp else 1.dp, if (focused) 2.dp else 1.dp,
if (focused) Color.White else Color.White.copy(alpha = 0.10f), if (focused) Color.White else Color.White.copy(alpha = 0.10f),
@@ -2105,7 +2109,7 @@ private fun MediaCard(
} }
Text( Text(
item.name, item.name,
color = if (focused) Color.White else Color(0xFFE1E5E8), color = if (focused) Color.White else MembyOnSurface,
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = if (focused) FontWeight.Bold else FontWeight.SemiBold, fontWeight = if (focused) FontWeight.Bold else FontWeight.SemiBold,
maxLines = 1, maxLines = 1,
@@ -2163,11 +2167,11 @@ private fun ScheduleStatusBadge(status: String, label: String, modifier: Modifie
"downloading" -> Color(0xFF5DA9FF) "downloading" -> Color(0xFF5DA9FF)
"awaiting" -> Color(0xFFFFB454) "awaiting" -> Color(0xFFFFB454)
"unmonitored" -> QuietText "unmonitored" -> QuietText
else -> Color(0xFFE1E5E8) else -> MembyOnSurface
} }
Text( Text(
text = label, text = label,
color = if (status == "available") Color(0xFF071008) else MembySurface, color = if (status == "available") MembyAccentInk else MembySurface,
fontSize = 9.sp, fontSize = 9.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
letterSpacing = 0.5.sp, 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 // 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. // made is green, over is red, not out yet is blue, in cinemas is amber.
val (background, foreground) = when (status) { val (background, foreground) = when (status) {
"continuing", "released" -> EmbyGreen to Color(0xFF071008) "continuing", "released" -> EmbyGreen to MembyAccentInk
"upcoming", "announced" -> Color(0xFF5DA9FF) to MembySurface "upcoming", "announced" -> Color(0xFF5DA9FF) to MembySurface
"incinemas" -> Color(0xFFFFB454) to MembySurface "incinemas" -> Color(0xFFFFB454) to MembySurface
"ended" -> Color(0xFFE04747) to Color.White "ended" -> Color(0xFFE04747) to Color.White
else -> Color(0xFF3A4249) to Color(0xFFE1E5E8) else -> MembyControlSurfaceRaised to MembyOnSurface
} }
Text( Text(
text = label, text = label,
@@ -2352,9 +2356,9 @@ private fun ArtworkLoadingSkeleton(modifier: Modifier = Modifier) {
modifier.background( modifier.background(
Brush.linearGradient( Brush.linearGradient(
colors = listOf( colors = listOf(
Color(0xFF20262B), MembyControlSurface,
Color(0xFF343B41), MembyControlSurfaceRaised,
Color(0xFF20262B), MembyControlSurface,
), ),
), ),
), ),
@@ -55,13 +55,14 @@ import com.ponzischeme89.memby.data.model.HomeRow
import com.ponzischeme89.memby.ui.detail.heroFacts import com.ponzischeme89.memby.ui.detail.heroFacts
import com.ponzischeme89.memby.ui.theme.FactSeparator import com.ponzischeme89.memby.ui.theme.FactSeparator
import com.ponzischeme89.memby.ui.theme.MembyAccent 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.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
import com.ponzischeme89.memby.ui.theme.MembyQuietText import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembySurface import com.ponzischeme89.memby.ui.theme.MembySurface
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
import kotlinx.coroutines.delay
import java.util.TimeZone import java.util.TimeZone
import kotlinx.coroutines.delay
internal const val HOME_HERO_ROW_ID = "home-movie-hero" internal const val HOME_HERO_ROW_ID = "home-movie-hero"
@@ -590,7 +591,7 @@ private fun HeroArtwork(item: BaseItem, previewArtwork: ImageBitmap?, modifier:
Box( Box(
modifier.background( modifier.background(
Brush.linearGradient( 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.seasonal.SeasonalDecorations
import com.ponzischeme89.memby.ui.whatsnew.whatsNewDecision import com.ponzischeme89.memby.ui.whatsnew.whatsNewDecision
import com.ponzischeme89.memby.ui.theme.MembyAccent import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyAccentBright
import com.ponzischeme89.memby.ui.theme.MembyAccentInk
import com.ponzischeme89.memby.ui.theme.MembyAccentMuted
import com.ponzischeme89.memby.ui.theme.MembyChipCorner 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.MembySurface
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
import com.ponzischeme89.memby.ui.theme.MembyTheme import com.ponzischeme89.memby.ui.theme.MembyTheme
import com.ponzischeme89.memby.ui.setup.SignInContent import com.ponzischeme89.memby.ui.setup.SignInContent
import com.ponzischeme89.memby.update.InstallPermission import com.ponzischeme89.memby.update.InstallPermission
@@ -378,10 +390,27 @@ private fun AppRoot(onCloseSettings: () -> Unit) {
Box(Modifier.fillMaxSize().background(MembySurface)) { Box(Modifier.fillMaxSize().background(MembySurface)) {
val loaded = settings 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 { when {
!initialUpdateCheckComplete -> MembyLoadingScreen( openingQuoteStyle != null -> Unit
quoteStyle = loaded?.welcomeQuoteStyle,
)
appUpdate != null -> UpdateScreen( appUpdate != null -> UpdateScreen(
update = appUpdate!!, update = appUpdate!!,
onDismiss = { onDismiss = {
@@ -392,15 +421,11 @@ private fun AppRoot(onCloseSettings: () -> Unit) {
} }
}, },
) )
loaded == null -> MembyLoadingScreen( loaded == null -> Unit
quoteStyle = ServiceLocator.settings.current?.welcomeQuoteStyle,
)
addingProfile -> SetupScreen( addingProfile -> SetupScreen(
onCancel = { addingProfile = false }, onCancel = { addingProfile = false },
onSignedIn = { addingProfile = false }, onSignedIn = { addingProfile = false },
) )
loaded.isSignedIn && recommendationOnboarding == null ->
MembyLoadingScreen(quoteStyle = loaded.welcomeQuoteStyle)
loaded.isSignedIn && recommendationOnboarding?.completed == false && loaded.isSignedIn && recommendationOnboarding?.completed == false &&
recommendationOnboarding?.prompted == true -> { recommendationOnboarding?.prompted == true -> {
RecommendationOnboardingScreen( RecommendationOnboardingScreen(
@@ -496,7 +521,7 @@ private fun ExitMembyConfirmation(
Column( Column(
modifier = Modifier modifier = Modifier
.width(480.dp) .width(480.dp)
.background(Color(0xFF20262B), RoundedCornerShape(18.dp)) .background(MembyControlSurface, RoundedCornerShape(18.dp))
.padding(32.dp), .padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(18.dp), verticalArrangement = Arrangement.spacedBy(18.dp),
@@ -509,7 +534,7 @@ private fun ExitMembyConfirmation(
) )
Text( Text(
"Choose Stay to keep browsing, or close Memby and return to your TV.", "Choose Stay to keep browsing, or close Memby and return to your TV.",
color = Color(0xFFBCC4CA), color = MembyMutedText,
fontSize = 16.sp, fontSize = 16.sp,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
@@ -541,9 +566,23 @@ private fun ExitMembyConfirmation(
} }
} }
@androidx.media3.common.util.UnstableApi
@Composable @Composable
private fun MembyLoadingScreen(quoteStyle: String? = null) { private fun MembyLoadingScreen(quoteStyle: String? = null) {
val welcomeQuote = remember(quoteStyle) { randomWelcomeQuote(quoteStyle) } 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 transition = rememberInfiniteTransition(label = "cold-start")
val pulse by transition.animateFloat( val pulse by transition.animateFloat(
initialValue = 0.96f, initialValue = 0.96f,
@@ -569,16 +608,40 @@ private fun MembyLoadingScreen(quoteStyle: String? = null) {
.fillMaxSize() .fillMaxSize()
.background( .background(
Brush.radialGradient( Brush.radialGradient(
colors = listOf(Color(0xFF16231E), Color(0xFF090C0F)), colors = listOf(MembySplashTint, MembySurface),
radius = 1_100f, radius = 1_100f,
), ),
), ),
contentAlignment = Alignment.Center, 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( Column(
verticalArrangement = Arrangement.spacedBy(14.dp), verticalArrangement = Arrangement.spacedBy(14.dp),
horizontalAlignment = Alignment.CenterHorizontally, 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( Image(
painter = painterResource(R.drawable.emby_logo), painter = painterResource(R.drawable.emby_logo),
contentDescription = "Memby", contentDescription = "Memby",
@@ -588,11 +651,11 @@ private fun MembyLoadingScreen(quoteStyle: String? = null) {
.graphicsLayer { .graphicsLayer {
scaleX = pulse scaleX = pulse
scaleY = pulse scaleY = pulse
alpha = 0.82f + (glow * 0.18f) alpha = (0.82f + (glow * 0.18f)) * (1f - prerollAlpha)
}, },
) )
Text( Text(
"Opening Memby…", headline,
color = Color.White.copy(alpha = 0.88f), color = Color.White.copy(alpha = 0.88f),
fontSize = 17.sp, fontSize = 17.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
@@ -678,8 +741,8 @@ internal fun FirstRunScreen(onGetStarted: () -> Unit) {
.background( .background(
Brush.linearGradient( Brush.linearGradient(
colors = listOf( colors = listOf(
Color(0xFF0A0E11), MembySurface,
Color(0xFF101A17), MembySplashTint,
MembySurface, MembySurface,
), ),
), ),
@@ -714,7 +777,7 @@ internal fun FirstRunScreen(onGetStarted: () -> Unit) {
) { ) {
Text( Text(
"MEMBY", "MEMBY",
color = Color(0xFF69CD61), color = MembyAccentBright,
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
letterSpacing = 2.sp, letterSpacing = 2.sp,
@@ -730,7 +793,7 @@ internal fun FirstRunScreen(onGetStarted: () -> Unit) {
) )
Text( Text(
"Enter your username & password on the next screen. Ensure to allow permissions for app updates.", "Enter your username & password on the next screen. Ensure to allow permissions for app updates.",
color = Color(0xFFB8C0C6), color = MembyMutedText,
fontSize = 20.sp, fontSize = 20.sp,
lineHeight = 29.sp, lineHeight = 29.sp,
) )
@@ -957,7 +1020,7 @@ private fun ProfileChooser(
switchingProfileId != null -> "Switching profile…" switchingProfileId != null -> "Switching profile…"
else -> "Choose a profile to continue" else -> "Choose a profile to continue"
}, },
color = Color(0xFFAAB1B7), color = MembyQuietText,
fontSize = 17.sp, fontSize = 17.sp,
modifier = Modifier.padding(top = 8.dp, bottom = 30.dp), modifier = Modifier.padding(top = 8.dp, bottom = 30.dp),
) )
@@ -1129,7 +1192,7 @@ private fun RecommendationOnboardingScreen(
.fillMaxSize() .fillMaxSize()
.background( .background(
Brush.radialGradient( Brush.radialGradient(
listOf(Color(0xFF193423), Color(0xFF0C1212), Color(0xFF07090B)), listOf(MembySplashTint, MembySurfaceRaised, MembySurface),
center = Offset(260f, 180f), center = Offset(260f, 180f),
radius = 1_500f, radius = 1_500f,
), ),
@@ -1142,7 +1205,7 @@ private fun RecommendationOnboardingScreen(
) { ) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) { 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) Text("Make Memby yours", color = Color.White, fontSize = 34.sp, fontWeight = FontWeight.Bold)
} }
Row(horizontalArrangement = Arrangement.spacedBy(7.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(7.dp)) {
@@ -1152,7 +1215,7 @@ private fun RecommendationOnboardingScreen(
.width(if (index == stageIndex) 34.dp else 12.dp) .width(if (index == stageIndex) 34.dp else 12.dp)
.height(5.dp) .height(5.dp)
.clip(CircleShape) .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( Text(
"Select as many as you like, or skip this step. Every choice gives your recommendations a stronger starting point.", "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)) Spacer(Modifier.height(8.dp))
if (stage == null) { if (stage == null) {
Spacer(Modifier.weight(1f)) 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)) Spacer(Modifier.weight(1f))
} else { } else {
when (stage) { when (stage) {
@@ -1202,7 +1265,7 @@ private fun RecommendationOnboardingScreen(
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text( Text(
"${ratings.count { it.value >= 4 } + selectedActors.count { it.value } + selectedActresses.count { it.value } + selectedDirectors.count { it.value }} selected", "${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)) } error?.let { Text(it, color = Color(0xFFFF7777), fontSize = 14.sp, modifier = Modifier.padding(end = 16.dp)) }
if (stageIndex > 0) { if (stageIndex > 0) {
@@ -1271,7 +1334,7 @@ private fun OnboardingSkipConfirmation(
Column( Column(
modifier = Modifier modifier = Modifier
.width(480.dp) .width(480.dp)
.background(Color(0xFF20262B), RoundedCornerShape(18.dp)) .background(MembyControlSurface, RoundedCornerShape(18.dp))
.padding(32.dp), .padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(18.dp), verticalArrangement = Arrangement.spacedBy(18.dp),
@@ -1284,7 +1347,7 @@ private fun OnboardingSkipConfirmation(
) )
Text( Text(
"You can start watching now. Memby may offer these choices again later.", "You can start watching now. Memby may offer these choices again later.",
color = Color(0xFFBCC4CA), color = MembyMutedText,
fontSize = 16.sp, fontSize = 16.sp,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
@@ -1342,18 +1405,18 @@ private fun OnboardingTitleRow(
Modifier Modifier
.width(148.dp).height(222.dp) .width(148.dp).height(222.dp)
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
.background(Color(0xFF20272C)) .background(MembyControlSurface)
.border(if (focused || selected) 3.dp else 1.dp, if (selected) Color(0xFF63C85B) else if (focused) Color.White else Color(0xFF465058), RoundedCornerShape(12.dp)), .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) { if (previewArtwork != null) {
Image(bitmap = previewArtwork, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) Image(bitmap = previewArtwork, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize())
} else { } else {
AsyncImage(model = repo.primaryUrl(item, 320), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) 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(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 -> ) { focused ->
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) { Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) {
Box( Box(
Modifier.size(148.dp).clip(CircleShape).background(Color(0xFF25322C)) Modifier.size(148.dp).clip(CircleShape).background(MembyAccentMuted)
.border(if (focused || selected) 4.dp else 1.dp, if (selected) Color(0xFF63C85B) else if (focused) Color.White else Color(0xFF465058), CircleShape), .border(if (focused || selected) 4.dp else 1.dp, if (selected) MembyAccentBright else if (focused) Color.White else MembyOutline, CircleShape),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
if (previewArtwork != null) { if (previewArtwork != null) {
@@ -1392,7 +1455,7 @@ private fun OnboardingPeopleRow(
} else if (person.id.isNotBlank() && person.imageTag.isNotBlank()) { } 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()) AsyncImage(model = repo.posterUrl(person.id, person.imageTag, 280), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize())
} else { } 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)) 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)) .clip(RoundedCornerShape(9.dp))
.background( .background(
when { when {
!enabled -> Color(0xFF343A3F) !enabled -> MembyControlSurfaceRaised
focused -> Color(0xFFE34B4B) focused -> Color(0xFFE34B4B)
else -> Color(0xE61A1D20) else -> MembyControlSurface.copy(alpha = 0.90f)
}, },
) )
.border( .border(
@@ -1484,7 +1547,7 @@ private fun ProfileDeleteButton(
) { ) {
Text( Text(
text = "Remove user", text = "Remove user",
color = if (enabled) Color.White else Color(0xFF899198), color = if (enabled) Color.White else MembyDisabledText,
fontSize = 12.sp, fontSize = 12.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
) )
@@ -1511,7 +1574,7 @@ private fun ProfileRemovalConfirmation(
Column( Column(
modifier = Modifier modifier = Modifier
.width(460.dp) .width(460.dp)
.background(Color(0xFF20262B), RoundedCornerShape(18.dp)) .background(MembyControlSurface, RoundedCornerShape(18.dp))
.padding(32.dp), .padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(18.dp), verticalArrangement = Arrangement.spacedBy(18.dp),
@@ -1524,7 +1587,7 @@ private fun ProfileRemovalConfirmation(
) )
Text( Text(
"Their saved sign-in will be forgotten on this device.", "Their saved sign-in will be forgotten on this device.",
color = Color(0xFFBCC4CA), color = MembyMutedText,
fontSize = 16.sp, fontSize = 16.sp,
) )
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
@@ -1575,10 +1638,10 @@ private fun ProfileTile(
modifier = Modifier modifier = Modifier
.size(112.dp) .size(112.dp)
.clip(CircleShape) .clip(CircleShape)
.background(if (focused) Color(0xFF5BC653) else Color(0xFF30373D)) .background(if (focused) MembyAccent else MembyControlSurfaceRaised)
.border( .border(
width = if (current) 4.dp else if (focused) 3.dp else 1.dp, 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, shape = CircleShape,
), ),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
@@ -1587,7 +1650,7 @@ private fun ProfileTile(
} }
Text( Text(
name, name,
color = if (focused) Color.White else Color(0xFFD0D4D7), color = if (focused) Color.White else MembyMutedText,
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = if (focused || current) FontWeight.SemiBold else FontWeight.Medium, fontWeight = if (focused || current) FontWeight.SemiBold else FontWeight.Medium,
maxLines = 2, maxLines = 2,
@@ -1595,7 +1658,7 @@ private fun ProfileTile(
modifier = Modifier.padding(top = 12.dp), modifier = Modifier.padding(top = 12.dp),
) )
if (current) { 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 // The gateway's maintenance notice when it sent one, and the
// generic wording otherwise. // generic wording otherwise.
homeStatus.statusMessage ?: "Connection is slow — showing available content", homeStatus.statusMessage ?: "Connection is slow — showing available content",
color = Color(0xFFD1D5D8), color = MembyMutedText,
fontSize = 14.sp, fontSize = 14.sp,
modifier = Modifier.padding(horizontal = 36.dp, vertical = 4.dp), 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", label = "playback-logo-rotation",
) )
Box( Box(
modifier = modifier.background(Color(0xEE050708)), modifier = modifier.background(MembySurface.copy(alpha = 0.93f)),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Column( Column(
@@ -2931,7 +2994,7 @@ private fun PlaybackLaunchOverlay(item: BaseItem, modifier: Modifier = Modifier)
) )
Text( Text(
"Preparing direct playback…", "Preparing direct playback…",
color = Color(0xFF9FA7AD), color = MembyQuietText,
fontSize = 14.sp, fontSize = 14.sp,
) )
} }
@@ -2958,14 +3021,14 @@ private fun RecentSearchesRow(
Spacer(Modifier.width(HomeRowHeaderIconGap)) Spacer(Modifier.width(HomeRowHeaderIconGap))
Text( Text(
"Recent searches", "Recent searches",
color = Color(0xFFF1F3F4), color = MembyOnSurface,
fontSize = 20.sp, fontSize = 20.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
Spacer(Modifier.width(10.dp)) Spacer(Modifier.width(10.dp))
Text( Text(
"LAST 30 DAYS", "LAST 30 DAYS",
color = Color(0xFFAEB7BF), color = MembyQuietText,
fontSize = 10.sp, fontSize = 10.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
@@ -2996,13 +3059,13 @@ private fun RecentSearchesRow(
) { focused -> ) { focused ->
Text( Text(
query, query,
color = if (focused) Color(0xFF06240A) else Color(0xFFE8EDF1), color = if (focused) MembyAccentInk else MembyOnSurface,
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
maxLines = 1, maxLines = 1,
modifier = Modifier modifier = Modifier
.background( .background(
if (focused) MembyAccent else Color(0xFF1A2129), if (focused) MembyAccent else MembyControlSurface,
) )
.padding(horizontal = 17.dp, vertical = 10.dp), .padding(horizontal = 17.dp, vertical = 10.dp),
) )
@@ -3049,7 +3112,7 @@ private fun HomeClock(
Row( Row(
modifier = Modifier modifier = Modifier
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.background(Color(0xB30A0D10)) .background(MembySurface.copy(alpha = 0.70f))
.padding(horizontal = 12.dp, vertical = 7.dp), .padding(horizontal = 12.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
@@ -3080,7 +3143,7 @@ private fun HomeClock(
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
modifier = Modifier modifier = Modifier
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.background(Color(0xB30A0D10)) .background(MembySurface.copy(alpha = 0.70f))
.padding(horizontal = 12.dp, vertical = 7.dp), .padding(horizontal = 12.dp, vertical = 7.dp),
) )
} }
@@ -3572,7 +3635,7 @@ private fun ForYouTimeBudget(
) { ) {
Text( Text(
"How much time do you have?", "How much time do you have?",
color = Color(0xFFD0D6DB), color = MembyMutedText,
fontSize = 15.sp, fontSize = 15.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
) )
@@ -3611,9 +3674,9 @@ private fun ForYouNudgeBanner(
.height(92.dp) .height(92.dp)
.background( .background(
Brush.horizontalGradient( Brush.horizontalGradient(
0f to Color(0xFF0D1512), 0f to MembySplashTint,
0.62f to Color(0xF20D1512), 0.62f to MembySplashTint.copy(alpha = 0.95f),
1f to Color(0xE0121A20), 1f to MembySurfaceRaised.copy(alpha = 0.88f),
), ),
) )
// Keep copy inside television overscan and clear of the collapsed rail. // Keep copy inside television overscan and clear of the collapsed rail.
@@ -3630,7 +3693,7 @@ private fun ForYouNudgeBanner(
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text( Text(
"FOR YOU", "FOR YOU",
color = Color(0xFF79D672), color = MembyAccentBright,
fontSize = 13.sp, fontSize = 13.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
letterSpacing = 1.4.sp, letterSpacing = 1.4.sp,
@@ -3641,7 +3704,7 @@ private fun ForYouNudgeBanner(
} else { } else {
"$name, your personalised picks are ready" "$name, your personalised picks are ready"
}, },
color = Color(0xFFF3F6F4), color = MembyOnSurface,
fontSize = 21.sp, fontSize = 21.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
maxLines = 1, maxLines = 1,
@@ -3649,7 +3712,7 @@ private fun ForYouNudgeBanner(
) )
Text( Text(
"Open For You from the menu and choose how much time you have.", "Open For You from the menu and choose how much time you have.",
color = Color(0xFFC7D0CB), color = MembyMutedText,
fontSize = 15.sp, fontSize = 15.sp,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
@@ -3690,7 +3753,7 @@ internal fun personalisedFavouritesTitle(username: String?): String {
private fun HeaderAction(label: String, onClick: () -> Unit) { private fun HeaderAction(label: String, onClick: () -> Unit) {
var focused by remember { mutableStateOf(false) } var focused by remember { mutableStateOf(false) }
val textColor by animateColorAsState( val textColor by animateColorAsState(
targetValue = if (focused) Color.White else Color(0xFFB8BEC3), targetValue = if (focused) Color.White else MembyMutedText,
animationSpec = tween(100), animationSpec = tween(100),
label = "header-action-text", 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) val imageUrl = repo.backdropUrl(item, maxWidth = 640) ?: repo.primaryUrl(item)
Card(onClick = onClick, modifier = Modifier.width(320.dp)) { Card(onClick = onClick, modifier = Modifier.width(320.dp)) {
Column { 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) { if (imageUrl != null) {
AsyncImage( AsyncImage(
model = imageUrl, model = imageUrl,
@@ -3760,12 +3823,12 @@ internal fun TvTextField(
) { ) {
var focused by remember { mutableStateOf(false) } var focused by remember { mutableStateOf(false) }
val borderColor by animateColorAsState( val borderColor by animateColorAsState(
targetValue = if (focused) Color(0xFF69CD61) else Color(0xFF3A424B), targetValue = if (focused) MembyAccentBright else MembyOutline,
animationSpec = tween(120), animationSpec = tween(120),
label = "login-field-border", label = "login-field-border",
) )
val backgroundColor by animateColorAsState( val backgroundColor by animateColorAsState(
targetValue = if (focused) Color(0xFF243129) else Color(0xFF161B21), targetValue = if (focused) MembyAccentMuted else MembySurfaceRaised,
animationSpec = tween(120), animationSpec = tween(120),
label = "login-field-background", label = "login-field-background",
) )
@@ -3775,7 +3838,7 @@ internal fun TvTextField(
) { ) {
Text( Text(
text = if (focused) "$label — selected" else label, text = if (focused) "$label — selected" else label,
color = if (focused) Color(0xFF8DE185) else Color(0xFF9AA3AC), color = if (focused) MembyAccentBright else MembyQuietText,
fontSize = 15.sp, fontSize = 15.sp,
fontWeight = if (focused) FontWeight.Bold else FontWeight.Medium, fontWeight = if (focused) FontWeight.Bold else FontWeight.Medium,
) )
@@ -3791,7 +3854,7 @@ internal fun TvTextField(
onValueChange = onValueChange, onValueChange = onValueChange,
singleLine = true, singleLine = true,
textStyle = TextStyle(color = Color.White, fontSize = 20.sp), textStyle = TextStyle(color = Color.White, fontSize = 20.sp),
cursorBrush = SolidColor(Color(0xFF69CD61)), cursorBrush = SolidColor(MembyAccentBright),
visualTransformation = if (isPassword) PasswordVisualTransformation() else VisualTransformation.None, visualTransformation = if (isPassword) PasswordVisualTransformation() else VisualTransformation.None,
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
keyboardType = if (isPassword) KeyboardType.Password else keyboardType, 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.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -54,13 +54,21 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.tv.material3.Icon import androidx.tv.material3.Icon
import androidx.tv.material3.Text import androidx.tv.material3.Text
import kotlinx.coroutines.delay
import com.ponzischeme89.memby.ui.theme.MembyAccent 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 MaintenanceAccent: Color get() = MembyAccent
private val MaintenanceTitle = Color(0xFFF2F5F7) private val MaintenanceTitle: Color get() = MembyOnSurface
private val MaintenanceBody = Color(0xFFAEB7BF) private val MaintenanceBody: Color get() = MembyMutedText
private val MaintenanceFaint = Color(0xFFA2ADB5) private val MaintenanceFaint: Color get() = MembyQuietText
/** How long between automatic retries while the gateway is down. */ /** How long between automatic retries while the gateway is down. */
private const val RETRY_SECONDS = 30 private const val RETRY_SECONDS = 30
@@ -217,7 +225,7 @@ private fun MaintenanceBackdrop(glow: Float) {
Canvas(Modifier.fillMaxSize()) { Canvas(Modifier.fillMaxSize()) {
drawRect( drawRect(
brush = Brush.verticalGradient( brush = Brush.verticalGradient(
listOf(Color(0xFF0B0F14), Color(0xFF121A22), Color(0xFF0A0D11)), listOf(MembySurface, MembySplashTint, MembySurface),
), ),
) )
val centre = Offset( val centre = Offset(
@@ -273,7 +281,7 @@ private fun PulsingEmblem(pulse: Float, gearRotation: Float) {
modifier = Modifier modifier = Modifier
.size(96.dp) .size(96.dp)
.clip(CircleShape) .clip(CircleShape)
.background(Color(0xFF16202A)) .background(MembySurfaceRaised)
.border(1.dp, MaintenanceAccent.copy(alpha = 0.35f), CircleShape), .border(1.dp, MaintenanceAccent.copy(alpha = 0.35f), CircleShape),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
@@ -333,7 +341,7 @@ private fun RetryButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
modifier = modifier modifier = modifier
.graphicsLayer { scaleX = scale; scaleY = scale } .graphicsLayer { scaleX = scale; scaleY = scale }
.clip(RoundedCornerShape(10.dp)) .clip(RoundedCornerShape(10.dp))
.background(if (focused) MaintenanceAccent else Color(0xFF1E2833)) .background(if (focused) MaintenanceAccent else MembyControlSurface)
.border( .border(
width = if (focused) 0.dp else 1.dp, width = if (focused) 0.dp else 1.dp,
color = Color.White.copy(alpha = 0.12f), color = Color.White.copy(alpha = 0.12f),
@@ -346,7 +354,7 @@ private fun RetryButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
) { ) {
Text( Text(
"Try again", "Try again",
color = if (focused) Color(0xFF06240A) else MaintenanceTitle, color = if (focused) MembyAccentInk else MaintenanceTitle,
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
) )
@@ -40,10 +40,13 @@ import androidx.tv.material3.Text
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.ponzischeme89.memby.data.EmbyRepository import com.ponzischeme89.memby.data.EmbyRepository
import com.ponzischeme89.memby.data.model.MyShow 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.Instant
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
@Composable @Composable
internal fun MyShowsStrip( internal fun MyShowsStrip(
@@ -68,14 +71,14 @@ internal fun MyShowsStrip(
Spacer(Modifier.width(HomeRowHeaderIconGap)) Spacer(Modifier.width(HomeRowHeaderIconGap))
Text( Text(
"My Shows", "My Shows",
color = Color(0xFFF1F3F4), color = MembyOnSurface,
fontSize = 20.sp, fontSize = 20.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
if (shows.isNotEmpty()) { if (shows.isNotEmpty()) {
Text( Text(
shows.size.toString(), shows.size.toString(),
color = Color(0xFFAEB7BF), color = MembyQuietText,
fontSize = 12.sp, fontSize = 12.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
modifier = Modifier modifier = Modifier
@@ -89,7 +92,7 @@ internal fun MyShowsStrip(
if (shows.isEmpty()) { if (shows.isEmpty()) {
Text( Text(
"Open any series and choose “Add to My Shows”.", "Open any series and choose “Add to My Shows”.",
color = Color(0xFFAEB7BF), color = MembyQuietText,
fontSize = 14.sp, fontSize = 14.sp,
modifier = Modifier.padding(horizontal = 36.dp, vertical = 18.dp), modifier = Modifier.padding(horizontal = 36.dp, vertical = 18.dp),
) )
@@ -172,7 +175,7 @@ private fun MyShowCard(
} }
Text( Text(
show.title, show.title,
color = if (focused) Color.White else Color(0xFFE1E5E8), color = if (focused) Color.White else MembyOnSurface,
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = if (focused) FontWeight.Bold else FontWeight.SemiBold, fontWeight = if (focused) FontWeight.Bold else FontWeight.SemiBold,
maxLines = 1, maxLines = 1,
@@ -181,7 +184,7 @@ private fun MyShowCard(
) )
Text( Text(
myShowCardSubtitle(show), myShowCardSubtitle(show),
color = Color(0xFFAEB7BF), color = MembyQuietText,
fontSize = 12.sp, fontSize = 12.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
maxLines = 1, maxLines = 1,
@@ -234,13 +237,13 @@ internal fun MyShowDetailsOverlay(
onClose: () -> Unit, onClose: () -> Unit,
) { ) {
Box( Box(
Modifier.fillMaxSize().zIndex(8f).background(Color(0xF5090B0D)), Modifier.fillMaxSize().zIndex(8f).background(MembySurface.copy(alpha = 0.96f)),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth(0.72f) .fillMaxWidth(0.72f)
.background(Color(0xFF151A1E), RoundedCornerShape(20.dp)) .background(MembySurfaceRaised, RoundedCornerShape(20.dp))
.padding(34.dp), .padding(34.dp),
horizontalArrangement = Arrangement.spacedBy(30.dp), horizontalArrangement = Arrangement.spacedBy(30.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -275,7 +278,7 @@ internal fun MyShowDetailsOverlay(
@Composable @Composable
private fun StatusLine(label: String, value: String) { private fun StatusLine(label: String, value: String) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { 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) 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.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed 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.seriesEpisodeComparator
import com.ponzischeme89.memby.ui.detail.technicalSpecs import com.ponzischeme89.memby.ui.detail.technicalSpecs
import com.ponzischeme89.memby.ui.detail.unwatchedCount 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 import java.util.TimeZone
/** /**
@@ -550,7 +554,7 @@ internal fun EpisodeCard(
.clip(shape) .clip(shape)
.background( .background(
when { when {
focused -> Color(0xFF23282C) focused -> MembyControlSurface
isCurrent -> DetailAccent.copy(alpha = 0.10f) isCurrent -> DetailAccent.copy(alpha = 0.10f)
else -> Color.White.copy(alpha = 0.035f) else -> Color.White.copy(alpha = 0.035f)
}, },
@@ -577,7 +581,7 @@ internal fun EpisodeCard(
.width(208.dp) .width(208.dp)
.fillMaxHeight() .fillMaxHeight()
.clip(RoundedCornerShape(6.dp)) .clip(RoundedCornerShape(6.dp))
.background(Color(0xFF15181C)), .background(MembySurfaceRaised),
) { ) {
AsyncImage( AsyncImage(
model = imageUrl, model = imageUrl,
@@ -645,7 +649,7 @@ internal fun EpisodeCard(
episode.indexNumber?.let { episode.indexNumber?.let {
Text( Text(
text = "$it.", text = "$it.",
color = if (focused) Color(0xFFB7C0C6) else DetailQuietText, color = if (focused) MembyMutedText else DetailQuietText,
fontSize = 15.sp, fontSize = 15.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
modifier = Modifier.padding(end = 7.dp), modifier = Modifier.padding(end = 7.dp),
@@ -677,7 +681,7 @@ internal fun EpisodeCard(
formatAirDate(episode.premiereDate)?.let { formatAirDate(episode.premiereDate)?.let {
Text( Text(
text = it, text = it,
color = if (focused) Color(0xFFB7C0C6) else DetailQuietText, color = if (focused) MembyMutedText else DetailQuietText,
fontSize = 12.sp, fontSize = 12.sp,
maxLines = 1, maxLines = 1,
modifier = Modifier.padding(start = 12.dp), modifier = Modifier.padding(start = 12.dp),
@@ -686,7 +690,7 @@ internal fun EpisodeCard(
episode.runtimeMinutes?.let { episode.runtimeMinutes?.let {
Text( Text(
text = "$it min", text = "$it min",
color = if (focused) Color(0xFFE4E8EA) else DetailText, color = if (focused) MembyOnSurface else DetailText,
fontSize = 13.sp, fontSize = 13.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
modifier = Modifier modifier = Modifier
@@ -703,7 +707,7 @@ internal fun EpisodeCard(
Spacer(Modifier.height(7.dp)) Spacer(Modifier.height(7.dp))
Text( Text(
text = episode.overview?.takeIf(String::isNotBlank) ?: "No plot summary available.", 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, fontSize = 13.sp,
lineHeight = 18.sp, lineHeight = 18.sp,
maxLines = 3, maxLines = 3,
@@ -61,17 +61,24 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.tv.material3.Icon import androidx.tv.material3.Icon
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.ponzischeme89.memby.data.model.GatewayUpdate 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.AppInstall
import com.ponzischeme89.memby.update.InstallPermissionRequiredException import com.ponzischeme89.memby.update.InstallPermissionRequiredException
import com.ponzischeme89.memby.update.UpdateChecker import com.ponzischeme89.memby.update.UpdateChecker
import kotlinx.coroutines.launch 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 UpdateAccent: Color get() = MembyAccent
private val UpdateTitle = Color(0xFFF2F5F7) private val UpdateTitle: Color get() = MembyOnSurface
private val UpdateBody = Color(0xFFAEB7BF) private val UpdateBody: Color get() = MembyMutedText
private val UpdateFaint = Color(0xFFA2ADB5) private val UpdateFaint: Color get() = MembyQuietText
/** /**
* The app-level update gate. AppRoot composes this instead of login, profiles, or Home, * The app-level update gate. AppRoot composes this instead of login, profiles, or Home,
@@ -209,7 +216,7 @@ fun UpdateScreen(
.size(92.dp) .size(92.dp)
.graphicsLayer { translationY = -6.dp.toPx() * bob } .graphicsLayer { translationY = -6.dp.toPx() * bob }
.clip(CircleShape) .clip(CircleShape)
.background(Color(0xFF16202A)) .background(MembySurfaceRaised)
.border(1.dp, UpdateAccent.copy(alpha = 0.4f), CircleShape), .border(1.dp, UpdateAccent.copy(alpha = 0.4f), CircleShape),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
@@ -339,9 +346,9 @@ internal fun UpdateButton(
.background( .background(
when { when {
focused && primary -> UpdateAccent focused && primary -> UpdateAccent
focused -> Color(0xFF2A343F) focused -> MembyControlSurfaceRaised
primary -> Color(0xFF2C6A29) primary -> MembyAccent.copy(alpha = 0.55f)
else -> Color(0xFF1E2833) else -> MembyControlSurface
}, },
) )
.border( .border(
@@ -375,7 +382,7 @@ internal fun UpdateButton(
) { ) {
Text( Text(
label, label,
color = if (focused && primary) Color(0xFF06240A) else UpdateTitle, color = if (focused && primary) MembyAccentInk else UpdateTitle,
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.SemiBold, 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( private val WelcomeQuotes = mapOf(
WelcomeQuoteStyle.NEUTRAL to listOf( WelcomeQuoteStyle.NEUTRAL to listOf(
"The sofa has been expecting you.", "The sofa has been expecting you.",
@@ -24,6 +39,26 @@ private val WelcomeQuotes = mapOf(
"Everything is ready. Decision-making is now your problem.", "Everything is ready. Decision-making is now your problem.",
"Welcome back. The pixels have been briefed.", "Welcome back. The pixels have been briefed.",
"No judgement. Even if you pick that again.", "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( WelcomeQuoteStyle.POSITIVE to listOf(
"Excellent choice showing up. The rest should be easy.", "Excellent choice showing up. The rest should be easy.",
@@ -31,6 +66,26 @@ private val WelcomeQuotes = mapOf(
"Tonight has strong main-character energy.", "Tonight has strong main-character energy.",
"Your next favourite thing might be one click away.", "Your next favourite thing might be one click away.",
"Settle in. Youve earned the good seat.", "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( WelcomeQuoteStyle.HOMICIDAL to listOf(
"Welcome back. I kept your spot. Nobody argued twice.", "Welcome back. I kept your spot. Nobody argued twice.",
@@ -38,9 +93,47 @@ private val WelcomeQuotes = mapOf(
"The remote knows what it did.", "The remote knows what it did.",
"Your watchlist is safe. The witnesses, less so.", "Your watchlist is safe. The witnesses, less so.",
"Relax. Everything is under control, allegedly.", "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( internal fun randomWelcomeQuote(
styleValue: String?, styleValue: String?,
random: Random = Random.Default, random: Random = Random.Default,
@@ -49,6 +142,9 @@ internal fun randomWelcomeQuote(
return quotes[random.nextInt(quotes.size)] return quotes[random.nextInt(quotes.size)]
} }
internal fun randomLoadingHeadline(random: Random = Random.Default): String =
LoadingHeadlines[random.nextInt(LoadingHeadlines.size)]
internal fun loginWelcomeMessage( internal fun loginWelcomeMessage(
username: String, username: String,
styleValue: String? = WelcomeQuoteStyle.NEUTRAL.value, styleValue: String? = WelcomeQuoteStyle.NEUTRAL.value,
@@ -56,11 +56,26 @@ internal object PrerollPreloader {
applicationContext = context.applicationContext applicationContext = context.applicationContext
val prepared = cachedPlayer val prepared = cachedPlayer
cachedPlayer = null cachedPlayer = null
if (prepared != null && prepared.playerError == null) return prepared if (prepared != null && prepared.playerError == null) return prepared.normalised()
prepared?.release() prepared?.release()
return createPreparedPlayer(context.applicationContext) 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 * 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. * the requested programme is playing. [start] prepares it again when Home next opens.
@@ -76,8 +91,13 @@ internal object PrerollPreloader {
player.pause() player.pause()
player.stop() player.stop()
player.seekTo(0L) player.seekTo(0L)
player.normalised()
cachedPlayer?.release() cachedPlayer?.release()
cachedPlayer = player 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. */ /** 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.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width 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.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.lazy.grid.rememberLazyGridState 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.lazy.itemsIndexed as rowItemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Backspace 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.Add
import androidx.compose.material.icons.filled.AutoAwesome import androidx.compose.material.icons.filled.AutoAwesome
import androidx.compose.material.icons.filled.CheckCircle 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.LiveTv
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Movie import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.PlayCircleFilled 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.Search
import androidx.compose.material.icons.filled.SentimentVerySatisfied
import androidx.compose.material.icons.filled.SpaceBar import androidx.compose.material.icons.filled.SpaceBar
import androidx.compose.material.icons.filled.TheaterComedy
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue 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.Color
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector 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.KeyEventType
import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.key.utf16CodePoint import androidx.compose.ui.input.key.utf16CodePoint
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
@@ -89,9 +89,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.tv.material3.Icon import androidx.tv.material3.Icon
import androidx.tv.material3.Text import androidx.tv.material3.Text
import coil.imageLoader
import kotlinx.coroutines.flow.distinctUntilChanged
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.imageLoader
import coil.request.ImageRequest import coil.request.ImageRequest
import com.ponzischeme89.memby.ServiceLocator import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.BaseItem
@@ -99,16 +98,22 @@ import com.ponzischeme89.memby.data.model.GatewayRequestCandidate
import com.ponzischeme89.memby.ui.FocusScaleContainer import com.ponzischeme89.memby.ui.FocusScaleContainer
import com.ponzischeme89.memby.ui.PosterGridCard import com.ponzischeme89.memby.ui.PosterGridCard
import com.ponzischeme89.memby.ui.theme.MembyAccent 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.MembySurface
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
import kotlinx.coroutines.flow.distinctUntilChanged
private val PaneBackground: Color get() = MembySurfaceRaised 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 KeyFocused: Color get() = MembyAccent
private val KeyLabel = Color(0xFFE8EDF1) private val KeyLabel: Color get() = MembyOnSurface
private val KeyLabelFocused = Color(0xFF06240A) private val KeyLabelFocused: Color get() = MembyAccentInk
private val Heading = Color(0xFFF2F5F7) private val Heading: Color get() = MembyOnSurface
private val Muted = Color(0xFFB7C0C8) private val Muted: Color get() = MembyMutedText
private val Accent: Color get() = MembyAccent private val Accent: Color get() = MembyAccent
/** /**
@@ -378,7 +383,7 @@ private fun QueryField(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(10.dp)) .clip(RoundedCornerShape(10.dp))
.background(Color(0xFF151C23)) .background(MembySurfaceRaised)
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(10.dp)) .border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(10.dp))
.padding(horizontal = 12.dp, vertical = 9.dp), .padding(horizontal = 12.dp, vertical = 9.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -922,7 +927,7 @@ private fun RequestOptions(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.clip(RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(16.dp))
.background(Color(0xFF11171C)) .background(MembySurfaceRaised)
.border(1.dp, Color.White.copy(alpha = 0.08f), RoundedCornerShape(16.dp)) .border(1.dp, Color.White.copy(alpha = 0.08f), RoundedCornerShape(16.dp))
.padding(20.dp), .padding(20.dp),
) { ) {
@@ -1026,9 +1031,9 @@ private fun RequestCandidateCard(
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
.background( .background(
when { when {
focused -> Color(0xFF203228) focused -> MembyAccentMuted
candidate.alreadyAdded -> Color(0xFF17231C) candidate.alreadyAdded -> MembyAccentMuted
else -> Color(0xFF1A2127) else -> MembyControlSurface
}, },
) )
.border( .border(
@@ -1048,7 +1053,7 @@ private fun RequestCandidateCard(
.width(76.dp) .width(76.dp)
.fillMaxHeight() .fillMaxHeight()
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.background(Color(0xFF0B0F12)), .background(MembySurface),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
if (previewArtwork != null) { if (previewArtwork != null) {
@@ -34,8 +34,10 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.text.BasicTextField 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.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Devices 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.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind 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.FocusRequester
import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged 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.Color
import androidx.compose.ui.graphics.SolidColor 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.Key
import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.platform.testTag 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.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.tv.material3.Icon
import androidx.tv.material3.Button import androidx.tv.material3.Button
import androidx.tv.material3.Icon
import androidx.tv.material3.Text 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.R
import com.ponzischeme89.memby.ServiceLocator import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.DEFAULT_SEEK_INTERVAL_SECONDS 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.ImageCacheMaintenance
import com.ponzischeme89.memby.data.ImageCacheSize 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.SEEK_INTERVAL_SECONDS
import com.ponzischeme89.memby.data.SKIP_INTRO_AUTO import com.ponzischeme89.memby.data.SKIP_INTRO_AUTO
import com.ponzischeme89.memby.data.SKIP_INTRO_OFF import com.ponzischeme89.memby.data.SKIP_INTRO_OFF
import com.ponzischeme89.memby.data.SKIP_INTRO_PROMPT import com.ponzischeme89.memby.data.SKIP_INTRO_PROMPT
import com.ponzischeme89.memby.data.Settings import com.ponzischeme89.memby.data.Settings
import com.ponzischeme89.memby.data.parseThemeColor import com.ponzischeme89.memby.data.formatCacheSize
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.data.model.GatewayDevice import com.ponzischeme89.memby.data.model.GatewayDevice
import com.ponzischeme89.memby.data.parseThemeColor
import com.ponzischeme89.memby.ui.PreviewSurface import com.ponzischeme89.memby.ui.PreviewSurface
import com.ponzischeme89.memby.ui.TvPreview import com.ponzischeme89.memby.ui.TvPreview
import com.ponzischeme89.memby.ui.WelcomeQuoteStyle import com.ponzischeme89.memby.ui.WelcomeQuoteStyle
import com.ponzischeme89.memby.ui.theme.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 com.ponzischeme89.memby.update.UpdateChecker
import kotlinx.coroutines.delay
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout 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 // 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. // would be showing them the wrong answer to the question they are being asked.
private val EmbyGreen: Color get() = MembyAccent private val EmbyGreen: Color get() = MembyAccent
private val Canvas = Color(0xFF000000) private val Canvas: Color get() = MembySurface
private val Panel = Color(0xFF040506) private val Panel: Color get() = MembySurface
private val RowFocused = Color(0xFF1B2228) private val RowFocused: Color get() = MembyControlSurface
private val ControlIdle = Color(0xFF1E252B) private val ControlIdle: Color get() = MembyControlSurface
private val TextPrimary = Color(0xFFF2F5F7) private val TextPrimary: Color get() = MembyOnSurface
private val TextSecondary = Color(0xFFAFB8BF) private val TextSecondary: Color get() = MembyMutedText
private val TextQuiet = Color(0xFF7C868E) private val TextQuiet: Color get() = MembyQuietText
private val Hairline = Color.White.copy(alpha = 0.08f) private val Hairline = Color.White.copy(alpha = 0.08f)
private val RailEdge = Color.White.copy(alpha = 0.10f) private val RailEdge = Color.White.copy(alpha = 0.10f)
@@ -1499,7 +1507,7 @@ private fun StatusToggle(checked: Boolean) {
.width(46.dp) .width(46.dp)
.height(25.dp) .height(25.dp)
.clip(CircleShape) .clip(CircleShape)
.background(if (checked) EmbyGreen else Color(0xFF394149)) .background(if (checked) EmbyGreen else MembyControlSurfaceRaised)
.padding(3.dp), .padding(3.dp),
) { ) {
Box( Box(
@@ -1507,7 +1515,7 @@ private fun StatusToggle(checked: Boolean) {
.align(if (checked) Alignment.CenterEnd else Alignment.CenterStart) .align(if (checked) Alignment.CenterEnd else Alignment.CenterStart)
.size(19.dp) .size(19.dp)
.clip(CircleShape) .clip(CircleShape)
.background(if (checked) Color(0xFF072108) else Color(0xFFCBD2D7)), .background(if (checked) MembyAccentInk else MembyOnSurface),
) )
} }
} }
@@ -1648,7 +1656,7 @@ private fun SettingsChoiceChip(
} }
Text( Text(
option.label, option.label,
color = if (selected) Color(0xFF062307) else TextPrimary, color = if (selected) MembyAccentInk else TextPrimary,
fontSize = 12.sp, fontSize = 12.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
@@ -1686,7 +1694,7 @@ private fun SettingsActionRow(
} }
Text( Text(
badge, badge,
color = if (focused) Color(0xFF062307) else EmbyGreen, color = if (focused) MembyAccentInk else EmbyGreen,
fontSize = 10.sp, fontSize = 10.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
letterSpacing = 0.7.sp, letterSpacing = 0.7.sp,
@@ -1702,7 +1710,7 @@ private fun SettingsActionRow(
private fun SettingsNotice(text: String, positive: Boolean) { private fun SettingsNotice(text: String, positive: Boolean) {
Text( Text(
text, text,
color = if (positive) Color(0xFF9DE29A) else Color(0xFFFF9B98), color = if (positive) MembyAccentBright else Color(0xFFFF9B98),
fontSize = 13.sp, fontSize = 13.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
modifier = Modifier modifier = Modifier
@@ -1802,7 +1810,7 @@ private fun ReleaseHistoryRow(
} }
Text( Text(
if (expanded) "HIDE" else "${release.changes.size} CHANGES", if (expanded) "HIDE" else "${release.changes.size} CHANGES",
color = if (focused) Color(0xFF062307) else EmbyGreen, color = if (focused) MembyAccentInk else EmbyGreen,
fontSize = 10.sp, fontSize = 10.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
letterSpacing = 0.7.sp, letterSpacing = 0.7.sp,
@@ -103,6 +103,72 @@ val MembyHairline: Color get() = activePalette.hairline
/** A quiet neutral capsule behind third-party ratings. */ /** A quiet neutral capsule behind third-party ratings. */
val MembyRatingsSurface: Color get() = activePalette.ratingsSurface 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 --------------------------------------------------------------------------- // --- Shape ---------------------------------------------------------------------------
// Three steps, largest last. Anything that needs a radius picks the nearest one rather // 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. // 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 @Test
fun `login greeting trims the authenticated username`() { fun `login greeting trims the authenticated username`() {
assertTrue( assertTrue(