Beide App-Ziele lesen denselben Ordner ein. XcodeGen nimmt nur die eigene Info.plist aus den Quellen heraus — die des jeweils anderen Ziels bleibt drin und landet unverarbeitet als Ressource im Bündel, mitsamt dem wörtlichen $(PRODUCT_BUNDLE_IDENTIFIER). Wer die Datei danach liest, liest die falsche: codesign stempelte Identifier=$(PRODUCT_BUNDLE_IDENTIFIER) auf die App, und das iPad lehnte sie als „not a valid bundle" ab. „Clean Build Folder" konnte daran nichts ändern — der Rat war falsch, der Fehler kam aus project.yml. Dazu: Bilder laden jetzt über ArtworkLoader statt AsyncImage. AsyncImage lädt bei jedem Erscheinen neu und kennt keinen Zwischenspeicher; bei sechzig Kacheln plus einem großen Aufmacher stauen sich die Anfragen hinter sechs Verbindungen. Auf dem iPad fiel das nicht auf, auf dem Mac blieb der Aufmacher grau. Und die beiden #if os(macOS) um die Breite der Seitenleiste liefen unter Catalyst ins Leere — dort ist os(iOS) wahr. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
93 lines
3.1 KiB
Swift
93 lines
3.1 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 {
|
|
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
|
|
}
|
|
}
|
|
}
|