diff --git a/Flixy/Views/Navigation/RootView.swift b/Flixy/Views/Navigation/RootView.swift index b6bac9a..7c8d743 100644 --- a/Flixy/Views/Navigation/RootView.swift +++ b/Flixy/Views/Navigation/RootView.swift @@ -248,7 +248,7 @@ struct RootView: View { .padding(.horizontal, FlixySpacing.snug) // Der Griff, an dem die Spalte hängt: dieser Inhalt sitzt in // der Seitenleiste, also nimmt sie seine Breitenangabe an. - #if os(macOS) + #if os(macOS) || targetEnvironment(macCatalyst) .navigationSplitViewColumnWidth( min: Self.sidebarWidth.min, ideal: Self.sidebarWidth.ideal, @@ -286,7 +286,7 @@ struct RootView: View { /// abgeschnitten wird; **ohne Obergrenze** ließ sie sich über das halbe /// Fenster ziehen. Beide Zahlen sind nötig, und die obere entspricht dem, /// was eine Seitenleiste auf dem iPad breit ist. - #if os(macOS) + #if os(macOS) || targetEnvironment(macCatalyst) private static let sidebarWidth: (min: CGFloat, ideal: CGFloat, max: CGFloat) = (min: 190, ideal: 230, max: 320) #endif diff --git a/Packages/FlixyKit/Sources/FlixyDesign/Components/ArtworkLoader.swift b/Packages/FlixyKit/Sources/FlixyDesign/Components/ArtworkLoader.swift new file mode 100644 index 0000000..e360f90 --- /dev/null +++ b/Packages/FlixyKit/Sources/FlixyDesign/Components/ArtworkLoader.swift @@ -0,0 +1,90 @@ +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 + private let cache = NSCache() + /// 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] = [:] + + 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 + } + + public func image(for url: URL) async -> PlatformImage? { + if let cached = cache.object(forKey: url as NSURL) { return cached } + if let running = inFlight[url] { return await running.value } + + let task = Task { [session] in + guard let (data, response) = try? await session.data(from: url), + let http = response as? HTTPURLResponse, + (200..<300).contains(http.statusCode), + let image = PlatformImage(data: data) + else { return nil } + return image + } + inFlight[url] = task + + let image = await task.value + inFlight[url] = nil + if let image { cache.setObject(image, forKey: url as NSURL) } + return image + } + + /// Das Bild, wenn es schon im Arbeitsspeicher liegt. + /// + /// Damit eine Kachel, die zum zweiten Mal erscheint, nicht erst kurz grau + /// aufblitzt — das ist beim Scrollen der sichtbarste Unterschied. + public func cached(_ url: URL) -> PlatformImage? { + cache.object(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 + } +} diff --git a/Packages/FlixyKit/Sources/FlixyDesign/Components/FlixyArtwork.swift b/Packages/FlixyKit/Sources/FlixyDesign/Components/FlixyArtwork.swift index f1957c8..9b9f812 100644 --- a/Packages/FlixyKit/Sources/FlixyDesign/Components/FlixyArtwork.swift +++ b/Packages/FlixyKit/Sources/FlixyDesign/Components/FlixyArtwork.swift @@ -2,10 +2,11 @@ import SwiftUI /// Ein Bild vom Server mit Lade- und Fehlerzustand. /// -/// Kapselt `AsyncImage`, damit der Austausch gegen einen eigenen, zwischen- -/// speichernden Lader später genau eine Datei betrifft. Die Startseite zeigt -/// dutzende Bilder gleichzeitig; ohne Zwischenspeicher lädt `AsyncImage` sie -/// bei jedem Erscheinen neu. +/// 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 @@ -30,31 +31,62 @@ public struct FlixyArtwork: View { 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 { - AsyncImage(url: url, transaction: Transaction(animation: .easeOut(duration: 0.2))) { phase in - switch phase { - case .success(let image): - switch contentMode { - case .fill: image.resizable().scaledToFill() - case .fit: image.resizable().scaledToFit() - } - case .failure: - // 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) - case .empty: - ShimmerPlaceholder() - @unknown default: - ShimmerPlaceholder() - } - } + 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 + } } } diff --git a/project.yml b/project.yml index 7047011..17d864d 100644 --- a/project.yml +++ b/project.yml @@ -34,9 +34,18 @@ targets: # Ausschluss läuft XcodeGen hinein und trägt icon.json und play.svg # einzeln ein — dann fehlt das Icon, ohne dass irgendwo ein Fehler # auftaucht. + # + # Die Info.plist des jeweils anderen Ziels muss ebenfalls hinaus. + # XcodeGen nimmt nur die eigene automatisch heraus; die fremde landet + # als gewöhnliche Ressource unverarbeitet im Bündel — mitsamt dem + # wörtlichen $(PRODUCT_BUNDLE_IDENTIFIER). Beim Installieren liest das + # Gerät dann diese statt der richtigen und lehnt die App als „not a + # valid bundle" ab. - path: Flixy excludes: - "AppIcon.icon" + - "Info-macOS.plist" + - "Flixy-macCatalyst.entitlements" - path: Flixy/AppIcon.icon type: file dependencies: @@ -164,9 +173,12 @@ targets: type: application platform: iOS sources: + # Siehe Flixy: umgekehrt genauso, sonst trägt das Mac-Bündel die + # Info.plist des iPhones in seinen Ressourcen. - path: Flixy excludes: - "AppIcon.icon" + - "Info.plist" - path: Flixy/AppIcon.icon type: file dependencies: