Files
Flaro/Flixy/Views/LoadState.swift
T
ScarriffleandClaude Opus 5 fb418c842d Wiedergabe: der Player spricht nicht mehr mit Plex, sondern mit dem Vertrag
Bis hierher konnte man sich bei Jellyfin anmelden und stöbern, aber nichts
abspielen. Der Player war die letzte große Stelle, die Plex unmittelbar
kannte.

Drei Mitglieder kommen dafür in den Vertrag: eine Wiedergabe vorbereiten,
sie beenden, den Fortschritt melden. Die Entscheidung **ob** umgerechnet
wird, fällt weiterhin vorher im `PlaybackDecider` — der Server führt sie nur
aus. Genau diese Trennung fehlte: der Player hat Plex' Transcoder
angesprochen und konnte deshalb mit nichts anderem reden.

`MediaBackendError` bekommt einen siebten Fall: `refused(reason:)`. Der
Server hat verstanden und sagt nein, in eigenen Worten. Bei Plex war genau
dieser Satz die einzige brauchbare Spur, und er ging tagelang verloren, weil
es dafür keinen Fall gab.

Bei Jellyfin kommt die Adresse einer umgerechneten Fassung vom Server selbst
(`PlaybackInfo` → `TranscodingUrl`), nicht von uns. Er weiß, welche Spuren er
kopieren kann und welche er neu rechnen muss.

Drei Dinge, die beim Hinsehen auffielen:

- **`startTimeTicks` gehört nicht in die Anfrage.** Sonst entscheiden zwei
  Stellen über den Startpunkt: der Server ließe den Strom dort beginnen, und
  der Player spränge noch einmal um dieselbe Strecke vor. Bei einundzwanzig
  Minuten Fortschritt landet man bei zweiundvierzig.
- **Der erste Bericht braucht einen eigenen Endpunkt.** Ohne ihn erscheint
  der Titel bei Jellyfin nirgends als „läuft gerade". Er fiel zunächst aus,
  weil er die Laufzeit verlangte — und die steht erst Sekunden nach dem
  Öffnen fest. Jetzt tut es für die erste Meldung die des Servers.
- **In der Download-Adresse standen Titel und Fassung vertauscht.** Bei einem
  Film mit einer Fassung sind beide Kennungen dieselbe und nichts fällt auf;
  bei zweien schon. `MediaFile` sagt jetzt, zu welchem Titel sie gehört.

Live-TV bleibt bei Plex und wird als Einziges noch unmittelbar durchgereicht.
Ein Tuner ist ein Stück Hardware, und die fünfhundert Zeilen, die es gekostet
hat, ihn zu bändigen, gehören in keinen gemeinsamen Vertrag.

Nachgesehen, nicht nur gebaut: gegen die Attrappe läuft ein echtes Video, der
Beginn wird gemeldet, der Fortschritt kommt in Ticks mit Sitzung und Fassung
an. Was die Attrappe **nicht** beantworten kann, ist, ob ein echter Jellyfin
das Geräteprofil annimmt und eine Umrechnung anbietet — das entscheidet erst
ein richtiger Server.

257 Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 14:00:02 +02:00

98 lines
3.8 KiB
Swift

import Foundation
import MediaKit
import PlexKit
/// 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 `FlixySession` —, 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, weil die Ansichten nur noch sie sehen.
// Plex' eigener Fehler kommt nur noch dort an, wo unmittelbar mit ihm
// gesprochen wird — Player, Downloads, Live-TV.
if let backendError = error as? MediaBackendError {
return describe(backendError)
}
if let plexError = error as? PlexError {
return describe(plexError.asMediaBackendError)
}
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
}