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>
182 lines
7.5 KiB
Swift
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 }
|
|
}
|