feat(app,android,ios): display type, artwork fade-in, shimmer, lifting tiles and paged shelves

A first pass at making the three clients look like a streaming app rather
than a file browser, with no new data behind it.

- Type: a Hero rung (34) set in Inter Display Bold, the optical size Inter
  draws for large text, for the banner's name and a title page's name where
  no logo stands in. Desktop `Role::Hero` and `theme::display`; Android and
  iOS `displayLarge`, at a tighter 1.1 leading. The font has its private use
  mappings stripped like the other two cuts.
- Artwork fades in over its skeleton on Android and iOS, as it already did
  on desktop cards; art already decoded draws at once. Desktop episode
  stills and logos fade in too.
- Skeletons shimmer: a soft diagonal highlight instead of a pulse.
- Desktop tiles lift towards the viewer under the pointer, capped so a
  lifted tile never reaches its neighbour, and keyboard focus gets a ring
  of its own.
- Desktop shelves share one `ui::shelf`: edges fade into the page and, under
  the pointer, round buttons page the row along.
- Genres are chips on Android and iOS; quality (4K, HDR, 10-bit, Dual audio)
  moves out of the facts line into small outlined marks on all three.
- Desktop title page and hero buttons use a tonal style beside the accent
  one, and the player's top bar a dark glass style, instead of egui's stock
  buttons. Episode numbers leave monospace for the type ramp.
- The phone players' top and bottom bars are gradients rather than flat
  bands, and a double-tap seek is felt as well as seen.
This commit is contained in:
nvrl committed 2026-10-30 20:36:09 +01:00
1 parent 3ebfd1a57b
commit 255e463fc8
33 files changed
+771 -175

No files matched your search

