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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2fcf6821c8
commit
4b9a1e7b67
@@ -0,0 +1,166 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user