Flixy heißt ab hier Halo
Reine Umbenennung, kein Verhalten geändert — bewusst als eigener Commit, damit „umbenannt" und „umgebaut" im Nachhinein auseinanderzuhalten sind. 712 mal `Flixy`, 778 mal `flixy`, 7 mal `FLIXY` in 169 Dateien, dazu 25 Pfade. Ziele: `Halo`, `HaloTV`, `HaloTopShelf`, `HaloUITests`, `HaloKit` mit `HaloDesign`, `HaloDownloads`, `HaloPlayback` und `HaloProbe`. `MediaKit`, `JellyfinKit` und `DownloadWidget` tragen den Namen nicht und bleiben. `docs/superpowers/specs/` ist ausgenommen. Datierte Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen; sie nachträglich umzuschreiben hiesse, die Vergangenheit zu glätten. **Die Bundle-ID wandert mit** — ausdrücklich so gewollt, mit bekanntem Preis: `com.scarriffleservices.halo` ist für iOS eine *andere* App. Die installierte Fassung bleibt daneben stehen, mit Downloads und Anmeldung in ihrem Container; Halo startet leer. Einmal neu anmelden, heruntergeladene Folgen einmal neu laden. Dasselbe gilt für die App-Gruppe, die sich TV-App und Top Shelf teilen. **Eine Folge davon, die beim Entscheiden noch nicht auf dem Tisch lag:** der Aufräumschritt aus dem vorigen Commit kommt an die alten Plex-Token nicht mehr heran. Der Schlüsselbund grenzt nach Team und Bundle-Kennung ab — was unter `…flixy` liegt, ist für `…halo` unerreichbar. Erledigt wird es trotzdem, nur anders: **die alte App löschen.** iOS räumt deren Schlüsselbund-Einträge beim Deinstallieren mit ab. Der Code bleibt stehen, weil er für jeden Stand vor dieser Umbenennung richtig ist und im Jahr einen Startvergleich kostet. Die Projektdatei ist aus `project.yml` neu erzeugt, der alte Ordner gelöscht statt umbenannt — sie steht ohnehin nicht im Repo. Das Verzeichnis des Repos selbst heißt weiter `Flixy`; das umzubenennen ist ein Handgriff ausserhalb des Projekts. 368 Tests grün. iOS und tvOS bauen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2fcf6821c8
commit
4b9a1e7b67
@@ -0,0 +1,166 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
public typealias PlatformImage = UIImage
|
||||
#else
|
||||
import AppKit
|
||||
public typealias PlatformImage = NSImage
|
||||
#endif
|
||||
|
||||
/// Lädt Bilder vom Server — einmal, und behält sie.
|
||||
///
|
||||
/// `AsyncImage` konnte das nicht: es lädt bei **jedem** Erscheinen neu, kennt
|
||||
/// keinen Zwischenspeicher und legt für jedes Bild eine eigene Anfrage an. Eine
|
||||
/// Startseite zeigt leicht sechzig Kacheln; dazu ein Aufmacher, der ein großes
|
||||
/// Bild anfordert, das der Server erst umrechnen muss. Mit sechs gleichzeitigen
|
||||
/// Verbindungen je Gegenstelle — der Voreinstellung — stauen sich die übrigen
|
||||
/// dahinter, und wer wegscrollt und zurückkommt, stellt sich hinten wieder an.
|
||||
///
|
||||
/// Auf dem iPad fiel das nicht auf, auf dem Mac schon: dort blieben der
|
||||
/// Aufmacher und die Hälfte der Plakate grau.
|
||||
///
|
||||
/// Zwei Speicher übereinander: die entpackten Bilder im Arbeitsspeicher, die
|
||||
/// Rohdaten auf der Platte. Der zweite überlebt einen Neustart der App —
|
||||
/// dieselbe Startseite kostet dann keine einzige Anfrage mehr.
|
||||
public actor ArtworkLoader {
|
||||
public static let shared = ArtworkLoader()
|
||||
|
||||
private let session: URLSession
|
||||
/// Der Speicher liegt **außerhalb** des Actors, damit er sich ohne
|
||||
/// `await` lesen lässt.
|
||||
///
|
||||
/// Das klingt nach einer Kleinigkeit und ist der ganze Punkt: ein `await`
|
||||
/// kostet immer mindestens einen Durchlauf, und genau der war beim
|
||||
/// Überblenden des Aufmachers zu sehen. Das neue Bild erschien im ersten
|
||||
/// Bild noch ohne Schriftzug, im zweiten mit — der Schriftzug sprang also
|
||||
/// getrennt herein, obwohl er längst da war. Wer das Bild schon hat, soll
|
||||
/// es im selben Augenblick zeigen können.
|
||||
private let cache = ImageMemoryCache()
|
||||
/// Läuft eine Anfrage schon, wird sie geteilt statt ein zweites Mal
|
||||
/// gestellt. Ohne das lädt eine Reihe, die dasselbe Plakat zweimal zeigt,
|
||||
/// es auch zweimal.
|
||||
private var inFlight: [URL: Task<PlatformImage?, Never>] = [:]
|
||||
|
||||
init() {
|
||||
let configuration = URLSessionConfiguration.default
|
||||
configuration.urlCache = URLCache(
|
||||
memoryCapacity: 32 * 1_024 * 1_024,
|
||||
diskCapacity: 512 * 1_024 * 1_024
|
||||
)
|
||||
configuration.requestCachePolicy = .returnCacheDataElseLoad
|
||||
// Bilder sind klein und viele. Sechs gleichzeitige Verbindungen sind
|
||||
// für eine Seite mit sechzig Kacheln zu wenig.
|
||||
configuration.httpMaximumConnectionsPerHost = 12
|
||||
configuration.timeoutIntervalForRequest = 30
|
||||
session = URLSession(configuration: configuration)
|
||||
|
||||
cache.countLimit = 400
|
||||
}
|
||||
|
||||
/// Das Bild, wenn es schon im Arbeitsspeicher liegt — ohne Warten.
|
||||
///
|
||||
/// `nonisolated`, damit eine Ansicht es beim Zeichnen aufrufen kann.
|
||||
public nonisolated static func cachedImage(for url: URL) -> PlatformImage? {
|
||||
shared.cache.image(for: url)
|
||||
}
|
||||
|
||||
public func image(for url: URL) async -> PlatformImage? {
|
||||
if let cached = cache.image(for: url) { return cached }
|
||||
if let running = inFlight[url] { return await running.value }
|
||||
|
||||
let task = Task<PlatformImage?, Never> { [session] in
|
||||
await Self.fetch(url, using: session)
|
||||
}
|
||||
inFlight[url] = task
|
||||
|
||||
let image = await task.value
|
||||
inFlight[url] = nil
|
||||
if let image { cache.store(image, for: url) }
|
||||
return image
|
||||
}
|
||||
|
||||
/// Holt ein Bild — und gibt nicht beim ersten Nein auf.
|
||||
///
|
||||
/// Der Grund ist ein Fehlerbild, das lange rätselhaft aussah: auf einem
|
||||
/// großen Fenster blieb die **zweite** Reihe fast vollständig grau,
|
||||
/// während die erste geladen hatte. Es lag nicht am Server und nicht an
|
||||
/// fehlenden Bildern, sondern an der Gleichzeitigkeit — sechzig sichtbare
|
||||
/// Kacheln teilen sich zwölf Verbindungen, die hinteren stehen so lange
|
||||
/// in der Schlange, dass sie in den Zeitablauf laufen.
|
||||
///
|
||||
/// Oben fragt niemand ein zweites Mal: `task(id:)` läuft einmal je
|
||||
/// Adresse. Ein einziger verpasster Versuch war damit endgültig.
|
||||
///
|
||||
/// **Nur wiederholt, was sich zu wiederholen lohnt.** Ein 404 heißt, dass
|
||||
/// es das Bild nicht gibt — dreimal zu fragen macht es nicht vorhanden
|
||||
/// und kostet den Server drei Antworten. Ein Zeitablauf oder ein
|
||||
/// abgerissener Strang dagegen sagt nur, dass es *gerade* nicht ging.
|
||||
private static func fetch(
|
||||
_ url: URL,
|
||||
using session: URLSession,
|
||||
attempts: Int = 3
|
||||
) async -> PlatformImage? {
|
||||
for attempt in 1...attempts {
|
||||
if Task.isCancelled { return nil }
|
||||
|
||||
do {
|
||||
let (data, response) = try await session.data(from: url)
|
||||
guard let http = response as? HTTPURLResponse else { return nil }
|
||||
|
||||
if (200..<300).contains(http.statusCode) {
|
||||
return PlatformImage(data: data)
|
||||
}
|
||||
// Der Server hat geantwortet und nein gesagt. Bei 5xx und 429
|
||||
// lohnt ein zweiter Versuch, sonst nicht.
|
||||
guard http.statusCode >= 500 || http.statusCode == 429 else { return nil }
|
||||
} catch is CancellationError {
|
||||
return nil
|
||||
} catch {
|
||||
// Zeitablauf, abgerissene Verbindung: der Fall, für den das
|
||||
// alles hier gebaut ist.
|
||||
}
|
||||
|
||||
guard attempt < attempts else { return nil }
|
||||
// Wachsender Abstand: ist die Leitung gerade verstopft, hilft es
|
||||
// nicht, sofort noch einmal zu drängeln.
|
||||
try? await Task.sleep(for: .milliseconds(300 * attempt * attempt))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Bildspeicher, der sich von überall lesen lässt.
|
||||
///
|
||||
/// `NSCache` ist laut Apples Dokumentation von sich aus für den Zugriff aus
|
||||
/// mehreren Strängen gebaut — der Compiler weiß das nur nicht. Deshalb hier
|
||||
/// von Hand versichert statt den Speicher in einen Actor zu sperren, was ihn
|
||||
/// genau um die Eigenschaft brächte, für die er hier steht: ohne Warten
|
||||
/// lesbar zu sein.
|
||||
private final class ImageMemoryCache: @unchecked Sendable {
|
||||
private let storage = NSCache<NSURL, PlatformImage>()
|
||||
|
||||
var countLimit: Int {
|
||||
get { storage.countLimit }
|
||||
set { storage.countLimit = newValue }
|
||||
}
|
||||
|
||||
func image(for url: URL) -> PlatformImage? {
|
||||
storage.object(forKey: url as NSURL)
|
||||
}
|
||||
|
||||
func store(_ image: PlatformImage, for url: URL) {
|
||||
storage.setObject(image, forKey: url as NSURL)
|
||||
}
|
||||
}
|
||||
|
||||
extension Image {
|
||||
/// Aus einem Plattformbild, ohne dass die Ansicht wissen muss, welches.
|
||||
init(platform image: PlatformImage) {
|
||||
#if canImport(UIKit)
|
||||
self.init(uiImage: image)
|
||||
#else
|
||||
self.init(nsImage: image)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Ein Bild vom Server mit Lade- und Fehlerzustand.
|
||||
///
|
||||
/// Der Austausch, den die frühere Notiz hier angekündigt hat, ist vollzogen:
|
||||
/// statt `AsyncImage` lädt `ArtworkLoader`. Der Grund stand schon da — eine
|
||||
/// Startseite zeigt dutzende Bilder gleichzeitig, und `AsyncImage` lädt sie bei
|
||||
/// jedem Erscheinen neu. Auf dem Mac wurde daraus ein sichtbarer Fehler: der
|
||||
/// Aufmacher und die Hälfte der Plakate blieben grau.
|
||||
public struct HaloArtwork: View {
|
||||
/// Die Adressen, der Reihe nach probiert.
|
||||
///
|
||||
/// **Warum mehrere.** Eine Folge hat selten ein eigenes Querformatbild,
|
||||
/// die Staffel manchmal, die Serie fast immer. Bisher wurde die erste
|
||||
/// Adresse genommen, die sich **zusammensetzen** liess — und ob es das
|
||||
/// Bild dahinter gibt, stellt sich erst beim Abruf heraus. Gab es das
|
||||
/// Staffelbild nicht, blieb die Fläche grau, und der Weg zur Serie wurde
|
||||
/// nie versucht.
|
||||
///
|
||||
/// Die Reihenfolge gehört deshalb hierher, ins Laden, nicht ins
|
||||
/// Zusammensetzen: erst wer gefragt hat, weiss, ob es etwas gibt.
|
||||
private let urls: [URL]
|
||||
private let aspectRatio: CGFloat
|
||||
/// Ob das Bild den Rahmen einnimmt, den es bekommt, statt sich seine Höhe
|
||||
/// aus dem Seitenverhältnis auszurechnen.
|
||||
///
|
||||
/// Für den Aufmacher: der soll so hoch sein wie das Fenster und nicht so
|
||||
/// hoch, wie 16:9 es vorgibt. Das Seitenverhältnis bleibt trotzdem
|
||||
/// wichtig — danach werden die Bilder beim Server angefordert.
|
||||
private let fillsFrame: Bool
|
||||
private let fallbackSymbol: String
|
||||
private let contentMode: ContentMode
|
||||
|
||||
/// - Parameter contentMode: `.fill` füllt die Fläche und schneidet ab —
|
||||
/// richtig für Plakate und Szenenbilder, die ohnehin formatfüllend
|
||||
/// gedacht sind. `.fit` zeigt das ganze Bild. Das braucht alles, was
|
||||
/// kein festes Seitenverhältnis hat: **Sendersymbole** sind mal breit,
|
||||
/// mal schmal, und formatfüllend in einem Quadrat fehlten links und
|
||||
/// rechts genau die Teile, an denen man den Sender erkennt.
|
||||
public init(
|
||||
url: URL?,
|
||||
aspectRatio: CGFloat,
|
||||
fillsFrame: Bool = false,
|
||||
fallbackSymbol: String = "film",
|
||||
contentMode: ContentMode = .fill
|
||||
) {
|
||||
self.init(
|
||||
urls: [url].compactMap(\.self),
|
||||
aspectRatio: aspectRatio,
|
||||
fillsFrame: fillsFrame,
|
||||
fallbackSymbol: fallbackSymbol,
|
||||
contentMode: contentMode
|
||||
)
|
||||
}
|
||||
|
||||
/// Mehrere Adressen, von der besten zur schlechtesten.
|
||||
public init(
|
||||
urls: [URL],
|
||||
aspectRatio: CGFloat,
|
||||
fillsFrame: Bool = false,
|
||||
fallbackSymbol: String = "film",
|
||||
contentMode: ContentMode = .fill
|
||||
) {
|
||||
self.urls = urls
|
||||
self.aspectRatio = aspectRatio
|
||||
self.fillsFrame = fillsFrame
|
||||
self.fallbackSymbol = fallbackSymbol
|
||||
self.contentMode = contentMode
|
||||
}
|
||||
|
||||
/// Das geladene Bild — **mit den Adressen, zu denen es gehört**.
|
||||
///
|
||||
/// **Ohne sie wird es falsch,** sobald eine Ansicht ihre Identität über
|
||||
/// einen Inhaltswechsel hinweg behält: der Zustand überlebt den Wechsel,
|
||||
/// und das alte Bild bliebe stehen, bis das neue geladen ist. Solange
|
||||
/// jede Kachel ihre eigene Identität hatte, fiel das nicht auf — der
|
||||
/// Aufmacher hat seit dem Umbau der Überblendung keine mehr.
|
||||
@State private var loaded: (urls: [URL], image: PlatformImage)?
|
||||
@State private var hasFailed = false
|
||||
|
||||
public var body: some View {
|
||||
placeholder
|
||||
.overlay {
|
||||
content
|
||||
}
|
||||
.clipped()
|
||||
.task(id: urls) { await load() }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var placeholder: some View {
|
||||
if fillsFrame {
|
||||
Rectangle().fill(Color.haloPlaceholder)
|
||||
} else {
|
||||
Rectangle().fill(Color.haloPlaceholder).aspectRatio(aspectRatio, contentMode: .fit)
|
||||
}
|
||||
}
|
||||
|
||||
/// Das Bild, das jetzt gezeigt werden kann.
|
||||
///
|
||||
/// Der Blick in den Speicher steht **hier**, nicht in `load()`: eine
|
||||
/// Kachel, die zum zweiten Mal erscheint, zeigt ihr Bild damit schon im
|
||||
/// ersten Bild und blitzt nicht kurz grau auf. Über ein `await` ginge das
|
||||
/// nicht — das kostet immer einen Durchlauf.
|
||||
private var available: PlatformImage? {
|
||||
if let loaded, loaded.urls == urls { return loaded.image }
|
||||
// Die erste, die schon im Speicher liegt — in der Reihenfolge der
|
||||
// Vorlieben, nicht in der des Zufalls.
|
||||
for url in urls {
|
||||
if let bild = ArtworkLoader.cachedImage(for: url) { return bild }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
if let loaded = available {
|
||||
switch contentMode {
|
||||
case .fill: Image(platform: loaded).resizable().scaledToFill()
|
||||
case .fit: Image(platform: loaded).resizable().scaledToFit()
|
||||
}
|
||||
} else if hasFailed {
|
||||
// Kein Fehlertext: bei einer Reihe aus zwanzig Kacheln wäre das
|
||||
// mehr Lärm als Hilfe. Das Symbol sagt genug.
|
||||
Image(systemName: fallbackSymbol)
|
||||
.font(.title2)
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
} else {
|
||||
ShimmerPlaceholder()
|
||||
}
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
guard !urls.isEmpty else {
|
||||
hasFailed = false
|
||||
loaded = nil
|
||||
return
|
||||
}
|
||||
|
||||
// Was schon da ist, zeigt `available` bereits — hier bleibt nur das
|
||||
// Nachladen.
|
||||
if available != nil {
|
||||
hasFailed = false
|
||||
return
|
||||
}
|
||||
|
||||
loaded = nil
|
||||
hasFailed = false
|
||||
|
||||
// **Der Reihe nach, bis eine antwortet.** Genau das konnte die
|
||||
// frühere Fassung nicht: sie hatte nur eine Adresse und musste sich
|
||||
// vorher entscheiden, ohne zu wissen, ob es das Bild gibt.
|
||||
for url in urls {
|
||||
let bild = await ArtworkLoader.shared.image(for: url)
|
||||
guard !Task.isCancelled else { return }
|
||||
if let bild {
|
||||
withAnimation(.easeOut(duration: 0.2)) { loaded = (urls, bild) }
|
||||
return
|
||||
}
|
||||
}
|
||||
hasFailed = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Das Aufmacher-Bild oben auf der Startseite.
|
||||
///
|
||||
/// Die Aktionsknöpfe kommen von außen, weil ein Film „Abspielen" und
|
||||
/// „Herunterladen" braucht, eine angefangene Folge dagegen „Weiterschauen".
|
||||
/// Der Baustein legt nur Bild, Verlauf und Textsatz fest.
|
||||
public struct HeroBanner<Actions: View>: View {
|
||||
private let title: String
|
||||
private let metadata: String?
|
||||
/// Eine Zeile, in der einzelne Teile anklickbar sind.
|
||||
///
|
||||
/// Steht sie, tritt sie an die Stelle von `metadata`. Gebaut wird sie beim
|
||||
/// Aufrufer, weil nur der weiß, wohin ein Teil führt — die Gestaltung
|
||||
/// kennt keine Ziele. `AnyView` statt eines weiteren Platzhaltertyps: der
|
||||
/// Aufmacher hat schon einen für die Knöpfe, und ein zweiter zöge sich
|
||||
/// durch jeden Aufruf.
|
||||
private let metadataContent: AnyView?
|
||||
private let summary: String?
|
||||
private let artworkURLs: [URL]
|
||||
/// Das Bild, das gerade verschwindet — nur für den Aufmacher mit Wechsel.
|
||||
///
|
||||
/// **Warum die Überblendung hier drin steckt und nicht darüber.** Das
|
||||
/// Karussell legte vorher zwei ganze Aufmacher übereinander und blendete
|
||||
/// den oberen ein. Jeder bringt seinen eigenen Verlauf nach unten mit —
|
||||
/// und zwei halbdurchsichtige Verläufe ergeben zusammen **keinen** ganzen.
|
||||
/// Gemessen an derselben Stelle: im Ruhezustand 68, in der Mitte des
|
||||
/// Wechsels 137. Sichtbar als ein Aufhellen genau dort, wo der Titel
|
||||
/// steht.
|
||||
///
|
||||
/// Jetzt wechselt nur das **Bild**; Verlauf und Text liegen einmal
|
||||
/// darüber und ändern sich nicht. Damit kann der Verlauf gar nicht mehr
|
||||
/// schwächer werden.
|
||||
private let outgoingArtworkURLs: [URL]
|
||||
/// Wie weit das neue Bild schon deckt: 0 beim Beginn, 1 am Ende.
|
||||
private let crossfade: CGFloat
|
||||
/// Wie weit das neue Bild dabei hereinrutscht.
|
||||
private let incomingOffset: CGFloat
|
||||
/// Titel, Zeile und Schriftzug des weichenden Eintrags.
|
||||
///
|
||||
/// **Damit der Schriftzug mitwandert.** Ohne ihn stünde während des
|
||||
/// Wechsels gar kein Text da: der neue blendet erst ein, der alte wäre
|
||||
/// schon weg. Er liegt **über** dem Verlauf, nicht darin — deshalb kann
|
||||
/// er die Abdunklung nicht schwächen, anders als ein zweiter ganzer
|
||||
/// Aufmacher.
|
||||
private let outgoing: (title: String, metadata: String?, logoURL: URL?)?
|
||||
/// Der freigestellte Schriftzug des Films. Steht er zur Verfügung, tritt
|
||||
/// er an die Stelle des gesetzten Titels — „Harry Potter" in seiner
|
||||
/// eigenen Schrift ist wiedererkennbar, eine Systemschrift nicht.
|
||||
private let logoURL: URL?
|
||||
private let aspectRatio: CGFloat
|
||||
/// Ob der Aufmacher den Platz einnimmt, den er bekommt.
|
||||
private let fillsAvailableSpace: Bool
|
||||
private let actions: Actions
|
||||
|
||||
/// Der geladene Schriftzug — **mit der Adresse, zu der er gehört**.
|
||||
///
|
||||
/// **Ohne die Adresse wird er falsch.** Seit der Aufmacher seine
|
||||
/// Identität über den Titelwechsel hinweg behält, überlebt auch dieser
|
||||
/// Zustand ihn: der Schriftzug des vorigen Films stand dann über dem Bild
|
||||
/// des neuen, bis der neue geladen war. „Werner Beinhart" über John Wick.
|
||||
///
|
||||
/// Ein Zustand, der eine andere Angabe zwischenspeichert, muss sagen,
|
||||
/// wozu er gehört — sonst gilt er für alles.
|
||||
@State private var loadedLogo: (url: URL, image: PlatformImage)?
|
||||
|
||||
public init(
|
||||
title: String,
|
||||
metadata: String? = nil,
|
||||
metadataContent: AnyView? = nil,
|
||||
summary: String? = nil,
|
||||
artworkURLs: [URL],
|
||||
outgoingArtworkURLs: [URL] = [],
|
||||
outgoingTitle: String? = nil,
|
||||
outgoingMetadata: String? = nil,
|
||||
outgoingLogoURL: URL? = nil,
|
||||
crossfade: CGFloat = 1,
|
||||
incomingOffset: CGFloat = 0,
|
||||
logoURL: URL? = nil,
|
||||
// 16:9, weil Plex genau das als `art` liefert. Vorher stand hier
|
||||
// 3:4 — mit `scaledToFill` wurde das Bild dadurch links und rechts
|
||||
// kräftig beschnitten, und vom Aufmacher war nur ein Ausschnitt zu
|
||||
// sehen.
|
||||
aspectRatio: CGFloat = HaloAspectRatio.hero,
|
||||
fillsAvailableSpace: Bool = false,
|
||||
@ViewBuilder actions: () -> Actions
|
||||
) {
|
||||
self.title = title
|
||||
self.metadata = metadata
|
||||
self.metadataContent = metadataContent
|
||||
self.summary = summary
|
||||
self.artworkURLs = artworkURLs
|
||||
self.outgoingArtworkURLs = outgoingArtworkURLs
|
||||
self.outgoing = outgoingTitle.map { ($0, outgoingMetadata, outgoingLogoURL) }
|
||||
self.crossfade = crossfade
|
||||
self.incomingOffset = incomingOffset
|
||||
self.logoURL = logoURL
|
||||
self.aspectRatio = aspectRatio
|
||||
self.fillsAvailableSpace = fillsAvailableSpace
|
||||
self.actions = actions()
|
||||
}
|
||||
|
||||
/// Wie stark Schrift und Schriftzug mitwachsen.
|
||||
///
|
||||
/// Der Aufmacher ist so breit wie das Fenster. Bei fester Schriftgröße
|
||||
/// wirkt derselbe Titel auf einem iPad groß und in einem breiten
|
||||
/// Mac-Fenster verloren — das Bild wächst, die Schrift nicht. Bezugsgröße
|
||||
/// ist die Breite eines iPads im Hochformat; alles darüber wächst mit,
|
||||
/// aber gedeckelt, damit ein Fenster über die ganze Wand nicht in
|
||||
/// Plakatschrift endet.
|
||||
private func scale(for width: CGFloat) -> CGFloat {
|
||||
min(max(width / 834, 1), 1.7)
|
||||
}
|
||||
|
||||
/// Das Bild — und, solange gewechselt wird, das vorige darunter.
|
||||
///
|
||||
/// Ohne Wechsel ist es genau ein `HaloArtwork` wie zuvor: die zweite
|
||||
/// Ebene entsteht nur, wenn jemand eine vorige Adresse mitgibt.
|
||||
@ViewBuilder
|
||||
private var bild: some View {
|
||||
if outgoingArtworkURLs.isEmpty {
|
||||
HaloArtwork(urls: artworkURLs, aspectRatio: aspectRatio, fillsFrame: fillsAvailableSpace)
|
||||
} else {
|
||||
ZStack {
|
||||
HaloArtwork(urls: outgoingArtworkURLs, aspectRatio: aspectRatio, fillsFrame: fillsAvailableSpace)
|
||||
HaloArtwork(urls: artworkURLs, aspectRatio: aspectRatio, fillsFrame: fillsAvailableSpace)
|
||||
.opacity(crossfade)
|
||||
.offset(x: incomingOffset)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Schriftzug, wenn vorhanden — sonst gesetzter Titel.
|
||||
///
|
||||
/// Geladen wird über `ArtworkLoader`, **nicht** über `AsyncImage`. Der
|
||||
/// Unterschied ist beim Überblenden des Aufmachers zu sehen: `AsyncImage`
|
||||
/// bringt seine eigene Einblendung mit und fängt erst an zu laden, wenn es
|
||||
/// erscheint. Der Schriftzug blendete sich dann getrennt und später ein,
|
||||
/// während Titelzeile, Beschreibung und Knöpfe längst mitgewandert waren —
|
||||
/// zwei Bewegungen, wo eine sein soll.
|
||||
///
|
||||
/// Jetzt liegt das Bild beim Erscheinen schon im Zwischenspeicher (das
|
||||
/// Karussell holt es vor) und wird ohne eigene Bewegung gezeigt: es gehört
|
||||
/// zum Aufmacher und macht mit, was der Aufmacher macht.
|
||||
///
|
||||
/// Ohne Bild-Transcoder, weil der Schriftzug ein freigestelltes PNG ist und
|
||||
/// der Transcoder JPEG liefert — aus dem Logo würde sonst ein schwarzer
|
||||
/// Kasten.
|
||||
/// Der Schriftzug, wenn er **jetzt** gezeigt werden kann.
|
||||
///
|
||||
/// Das Karussell holt ihn vor; liegt er bereit, steht er schon im ersten
|
||||
/// Bild des Übergangs da. Nur wenn nicht, wird nachgeladen — und dann
|
||||
/// steht bis dahin der gesetzte Titel.
|
||||
private var availableLogo: PlatformImage? {
|
||||
guard let logoURL else { return nil }
|
||||
if let loadedLogo, loadedLogo.url == logoURL { return loadedLogo.image }
|
||||
return ArtworkLoader.cachedImage(for: logoURL)
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
bild
|
||||
.overlay(alignment: .bottom) {
|
||||
// Der Verlauf endet in der Hintergrundfarbe der Seite, nicht in
|
||||
// Schwarz: sonst entsteht genau dort, wo der Aufmacher in die
|
||||
// erste Reihe übergeht, eine sichtbare Kante.
|
||||
LinearGradient(
|
||||
stops: [
|
||||
.init(color: .clear, location: 0),
|
||||
.init(color: Color.haloCanvas.opacity(0.75), location: 0.45),
|
||||
.init(color: Color.haloCanvas, location: 1),
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.frame(maxHeight: .infinity, alignment: .bottom)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.overlay(alignment: .top) {
|
||||
// Oben liegen Uhrzeit, Batterie und die Navigationsinsel — auf
|
||||
// dem Bild, seit der Aufmacher bis an den Bildschirmrand
|
||||
// reicht. Über einem dunklen Aufmacher geht das gut; über
|
||||
// einem hellen verschwinden sie.
|
||||
//
|
||||
// Deshalb ein Verlauf, aber ein knapper: er reicht nur so
|
||||
// weit, wie die Insel baut, und läuft schnell aus. Ein
|
||||
// großzügigerer deckte das Bild zu, und genau das Bild ist der
|
||||
// Grund, warum es randlos sein soll.
|
||||
LinearGradient(
|
||||
colors: [.black.opacity(0.45), .clear],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.frame(height: 150)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.overlay(alignment: .bottomLeading) {
|
||||
GeometryReader { geometry in
|
||||
let scale = scale(for: geometry.size.width)
|
||||
|
||||
ZStack(alignment: .bottomLeading) {
|
||||
// **Der weichende Text bleibt stehen, bis der neue
|
||||
// ihn zudeckt.** Sonst stünde mitten im Wechsel gar
|
||||
// keiner da: der neue blendet ein, der alte wäre
|
||||
// schon weg. Er liegt über dem Verlauf — dort kostet
|
||||
// er die Abdunklung nichts.
|
||||
if let outgoing, crossfade < 1 {
|
||||
inhalt(
|
||||
title: outgoing.title,
|
||||
metadata: outgoing.metadata,
|
||||
logo: outgoing.logoURL.flatMap(ArtworkLoader.cachedImage(for:)),
|
||||
scale: scale
|
||||
)
|
||||
}
|
||||
|
||||
// Der neue — mit demselben Einblenden und demselben
|
||||
// Hereinrutschen wie das Bild darunter, damit beides
|
||||
// als eine Bewegung erscheint.
|
||||
inhalt(
|
||||
title: title,
|
||||
metadata: metadata,
|
||||
logo: availableLogo,
|
||||
scale: scale
|
||||
)
|
||||
.opacity(crossfade)
|
||||
.offset(x: incomingOffset)
|
||||
}
|
||||
.frame(
|
||||
width: geometry.size.width,
|
||||
height: geometry.size.height,
|
||||
alignment: .bottomLeading
|
||||
)
|
||||
}
|
||||
}
|
||||
.task(id: logoURL) {
|
||||
guard let logoURL, ArtworkLoader.cachedImage(for: logoURL) == nil else { return }
|
||||
if let bild = await ArtworkLoader.shared.image(for: logoURL) {
|
||||
loadedLogo = (logoURL, bild)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Schriftzug beziehungsweise Titel, Zeile, Beschreibung und Knöpfe.
|
||||
///
|
||||
/// Als eigene Funktion, weil es sie während eines Wechsels zweimal gibt —
|
||||
/// einmal für den weichenden und einmal für den kommenden Eintrag.
|
||||
@ViewBuilder
|
||||
private func inhalt(
|
||||
title: String,
|
||||
metadata: String?,
|
||||
logo: PlatformImage?,
|
||||
scale: CGFloat
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: HaloSpacing.snug * scale) {
|
||||
if let logo {
|
||||
Image(platform: logo)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
// Nach oben begrenzt: manche Schriftzüge sind fast
|
||||
// quadratisch, andere sehr breit. Ohne Deckel schöbe ein
|
||||
// hohes Logo die Knöpfe aus dem Bild.
|
||||
.frame(maxWidth: 260 * scale, maxHeight: 76 * scale, alignment: .leading)
|
||||
.accessibilityLabel(Text(title))
|
||||
} else {
|
||||
// Solange nichts da ist — und wenn es scheitert — bleibt es
|
||||
// beim gesetzten Titel. Ein leerer Platz wäre schlimmer als
|
||||
// eine Systemschrift.
|
||||
Text(title)
|
||||
.font(.system(size: 34 * scale, weight: .bold))
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
|
||||
if let metadataContent {
|
||||
metadataContent
|
||||
.font(.system(size: 12 * scale))
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
} else if let metadata {
|
||||
Text(metadata)
|
||||
.font(.system(size: 12 * scale))
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
}
|
||||
|
||||
if let summary {
|
||||
Text(summary)
|
||||
.font(.system(size: 12 * scale))
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
.lineLimit(2)
|
||||
// Ein Satz über die ganze Breite eines großen Fensters ist
|
||||
// unlesbar; das Auge verliert die Zeile. Achtzig Zeichen
|
||||
// sind die Grenze, ab der Satzspiegel unangenehm werden.
|
||||
.frame(maxWidth: 640 * scale, alignment: .leading)
|
||||
}
|
||||
|
||||
HStack(spacing: HaloSpacing.regular) {
|
||||
actions
|
||||
}
|
||||
.padding(.top, HaloSpacing.tight)
|
||||
}
|
||||
.padding(.horizontal, HaloSpacing.screenMargin * scale)
|
||||
.padding(.bottom, HaloSpacing.loose * scale)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Ein Titel im Querformat — die Kachelform der Startseite.
|
||||
///
|
||||
/// Anders als das Poster zeigt sie ein Szenenbild und trägt den Titel unter
|
||||
/// dem Bild, weil Szenenbilder selten Schrift enthalten.
|
||||
public struct LandscapeCard: View {
|
||||
private let title: String
|
||||
private let subtitle: String?
|
||||
private let artworkURL: URL?
|
||||
private let progress: Double?
|
||||
|
||||
public static let aspectRatio: CGFloat = 16.0 / 9.0
|
||||
|
||||
public init(title: String, subtitle: String? = nil, artworkURL: URL?, progress: Double? = nil) {
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.artworkURL = artworkURL
|
||||
self.progress = progress
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(alignment: .leading, spacing: HaloSpacing.snug) {
|
||||
HaloArtwork(url: artworkURL, aspectRatio: Self.aspectRatio, fallbackSymbol: "play.rectangle")
|
||||
.overlay(alignment: .bottom) {
|
||||
if let progress {
|
||||
WatchProgressBar(fraction: progress)
|
||||
}
|
||||
}
|
||||
.clipShape(.rect(cornerRadius: HaloRadius.card))
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.haloCardTitle)
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
.lineLimit(1)
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Eine Kachel, die es gleich geben wird.
|
||||
///
|
||||
/// **Warum es das gibt.** Eine Mediathek lädt seitenweise nach. Wer unten
|
||||
/// ankommt, sieht ein Raster, das aufhört — und kann nicht unterscheiden, ob
|
||||
/// das alles war oder ob gerade nachgeladen wird. Beides sieht gleich aus, und
|
||||
/// im Zweifel hält man eine halb geladene Liste für vollständig.
|
||||
///
|
||||
/// Diese Kacheln sagen: hier kommt noch etwas. Sie werden nur so oft gezeigt,
|
||||
/// wie der Server tatsächlich noch Titel gemeldet hat — es ist also keine
|
||||
/// Beschwichtigung, sondern eine Auskunft.
|
||||
///
|
||||
/// Ein Ladekreis täte das nicht: er sagt „es passiert etwas", aber nicht
|
||||
/// **wo** und nicht **wie viel**. Die Form der Kachel an der Stelle, an der
|
||||
/// sie erscheinen wird, sagt beides.
|
||||
public struct PlaceholderCard: View {
|
||||
/// Womit das Aufleuchten versetzt wird.
|
||||
///
|
||||
/// Alle gleichzeitig pulsieren zu lassen sieht nach einem Fehler aus —
|
||||
/// als blinkte die ganze Seite. Ein Versatz je Kachel macht daraus eine
|
||||
/// Bewegung, die von links nach rechts läuft.
|
||||
let index: Int
|
||||
|
||||
@State private var isBright = false
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
public init(index: Int = 0) {
|
||||
self.index = index
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(alignment: .leading, spacing: HaloSpacing.snug) {
|
||||
RoundedRectangle(cornerRadius: HaloRadius.card)
|
||||
.fill(Color.haloElevated)
|
||||
.aspectRatio(PosterCard.aspectRatio, contentMode: .fit)
|
||||
|
||||
// Zwei Balken an der Stelle von Titel und Jahr. Ohne sie springt
|
||||
// die Zeile, sobald der echte Titel kommt.
|
||||
RoundedRectangle(cornerRadius: 3)
|
||||
.fill(Color.haloElevated)
|
||||
.frame(height: 10)
|
||||
RoundedRectangle(cornerRadius: 3)
|
||||
.fill(Color.haloElevated)
|
||||
.frame(width: 32, height: 8)
|
||||
}
|
||||
.opacity(isBright ? 0.85 : 0.45)
|
||||
.animation(
|
||||
reduceMotion
|
||||
? nil
|
||||
: .easeInOut(duration: 0.9)
|
||||
.repeatForever(autoreverses: true)
|
||||
.delay(Double(index % 6) * 0.1),
|
||||
value: isBright
|
||||
)
|
||||
.onAppear { isBright = true }
|
||||
// Für die Sprachausgabe ist eine Kachel ohne Inhalt nichts, worüber
|
||||
// man wischen möchte.
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Ein Titel im Hochformat, wie er in Bibliotheksrastern steht.
|
||||
public struct PosterCard: View {
|
||||
private let title: String
|
||||
private let subtitle: String?
|
||||
private let artworkURL: URL?
|
||||
private let progress: Double?
|
||||
|
||||
/// Das Seitenverhältnis von Kinoplakaten. Plex liefert Poster in diesem
|
||||
/// Format; ein anderer Wert würde die Bilder beschneiden.
|
||||
public static let aspectRatio: CGFloat = 2.0 / 3.0
|
||||
|
||||
public init(title: String, subtitle: String? = nil, artworkURL: URL?, progress: Double? = nil) {
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.artworkURL = artworkURL
|
||||
self.progress = progress
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(alignment: .leading, spacing: HaloSpacing.snug) {
|
||||
HaloArtwork(url: artworkURL, aspectRatio: Self.aspectRatio)
|
||||
.clipShape(.rect(cornerRadius: HaloRadius.card))
|
||||
.overlay(alignment: .bottom) {
|
||||
if let progress {
|
||||
WatchProgressBar(fraction: progress)
|
||||
}
|
||||
}
|
||||
.clipShape(.rect(cornerRadius: HaloRadius.card))
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.haloCardTitle)
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
.lineLimit(1)
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Ohne diese Zusammenfassung liest VoiceOver Bild, Titel und Balken als
|
||||
// drei getrennte Elemente vor — bei einer Reihe aus zwanzig Kacheln
|
||||
// wird das Durchwischen dadurch dreimal so lang.
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Die Platzhalterfläche, solange ein Bild noch lädt.
|
||||
///
|
||||
/// Ein wandernder Lichtschein statt eines Spinners: er zeigt die endgültige
|
||||
/// Form des Inhalts schon an, wodurch die Startseite beim Aufbauen nicht
|
||||
/// springt.
|
||||
public struct ShimmerPlaceholder: View {
|
||||
@State private var phase: CGFloat = -1
|
||||
/// Bewegte Flächen sind für manche Menschen unangenehm; bei aktivierter
|
||||
/// Systemeinstellung bleibt der Platzhalter deshalb einfach ruhig stehen.
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
public init() {}
|
||||
|
||||
public var body: some View {
|
||||
Rectangle()
|
||||
.fill(Color.haloPlaceholder)
|
||||
.overlay {
|
||||
if !reduceMotion {
|
||||
GeometryReader { proxy in
|
||||
LinearGradient(
|
||||
colors: [.clear, Color.white.opacity(0.06), .clear],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
.frame(width: proxy.size.width * 0.6)
|
||||
.offset(x: phase * proxy.size.width * 1.6)
|
||||
}
|
||||
}
|
||||
}
|
||||
.clipped()
|
||||
.onAppear {
|
||||
guard !reduceMotion else { return }
|
||||
withAnimation(.linear(duration: 1.4).repeatForever(autoreverses: false)) {
|
||||
phase = 1
|
||||
}
|
||||
}
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
#if os(tvOS)
|
||||
import SwiftUI
|
||||
|
||||
/// Ein Plakat, das auf den Fokus antwortet.
|
||||
///
|
||||
/// **Ein Knopf, kein Bild mit Geste.** Auf tvOS ist alles Bedienbare
|
||||
/// fokussierbar, und `.buttonStyle(.card)` bringt die Systemwirkung mit:
|
||||
/// Anheben, Schlagschatten und die Parallaxe, bei der sich die Ebenen beim
|
||||
/// Wischen gegeneinander verschieben. Die von Hand nachzubauen —
|
||||
/// `scaleEffect` und ein Schatten — sieht auf den ersten Blick gleich aus und
|
||||
/// auf den zweiten falsch: die Parallaxe ist der Teil, den man wiedererkennt.
|
||||
///
|
||||
/// Liegt in HaloDesign und nicht im Fernseh-Ziel, aus demselben Grund wie
|
||||
/// `PosterCard`: das Paket kennt weder `MediaItem` noch die Wiedergabe, und
|
||||
/// das soll so bleiben. Herein kommen Zeichenketten und Adressen.
|
||||
public struct TVPosterCard: View {
|
||||
private let title: String
|
||||
private let subtitle: String?
|
||||
private let artworkURL: URL?
|
||||
private let progress: Double?
|
||||
/// Meldet nach oben, wenn diese Karte den Fokus bekommt oder verliert.
|
||||
///
|
||||
/// **Nach aussen als Rückruf, innen als `@FocusState`.** Die Umgebung
|
||||
/// könnte es nicht beantworten — `\.isFocused` meint den nächsten
|
||||
/// fokussierbaren Vorfahren, und das ist in einer Reihe aus Karten nicht
|
||||
/// verlässlich diese. Wer es wissen will, bekommt es deshalb gesagt.
|
||||
private let onFocusChange: (Bool) -> Void
|
||||
private let action: () -> Void
|
||||
|
||||
/// **Ausdrücklich und nicht über `\.isFocused`.** Die Umgebungsvariable
|
||||
/// beantwortet die Frage nach dem nächstgelegenen fokussierbaren
|
||||
/// Vorfahren; in einer Reihe aus Karten ist das nicht verlässlich diese.
|
||||
@FocusState private var isFocused: Bool
|
||||
|
||||
public init(
|
||||
title: String,
|
||||
subtitle: String? = nil,
|
||||
artworkURL: URL?,
|
||||
progress: Double? = nil,
|
||||
onFocusChange: @escaping (Bool) -> Void = { _ in },
|
||||
action: @escaping () -> Void
|
||||
) {
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.artworkURL = artworkURL
|
||||
self.progress = progress
|
||||
self.onFocusChange = onFocusChange
|
||||
self.action = action
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
TVCard(
|
||||
aspectRatio: PosterCard.aspectRatio,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
artworkURL: artworkURL,
|
||||
progress: progress,
|
||||
isFocused: $isFocused,
|
||||
onFocusChange: onFocusChange,
|
||||
action: action
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Dasselbe im Querformat — für „Weiterschauen".
|
||||
///
|
||||
/// Angefangenes erkennt man an der Szene, an der man aufgehört hat; wer
|
||||
/// dagegen aussucht, was als Nächstes kommt, sucht nach Plakaten.
|
||||
public struct TVLandscapeCard: View {
|
||||
private let title: String
|
||||
private let subtitle: String?
|
||||
private let artworkURL: URL?
|
||||
private let progress: Double?
|
||||
/// Meldet nach oben, wenn diese Karte den Fokus bekommt oder verliert.
|
||||
///
|
||||
/// **Nach aussen als Rückruf, innen als `@FocusState`.** Die Umgebung
|
||||
/// könnte es nicht beantworten — `\.isFocused` meint den nächsten
|
||||
/// fokussierbaren Vorfahren, und das ist in einer Reihe aus Karten nicht
|
||||
/// verlässlich diese. Wer es wissen will, bekommt es deshalb gesagt.
|
||||
private let onFocusChange: (Bool) -> Void
|
||||
private let action: () -> Void
|
||||
|
||||
@FocusState private var isFocused: Bool
|
||||
|
||||
public init(
|
||||
title: String,
|
||||
subtitle: String? = nil,
|
||||
artworkURL: URL?,
|
||||
progress: Double? = nil,
|
||||
onFocusChange: @escaping (Bool) -> Void = { _ in },
|
||||
action: @escaping () -> Void
|
||||
) {
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.artworkURL = artworkURL
|
||||
self.progress = progress
|
||||
self.onFocusChange = onFocusChange
|
||||
self.action = action
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
TVCard(
|
||||
aspectRatio: HaloAspectRatio.landscape,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
artworkURL: artworkURL,
|
||||
progress: progress,
|
||||
isFocused: $isFocused,
|
||||
onFocusChange: onFocusChange,
|
||||
action: action
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Was beide gemeinsam haben.
|
||||
private struct TVCard: View {
|
||||
let aspectRatio: CGFloat
|
||||
let title: String
|
||||
let subtitle: String?
|
||||
let artworkURL: URL?
|
||||
let progress: Double?
|
||||
@FocusState.Binding var isFocused: Bool
|
||||
let onFocusChange: (Bool) -> Void
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: HaloSpacing.snug) {
|
||||
Button(action: action) {
|
||||
HaloArtwork(url: artworkURL, aspectRatio: aspectRatio)
|
||||
.overlay(alignment: .bottom) {
|
||||
if let progress { WatchProgressBar(fraction: progress) }
|
||||
}
|
||||
.clipShape(.rect(cornerRadius: HaloRadius.card))
|
||||
}
|
||||
.buttonStyle(.card)
|
||||
.focused($isFocused)
|
||||
|
||||
// **Die Höhe wird immer belegt, der Text nicht immer gezeigt.**
|
||||
//
|
||||
// Sonst wächst die Karte beim Fokussieren nach unten und schiebt
|
||||
// die ganze Reihe — und man sieht nicht den Fokus, sondern ein
|
||||
// Zucken. Und dauerhaft beschriftet wären zwanzig Plakate aus drei
|
||||
// Metern eine Textwand.
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.haloCardTitle)
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
.lineLimit(1)
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.opacity(isFocused ? 1 : 0)
|
||||
.animation(.easeOut(duration: 0.15), value: isFocused)
|
||||
}
|
||||
.onChange(of: isFocused) { _, neu in onFocusChange(neu) }
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,28 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Der dünne Balken unter angefangenen Titeln.
|
||||
public struct WatchProgressBar: View {
|
||||
/// 0…1. Werte außerhalb werden abgeschnitten, damit ein fehlerhafter
|
||||
/// Serverwert den Balken nicht über die Kachel hinausschieben kann.
|
||||
private let fraction: Double
|
||||
@Environment(\.haloAccent) private var accent
|
||||
|
||||
public init(fraction: Double) {
|
||||
self.fraction = fraction.clamped()
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
GeometryReader { proxy in
|
||||
ZStack(alignment: .leading) {
|
||||
Rectangle().fill(Color.white.opacity(0.15))
|
||||
Rectangle()
|
||||
.fill(accent)
|
||||
.frame(width: proxy.size.width * fraction)
|
||||
}
|
||||
}
|
||||
.frame(height: 3)
|
||||
.accessibilityElement()
|
||||
.accessibilityLabel(Text("progress.watched", bundle: .module))
|
||||
.accessibilityValue(Text(fraction.formatted(.percent.precision(.fractionLength(0)))))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user