@@ -84,6 +84,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size 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.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
@@ -186,6 +189,7 @@ fun PlayerScreen(
onClose: () -> Unit, onClose: () -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val haptics = LocalHapticFeedback.current
val activity = context as? Activity val activity = context as? Activity
val titleKey = title.key val titleKey = title.key
val episode = episodes.getOrNull(index) val episode = episodes.getOrNull(index)
@@ -506,6 +510,9 @@ fun PlayerScreen(
offset.x > size.width - third -> DOUBLE_TAP_SECONDS offset.x > size.width - third -> DOUBLE_TAP_SECONDS
else -> 0 else -> 0
} }
// Felt as well as seen: the bubble is under the thumb
// that asked for it.
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
if (seconds == 0) { if (seconds == 0) {
host.setPaused(!current.paused) host.setPaused(!current.paused)
} else { } else {
@@ -542,12 +549,15 @@ fun PlayerScreen(
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut(), 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( Row(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.45f)) .background(Brush.verticalGradient(listOf(Scrim.copy(alpha = 0.75f), Color.Transparent)))
.windowInsetsPadding(WindowInsets.safeDrawing) .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, verticalAlignment = Alignment.CenterVertically,
) { ) {
IconButton(onClick = onMinimize) { IconButton(onClick = onMinimize) {
@@ -632,7 +642,7 @@ fun PlayerScreen(
modifier = Modifier modifier = Modifier
.align(Alignment.BottomEnd) .align(Alignment.BottomEnd)
.windowInsetsPadding(WindowInsets.safeDrawing) .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) } ) { Text(skip.label) }
} }
@@ -667,7 +677,7 @@ fun PlayerScreen(
modifier = Modifier modifier = Modifier
.align(Alignment.BottomEnd) .align(Alignment.BottomEnd)
.windowInsetsPadding(WindowInsets.safeDrawing) .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, // Played to the end with nothing starting after it: the last episode,
@@ -874,9 +884,9 @@ private fun PlayerControls(
Column( Column(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.68f)) .background(Brush.verticalGradient(listOf(Color.Transparent, Scrim.copy(alpha = 0.85f))))
.windowInsetsPadding(WindowInsets.safeDrawing) .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 // 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 // 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) return if (hours > 0) "%d:%02d:%02d".format(hours, (total % 3_600) / 60, total % 60)
else "%d:%02d".format(total / 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
@@ -4,6 +4,9 @@ import android.net.ConnectivityManager
import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope 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.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
@@ -256,3 +259,38 @@ internal fun EpisodeRecord.heading(position: Int): String {
else -> name.substringBeforeLast('.').ifBlank { "Episode ${position + 1}" } 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),
)
}
@@ -644,7 +644,7 @@ private fun FeaturedBanner(title: TitleRecord, resume: Resumable?, onPlay: (Int)
) { ) {
Text( Text(
title.displayName, title.displayName,
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.displayLarge,
color = Color.White, color = Color.White,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
@@ -6,13 +6,15 @@ import android.graphics.BitmapFactory
import android.util.LruCache import android.util.LruCache
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background 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.animateFloat
import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -25,10 +27,11 @@ import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur 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.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale 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 * [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 * 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 * anything, it shows [ArtworkPlaceholder] rather than an empty card — without
* initials when [labelled] is false, for art with the name already over it. * 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 @Composable
internal fun RemoteArtwork( 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, // 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. // 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 val edge = with(LocalDensity.current) { maxEdge?.roundToPx() }?.coerceIn(1, MAX_ART_EDGE) ?: MAX_ART_EDGE
var image by remember(url, fallback) { mutableStateOf<ArtworkLoad>(ArtworkLoad.Pending) } var image by remember(url, fallback) { mutableStateOf(alreadyDecoded(url, fallback, edge)) }
LaunchedEffect(url, fallback, enabled) { LaunchedEffect(url, fallback, enabled) {
if (!enabled) { if (!enabled) {
image = ArtworkLoad.Missing image = ArtworkLoad.Missing
@@ -107,13 +114,35 @@ internal fun RemoteArtwork(
?.let { ArtworkLoad.Loaded(it.asImageBitmap()) } ?.let { ArtworkLoad.Loaded(it.asImageBitmap()) }
?: ArtworkLoad.Missing ?: ArtworkLoad.Missing
} }
when (val loaded = image) { Crossfade(image, modifier, animationSpec = tween(ARRIVE_MS), label = "artwork") { loaded ->
ArtworkLoad.Pending -> Skeleton(modifier) when (loaded) {
ArtworkLoad.Missing -> ArtworkPlaceholder(description, modifier, labelled) ArtworkLoad.Pending -> Skeleton(Modifier.fillMaxSize())
is ArtworkLoad.Loaded -> Image(loaded.image, description, modifier, contentScale = ContentScale.Crop) 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 * 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 * poster as a wash of its own colours — decoded tiny and blurred, so it reads as
@@ -158,7 +187,8 @@ internal fun RemoteLogo(
LaunchedEffect(url, enabled) { LaunchedEffect(url, enabled) {
logo = if (enabled) loadLogo(context, url)?.asImageBitmap() else null logo = if (enabled) loadLogo(context, url)?.asImageBitmap() else null
} }
when (val loaded = logo) { Crossfade(logo, animationSpec = tween(ARRIVE_MS), label = "logo") { loaded ->
when (loaded) {
null -> fallback() null -> fallback()
else -> Image( else -> Image(
loaded, loaded,
@@ -168,6 +198,7 @@ internal fun RemoteLogo(
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
) )
} }
}
} }
private suspend fun loadLogo(context: Context, url: String?): Bitmap? { private suspend fun loadLogo(context: Context, url: String?): Bitmap? {
@@ -231,20 +262,38 @@ private sealed interface ArtworkLoad {
internal val LocalArtworkLoading = staticCompositionLocalOf { true } 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 * 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. * 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 @Composable
internal fun Skeleton(modifier: Modifier = Modifier) { internal fun Skeleton(modifier: Modifier = Modifier) {
val pulse by rememberInfiniteTransition(label = "skeleton").animateFloat( val sweep by rememberInfiniteTransition(label = "skeleton").animateFloat(
initialValue = 0.55f, initialValue = -1f,
targetValue = 1f, targetValue = 2f,
animationSpec = infiniteRepeatable(tween(800), RepeatMode.Reverse), animationSpec = infiniteRepeatable(tween(SHIMMER_MS, easing = LinearEasing)),
label = "skeleton alpha", 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))
} }
/** /**
@@ -12,6 +12,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -207,7 +208,7 @@ internal fun TitleScreen(
item(key = "heading") { item(key = "heading") {
Column(Modifier.padding(horizontal = side + 16.dp)) { Column(Modifier.padding(horizontal = side + 16.dp)) {
val heading = @Composable { 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 // A light page keeps the name: a clear logo is lettering
// made for dark art, white as often as not. // 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.runtime?.takeIf { title.kind == TitleType.FILM }?.let(::formatRuntime),
title.rating?.let { "★ %.1f".format(it) }, title.rating?.let { "★ %.1f".format(it) },
"${title.watchedCount} of ${title.episodeCount} watched".takeIf { title.episodeCount > 1uL }, "${title.watchedCount} of ${title.episodeCount} watched".takeIf { title.episodeCount > 1uL },
).plus(title.quality).joinToString(" · "), ).joinToString(" · "),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp), modifier = Modifier.padding(top = 6.dp),
@@ -256,6 +257,12 @@ internal fun TitleScreen(
modifier = Modifier.padding(top = 2.dp), 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 // The one thing the page is for, full width and naming what it
// will play: "Resume" alone left the viewer guessing which // will play: "Resume" alone left the viewer guessing which
// episode of forty it meant. // episode of forty it meant.
@@ -323,12 +330,21 @@ internal fun TitleScreen(
// Genres as chips, then the provider's tags quieter after // Genres as chips, then the provider's tags quieter after
// them: a tag is its community describing it, not what it // them: a tag is its community describing it, not what it
// is filed as. // 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( Text(
(title.genres + title.tags.take(4)).joinToString(" · "), title.tags.take(4).joinToString(" · "),
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, 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 // Said plainly, because it changes what a re-match will do: a
@@ -24,6 +24,13 @@ internal val Inter = FontFamily(
Font(R.font.inter_semibold, FontWeight.SemiBold), 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. * 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 * were built to share one palette; sharing one scale is the other half of why
* they should read as one app. * 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 * 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 * 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. * [TypeRampTest] pins every one of these against the Rust ramp.
*/ */
internal val ProtonTypography = Typography( 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), displayMedium = role(DISPLAY, FontWeight.SemiBold),
displaySmall = role(TITLE, FontWeight.SemiBold), displaySmall = role(TITLE, FontWeight.SemiBold),
// The name of the title you opened — the desktop's display line. // 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), labelSmall = role(MICRO, FontWeight.Medium),
) )
internal const val HERO = 34f
internal const val DISPLAY = 26f internal const val DISPLAY = 26f
internal const val TITLE = 20f internal const val TITLE = 20f
internal const val HEADING = 18f internal const val HEADING = 18f
@@ -79,6 +91,9 @@ internal const val LABEL = 13f
internal const val CAPTION = 12f internal const val CAPTION = 12f
internal const val MICRO = 11f internal const val MICRO = 11f
internal const val LEADING = 1.35f
internal const val HERO_LEADING = 1.1f
/** /**
* One rung as a `TextStyle`. * 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 * enough for the two-line title names in the grid and tight enough that a
* label on a control does not float. * 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, fontSize = size.sp,
lineHeight = (size * 1.35f).sp, lineHeight = (size * leading).sp,
fontWeight = weight, fontWeight = weight,
fontFamily = Inter, fontFamily = family,
) )
Binary file not shown.
@@ -22,10 +22,10 @@ import org.junit.Test
*/ */
class TypeRampTest { class TypeRampTest {
/** `Role::size()`, in the order `theme.rs` declares it. */ /** `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( private val expected = mapOf(
"displayLarge" to 26f, "displayLarge" to 34f,
"displayMedium" to 26f, "displayMedium" to 26f,
"displaySmall" to 20f, "displaySmall" to 20f,
"headlineLarge" to 26f, "headlineLarge" to 26f,
@@ -62,12 +62,14 @@ class TypeRampTest {
/** /**
* Leading is derived from the size, so the only thing worth asserting is * 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 * 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 @Test
fun `line height follows the size it belongs to`() { fun `line height follows the size it belongs to`() {
styles().forEach { (name, style) -> 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)
} }
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 813 KiB

After

Width:  |  Height:  |  Size: 811 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 768 KiB

After

Width:  |  Height:  |  Size: 767 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 672 KiB

After

Width:  |  Height:  |  Size: 671 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 806 KiB

After

Width:  |  Height:  |  Size: 805 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 632 KiB

After

Width:  |  Height:  |  Size: 635 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 670 KiB

After

Width:  |  Height:  |  Size: 636 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.
+78 -8
View File
@@ -41,6 +41,10 @@ pub const CARD_GAP: f32 = 14.0;
pub const POSTER_WIDTH: f32 = 168.0; pub const POSTER_WIDTH: f32 = 168.0;
/// Covers are 2:3. /// Covers are 2:3.
pub const POSTER_ASPECT: f32 = 3.0 / 2.0; 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. /// 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`]. /// the one monospace style in the app is egui's own, set in [`apply`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Role { 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. /// The name of the thing you are looking at, once per page.
Display, Display,
/// An empty state, or the heading of a page that has no display line. /// An empty state, or the heading of a page that has no display line.
@@ -78,6 +87,7 @@ pub enum Role {
impl Role { impl Role {
pub const fn size(self) -> f32 { pub const fn size(self) -> f32 {
match self { match self {
Self::Hero => 34.0,
Self::Display => 26.0, Self::Display => 26.0,
Self::Title => 20.0, Self::Title => 20.0,
Self::Heading => 18.0, Self::Heading => 18.0,
@@ -90,10 +100,11 @@ impl Role {
} }
} }
/// Headings are set in the semibold cut; everything below them in the /// The hero line is set in the display cut, headings in the semibold
/// regular one. /// one, and everything below them in the regular one.
pub fn family(self) -> egui::FontFamily { pub fn family(self) -> egui::FontFamily {
match self { match self {
Self::Hero => display(),
Self::Display | Self::Title | Self::Heading | Self::Section => semibold(), Self::Display | Self::Title | Self::Heading | Self::Section => semibold(),
_ => egui::FontFamily::Proportional, _ => egui::FontFamily::Proportional,
} }
@@ -159,6 +170,9 @@ pub mod motion {
/// Artwork arriving over its placeholder. Slower, because it replaces /// Artwork arriving over its placeholder. Slower, because it replaces
/// something rather than adding to it, and a fast swap reads as a flicker. /// something rather than adding to it, and a fast swap reads as a flicker.
pub const ARRIVE: f32 = 0.24; 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. /// Fast out, slow in: the curve every hand-driven transition uses.
pub fn ease_out(t: f32) -> f32 { 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 /// sets the same on every desktop. Inter, under the OFL — the licence is
/// beside the files. /// 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 /// 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 /// 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 /// refresh arrow drew as an accented letter. The glyphs themselves are still
@@ -179,6 +193,9 @@ pub mod motion {
/// U+E000..=U+F8FF). /// U+E000..=U+F8FF).
const INTER: &[u8] = include_bytes!("../assets/fonts/Inter-Regular.ttf"); const INTER: &[u8] = include_bytes!("../assets/fonts/Inter-Regular.ttf");
const INTER_SEMIBOLD: &[u8] = include_bytes!("../assets/fonts/Inter-SemiBold.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 /// The family the headings are set in. egui has no notion of weight, so the
/// semibold cut is a family of its own. /// semibold cut is a family of its own.
@@ -186,6 +203,11 @@ pub fn semibold() -> egui::FontFamily {
egui::FontFamily::Name("semibold".into()) 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 /// 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 /// font resolves to, ahead of the app's own fonts, which stand in until it has
/// loaded and for any glyph it lacks. /// loaded and for any glyph it lacks.
@@ -238,6 +260,10 @@ fn font_definitions(system: &SystemFonts) -> egui::FontDefinitions {
"inter-semibold".into(), "inter-semibold".into(),
Arc::new(egui::FontData::from_static(INTER_SEMIBOLD)), Arc::new(egui::FontData::from_static(INTER_SEMIBOLD)),
); );
fonts.font_data.insert(
"inter-display".into(),
Arc::new(egui::FontData::from_static(INTER_DISPLAY)),
);
fonts fonts
.families .families
.entry(Proportional) .entry(Proportional)
@@ -258,10 +284,13 @@ fn font_definitions(system: &SystemFonts) -> egui::FontDefinitions {
.filter(|name| *name != "inter") .filter(|name| *name != "inter")
.cloned() .cloned()
.collect(); .collect();
let semibold_chain = std::iter::once("inter-semibold".to_owned()) let chain = |first: &str| {
.chain(fallbacks) std::iter::once(first.to_owned())
.collect(); .chain(fallbacks.iter().cloned())
fonts.families.insert(semibold(), semibold_chain); .collect()
};
fonts.families.insert(semibold(), chain("inter-semibold"));
fonts.families.insert(display(), chain("inter-display"));
let mut subtitle_chain = fonts.families[&Proportional].clone(); let mut subtitle_chain = fonts.families[&Proportional].clone();
if let Some((_, face, _)) = &system.subtitle { if let Some((_, face, _)) = &system.subtitle {
let name = "system-subtitle".to_owned(); 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 /// 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. /// somebody else — the selected tab, which sits on the pill.
pub fn veil_over( pub fn veil_over(
@@ -1169,7 +1235,11 @@ mod tests {
fn the_bundled_inter_leaves_the_icon_codepoints_to_phosphor() { fn the_bundled_inter_leaves_the_icon_codepoints_to_phosphor() {
use skrifa::MetadataProvider as _; 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 font = skrifa::FontRef::new(bytes).expect("bundled Inter parses");
let shadowed: Vec<u32> = font let shadowed: Vec<u32> = font
.charmap() .charmap()
+15 -17
View File
@@ -277,7 +277,8 @@ fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<A
} }
ui.add_space(theme::space::S); ui.add_space(theme::space::S);
} }
if ui.button("More info").clicked() { 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()))); actions.push(Action::Goto(Page::Title(title.key.clone())));
} }
}); });
@@ -303,15 +304,17 @@ fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<A
ui.label(ui::muted(facts.join(" · "))); ui.label(ui::muted(facts.join(" · ")));
ui.add_space(theme::space::XXS); ui.add_space(theme::space::XXS);
if ui::logo_heading(ui, art, title, 110.0).is_none() { if ui::logo_heading(ui, art, title, 110.0).is_none() {
ui.add( // Two lines before it gives up: a long romanised name cut to
egui::Label::new( // one at this size kept barely more than its first word.
theme::Role::Display let mut job = egui::text::LayoutJob::simple(
.rich(art.name(title)) art.name(title).to_owned(),
.strong() theme::Role::Hero.font(),
.color(theme::text()), theme::text(),
) ui.available_width().min(640.0),
.truncate(),
); );
job.wrap.max_rows = 2;
job.wrap.overflow_character = Some('…');
ui.label(job);
} }
}, },
); );
@@ -382,10 +385,7 @@ fn shelf_row(
actions: &mut Vec<Action>, actions: &mut Vec<Action>,
) { ) {
let (width, aspect) = tile_shape(tiles); let (width, aspect) = tile_shape(tiles);
egui::ScrollArea::horizontal() ui::shelf(ui, ("shelf", label), |ui| {
.id_salt(("shelf", label))
.auto_shrink([false, true])
.show(ui, |ui| {
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = theme::CARD_GAP; ui.spacing_mut().item_spacing.x = theme::CARD_GAP;
for title in titles { for title in titles {
@@ -429,10 +429,7 @@ fn continue_row(
titles: &[&Title], titles: &[&Title],
actions: &mut Vec<Action>, actions: &mut Vec<Action>,
) { ) {
egui::ScrollArea::horizontal() ui::shelf(ui, "continue", |ui| {
.id_salt("continue")
.auto_shrink([false, true])
.show(ui, |ui| {
ui.horizontal(|ui| { ui.horizontal(|ui| {
for title in titles { for title in titles {
let Some(episode) = title.continue_with() else { let Some(episode) = title.continue_with() else {
@@ -669,6 +666,7 @@ fn placeholder(ui: &mut egui::Ui) {
egui::CornerRadius::same(theme::radius::MD), egui::CornerRadius::same(theme::radius::MD),
theme::card(), theme::card(),
); );
theme::shimmer(ui.painter(), image);
} }
}); });
ui.add_space(theme::CARD_GAP); ui.add_space(theme::CARD_GAP);
+229 -6
View File
@@ -235,12 +235,22 @@ pub fn logo_heading(
if theme::palette().light { if theme::palette().light {
return None; 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 aspect = texture.aspect_ratio().max(0.1);
let width = ui.available_width().min(420.0).min(max_height * aspect); let width = ui.available_width().min(420.0).min(max_height * aspect);
let size = egui::vec2(width, width / aspect); let size = egui::vec2(width, width / aspect);
Some( Some(
ui.add(egui::Image::new((texture.id(), size))) ui.add(
egui::Image::new((texture.id(), size)).tint(egui::Color32::WHITE.gamma_multiply(shown)),
)
.on_hover_text(art.name(title)), .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)) 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. /// 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 /// Escape inside it clears it. `focus` puts the caret in it this frame — which
@@ -548,6 +571,10 @@ enum Fill {
Ink, Ink,
/// Nothing at rest, the hover colour under the pointer. /// Nothing at rest, the hover colour under the pointer.
None, 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. /// 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) 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; 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; 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 // 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. // 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 // egui drives this from wall-clock time, so it is the same speed whatever
// the frame rate — and it needs no state kept here. // the frame rate — and it needs no state kept here.
// Focus lights a tile the way the pointer does, so the keyboard can be // Focus lifts a tile the way the pointer does, and adds a ring of its own,
// followed across the grid. // so the keyboard can be followed across the grid.
let hover = ui.ctx().animate_bool_with_time( let hover = ui.ctx().animate_bool_with_time(
response.id.with("hover"), response.id.with("hover"),
response.hovered() || response.has_focus(), response.hovered() || response.has_focus(),
theme::motion::HOVER, 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 picture arrives whenever its download finishes, several frames after
// the tile first drew. Fading it in over the placeholder turns a wall of // the tile first drew. Fading it in over the placeholder turns a wall of
// letters popping into stills into one settle. // 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, 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( 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), Vec2::new(width, text_height - 6.0),
); );
let painter = painter.with_clip_rect(text_rect); 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) 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<R>(
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<f32> = 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 largest centred rectangle inside `target` with `image`'s aspect ratio.
/// ///
/// The other half of [`cover_uv`]: where that one keeps the whole rectangle and /// The other half of [`cover_uv`]: where that one keeps the whole rectangle and
@@ -1024,6 +1237,16 @@ mod tests {
use super::*; 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] #[test]
fn a_running_time_reads_in_hours_and_minutes() { fn a_running_time_reads_in_hours_and_minutes() {
assert_eq!(format_runtime(24), "24 min"); assert_eq!(format_runtime(24), "24 min");
+17 -49
View File
@@ -707,13 +707,8 @@ fn chrome(
.max_rect(top.shrink2(Vec2::new(18.0, 14.0))) .max_rect(top.shrink2(Vec2::new(18.0, 14.0)))
.layout(egui::Layout::left_to_right(egui::Align::Center)), .layout(egui::Layout::left_to_right(egui::Align::Center)),
|ui| { |ui| {
if ui let library = format!("{} Library", egui_phosphor::regular::CARET_LEFT);
.add( if ui::glass_button(ui, &library)
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)),
)
.on_hover_text("Keep playing, and go back to the library (Esc)") .on_hover_text("Keep playing, and go back to the library (Esc)")
.clicked() .clicked()
{ {
@@ -736,16 +731,13 @@ fn chrome(
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let fullscreen = ui.input(|input| input.viewport().fullscreen.unwrap_or(false)); let fullscreen = ui.input(|input| input.viewport().fullscreen.unwrap_or(false));
if ui if ui::glass_button(
.add( ui,
egui::Button::new(theme::Role::Subhead.rich(if fullscreen { if fullscreen {
egui_phosphor::regular::CORNERS_IN egui_phosphor::regular::CORNERS_IN
} else { } else {
egui_phosphor::regular::CORNERS_OUT 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 { .hint(if fullscreen {
"Leave fullscreen (F)" "Leave fullscreen (F)"
@@ -758,47 +750,22 @@ fn chrome(
} }
if let Some(open) = drawer { if let Some(open) = drawer {
ui.add_space(theme::space::S); ui.add_space(theme::space::S);
let fill = if open { // In the accent while the drawer is out, so the button
theme::accent() // 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 { } else {
Color32::from_black_alpha(160) ui::glass_button(ui, icon)
}; };
let ink = if open { if button.hint("Episodes (E)").clicked() {
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()
{
actions.push(Action::ToggleEpisodes); actions.push(Action::ToggleEpisodes);
} }
} }
// Esc leaves the page and keeps playing; this is the one-step // Esc leaves the page and keeps playing; this is the one-step
// way to be done with the episode. // way to be done with the episode.
ui.add_space(theme::space::S); ui.add_space(theme::space::S);
if ui if ui::glass_button(ui, egui_phosphor::regular::STOP)
.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)),
)
.hint("Stop") .hint("Stop")
.clicked() .clicked()
{ {
@@ -1010,8 +977,9 @@ fn drawer_row(
.unwrap_or_else(|| episode.detail().to_owned()); .unwrap_or_else(|| episode.detail().to_owned());
if let Some(numbering) = episode.numbering() { if let Some(numbering) = episode.numbering() {
ui.label( ui.label(
egui::RichText::new(numbering) theme::Role::Caption
.monospace() .rich(numbering)
.family(theme::semibold())
.color(theme::muted()), .color(theme::muted()),
); );
} }
+38 -20
View File
@@ -121,10 +121,7 @@ fn franchise_row(
members: &[&Title], members: &[&Title],
actions: &mut Vec<Action>, actions: &mut Vec<Action>,
) { ) {
egui::ScrollArea::horizontal() ui::shelf(ui, ("franchise", &current.key), |ui| {
.id_salt(("franchise", &current.key))
.auto_shrink([false, true])
.show(ui, |ui| {
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = theme::CARD_GAP; ui.spacing_mut().item_spacing.x = theme::CARD_GAP;
for member in members { for member in members {
@@ -218,7 +215,7 @@ fn season_watched(ui: &mut egui::Ui, title: &Title, season: &Season, actions: &m
} else { } else {
"Mark season watched" "Mark season watched"
}; };
if ui.button(label).clicked() { if ui::tonal_button(ui, label).clicked() {
actions.push(Action::MarkEpisodes { actions.push(Action::MarkEpisodes {
key: title.key.clone(), key: title.key.clone(),
links: season links: season
@@ -324,7 +321,7 @@ fn header(
// right of it rather than under it. // right of it rather than under it.
ui.set_max_width(ui.available_width().min(820.0)); ui.set_max_width(ui.available_width().min(820.0));
if ui::logo_heading(ui, art, title, 120.0).is_none() { 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 // Whichever of the share's name and the provider's is not the one
// shown. Worth showing rather than only replacing: a viewer should // shown. Worth showing rather than only replacing: a viewer should
@@ -348,6 +345,19 @@ fn header(
} }
ui.add_space(theme::space::XS); ui.add_space(theme::space::XS);
ui.label(ui::muted(meta_line(title, found.as_ref()))); 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.add_space(theme::space::L);
ui.horizontal(|ui| { ui.horizontal(|ui| {
@@ -374,8 +384,7 @@ fn header(
} }
// Beside the button it is the alternative to. // Beside the button it is the alternative to.
if resuming if resuming
&& ui && ui::tonal_button(ui, "Start over")
.button("Start over")
.on_hover_text("Play from the beginning") .on_hover_text("Play from the beginning")
.clicked() .clicked()
{ {
@@ -388,15 +397,13 @@ fn header(
let title_keys: Vec<_> = title.episodes().map(key_of).collect(); let title_keys: Vec<_> = title.episodes().map(key_of).collect();
let all_offline = title_keys.iter().all(|key| offline.files.contains(key)); let all_offline = title_keys.iter().all(|key| offline.files.contains(key));
if all_offline { if all_offline {
if ui if ui::tonal_button(ui, "Delete show")
.button("Delete show")
.on_hover_text("Delete the offline copies; keep the online source") .on_hover_text("Delete the offline copies; keep the online source")
.clicked() .clicked()
{ {
actions.push(Action::DeleteOffline(title_keys, title.name.clone())); actions.push(Action::DeleteOffline(title_keys, title.name.clone()));
} }
} else if ui } else if ui::tonal_button(ui, "Download show")
.button("Download show")
.on_hover_text("Download every episode for disconnected playback") .on_hover_text("Download every episode for disconnected playback")
.clicked() .clicked()
{ {
@@ -431,11 +438,13 @@ fn header(
// wrongly. Offered whether or not anything matched: the two // wrongly. Offered whether or not anything matched: the two
// cases it exists for are a title with no poster at all and a // cases it exists for are a title with no poster at all and a
// title wearing someone else's. // title wearing someone else's.
if ui if ui::tonal_button(
.button(match &found { ui,
match &found {
Some(_) => "Change match", Some(_) => "Change match",
None => "Match…", None => "Match…",
}) },
)
.on_hover_text("Search the metadata provider and pick the entry yourself") .on_hover_text("Search the metadata provider and pick the entry yourself")
.clicked() .clicked()
{ {
@@ -689,8 +698,6 @@ fn meta_line(title: &Title, found: Option<&TitleMetadata>) -> String {
parts.push(format!("{total} on {}", found.provider.label())); 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(" · ") parts.join(" · ")
} }
@@ -753,7 +760,10 @@ fn episode_row(
job.append( job.append(
&format!("{numbering} "), &format!("{numbering} "),
0.0, 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( job.append(
@@ -906,12 +916,20 @@ pub(crate) fn still(
painter.rect_filled(rect, radius, theme::card_hover()); painter.rect_filled(rect, radius, theme::card_hover());
// Asked for only once the row is on screen: a season of twenty-five is // 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. // 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 { let tint = if watched {
egui::Color32::from_gray(150) egui::Color32::from_gray(150)
} else { } else {
egui::Color32::WHITE egui::Color32::WHITE
}; }
.gamma_multiply(art_in);
painter.add( painter.add(
egui::epaint::RectShape::filled(rect, radius, tint) egui::epaint::RectShape::filled(rect, radius, tint)
.with_texture(texture.id(), ui::cover_uv(texture.size_vec2(), rect.size())), .with_texture(texture.id(), ui::cover_uv(texture.size_vec2(), rect.size())),
+19
View File
@@ -390,6 +390,25 @@ pub fn download_button(ui: &mut egui::Ui, glyph: DownloadGlyph) -> egui::Respons
response.on_hover_cursor(egui::CursorIcon::PointingHand) 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. /// A small rounded label: a genre, a year, a fact.
pub fn chip(ui: &mut egui::Ui, text: &str) -> egui::Response { pub fn chip(ui: &mut egui::Ui, text: &str) -> egui::Response {
egui::Frame::new() egui::Frame::new()
+7 -3
View File
@@ -20,12 +20,13 @@ is the contract, and each side's copy is asserted against it by a test.
## Type ## 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 scale is a different one (`displayLarge` is 57sp, which is a size for a lock
screen and not for anything in a library grid). screen and not for anything in a library grid).
| Role | Size | What it is | | 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 | | 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 | | 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 | | 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 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 (`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`); 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 everything below Section is the regular cut. Hero is set in Inter Display Bold,
1.35 × size rather than naming it nine more times. 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`, 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 regular weight), installed right behind Inter so an icon can sit inside an
+4 -1
View File
@@ -22,6 +22,9 @@ show it once.
genre with at least three titles. genre with at least three titles.
- **All titles** is the full grid. Choose a filter and a sort order above it. - **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 | | Filters | Sort orders |
|---|---| |---|---|
| All, Series, Films, Watching, Not started, Downloaded | A – Z, Recently watched, Recently added, Newest, Highest rated, Most popular | | 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. 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 - 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. 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**. **1080p**, **HDR**, **10-bit** or **Dual audio**.
An episode counts as watched once you have seen 90% of it. An episode counts as watched once you have seen 90% of it.
+18 -5
View File
@@ -123,7 +123,7 @@ struct PlayerView: View {
} }
.frame(maxWidth: .infinity, alignment: .trailing) .frame(maxWidth: .infinity, alignment: .trailing)
.padding(.trailing, 16) .padding(.trailing, 16)
.padding(.bottom, showControls ? 132 : 24) .padding(.bottom, showControls ? 160 : 24)
// Only while nothing else explains the still picture: an error // Only while nothing else explains the still picture: an error
// is the more useful thing to say. // is the more useful thing to say.
@@ -224,8 +224,15 @@ struct PlayerView: View {
} }
} }
.padding(.horizontal, 8) .padding(.horizontal, 8)
.padding(.vertical, 4) .padding(.top, 4)
.background(scheme.scrim.opacity(0.45).ignoresSafeArea(edges: [.top, .horizontal])) .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. /// 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) { private func doubleTap(at x: CGFloat, width: CGFloat) {
let third = width / 3 let third = width / 3
let seconds = x < third ? -doubleTapSeconds : x > width - third ? doubleTapSeconds : 0 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 { if seconds == 0 {
host.setPaused(!player.paused) host.setPaused(!player.paused)
} else { } else {
@@ -355,8 +364,12 @@ private struct PlayerControls: View {
} }
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 4) .padding(.top, 32)
.background(scheme.scrim.opacity(0.68).ignoresSafeArea(edges: [.bottom, .horizontal])) .padding(.bottom, 4)
.background(
LinearGradient(colors: [.clear, .black.opacity(0.85)], startPoint: .top, endPoint: .bottom)
.ignoresSafeArea(edges: [.bottom, .horizontal])
)
} }
private func toggleMute() { private func toggleMute() {
+81
View File
@@ -400,3 +400,84 @@ private extension UIColor {
return (red, green, blue, alpha) 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 }
}
}
+1 -1
View File
@@ -508,7 +508,7 @@ private struct FeaturedBanner: View {
// Always over the dark scrim above, so a logo made for dark // Always over the dark scrim above, so a logo made for dark
// art reads here whatever the theme. // art reads here whatever the theme.
RemoteLogo(title.logoUrl, title.displayName, maxWidth: 240, maxHeight: 72) { 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) .padding(.bottom, 6)
Text(factLine).textStyle(.bodySmall).foregroundStyle(.white.opacity(0.8)) Text(factLine).textStyle(.bodySmall).foregroundStyle(.white.opacity(0.8))
+34 -12
View File
@@ -32,9 +32,12 @@ extension EpisodeRecord {
/// ///
/// `fallback` is used only when there is no provider artwork or it fails to /// `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 /// 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 /// anything, it shows `ArtworkPlaceholder` rather than an empty card — without
/// initials when `labelled` is false, for art with the name already over it. /// 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 { struct RemoteArtwork: View {
let url: String? let url: String?
let description: String let description: String
@@ -57,11 +60,11 @@ struct RemoteArtwork: View {
.overlay { .overlay {
switch load { switch load {
case .pending: case .pending:
Skeleton() Skeleton().transition(.opacity)
case .missing: case .missing:
ArtworkPlaceholder(name: description, labelled: labelled) ArtworkPlaceholder(name: description, labelled: labelled).transition(.opacity)
case let .loaded(image): case let .loaded(image):
Image(uiImage: image).resizable().scaledToFill() Image(uiImage: image).resizable().scaledToFill().transition(.opacity)
} }
} }
.clipped() .clipped()
@@ -71,7 +74,7 @@ struct RemoteArtwork: View {
if case .loaded = load, ArtworkLoader.shared.cached(url: url, fallback: fallback) != nil { return } if case .loaded = load, ArtworkLoader.shared.cached(url: url, fallback: fallback) != nil { return }
let loaded = await ArtworkLoader.shared.artwork(url: url, fallback: fallback) let loaded = await ArtworkLoader.shared.artwork(url: url, fallback: fallback)
if Task.isCancelled { return } 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<Fallback: View>: View {
.scaledToFit() .scaledToFit()
.frame(maxWidth: maxWidth, maxHeight: maxHeight, alignment: .bottomLeading) .frame(maxWidth: maxWidth, maxHeight: maxHeight, alignment: .bottomLeading)
.accessibilityLabel(description) .accessibilityLabel(description)
.transition(.opacity)
} else { } else {
fallback() fallback().transition(.opacity)
} }
} }
.task(id: url) { .task(id: url) {
if logo != nil { return } if logo != nil { return }
let loaded = await ArtworkLoader.shared.logo(url) let loaded = await ArtworkLoader.shared.logo(url)
if Task.isCancelled { return } if Task.isCancelled { return }
logo = loaded withAnimation(Motion.arrive) { logo = loaded }
} }
} }
} }
@@ -211,21 +215,39 @@ private enum ArtworkLoad {
case loaded(UIImage) 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 /// 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. /// says what is coming and where, and settles the layout before it arrives.
struct Skeleton: View { struct Skeleton: View {
@Environment(\.scheme) private var scheme @Environment(\.scheme) private var scheme
@State private var dim = false @State private var sweep: CGFloat = -1
var body: some View { var body: some View {
scheme.surfaceVariant scheme.surfaceVariant
.opacity(dim ? 0.55 : 1) .overlay {
.onAppear { GeometryReader { geometry in
withAnimation(.linear(duration: 0.8).repeatForever(autoreverses: true)) { dim = true } 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 } }
}
} }
/// Art for a title that has none: the accent as a gradient, and its initials. /// Art for a title that has none: the accent as a gradient, and its initials.
+21 -7
View File
@@ -3,9 +3,11 @@ import SwiftUI
/// The type ramp, which is the desktop client's type ramp, under Material's /// 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. /// 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 /// Inter, Regular and SemiBold, as on the desktop and on Android: a `Medium`
/// `Medium` role resolves to Regular. The rungs, largest first: /// role resolves to Regular. The rungs, largest first:
/// 26 / 20 / 18 / 17 / 15 / 14 / 13 / 12 / 11. `TypeRampTests` pins them. /// 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 { enum TypeRole: CaseIterable {
case displayLarge, displayMedium, displaySmall case displayLarge, displayMedium, displaySmall
case headlineLarge, headlineMedium, headlineSmall case headlineLarge, headlineMedium, headlineSmall
@@ -15,7 +17,8 @@ enum TypeRole: CaseIterable {
var size: CGFloat { var size: CGFloat {
switch self { 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 .displaySmall, .titleLarge: Ramp.title
case .headlineSmall: Ramp.heading case .headlineSmall: Ramp.heading
case .titleMedium: Ramp.section case .titleMedium: Ramp.section
@@ -29,7 +32,7 @@ enum TypeRole: CaseIterable {
var semibold: Bool { var semibold: Bool {
switch self { switch self {
case .displayLarge, .displayMedium, .displaySmall, .headlineLarge, .headlineMedium, .headlineSmall, .titleLarge: case .displayMedium, .displaySmall, .headlineLarge, .headlineMedium, .headlineSmall, .titleLarge:
true true
default: default:
false false
@@ -39,6 +42,7 @@ enum TypeRole: CaseIterable {
/// The Dynamic Type style each rung scales with. /// The Dynamic Type style each rung scales with.
var relative: Font.TextStyle { var relative: Font.TextStyle {
switch size { switch size {
case Ramp.hero: .largeTitle
case Ramp.display: .title case Ramp.display: .title
case Ramp.title: .title2 case Ramp.title: .title2
case Ramp.heading: .title3 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, /// 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. /// 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 { enum Ramp {
static let hero: CGFloat = 34
static let display: CGFloat = 26 static let display: CGFloat = 26
static let title: CGFloat = 20 static let title: CGFloat = 20
static let heading: CGFloat = 18 static let heading: CGFloat = 18
@@ -67,11 +80,12 @@ enum Ramp {
static let caption: CGFloat = 12 static let caption: CGFloat = 12
static let micro: CGFloat = 11 static let micro: CGFloat = 11
static let lineHeight: CGFloat = 1.35 static let lineHeight: CGFloat = 1.35
static let heroLineHeight: CGFloat = 1.1
} }
extension Font { extension Font {
static func inter(_ style: TypeRole, semibold: Bool? = nil) -> 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 { static func inter(size: CGFloat, semibold: Bool = false) -> Font {
+21 -6
View File
@@ -150,10 +150,10 @@ struct TitleView: View {
// A light page keeps the name: a clear logo is lettering made for // A light page keeps the name: a clear logo is lettering made for
// dark art, white as often as not. // dark art, white as often as not.
if scheme.light { if scheme.light {
Text(title.displayName).textStyle(.headlineMedium).foregroundStyle(scheme.onSurface) Text(title.displayName).textStyle(.displayLarge).foregroundStyle(scheme.onSurface)
} else { } else {
RemoteLogo(title.logoUrl, title.displayName, maxWidth: 280, maxHeight: 88) { 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) .padding(.bottom, 4)
} }
@@ -179,6 +179,13 @@ struct TitleView: View {
if !facts.isEmpty { if !facts.isEmpty {
Text(facts).textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant).padding(.top, 2) 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 // The one thing the page is for, full width and naming what it will
// play: "Resume" alone left the viewer guessing which episode. // play: "Resume" alone left the viewer guessing which episode.
Button { onPlay(playlist, nextUp) } label: { Button { onPlay(playlist, nextUp) } label: {
@@ -213,9 +220,17 @@ struct TitleView: View {
.padding(.top, 12) .padding(.top, 12)
.onTapGesture { overviewOpen.toggle() } .onTapGesture { overviewOpen.toggle() }
} }
if !title.genres.isEmpty || !title.tags.isEmpty { // Genres as chips, then the provider's tags quieter after them: a
Text((title.genres + title.tags.prefix(4)).joined(separator: " · ")) // tag is its community describing it, not what it is filed as.
.textStyle(.labelMedium).foregroundStyle(scheme.onSurfaceVariant).padding(.top, 10) 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. // Said plainly, because it changes what a re-match will do.
if title.manualMatch { if title.manualMatch {
@@ -231,7 +246,7 @@ struct TitleView: View {
// A series' running time is its episodes', which each row says. // A series' running time is its episodes', which each row says.
let runtime: String? = title.kind == .film ? title.runtime.map(formatRuntime) : nil let runtime: String? = title.kind == .film ? title.runtime.map(formatRuntime) : nil
let parts: [String?] = [title.formatLabel, title.seasonLabel ?? year, runtime, rating, watched] 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 { private func playLabel(playlist: [EpisodeRecord], upNext: EpisodeRecord?) -> String {
+7 -3
View File
@@ -264,10 +264,10 @@ final class ThemeTests: XCTestCase {
XCTAssertEqual(alpha, 128 / 255, accuracy: 0.001) 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`. /// derived from the size — Android's `TypeRampTest`.
func testEveryStyleIsOnTheDesktopRamp() { func testEveryStyleIsOnTheDesktopRamp() {
let rungs: Set<CGFloat> = [26, 20, 18, 17, 15, 14, 13, 12, 11] let rungs: Set<CGFloat> = [34, 26, 20, 18, 17, 15, 14, 13, 12, 11]
let used = Set(TypeRole.allCases.map(\.size)) let used = Set(TypeRole.allCases.map(\.size))
XCTAssertEqual(used, rungs) XCTAssertEqual(used, rungs)
} }
@@ -275,10 +275,14 @@ final class ThemeTests: XCTestCase {
func testOnlyHeadingsAreSemibold() { func testOnlyHeadingsAreSemibold() {
XCTAssertEqual( XCTAssertEqual(
Set(TypeRole.allCases.filter(\.semibold)), 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() { func testAPaddedLogoIsBoundedByItsLetteringAndNotItsHaze() {
let width = 10 let width = 10
var alpha = [UInt8](repeating: 0, count: width * 6) var alpha = [UInt8](repeating: 0, count: width * 6)
+3
View File
@@ -31,6 +31,8 @@ targets:
buildPhase: resources buildPhase: resources
- path: ../android/app/src/main/res/font/inter_semibold.ttf - path: ../android/app/src/main/res/font/inter_semibold.ttf
buildPhase: resources 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 - path: ../android/app/src/main/assets/licenses/GPL-3.0.txt
buildPhase: resources buildPhase: resources
- path: ../android/app/src/main/assets/licenses/Inter-OFL.txt - path: ../android/app/src/main/assets/licenses/Inter-OFL.txt
@@ -74,6 +76,7 @@ targets:
UIAppFonts: UIAppFonts:
- inter_regular.ttf - inter_regular.ttf
- inter_semibold.ttf - inter_semibold.ttf
- inter_display_bold.ttf
# A share link handed over from elsewhere: protonstream://add?url=…, # A share link handed over from elsewhere: protonstream://add?url=…,
# or the link with its scheme swapped (IncomingShareLink.swift). # or the link with its scheme swapped (IncomingShareLink.swift).
CFBundleURLTypes: CFBundleURLTypes: