0.2.41 - Preroll fixes, theme fixes
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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. You’ve earned the good seat.",
|
"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(
|
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(
|
||||||
|
|||||||
Reference in New Issue
Block a user