feat(app,android,ios): full-bleed hero carousel with ambient colour

The library's banner now runs edge to edge and turns through up to five
titles: the one watched last, then titles with a wide picture, starting
from a different one each day. It moves on every eight seconds, holds
still under the pointer, and has dots to pick a title. A part-watched
title shows how far in the viewer is under its buttons.

The veils over the banner and over a title page's band are tinted with
the picture's own colour. The weighting and the veil live in
pstr_core::appearance (mood, mood_veil), so the desktop and both phones,
which reach them through the bridge, land on the same colour.

On Android the banner is a HorizontalPager under a clear bar that fills
in as it scrolls away; iOS uses a paged TabView the same way.
This commit is contained in:
nvrl committed 2026-10-30 21:19:37 +01:00
1 parent 255e463fc8
commit c6d303f269
20 files changed
+1358 -327

No files matched your search

@@ -1,6 +1,28 @@
package io.narl.protonstream.ui
import androidx.compose.foundation.background
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.interaction.collectIsDraggedAsState
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.unit.Dp
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -199,72 +221,83 @@ internal fun LibraryScreen(
onSearch("")
}
BackHandler(enabled = searching, onBack = closeSearch)
// The large title folds into the bar as the grid scrolls, and comes back
// only at the top: the page says what it is on arrival, then gets out of
// the way.
// The banner turns through these. Worked out here rather than in the grid,
// because whether there is one decides the shape of the whole page.
val featured = remember(state.titles, resumable) { featured(state.titles, resumable) }
// With a banner, the bar lies over it, clear, and fills in as the banner
// scrolls away; without one, the large title folds into the bar as the
// grid scrolls and comes back only at the top.
val overBanner = !searching && !state.loading && state.query.isBlank() && featured.isNotEmpty()
val scroll = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
Column(Modifier.fillMaxSize().nestedScroll(scroll.nestedScrollConnection)) {
if (searching) {
SearchBar(state.query, onSearch, closeSearch)
} else {
LargeTopAppBar(
title = { Text("Library") },
actions = {
IconButton(onClick = { searching = true }) {
Icon(Icons.Default.Search, contentDescription = "Search library")
}
ArrangeMenu(state.sort, state.grouped, onArrange)
// Pull to refresh is the gesture; the icon stays for a
// viewer who does not know it.
IconButton(onClick = onRefresh, enabled = !state.refreshing) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
}
},
colors = pageBarColors(),
scrollBehavior = scroll,
)
val grid = rememberLazyGridState()
val actions: @Composable RowScope.() -> Unit = {
IconButton(onClick = { searching = true }) {
Icon(Icons.Default.Search, contentDescription = "Search library")
}
PullToRefreshBox(
isRefreshing = state.refreshing,
onRefresh = onRefresh,
modifier = Modifier.weight(1f).padding(padding),
) {
when {
state.loading -> LibrarySkeleton()
state.titles.isEmpty() && state.query.isNotBlank() -> EmptyState(
"Nothing matches",
"No title in the library is called “${state.query.trim()}”.",
)
// A share just added: say it is being looked through, rather
// than offer to add the one that is already filling the page.
state.titles.isEmpty() && state.activity != null -> EmptyState(
"Looking through the share",
"${state.activity} Its titles appear here once it has been listed.",
)
state.titles.isEmpty() -> EmptyState(
"Your library is empty",
"Add a Proton Drive share link, and everything playable behind it shows up here.",
action = "Add a share" to onAddShare,
)
else -> LibraryGrid(
state,
resumable,
onResume,
onTitle,
onTitleMenu = { menuFor = it },
onResumeMenu = { menuResume = it },
onFilter = onFilter,
metadataOffer = if (state.metadataSettings.enabled || offerDismissed) {
null
} else {
MetadataOffer(onOpenSettings) {
offerDismissed = true
SettingsStore(context).metadataOfferDismissed = true
}
},
ArrangeMenu(state.sort, state.grouped, onArrange)
// Pull to refresh is the gesture; the icon stays for a viewer who
// does not know it.
IconButton(onClick = onRefresh, enabled = !state.refreshing) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
}
}
Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize().then(if (overBanner) Modifier else Modifier.nestedScroll(scroll.nestedScrollConnection))) {
if (searching) {
SearchBar(state.query, onSearch, closeSearch)
} else if (!overBanner) {
LargeTopAppBar(
title = { Text("Library") },
actions = actions,
colors = pageBarColors(),
scrollBehavior = scroll,
)
}
PullToRefreshBox(
isRefreshing = state.refreshing,
onRefresh = onRefresh,
modifier = Modifier.weight(1f).padding(padding),
) {
when {
state.loading -> LibrarySkeleton()
state.titles.isEmpty() && state.query.isNotBlank() -> EmptyState(
"Nothing matches",
"No title in the library is called “${state.query.trim()}”.",
)
// A share just added: say it is being looked through, rather
// than offer to add the one that is already filling the page.
state.titles.isEmpty() && state.activity != null -> EmptyState(
"Looking through the share",
"${state.activity} Its titles appear here once it has been listed.",
)
state.titles.isEmpty() -> EmptyState(
"Your library is empty",
"Add a Proton Drive share link, and everything playable behind it shows up here.",
action = "Add a share" to onAddShare,
)
else -> LibraryGrid(
state,
resumable,
onResume,
onTitle,
onTitleMenu = { menuFor = it },
onResumeMenu = { menuResume = it },
onFilter = onFilter,
metadataOffer = if (state.metadataSettings.enabled || offerDismissed) {
null
} else {
MetadataOffer(onOpenSettings) {
offerDismissed = true
SettingsStore(context).metadataOfferDismissed = true
}
},
featured = if (overBanner) featured else emptyList(),
grid = grid,
)
}
}
}
if (overBanner) BarOverBanner(grid, actions)
}
menuFor?.let { title ->
val playlist = title.seasons.flatMap(SeasonRecord::episodes)
@@ -458,6 +491,8 @@ private fun LibraryGrid(
onResumeMenu: (Resumable) -> Unit,
onFilter: (LibraryFilter) -> Unit = {},
metadataOffer: MetadataOffer? = null,
featured: List<TitleRecord> = emptyList(),
grid: LazyGridState = rememberLazyGridState(),
) {
// The arrangement names titles by key; before it has arrived, the titles
// as they came are the grid.
@@ -476,10 +511,24 @@ private fun LibraryGrid(
LazyVerticalGrid(
columns = GridCells.Adaptive(108.dp),
modifier = Modifier.fillMaxSize(),
state = grid,
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp),
// No room above a banner: it starts under the status bar, and the bar
// lies over it.
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = if (featured.isEmpty()) 8.dp else 0.dp, bottom = 24.dp),
) {
if (featured.isNotEmpty()) {
item(key = "featured", span = { GridItemSpan(maxLineSpan) }) {
FeaturedBanner(
featured,
resumable,
onPlay = onResume,
onOpen = onTitle,
modifier = Modifier.bleed(16.dp),
)
}
}
// Above the grid, and only when there is something in it: what a viewer
// opening the app wants is almost always the thing they were part way
// through. Inside the grid rather than pinned over it, so the shelf
@@ -487,17 +536,6 @@ private fun LibraryGrid(
if (metadataOffer != null && state.query.isBlank()) {
item(key = "metadata-offer", span = { GridItemSpan(maxLineSpan) }) { MetadataOfferCard(metadataOffer) }
}
val featured = if (state.query.isBlank()) featured(state.titles, resumable) else null
featured?.let { title ->
item(key = "featured", span = { GridItemSpan(maxLineSpan) }) {
FeaturedBanner(
title,
resume = resumable.firstOrNull { it.title.key == title.key },
onPlay = { onResume(title, it) },
onOpen = { onTitle(title) },
)
}
}
if (state.query.isBlank() && resumable.isNotEmpty()) {
item(key = "continue-watching", span = { GridItemSpan(maxLineSpan) }) {
Column {
@@ -611,44 +649,173 @@ private val SHELF_TILE_WIDTH = 112.dp
/** Enough placeholder tiles to fill a tablet's first screen. */
private const val SKELETON_TILES = 18
/** How many titles the banner turns through: the desktop's `FEATURED`. */
private const val FEATURED_MAX = 5
/** How long each title holds the banner: the desktop's `HOLD`. */
private const val HOLD_MS = 8_000L
/** How long one title takes to give way to the next: the desktop's `TURN`. */
private const val TURN_MS = 600
/**
* The title the banner shows: the one watched last, or with nothing part
* watched, one of the titles the provider has a backdrop for, turning over
* once a day — the desktop's `featured`.
* The titles the banner turns through: the one watched last first, then titles
* the provider has a backdrop for, starting from a different one each day —
* the desktop's `featured`.
*/
private fun featured(titles: List<TitleRecord>, resumable: List<Resumable>): TitleRecord? {
resumable.firstOrNull()?.let { return it.title }
private fun featured(titles: List<TitleRecord>, resumable: List<Resumable>): List<TitleRecord> {
val picked = listOfNotNull(resumable.firstOrNull()?.title).toMutableList()
val pictured = titles.filter { it.backdropUrl != null }
if (pictured.isEmpty()) return null
return pictured[(LocalDate.now().toEpochDay() % pictured.size).toInt()]
if (pictured.isEmpty()) return picked
val start = (LocalDate.now().toEpochDay() % pictured.size).toInt()
for (offset in pictured.indices) {
if (picked.size == FEATURED_MAX) break
val title = pictured[(start + offset) % pictured.size]
if (picked.none { it.key == title.key }) picked += title
}
return picked
}
/** Large art behind the name, rating, genres and three lines of synopsis. */
/**
* Wider than the space it is given by [horizontal] on each side: for the
* banner, which runs edge to edge inside a grid with a margin.
*/
private fun Modifier.bleed(horizontal: Dp): Modifier = layout { measurable, constraints ->
val extra = horizontal.roundToPx() * 2
val placeable = measurable.measure(
constraints.copy(minWidth = constraints.minWidth + extra, maxWidth = constraints.maxWidth + extra),
)
layout(placeable.width - extra, placeable.height) { placeable.place(-extra / 2, 0) }
}
/**
* The bar over the banner: clear at the top, with white icons over the
* banner's own scrim, and filling in to the page's bar as the banner scrolls
* away — at which point it is the page's bar, title and all.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun FeaturedBanner(title: TitleRecord, resume: Resumable?, onPlay: (Int) -> Unit, onOpen: () -> Unit) {
private fun BarOverBanner(grid: LazyGridState, actions: @Composable RowScope.() -> Unit) {
val reveal = with(LocalDensity.current) { BAR_REVEAL.toPx() }
val solid by remember(grid) {
derivedStateOf {
if (grid.firstVisibleItemIndex > 0) 1f else (grid.firstVisibleItemScrollOffset / reveal).coerceIn(0f, 1f)
}
}
val scheme = MaterialTheme.colorScheme
val ink = lerp(Color.White, scheme.onSurface, solid)
TopAppBar(
title = { Text("Library", modifier = Modifier.graphicsLayer { alpha = solid }) },
actions = actions,
colors = TopAppBarDefaults.topAppBarColors(
containerColor = scheme.surfaceContainer.copy(alpha = solid),
titleContentColor = ink,
actionIconContentColor = ink,
),
)
}
/** How far the banner scrolls before the bar over it is solid. */
private val BAR_REVEAL = 280.dp
/**
* The banner at the top of the library: one title at a time, edge to edge
* under the status bar, turning to the next every [HOLD_MS] unless a finger is
* on it. Each title's veil is tinted with its own picture's colour.
*/
@Composable
private fun FeaturedBanner(
titles: List<TitleRecord>,
resumable: List<Resumable>,
onPlay: (TitleRecord, Int) -> Unit,
onOpen: (TitleRecord) -> Unit,
modifier: Modifier = Modifier,
) {
val pager = rememberPagerState { titles.size }
val dragged by pager.interactionSource.collectIsDraggedAsState()
if (titles.size > 1 && !dragged) {
LaunchedEffect(pager.currentPage, titles.size) {
delay(HOLD_MS)
pager.animateScrollToPage((pager.currentPage + 1) % titles.size, animationSpec = tween(TURN_MS))
}
}
// Most of a phone's first screen, but never so much of a tablet's that
// nothing under it shows.
val height = (LocalConfiguration.current.screenHeightDp * 0.62f).dp.coerceIn(360.dp, 620.dp)
Box(modifier.fillMaxWidth().height(height)) {
HorizontalPager(pager, Modifier.fillMaxSize(), key = { titles[it].key }) { page ->
val title = titles[page]
FeaturedPage(
title,
resumable.firstOrNull { it.title.key == title.key },
onPlay = { onPlay(title, it) },
onOpen = { onOpen(title) },
)
}
if (titles.size > 1) {
val scope = rememberCoroutineScope()
Row(
Modifier.align(Alignment.BottomCenter).padding(bottom = 10.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
titles.forEachIndexed { index, title ->
val shown = index == pager.currentPage
val width by animateDpAsState(if (shown) 18.dp else 6.dp, label = "dot")
Box(
Modifier
.size(width, 6.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.onBackground.copy(alpha = if (shown) 1f else 0.4f))
.clickable(onClickLabel = "Show ${title.displayName}") {
scope.launch { pager.animateScrollToPage(index, animationSpec = tween(TURN_MS)) }
}
.semantics { selected = shown },
)
}
}
}
}
}
/** One title on the banner: its art, its veil, and what it is, from the bottom up. */
@Composable
private fun FeaturedPage(title: TitleRecord, resume: Resumable?, onPlay: (Int) -> Unit, onOpen: () -> Unit) {
val playlist = remember(title) { title.seasons.flatMap(SeasonRecord::episodes) }
Box(Modifier.fillMaxWidth().aspectRatio(4f / 3f).clip(MaterialTheme.shapes.large)) {
Backdrop(title, Modifier.fillMaxSize())
var mood by remember(title.key) { mutableStateOf<Color?>(null) }
val background = MaterialTheme.colorScheme.background
val veil = moodVeil(mood)
val ink = MaterialTheme.colorScheme.onBackground
// A logo is lettering made for dark art, white as often as not; over a
// light palette's veil it is gone, so the light palette keeps the name.
val dark = background.luminance() < 0.5f
Box(Modifier.fillMaxSize().clickable(onClickLabel = "Open ${title.displayName}", onClick = onOpen)) {
Backdrop(title, Modifier.fillMaxSize(), onMood = { mood = it })
Box(
Modifier.fillMaxSize().background(
Brush.verticalGradient(0.35f to Color.Transparent, 1f to Color.Black.copy(alpha = 0.85f)),
Brush.verticalGradient(
// Dark under the status bar and the bar's white icons…
0f to Color.Black.copy(alpha = 0.5f),
0.22f to Color.Transparent,
// …and the picture's own colour under the text, ending in
// the page it hands over to.
0.45f to veil.copy(alpha = 0.35f),
0.78f to veil.copy(alpha = 0.92f),
1f to background,
),
),
)
Column(Modifier.align(Alignment.BottomStart).padding(16.dp)) {
// Always over the dark scrim above, so a logo made for dark art
// reads here whatever the theme.
RemoteLogo(
title.logoUrl,
title.displayName,
Modifier.fillMaxWidth(0.6f).heightIn(max = 72.dp).padding(bottom = 6.dp),
) {
Text(
Column(
Modifier.align(Alignment.BottomStart).widthIn(max = 560.dp).padding(start = 16.dp, end = 16.dp, bottom = 28.dp),
) {
if (dark) {
RemoteLogo(
title.logoUrl,
title.displayName,
style = MaterialTheme.typography.displayLarge,
color = Color.White,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Modifier.fillMaxWidth(0.7f).heightIn(max = 88.dp).padding(bottom = 8.dp),
) { HeroName(title.displayName, ink) }
} else {
HeroName(title.displayName, ink)
}
Text(
listOfNotNull(
@@ -656,14 +823,14 @@ private fun FeaturedBanner(title: TitleRecord, resume: Resumable?, onPlay: (Int)
title.genres.takeIf { it.isNotEmpty() }?.take(3)?.joinToString(", "),
).joinToString(" · ").ifEmpty { title.caption() },
style = MaterialTheme.typography.bodySmall,
color = Color.White.copy(alpha = 0.8f),
color = ink.copy(alpha = 0.8f),
)
title.overview?.let {
Text(
it.trim(),
style = MaterialTheme.typography.bodySmall,
color = Color.White.copy(alpha = 0.8f),
maxLines = 3,
color = ink.copy(alpha = 0.8f),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 6.dp),
)
@@ -672,14 +839,48 @@ private fun FeaturedBanner(title: TitleRecord, resume: Resumable?, onPlay: (Int)
AccentButton(onClick = { onPlay(resume?.index ?: nextUpIndex(playlist, title.continueIndex)) }) {
Icon(Icons.Default.PlayArrow, contentDescription = null)
Spacer(Modifier.width(6.dp))
Text(if (resume != null) "Resume" else "Play")
Text(if (resume?.episode?.resumeAt != null) "Resume" else "Play")
}
TonalButton(onClick = onOpen) {
Icon(Icons.Default.Info, contentDescription = null)
Spacer(Modifier.width(6.dp))
Text("More info")
}
}
val episode = resume?.episode
val progress = episode?.progress?.takeIf { episode.resumeAt != null }
if (progress != null) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 12.dp)) {
AccentProgress(
progress = { progress.toFloat().coerceIn(0f, 1f) },
modifier = Modifier.width(120.dp),
)
episode.timeLeft()?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = ink.copy(alpha = 0.8f),
modifier = Modifier.padding(start = 8.dp),
)
}
}
TonalButton(onClick = onOpen) { Text("More info") }
}
}
}
}
@Composable
private fun HeroName(name: String, ink: Color) {
Text(
name,
style = MaterialTheme.typography.displayLarge,
color = ink,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(bottom = 4.dp),
)
}
/** A part-watched episode: its still, where it was left, and what is left of it. */
@Composable
private fun ContinueCard(entry: Resumable, onClick: () -> Unit, onLongClick: () -> Unit) {
@@ -7,6 +7,7 @@ import android.util.LruCache
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.animation.Crossfade
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
@@ -33,6 +34,8 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
@@ -45,6 +48,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.moodVeil as sharedMoodVeil
import uniffi.pstr_android.pictureMood as sharedPictureMood
import java.net.HttpURLConnection
import java.net.URI
import java.io.ByteArrayOutputStream
@@ -98,6 +103,7 @@ internal fun RemoteArtwork(
fallback: ThumbnailSource? = null,
labelled: Boolean = true,
maxEdge: Dp? = null,
onMood: ((Color) -> Unit)? = null,
) {
val context = LocalContext.current
val enabled = LocalArtworkLoading.current
@@ -110,9 +116,11 @@ internal fun RemoteArtwork(
image = ArtworkLoad.Missing
return@LaunchedEffect
}
image = (loadArtwork(context, url, edge) ?: loadThumbnail(fallback, edge))
?.let { ArtworkLoad.Loaded(it.asImageBitmap()) }
?: ArtworkLoad.Missing
val bitmap = loadArtwork(context, url, edge) ?: loadThumbnail(fallback, edge)
image = bitmap?.let { ArtworkLoad.Loaded(it.asImageBitmap()) } ?: ArtworkLoad.Missing
if (bitmap != null && onMood != null) {
moodOf(url ?: fallback?.key ?: return@LaunchedEffect, bitmap)?.let(onMood)
}
}
Crossfade(image, modifier, animationSpec = tween(ARRIVE_MS), label = "artwork") { loaded ->
when (loaded) {
@@ -151,7 +159,7 @@ private const val SHIMMER_MS = 1_400
* stretched, is what softens it.
*/
@Composable
internal fun Backdrop(title: TitleRecord, modifier: Modifier = Modifier) {
internal fun Backdrop(title: TitleRecord, modifier: Modifier = Modifier, onMood: ((Color) -> Unit)? = null) {
val wash = title.backdropUrl == null && title.posterUrl != null
RemoteArtwork(
title.backdropUrl ?: title.posterUrl,
@@ -160,9 +168,52 @@ internal fun Backdrop(title: TitleRecord, modifier: Modifier = Modifier) {
fallback = title.thumbnailSource,
labelled = false,
maxEdge = if (wash) WASH_EDGE else null,
onMood = onMood,
)
}
/**
* The colour a picture leaves behind — the shared `appearance::mood`, over the
* picture squeezed to [MOOD_EDGE] pixels a side. Remembered per picture, so a
* banner paging back to a title does not work it out again.
*/
private suspend fun moodOf(key: String, bitmap: Bitmap): Color? {
moods.get(key)?.let { return Color(it) }
val argb = withContext(Dispatchers.Default) {
runCatching {
val small = Bitmap.createScaledBitmap(bitmap, MOOD_EDGE, MOOD_EDGE, true)
val pixels = IntArray(MOOD_EDGE * MOOD_EDGE)
small.getPixels(pixels, 0, MOOD_EDGE, 0, 0, MOOD_EDGE, MOOD_EDGE)
sharedPictureMood(pixels.map(Int::toUInt))?.toInt()
}.getOrNull()
} ?: return null
moods.put(key, argb)
return Color(argb)
}
private val moods = LruCache<String, Int>(64)
/** A picture squeezed to this many pixels a side is still its colours, and a few hundred values to average. */
private const val MOOD_EDGE = 24
/**
* [background] tinted by a picture's [mood], for the veils over that picture —
* the shared `appearance::mood_veil`. [background] itself until the mood is
* known.
*/
@Composable
internal fun moodVeil(mood: Color?, background: Color = MaterialTheme.colorScheme.background): Color {
val light = background.luminance() > 0.5f
val target = remember(mood, background) {
mood?.let {
runCatching {
Color(sharedMoodVeil(background.toArgb().toUInt(), it.toArgb().toUInt(), light).toInt())
}.getOrNull()
} ?: background
}
return animateColorAsState(target, tween(ARRIVE_MS), label = "veil").value
}
private val WASH_EDGE = 48.dp
private val WASH_BLUR = 24.dp
Binary file not shown.

Before

Width:  |  Height:  |  Size: 811 KiB

After

Width:  |  Height:  |  Size: 867 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 767 KiB

After

Width:  |  Height:  |  Size: 826 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 671 KiB

After

Width:  |  Height:  |  Size: 782 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 805 KiB

After

Width:  |  Height:  |  Size: 887 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

+45
View File
@@ -457,6 +457,35 @@ pub fn stored_palette(paths: AndroidPaths) -> PaletteRecord {
palette_record(&appearance_record(appearance))
}
/// The colour a picture leaves behind, as `0xAARRGGBB`, opaque: the shared
/// `pstr_core::appearance::mood` over [`pixels`], which are `0xAARRGGBB` too —
/// what `Bitmap.getPixels` hands back. `None` for nothing opaque.
///
/// The client squeezes the picture to a few hundred pixels first; this is the
/// weighting, kept in one place so every client lands on the same colour.
#[uniffi::export]
pub fn picture_mood(pixels: Vec<u32>) -> Option<u32> {
pstr_core::appearance::mood(pixels.into_iter().map(|argb| {
let channel = |shift: u32| ((argb >> shift) & 0xff) as u8;
(
pstr_core::appearance::Rgb::new(channel(16), channel(8), channel(0)),
channel(24),
)
}))
.map(pstr_core::appearance::Rgb::argb)
}
/// The page colour `background` tinted by a picture's [`picture_mood`], for
/// the veils over that picture. All `0xAARRGGBB`; see
/// `pstr_core::appearance::mood_veil`.
#[uniffi::export]
pub fn mood_veil(background: u32, mood: u32, light: bool) -> u32 {
let rgb = |argb: u32| {
pstr_core::appearance::Rgb::new((argb >> 16) as u8, (argb >> 8) as u8, argb as u8)
};
pstr_core::appearance::mood_veil(rgb(background), rgb(mood), light).argb()
}
#[derive(Debug, Clone, uniffi::Record)]
pub struct MetadataSettingsRecord {
pub enabled: bool,
@@ -3849,6 +3878,22 @@ mod tests {
/// Material's containment ladder has five rungs where the palette names
/// three, and Kotlin lays them out in this order. A rung that is not past
/// the one below it is a rung that reads as the same surface.
#[test]
fn a_picture_handed_over_as_argb_pixels_keeps_its_colour_and_skips_the_clear_ones() {
let teal = 0xff14_8c96;
assert_eq!(super::picture_mood(vec![teal; 9]), Some(teal));
assert_eq!(super::picture_mood(vec![teal, 0x00ff_ffff]), Some(teal));
assert_eq!(super::picture_mood(vec![0x00ff_ffff]), None);
}
#[test]
fn the_veil_over_a_picture_is_the_shared_one_packed_as_argb() {
let background = 0xff0b_0b0f;
let veil = super::mood_veil(background, 0xffdc_1e1e, false);
assert_eq!(veil >> 24, 0xff);
assert!((veil >> 16) & 0xff > 0x0b, "{veil:08x}");
}
#[test]
fn the_derived_rung_continues_the_ladder_in_both_polarities() {
for flavor in FLAVORS {
+13 -1
View File
@@ -2471,7 +2471,19 @@ impl eframe::App for App {
.frame(
egui::Frame::new()
.fill(theme::background())
.inner_margin(egui::Margin::symmetric(18, 12)),
.inner_margin(egui::Margin {
left: ui::PAGE_MARGIN.x as i8,
right: ui::PAGE_MARGIN.x as i8,
// The library's banner starts right under the bar;
// the page keeps the margin itself when it has no
// banner to show.
top: if *page == Page::Library {
0
} else {
ui::PAGE_MARGIN.y as i8
},
bottom: ui::PAGE_MARGIN.y as i8,
}),
)
.show(ui, |ui| {
ui.multiply_opacity(fade);
+38
View File
@@ -3176,6 +3176,24 @@ struct Entry {
texture: egui::TextureHandle,
used: u64,
bytes: usize,
/// The picture's colour — see [`pstr_core::appearance::mood`]. Worked out
/// once, at upload, from the pixels on their way to the GPU.
mood: Option<egui::Color32>,
}
/// About how many pixels [`mood_of`] reads. A 64×36 wash is read whole; a
/// 1920 px backdrop is read at a stride that lands on about this many, which
/// is plenty for an average and costs nothing worth a frame.
const MOOD_SAMPLES: usize = 4096;
/// [`pstr_core::appearance::mood`] over a decoded picture.
fn mood_of(image: &egui::ColorImage) -> Option<egui::Color32> {
let stride = (image.pixels.len() / MOOD_SAMPLES).max(1);
pstr_core::appearance::mood(image.pixels.iter().step_by(stride).map(|pixel| {
let [r, g, b, a] = pixel.to_srgba_unmultiplied();
(pstr_core::appearance::Rgb::new(r, g, b), a)
}))
.map(|rgb| egui::Color32::from_rgb(rgb.r, rgb.g, rgb.b))
}
/// Texture memory one [`ImageCache`] may hold. A 640 px card picture is about
@@ -3243,12 +3261,14 @@ impl ImageCache {
break;
};
let bytes = image.pixels.len() * 4;
let mood = mood_of(&image);
let texture = ctx.load_texture(&key, image, egui::TextureOptions::LINEAR);
self.clock += 1;
let entry = Entry {
texture,
used: self.clock,
bytes,
mood,
};
if let Some(old) = self.textures.insert(key, entry) {
self.bytes -= old.bytes;
@@ -3278,6 +3298,12 @@ impl ImageCache {
}
}
/// The colour of the picture under `key`, once it is a texture. A lookup
/// that does not count as asking for it: only drawing a picture keeps it.
pub fn mood(&self, key: &str) -> Option<egui::Color32> {
self.textures.get(key).and_then(|entry| entry.mood)
}
pub fn mark_missing(&mut self, key: String) {
self.missing.insert(key);
}
@@ -3338,6 +3364,18 @@ mod image_cache_tests {
);
}
#[test]
fn an_uploaded_picture_keeps_its_colour_and_a_waiting_one_has_none() {
let ctx = egui::Context::default();
let mut cache = ImageCache::default();
let teal = egui::Color32::from_rgb(20, 140, 150);
cache.texture("a", || {});
cache.insert("a".into(), egui::ColorImage::filled([8, 8], teal));
assert_eq!(cache.mood("a"), None);
cache.upload(&ctx);
assert_eq!(cache.mood("a"), Some(teal));
}
#[test]
fn uploads_are_spread_over_frames() {
let ctx = egui::Context::default();
+28 -2
View File
@@ -823,6 +823,11 @@ pub fn ramp_fill(
/// scrim that starts opaque and lets the picture out, `true` for one that lets
/// the picture *into* the page — which is the one the title backdrop wants,
/// since the page is below the band.
///
/// The ramp itself is white and the colour is the shape's fill, which the GPU
/// multiplies in: one texture per direction, however many colours fade —
/// a banner tinted per title would otherwise build a ramp for every frame of
/// its cross-fade.
pub fn fade_shape(
ctx: &egui::Context,
rect: Rect,
@@ -830,8 +835,29 @@ pub fn fade_shape(
direction: Direction,
arriving: bool,
) -> egui::Shape {
let texture = alpha_ramp(ctx, color, direction, arriving);
textured(rect, CornerRadius::ZERO, &texture)
let texture = alpha_ramp(ctx, Color32::WHITE, direction, arriving);
let mut shape = egui::epaint::RectShape::filled(rect, CornerRadius::ZERO, color);
shape.brush = Some(Arc::new(egui::epaint::Brush {
fill_texture_id: texture.id(),
uv: Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
}));
shape.into()
}
/// The page colour tinted by a picture's colour, for the veils over that
/// picture: see [`pstr_core::appearance::mood_veil`]. The page colour itself
/// while the picture has not arrived.
pub fn mood_veil(mood: Option<Color32>) -> Color32 {
let palette = palette();
let Some(mood) = mood else {
return palette.background;
};
let rgb = |c: Color32| pstr_core::appearance::Rgb::new(c.r(), c.g(), c.b());
color(pstr_core::appearance::mood_veil(
rgb(palette.background),
rgb(mood),
palette.light,
))
}
/// The one gradient that is not the accent: the top bar, which runs from the
+421 -133
View File
@@ -36,6 +36,17 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions:
crawl,
offer_metadata,
} = shelves;
let searching = !search.trim().is_empty();
let featured = if searching {
Vec::new()
} else {
featured(library, view, art.metadata, today())
};
// The panel leaves the top margin to this page, for the banner to start
// under the bar.
if featured.is_empty() {
ui.add_space(ui::PAGE_MARGIN.y);
}
if !loaded {
return placeholder(ui);
}
@@ -50,20 +61,19 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions:
.collect()
};
let matches = pick(&view.matches);
let searching = !search.trim().is_empty();
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
if !featured.is_empty() {
hero(ui, art, &featured, actions);
ui.add_space(theme::space::L);
}
if !searching && offer_metadata {
metadata_offer(ui, actions);
ui.add_space(theme::space::XL);
}
if !searching {
if let Some(title) = featured(library, view, art.metadata, today()) {
hero(ui, art, title, actions);
ui.add_space(theme::space::XL);
}
let resumable = pick(&view.resumable);
if !resumable.is_empty() {
ui::section(ui, "Continue watching");
@@ -115,30 +125,40 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions:
});
}
/// Days since the epoch: what the featured title turns over on.
/// Days since the epoch: what the featured titles turn over on.
fn today() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |since| since.as_secs() / 86_400)
}
/// The title the banner shows: whatever was watched last, so the top of the
/// page is one click from carrying on. With nothing part-watched, one of the
/// titles a provider has a backdrop for, turning over once a day — the same
/// one every time the page is opened today, rather than a new one per frame.
/// How many titles the banner turns through.
const FEATURED: usize = 5;
/// How long each title holds the banner before the next takes it, in seconds.
const HOLD: f64 = 8.0;
/// How long one title takes to give way to the next. Slower than artwork
/// arriving: this is the whole banner changing, and a quick one reads as a
/// cut.
const TURN: f32 = 0.6;
/// The titles the banner turns through: whatever was watched last first, so
/// the top of the page is one click from carrying on, then titles a provider
/// has a backdrop for, starting from a different one each day — the same
/// order every time the page is opened today, rather than a new one per frame.
fn featured<'a>(
library: &'a Library,
view: &LibraryView,
metadata: &std::collections::HashMap<String, pstr_core::metadata::MetadataRecord>,
day: u64,
) -> Option<&'a Title> {
if let Some(title) = view
) -> Vec<&'a Title> {
let mut picked: Vec<&Title> = view
.resumable
.first()
.and_then(|&index| library.titles.get(index))
{
return Some(title);
}
.into_iter()
.collect();
let pictured: Vec<&Title> = library
.titles
.iter()
@@ -150,68 +170,130 @@ fn featured<'a>(
})
.collect();
if pictured.is_empty() {
return None;
return picked;
}
pictured
.get((day % pictured.len() as u64) as usize)
.copied()
let start = (day % pictured.len() as u64) as usize;
for title in pictured.iter().cycle().skip(start).take(pictured.len()) {
if picked.len() == FEATURED {
break;
}
if !picked.iter().any(|already| already.key == title.key) {
picked.push(title);
}
}
picked
}
/// The banner across the top of the library: one title, large, with what it
/// is and a way to play it.
fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<Action>) {
let height = (ui.ctx().content_rect().height() * 0.42).clamp(220.0, 360.0);
let (rect, response) = ui.allocate_exact_size(
/// Which of the featured titles the banner is on, and since when. Kept in
/// egui's memory: it is the banner's own business, and gone with the window.
#[derive(Clone, Copy)]
struct Turn {
shown: usize,
/// The one it is turning from, while the cross-fade lasts.
leaving: Option<usize>,
/// `InputState::time` at the last turn.
since: f64,
}
/// The banner across the top of the library: one title at a time, large, with
/// what it is and a way to play it, turning to the next every [`HOLD`]
/// seconds unless the pointer is over it.
///
/// Edge to edge rather than inside the page's margin, and square: a banner
/// in the margin with rounded corners read as one more card.
fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut Vec<Action>) {
let height = (ui.ctx().content_rect().height() * 0.5).clamp(280.0, 520.0);
let (column, _) = ui.allocate_exact_size(
egui::vec2(ui.available_width(), height),
egui::Sense::click(),
egui::Sense::hover(),
);
if !ui.is_rect_visible(rect) {
let rect = egui::Rect::from_min_max(
egui::pos2(column.left() - ui::PAGE_MARGIN.x, column.top()),
egui::pos2(column.right() + ui::PAGE_MARGIN.x, column.bottom()),
);
if titles.is_empty() || !ui.is_rect_visible(rect) {
return;
}
let radius = egui::CornerRadius::same(theme::radius::LG);
let picture = art.wide(title);
let found = art
.metadata
.get(&title.key)
.and_then(|record| record.metadata.clone());
let id = ui.id().with("hero");
let response = ui.interact(rect, id, egui::Sense::click());
let now = ui.input(|input| input.time);
let count = titles.len();
let mut turn = ui.data(|data| data.get_temp::<Turn>(id)).unwrap_or(Turn {
shown: 0,
leaving: None,
since: now,
});
// The list is worked out again every frame, and can be shorter than the
// last time — a title finished, a match removed.
turn.shown %= count;
turn.leaving = turn.leaving.filter(|&index| index < count);
let hovered = ui.rect_contains_pointer(rect);
let elapsed = now - turn.since;
if hovered && elapsed > f64::from(TURN) {
// Held where it is while it is being looked at, and the count starts
// again once the pointer leaves rather than turning the moment it does.
turn.since = now - f64::from(TURN);
} else if count > 1 && elapsed >= HOLD {
turn = Turn {
shown: (turn.shown + 1) % count,
leaving: Some(turn.shown),
since: now,
};
}
let progress = theme::motion::ease_out(((now - turn.since) / f64::from(TURN)) as f32);
if progress >= 1.0 {
turn.leaving = None;
}
let title = titles[turn.shown];
let painter = ui.painter().with_clip_rect(rect);
painter.rect_filled(rect, radius, theme::card());
let art_in = ui.ctx().animate_bool_with_time(
response.id.with("art"),
picture.is_some(),
theme::motion::ARRIVE,
);
if art_in < 1.0 {
ui::placeholder(&painter, rect, "", 1.0 - art_in);
}
if let Some((texture, _)) = &picture {
painter.add(
egui::epaint::RectShape::filled(
rect,
radius,
egui::Color32::WHITE.gamma_multiply(art_in),
)
.with_texture(texture.id(), ui::cover_uv(texture.size_vec2(), rect.size())),
);
painter.rect_filled(rect, egui::CornerRadius::ZERO, theme::card());
// A measure that reads as a column, not a line across a wide window.
let text_right = column.left() + (column.width() * 0.45).clamp(360.0, 720.0);
let mut veil = None;
if let Some(leaving) = turn.leaving {
veil = Some(backdrop(
ui,
&painter,
art,
titles[leaving],
rect,
text_right,
1.0,
));
}
let arriving = if turn.leaving.is_some() {
progress
} else {
1.0
};
let shown_veil = backdrop(ui, &painter, art, title, rect, text_right, arriving);
let veil = veil.map_or(shown_veil, |from| from.lerp_to_gamma(shown_veil, progress));
// The same two coats as the title page's band, for the same reason: a
// still is arbitrary, and the text over it has to read whatever it is.
// Tinted with the picture's own colour, so each title's banner has a light
// of its own; the fade at the bottom is the page's, because that is what
// it hands over to.
if theme::ramps_on() {
painter.rect_filled(rect, radius, theme::background().gamma_multiply(0.25));
painter.rect_filled(rect, egui::CornerRadius::ZERO, veil.gamma_multiply(0.3));
let text_side = egui::Rect::from_min_max(
rect.min,
egui::pos2(rect.left() + rect.width() * 0.7, rect.bottom()),
egui::pos2(
text_right + 0.5 * (rect.right() - text_right),
rect.bottom(),
),
);
painter.add(theme::fade_shape(
ui.ctx(),
text_side,
theme::background(),
veil,
theme::Direction::Horizontal,
false,
));
let bottom =
egui::Rect::from_min_max(egui::pos2(rect.left(), rect.bottom() - 90.0), rect.max);
egui::Rect::from_min_max(egui::pos2(rect.left(), rect.bottom() - 120.0), rect.max);
painter.add(theme::fade_shape(
ui.ctx(),
bottom,
@@ -220,36 +302,104 @@ fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<A
true,
));
} else {
painter.rect_filled(rect, radius, theme::background().gamma_multiply(0.82));
painter.rect_filled(rect, egui::CornerRadius::ZERO, veil.gamma_multiply(0.82));
}
let inner = egui::Rect::from_min_max(
rect.min + egui::vec2(32.0, 0.0),
egui::pos2(
rect.left() + (rect.width() * 0.55).max(360.0),
rect.bottom() - 28.0,
),
egui::pos2(column.left(), column.top() + theme::space::XL),
egui::pos2(text_right, column.bottom() - 40.0),
);
ui.scope_builder(
egui::UiBuilder::new()
.max_rect(inner)
.layout(egui::Layout::bottom_up(egui::Align::Min)),
|ui| {
ui.multiply_opacity(arriving);
hero_text(ui, art, title, actions);
},
);
if count > 1
&& let Some(picked) = dots(ui, id, column, titles, turn.shown)
{
if picked != turn.shown {
turn = Turn {
shown: picked,
leaving: Some(turn.shown),
since: now,
};
}
} else if response.clicked() {
actions.push(Action::Goto(Page::Title(title.key.clone())));
}
response
.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, art.name(title)));
ui.data_mut(|data| data.insert_temp(id, turn));
if turn.leaving.is_some() {
ui.ctx().request_repaint();
} else if count > 1 && !hovered {
let left = (HOLD - (now - turn.since)).max(0.0);
ui.ctx()
.request_repaint_after(std::time::Duration::from_secs_f64(left));
}
}
/// One title's picture across the banner, at `alpha`, and the colour its veils
/// are to be — the page's until the picture has arrived, then the picture's
/// own, eased in with it.
fn backdrop(
ui: &egui::Ui,
painter: &egui::Painter,
art: &mut Art<'_>,
title: &Title,
rect: egui::Rect,
text_right: f32,
alpha: f32,
) -> egui::Color32 {
let picture = art.wide(title);
let art_in = ui.ctx().animate_bool_with_time(
ui.id().with(("hero-art", &title.key)),
picture.is_some(),
theme::motion::ARRIVE,
);
if art_in < 1.0 {
ui::placeholder(painter, rect, "", alpha * (1.0 - art_in));
}
if let Some((texture, _)) = &picture {
painter.add(
egui::epaint::RectShape::filled(
rect,
egui::CornerRadius::ZERO,
egui::Color32::WHITE.gamma_multiply(alpha * art_in),
)
.with_texture(texture.id(), ui::cover_uv(texture.size_vec2(), rect.size())),
);
}
// With no wide art the banner is the poster's wash, so the poster itself
// stands at the right, where wide art keeps its subject — over the veils,
// and only where it clears the text.
// stands at the right, where wide art keeps its subject — and only where
// it clears the text.
if !art.has_wide_art(title)
&& let Some((poster, pstr_core::metadata::ArtShape::Portrait)) = art.poster(title)
{
const INSET: f32 = 28.0;
let height = rect.height() - 2.0 * INSET;
const INSET: f32 = 40.0;
let height = rect.height() - 2.0 * INSET - 40.0;
let size = egui::vec2(height * 2.0 / 3.0, height);
let at = egui::Rect::from_min_size(
egui::pos2(rect.right() - 2.0 * INSET - size.x, rect.top() + INSET),
size,
);
if at.left() > inner.right() + INSET {
if at.left() > text_right + INSET {
painter.add(
theme::tile_shadow(alpha * art_in)
.as_shape(at, egui::CornerRadius::same(theme::radius::MD)),
);
painter.add(
egui::epaint::RectShape::filled(
at,
egui::CornerRadius::same(theme::radius::MD),
egui::Color32::WHITE.gamma_multiply(art_in),
egui::Color32::WHITE.gamma_multiply(alpha * art_in),
)
.with_texture(
poster.id(),
@@ -258,70 +408,153 @@ fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<A
);
}
}
ui.scope_builder(
egui::UiBuilder::new()
.max_rect(inner)
.layout(egui::Layout::bottom_up(egui::Align::Min)),
|ui| {
ui.horizontal(|ui| {
if let Some(episode) = title.next_up() {
let label = match (title.resume(), episode.numbering()) {
(Some(_), Some(numbering)) => {
format!("{} Resume {numbering}", egui_phosphor::regular::PLAY)
}
(Some(_), None) => format!("{} Resume", egui_phosphor::regular::PLAY),
(None, _) => format!("{} Play", egui_phosphor::regular::PLAY),
};
if ui::accent_button(ui, &label).clicked() {
actions.push(Action::Play(PlaybackTarget::new(title, episode)));
}
ui.add_space(theme::space::S);
}
let more = format!("{} More info", egui_phosphor::regular::INFO);
if ui::tonal_button(ui, &more).clicked() {
actions.push(Action::Goto(Page::Title(title.key.clone())));
}
});
ui.add_space(theme::space::L);
if let Some(overview) = found.as_ref().and_then(|found| found.overview.as_deref()) {
let mut job = egui::text::LayoutJob::simple(
overview.to_owned(),
theme::Role::Body.font(),
theme::text(),
ui.available_width(),
);
job.wrap.max_rows = 3;
ui.label(job);
ui.add_space(theme::space::M);
}
let mut facts = vec![subtitle(title)];
if let Some(found) = &found {
if let Some(rating) = found.rating {
facts.push(format!("{} {rating:.1}", egui_phosphor::regular::STAR));
}
facts.extend(found.genres.iter().take(3).cloned());
}
ui.label(ui::muted(facts.join(" · ")));
ui.add_space(theme::space::XXS);
if ui::logo_heading(ui, art, title, 110.0).is_none() {
// Two lines before it gives up: a long romanised name cut to
// one at this size kept barely more than its first word.
let mut job = egui::text::LayoutJob::simple(
art.name(title).to_owned(),
theme::Role::Hero.font(),
theme::text(),
ui.available_width().min(640.0),
);
job.wrap.max_rows = 2;
job.wrap.overflow_character = Some('…');
ui.label(job);
}
},
);
theme::background().lerp_to_gamma(theme::mood_veil(art.mood(title)), art_in)
}
if response.clicked() {
actions.push(Action::Goto(Page::Title(title.key.clone())));
/// What the banner says about one title, from the bottom up: how far into it
/// the viewer is, the buttons, the synopsis, the facts and the name.
fn hero_text(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<Action>) {
let found = art
.metadata
.get(&title.key)
.and_then(|record| record.metadata.clone());
if let Some(episode) = title.resume()
&& let Some(progress) = episode.progress()
{
ui.horizontal(|ui| {
ui::progress_bar(ui, progress as f32, 180.0);
if let Some(left) = time_left(episode) {
ui.add_space(theme::space::S);
ui.label(ui::muted(left));
}
});
ui.add_space(theme::space::M);
}
ui.horizontal(|ui| {
if let Some(episode) = title.next_up() {
let label = match (title.resume(), episode.numbering()) {
(Some(_), Some(numbering)) => {
format!("{} Resume {numbering}", egui_phosphor::regular::PLAY)
}
(Some(_), None) => format!("{} Resume", egui_phosphor::regular::PLAY),
(None, _) => format!("{} Play", egui_phosphor::regular::PLAY),
};
if ui::accent_button(ui, &label).clicked() {
actions.push(Action::Play(PlaybackTarget::new(title, episode)));
}
ui.add_space(theme::space::S);
}
let more = format!("{} More info", egui_phosphor::regular::INFO);
if ui::tonal_button(ui, &more).clicked() {
actions.push(Action::Goto(Page::Title(title.key.clone())));
}
});
ui.add_space(theme::space::L);
if let Some(overview) = found.as_ref().and_then(|found| found.overview.as_deref()) {
let mut job = egui::text::LayoutJob::simple(
overview.to_owned(),
theme::Role::Body.font(),
theme::text(),
ui.available_width(),
);
job.wrap.max_rows = 3;
ui.label(job);
ui.add_space(theme::space::M);
}
let mut facts = vec![subtitle(title)];
if let Some(found) = &found {
if let Some(rating) = found.rating {
facts.push(format!("{} {rating:.1}", egui_phosphor::regular::STAR));
}
facts.extend(found.genres.iter().take(3).cloned());
}
ui.label(ui::muted(facts.join(" · ")));
ui.add_space(theme::space::XXS);
if ui::logo_heading(ui, art, title, 120.0).is_none() {
// Two lines before it gives up: a long romanised name cut to one at
// this size kept barely more than its first word.
let mut job = egui::text::LayoutJob::simple(
art.name(title).to_owned(),
theme::Role::Hero.font(),
theme::text(),
ui.available_width().min(640.0),
);
job.wrap.max_rows = 2;
job.wrap.overflow_character = Some('…');
ui.label(job);
}
}
/// "23 min left", for an episode part-way through. `None` when the file's
/// length is not known.
fn time_left(episode: &pstr_core::library::Episode) -> Option<String> {
let watch = episode.watch?;
let left = (watch.duration_secs? - watch.position_secs).max(0.0);
let minutes = (left / 60.0).ceil() as u64;
Some(if minutes >= 60 {
format!("{}h {}m left", minutes / 60, minutes % 60)
} else {
format!("{minutes} min left")
})
}
/// One dot per featured title at the banner's lower right, the one shown
/// drawn long. Returns the one clicked.
fn dots(
ui: &mut egui::Ui,
id: egui::Id,
column: egui::Rect,
titles: &[&Title],
shown: usize,
) -> Option<usize> {
const DOT: f32 = 8.0;
const LONG: f32 = 24.0;
const GAP: f32 = 8.0;
let mut picked = None;
// Clear of the scroll bar, which floats over the column's right edge.
let mut right = column.right() - theme::space::L;
let centre = column.bottom() - 58.0;
// Right to left, so the row ends at the column's edge whatever its width.
for (index, title) in titles.iter().enumerate().rev() {
let width = ui.ctx().animate_value_with_time(
id.with(("dot", index)),
if index == shown { LONG } else { DOT },
theme::motion::STATE,
);
let dot = egui::Rect::from_min_max(
egui::pos2(right - width, centre - DOT / 2.0),
egui::pos2(right, centre + DOT / 2.0),
);
right -= width + GAP;
let response = ui
.interact(
dot.expand2(egui::vec2(GAP / 2.0, 8.0)),
id.with(("dot-hit", index)),
egui::Sense::click(),
)
.on_hover_cursor(egui::CursorIcon::PointingHand);
response.widget_info(|| {
egui::WidgetInfo::selected(
egui::WidgetType::RadioButton,
true,
index == shown,
&title.name,
)
});
let ink = if index == shown {
theme::text()
} else if response.hovered() {
theme::text().gamma_multiply(0.7)
} else {
theme::text().gamma_multiply(0.35)
};
ui.painter()
.rect_filled(dot, egui::CornerRadius::same(4), ink);
if response.clicked() {
picked = Some(index);
}
}
picked
}
/// The filter pills, the order and the grouping, at the right of the grid's
@@ -784,12 +1017,67 @@ mod tests {
};
let metadata = std::collections::HashMap::new();
let mut view = LibraryView::default();
assert!(featured(&library, &view, &metadata, 0).is_none());
assert!(featured(&library, &view, &metadata, 0).is_empty());
view.resumable = vec![1];
assert_eq!(
featured(&library, &view, &metadata, 0).map(|title| title.key.as_str()),
Some("second")
);
let keys: Vec<&str> = featured(&library, &view, &metadata, 0)
.iter()
.map(|title| title.key.as_str())
.collect();
assert_eq!(keys, ["second"]);
}
fn pictured(key: &str) -> pstr_core::metadata::MetadataRecord {
use pstr_core::metadata::{MetadataRecord, ProviderId, TitleMetadata};
MetadataRecord {
title_key: key.into(),
provider: ProviderId::AniList,
metadata: Some(TitleMetadata {
provider: ProviderId::AniList,
remote_id: key.into(),
name: key.into(),
original_name: None,
overview: None,
year: None,
kind: TitleKind::Film,
poster_url: None,
backdrop_url: Some(format!("https://example.com/{key}.jpg")),
logo_url: None,
rating: None,
genres: Vec::new(),
episodes: None,
url: None,
details: None,
}),
fetched_at: 0,
manual: false,
}
}
#[test]
fn the_banner_turns_through_five_pictured_titles_after_the_one_being_watched() {
let titles: Vec<Title> = (0..8)
.map(|index| {
let mut one = title(TitleKind::Film, None);
one.key = format!("t{index}");
one
})
.collect();
let library = Library { titles };
let metadata: std::collections::HashMap<_, _> = (0..8)
.map(|index| (format!("t{index}"), pictured(&format!("t{index}"))))
.collect();
let mut view = LibraryView::default();
view.resumable = vec![5];
let keys = |day| -> Vec<String> {
featured(&library, &view, &metadata, day)
.iter()
.map(|title| title.key.clone())
.collect()
};
assert_eq!(keys(2), ["t5", "t2", "t3", "t4", "t6"]);
// The same order all day, and a different start the next.
assert_eq!(keys(2), keys(2));
assert_eq!(keys(3)[1], "t3");
}
#[test]
+16
View File
@@ -143,6 +143,18 @@ impl Art<'_> {
self.of(title)
}
/// The colour of whatever [`Art::wide`] is drawing for `title`, once it
/// has arrived — what that title's veils are tinted with. See
/// [`theme::mood_veil`].
pub fn mood(&self, title: &Title) -> Option<egui::Color32> {
[wide_key, ambient_key, poster_key]
.into_iter()
.map(|key| key(&title.key))
// The tile's own picture last: what `wide` falls back on.
.chain(std::iter::once(title.key.clone()))
.find_map(|key| self.posters.mood(&key))
}
/// Whether `title` has a picture made for a strip across the window, rather
/// than [`Art::wide`] standing one in.
pub fn has_wide_art(&self, title: &Title) -> bool {
@@ -203,6 +215,10 @@ impl Art<'_> {
}
}
/// The central panel's margin around every page. Anything drawn edge to edge —
/// the library's banner, the title page's band — reaches past it.
pub const PAGE_MARGIN: egui::Vec2 = egui::vec2(18.0, 12.0);
/// The texture key of a title's cover, beside [`Art::of`]'s, which is the
/// title key itself.
pub fn poster_key(title_key: &str) -> String {
+15 -5
View File
@@ -461,7 +461,7 @@ fn header(
if let Some((texture, index)) = backdrop {
ui.add_space(theme::space::L);
band(ui, &texture, index);
band(ui, &texture, index, art.mood(title));
}
}
@@ -475,10 +475,15 @@ fn header(
/// page colour so text laid over it stays readable whatever the still happens to
/// be, and a fade into the page along the bottom so the band ends rather than
/// stops.
fn band(ui: &mut egui::Ui, texture: &egui::TextureHandle, index: egui::layers::ShapeIdx) {
fn band(
ui: &mut egui::Ui,
texture: &egui::TextureHandle,
index: egui::layers::ShapeIdx,
mood: Option<egui::Color32>,
) {
// Full width of the window, not of the content: the panel's own margin is
// what the band has to reach past, or it reads as a picture in a box.
const BLEED: f32 = 18.0;
const BLEED: f32 = super::PAGE_MARGIN.x;
let content = ui.min_rect();
let rect = egui::Rect::from_min_max(
egui::pos2(ui.max_rect().left() - BLEED, content.top()),
@@ -512,7 +517,12 @@ fn band(ui: &mut egui::Ui, texture: &egui::TextureHandle, index: egui::layers::S
//
// Heavier still when there is no fade coming, since then these are the only
// things standing between the text and the picture.
//
// Both in the page colour tinted by the picture's own, so each title's
// page carries its light; the fade at the bottom stays the page's, since
// that is what it ends in.
let ramps = theme::ramps_on();
let veil = theme::mood_veil(mood);
layers.push(
egui::epaint::RectShape::filled(
rect,
@@ -520,7 +530,7 @@ fn band(ui: &mut egui::Ui, texture: &egui::TextureHandle, index: egui::layers::S
// Lighter with the ramps on: the horizontal one below is solid
// where the text is, so this coat only has to calm the picture's
// right side, not make it disappear.
theme::background().gamma_multiply(if ramps { 0.72 } else { 0.94 }),
veil.gamma_multiply(if ramps { 0.72 } else { 0.94 }),
)
.into(),
);
@@ -532,7 +542,7 @@ fn band(ui: &mut egui::Ui, texture: &egui::TextureHandle, index: egui::layers::S
layers.push(theme::fade_shape(
ui.ctx(),
text_side,
theme::background(),
veil,
theme::Direction::Horizontal,
// Solid at the left, where the poster and the text are.
false,
+120
View File
@@ -280,6 +280,81 @@ pub fn worst_contrast(from: Rgb, to: Rgb, ink: Rgb) -> f32 {
/// a tab, a play glyph, never body copy.
pub const MIN_CONTRAST: f32 = 3.0;
/// The colour a picture leaves behind: its pixels averaged, with the vivid
/// ones counting for more. `None` for a picture with nothing opaque in it.
///
/// Weighted rather than a plain mean, because a plain mean of almost any
/// still is a brownish grey — the sky and the shadows cancel out, and the one
/// red coat that makes the poster what it is barely moves the sum. Each pixel
/// counts once plus [`MOOD_CHROMA_WEIGHT`] times its chroma, so a saturated
/// patch pulls the result towards itself without a single pixel deciding it.
///
/// Takes whatever pixels the caller has, in any order and at any size: every
/// client hands over a picture already squeezed small, so this is a few
/// hundred values, not a frame.
pub fn mood(pixels: impl IntoIterator<Item = (Rgb, u8)>) -> Option<Rgb> {
let (mut r, mut g, mut b, mut total) = (0.0_f32, 0.0_f32, 0.0_f32, 0.0_f32);
for (pixel, alpha) in pixels {
// Half transparent and below is a logo's anti-aliasing or a poster's
// rounded corner, not the picture.
if alpha < 128 {
continue;
}
let high = pixel.r.max(pixel.g).max(pixel.b);
let low = pixel.r.min(pixel.g).min(pixel.b);
let weight = 1.0 + MOOD_CHROMA_WEIGHT * f32::from(high - low) / 255.0;
r += weight * f32::from(pixel.r);
g += weight * f32::from(pixel.g);
b += weight * f32::from(pixel.b);
total += weight;
}
(total > 0.0).then(|| {
let channel = |sum: f32| (sum / total).round().clamp(0.0, 255.0) as u8;
Rgb::new(channel(r), channel(g), channel(b))
})
}
/// How much more a fully saturated pixel counts towards [`mood`] than a grey
/// one.
pub const MOOD_CHROMA_WEIGHT: f32 = 4.0;
/// The brightest channel a mood is brought down to before it tints a dark
/// palette. Any brighter and the veil stops being the page with a hint of the
/// picture in it, and text over it loses the contrast the veil is there for.
pub const MOOD_DEPTH: u8 = 60;
/// How far a dark palette's veil moves from the page colour towards the mood.
pub const MOOD_DARK: f32 = 0.55;
/// The same for a light palette — much less, because a light page tinted as
/// hard reads as a coloured page rather than a lit one.
pub const MOOD_LIGHT: f32 = 0.12;
/// The page colour, tinted by a picture's [`mood`]: what the veils over a
/// title's backdrop are painted in, so each title's banner carries its own
/// light rather than the same flat colour.
///
/// On a dark palette the mood is first darkened to [`MOOD_DEPTH`] — only its
/// hue and saturation are wanted, never its brightness — and the page moves
/// [`MOOD_DARK`] of the way to it. A light palette moves [`MOOD_LIGHT`] of the
/// way to the mood as it is.
pub fn mood_veil(background: Rgb, mood: Rgb, light: bool) -> Rgb {
if light {
return mix(background, mood, MOOD_LIGHT);
}
let high = mood.r.max(mood.g).max(mood.b);
if high == 0 {
return mix(background, mood, MOOD_DARK);
}
let scale = f32::from(MOOD_DEPTH.min(high)) / f32::from(high);
let deep = |channel: u8| (f32::from(channel) * scale).round() as u8;
mix(
background,
Rgb::new(deep(mood.r), deep(mood.g), deep(mood.b)),
MOOD_DARK,
)
}
/// Every colour a client draws with, resolved from a [`Flavor`] and an
/// [`Accent`].
///
@@ -627,6 +702,51 @@ mod tests {
}
}
#[test]
fn a_picture_of_one_colour_has_that_colour_as_its_mood() {
let teal = Rgb::new(20, 140, 150);
assert_eq!(mood(std::iter::repeat_n((teal, 255), 16)), Some(teal));
}
#[test]
fn a_vivid_patch_outweighs_as_much_grey_in_the_mood() {
let red = Rgb::new(220, 30, 30);
let grey = Rgb::new(120, 120, 120);
let found = mood([(red, 255), (grey, 255)]).unwrap();
assert!(found.r > 180, "{found:?}");
assert!(found.g < 60 && found.b < 60, "{found:?}");
}
#[test]
fn transparent_pixels_do_not_count_towards_the_mood() {
let blue = Rgb::new(30, 60, 200);
assert_eq!(
mood([(blue, 255), (Rgb::new(255, 255, 255), 0)]),
Some(blue)
);
assert_eq!(mood([(blue, 10)]), None);
assert_eq!(mood(std::iter::empty()), None);
}
#[test]
fn a_dark_veil_takes_the_moods_hue_but_never_its_brightness() {
let background = Rgb::new(11, 11, 15);
let veil = mood_veil(background, Rgb::new(255, 240, 40), false);
// Warmer than the page…
assert!(veil.r > background.r && veil.g > background.g, "{veil:?}");
// …and still dark enough to be a page under white text.
assert!(luminance(veil) < 0.05, "{veil:?}");
assert!(contrast(veil, Rgb::new(255, 255, 255)) > 12.0, "{veil:?}");
}
#[test]
fn a_light_veil_moves_only_a_little_way_to_the_mood() {
let background = Rgb::new(239, 241, 245);
let veil = mood_veil(background, Rgb::new(200, 20, 20), true);
assert!(veil.r >= veil.g, "{veil:?}");
assert!(luminance(veil) > 0.6, "{veil:?}");
}
#[test]
fn a_first_run_gets_the_palette_the_app_shipped_with() {
let appearance = Appearance::default();
+15 -1
View File
@@ -63,9 +63,23 @@ this module, and keep Material's sizes. Nothing in the app draws with them.
buttons, the tab pill. **4** (`BADGE`) for a badge over artwork. The desktop
also has **3** (`BAR`) for progress bars and edge strips, **6** (`SM`) for a
small control inside something else, and **12** (`LG`) for what floats — row
cards, dialogs, toasts, the hero. **16** for an Android bottom sheet, which has
cards, dialogs, toasts. The library's banner is square: it runs edge to edge. **16** for an Android bottom sheet, which has
no desktop counterpart.
## A picture's colour
The veils over a title's backdrop — the library's banner, the top of a title's
page — are the page colour tinted by the picture under them, so each title
carries its own light. All three clients get the same colour from the same
code: `pstr_core::appearance::mood` averages the picture, squeezed to a few
hundred pixels, counting a saturated pixel up to five times a grey one
(`MOOD_CHROMA_WEIGHT`), and `mood_veil` turns that into the veil. On a dark
palette the mood is darkened until its brightest channel is 60 (`MOOD_DEPTH`)
and the page moves 55% of the way to it (`MOOD_DARK`); a light palette moves
12% (`MOOD_LIGHT`). The phones reach both through the bridge, as
`pictureMood` and `moodVeil`. The fade at the bottom stays the page colour,
because that is what it ends in.
## Spacing
The desktop spaces things from one scale, `theme::space`: 2, 4, 6, 8, 12, 16,
+5 -1
View File
@@ -13,7 +13,11 @@ The app has five pages, in the bar at the top: **Library**, **History**, **Share
The library shows every title from every share in one place. Two shares that hold the same show
show it once.
- **The banner** at the top shows the title you watched last, with **Resume** and **More info**.
- **The banner** across the top shows the title you watched last first, with **Resume**, how far
into it you are and **More info**. It then turns through up to four more titles that have a wide
picture, a different set each day, moving on every eight seconds. It holds still while the
pointer is over it; on a phone, swipe it or tap a dot under it to move on yourself. Its shading
takes on the colours of the picture behind it, and so does the top of a title's page.
- **Continue watching** has the episode you stopped in for each show, or the next one when you
finished an episode. To take a show off this row, right-click it and choose **Remove from
Continue watching**.
+205 -73
View File
@@ -77,35 +77,57 @@ struct LibraryView: View {
// library spent a band of every visit on something used in few of them.
@SceneStorage("library.searching") private var searching = false
@State private var collapsed = false
/// How far the bar over the banner has filled in, from clear to solid.
@State private var reveal: CGFloat = 0
/// The page's bar: its title, shown as much as `solid` says, and its
/// actions, in `ink` or the scheme's own.
private func bar(solid: CGFloat, ink: Color?) -> some View {
HStack(spacing: 0) {
Text("Library")
.textStyle(.titleLarge)
.foregroundStyle(scheme.onSurface)
.padding(.leading, 16)
.opacity(solid)
Spacer()
IconButton("magnifyingglass", "Search library", tint: ink) { searching = true }
ArrangeMenu(sort: model.sort, grouped: model.grouped, onArrange: model.arrange)
// Pull to refresh is the gesture; the icon stays for a viewer who
// does not know it.
IconButton("arrow.clockwise", "Refresh", tint: ink, enabled: !model.refreshing) { model.refresh() }
}
.frame(height: 56)
.padding(.trailing, 4)
}
var body: some View {
let resumable = resumable(model.titles)
VStack(spacing: 0) {
if searching {
SearchBar(query: Binding(get: { model.query }, set: model.search)) {
searching = false
model.search("")
let browsing = model.query.trimmingCharacters(in: .whitespaces).isEmpty
let featured = featuredTitles(model.titles, resumable)
// With a banner, the bar lies over it, clear, and fills in as the
// banner scrolls away; without one, the large title folds into the bar
// as the grid scrolls.
let overBanner = !searching && !model.loading && browsing && !featured.isEmpty
ZStack(alignment: .top) {
VStack(spacing: 0) {
if searching {
SearchBar(query: Binding(get: { model.query }, set: model.search)) {
searching = false
model.search("")
}
} else if !overBanner {
bar(solid: collapsed ? 1 : 0, ink: nil)
.background(collapsed ? scheme.surfaceContainer : scheme.background)
}
} else {
HStack(spacing: 0) {
// The large title folds into the bar as the grid scrolls.
Text("Library")
.textStyle(.titleLarge)
.foregroundStyle(scheme.onSurface)
.padding(.leading, 16)
.opacity(collapsed ? 1 : 0)
Spacer()
IconButton("magnifyingglass", "Search library") { searching = true }
ArrangeMenu(sort: model.sort, grouped: model.grouped, onArrange: model.arrange)
// Pull to refresh is the gesture; the icon stays for a
// viewer who does not know it.
IconButton("arrow.clockwise", "Refresh", enabled: !model.refreshing) { model.refresh() }
}
.frame(height: 56)
.padding(.trailing, 4)
.background(collapsed ? scheme.surfaceContainer : scheme.background)
content(resumable, featured: overBanner ? featured : [])
}
if overBanner {
// White over the banner's own scrim, then the page's ink once
// the bar has filled in.
bar(solid: reveal, ink: reveal > 0.5 ? nil : .white)
.background(scheme.surfaceContainer.opacity(reveal).ignoresSafeArea(edges: .top))
.animation(.easeOut(duration: 0.15), value: reveal > 0.5)
}
content(resumable)
}
.background(scheme.background)
.sheet(item: $menuFor, onDismiss: {
@@ -150,7 +172,7 @@ struct LibraryView: View {
}
}
@ViewBuilder private func content(_ resumable: [Resumable]) -> some View {
@ViewBuilder private func content(_ resumable: [Resumable], featured: [TitleRecord]) -> some View {
if model.loading {
LibrarySkeleton()
} else if model.titles.isEmpty && !model.query.trimmingCharacters(in: .whitespaces).isEmpty {
@@ -170,11 +192,11 @@ struct LibraryView: View {
}
.refreshable { await model.crawlNow(nil) }
} else {
grid(resumable)
grid(resumable, featured: featured)
}
}
private func grid(_ resumable: [Resumable]) -> some View {
private func grid(_ resumable: [Resumable], featured: [TitleRecord]) -> some View {
// The arrangement names titles by key; before it has arrived, the
// titles as they came are the grid.
let byKey = Dictionary(model.titles.map { ($0.key, $0) }, uniquingKeysWith: { first, _ in first })
@@ -184,11 +206,25 @@ struct LibraryView: View {
.map { Shelf(label: $0.label, titles: $0.keys.compactMap { byKey[$0] }) }
.filter { !$0.titles.isEmpty }
let browsing = model.query.trimmingCharacters(in: .whitespaces).isEmpty
let featured = browsing ? featuredTitle(model.titles, resumable) : nil
return ScrollView {
LazyVStack(alignment: .leading, spacing: 16) {
if !searching {
if !featured.isEmpty {
FeaturedBanner(titles: featured, resumable: resumable, onPlay: onResume, onOpen: onTitle)
// Edge to edge, out of the grid's margin.
.padding(.horizontal, -16)
.background {
GeometryReader { geometry in
Color.clear.onChange(of: geometry.frame(in: .named("library")).minY) { _, top in
reveal = min(max(-top / 280, 0), 1)
}
}
}
// Scrolled far enough to leave the lazy stack: the bar
// is long since solid.
.onDisappear { reveal = 1 }
.onAppear { reveal = 0 }
} else if !searching {
Text("Library")
.textStyle(.headlineMedium)
.foregroundStyle(scheme.onSurface)
@@ -206,14 +242,6 @@ struct LibraryView: View {
SettingsStore().metadataOfferDismissed = true
}
}
if let featured {
FeaturedBanner(
title: featured,
resume: resumable.first { $0.title.key == featured.key },
onPlay: { onResume(featured, $0) },
onOpen: { onTitle(featured) }
)
}
if browsing && !resumable.isEmpty {
VStack(alignment: .leading, spacing: 10) {
Text("Continue watching").textStyle(.titleMedium).foregroundStyle(scheme.onSurface)
@@ -273,8 +301,11 @@ struct LibraryView: View {
}
}
}
.padding(EdgeInsets(top: 8, leading: 16, bottom: 24, trailing: 16))
// No room above a banner: it starts under the status bar, and the
// bar lies over it.
.padding(EdgeInsets(top: featured.isEmpty ? 8 : 0, leading: 16, bottom: 24, trailing: 16))
}
.ignoresSafeArea(edges: featured.isEmpty ? [] : .top)
.coordinateSpace(name: "library")
.refreshable { await model.crawlNow(nil) }
.scrollDismissesKeyboard(.immediately)
@@ -475,59 +506,160 @@ private struct MetadataOfferCard: View {
}
}
/// The title the banner shows: the one watched last, or with nothing part
/// watched, one of the titles the provider has a backdrop for, turning over
/// once a day — the desktop's `featured`.
private func featuredTitle(_ titles: [TitleRecord], _ resumable: [Resumable]) -> TitleRecord? {
if let first = resumable.first { return first.title }
/// How many titles the banner turns through: the desktop's `FEATURED`.
private let featuredMax = 5
/// How long each title holds the banner: the desktop's `HOLD`.
private let holdSeconds = 8.0
/// One title giving way to the next: the desktop's `TURN`.
private let turn = Animation.easeInOut(duration: 0.6)
/// The titles the banner turns through: the one watched last first, then
/// titles the provider has a backdrop for, starting from a different one each
/// day — the desktop's `featured`.
private func featuredTitles(_ titles: [TitleRecord], _ resumable: [Resumable]) -> [TitleRecord] {
var picked = resumable.first.map { [$0.title] } ?? []
let pictured = titles.filter { $0.backdropUrl != nil }
if pictured.isEmpty { return nil }
if pictured.isEmpty { return picked }
let day = Int(Date().timeIntervalSince1970 + Double(TimeZone.current.secondsFromGMT())) / 86400
return pictured[day % pictured.count]
for offset in pictured.indices {
if picked.count == featuredMax { break }
let title = pictured[(day + offset) % pictured.count]
if !picked.contains(where: { $0.key == title.key }) { picked.append(title) }
}
return picked
}
/// Large art behind the name, rating, genres and three lines of synopsis.
/// The banner at the top of the library: one title at a time, edge to edge
/// under the status bar, turning to the next every `holdSeconds` — a swipe
/// starts the count again. Each title's veil is tinted with its own picture's
/// colour.
private struct FeaturedBanner: View {
let titles: [TitleRecord]
let resumable: [Resumable]
let onPlay: (TitleRecord, Int) -> Void
let onOpen: (TitleRecord) -> Void
@Environment(\.scheme) private var scheme
@State private var shown = ""
var body: some View {
let current = titles.contains { $0.key == shown } ? shown : titles.first?.key ?? ""
TabView(selection: Binding(get: { current }, set: { shown = $0 })) {
ForEach(titles, id: \.key) { title in
FeaturedPage(
title: title,
resume: resumable.first { $0.title.key == title.key },
onPlay: { onPlay(title, $0) },
onOpen: { onOpen(title) }
)
.tag(title.key)
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
// Most of a phone's first screen, but never so much of an iPad's that
// nothing under it shows.
.containerRelativeFrame(.vertical) { height, _ in min(max(height * 0.62, 360), 620) }
.overlay(alignment: .bottom) {
if titles.count > 1 {
HStack(spacing: 6) {
ForEach(titles, id: \.key) { title in
let selected = title.key == current
Capsule()
.fill(scheme.onBackground.opacity(selected ? 1 : 0.4))
.frame(width: selected ? 18 : 6, height: 6)
.contentShape(Rectangle().inset(by: -8))
.onTapGesture { withAnimation(turn) { shown = title.key } }
.accessibilityLabel("Show \(title.displayName)")
.accessibilityAddTraits(selected ? [.isButton, .isSelected] : .isButton)
}
}
.animation(.easeOut(duration: 0.15), value: current)
.padding(.bottom, 10)
}
}
.task(id: current) {
guard titles.count > 1 else { return }
try? await Task.sleep(for: .seconds(holdSeconds))
if Task.isCancelled { return }
let index = titles.firstIndex { $0.key == current } ?? 0
withAnimation(turn) { shown = titles[(index + 1) % titles.count].key }
}
}
}
/// One title on the banner: its art, its veil, and what it is, from the
/// bottom up.
private struct FeaturedPage: View {
let title: TitleRecord
let resume: Resumable?
let onPlay: (Int) -> Void
let onOpen: () -> Void
@Environment(\.scheme) private var scheme
@State private var mood: UInt32?
var body: some View {
Backdrop(title: title)
.aspectRatio(4 / 3, contentMode: .fit)
.overlay {
LinearGradient(
stops: [.init(color: .clear, location: 0.35), .init(color: .black.opacity(0.85), location: 1)],
startPoint: .top,
endPoint: .bottom
)
}
.overlay(alignment: .bottomLeading) {
VStack(alignment: .leading, spacing: 0) {
// Always over the dark scrim above, so a logo made for dark
// art reads here whatever the theme.
RemoteLogo(title.logoUrl, title.displayName, maxWidth: 240, maxHeight: 72) {
Text(title.displayName).textStyle(.displayLarge).foregroundStyle(.white).lineLimit(2)
}
.padding(.bottom, 6)
Text(factLine).textStyle(.bodySmall).foregroundStyle(.white.opacity(0.8))
if let overview = title.overview {
Text(overview.trimmingCharacters(in: .whitespacesAndNewlines))
.textStyle(.bodySmall).foregroundStyle(.white.opacity(0.8)).lineLimit(3).padding(.top, 6)
let veil = scheme.veil(mood)
ZStack(alignment: .bottomLeading) {
Backdrop(title: title) { mood = $0 }
LinearGradient(
stops: [
// Dark under the status bar and the bar's white icons…
.init(color: .black.opacity(0.5), location: 0),
.init(color: .clear, location: 0.22),
// …and the picture's own colour under the text, ending in
// the page it hands over to.
.init(color: veil.opacity(0.35), location: 0.45),
.init(color: veil.opacity(0.92), location: 0.78),
.init(color: scheme.background, location: 1),
],
startPoint: .top,
endPoint: .bottom
)
VStack(alignment: .leading, spacing: 0) {
// A logo is lettering made for dark art, white as often as
// not; over a light palette's veil it is gone, so the light
// palette keeps the name.
if scheme.light {
name
} else {
RemoteLogo(title.logoUrl, title.displayName, maxWidth: 260, maxHeight: 88) { name }
.padding(.bottom, 8)
}
Text(factLine).textStyle(.bodySmall).foregroundStyle(scheme.onBackground.opacity(0.8))
if let overview = title.overview {
Text(overview.trimmingCharacters(in: .whitespacesAndNewlines))
.textStyle(.bodySmall).foregroundStyle(scheme.onBackground.opacity(0.8)).lineLimit(2).padding(.top, 6)
}
HStack(spacing: 8) {
Button { onPlay(resume?.index ?? nextUpIndex(title.playlist, continueIndex: title.continueIndex)) } label: {
Label(resume?.episode.resumeAt != nil ? "Resume" : "Play", systemImage: "play.fill")
}
.buttonStyle(.accent)
Button(action: onOpen) { Label("More info", systemImage: "info.circle") }.buttonStyle(.tonal)
}
.padding(.top, 12)
if let episode = resume?.episode, episode.resumeAt != nil, let progress = episode.progress {
HStack(spacing: 8) {
Button { onPlay(resume?.index ?? nextUpIndex(title.playlist, continueIndex: title.continueIndex)) } label: {
Label(resume != nil ? "Resume" : "Play", systemImage: "play.fill")
AccentProgress(progress: progress).frame(width: 120, height: 4)
if let left = episode.timeLeft {
Text(left).textStyle(.bodySmall).foregroundStyle(scheme.onBackground.opacity(0.8))
}
.buttonStyle(.accent)
Button("More info", action: onOpen).buttonStyle(.tonal)
}
.padding(.top, 12)
}
.padding(16)
}
.clipShape(RoundedRectangle(cornerRadius: Corner.standard))
.frame(maxWidth: 560, alignment: .leading)
.padding(EdgeInsets(top: 0, leading: 16, bottom: 28, trailing: 16))
}
.animation(Motion.arrive, value: mood)
.clipped()
.contentShape(Rectangle())
.onTapGesture(perform: onOpen)
}
private var name: some View {
Text(title.displayName).textStyle(.displayLarge).foregroundStyle(scheme.onBackground).lineLimit(2).padding(.bottom, 4)
}
private var factLine: String {
+73 -3
View File
@@ -43,13 +43,22 @@ struct RemoteArtwork: View {
let description: String
var fallback: ThumbnailSource?
var labelled = true
/// Told the picture's colour once it has arrived — see `pictureMood`.
var onMood: ((UInt32) -> Void)?
@State private var load: ArtworkLoad
init(_ url: String?, _ description: String, fallback: ThumbnailSource? = nil, labelled: Bool = true) {
init(
_ url: String?,
_ description: String,
fallback: ThumbnailSource? = nil,
labelled: Bool = true,
onMood: ((UInt32) -> Void)? = nil
) {
self.url = url
self.description = description
self.fallback = fallback
self.labelled = labelled
self.onMood = onMood
// A poster already decoded draws in the first frame, so scrolling back
// over the grid does not flash a skeleton per tile.
_load = State(initialValue: ArtworkLoader.shared.cached(url: url, fallback: fallback).map(ArtworkLoad.loaded) ?? .pending)
@@ -71,28 +80,89 @@ struct RemoteArtwork: View {
.accessibilityElement()
.accessibilityLabel(description)
.task(id: Request(url: url, fallback: fallback)) {
if case .loaded = load, ArtworkLoader.shared.cached(url: url, fallback: fallback) != nil { return }
if case let .loaded(image) = load, ArtworkLoader.shared.cached(url: url, fallback: fallback) != nil {
report(image)
return
}
let loaded = await ArtworkLoader.shared.artwork(url: url, fallback: fallback)
if Task.isCancelled { return }
withAnimation(Motion.arrive) { load = loaded.map(ArtworkLoad.loaded) ?? .missing }
if let loaded { report(loaded) }
}
}
private func report(_ image: UIImage) {
guard let onMood, let mood = moodOf(image) else { return }
onMood(mood)
}
private struct Request: Hashable {
let url: String?
let fallback: ThumbnailSource?
}
}
/// A picture squeezed to this many pixels a side is still its colours, and a
/// few hundred values to average — Android's `MOOD_EDGE`.
private let moodEdge = 24
/// The colour a picture leaves behind, as `0xAARRGGBB`: the shared
/// `appearance::mood`, over the picture squeezed to `moodEdge` pixels a side.
func moodOf(_ image: UIImage) -> UInt32? {
guard let cgImage = image.cgImage else { return nil }
var bytes = [UInt8](repeating: 0, count: moodEdge * moodEdge * 4)
let drawn: Bool = bytes.withUnsafeMutableBytes { buffer in
guard let context = CGContext(
data: buffer.baseAddress,
width: moodEdge,
height: moodEdge,
bitsPerComponent: 8,
bytesPerRow: moodEdge * 4,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else { return false }
context.interpolationQuality = .medium
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: moodEdge, height: moodEdge))
return true
}
guard drawn else { return nil }
let pixels: [UInt32] = stride(from: 0, to: bytes.count, by: 4).map { index in
let alpha = UInt32(bytes[index + 3])
// Premultiplied as drawn; the average wants the colour itself.
let channel = { (value: UInt8) -> UInt32 in
alpha == 0 ? 0 : min(255, UInt32(value) * 255 / alpha)
}
return alpha << 24 | channel(bytes[index]) << 16 | channel(bytes[index + 1]) << 8 | channel(bytes[index + 2])
}
return pictureMood(pixels: pixels)
}
extension Scheme {
/// The page colour tinted by a picture's `mood`, for the veils over that
/// picture — the shared `appearance::mood_veil`. The page colour itself
/// until the mood is known.
func veil(_ mood: UInt32?) -> Color {
guard let mood else { return background }
return argb(moodVeil(background: backgroundArgb, mood: mood, light: light))
}
}
/// A title's art across the top of a page: its backdrop, or with none, its
/// poster as a wash of its own colours — blurred, so it reads as the poster's
/// light rather than a blown-up slice of it. The desktop's `Fit::Ambient`.
struct Backdrop: View {
let title: TitleRecord
var onMood: ((UInt32) -> Void)?
var body: some View {
let wash = title.backdropUrl == nil && title.posterUrl != nil
RemoteArtwork(title.backdropUrl ?? title.posterUrl, title.displayName, fallback: title.thumbnailSource, labelled: false)
RemoteArtwork(
title.backdropUrl ?? title.posterUrl,
title.displayName,
fallback: title.thumbnailSource,
labelled: false,
onMood: onMood
)
.blur(radius: wash ? 24 : 0, opaque: true)
}
}
+4
View File
@@ -53,6 +53,9 @@ final class AppearanceState {
/// `schemeOf` line for line; `SchemeRolesTests` pins it.
struct Scheme: Equatable {
let light: Bool
/// `background` as the bridge hands it over, for the shared colour work
/// that takes it back — `Scheme.veil`.
let backgroundArgb: UInt32
let primary: Color
let onPrimary: Color
let primaryContainer: Color
@@ -91,6 +94,7 @@ struct Scheme: Equatable {
init(_ palette: PaletteRecord) {
light = palette.light
backgroundArgb = palette.background
primary = argb(palette.accent)
onPrimary = argb(palette.onAccent)
primaryContainer = argb(palette.accentDim)