mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
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:
1 parent
255e463fc8
commit
c6d303f269
20 files changed
+1358
-327
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user