Drei Sachen am Karussell. Die Vorschlaege wechselten bei jedem Start, obwohl es dafuer laengst ein Zeitfenster von sechs Stunden gab. Der Grund: das Los hing an der Position in der Liste, nicht am Titel. Kam einer dazu, verschwand einer, oder aenderte sich ein Fortschritt, bekamen alle dahinter ein anderes Los — und „Weiterschauen\" waechst und schrumpft staendig. Jetzt haengt das Los an der Kennung; wer dazukommt, stoert niemanden. Als eigener Typ mit elf Tests, weil „es wechselt zu oft\" kein Befund ist, den man debuggen kann. Folgen zeigten kein Bild. Die Kette Folge → Serie gab es zwar, aber sie hoerte beim ersten Glied auf, das sich **zusammensetzen** liess — und ob es das Bild dahinter gibt, stellt sich erst beim Abruf heraus. Die Reihenfolge wandert deshalb ins Laden: Folge, Staffel, Serie, der Reihe nach, bis eine Adresse antwortet. Und auf dem Mac gibt es jetzt Pfeile links und rechts, sobald der Zeiger ueber dem Aufmacher liegt, sowie Punkte, die unter dem Zeiger wachsen und auf die man springen kann. Nur dort: auf dem iPad und dem iPhone wischt man ohnehin, und zwei Knoepfe staenden nur im Bild. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
157 lines
5.6 KiB
Swift
157 lines
5.6 KiB
Swift
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 FlixyArtwork: 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
|
|
}
|
|
|
|
@State private var loaded: 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.flixyPlaceholder)
|
|
} else {
|
|
Rectangle().fill(Color.flixyPlaceholder).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 { return loaded }
|
|
// 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.flixyTextMuted)
|
|
} 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 = bild }
|
|
return
|
|
}
|
|
}
|
|
hasFailed = true
|
|
}
|
|
}
|