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>
108 lines
4.1 KiB
Swift
108 lines
4.1 KiB
Swift
import Foundation
|
|
import MediaKit
|
|
|
|
/// Der Ladezustand eines Bildschirms.
|
|
///
|
|
/// Ein gemeinsamer Typ statt dreier Einzelfelder (`isLoading`, `items`,
|
|
/// `errorMessage`): die lassen sich widersprüchlich besetzen — lädt noch und
|
|
/// hat gleichzeitig einen Fehler — und jede View müsste selbst entscheiden,
|
|
/// was dann gilt.
|
|
enum LoadState<Value: Sendable>: Sendable {
|
|
case loading
|
|
case loaded(Value)
|
|
case failed(message: String)
|
|
|
|
var value: Value? {
|
|
if case .loaded(let value) = self { return value }
|
|
return nil
|
|
}
|
|
}
|
|
|
|
extension LoadState {
|
|
/// Führt eine Ladeoperation aus und übersetzt Fehler in Text.
|
|
///
|
|
/// `keepingPrevious` ist für „Nach unten ziehen zum Aktualisieren": dabei
|
|
/// soll die Liste stehen bleiben, statt für die Dauer der Anfrage durch
|
|
/// einen Ladekreis ersetzt zu werden.
|
|
///
|
|
/// Auf dem Hauptakteur, weil sie ausschließlich aus Views heraus gerufen
|
|
/// wird. Ohne diese Festlegung müsste der übergebene Ausdruck `Sendable`
|
|
/// sein — was er nie ist, weil er den Server aus der Umgebung mitnimmt.
|
|
@MainActor
|
|
static func load(
|
|
keepingPrevious previous: Self? = nil,
|
|
_ operation: () async throws -> Value
|
|
) async -> Self {
|
|
do {
|
|
return .loaded(try await operation())
|
|
} catch {
|
|
// Der Bildschirm wurde verlassen, während geladen wurde. Ein
|
|
// Fehlertext wäre hier falsch — es ist niemand mehr da, den er
|
|
// interessiert.
|
|
//
|
|
// Gefragt wird die Aufgabe, nicht der Fehler: eine abgebrochene
|
|
// Netzanfrage kommt als Übertragungsfehler zurück und nicht als
|
|
// `CancellationError`. Danach zu fangen sah richtig aus und griff
|
|
// nie.
|
|
if Task.isCancelled { return previous ?? .loading }
|
|
return .failed(message: ErrorText.describe(error))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Übersetzt Fehler in Sätze, die man einem Menschen zeigen kann.
|
|
///
|
|
/// **Eine** Stelle. Es gab davon zwei — hier und in `HaloSession` —, und sie
|
|
/// waren bereits auseinandergelaufen: derselbe Fehler bekam je nach
|
|
/// Bildschirm eine andere Erklärung.
|
|
enum ErrorText {
|
|
static func describe(_ error: any Error) -> String {
|
|
// Die neutrale Form zuerst: die Ansichten sehen nur noch sie, und
|
|
// jeder Server übersetzt seine eigenen Fehler schon an der Grenze
|
|
// seines Moduls dorthin.
|
|
if let backendError = error as? MediaBackendError {
|
|
return describe(backendError)
|
|
}
|
|
return error.localizedDescription
|
|
}
|
|
|
|
static func describe(_ error: MediaBackendError) -> String {
|
|
switch error {
|
|
case .unauthorized: String(localized: "error.unauthorized")
|
|
case .notFound: String(localized: "error.notFound")
|
|
case .noReachableConnection: String(localized: "error.serverUnreachable")
|
|
case .server(let status): String(localized: "error.server \(status)")
|
|
case .transport(let description): description
|
|
case .decoding: String(localized: "error.unexpectedResponse")
|
|
// Die Worte des Servers, nicht unsere: er weiß besser, warum er
|
|
// ablehnt, als wir es raten könnten.
|
|
case .refused(let reason): reason
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Wohin ein Tipp auf eine Kachel führt.
|
|
///
|
|
/// Nur der Schlüssel, nicht der ganze Titel: die Einträge aus den Reihen der
|
|
/// Startseite sind unvollständig — ohne Dateiliste, ohne Besetzung. Die
|
|
/// Detailseite muss den Titel ohnehin frisch holen.
|
|
struct MediaRoute: Hashable {
|
|
let ratingKey: String
|
|
/// Für die Titelzeile, solange noch geladen wird.
|
|
let title: String
|
|
}
|
|
|
|
/// Der Weg zu einer Mediathek.
|
|
///
|
|
/// Ein Wert statt einer Ansicht, und das ist kein Geschmack: in einem Stapel
|
|
/// dürfen nicht beide Arten von Verknüpfung nebeneinander vorkommen. Sobald
|
|
/// eine Ansicht über eine **Ansichts**-Verknüpfung geschoben wurde, fanden die
|
|
/// **Wert**-Verknüpfungen darin ihr Ziel nicht mehr — jedes Antippen einer
|
|
/// Kachel führte ins Leere, ohne Fehlermeldung.
|
|
struct LibraryRoute: Hashable {
|
|
let id: String
|
|
}
|
|
|
|
/// Der Weg zum Programmführer.
|
|
struct LiveTVRoute: Hashable {}
|