Files
Flaro/Packages/FlixyKit/Sources/MediaKit/MediaBackend.swift
T
ScarriffleandClaude Opus 5 596a143545 Die Tonspur wird aus der Datei gewählt, nicht aus dem Stream
Im Spurenblatt stand eine einzige Zeile: „Unbekannt". Das ist der Name, den
AVFoundation einer namenlosen Spur gibt — und bei einem umgerechneten
Stream liefert der Server genau eine. Der Player hatte nichts zur Auswahl,
weil die Wahl längst gefallen war, bevor er gefragt wurde.

Gewählt wird jetzt aus den Spuren, die **in der Datei** stehen, und zwar
bevor der Stream ausgehandelt wird. Die Nummer geht als `audioStreamIndex`
mit hinaus; wer mitten im Film umstellt, bekommt den Stream neu — ausser
bei unveränderter Wiedergabe, wo der Player ohne Unterbrechung umschaltet.

Die Abwägung selbst macht `AudioTrackChooser`: gemerkte Spur, dann
Wunschsprache, dann Rangliste, dann Standardspur — und Audiodeskription
mit deutlichem Abzug statt Ausschluss, damit eine Sprache, die es nur so
gibt, immer noch besser ist als Stille.

Drei Ebenen in `LanguagePreferences`: Serie, Mediathek, allgemein. Gefragt
wird von unten nach oben. Die Mediathek wird nur nachgeschlagen, wenn
überhaupt jemand eine Sprache je Mediathek gesetzt hat — sonst kostete jede
Wiedergabe eine Anfrage für eine Ebene, die niemand benutzt.

Wer von Hand umstellt, dessen Wahl wird für die Serie gemerkt, ohne
Nachfrage. Der Dialog „für diese Serie merken?" ist genau die Art, die man
nach dem dritten Mal wegklickt. Sichtbar ist es trotzdem: das Spurenblatt
zeigt es und lässt es löschen.

Dazu die Rangliste in den Einstellungen, ziehbar, und der Schalter
„Audiodeskription meiden".

Und die Player-Oberfläche blendet endlich als Ganzes über. Die Kopfzeile
hatte gar keine Überblendung — sie sprang herein, während die Bedienung
darunter verblasste. Jetzt steht die Animation an einer Stelle statt an
sechs, und sie läuft über Deckkraft statt über Einfügen und Entfernen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 16:31:38 +02:00

180 lines
7.4 KiB
Swift

