Reine Umbenennung, kein Verhalten geändert — bewusst als eigener Commit, damit „umbenannt" und „umgebaut" im Nachhinein auseinanderzuhalten sind. 712 mal `Flixy`, 778 mal `flixy`, 7 mal `FLIXY` in 169 Dateien, dazu 25 Pfade. Ziele: `Halo`, `HaloTV`, `HaloTopShelf`, `HaloUITests`, `HaloKit` mit `HaloDesign`, `HaloDownloads`, `HaloPlayback` und `HaloProbe`. `MediaKit`, `JellyfinKit` und `DownloadWidget` tragen den Namen nicht und bleiben. `docs/superpowers/specs/` ist ausgenommen. Datierte Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen; sie nachträglich umzuschreiben hiesse, die Vergangenheit zu glätten. **Die Bundle-ID wandert mit** — ausdrücklich so gewollt, mit bekanntem Preis: `com.scarriffleservices.halo` ist für iOS eine *andere* App. Die installierte Fassung bleibt daneben stehen, mit Downloads und Anmeldung in ihrem Container; Halo startet leer. Einmal neu anmelden, heruntergeladene Folgen einmal neu laden. Dasselbe gilt für die App-Gruppe, die sich TV-App und Top Shelf teilen. **Eine Folge davon, die beim Entscheiden noch nicht auf dem Tisch lag:** der Aufräumschritt aus dem vorigen Commit kommt an die alten Plex-Token nicht mehr heran. Der Schlüsselbund grenzt nach Team und Bundle-Kennung ab — was unter `…flixy` liegt, ist für `…halo` unerreichbar. Erledigt wird es trotzdem, nur anders: **die alte App löschen.** iOS räumt deren Schlüsselbund-Einträge beim Deinstallieren mit ab. Der Code bleibt stehen, weil er für jeden Stand vor dieser Umbenennung richtig ist und im Jahr einen Startvergleich kostet. Die Projektdatei ist aus `project.yml` neu erzeugt, der alte Ordner gelöscht statt umbenannt — sie steht ohnehin nicht im Repo. Das Verzeichnis des Repos selbst heißt weiter `Flixy`; das umzubenennen ist ein Handgriff ausserhalb des Projekts. 368 Tests grün. iOS und tvOS bauen. 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 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 }
|
|
}
|