diff --git a/android/app/src/main/java/io/narl/protonstream/playback/PlayerScreen.kt b/android/app/src/main/java/io/narl/protonstream/playback/PlayerScreen.kt index 80be7df..74309d3 100644 --- a/android/app/src/main/java/io/narl/protonstream/playback/PlayerScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/playback/PlayerScreen.kt @@ -84,6 +84,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity @@ -186,6 +189,7 @@ fun PlayerScreen( onClose: () -> Unit, ) { val context = LocalContext.current + val haptics = LocalHapticFeedback.current val activity = context as? Activity val titleKey = title.key val episode = episodes.getOrNull(index) @@ -506,6 +510,9 @@ fun PlayerScreen( offset.x > size.width - third -> DOUBLE_TAP_SECONDS else -> 0 } + // Felt as well as seen: the bubble is under the thumb + // that asked for it. + haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) if (seconds == 0) { host.setPaused(!current.paused) } else { @@ -542,12 +549,15 @@ fun PlayerScreen( enter = fadeIn(), exit = fadeOut(), ) { + // A gradient rather than a band: dark where the text is, and gone + // by the time it reaches the picture, so the top of the frame is + // dimmed rather than cut off by a grey bar. Row( Modifier .fillMaxWidth() - .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.45f)) + .background(Brush.verticalGradient(listOf(Scrim.copy(alpha = 0.75f), Color.Transparent))) .windowInsetsPadding(WindowInsets.safeDrawing) - .padding(horizontal = 8.dp, vertical = 4.dp), + .padding(start = 8.dp, end = 8.dp, top = 4.dp, bottom = 28.dp), verticalAlignment = Alignment.CenterVertically, ) { IconButton(onClick = onMinimize) { @@ -632,7 +642,7 @@ fun PlayerScreen( modifier = Modifier .align(Alignment.BottomEnd) .windowInsetsPadding(WindowInsets.safeDrawing) - .padding(end = 16.dp, bottom = if (showControls) 132.dp else 24.dp), + .padding(end = 16.dp, bottom = if (showControls) 160.dp else 24.dp), ) { Text(skip.label) } } @@ -667,7 +677,7 @@ fun PlayerScreen( modifier = Modifier .align(Alignment.BottomEnd) .windowInsetsPadding(WindowInsets.safeDrawing) - .padding(end = 16.dp, bottom = if (showControls) 132.dp else 24.dp), + .padding(end = 16.dp, bottom = if (showControls) 160.dp else 24.dp), ) // Played to the end with nothing starting after it: the last episode, @@ -874,9 +884,9 @@ private fun PlayerControls( Column( Modifier .fillMaxWidth() - .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.68f)) + .background(Brush.verticalGradient(listOf(Color.Transparent, Scrim.copy(alpha = 0.85f)))) .windowInsetsPadding(WindowInsets.safeDrawing) - .padding(horizontal = 12.dp, vertical = 4.dp), + .padding(start = 12.dp, end = 12.dp, top = 32.dp, bottom = 4.dp), ) { // Where the thumb is while a finger is on it. A seek per touch-move // aborts every outstanding prefetch, so a one-second drag used to issue @@ -1460,3 +1470,9 @@ private fun formatTime(seconds: Double): String { return if (hours > 0) "%d:%02d:%02d".format(hours, (total % 3_600) / 60, total % 60) else "%d:%02d".format(total / 60, total % 60) } + +/** + * The player's scrims are black whatever the theme: what is under them is a + * video frame, not the page, and a light theme's scrim would wash it out. + */ +private val Scrim = Color.Black diff --git a/android/app/src/main/java/io/narl/protonstream/ui/Common.kt b/android/app/src/main/java/io/narl/protonstream/ui/Common.kt index 9d19a0f..4c47fff 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/Common.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/Common.kt @@ -4,6 +4,9 @@ import android.net.ConnectivityManager import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope +import androidx.compose.foundation.border +import androidx.compose.foundation.background +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -256,3 +259,38 @@ internal fun EpisodeRecord.heading(position: Int): String { else -> name.substringBeforeLast('.').ifBlank { "Episode ${position + 1}" } } } + +/** + * Something the title is filed under — a genre — as a small rounded label. + * The desktop's `widgets::chip`. + */ +@Composable +internal fun GenreChip(text: String) { + Text( + text, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(12.dp)) + .padding(horizontal = 10.dp, vertical = 4.dp), + ) +} + +/** + * A fact about the files rather than the title — 4K, HDR, Dual audio — as a + * small outlined mark, the way a box prints them. Quieter than a [GenreChip]. + * The desktop's `widgets::spec_badge`. + */ +@Composable +internal fun SpecBadge(text: String) { + val ink = MaterialTheme.colorScheme.onSurfaceVariant + Text( + text, + style = MaterialTheme.typography.labelSmall, + fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold, + color = ink, + modifier = Modifier + .border(1.dp, ink.copy(alpha = 0.7f), MaterialTheme.shapes.extraSmall) + .padding(horizontal = 5.dp, vertical = 1.dp), + ) +} 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 477fd94..f87ac7a 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 @@ -644,7 +644,7 @@ private fun FeaturedBanner(title: TitleRecord, resume: Resumable?, onPlay: (Int) ) { Text( title.displayName, - style = MaterialTheme.typography.headlineMedium, + style = MaterialTheme.typography.displayLarge, color = Color.White, maxLines = 2, overflow = TextOverflow.Ellipsis, 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 a1a66bb..966b649 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 @@ -6,13 +6,15 @@ import android.graphics.BitmapFactory import android.util.LruCache import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -25,10 +27,11 @@ import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale @@ -79,9 +82,13 @@ internal val EpisodeRecord.thumbnailSource: ThumbnailSource * * [fallback] is used only when there is no provider artwork or it fails to * load, which — with metadata off — is the common case rather than the rare - * one. Until either answers, the box pulses as a skeleton; when neither has + * one. Until either answers, the box shimmers as a skeleton; when neither has * anything, it shows [ArtworkPlaceholder] rather than an empty card — without * initials when [labelled] is false, for art with the name already over it. + * + * Art that arrives fades in over the skeleton rather than replacing it in one + * frame, which across a grid reads as flicker. Art already decoded is drawn + * at once: a tile scrolled back into view has nothing to arrive. */ @Composable internal fun RemoteArtwork( @@ -97,7 +104,7 @@ internal fun RemoteArtwork( // Decoded at the size it is drawn: a grid tile is a few hundred pixels, // and decoding its poster at 1024 holds four times the memory for nothing. val edge = with(LocalDensity.current) { maxEdge?.roundToPx() }?.coerceIn(1, MAX_ART_EDGE) ?: MAX_ART_EDGE - var image by remember(url, fallback) { mutableStateOf(ArtworkLoad.Pending) } + var image by remember(url, fallback) { mutableStateOf(alreadyDecoded(url, fallback, edge)) } LaunchedEffect(url, fallback, enabled) { if (!enabled) { image = ArtworkLoad.Missing @@ -107,13 +114,35 @@ internal fun RemoteArtwork( ?.let { ArtworkLoad.Loaded(it.asImageBitmap()) } ?: ArtworkLoad.Missing } - when (val loaded = image) { - ArtworkLoad.Pending -> Skeleton(modifier) - ArtworkLoad.Missing -> ArtworkPlaceholder(description, modifier, labelled) - is ArtworkLoad.Loaded -> Image(loaded.image, description, modifier, contentScale = ContentScale.Crop) + Crossfade(image, modifier, animationSpec = tween(ARRIVE_MS), label = "artwork") { loaded -> + when (loaded) { + ArtworkLoad.Pending -> Skeleton(Modifier.fillMaxSize()) + ArtworkLoad.Missing -> ArtworkPlaceholder(description, Modifier.fillMaxSize(), labelled) + is ArtworkLoad.Loaded -> Image( + loaded.image, + description, + Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } } } +/** What the decoded cache already holds for this art, so it skips the fade. */ +private fun alreadyDecoded(url: String?, fallback: ThumbnailSource?, edge: Int): ArtworkLoad { + val hit = url?.let { decoded.get(sized(it, edge)) } ?: fallback?.let { decoded.get(sized(it.key, edge)) } + return hit?.let { ArtworkLoad.Loaded(it.asImageBitmap()) } ?: ArtworkLoad.Pending +} + +/** + * How long art takes to fade in over its skeleton: the desktop's + * `motion::ARRIVE`. + */ +private const val ARRIVE_MS = 240 + +/** One pass of the skeleton's highlight: the desktop's `motion::SHIMMER`. */ +private const val SHIMMER_MS = 1_400 + /** * A title's art across the top of a page: its backdrop, or with none, its * poster as a wash of its own colours — decoded tiny and blurred, so it reads as @@ -158,15 +187,17 @@ internal fun RemoteLogo( LaunchedEffect(url, enabled) { logo = if (enabled) loadLogo(context, url)?.asImageBitmap() else null } - when (val loaded = logo) { - null -> fallback() - else -> Image( - loaded, - description, - modifier, - alignment = Alignment.BottomStart, - contentScale = ContentScale.Fit, - ) + Crossfade(logo, animationSpec = tween(ARRIVE_MS), label = "logo") { loaded -> + when (loaded) { + null -> fallback() + else -> Image( + loaded, + description, + modifier, + alignment = Alignment.BottomStart, + contentScale = ContentScale.Fit, + ) + } } } @@ -231,20 +262,38 @@ private sealed interface ArtworkLoad { internal val LocalArtworkLoading = staticCompositionLocalOf { true } /** - * Something being loaded: the surface tint, breathing. + * Something being loaded: the surface tint, with a soft highlight sweeping + * across it on a diagonal. * * A spinner per tile is a grid of spinners; a shape where the poster will be * says what is coming and where, and settles the layout before it arrives. + * The sweep is measured in the tile's own width, so neighbouring tiles of one + * size shimmer in step, as one surface. */ @Composable internal fun Skeleton(modifier: Modifier = Modifier) { - val pulse by rememberInfiniteTransition(label = "skeleton").animateFloat( - initialValue = 0.55f, - targetValue = 1f, - animationSpec = infiniteRepeatable(tween(800), RepeatMode.Reverse), - label = "skeleton alpha", + val sweep by rememberInfiniteTransition(label = "skeleton").animateFloat( + initialValue = -1f, + targetValue = 2f, + animationSpec = infiniteRepeatable(tween(SHIMMER_MS, easing = LinearEasing)), + label = "skeleton sweep", + ) + val base = MaterialTheme.colorScheme.surfaceVariant + val highlight = lerp(base, MaterialTheme.colorScheme.onSurface, 0.07f) + Box( + modifier.drawBehind { + drawRect(base) + val centre = size.width * sweep + val half = size.width * 0.6f + drawRect( + Brush.linearGradient( + listOf(Color.Transparent, highlight, Color.Transparent), + start = Offset(centre - half, 0f), + end = Offset(centre + half, size.height), + ), + ) + }, ) - Box(modifier.graphicsLayer { alpha = pulse }.background(MaterialTheme.colorScheme.surfaceVariant)) } /** diff --git a/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt index 13e0bca..ed935f4 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -207,7 +208,7 @@ internal fun TitleScreen( item(key = "heading") { Column(Modifier.padding(horizontal = side + 16.dp)) { val heading = @Composable { - Text(title.displayName, style = MaterialTheme.typography.headlineMedium) + Text(title.displayName, style = MaterialTheme.typography.displayLarge) } // A light page keeps the name: a clear logo is lettering // made for dark art, white as often as not. @@ -237,7 +238,7 @@ internal fun TitleScreen( title.runtime?.takeIf { title.kind == TitleType.FILM }?.let(::formatRuntime), title.rating?.let { "★ %.1f".format(it) }, "${title.watchedCount} of ${title.episodeCount} watched".takeIf { title.episodeCount > 1uL }, - ).plus(title.quality).joinToString(" · "), + ).joinToString(" · "), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 6.dp), @@ -256,6 +257,12 @@ internal fun TitleScreen( modifier = Modifier.padding(top = 2.dp), ) } + // What the files are, rather than what the title is. + if (title.quality.isNotEmpty()) { + Row(Modifier.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) { + title.quality.forEach { SpecBadge(it) } + } + } // The one thing the page is for, full width and naming what it // will play: "Resume" alone left the viewer guessing which // episode of forty it meant. @@ -323,12 +330,21 @@ internal fun TitleScreen( // Genres as chips, then the provider's tags quieter after // them: a tag is its community describing it, not what it // is filed as. - if (title.genres.isNotEmpty() || title.tags.isNotEmpty()) { + if (title.genres.isNotEmpty()) { + FlowRow( + Modifier.padding(top = 12.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + title.genres.forEach { GenreChip(it) } + } + } + if (title.tags.isNotEmpty()) { Text( - (title.genres + title.tags.take(4)).joinToString(" · "), + title.tags.take(4).joinToString(" · "), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 10.dp), + modifier = Modifier.padding(top = 8.dp), ) } // Said plainly, because it changes what a re-match will do: a diff --git a/android/app/src/main/java/io/narl/protonstream/ui/theme/Type.kt b/android/app/src/main/java/io/narl/protonstream/ui/theme/Type.kt index c91295c..cc1ddbb 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/theme/Type.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/theme/Type.kt @@ -24,6 +24,13 @@ internal val Inter = FontFamily( Font(R.font.inter_semibold, FontWeight.SemiBold), ) +/** + * Inter Display Bold, the optical size drawn for large text, for the one + * [HERO] line: the banner's name and the name at the top of a title's page. + * The desktop's `theme::display` family, from the same file. + */ +internal val InterDisplay = FontFamily(Font(R.font.inter_display_bold, FontWeight.Bold)) + /** * The type ramp, which is the desktop client's type ramp. * @@ -33,7 +40,10 @@ internal val Inter = FontFamily( * were built to share one palette; sharing one scale is the other half of why * they should read as one app. * - * The rungs, largest first: 26 / 20 / 18 / 17 / 15 / 14 / 13 / 12 / 11. + * The rungs, largest first: 34 / 26 / 20 / 18 / 17 / 15 / 14 / 13 / 12 / 11. + * The top one is the hero line, in [InterDisplay] and set tighter, at + * [HERO_LEADING]: at that size the text face's leading pushes a two-line name + * apart. * * Where a role maps is decided by what actually draws with it here, not by * Material's own sizes — Material's `displayLarge` is 57sp, which is a size for @@ -44,7 +54,8 @@ internal val Inter = FontFamily( * [TypeRampTest] pins every one of these against the Rust ramp. */ internal val ProtonTypography = Typography( - displayLarge = role(DISPLAY, FontWeight.SemiBold), + // The banner's name and the title page's name, where no logo stands in. + displayLarge = role(HERO, FontWeight.Bold, InterDisplay, HERO_LEADING), displayMedium = role(DISPLAY, FontWeight.SemiBold), displaySmall = role(TITLE, FontWeight.SemiBold), // The name of the title you opened — the desktop's display line. @@ -69,6 +80,7 @@ internal val ProtonTypography = Typography( labelSmall = role(MICRO, FontWeight.Medium), ) +internal const val HERO = 34f internal const val DISPLAY = 26f internal const val TITLE = 20f internal const val HEADING = 18f @@ -79,6 +91,9 @@ internal const val LABEL = 13f internal const val CAPTION = 12f internal const val MICRO = 11f +internal const val LEADING = 1.35f +internal const val HERO_LEADING = 1.1f + /** * One rung as a `TextStyle`. * @@ -87,9 +102,14 @@ internal const val MICRO = 11f * enough for the two-line title names in the grid and tight enough that a * label on a control does not float. */ -private fun role(size: Float, weight: FontWeight) = TextStyle( +private fun role( + size: Float, + weight: FontWeight, + family: FontFamily = Inter, + leading: Float = LEADING, +) = TextStyle( fontSize = size.sp, - lineHeight = (size * 1.35f).sp, + lineHeight = (size * leading).sp, fontWeight = weight, - fontFamily = Inter, + fontFamily = family, ) diff --git a/android/app/src/main/res/font/inter_display_bold.ttf b/android/app/src/main/res/font/inter_display_bold.ttf new file mode 100644 index 0000000..046b24a Binary files /dev/null and b/android/app/src/main/res/font/inter_display_bold.ttf differ diff --git a/android/app/src/test/java/io/narl/protonstream/ui/theme/TypeRampTest.kt b/android/app/src/test/java/io/narl/protonstream/ui/theme/TypeRampTest.kt index 6a09296..295a058 100644 --- a/android/app/src/test/java/io/narl/protonstream/ui/theme/TypeRampTest.kt +++ b/android/app/src/test/java/io/narl/protonstream/ui/theme/TypeRampTest.kt @@ -22,10 +22,10 @@ import org.junit.Test */ class TypeRampTest { /** `Role::size()`, in the order `theme.rs` declares it. */ - private val ramp = listOf(26f, 20f, 18f, 17f, 15f, 14f, 13f, 12f, 11f) + private val ramp = listOf(34f, 26f, 20f, 18f, 17f, 15f, 14f, 13f, 12f, 11f) private val expected = mapOf( - "displayLarge" to 26f, + "displayLarge" to 34f, "displayMedium" to 26f, "displaySmall" to 20f, "headlineLarge" to 26f, @@ -62,12 +62,14 @@ class TypeRampTest { /** * Leading is derived from the size, so the only thing worth asserting is * that it *was* derived — a style that kept Material's line height carries - * a number with no relation to its own size. + * a number with no relation to its own size. The hero line is the one set + * tighter. */ @Test fun `line height follows the size it belongs to`() { styles().forEach { (name, style) -> - assertEquals(name, style.fontSize.value * 1.35f, style.lineHeight.value, 0.01f) + val leading = if (name == "displayLarge") HERO_LEADING else LEADING + assertEquals(name, style.fontSize.value * leading, style.lineHeight.value, 0.01f) } } diff --git a/android/app/src/test/screenshots/library-loading.png b/android/app/src/test/screenshots/library-loading.png index 5a74776..d7e6cb3 100644 Binary files a/android/app/src/test/screenshots/library-loading.png and b/android/app/src/test/screenshots/library-loading.png differ 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 b8a2b30..3311a70 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 4827c43..c81199d 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 56c1bba..521b6cc 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 355348e..b5e751b 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/title-phone-light.png b/android/app/src/test/screenshots/title-phone-light.png index 805fa05..645cbfa 100644 Binary files a/android/app/src/test/screenshots/title-phone-light.png and b/android/app/src/test/screenshots/title-phone-light.png differ diff --git a/android/app/src/test/screenshots/title-phone.png b/android/app/src/test/screenshots/title-phone.png index 7d3330d..2368c35 100644 Binary files a/android/app/src/test/screenshots/title-phone.png and b/android/app/src/test/screenshots/title-phone.png differ diff --git a/android/app/src/test/screenshots/title-tablet.png b/android/app/src/test/screenshots/title-tablet.png index c47aa22..8412565 100644 Binary files a/android/app/src/test/screenshots/title-tablet.png and b/android/app/src/test/screenshots/title-tablet.png differ diff --git a/crates/pstr-app/assets/fonts/InterDisplay-Bold.ttf b/crates/pstr-app/assets/fonts/InterDisplay-Bold.ttf new file mode 100644 index 0000000..046b24a Binary files /dev/null and b/crates/pstr-app/assets/fonts/InterDisplay-Bold.ttf differ diff --git a/crates/pstr-app/src/theme.rs b/crates/pstr-app/src/theme.rs index 5d09fe6..74e5b47 100644 --- a/crates/pstr-app/src/theme.rs +++ b/crates/pstr-app/src/theme.rs @@ -41,6 +41,10 @@ pub const CARD_GAP: f32 = 14.0; pub const POSTER_WIDTH: f32 = 168.0; /// Covers are 2:3. pub const POSTER_ASPECT: f32 = 3.0 / 2.0; +/// How much a tile's picture grows under the pointer, as a fraction of its +/// size. Half of it lands on each side, which has to fit in half of +/// [`CARD_GAP`] at the widest card, or a lifted tile overlaps its neighbour. +pub const TILE_LIFT: f32 = 0.04; /// The type ramp, named once. /// @@ -55,6 +59,11 @@ pub const POSTER_ASPECT: f32 = 3.0 / 2.0; /// the one monospace style in the app is egui's own, set in [`apply`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Role { + /// The name in the library's banner and at the top of a title's page, + /// where there is no logo to stand in for it. Set in Inter Display, the + /// cut drawn for this size: tighter and heavier than the text face, which + /// at forty points looks like body copy that was zoomed. + Hero, /// The name of the thing you are looking at, once per page. Display, /// An empty state, or the heading of a page that has no display line. @@ -78,6 +87,7 @@ pub enum Role { impl Role { pub const fn size(self) -> f32 { match self { + Self::Hero => 34.0, Self::Display => 26.0, Self::Title => 20.0, Self::Heading => 18.0, @@ -90,10 +100,11 @@ impl Role { } } - /// Headings are set in the semibold cut; everything below them in the - /// regular one. + /// The hero line is set in the display cut, headings in the semibold + /// one, and everything below them in the regular one. pub fn family(self) -> egui::FontFamily { match self { + Self::Hero => display(), Self::Display | Self::Title | Self::Heading | Self::Section => semibold(), _ => egui::FontFamily::Proportional, } @@ -159,6 +170,9 @@ pub mod motion { /// Artwork arriving over its placeholder. Slower, because it replaces /// something rather than adding to it, and a fast swap reads as a flicker. pub const ARRIVE: f32 = 0.24; + /// One pass of the highlight across a loading skeleton. Slow, because it + /// is something to wait in front of, not something happening. + pub const SHIMMER: f32 = 1.4; /// Fast out, slow in: the curve every hand-driven transition uses. pub fn ease_out(t: f32) -> f32 { @@ -170,7 +184,7 @@ pub mod motion { /// sets the same on every desktop. Inter, under the OFL — the licence is /// beside the files. /// -/// Both files have their private use area mappings removed, because the +/// All three files have their private use area mappings removed, because the /// Phosphor icons live there. Inter maps 745 codepoints in that area to its /// own alternate glyphs, and as the first font in the family it won: the /// refresh arrow drew as an accented letter. The glyphs themselves are still @@ -179,6 +193,9 @@ pub mod motion { /// U+E000..=U+F8FF). const INTER: &[u8] = include_bytes!("../assets/fonts/Inter-Regular.ttf"); const INTER_SEMIBOLD: &[u8] = include_bytes!("../assets/fonts/Inter-SemiBold.ttf"); +/// Inter Display Bold, the optical size for the hero line. Stripped like the +/// other two. +const INTER_DISPLAY: &[u8] = include_bytes!("../assets/fonts/InterDisplay-Bold.ttf"); /// The family the headings are set in. egui has no notion of weight, so the /// semibold cut is a family of its own. @@ -186,6 +203,11 @@ pub fn semibold() -> egui::FontFamily { egui::FontFamily::Name("semibold".into()) } +/// The family [`Role::Hero`] is set in. +pub fn display() -> egui::FontFamily { + egui::FontFamily::Name("display".into()) +} + /// The family the subtitle preview is drawn in: the face the chosen subtitle /// font resolves to, ahead of the app's own fonts, which stand in until it has /// loaded and for any glyph it lacks. @@ -238,6 +260,10 @@ fn font_definitions(system: &SystemFonts) -> egui::FontDefinitions { "inter-semibold".into(), Arc::new(egui::FontData::from_static(INTER_SEMIBOLD)), ); + fonts.font_data.insert( + "inter-display".into(), + Arc::new(egui::FontData::from_static(INTER_DISPLAY)), + ); fonts .families .entry(Proportional) @@ -258,10 +284,13 @@ fn font_definitions(system: &SystemFonts) -> egui::FontDefinitions { .filter(|name| *name != "inter") .cloned() .collect(); - let semibold_chain = std::iter::once("inter-semibold".to_owned()) - .chain(fallbacks) - .collect(); - fonts.families.insert(semibold(), semibold_chain); + let chain = |first: &str| { + std::iter::once(first.to_owned()) + .chain(fallbacks.iter().cloned()) + .collect() + }; + fonts.families.insert(semibold(), chain("inter-semibold")); + fonts.families.insert(display(), chain("inter-display")); let mut subtitle_chain = fonts.families[&Proportional].clone(); if let Some((_, face, _)) = &system.subtitle { let name = "system-subtitle".to_owned(); @@ -868,6 +897,43 @@ fn shadow(strength: f32, offset: [i8; 2], blur: u8, alpha: u8) -> egui::epaint:: } } +/// A soft highlight sweeping across a loading skeleton on a diagonal: what +/// says the shape is waiting for something rather than empty. +/// +/// The phase runs off the clock rather than per tile, so every skeleton on +/// screen sweeps in step, as one surface. Asks for the next frame itself; a +/// skeleton is only ever on screen for a moment. +pub fn shimmer(painter: &egui::Painter, rect: Rect) { + let time = painter.ctx().input(|input| input.time); + let phase = (time / f64::from(motion::SHIMMER)).fract() as f32; + let span = rect.width() * 0.6; + let centre = rect.left() - span + phase * (rect.width() + 2.0 * span); + let skew = rect.height() * 0.4; + let highlight = if palette().light { + Color32::from_black_alpha(10) + } else { + Color32::from_white_alpha(12) + }; + let mut mesh = egui::Mesh::default(); + for (x, color) in [ + (centre - span, Color32::TRANSPARENT), + (centre, highlight), + (centre + span, Color32::TRANSPARENT), + ] { + mesh.colored_vertex(egui::pos2(x + skew, rect.top()), color); + mesh.colored_vertex(egui::pos2(x - skew, rect.bottom()), color); + } + for quad in 0..2 { + let i = quad * 2; + mesh.add_triangle(i, i + 1, i + 2); + mesh.add_triangle(i + 1, i + 3, i + 2); + } + painter + .with_clip_rect(rect.intersect(painter.clip_rect())) + .add(mesh); + painter.ctx().request_repaint(); +} + /// The hover and press film on its own, for a control whose fill was painted by /// somebody else — the selected tab, which sits on the pill. pub fn veil_over( @@ -1169,7 +1235,11 @@ mod tests { fn the_bundled_inter_leaves_the_icon_codepoints_to_phosphor() { use skrifa::MetadataProvider as _; - for (name, bytes) in [("regular", INTER), ("semibold", INTER_SEMIBOLD)] { + for (name, bytes) in [ + ("regular", INTER), + ("semibold", INTER_SEMIBOLD), + ("display", INTER_DISPLAY), + ] { let font = skrifa::FontRef::new(bytes).expect("bundled Inter parses"); let shadowed: Vec = font .charmap() diff --git a/crates/pstr-app/src/ui/library.rs b/crates/pstr-app/src/ui/library.rs index 1b257c0..8cc2238 100644 --- a/crates/pstr-app/src/ui/library.rs +++ b/crates/pstr-app/src/ui/library.rs @@ -277,7 +277,8 @@ fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec, title: &Title, actions: &mut Vec, ) { let (width, aspect) = tile_shape(tiles); - egui::ScrollArea::horizontal() - .id_salt(("shelf", label)) - .auto_shrink([false, true]) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = theme::CARD_GAP; - for title in titles { - let picture = match tiles { - Tiles::Posters => art.poster(title), - Tiles::Stills => art.of(title), - }; - let response = ui::card( - ui, - Card { - art: picture, - name: art.name(title), - subtitle: subtitle(title), - progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32), - badge: None, - width, - aspect, - }, - ); - tile_menu(&response, title, false, actions); - if response.clicked() { - actions.push(Action::Goto(Page::Title(title.key.clone()))); - } + ui::shelf(ui, ("shelf", label), |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = theme::CARD_GAP; + for title in titles { + let picture = match tiles { + Tiles::Posters => art.poster(title), + Tiles::Stills => art.of(title), + }; + let response = ui::card( + ui, + Card { + art: picture, + name: art.name(title), + subtitle: subtitle(title), + progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32), + badge: None, + width, + aspect, + }, + ); + tile_menu(&response, title, false, actions); + if response.clicked() { + actions.push(Action::Goto(Page::Title(title.key.clone()))); } - }); + } }); + }); } /// The minimum width and the picture's shape of a tile. @@ -429,48 +429,45 @@ fn continue_row( titles: &[&Title], actions: &mut Vec, ) { - egui::ScrollArea::horizontal() - .id_salt("continue") - .auto_shrink([false, true]) - .show(ui, |ui| { - ui.horizontal(|ui| { - for title in titles { - let Some(episode) = title.continue_with() else { - continue; - }; - let remaining = match episode.resume_at() { - Some(at) => episode - .watch - .and_then(|watch| watch.duration_secs) - .map(|duration| format!("{} left", ui::format_time(duration - at))) - .unwrap_or_else(|| episode.label()), - None => format!("Next: {}", episode.label()), - }; + ui::shelf(ui, "continue", |ui| { + ui.horizontal(|ui| { + for title in titles { + let Some(episode) = title.continue_with() else { + continue; + }; + let remaining = match episode.resume_at() { + Some(at) => episode + .watch + .and_then(|watch| watch.duration_secs) + .map(|duration| format!("{} left", ui::format_time(duration - at))) + .unwrap_or_else(|| episode.label()), + None => format!("Next: {}", episode.label()), + }; - let picture = art.of(title); - let response = ui::card( - ui, - Card { - art: picture, - name: art.name(title), - subtitle: remaining, - progress: episode.progress().map(|value| value as f32), - badge: episode.numbering(), - // A shelf that scrolls sideways has no right edge - // to reach, so nothing to flex to. Always a - // still: it is a frame from where they stopped. - width: theme::CARD_WIDTH, - aspect: theme::CARD_ASPECT, - }, - ); - tile_menu(&response, title, true, actions); + let picture = art.of(title); + let response = ui::card( + ui, + Card { + art: picture, + name: art.name(title), + subtitle: remaining, + progress: episode.progress().map(|value| value as f32), + badge: episode.numbering(), + // A shelf that scrolls sideways has no right edge + // to reach, so nothing to flex to. Always a + // still: it is a frame from where they stopped. + width: theme::CARD_WIDTH, + aspect: theme::CARD_ASPECT, + }, + ); + tile_menu(&response, title, true, actions); - if response.clicked() { - actions.push(Action::Goto(Page::Title(title.key.clone()))); - } + if response.clicked() { + actions.push(Action::Goto(Page::Title(title.key.clone()))); } - }); + } }); + }); } /// Every title, wrapped to the window. @@ -669,6 +666,7 @@ fn placeholder(ui: &mut egui::Ui) { egui::CornerRadius::same(theme::radius::MD), theme::card(), ); + theme::shimmer(ui.painter(), image); } }); ui.add_space(theme::CARD_GAP); diff --git a/crates/pstr-app/src/ui/mod.rs b/crates/pstr-app/src/ui/mod.rs index b9bb217..ae29118 100644 --- a/crates/pstr-app/src/ui/mod.rs +++ b/crates/pstr-app/src/ui/mod.rs @@ -235,13 +235,23 @@ pub fn logo_heading( if theme::palette().light { return None; } - let texture = art.logo(title)?; + let texture = art.logo(title); + // From nothing rather than from the name it replaces: the name is gone the + // frame the logo is ready, and a logo easing in reads as it arriving. + let shown = ui.ctx().animate_bool_with_time( + ui.id().with(("logo", &title.key)), + texture.is_some(), + theme::motion::ARRIVE, + ); + let texture = texture?; let aspect = texture.aspect_ratio().max(0.1); let width = ui.available_width().min(420.0).min(max_height * aspect); let size = egui::vec2(width, width / aspect); Some( - ui.add(egui::Image::new((texture.id(), size))) - .on_hover_text(art.name(title)), + ui.add( + egui::Image::new((texture.id(), size)).tint(egui::Color32::WHITE.gamma_multiply(shown)), + ) + .on_hover_text(art.name(title)), ) } @@ -342,6 +352,19 @@ pub fn accent_button(ui: &mut egui::Ui, text: &str) -> egui::Response { filled(ui, text, Fill::Accent, Vec2::new(0.0, 0.0)) } +/// The button beside the accent one: the same shape, in a film of the text +/// colour, so "Start over" next to "Resume" reads as the alternative rather +/// than as a second, unrelated kind of control. +pub fn tonal_button(ui: &mut egui::Ui, text: &str) -> egui::Response { + filled(ui, text, Fill::Tonal, Vec2::new(0.0, 0.0)) +} + +/// A button laid over the picture: dark glass and white ink, whatever the +/// theme, because what is under it is a video frame and not the page. +pub fn glass_button(ui: &mut egui::Ui, text: &str) -> egui::Response { + filled(ui, text, Fill::Glass, Vec2::new(0.0, 0.0)) +} + /// The search box in the top bar: a magnifier, the text, and a way to clear it. /// /// Escape inside it clears it. `focus` puts the caret in it this frame — which @@ -548,6 +571,10 @@ enum Fill { Ink, /// Nothing at rest, the hover colour under the pointer. None, + /// A film of the text colour over whatever is behind it. + Tonal, + /// Dark glass over the picture. + Glass, } /// A row of tabs with one accent pill that slides between them. @@ -668,6 +695,35 @@ fn filled(ui: &mut egui::Ui, text: &str, fill: Fill, extra: Vec2) -> egui::Respo } theme::muted().lerp_to_gamma(theme::text(), hover) } + Fill::Tonal => { + let film = if theme::palette().light { + Color32::from_black_alpha(16) + } else { + Color32::from_white_alpha(22) + }; + ui.painter().rect_filled(rect, radius, film); + theme::veil_over(ui.painter(), rect, radius, lift); + theme::text() + } + Fill::Glass => { + ui.painter() + .rect_filled(rect, radius, Color32::from_black_alpha(140)); + ui.painter().rect_stroke( + rect, + radius, + Stroke::new(1.0, Color32::from_white_alpha(18)), + egui::StrokeKind::Inside, + ); + theme::veil_over(ui.painter(), rect, radius, lift); + Color32::WHITE + } + }; + // A disabled control is drawn at part strength: the ink is what says what + // the button is, so that is what fades. + let ink = if ui.is_enabled() { + ink + } else { + ink.gamma_multiply(0.4) }; let position = rect.center() - galley.size() / 2.0; @@ -740,18 +796,27 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response { return response; } - let image_rect = Rect::from_min_size(rect.min, Vec2::new(width, image_height)); + let resting = Rect::from_min_size(rect.min, Vec2::new(width, image_height)); // Hover is a value, not a flag: the border, the lift and the shadow all // ride it, so they arrive together rather than each snapping on its own. // egui drives this from wall-clock time, so it is the same speed whatever // the frame rate — and it needs no state kept here. - // Focus lights a tile the way the pointer does, so the keyboard can be - // followed across the grid. + // Focus lifts a tile the way the pointer does, and adds a ring of its own, + // so the keyboard can be followed across the grid. let hover = ui.ctx().animate_bool_with_time( response.id.with("hover"), response.hovered() || response.has_focus(), theme::motion::HOVER, ); + let focus = ui.ctx().animate_bool_with_time( + response.id.with("focus"), + response.has_focus(), + theme::motion::HOVER, + ); + // The picture grows towards the viewer into the gap around it, which is + // what says "this one" across a shelf of near-identical rectangles. The + // name under it stays put, so the row's text never wobbles. + let image_rect = resting.expand2(resting.size() * lift_per_side(width) * hover); // The picture arrives whenever its download finishes, several frames after // the tile first drew. Fading it in over the placeholder turns a wall of // letters popping into stills into one settle. @@ -867,9 +932,17 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response { egui::StrokeKind::Inside, ); } + if focus > 0.0 { + painter.rect_stroke( + image_rect.expand(3.0), + CornerRadius::same(theme::radius::MD + 3), + Stroke::new(2.0, theme::text().gamma_multiply(focus)), + egui::StrokeKind::Outside, + ); + } let text_rect = Rect::from_min_size( - image_rect.left_bottom() + Vec2::new(0.0, 6.0), + resting.left_bottom() + Vec2::new(0.0, 6.0), Vec2::new(width, text_height - 6.0), ); let painter = painter.with_clip_rect(text_rect); @@ -900,6 +973,146 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response { response.on_hover_cursor(egui::CursorIcon::PointingHand) } +/// Every sideways row in the app: the shelves on the home page and the +/// franchise on a title's page. +/// +/// What runs past either edge fades into the page rather than being cut off +/// by it, so a row says there is more without a scroll bar saying it. Under +/// the pointer, a round button at each edge with more beyond it pages the row +/// by most of its width: a mouse has no sideways wheel, and the bar was the +/// only other way across. +/// +/// The row is padded by half a gap all round inside its scroll area, and the +/// area reaches the same half gap past the column on either side, so a lifted +/// tile is not clipped by the area and the tiles still line up with the +/// headings above them. +pub fn shelf( + ui: &mut egui::Ui, + id_salt: impl std::hash::Hash + std::fmt::Debug, + add: impl FnOnce(&mut egui::Ui) -> R, +) -> R { + let half = (theme::CARD_GAP / 2.0) as i8; + let id = ui.id().with(("shelf", &id_salt)); + let page = id.with("page"); + let pending: Option = ui.ctx().data_mut(|data| data.remove_temp(page)); + let output = egui::Frame::new() + .outer_margin(egui::Margin { + left: -half, + right: -half, + top: -half, + bottom: -half, + }) + .show(ui, |ui| { + egui::ScrollArea::horizontal() + .id_salt(id) + .auto_shrink([false, true]) + .show(ui, |ui| { + if let Some(delta) = pending { + ui.scroll_with_delta_animation( + Vec2::new(delta, 0.0), + egui::style::ScrollAnimation::duration(SHELF_PAGE_SECONDS), + ); + } + egui::Frame::new() + .inner_margin(egui::Margin::same(half)) + .show(ui, add) + .inner + }) + }) + .inner; + + let rect = output.inner_rect; + let offset = output.state.offset.x; + let overflow = (output.content_size.x - rect.width()).max(0.0); + let ends = [ + (offset > 1.0, false, egui_phosphor::regular::CARET_LEFT, 1.0), + ( + offset < overflow - 1.0, + true, + egui_phosphor::regular::CARET_RIGHT, + -1.0, + ), + ]; + let pointer_in = ui.rect_contains_pointer(rect); + let painter = ui.painter_at(rect); + for (more, right, icon, direction) in ends { + if !more { + continue; + } + let strip = if right { + Rect::from_min_max(egui::pos2(rect.right() - SHELF_FADE, rect.top()), rect.max) + } else { + Rect::from_min_max( + rect.min, + egui::pos2(rect.left() + SHELF_FADE, rect.bottom()), + ) + }; + painter.add(theme::fade_shape( + ui.ctx(), + strip, + theme::background(), + theme::Direction::Horizontal, + right, + )); + + let button_id = id.with(right); + let shown = ui + .ctx() + .animate_bool_with_time(button_id, pointer_in, theme::motion::HOVER); + if shown == 0.0 { + continue; + } + let centre = egui::pos2( + if right { + rect.right() - SHELF_BUTTON / 2.0 - theme::space::M + } else { + rect.left() + SHELF_BUTTON / 2.0 + theme::space::M + }, + rect.center().y, + ); + let button = Rect::from_center_size(centre, Vec2::splat(SHELF_BUTTON)); + let response = ui + .interact(button, button_id.with("button"), Sense::click()) + .on_hover_cursor(egui::CursorIcon::PointingHand); + let response = response.named(if right { "Scroll right" } else { "Scroll left" }); + let lit = if response.hovered() { 1.0 } else { 0.0 }; + painter.circle_filled( + centre, + SHELF_BUTTON / 2.0, + Color32::from_black_alpha((150.0 + 60.0 * lit) as u8).gamma_multiply(shown), + ); + painter.text( + centre, + Align2::CENTER_CENTER, + icon, + egui::FontId::proportional(18.0), + Color32::WHITE.gamma_multiply(shown), + ); + if response.clicked() { + let delta = direction * rect.width() * SHELF_PAGE; + ui.ctx().data_mut(|data| data.insert_temp(page, delta)); + ui.ctx().request_repaint(); + } + } + output.inner +} + +/// How wide the fade at a shelf's edge is. +const SHELF_FADE: f32 = 56.0; +/// The diameter of a shelf's paging buttons. +const SHELF_BUTTON: f32 = 40.0; +/// How much of a shelf's width one page moves: most of it, so the tile that +/// was cut by the edge is whole on the next page. +const SHELF_PAGE: f32 = 0.85; +const SHELF_PAGE_SECONDS: f32 = 0.35; + +/// How far a lifted tile grows past each edge, as a fraction of its size: +/// [`theme::TILE_LIFT`], but never so far that it reaches into the gap's other +/// half, which belongs to the neighbour. A wide window makes wide tiles. +fn lift_per_side(width: f32) -> f32 { + (theme::TILE_LIFT / 2.0).min((theme::CARD_GAP / 2.0 - 1.0) / width.max(1.0)) +} + /// The largest centred rectangle inside `target` with `image`'s aspect ratio. /// /// The other half of [`cover_uv`]: where that one keeps the whole rectangle and @@ -1024,6 +1237,16 @@ mod tests { use super::*; + #[test] + fn a_lifted_tile_never_reaches_its_neighbour_however_wide_the_window() { + for available in [400.0, 1280.0, 1919.0, 2560.0, 3840.0] { + for minimum in [theme::CARD_WIDTH, theme::POSTER_WIDTH] { + let width = columns_of(available, minimum).width; + assert!(width * lift_per_side(width) < theme::CARD_GAP / 2.0); + } + } + } + #[test] fn a_running_time_reads_in_hours_and_minutes() { assert_eq!(format_runtime(24), "24 min"); diff --git a/crates/pstr-app/src/ui/player.rs b/crates/pstr-app/src/ui/player.rs index 93f48b4..c7f29a9 100644 --- a/crates/pstr-app/src/ui/player.rs +++ b/crates/pstr-app/src/ui/player.rs @@ -707,13 +707,8 @@ fn chrome( .max_rect(top.shrink2(Vec2::new(18.0, 14.0))) .layout(egui::Layout::left_to_right(egui::Align::Center)), |ui| { - if ui - .add( - egui::Button::new(theme::Role::Label.rich("Library")) - .fill(Color32::from_black_alpha(160)) - .corner_radius(CornerRadius::same(theme::radius::MD)) - .min_size(Vec2::new(96.0, 32.0)), - ) + let library = format!("{} Library", egui_phosphor::regular::CARET_LEFT); + if ui::glass_button(ui, &library) .on_hover_text("Keep playing, and go back to the library (Esc)") .clicked() { @@ -736,69 +731,41 @@ fn chrome( ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let fullscreen = ui.input(|input| input.viewport().fullscreen.unwrap_or(false)); - if ui - .add( - egui::Button::new(theme::Role::Subhead.rich(if fullscreen { - egui_phosphor::regular::CORNERS_IN - } else { - egui_phosphor::regular::CORNERS_OUT - })) - .fill(Color32::from_black_alpha(160)) - .corner_radius(CornerRadius::same(theme::radius::MD)) - .min_size(Vec2::new(38.0, 32.0)), - ) - .hint(if fullscreen { - "Leave fullscreen (F)" + if ui::glass_button( + ui, + if fullscreen { + egui_phosphor::regular::CORNERS_IN } else { - "Fullscreen (F)" - }) - .clicked() + egui_phosphor::regular::CORNERS_OUT + }, + ) + .hint(if fullscreen { + "Leave fullscreen (F)" + } else { + "Fullscreen (F)" + }) + .clicked() { actions.push(Action::ToggleFullscreen); } if let Some(open) = drawer { ui.add_space(theme::space::S); - let fill = if open { - theme::accent() + // In the accent while the drawer is out, so the button + // that closes it is the one that says it is open. + let icon = egui_phosphor::regular::LIST_BULLETS; + let button = if open { + ui::accent_button(ui, icon) } else { - Color32::from_black_alpha(160) + ui::glass_button(ui, icon) }; - let ink = if open { - theme::on_accent() - } else { - Color32::WHITE - }; - if ui - .add( - egui::Button::new( - theme::Role::Subhead - .rich(egui_phosphor::regular::LIST_BULLETS) - .color(ink), - ) - .fill(fill) - .corner_radius(CornerRadius::same(theme::radius::MD)) - .min_size(Vec2::new(38.0, 32.0)), - ) - .hint("Episodes (E)") - .clicked() - { + if button.hint("Episodes (E)").clicked() { actions.push(Action::ToggleEpisodes); } } // Esc leaves the page and keeps playing; this is the one-step // way to be done with the episode. ui.add_space(theme::space::S); - if ui - .add( - egui::Button::new( - theme::Role::Subhead - .rich(egui_phosphor::regular::STOP) - .color(Color32::WHITE), - ) - .fill(Color32::from_black_alpha(160)) - .corner_radius(CornerRadius::same(theme::radius::MD)) - .min_size(Vec2::new(38.0, 32.0)), - ) + if ui::glass_button(ui, egui_phosphor::regular::STOP) .hint("Stop") .clicked() { @@ -1010,8 +977,9 @@ fn drawer_row( .unwrap_or_else(|| episode.detail().to_owned()); if let Some(numbering) = episode.numbering() { ui.label( - egui::RichText::new(numbering) - .monospace() + theme::Role::Caption + .rich(numbering) + .family(theme::semibold()) .color(theme::muted()), ); } diff --git a/crates/pstr-app/src/ui/title.rs b/crates/pstr-app/src/ui/title.rs index 5b1f9b5..0f0832f 100644 --- a/crates/pstr-app/src/ui/title.rs +++ b/crates/pstr-app/src/ui/title.rs @@ -121,49 +121,46 @@ fn franchise_row( members: &[&Title], actions: &mut Vec, ) { - egui::ScrollArea::horizontal() - .id_salt(("franchise", ¤t.key)) - .auto_shrink([false, true]) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = theme::CARD_GAP; - for member in members { - let here = member.key == current.key; - let picture = art.poster(member); - let found = art - .metadata - .get(&member.key) - .and_then(|record| record.metadata.as_ref()); - let what = found - .and_then(|found| found.details.as_ref()) - .and_then(|details| details.format_label()) - .unwrap_or(match member.kind { - TitleKind::Series => "Series", - TitleKind::Film => "Film", - }); - let year = found - .and_then(|found| found.year) - .or(member.year) - .map(|year| format!(" · {year}")) - .unwrap_or_default(); - let response = ui::card( - ui, - Card { - art: picture, - name: art.name(member), - subtitle: format!("{what}{year}"), - progress: member.resume().and_then(|e| e.progress()).map(|v| v as f32), - badge: here.then(|| "Here".to_owned()), - width: 128.0, - aspect: theme::POSTER_ASPECT, - }, - ); - if !here && response.clicked() { - actions.push(Action::Goto(Page::Title(member.key.clone()))); - } + ui::shelf(ui, ("franchise", ¤t.key), |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = theme::CARD_GAP; + for member in members { + let here = member.key == current.key; + let picture = art.poster(member); + let found = art + .metadata + .get(&member.key) + .and_then(|record| record.metadata.as_ref()); + let what = found + .and_then(|found| found.details.as_ref()) + .and_then(|details| details.format_label()) + .unwrap_or(match member.kind { + TitleKind::Series => "Series", + TitleKind::Film => "Film", + }); + let year = found + .and_then(|found| found.year) + .or(member.year) + .map(|year| format!(" · {year}")) + .unwrap_or_default(); + let response = ui::card( + ui, + Card { + art: picture, + name: art.name(member), + subtitle: format!("{what}{year}"), + progress: member.resume().and_then(|e| e.progress()).map(|v| v as f32), + badge: here.then(|| "Here".to_owned()), + width: 128.0, + aspect: theme::POSTER_ASPECT, + }, + ); + if !here && response.clicked() { + actions.push(Action::Goto(Page::Title(member.key.clone()))); } - }); + } }); + }); } /// A title's poster, at the poster's own shape, with how far into it the @@ -218,7 +215,7 @@ fn season_watched(ui: &mut egui::Ui, title: &Title, season: &Season, actions: &m } else { "Mark season watched" }; - if ui.button(label).clicked() { + if ui::tonal_button(ui, label).clicked() { actions.push(Action::MarkEpisodes { key: title.key.clone(), links: season @@ -324,7 +321,7 @@ fn header( // right of it rather than under it. ui.set_max_width(ui.available_width().min(820.0)); if ui::logo_heading(ui, art, title, 120.0).is_none() { - ui.label(theme::Role::Display.rich(&shown).strong()); + ui.label(theme::Role::Hero.rich(&shown).color(theme::text())); } // Whichever of the share's name and the provider's is not the one // shown. Worth showing rather than only replacing: a viewer should @@ -348,6 +345,19 @@ fn header( } ui.add_space(theme::space::XS); ui.label(ui::muted(meta_line(title, found.as_ref()))); + // What the files are, rather than what the title is: set apart + // from the line above as small outlined marks, the way a box + // prints them. + let quality = title.quality().badges(); + if !quality.is_empty() { + ui.add_space(theme::space::XS); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = theme::space::XS; + for badge in quality { + ui::widgets::spec_badge(ui, badge); + } + }); + } ui.add_space(theme::space::L); ui.horizontal(|ui| { @@ -374,8 +384,7 @@ fn header( } // Beside the button it is the alternative to. if resuming - && ui - .button("Start over") + && ui::tonal_button(ui, "Start over") .on_hover_text("Play from the beginning") .clicked() { @@ -388,15 +397,13 @@ fn header( let title_keys: Vec<_> = title.episodes().map(key_of).collect(); let all_offline = title_keys.iter().all(|key| offline.files.contains(key)); if all_offline { - if ui - .button("Delete show") + if ui::tonal_button(ui, "Delete show") .on_hover_text("Delete the offline copies; keep the online source") .clicked() { actions.push(Action::DeleteOffline(title_keys, title.name.clone())); } - } else if ui - .button("Download show") + } else if ui::tonal_button(ui, "Download show") .on_hover_text("Download every episode for disconnected playback") .clicked() { @@ -431,13 +438,15 @@ fn header( // wrongly. Offered whether or not anything matched: the two // cases it exists for are a title with no poster at all and a // title wearing someone else's. - if ui - .button(match &found { + if ui::tonal_button( + ui, + match &found { Some(_) => "Change match", None => "Match…", - }) - .on_hover_text("Search the metadata provider and pick the entry yourself") - .clicked() + }, + ) + .on_hover_text("Search the metadata provider and pick the entry yourself") + .clicked() { actions.push(Action::OpenMatcher(title.key.clone())); } @@ -689,8 +698,6 @@ fn meta_line(title: &Title, found: Option<&TitleMetadata>) -> String { parts.push(format!("{total} on {}", found.provider.label())); } } - // Last: what the files are, rather than what the title is. - parts.extend(title.quality().badges().into_iter().map(str::to_owned)); parts.join(" · ") } @@ -753,7 +760,10 @@ fn episode_row( job.append( &format!("{numbering} "), 0.0, - egui::TextFormat::simple(egui::FontId::monospace(13.0), theme::muted()), + egui::TextFormat::simple( + egui::FontId::new(theme::Role::Label.size(), theme::semibold()), + theme::muted(), + ), ); } job.append( @@ -906,12 +916,20 @@ pub(crate) fn still( painter.rect_filled(rect, radius, theme::card_hover()); // Asked for only once the row is on screen: a season of twenty-five is // twenty-five requests, and most of them are never scrolled to. - if let Some(texture) = art.still(&title.key, episode) { + let texture = art.still(&title.key, episode); + // Faded in like a tile's art, so a season's stills settle rather than pop. + let art_in = ui.ctx().animate_bool_with_time( + response.id.with("art"), + texture.is_some(), + theme::motion::ARRIVE, + ); + if let Some(texture) = texture { let tint = if watched { egui::Color32::from_gray(150) } else { egui::Color32::WHITE - }; + } + .gamma_multiply(art_in); painter.add( egui::epaint::RectShape::filled(rect, radius, tint) .with_texture(texture.id(), ui::cover_uv(texture.size_vec2(), rect.size())), diff --git a/crates/pstr-app/src/ui/widgets.rs b/crates/pstr-app/src/ui/widgets.rs index 590509b..8aebd50 100644 --- a/crates/pstr-app/src/ui/widgets.rs +++ b/crates/pstr-app/src/ui/widgets.rs @@ -390,6 +390,25 @@ pub fn download_button(ui: &mut egui::Ui, glyph: DownloadGlyph) -> egui::Respons response.on_hover_cursor(egui::CursorIcon::PointingHand) } +/// A fact about the files rather than the title — 4K, HDR, Dual audio — as +/// a small outlined mark: quieter than a [`chip`], which is something the +/// title is filed under. +pub fn spec_badge(ui: &mut egui::Ui, text: &str) -> egui::Response { + egui::Frame::new() + .stroke(egui::Stroke::new(1.0, theme::muted().gamma_multiply(0.7))) + .corner_radius(CornerRadius::same(theme::radius::BADGE)) + .inner_margin(egui::Margin::symmetric(5, 1)) + .show(ui, |ui| { + ui.label( + theme::Role::Micro + .rich(text) + .family(theme::semibold()) + .color(theme::muted()), + ); + }) + .response +} + /// A small rounded label: a genre, a year, a fact. pub fn chip(ui: &mut egui::Ui, text: &str) -> egui::Response { egui::Frame::new() diff --git a/docs/DESIGN-TOKENS.md b/docs/DESIGN-TOKENS.md index 3ad1167..4d0c923 100644 --- a/docs/DESIGN-TOKENS.md +++ b/docs/DESIGN-TOKENS.md @@ -20,12 +20,13 @@ is the contract, and each side's copy is asserted against it by a test. ## Type -Nine rungs. The desktop names them; Android restates them because Material's own +Ten rungs. The desktop names them; Android restates them because Material's own scale is a different one (`displayLarge` is 57sp, which is a size for a lock screen and not for anything in a library grid). | Role | Size | What it is | |---|---|---| +| Hero | 34 | the banner's name and a title page's name, where no logo stands in | | Display | 26 | the name of the thing you are looking at, once per page | | Title | 20 | an empty state, or a page heading with no display line | | Heading | 18 | the brand in the nav bar; a dialog's heading | @@ -39,8 +40,11 @@ screen and not for anything in a library grid). Weight and leading are each side's own. The desktop bundles Inter (`pstr-app/assets/fonts`, OFL) and sets Display, Title, Heading and Section in its semibold cut, which egui sees as a family of its own (`theme::semibold`); -everything below Section is the regular cut. Compose derives line height as -1.35 × size rather than naming it nine more times. +everything below Section is the regular cut. Hero is set in Inter Display Bold, +the optical size Inter draws for large text (`theme::display`); Android and iOS +map it to `displayLarge`. Compose derives line height as 1.35 × size rather +than naming it ten more times, except Hero at 1.1, where the text leading +pushes a two-line name apart. Icons on the desktop come from the Phosphor icon font (`egui-phosphor`, regular weight), installed right behind Inter so an icon can sit inside an diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index da31617..d711c81 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -22,6 +22,9 @@ show it once. genre with at least three titles. - **All titles** is the full grid. Choose a filter and a sort order above it. +Rows that run past the window fade out at the edge. Point at a row to show the arrows at its +ends, which move it along by a page. + | Filters | Sort orders | |---|---| | All, Series, Films, Watching, Not started, Downloaded | A – Z, Recently watched, Recently added, Newest, Highest rated, Most popular | @@ -78,7 +81,7 @@ Click a title to open its page. **Esc** goes back. it runs in the line under its name. - A title with no wide picture shows its poster blurred across the top of the page instead, and on the library's banner the poster itself beside it. -- The line under the name also says what most of the files are, when their names say it: **4K**, +- Small marks under the name say what most of the files are, when their names say it: **4K**, **1080p**, **HDR**, **10-bit** or **Dual audio**. An episode counts as watched once you have seen 90% of it. diff --git a/ios/ProtonStream/Player/PlayerView.swift b/ios/ProtonStream/Player/PlayerView.swift index b33353b..5c4dfb9 100644 --- a/ios/ProtonStream/Player/PlayerView.swift +++ b/ios/ProtonStream/Player/PlayerView.swift @@ -123,7 +123,7 @@ struct PlayerView: View { } .frame(maxWidth: .infinity, alignment: .trailing) .padding(.trailing, 16) - .padding(.bottom, showControls ? 132 : 24) + .padding(.bottom, showControls ? 160 : 24) // Only while nothing else explains the still picture: an error // is the more useful thing to say. @@ -224,8 +224,15 @@ struct PlayerView: View { } } .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(scheme.scrim.opacity(0.45).ignoresSafeArea(edges: [.top, .horizontal])) + .padding(.top, 4) + .padding(.bottom, 28) + // A gradient rather than a band: dark where the text is, and gone by + // the time it reaches the picture. Black whatever the theme, since + // what is under it is a video frame. + .background( + LinearGradient(colors: [.black.opacity(0.75), .clear], startPoint: .top, endPoint: .bottom) + .ignoresSafeArea(edges: [.top, .horizontal]) + ) } /// Show or hide the controls; shown and playing, they hide themselves. @@ -242,6 +249,8 @@ struct PlayerView: View { private func doubleTap(at x: CGFloat, width: CGFloat) { let third = width / 3 let seconds = x < third ? -doubleTapSeconds : x > width - third ? doubleTapSeconds : 0 + // Felt as well as seen: the bubble is under the thumb that asked for it. + UIImpactFeedbackGenerator(style: .light).impactOccurred() if seconds == 0 { host.setPaused(!player.paused) } else { @@ -355,8 +364,12 @@ private struct PlayerControls: View { } } .padding(.horizontal, 12) - .padding(.vertical, 4) - .background(scheme.scrim.opacity(0.68).ignoresSafeArea(edges: [.bottom, .horizontal])) + .padding(.top, 32) + .padding(.bottom, 4) + .background( + LinearGradient(colors: [.clear, .black.opacity(0.85)], startPoint: .top, endPoint: .bottom) + .ignoresSafeArea(edges: [.bottom, .horizontal]) + ) } private func toggleMute() { diff --git a/ios/ProtonStream/UI/Common.swift b/ios/ProtonStream/UI/Common.swift index bf6c023..7616a08 100644 --- a/ios/ProtonStream/UI/Common.swift +++ b/ios/ProtonStream/UI/Common.swift @@ -400,3 +400,84 @@ private extension UIColor { return (red, green, blue, alpha) } } + +/// Something the title is filed under — a genre — as a small rounded label. +/// Android's `GenreChip`. +struct GenreChip: View { + @Environment(\.scheme) private var scheme + let text: String + + var body: some View { + Text(text) + .textStyle(.labelMedium) + .foregroundStyle(scheme.onSurface) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(scheme.surfaceVariant, in: RoundedRectangle(cornerRadius: 12)) + } +} + +/// A fact about the files rather than the title — 4K, HDR, Dual audio — as a +/// small outlined mark, the way a box prints them. Android's `SpecBadge`. +struct SpecBadge: View { + @Environment(\.scheme) private var scheme + let text: String + + var body: some View { + Text(text) + .textStyle(.labelSmall, semibold: true) + .foregroundStyle(scheme.onSurfaceVariant) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(scheme.onSurfaceVariant.opacity(0.7), lineWidth: 1)) + } +} + +/// Children left to right, wrapping onto a new line when the next one would +/// not fit: Compose's `FlowRow`, which SwiftUI does not have. +struct FlowLayout: Layout { + var spacing: CGFloat = 6 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let rows = arrange(width: proposal.width ?? .infinity, subviews: subviews) + let width = rows.map(\.width).max() ?? 0 + let height = rows.map(\.height).reduce(0, +) + spacing * CGFloat(max(rows.count - 1, 0)) + return CGSize(width: width, height: height) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + var y = bounds.minY + for row in arrange(width: bounds.width, subviews: subviews) { + var x = bounds.minX + for index in row.members { + let size = subviews[index].sizeThatFits(.unspecified) + subviews[index].place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size)) + x += size.width + spacing + } + y += row.height + spacing + } + } + + private struct Row { + var members: [Int] = [] + var width: CGFloat = 0 + var height: CGFloat = 0 + } + + private func arrange(width: CGFloat, subviews: Subviews) -> [Row] { + var rows: [Row] = [Row()] + for index in subviews.indices { + let size = subviews[index].sizeThatFits(.unspecified) + let needed = rows[rows.count - 1].members.isEmpty ? size.width : rows[rows.count - 1].width + spacing + size.width + if needed > width, !rows[rows.count - 1].members.isEmpty { + rows.append(Row()) + } + var row = rows[rows.count - 1] + row.width = row.members.isEmpty ? size.width : row.width + spacing + size.width + row.height = max(row.height, size.height) + row.members.append(index) + rows[rows.count - 1] = row + } + return rows.filter { !$0.members.isEmpty } + } +} diff --git a/ios/ProtonStream/UI/LibraryView.swift b/ios/ProtonStream/UI/LibraryView.swift index 8e822bf..fdde1d3 100644 --- a/ios/ProtonStream/UI/LibraryView.swift +++ b/ios/ProtonStream/UI/LibraryView.swift @@ -508,7 +508,7 @@ private struct FeaturedBanner: View { // 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(.headlineMedium).foregroundStyle(.white).lineLimit(2) + Text(title.displayName).textStyle(.displayLarge).foregroundStyle(.white).lineLimit(2) } .padding(.bottom, 6) Text(factLine).textStyle(.bodySmall).foregroundStyle(.white.opacity(0.8)) diff --git a/ios/ProtonStream/UI/RemoteArtwork.swift b/ios/ProtonStream/UI/RemoteArtwork.swift index 8ae0d63..50b08f1 100644 --- a/ios/ProtonStream/UI/RemoteArtwork.swift +++ b/ios/ProtonStream/UI/RemoteArtwork.swift @@ -32,9 +32,12 @@ extension EpisodeRecord { /// /// `fallback` is used only when there is no provider artwork or it fails to /// load, which — with metadata off — is the common case rather than the rare -/// one. Until either answers, the box pulses as a skeleton; when neither has +/// one. Until either answers, the box shimmers as a skeleton; when neither has /// anything, it shows `ArtworkPlaceholder` rather than an empty card — without /// initials when `labelled` is false, for art with the name already over it. +/// +/// Art that arrives fades in over the skeleton, as on Android; art already +/// decoded is drawn in the first frame and has nothing to fade. struct RemoteArtwork: View { let url: String? let description: String @@ -57,11 +60,11 @@ struct RemoteArtwork: View { .overlay { switch load { case .pending: - Skeleton() + Skeleton().transition(.opacity) case .missing: - ArtworkPlaceholder(name: description, labelled: labelled) + ArtworkPlaceholder(name: description, labelled: labelled).transition(.opacity) case let .loaded(image): - Image(uiImage: image).resizable().scaledToFill() + Image(uiImage: image).resizable().scaledToFill().transition(.opacity) } } .clipped() @@ -71,7 +74,7 @@ struct RemoteArtwork: View { if case .loaded = load, ArtworkLoader.shared.cached(url: url, fallback: fallback) != nil { return } let loaded = await ArtworkLoader.shared.artwork(url: url, fallback: fallback) if Task.isCancelled { return } - load = loaded.map(ArtworkLoad.loaded) ?? .missing + withAnimation(Motion.arrive) { load = loaded.map(ArtworkLoad.loaded) ?? .missing } } } @@ -136,15 +139,16 @@ struct RemoteLogo: View { .scaledToFit() .frame(maxWidth: maxWidth, maxHeight: maxHeight, alignment: .bottomLeading) .accessibilityLabel(description) + .transition(.opacity) } else { - fallback() + fallback().transition(.opacity) } } .task(id: url) { if logo != nil { return } let loaded = await ArtworkLoader.shared.logo(url) if Task.isCancelled { return } - logo = loaded + withAnimation(Motion.arrive) { logo = loaded } } } } @@ -211,20 +215,38 @@ private enum ArtworkLoad { case loaded(UIImage) } -/// Something being loaded: the surface tint, breathing. +/// How long things take to change: the desktop's `theme::motion`. +enum Motion { + /// Artwork arriving over its skeleton. + static let arrive = Animation.easeOut(duration: 0.24) + /// One pass of a skeleton's highlight. + static let shimmer = Animation.linear(duration: 1.4).repeatForever(autoreverses: false) +} + +/// Something being loaded: the surface tint, with a soft highlight sweeping +/// across it on a diagonal. /// /// A spinner per tile is a grid of spinners; a shape where the poster will be /// says what is coming and where, and settles the layout before it arrives. struct Skeleton: View { @Environment(\.scheme) private var scheme - @State private var dim = false + @State private var sweep: CGFloat = -1 var body: some View { scheme.surfaceVariant - .opacity(dim ? 0.55 : 1) - .onAppear { - withAnimation(.linear(duration: 0.8).repeatForever(autoreverses: true)) { dim = true } + .overlay { + GeometryReader { geometry in + LinearGradient( + colors: [.clear, mix(scheme.surfaceVariant, scheme.onSurface, 0.07), .clear], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .frame(width: geometry.size.width * 1.2) + .offset(x: sweep * geometry.size.width) + } } + .clipped() + .onAppear { withAnimation(Motion.shimmer) { sweep = 2 } } } } diff --git a/ios/ProtonStream/UI/Theme/Typography.swift b/ios/ProtonStream/UI/Theme/Typography.swift index 8af26f6..228f686 100644 --- a/ios/ProtonStream/UI/Theme/Typography.swift +++ b/ios/ProtonStream/UI/Theme/Typography.swift @@ -3,9 +3,11 @@ import SwiftUI /// The type ramp, which is the desktop client's type ramp, under Material's /// names so a view ported from the Android client asks for the same style. /// -/// Inter, Regular and SemiBold only, as on the desktop and on Android: a -/// `Medium` role resolves to Regular. The rungs, largest first: -/// 26 / 20 / 18 / 17 / 15 / 14 / 13 / 12 / 11. `TypeRampTests` pins them. +/// Inter, Regular and SemiBold, as on the desktop and on Android: a `Medium` +/// role resolves to Regular. The rungs, largest first: +/// 34 / 26 / 20 / 18 / 17 / 15 / 14 / 13 / 12 / 11. The top one is the hero +/// line — the banner's name and the title page's — in Inter Display Bold and +/// set tighter. `TypeRampTests` pins them. enum TypeRole: CaseIterable { case displayLarge, displayMedium, displaySmall case headlineLarge, headlineMedium, headlineSmall @@ -15,7 +17,8 @@ enum TypeRole: CaseIterable { var size: CGFloat { switch self { - case .displayLarge, .displayMedium, .headlineLarge, .headlineMedium: Ramp.display + case .displayLarge: Ramp.hero + case .displayMedium, .headlineLarge, .headlineMedium: Ramp.display case .displaySmall, .titleLarge: Ramp.title case .headlineSmall: Ramp.heading case .titleMedium: Ramp.section @@ -29,7 +32,7 @@ enum TypeRole: CaseIterable { var semibold: Bool { switch self { - case .displayLarge, .displayMedium, .displaySmall, .headlineLarge, .headlineMedium, .headlineSmall, .titleLarge: + case .displayMedium, .displaySmall, .headlineLarge, .headlineMedium, .headlineSmall, .titleLarge: true default: false @@ -39,6 +42,7 @@ enum TypeRole: CaseIterable { /// The Dynamic Type style each rung scales with. var relative: Font.TextStyle { switch size { + case Ramp.hero: .largeTitle case Ramp.display: .title case Ramp.title: .title2 case Ramp.heading: .title3 @@ -53,10 +57,19 @@ enum TypeRole: CaseIterable { /// Line height is derived rather than named per style: 1.35 of the size, /// as on Android. Inter's own leading is about 1.21, so the rest is spacing. - var lineSpacing: CGFloat { size * (Ramp.lineHeight - 1.21) } + var lineSpacing: CGFloat { + size * ((self == .displayLarge ? Ramp.heroLineHeight : Ramp.lineHeight) - 1.21) + } + + /// The PostScript name of the face this style is set in. + func face(semibold: Bool? = nil) -> String { + if self == .displayLarge, semibold == nil { return "InterDisplay-Bold" } + return (semibold ?? self.semibold) ? "Inter-SemiBold" : "Inter-Regular" + } } enum Ramp { + static let hero: CGFloat = 34 static let display: CGFloat = 26 static let title: CGFloat = 20 static let heading: CGFloat = 18 @@ -67,11 +80,12 @@ enum Ramp { static let caption: CGFloat = 12 static let micro: CGFloat = 11 static let lineHeight: CGFloat = 1.35 + static let heroLineHeight: CGFloat = 1.1 } extension Font { static func inter(_ style: TypeRole, semibold: Bool? = nil) -> Font { - .custom((semibold ?? style.semibold) ? "Inter-SemiBold" : "Inter-Regular", size: style.size, relativeTo: style.relative) + .custom(style.face(semibold: semibold), size: style.size, relativeTo: style.relative) } static func inter(size: CGFloat, semibold: Bool = false) -> Font { diff --git a/ios/ProtonStream/UI/TitleView.swift b/ios/ProtonStream/UI/TitleView.swift index 413b428..33ca906 100644 --- a/ios/ProtonStream/UI/TitleView.swift +++ b/ios/ProtonStream/UI/TitleView.swift @@ -150,10 +150,10 @@ struct TitleView: View { // A light page keeps the name: a clear logo is lettering made for // dark art, white as often as not. if scheme.light { - Text(title.displayName).textStyle(.headlineMedium).foregroundStyle(scheme.onSurface) + Text(title.displayName).textStyle(.displayLarge).foregroundStyle(scheme.onSurface) } else { RemoteLogo(title.logoUrl, title.displayName, maxWidth: 280, maxHeight: 88) { - Text(title.displayName).textStyle(.headlineMedium).foregroundStyle(scheme.onSurface) + Text(title.displayName).textStyle(.displayLarge).foregroundStyle(scheme.onSurface) } .padding(.bottom, 4) } @@ -179,6 +179,13 @@ struct TitleView: View { if !facts.isEmpty { Text(facts).textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant).padding(.top, 2) } + // What the files are, rather than what the title is. + if !title.quality.isEmpty { + HStack(spacing: 4) { + ForEach(title.quality, id: \.self) { SpecBadge(text: $0) } + } + .padding(.top, 8) + } // The one thing the page is for, full width and naming what it will // play: "Resume" alone left the viewer guessing which episode. Button { onPlay(playlist, nextUp) } label: { @@ -213,9 +220,17 @@ struct TitleView: View { .padding(.top, 12) .onTapGesture { overviewOpen.toggle() } } - if !title.genres.isEmpty || !title.tags.isEmpty { - Text((title.genres + title.tags.prefix(4)).joined(separator: " · ")) - .textStyle(.labelMedium).foregroundStyle(scheme.onSurfaceVariant).padding(.top, 10) + // Genres as chips, then the provider's tags quieter after them: a + // tag is its community describing it, not what it is filed as. + if !title.genres.isEmpty { + FlowLayout(spacing: 6) { + ForEach(title.genres, id: \.self) { GenreChip(text: $0) } + } + .padding(.top, 12) + } + if !title.tags.isEmpty { + Text(title.tags.prefix(4).joined(separator: " · ")) + .textStyle(.labelMedium).foregroundStyle(scheme.onSurfaceVariant).padding(.top, 8) } // Said plainly, because it changes what a re-match will do. if title.manualMatch { @@ -231,7 +246,7 @@ struct TitleView: View { // A series' running time is its episodes', which each row says. let runtime: String? = title.kind == .film ? title.runtime.map(formatRuntime) : nil let parts: [String?] = [title.formatLabel, title.seasonLabel ?? year, runtime, rating, watched] - return (parts.compactMap { $0 } + title.quality).joined(separator: " · ") + return parts.compactMap { $0 }.joined(separator: " · ") } private func playLabel(playlist: [EpisodeRecord], upNext: EpisodeRecord?) -> String { diff --git a/ios/ProtonStreamTests/UiLogicTests.swift b/ios/ProtonStreamTests/UiLogicTests.swift index 3a61dd6..20565bb 100644 --- a/ios/ProtonStreamTests/UiLogicTests.swift +++ b/ios/ProtonStreamTests/UiLogicTests.swift @@ -264,10 +264,10 @@ final class ThemeTests: XCTestCase { XCTAssertEqual(alpha, 128 / 255, accuracy: 0.001) } - /// Every style on one of the nine rungs, every rung used, and leading + /// Every style on one of the ten rungs, every rung used, and leading /// derived from the size — Android's `TypeRampTest`. func testEveryStyleIsOnTheDesktopRamp() { - let rungs: Set = [26, 20, 18, 17, 15, 14, 13, 12, 11] + let rungs: Set = [34, 26, 20, 18, 17, 15, 14, 13, 12, 11] let used = Set(TypeRole.allCases.map(\.size)) XCTAssertEqual(used, rungs) } @@ -275,10 +275,14 @@ final class ThemeTests: XCTestCase { func testOnlyHeadingsAreSemibold() { XCTAssertEqual( Set(TypeRole.allCases.filter(\.semibold)), - [.displayLarge, .displayMedium, .displaySmall, .headlineLarge, .headlineMedium, .headlineSmall, .titleLarge] + [.displayMedium, .displaySmall, .headlineLarge, .headlineMedium, .headlineSmall, .titleLarge] ) } + func testOnlyTheHeroLineIsSetInTheDisplayCut() { + XCTAssertEqual(TypeRole.allCases.filter { $0.face() == "InterDisplay-Bold" }, [.displayLarge]) + } + func testAPaddedLogoIsBoundedByItsLetteringAndNotItsHaze() { let width = 10 var alpha = [UInt8](repeating: 0, count: width * 6) diff --git a/ios/project.yml b/ios/project.yml index 3caba03..3b80d97 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -31,6 +31,8 @@ targets: buildPhase: resources - path: ../android/app/src/main/res/font/inter_semibold.ttf buildPhase: resources + - path: ../android/app/src/main/res/font/inter_display_bold.ttf + buildPhase: resources - path: ../android/app/src/main/assets/licenses/GPL-3.0.txt buildPhase: resources - path: ../android/app/src/main/assets/licenses/Inter-OFL.txt @@ -74,6 +76,7 @@ targets: UIAppFonts: - inter_regular.ttf - inter_semibold.ttf + - inter_display_bold.ttf # A share link handed over from elsewhere: protonstream://add?url=…, # or the link with its scheme swapped (IncomingShareLink.swift). CFBundleURLTypes: