Der Simulator läuft wieder. Ich habe alles durchgeklickt, was ich dir in den letzten beiden Runden ungeprüft gegeben habe. Es war nicht in Ordnung. **Zwei Navigationsleisten übereinander.** Auf dem iPhone hat die Leiste unten fünf Plätze; alles darüber schiebt das System in ein „Mehr"-Verzeichnis — und dessen Einträge bekamen zwei Zurück-Pfeile, einen vom System und einen von uns. Statt das zu flicken, gibt es jetzt die **Bibliothek**, die du ohnehin wolltest: auf dem iPhone ein Eintrag, hinter dem alle Mediatheken stehen, auf dem iPad weiter jede einzeln in der Seitenleiste. Damit bleibt die Leiste bei fünf Einträgen, und das „Mehr" entfällt. **Kacheln in der Mediathek öffneten nichts.** Kein Fehler, keine Meldung — Antippen führte einfach ins Leere. Der Grund: in einem Stapel dürfen nicht beide Arten von Verknüpfung nebeneinander vorkommen. Sobald ein Bildschirm über eine **Ansichts**-Verknüpfung geschoben wurde, fanden die **Wert**-Verknüpfungen darin ihr Ziel nicht mehr. Jetzt ist alles wertbasiert, und das Ziel für Detailseiten steht **einmal je Stapel** an dessen Wurzel statt in jedem Bildschirm einzeln. **Der Lupenknopf in der Kopfzeile zeigte auf ein Ziel, das es nie gab.** Kein Bildschirm hat je ein `SearchRoute`-Ziel angemeldet — der Knopf tat seit jeher nichts. Er ist weg; die Suche hat ihren eigenen Platz in der Leiste. **Der Download-Dialog zeigte Schlüssel statt Namen** — „quality.hd" statt „720p". Die drei Übersetzungen fehlten schlicht. **Die Kontokarte wuchs aus dem Bild heraus.** Die Leiste unten in der Seitenleiste hat eine feste Höhe; meine aufklappende Karte schob die Zeile mit dem Namen nach unten hinaus. Jetzt hängt sie als Popover darüber — die Zeile bleibt stehen, wie bei JellyTV. Verkleinerte Downloads bei Jellyfin laufen: Stufen erscheinen, die Adresse stimmt, die Datei kommt an. Dass die Attrappe nur 28 kB liefert und die App das als „keine Videodatei" zurückweist, ist richtig so — die Prüfung tut, was sie soll. Zwei Gruben in der Attrappe nebenbei gestopft: sie kannte `/stream.mp4` nicht und meldete ihr Testbild als 360p, womit es gar keine kleineren Stufen geben konnte. 263 Tests grün. Diesmal alles angesehen: iPhone und iPad, Einstellungen, Bibliothek, Detailseite, Download-Dialog, Kontokarte. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
112 lines
4.3 KiB
Swift
112 lines
4.3 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
|
|
}
|
|
|
|
/// 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 {}
|