Files
Flaro/Flixy/Session/LanguagePreferences.swift
T
ScarriffleandClaude Opus 5 983015b9ad Die Sprache lässt sich jetzt auch je Serie und je Mediathek sagen
Das Regelwerk stand schon und wurde abgefragt — es gab nur keinen Ort, an
dem man etwas hineinschreiben konnte. Jetzt steht die Wahl auf der
Serienseite und im Filter-Sheet der Mediathek, beide über denselben
Auswahlknopf: zwei mit demselben Inhalt liefen unweigerlich auseinander.

Bei der Mediathek in einem eigenen Abschnitt, nicht zwischen den Filtern.
Ein Filter schränkt ein, was zu sehen ist; das hier bestimmt, was zu hören
ist — dazwischengestellt läse es sich als „nur Titel mit deutscher Tonspur
zeigen".

Die Rangfolge Serie → Mediathek → allgemein ist dabei von den Einstellungen
zu `AudioTrackChooser.resolve` gewandert. Sie ist eine Regel, keine Ablage,
und im App-Ziel gibt es kein Unit-Test-Ziel — dort liesse sie sich nicht
durchspielen. Sechs Tests decken sie jetzt ab, darunter der Fall, an dem
man sonst lange sucht: eine leere Zeichenkette steht in den Einstellungen
für „soll der Server entscheiden" und darf nicht als Sprache gelten, sonst
schaltet sie die Ebene darüber stillschweigend ab.

Nebenbei bekommt das Filter-Sheet die Mediathek selbst statt nur ihrer Art.
Die Genres wurden vorher über irgendeine Mediathek dieser Art nachgeschlagen
— bei zwei Filmmediatheken also über die falsche.

Und der Puffer steht auf einer Minute statt auf 25 Sekunden. Es ist ein
Mindestwunsch, keine Obergrenze; der harte Deckel liegt ohnehin woanders,
nämlich darin, wie weit der Transcoder schon produziert hat.

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

182 lines
7.5 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.
///
/// Die Werte kommen von hier, die Rangfolge steht in
/// `AudioTrackChooser.resolve` — dort lässt sie sich prüfen.
func wish(seriesID: String?, libraryID: String?) -> AudioTrackChooser.Wish {
AudioTrackChooser.resolve(
series: seriesID.flatMap { perSeries[$0] },
library: libraryID.flatMap { perLibrary[$0] },
general: audioLanguage,
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 }
}