diff --git a/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt index f87ac7a..556e8e6 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt @@ -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 = 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, resumable: List): TitleRecord? { - resumable.firstOrNull()?.let { return it.title } +private fun featured(titles: List, resumable: List): List { + 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, + resumable: List, + 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(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) { diff --git a/android/app/src/main/java/io/narl/protonstream/ui/RemoteArtwork.kt b/android/app/src/main/java/io/narl/protonstream/ui/RemoteArtwork.kt index 966b649..83310a7 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/RemoteArtwork.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/RemoteArtwork.kt @@ -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(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 diff --git a/android/app/src/test/screenshots/library-phone-large-font.png b/android/app/src/test/screenshots/library-phone-large-font.png index 3311a70..7857e7a 100644 Binary files a/android/app/src/test/screenshots/library-phone-large-font.png and b/android/app/src/test/screenshots/library-phone-large-font.png differ diff --git a/android/app/src/test/screenshots/library-phone-light.png b/android/app/src/test/screenshots/library-phone-light.png index c81199d..0e13daf 100644 Binary files a/android/app/src/test/screenshots/library-phone-light.png and b/android/app/src/test/screenshots/library-phone-light.png differ diff --git a/android/app/src/test/screenshots/library-phone-offer.png b/android/app/src/test/screenshots/library-phone-offer.png index 521b6cc..c33acac 100644 Binary files a/android/app/src/test/screenshots/library-phone-offer.png and b/android/app/src/test/screenshots/library-phone-offer.png differ diff --git a/android/app/src/test/screenshots/library-phone.png b/android/app/src/test/screenshots/library-phone.png index b5e751b..b4df4c5 100644 Binary files a/android/app/src/test/screenshots/library-phone.png and b/android/app/src/test/screenshots/library-phone.png differ diff --git a/android/app/src/test/screenshots/library-tablet.png b/android/app/src/test/screenshots/library-tablet.png index 6273b4b..6f1b516 100644 Binary files a/android/app/src/test/screenshots/library-tablet.png and b/android/app/src/test/screenshots/library-tablet.png differ diff --git a/crates/pstr-android/src/lib.rs b/crates/pstr-android/src/lib.rs index f0b824a..5a83156 100644 --- a/crates/pstr-android/src/lib.rs +++ b/crates/pstr-android/src/lib.rs @@ -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) -> Option { + 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 { diff --git a/crates/pstr-app/src/app.rs b/crates/pstr-app/src/app.rs index 5ce42bb..00434b7 100644 --- a/crates/pstr-app/src/app.rs +++ b/crates/pstr-app/src/app.rs @@ -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); diff --git a/crates/pstr-app/src/engine.rs b/crates/pstr-app/src/engine.rs index d125605..1dd0220 100644 --- a/crates/pstr-app/src/engine.rs +++ b/crates/pstr-app/src/engine.rs @@ -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, +} + +/// 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 { + 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 { + 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(); diff --git a/crates/pstr-app/src/theme.rs b/crates/pstr-app/src/theme.rs index 74e5b47..83b0185 100644 --- a/crates/pstr-app/src/theme.rs +++ b/crates/pstr-app/src/theme.rs @@ -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 { + 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 diff --git a/crates/pstr-app/src/ui/library.rs b/crates/pstr-app/src/ui/library.rs index 8cc2238..0507f6d 100644 --- a/crates/pstr-app/src/ui/library.rs +++ b/crates/pstr-app/src/ui/library.rs @@ -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, 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) { - 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, + /// `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) { + 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::(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 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 { - 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) { + 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 { + 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 { + 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 = (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] diff --git a/crates/pstr-app/src/ui/mod.rs b/crates/pstr-app/src/ui/mod.rs index ae29118..6c72e09 100644 --- a/crates/pstr-app/src/ui/mod.rs +++ b/crates/pstr-app/src/ui/mod.rs @@ -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 { diff --git a/crates/pstr-app/src/ui/title.rs b/crates/pstr-app/src/ui/title.rs index 0f0832f..fc66b18 100644 --- a/crates/pstr-app/src/ui/title.rs +++ b/crates/pstr-app/src/ui/title.rs @@ -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, diff --git a/crates/pstr-core/src/appearance.rs b/crates/pstr-core/src/appearance.rs index f3dc541..a9b70b2 100644 --- a/crates/pstr-core/src/appearance.rs +++ b/crates/pstr-core/src/appearance.rs @@ -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(); diff --git a/docs/DESIGN-TOKENS.md b/docs/DESIGN-TOKENS.md index 4d0c923..170e022 100644 --- a/docs/DESIGN-TOKENS.md +++ b/docs/DESIGN-TOKENS.md @@ -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, diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index d711c81..54dcc64 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -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**. diff --git a/ios/ProtonStream/UI/LibraryView.swift b/ios/ProtonStream/UI/LibraryView.swift index fdde1d3..0fc39a7 100644 --- a/ios/ProtonStream/UI/LibraryView.swift +++ b/ios/ProtonStream/UI/LibraryView.swift @@ -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 { diff --git a/ios/ProtonStream/UI/RemoteArtwork.swift b/ios/ProtonStream/UI/RemoteArtwork.swift index 50b08f1..d93967c 100644 --- a/ios/ProtonStream/UI/RemoteArtwork.swift +++ b/ios/ProtonStream/UI/RemoteArtwork.swift @@ -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) } } diff --git a/ios/ProtonStream/UI/Theme/Theme.swift b/ios/ProtonStream/UI/Theme/Theme.swift index 489ea74..23e57b4 100644 --- a/ios/ProtonStream/UI/Theme/Theme.swift +++ b/ios/ProtonStream/UI/Theme/Theme.swift @@ -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)