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>
This commit is contained in:
Scarriffle
2026-09-28 16:31:38 +02:00
co-authored by Claude Opus 5
parent 2dbe260901
commit 596a143545
12 changed files with 755 additions and 120 deletions
@@ -8,6 +8,12 @@ public struct JellyfinBackend: MediaBackend {
/// Nicht `private`: Live-TV und Aufnahmen liegen in eigenen Dateien und
/// brauchen dieselben Bildpfade.
let images = JellyfinImagePaths()
/// Wo ein Titel hingehört — einmal gefragt, dann gemerkt.
///
/// Ein Actor, also ein Referenztyp: Kopien dieses Structs teilen sich
/// denselben und damit das Gedächtnis. Ohne das fragte jede Folge einer
/// Serie dasselbe noch einmal.
let ancestors = JellyfinLibraryMemo()
public let serverName: String
/// Konto **und** Adresse: dasselbe Konto auf zwei Servern ist nicht
@@ -213,6 +219,28 @@ public func isInLocalNetwork() async -> Bool {
return MediaPage(items: mapped, offset: offset, totalCount: total)
}
/// Die Mediathek über die Ahnenkette.
///
/// Jellyfin beantwortet das mit `/Items/{id}/Ancestors`: Staffel, Serie,
/// Mediathek, Wurzel. Gesucht ist der Eintrag vom Typ `CollectionFolder`
/// — das ist eine Mediathek. `UserRootFolder` ist die Wurzel darüber und
/// wäre für alle Titel dieselbe, also nutzlos.
///
/// Die Antwort wird gemerkt: innerhalb einer Serie fragt sonst jede Folge
/// dasselbe noch einmal.
public func libraryID(for item: MediaItem) async -> String? {
let schlüssel = item.seriesID ?? item.id
if let gemerkt = await ancestors.recorded(schlüssel) { return gemerkt.value }
let kette = try? await client.send(
JellyfinRequest(path: "/Items/\(schlüssel)/Ancestors"),
as: [JellyfinItem].self
)
let gefunden = kette?.first { $0.type == "CollectionFolder" }?.id
await ancestors.store(gefunden, for: schlüssel)
return gefunden
}
public func item(id: String) async throws(MediaBackendError) -> MediaItem {
let item = try await client.send(
JellyfinRequest(
@@ -0,0 +1,32 @@
import Foundation
/// Merkt sich, in welcher Mediathek ein Titel liegt.
///
/// **Warum überhaupt.** Die Antwort kostet eine Serveranfrage und ändert sich
/// praktisch nie — ein Titel wandert nicht von den Filmen zu den Serien. Ohne
/// Gedächtnis fragte jede Folge einer Serie dasselbe noch einmal, und zwar
/// genau in dem Augenblick, in dem der Film anlaufen soll.
///
/// **Auch das Nichts wird gemerkt.** Ein Server, der die Frage nicht
/// beantworten kann, beantwortet sie beim zweiten Mal auch nicht — sonst
/// wiederholte sich die vergebliche Anfrage bei jedem Titel. Deshalb die
/// doppelte Optionalität: „nie gefragt" und „gefragt, keine Antwort" sind
/// zwei verschiedene Dinge.
public actor JellyfinLibraryMemo {
/// Eine Antwort, die es gab — der Inhalt darf leer sein.
public struct Answer: Sendable {
public let value: String?
}
private var seen: [String: String?] = [:]
public init() {}
public func recorded(_ key: String) -> Answer? {
seen[key].map(Answer.init)
}
public func store(_ value: String?, for key: String) {
seen[key] = value
}
}
@@ -31,6 +31,21 @@ public protocol MediaBackend: Sendable {
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.
@@ -143,6 +158,15 @@ public struct MediaPage: Sendable, Equatable {
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