import Foundation
/// Was ein Server können muss, damit die App mit ihm arbeiten kann.
///
/// Der Vertrag ist bewusst schmal. Alles, was nur ein Server kann — Plex' PIN-
/// Anmeldung, Jellyfins Verwaltung, die Eigenheiten des Programmführers —
/// steht **nicht** hier, sondern als eigene Fähigkeit. Ein gemeinsamer Nenner,
/// der alles umfasst, wäre kein Nenner mehr.
public protocol MediaBackend: Sendable {
/// Wie der Server heißt, so wie er sich selbst nennt.
nonisolated var serverName: String { get }
/// Wer dieser Server ist — für alles, was ihn über einen Start hinaus
/// wiedererkennen muss.
///
/// **Nicht der Name.** Zwei Server dürfen „Medien" heißen, und derselbe
/// Server darf umbenannt werden. Was hier steht, ist so gewählt, dass es
/// beides überlebt und beim nächsten Start noch gleich lautet. Es ist
/// eine Kennung und geht nirgends an die Oberfläche.
nonisolated var identity: String { get }
/// Was dieser Server darüber hinaus beherrscht.
nonisolated var capabilities: Set<BackendCapability> { get }
// MARK: - Blättern
func libraries() async throws(MediaBackendError) -> [MediaLibrary]
func homeRows() async throws(MediaBackendError) -> [MediaRowGroup]
func items(
inLibrary library: MediaLibrary,
query: MediaQuery,
offset: Int,
limit: Int
) async throws(MediaBackendError) -> MediaPage
func item(id: String) async throws(MediaBackendError) -> MediaItem
/// In welcher Mediathek ein Titel liegt.
///
/// **Warum das eine Frage ist.** Ein Titel weiss es nicht von selbst: er
/// kennt seinen unmittelbaren Vorgesetzten — bei einer Folge die Staffel,
/// bei einem Film die Mediathek — und mehr nicht. Wer die Mediathek einer
/// Folge wissen will, muss die Kette hinaufgehen, und das ist eine
/// Serveranfrage.
///
/// Deshalb steht sie hier als eigene Frage und nicht als Feld an
/// `MediaItem`: sie wird selten gebraucht, kostet etwas, und ein Feld,
/// das meistens `nil` ist, verschweigt genau das.
///
/// `nil`, wenn der Server es nicht sagen kann — dann fällt die Ebene weg,
/// statt zu raten.
func libraryID(for item: MediaItem) async -> String?
func children(of id: String) async throws(MediaBackendError) -> [MediaItem]
func search(_ text: String, limit: Int) async throws(MediaBackendError) -> [MediaRowGroup]
/// Die möglichen Werte eines Filters, etwa alle Genres einer Mediathek.
func filterOptions(
inLibrary library: MediaLibrary,
field: MediaQuery.Field
) async throws(MediaBackendError) -> [MediaFilterOption]
/// Die nächste Folge nach dieser, sofern es eine gibt.
func nextEpisode(after item: MediaItem) async throws(MediaBackendError) -> MediaItem?
// MARK: - Bilder
/// Ein Bild in der gewünschten Größe.
///
/// `nonisolated`, weil die Adresse ohne Netzzugriff entsteht und der
/// Systemplayer sie ohne Kopfzeilen abruft — der Token steckt deshalb in
/// der Adresse selbst.
nonisolated func artworkURL(path: String?, width: Int, height: Int) -> URL?
nonisolated func rawImageURL(path: String?) -> URL?
// MARK: - Herunterladen
/// Die Adresse, unter der eine Datei unverändert zu holen ist.
nonisolated func downloadURL(for file: MediaFile) -> URL?
/// Die Adresse für eine verkleinerte Fassung.
///
/// `nil`, wenn der Server das nicht anbietet — dann bleibt nur das
/// Original, und die Oberfläche soll die Wahl gar nicht erst zeigen.
nonisolated func compressedDownloadURL(
itemID: String,
versionIndex: Int,
maximumBitrate: Int,
resolution: String?
) -> URL?
// MARK: - Abspielen
/// Lässt den Server eine Wiedergabe vorbereiten und nennt die Adresse.
///
/// Wirft mit der Begründung des Servers, wenn er ablehnt. Genau daran hing
/// beim Live-TV tagelang alles: der Server hatte im Klartext gesagt, warum
/// er nicht mitspielt, und die Antwort wurde weggeworfen.
func preparePlayback(_ intent: PlaybackIntent) async throws(MediaBackendError) -> PlaybackTarget
/// Beendet, was der Server für diese Wiedergabe angelegt hat.
///
/// Ohne dieses Signal rechnet ein Transcoder minutenlang weiter, obwohl
/// niemand mehr zusieht.
func endPlayback(sessionID: String) async
/// Meldet, wo man gerade steht.
///
/// Wirft nicht: eine verlorene Meldung kostet ein paar Sekunden
/// Fortschritt, und dafür mitten im Film eine Fehlermeldung zu zeigen
/// wäre die schlechtere Hälfte des Tauschs.
func reportProgress(_ report: PlaybackReport) async
// MARK: - Zustand melden
func markWatched(id: String) async throws(MediaBackendError)
func markUnwatched(id: String) async throws(MediaBackendError)
func setFavourite(_ isFavourite: Bool, id: String) async throws(MediaBackendError)
func rate(id: String, rating: Int) async throws(MediaBackendError)
func refreshLibrary(_ library: MediaLibrary) async throws(MediaBackendError)
}
/// Was ein Server über das Nötigste hinaus kann.
///
/// Abgefragt statt vorausgesetzt: Plex kennt keine Favoriten, Jellyfin keine
/// Plex-Empfänger, und eine Oberfläche, die Knöpfe für Dinge zeigt, die der
/// Server nicht kann, ist schlimmer als eine, die sie weglässt.
public enum BackendCapability: String, Sendable, Hashable {
case liveTV
/// Aufnehmen und Vormerken — nicht dasselbe wie `liveTV`.
///
/// Plex kann Sender zeigen und einschalten, aber in dieser App nichts
/// aufnehmen; Jellyfin kann beides. Eine Fähigkeit für beides zusammen
/// hieße, entweder Plex Live-TV wegzunehmen oder einen Knopf zu zeigen,
/// der dort nichts tut.
case liveTVRecordings
case favourites
case ratings
case libraryScan
/// Die Verwaltung des Servers aus der App heraus.
case administration
case transcoding
}
/// Ein Ausschnitt aus einer Mediathek.
///
/// „Seite" ist hier eine technische Portion, **keine Seite in der Oberfläche**.
/// Die Mediathek ist eine einzige lange Liste, die nachlädt, sobald man sich
/// dem Ende nähert — Blättern mit „weiter zu Seite 2" gehört nicht in eine
/// Mediathek, in der man stöbert.
public struct MediaPage: Sendable, Equatable {
public let items: [MediaItem]
/// Ab welcher Stelle dieser Ausschnitt beginnt.
public let offset: Int
/// Wie viele es insgesamt gibt — Grundlage fürs Nachladen.
public let totalCount: Int
public init(items: [MediaItem], offset: Int = 0, totalCount: Int) {
self.items = items
self.offset = offset
self.totalCount = totalCount
}
public var hasMore: Bool { offset + items.count < totalCount }
public var nextOffset: Int { offset + items.count }
}
public extension MediaBackend {
/// Ohne eigene Antwort keine Mediathek.
///
/// Plex beantwortet das heute nicht, und eine falsche Antwort wäre
/// schlechter als keine: sie stellte die Sprache einer fremden Mediathek
/// über den allgemeinen Wunsch.
func libraryID(for item: MediaItem) async -> String? { nil }
}
/// Ein möglicher Wert für einen Filter.
public struct MediaFilterOption: Sendable, Equatable, Identifiable {
public let id: String
public let title: String
public init(id: String, title: String) {
self.id = id
self.title = title
}
}