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
+886 -290

No files matched your search

@@ -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
@@ -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),
)
}
@@ -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,
@@ -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>(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))
}
/**
@@ -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
@@ -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,
)
Binary file not shown.
@@ -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)
}
}
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;
/// 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<u32> = font
.charmap()
+73 -75
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);
}
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())));
}
});
@@ -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.add_space(theme::space::XXS);
if ui::logo_heading(ui, art, title, 110.0).is_none() {
ui.add(
egui::Label::new(
theme::Role::Display
.rich(art.name(title))
.strong()
.color(theme::text()),
)
.truncate(),
// Two lines before it gives up: a long romanised name cut to
// one at this size kept barely more than its first word.
let mut job = egui::text::LayoutJob::simple(
art.name(title).to_owned(),
theme::Role::Hero.font(),
theme::text(),
ui.available_width().min(640.0),
);
job.wrap.max_rows = 2;
job.wrap.overflow_character = Some('…');
ui.label(job);
}
},
);
@@ -382,36 +385,33 @@ fn shelf_row(
actions: &mut Vec<Action>,
) {
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<Action>,
) {
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);
+230 -7
View File
@@ -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<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 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");
+26 -58
View File
@@ -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()),
);
}
+77 -59
View File
@@ -121,49 +121,46 @@ fn franchise_row(
members: &[&Title],
actions: &mut Vec<Action>,
) {
egui::ScrollArea::horizontal()
.id_salt(("franchise", &current.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", &current.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())),
+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)
}
/// 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()
+7 -3
View File
@@ -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
+4 -1
View File
@@ -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.
+18 -5
View File
@@ -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() {
+81
View File
@@ -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 }
}
}
+1 -1
View File
@@ -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))
+34 -12
View File
@@ -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<Fallback: View>: 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 } }
}
}
+21 -7
View File
@@ -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 {
+21 -6
View File
@@ -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 {
+7 -3
View File
@@ -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<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))
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)
+3
View File
@@ -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: