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

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