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 { private let url: URL? private let aspectRatio: CGFloat 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, fallbackSymbol: String = "film", contentMode: ContentMode = .fill ) { self.url = url self.aspectRatio = aspectRatio self.fallbackSymbol = fallbackSymbol self.contentMode = contentMode } @State private var loaded: PlatformImage? @State private var hasFailed = false public var body: some View { Rectangle() .fill(Color.flixyPlaceholder) .aspectRatio(aspectRatio, contentMode: .fit) .overlay { content } .clipped() .task(id: url) { await load() } } @ViewBuilder private var content: some View { if let loaded { 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 let url else { hasFailed = false loaded = nil return } // Liegt es schon im Arbeitsspeicher, gibt es kein Aufblitzen: ohne // diesen Schritt wäre jede zurückscrollende Kachel kurz grau. if let cached = await ArtworkLoader.shared.cached(url) { loaded = cached hasFailed = false return } loaded = nil hasFailed = false let image = await ArtworkLoader.shared.image(for: url) guard !Task.isCancelled else { return } withAnimation(.easeOut(duration: 0.2)) { loaded = image hasFailed = image == nil } } }