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