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

+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 {