import Foundation import Observation import MediaKit import HaloPlayback /// 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 = "halo.playback.audioLanguage" static let subtitles = "halo.playback.subtitleLanguage" static let fallbacks = "halo.languages.fallbacks" static let avoidsAudioDescription = "halo.languages.avoidsAudioDescription" static let perLibrary = "halo.languages.library" static let perSeries = "halo.languages.series" static let rememberedTracks = "halo.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 } }