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.
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,12 +114,34 @@ 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
|
||||
@@ -158,7 +187,8 @@ internal fun RemoteLogo(
|
||||
LaunchedEffect(url, enabled) {
|
||||
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()
|
||||
else -> Image(
|
||||
loaded,
|
||||
@@ -169,6 +199,7 @@ internal fun RemoteLogo(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadLogo(context: Context, url: String?): Bitmap? {
|
||||
val requested = url ?: return null
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 181 KiB |
|
Before Width: | Height: | Size: 813 KiB After Width: | Height: | Size: 811 KiB |
|
Before Width: | Height: | Size: 768 KiB After Width: | Height: | Size: 767 KiB |
|
Before Width: | Height: | Size: 672 KiB After Width: | Height: | Size: 671 KiB |
|
Before Width: | Height: | Size: 806 KiB After Width: | Height: | Size: 805 KiB |
|
Before Width: | Height: | Size: 632 KiB After Width: | Height: | Size: 635 KiB |
|
Before Width: | Height: | Size: 670 KiB After Width: | Height: | Size: 636 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
@@ -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()
|
||||
|
||||
@@ -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,10 +385,7 @@ 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::shelf(ui, ("shelf", label), |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = theme::CARD_GAP;
|
||||
for title in titles {
|
||||
@@ -429,10 +429,7 @@ fn continue_row(
|
||||
titles: &[&Title],
|
||||
actions: &mut Vec<Action>,
|
||||
) {
|
||||
egui::ScrollArea::horizontal()
|
||||
.id_salt("continue")
|
||||
.auto_shrink([false, true])
|
||||
.show(ui, |ui| {
|
||||
ui::shelf(ui, "continue", |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
for title in titles {
|
||||
let Some(episode) = title.continue_with() else {
|
||||
@@ -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);
|
||||
|
||||
@@ -235,12 +235,22 @@ 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)))
|
||||
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");
|
||||
|
||||
@@ -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,16 +731,13 @@ 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 {
|
||||
if ui::glass_button(
|
||||
ui,
|
||||
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)"
|
||||
@@ -758,47 +750,22 @@ fn chrome(
|
||||
}
|
||||
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()),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -121,10 +121,7 @@ fn franchise_row(
|
||||
members: &[&Title],
|
||||
actions: &mut Vec<Action>,
|
||||
) {
|
||||
egui::ScrollArea::horizontal()
|
||||
.id_salt(("franchise", ¤t.key))
|
||||
.auto_shrink([false, true])
|
||||
.show(ui, |ui| {
|
||||
ui::shelf(ui, ("franchise", ¤t.key), |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = theme::CARD_GAP;
|
||||
for member in members {
|
||||
@@ -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,11 +438,13 @@ 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()
|
||||
{
|
||||
@@ -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())),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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,21 +215,39 @@ 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 } }
|
||||
}
|
||||
}
|
||||
|
||||
/// Art for a title that has none: the accent as a gradient, and its initials.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||