feat(app,android,ios): full-bleed hero carousel with ambient colour

The library's banner now runs edge to edge and turns through up to five
titles: the one watched last, then titles with a wide picture, starting
from a different one each day. It moves on every eight seconds, holds
still under the pointer, and has dots to pick a title. A part-watched
title shows how far in the viewer is under its buttons.

The veils over the banner and over a title page's band are tinted with
the picture's own colour. The weighting and the veil live in
pstr_core::appearance (mood, mood_veil), so the desktop and both phones,
which reach them through the bridge, land on the same colour.

On Android the banner is a HorizontalPager under a clear bar that fills
in as it scrolls away; iOS uses a paged TabView the same way.
This commit is contained in:
nvrl committed 2026-10-30 21:19:37 +01:00
1 parent 255e463fc8
commit c6d303f269
20 files changed
+1358 -327

No files matched your search

+205 -73
View File
@@ -77,35 +77,57 @@ struct LibraryView: View {
// library spent a band of every visit on something used in few of them.
@SceneStorage("library.searching") private var searching = false
@State private var collapsed = false
/// How far the bar over the banner has filled in, from clear to solid.
@State private var reveal: CGFloat = 0
/// The page's bar: its title, shown as much as `solid` says, and its
/// actions, in `ink` or the scheme's own.
private func bar(solid: CGFloat, ink: Color?) -> some View {
HStack(spacing: 0) {
Text("Library")
.textStyle(.titleLarge)
.foregroundStyle(scheme.onSurface)
.padding(.leading, 16)
.opacity(solid)
Spacer()
IconButton("magnifyingglass", "Search library", tint: ink) { searching = true }
ArrangeMenu(sort: model.sort, grouped: model.grouped, onArrange: model.arrange)
// Pull to refresh is the gesture; the icon stays for a viewer who
// does not know it.
IconButton("arrow.clockwise", "Refresh", tint: ink, enabled: !model.refreshing) { model.refresh() }
}
.frame(height: 56)
.padding(.trailing, 4)
}
var body: some View {
let resumable = resumable(model.titles)
VStack(spacing: 0) {
if searching {
SearchBar(query: Binding(get: { model.query }, set: model.search)) {
searching = false
model.search("")
let browsing = model.query.trimmingCharacters(in: .whitespaces).isEmpty
let featured = featuredTitles(model.titles, resumable)
// With a banner, the bar lies over it, clear, and fills in as the
// banner scrolls away; without one, the large title folds into the bar
// as the grid scrolls.
let overBanner = !searching && !model.loading && browsing && !featured.isEmpty
ZStack(alignment: .top) {
VStack(spacing: 0) {
if searching {
SearchBar(query: Binding(get: { model.query }, set: model.search)) {
searching = false
model.search("")
}
} else if !overBanner {
bar(solid: collapsed ? 1 : 0, ink: nil)
.background(collapsed ? scheme.surfaceContainer : scheme.background)
}
} else {
HStack(spacing: 0) {
// The large title folds into the bar as the grid scrolls.
Text("Library")
.textStyle(.titleLarge)
.foregroundStyle(scheme.onSurface)
.padding(.leading, 16)
.opacity(collapsed ? 1 : 0)
Spacer()
IconButton("magnifyingglass", "Search library") { searching = true }
ArrangeMenu(sort: model.sort, grouped: model.grouped, onArrange: model.arrange)
// Pull to refresh is the gesture; the icon stays for a
// viewer who does not know it.
IconButton("arrow.clockwise", "Refresh", enabled: !model.refreshing) { model.refresh() }
}
.frame(height: 56)
.padding(.trailing, 4)
.background(collapsed ? scheme.surfaceContainer : scheme.background)
content(resumable, featured: overBanner ? featured : [])
}
if overBanner {
// White over the banner's own scrim, then the page's ink once
// the bar has filled in.
bar(solid: reveal, ink: reveal > 0.5 ? nil : .white)
.background(scheme.surfaceContainer.opacity(reveal).ignoresSafeArea(edges: .top))
.animation(.easeOut(duration: 0.15), value: reveal > 0.5)
}
content(resumable)
}
.background(scheme.background)
.sheet(item: $menuFor, onDismiss: {
@@ -150,7 +172,7 @@ struct LibraryView: View {
}
}
@ViewBuilder private func content(_ resumable: [Resumable]) -> some View {
@ViewBuilder private func content(_ resumable: [Resumable], featured: [TitleRecord]) -> some View {
if model.loading {
LibrarySkeleton()
} else if model.titles.isEmpty && !model.query.trimmingCharacters(in: .whitespaces).isEmpty {
@@ -170,11 +192,11 @@ struct LibraryView: View {
}
.refreshable { await model.crawlNow(nil) }
} else {
grid(resumable)
grid(resumable, featured: featured)
}
}
private func grid(_ resumable: [Resumable]) -> some View {
private func grid(_ resumable: [Resumable], featured: [TitleRecord]) -> some View {
// The arrangement names titles by key; before it has arrived, the
// titles as they came are the grid.
let byKey = Dictionary(model.titles.map { ($0.key, $0) }, uniquingKeysWith: { first, _ in first })
@@ -184,11 +206,25 @@ struct LibraryView: View {
.map { Shelf(label: $0.label, titles: $0.keys.compactMap { byKey[$0] }) }
.filter { !$0.titles.isEmpty }
let browsing = model.query.trimmingCharacters(in: .whitespaces).isEmpty
let featured = browsing ? featuredTitle(model.titles, resumable) : nil
return ScrollView {
LazyVStack(alignment: .leading, spacing: 16) {
if !searching {
if !featured.isEmpty {
FeaturedBanner(titles: featured, resumable: resumable, onPlay: onResume, onOpen: onTitle)
// Edge to edge, out of the grid's margin.
.padding(.horizontal, -16)
.background {
GeometryReader { geometry in
Color.clear.onChange(of: geometry.frame(in: .named("library")).minY) { _, top in
reveal = min(max(-top / 280, 0), 1)
}
}
}
// Scrolled far enough to leave the lazy stack: the bar
// is long since solid.
.onDisappear { reveal = 1 }
.onAppear { reveal = 0 }
} else if !searching {
Text("Library")
.textStyle(.headlineMedium)
.foregroundStyle(scheme.onSurface)
@@ -206,14 +242,6 @@ struct LibraryView: View {
SettingsStore().metadataOfferDismissed = true
}
}
if let featured {
FeaturedBanner(
title: featured,
resume: resumable.first { $0.title.key == featured.key },
onPlay: { onResume(featured, $0) },
onOpen: { onTitle(featured) }
)
}
if browsing && !resumable.isEmpty {
VStack(alignment: .leading, spacing: 10) {
Text("Continue watching").textStyle(.titleMedium).foregroundStyle(scheme.onSurface)
@@ -273,8 +301,11 @@ struct LibraryView: View {
}
}
}
.padding(EdgeInsets(top: 8, leading: 16, bottom: 24, trailing: 16))
// No room above a banner: it starts under the status bar, and the
// bar lies over it.
.padding(EdgeInsets(top: featured.isEmpty ? 8 : 0, leading: 16, bottom: 24, trailing: 16))
}
.ignoresSafeArea(edges: featured.isEmpty ? [] : .top)
.coordinateSpace(name: "library")
.refreshable { await model.crawlNow(nil) }
.scrollDismissesKeyboard(.immediately)
@@ -475,59 +506,160 @@ private struct MetadataOfferCard: View {
}
}
/// The title the banner shows: the one watched last, or with nothing part
/// watched, one of the titles the provider has a backdrop for, turning over
/// once a day — the desktop's `featured`.
private func featuredTitle(_ titles: [TitleRecord], _ resumable: [Resumable]) -> TitleRecord? {
if let first = resumable.first { return first.title }
/// How many titles the banner turns through: the desktop's `FEATURED`.
private let featuredMax = 5
/// How long each title holds the banner: the desktop's `HOLD`.
private let holdSeconds = 8.0
/// One title giving way to the next: the desktop's `TURN`.
private let turn = Animation.easeInOut(duration: 0.6)
/// The titles the banner turns through: the one watched last first, then
/// titles the provider has a backdrop for, starting from a different one each
/// day — the desktop's `featured`.
private func featuredTitles(_ titles: [TitleRecord], _ resumable: [Resumable]) -> [TitleRecord] {
var picked = resumable.first.map { [$0.title] } ?? []
let pictured = titles.filter { $0.backdropUrl != nil }
if pictured.isEmpty { return nil }
if pictured.isEmpty { return picked }
let day = Int(Date().timeIntervalSince1970 + Double(TimeZone.current.secondsFromGMT())) / 86400
return pictured[day % pictured.count]
for offset in pictured.indices {
if picked.count == featuredMax { break }
let title = pictured[(day + offset) % pictured.count]
if !picked.contains(where: { $0.key == title.key }) { picked.append(title) }
}
return picked
}
/// Large art behind the name, rating, genres and three lines of synopsis.
/// The banner at the top of the library: one title at a time, edge to edge
/// under the status bar, turning to the next every `holdSeconds` — a swipe
/// starts the count again. Each title's veil is tinted with its own picture's
/// colour.
private struct FeaturedBanner: View {
let titles: [TitleRecord]
let resumable: [Resumable]
let onPlay: (TitleRecord, Int) -> Void
let onOpen: (TitleRecord) -> Void
@Environment(\.scheme) private var scheme
@State private var shown = ""
var body: some View {
let current = titles.contains { $0.key == shown } ? shown : titles.first?.key ?? ""
TabView(selection: Binding(get: { current }, set: { shown = $0 })) {
ForEach(titles, id: \.key) { title in
FeaturedPage(
title: title,
resume: resumable.first { $0.title.key == title.key },
onPlay: { onPlay(title, $0) },
onOpen: { onOpen(title) }
)
.tag(title.key)
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
// Most of a phone's first screen, but never so much of an iPad's that
// nothing under it shows.
.containerRelativeFrame(.vertical) { height, _ in min(max(height * 0.62, 360), 620) }
.overlay(alignment: .bottom) {
if titles.count > 1 {
HStack(spacing: 6) {
ForEach(titles, id: \.key) { title in
let selected = title.key == current
Capsule()
.fill(scheme.onBackground.opacity(selected ? 1 : 0.4))
.frame(width: selected ? 18 : 6, height: 6)
.contentShape(Rectangle().inset(by: -8))
.onTapGesture { withAnimation(turn) { shown = title.key } }
.accessibilityLabel("Show \(title.displayName)")
.accessibilityAddTraits(selected ? [.isButton, .isSelected] : .isButton)
}
}
.animation(.easeOut(duration: 0.15), value: current)
.padding(.bottom, 10)
}
}
.task(id: current) {
guard titles.count > 1 else { return }
try? await Task.sleep(for: .seconds(holdSeconds))
if Task.isCancelled { return }
let index = titles.firstIndex { $0.key == current } ?? 0
withAnimation(turn) { shown = titles[(index + 1) % titles.count].key }
}
}
}
/// One title on the banner: its art, its veil, and what it is, from the
/// bottom up.
private struct FeaturedPage: View {
let title: TitleRecord
let resume: Resumable?
let onPlay: (Int) -> Void
let onOpen: () -> Void
@Environment(\.scheme) private var scheme
@State private var mood: UInt32?
var body: some View {
Backdrop(title: title)
.aspectRatio(4 / 3, contentMode: .fit)
.overlay {
LinearGradient(
stops: [.init(color: .clear, location: 0.35), .init(color: .black.opacity(0.85), location: 1)],
startPoint: .top,
endPoint: .bottom
)
}
.overlay(alignment: .bottomLeading) {
VStack(alignment: .leading, spacing: 0) {
// 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(.displayLarge).foregroundStyle(.white).lineLimit(2)
}
.padding(.bottom, 6)
Text(factLine).textStyle(.bodySmall).foregroundStyle(.white.opacity(0.8))
if let overview = title.overview {
Text(overview.trimmingCharacters(in: .whitespacesAndNewlines))
.textStyle(.bodySmall).foregroundStyle(.white.opacity(0.8)).lineLimit(3).padding(.top, 6)
let veil = scheme.veil(mood)
ZStack(alignment: .bottomLeading) {
Backdrop(title: title) { mood = $0 }
LinearGradient(
stops: [
// Dark under the status bar and the bar's white icons…
.init(color: .black.opacity(0.5), location: 0),
.init(color: .clear, location: 0.22),
// …and the picture's own colour under the text, ending in
// the page it hands over to.
.init(color: veil.opacity(0.35), location: 0.45),
.init(color: veil.opacity(0.92), location: 0.78),
.init(color: scheme.background, location: 1),
],
startPoint: .top,
endPoint: .bottom
)
VStack(alignment: .leading, spacing: 0) {
// A logo is lettering made for dark art, white as often as
// not; over a light palette's veil it is gone, so the light
// palette keeps the name.
if scheme.light {
name
} else {
RemoteLogo(title.logoUrl, title.displayName, maxWidth: 260, maxHeight: 88) { name }
.padding(.bottom, 8)
}
Text(factLine).textStyle(.bodySmall).foregroundStyle(scheme.onBackground.opacity(0.8))
if let overview = title.overview {
Text(overview.trimmingCharacters(in: .whitespacesAndNewlines))
.textStyle(.bodySmall).foregroundStyle(scheme.onBackground.opacity(0.8)).lineLimit(2).padding(.top, 6)
}
HStack(spacing: 8) {
Button { onPlay(resume?.index ?? nextUpIndex(title.playlist, continueIndex: title.continueIndex)) } label: {
Label(resume?.episode.resumeAt != nil ? "Resume" : "Play", systemImage: "play.fill")
}
.buttonStyle(.accent)
Button(action: onOpen) { Label("More info", systemImage: "info.circle") }.buttonStyle(.tonal)
}
.padding(.top, 12)
if let episode = resume?.episode, episode.resumeAt != nil, let progress = episode.progress {
HStack(spacing: 8) {
Button { onPlay(resume?.index ?? nextUpIndex(title.playlist, continueIndex: title.continueIndex)) } label: {
Label(resume != nil ? "Resume" : "Play", systemImage: "play.fill")
AccentProgress(progress: progress).frame(width: 120, height: 4)
if let left = episode.timeLeft {
Text(left).textStyle(.bodySmall).foregroundStyle(scheme.onBackground.opacity(0.8))
}
.buttonStyle(.accent)
Button("More info", action: onOpen).buttonStyle(.tonal)
}
.padding(.top, 12)
}
.padding(16)
}
.clipShape(RoundedRectangle(cornerRadius: Corner.standard))
.frame(maxWidth: 560, alignment: .leading)
.padding(EdgeInsets(top: 0, leading: 16, bottom: 28, trailing: 16))
}
.animation(Motion.arrive, value: mood)
.clipped()
.contentShape(Rectangle())
.onTapGesture(perform: onOpen)
}
private var name: some View {
Text(title.displayName).textStyle(.displayLarge).foregroundStyle(scheme.onBackground).lineLimit(2).padding(.bottom, 4)
}
private var factLine: String {
+73 -3
View File
@@ -43,13 +43,22 @@ struct RemoteArtwork: View {
let description: String
var fallback: ThumbnailSource?
var labelled = true
/// Told the picture's colour once it has arrived — see `pictureMood`.
var onMood: ((UInt32) -> Void)?
@State private var load: ArtworkLoad
init(_ url: String?, _ description: String, fallback: ThumbnailSource? = nil, labelled: Bool = true) {
init(
_ url: String?,
_ description: String,
fallback: ThumbnailSource? = nil,
labelled: Bool = true,
onMood: ((UInt32) -> Void)? = nil
) {
self.url = url
self.description = description
self.fallback = fallback
self.labelled = labelled
self.onMood = onMood
// A poster already decoded draws in the first frame, so scrolling back
// over the grid does not flash a skeleton per tile.
_load = State(initialValue: ArtworkLoader.shared.cached(url: url, fallback: fallback).map(ArtworkLoad.loaded) ?? .pending)
@@ -71,28 +80,89 @@ struct RemoteArtwork: View {
.accessibilityElement()
.accessibilityLabel(description)
.task(id: Request(url: url, fallback: fallback)) {
if case .loaded = load, ArtworkLoader.shared.cached(url: url, fallback: fallback) != nil { return }
if case let .loaded(image) = load, ArtworkLoader.shared.cached(url: url, fallback: fallback) != nil {
report(image)
return
}
let loaded = await ArtworkLoader.shared.artwork(url: url, fallback: fallback)
if Task.isCancelled { return }
withAnimation(Motion.arrive) { load = loaded.map(ArtworkLoad.loaded) ?? .missing }
if let loaded { report(loaded) }
}
}
private func report(_ image: UIImage) {
guard let onMood, let mood = moodOf(image) else { return }
onMood(mood)
}
private struct Request: Hashable {
let url: String?
let fallback: ThumbnailSource?
}
}
/// A picture squeezed to this many pixels a side is still its colours, and a
/// few hundred values to average — Android's `MOOD_EDGE`.
private let moodEdge = 24
/// The colour a picture leaves behind, as `0xAARRGGBB`: the shared
/// `appearance::mood`, over the picture squeezed to `moodEdge` pixels a side.
func moodOf(_ image: UIImage) -> UInt32? {
guard let cgImage = image.cgImage else { return nil }
var bytes = [UInt8](repeating: 0, count: moodEdge * moodEdge * 4)
let drawn: Bool = bytes.withUnsafeMutableBytes { buffer in
guard let context = CGContext(
data: buffer.baseAddress,
width: moodEdge,
height: moodEdge,
bitsPerComponent: 8,
bytesPerRow: moodEdge * 4,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else { return false }
context.interpolationQuality = .medium
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: moodEdge, height: moodEdge))
return true
}
guard drawn else { return nil }
let pixels: [UInt32] = stride(from: 0, to: bytes.count, by: 4).map { index in
let alpha = UInt32(bytes[index + 3])
// Premultiplied as drawn; the average wants the colour itself.
let channel = { (value: UInt8) -> UInt32 in
alpha == 0 ? 0 : min(255, UInt32(value) * 255 / alpha)
}
return alpha << 24 | channel(bytes[index]) << 16 | channel(bytes[index + 1]) << 8 | channel(bytes[index + 2])
}
return pictureMood(pixels: pixels)
}
extension Scheme {
/// The page colour tinted by a picture's `mood`, for the veils over that
/// picture — the shared `appearance::mood_veil`. The page colour itself
/// until the mood is known.
func veil(_ mood: UInt32?) -> Color {
guard let mood else { return background }
return argb(moodVeil(background: backgroundArgb, mood: mood, light: light))
}
}
/// A title's art across the top of a page: its backdrop, or with none, its
/// poster as a wash of its own colours — blurred, so it reads as the poster's
/// light rather than a blown-up slice of it. The desktop's `Fit::Ambient`.
struct Backdrop: View {
let title: TitleRecord
var onMood: ((UInt32) -> Void)?
var body: some View {
let wash = title.backdropUrl == nil && title.posterUrl != nil
RemoteArtwork(title.backdropUrl ?? title.posterUrl, title.displayName, fallback: title.thumbnailSource, labelled: false)
RemoteArtwork(
title.backdropUrl ?? title.posterUrl,
title.displayName,
fallback: title.thumbnailSource,
labelled: false,
onMood: onMood
)
.blur(radius: wash ? 24 : 0, opaque: true)
}
}
+4
View File
@@ -53,6 +53,9 @@ final class AppearanceState {
/// `schemeOf` line for line; `SchemeRolesTests` pins it.
struct Scheme: Equatable {
let light: Bool
/// `background` as the bridge hands it over, for the shared colour work
/// that takes it back — `Scheme.veil`.
let backgroundArgb: UInt32
let primary: Color
let onPrimary: Color
let primaryContainer: Color
@@ -91,6 +94,7 @@ struct Scheme: Equatable {
init(_ palette: PaletteRecord) {
light = palette.light
backgroundArgb = palette.background
primary = argb(palette.accent)
onPrimary = argb(palette.onAccent)
primaryContainer = argb(palette.accentDim)