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