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

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