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.
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
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 811 KiB After Width: | Height: | Size: 867 KiB |
|
Before Width: | Height: | Size: 767 KiB After Width: | Height: | Size: 826 KiB |
|
Before Width: | Height: | Size: 671 KiB After Width: | Height: | Size: 782 KiB |
|
Before Width: | Height: | Size: 805 KiB After Width: | Height: | Size: 887 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.1 MiB |
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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**.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||