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 FlaroArtwork: 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.flaroPlaceholder) } else { Rectangle().fill(Color.flaroPlaceholder).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.flaroTextMuted) } 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 } }