Files
Flaro/Flixy/Session/LanguagePreferences.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

185 lines
7.6 KiB
Swift

import Foundation
import Observation
import MediaKit
import FlixyPlayback
/// Welche Tonspur gewählt werden soll — und auf welcher Ebene das jemand
/// gesagt hat.
///
/// **Drei Ebenen, eine Frage.**
///
/// Serie → Mediathek → allgemein
///
/// Gefragt wird von unten nach oben; die erste Antwort gilt. Bei Filmen gibt
/// es die Serienebene nicht, dort endet es bei der Mediathek.
///
/// **Warum ein eigener Typ und nicht in `PlaybackSettings`.** Weil das hier
/// keine Einstellung ist, sondern ein kleines Regelwerk: drei Ebenen, eine
/// Rangliste darunter, und ein Gedächtnis, das sich ohne Nachfrage füllt. Das
/// neben Sprungweiten und Abspielgeschwindigkeit zu legen hiesse, beim Lesen
/// jedes Mal beides gleichzeitig im Kopf zu haben.
///
/// **Die Schlüssel der allgemeinen Sprachen bleiben die alten.** Wer sie
/// eingestellt hat, soll sie nach dem Umzug wiederfinden — ein Umzug, den
/// niemand bemerkt, ist ein guter Umzug.
@MainActor
@Observable
final class LanguagePreferences {
private enum Key {
/// Absichtlich die Schlüssel aus `PlaybackSettings`: hier ist nur der
/// Besitzer gewechselt, nicht der Wert.
static let audio = "flixy.playback.audioLanguage"
static let subtitles = "flixy.playback.subtitleLanguage"
static let fallbacks = "flixy.languages.fallbacks"
static let avoidsAudioDescription = "flixy.languages.avoidsAudioDescription"
static let perLibrary = "flixy.languages.library"
static let perSeries = "flixy.languages.series"
static let rememberedTracks = "flixy.languages.seriesTrack"
}
/// Die Sprachen, die zur Auswahl stehen.
///
/// Keine vollständige Liste aller Sprachen der Welt: ein Auswahlrad mit
/// zweihundert Einträgen, um „Deutsch" zu finden, ist keine Hilfe.
/// Schweizerdeutsch steht dabei, weil es in dieser Sammlung vorkommt und
/// **keine** Schreibweise von Deutsch ist — wer es will, muss es nennen
/// können.
static let offeredCodes = [
"de", "gsw", "en", "fr", "it", "es", "nl", "pt", "pl", "ru", "sv",
"da", "no", "fi", "tr", "ja", "ko", "zh", "cs", "hu",
]
private let defaults: UserDefaults
// MARK: - Allgemein
var audioLanguage: String {
didSet { defaults.set(audioLanguage, forKey: Key.audio) }
}
var subtitleLanguage: String {
didSet { defaults.set(subtitleLanguage, forKey: Key.subtitles) }
}
/// Die Rangliste für den Fall, dass die Wunschsprache fehlt.
///
/// **Nur einmal, für alles.** Sie ist keine dritte Einstellung je
/// Mediathek oder Serie: dort steht der *Wunsch*, die Rangliste ist der
/// Boden darunter und gilt überall gleich. Eine Reihenfolge je Mediathek
/// zu pflegen wäre Arbeit für einen Fall, den es nicht gibt.
var fallbacks: [String] {
didSet { defaults.set(fallbacks, forKey: Key.fallbacks) }
}
/// Ob Spuren mit Audiodeskription gemieden werden.
///
/// An, und das ist die richtige Vorgabe: wer Audiodeskription braucht,
/// weiss das und findet den Schalter. Wer sie nicht braucht, ahnt nicht
/// einmal, dass es sie gibt — und bekäme sie sonst untergeschoben, weil
/// sie in manchen Dateien als die deutsche Spur ausgezeichnet ist.
var avoidsAudioDescription: Bool {
didSet { defaults.set(avoidsAudioDescription, forKey: Key.avoidsAudioDescription) }
}
// MARK: - Je Mediathek und je Serie
private var perLibrary: [String: String] {
didSet { defaults.set(perLibrary, forKey: Key.perLibrary) }
}
private var perSeries: [String: String] {
didSet { defaults.set(perSeries, forKey: Key.perSeries) }
}
/// Was zuletzt **von Hand** für eine Serie gewählt wurde.
///
/// Keine Sprache, sondern eine Beschreibung der Spur. Das ist die
/// Kernentscheidung des ganzen Entwurfs: bei manchen Titeln ist die als
/// deutsch ausgezeichnete Spur die mit Audiodeskription und die als
/// englisch ausgezeichnete die eigentliche deutsche. Eine Sprache
/// abzulegen hiesse dort, den Fehler festzuschreiben.
private var rememberedTracks: [String: AudioTrackChooser.TrackSignature] {
didSet {
guard let daten = try? JSONEncoder().encode(rememberedTracks) else { return }
defaults.set(daten, forKey: Key.rememberedTracks)
}
}
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
self.audioLanguage = defaults.string(forKey: Key.audio) ?? ""
self.subtitleLanguage = defaults.string(forKey: Key.subtitles) ?? ""
self.fallbacks = defaults.stringArray(forKey: Key.fallbacks) ?? []
// `object(forKey:)` statt `bool(forKey:)`: das gäbe für „nie gesetzt"
// ein `false` zurück, und die Vorgabe wäre damit still das Gegenteil
// der beabsichtigten.
self.avoidsAudioDescription =
(defaults.object(forKey: Key.avoidsAudioDescription) as? Bool) ?? true
self.perLibrary = defaults.dictionary(forKey: Key.perLibrary) as? [String: String] ?? [:]
self.perSeries = defaults.dictionary(forKey: Key.perSeries) as? [String: String] ?? [:]
self.rememberedTracks = (defaults.data(forKey: Key.rememberedTracks)).flatMap {
try? JSONDecoder().decode([String: AudioTrackChooser.TrackSignature].self, from: $0)
} ?? [:]
}
// MARK: - Lesen und Setzen je Ebene
func audioLanguage(forLibrary id: String) -> String? { perLibrary[id] }
func setAudioLanguage(_ code: String?, forLibrary id: String) {
perLibrary[id] = code
}
func audioLanguage(forSeries id: String) -> String? { perSeries[id] }
func setAudioLanguage(_ code: String?, forSeries id: String) {
perSeries[id] = code
}
func rememberedTrack(forSeries id: String) -> AudioTrackChooser.TrackSignature? {
rememberedTracks[id]
}
/// Merkt sich, was jemand von Hand gewählt hat — ohne zu fragen.
///
/// **Ohne Nachfrage**, weil „für diese Serie merken?" bei jeder Umstellung
/// genau die Art Dialog ist, die man nach dem dritten Mal wegklickt.
/// Sichtbar wird es trotzdem: das Spurenblatt zeigt es an und lässt es
/// löschen. Eine automatische Handlung, die man nicht sehen kann, ist von
/// einem Fehler nicht zu unterscheiden.
func remember(_ stream: MediaStream, forSeries id: String) {
rememberedTracks[id] = AudioTrackChooser.TrackSignature(stream)
}
func forgetTrack(forSeries id: String) {
rememberedTracks[id] = nil
}
// MARK: - Was dabei herauskommt
/// Der Wunsch für einen bestimmten Titel.
///
/// Hier laufen die drei Ebenen zusammen — und zwar **nur** hier. Wer
/// wissen will, in welcher Reihenfolge gefragt wird, liest diese Methode
/// und sonst nichts.
func wish(seriesID: String?, libraryID: String?) -> AudioTrackChooser.Wish {
let ausdrücklich = seriesID.flatMap { perSeries[$0] }
?? libraryID.flatMap { perLibrary[$0] }
?? (audioLanguage.isEmpty ? nil : audioLanguage)
return AudioTrackChooser.Wish(
language: ausdrücklich,
fallbacks: fallbacks,
remembered: seriesID.flatMap { rememberedTracks[$0] },
avoidsAudioDescription: avoidsAudioDescription
)
}
/// Ob überhaupt eine Mediathek ausschlaggebend sein kann.
///
/// Wer nie eine Sprache je Mediathek gesetzt hat — der Normalfall —, für
/// den braucht auch niemand herauszufinden, in welcher Mediathek ein Titel
/// liegt. Das spart bei jeder Wiedergabe eine Serveranfrage.
var usesLibraryLevel: Bool { !perLibrary.isEmpty }
}