Files
Flaro/Packages/HaloKit/Sources/HaloDesign/Components/ArtworkLoader.swift
T
ScarriffleandClaude Opus 5 4b9a1e7b67 Flixy heißt ab hier Halo
Reine Umbenennung, kein Verhalten geändert — bewusst als eigener Commit,
damit „umbenannt" und „umgebaut" im Nachhinein auseinanderzuhalten sind.

712 mal `Flixy`, 778 mal `flixy`, 7 mal `FLIXY` in 169 Dateien, dazu 25
Pfade. Ziele: `Halo`, `HaloTV`, `HaloTopShelf`, `HaloUITests`, `HaloKit` mit
`HaloDesign`, `HaloDownloads`, `HaloPlayback` und `HaloProbe`. `MediaKit`,
`JellyfinKit` und `DownloadWidget` tragen den Namen nicht und bleiben.

`docs/superpowers/specs/` ist ausgenommen. Datierte Entwurfsdokumente sind
ein Protokoll getroffener Entscheidungen; sie nachträglich umzuschreiben
hiesse, die Vergangenheit zu glätten.

**Die Bundle-ID wandert mit** — ausdrücklich so gewollt, mit bekanntem Preis:
`com.scarriffleservices.halo` ist für iOS eine *andere* App. Die installierte
Fassung bleibt daneben stehen, mit Downloads und Anmeldung in ihrem Container;
Halo startet leer. Einmal neu anmelden, heruntergeladene Folgen einmal neu
laden. Dasselbe gilt für die App-Gruppe, die sich TV-App und Top Shelf teilen.

**Eine Folge davon, die beim Entscheiden noch nicht auf dem Tisch lag:** der
Aufräumschritt aus dem vorigen Commit kommt an die alten Plex-Token nicht mehr
heran. Der Schlüsselbund grenzt nach Team und Bundle-Kennung ab — was unter
`…flixy` liegt, ist für `…halo` unerreichbar. Erledigt wird es trotzdem, nur
anders: **die alte App löschen.** iOS räumt deren Schlüsselbund-Einträge beim
Deinstallieren mit ab. Der Code bleibt stehen, weil er für jeden Stand vor
dieser Umbenennung richtig ist und im Jahr einen Startvergleich kostet.

Die Projektdatei ist aus `project.yml` neu erzeugt, der alte Ordner gelöscht
statt umbenannt — sie steht ohnehin nicht im Repo.

Das Verzeichnis des Repos selbst heißt weiter `Flixy`; das umzubenennen ist
ein Handgriff ausserhalb des Projekts.

368 Tests grün. iOS und tvOS bauen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 11:12:32 +02:00

167 lines
6.7 KiB
Swift

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
/// Der Speicher liegt **außerhalb** des Actors, damit er sich ohne
/// `await` lesen lässt.
///
/// Das klingt nach einer Kleinigkeit und ist der ganze Punkt: ein `await`
/// kostet immer mindestens einen Durchlauf, und genau der war beim
/// Überblenden des Aufmachers zu sehen. Das neue Bild erschien im ersten
/// Bild noch ohne Schriftzug, im zweiten mit — der Schriftzug sprang also
/// getrennt herein, obwohl er längst da war. Wer das Bild schon hat, soll
/// es im selben Augenblick zeigen können.
private let cache = ImageMemoryCache()
/// 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<PlatformImage?, Never>] = [:]
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
}
/// Das Bild, wenn es schon im Arbeitsspeicher liegt — ohne Warten.
///
/// `nonisolated`, damit eine Ansicht es beim Zeichnen aufrufen kann.
public nonisolated static func cachedImage(for url: URL) -> PlatformImage? {
shared.cache.image(for: url)
}
public func image(for url: URL) async -> PlatformImage? {
if let cached = cache.image(for: url) { return cached }
if let running = inFlight[url] { return await running.value }
let task = Task<PlatformImage?, Never> { [session] in
await Self.fetch(url, using: session)
}
inFlight[url] = task
let image = await task.value
inFlight[url] = nil
if let image { cache.store(image, for: url) }
return image
}
/// Holt ein Bild — und gibt nicht beim ersten Nein auf.
///
/// Der Grund ist ein Fehlerbild, das lange rätselhaft aussah: auf einem
/// großen Fenster blieb die **zweite** Reihe fast vollständig grau,
/// während die erste geladen hatte. Es lag nicht am Server und nicht an
/// fehlenden Bildern, sondern an der Gleichzeitigkeit — sechzig sichtbare
/// Kacheln teilen sich zwölf Verbindungen, die hinteren stehen so lange
/// in der Schlange, dass sie in den Zeitablauf laufen.
///
/// Oben fragt niemand ein zweites Mal: `task(id:)` läuft einmal je
/// Adresse. Ein einziger verpasster Versuch war damit endgültig.
///
/// **Nur wiederholt, was sich zu wiederholen lohnt.** Ein 404 heißt, dass
/// es das Bild nicht gibt — dreimal zu fragen macht es nicht vorhanden
/// und kostet den Server drei Antworten. Ein Zeitablauf oder ein
/// abgerissener Strang dagegen sagt nur, dass es *gerade* nicht ging.
private static func fetch(
_ url: URL,
using session: URLSession,
attempts: Int = 3
) async -> PlatformImage? {
for attempt in 1...attempts {
if Task.isCancelled { return nil }
do {
let (data, response) = try await session.data(from: url)
guard let http = response as? HTTPURLResponse else { return nil }
if (200..<300).contains(http.statusCode) {
return PlatformImage(data: data)
}
// Der Server hat geantwortet und nein gesagt. Bei 5xx und 429
// lohnt ein zweiter Versuch, sonst nicht.
guard http.statusCode >= 500 || http.statusCode == 429 else { return nil }
} catch is CancellationError {
return nil
} catch {
// Zeitablauf, abgerissene Verbindung: der Fall, für den das
// alles hier gebaut ist.
}
guard attempt < attempts else { return nil }
// Wachsender Abstand: ist die Leitung gerade verstopft, hilft es
// nicht, sofort noch einmal zu drängeln.
try? await Task.sleep(for: .milliseconds(300 * attempt * attempt))
}
return nil
}
}
/// Ein Bildspeicher, der sich von überall lesen lässt.
///
/// `NSCache` ist laut Apples Dokumentation von sich aus für den Zugriff aus
/// mehreren Strängen gebaut — der Compiler weiß das nur nicht. Deshalb hier
/// von Hand versichert statt den Speicher in einen Actor zu sperren, was ihn
/// genau um die Eigenschaft brächte, für die er hier steht: ohne Warten
/// lesbar zu sein.
private final class ImageMemoryCache: @unchecked Sendable {
private let storage = NSCache<NSURL, PlatformImage>()
var countLimit: Int {
get { storage.countLimit }
set { storage.countLimit = newValue }
}
func image(for url: URL) -> PlatformImage? {
storage.object(forKey: url as NSURL)
}
func store(_ image: PlatformImage, for url: URL) {
storage.setObject(image, 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
}
}