Files
Flaro/Flaro/Session/LanguagePreferences.swift
T
ScarriffleandClaude Opus 5 a7cf6c5def Halo heisst ab hier Flaro — die Marke stand im Weg
Der Upload zu App Store Connect scheiterte am Namen. „Halo" ist eine
eingetragene Marke von Microsoft; im App Store heisst zwar keine App exakt
so, aber reservierte und markenrechtlich belegte Namen tauchen in keiner
Suche auf. Der einzige verlässliche Test war die Eingabe dort, und die ging
daneben.

**Der Name wurde diesmal vorher geprüft.** Zwanzig Kandidaten gegen den
deutschen und amerikanischen App Store, auf exakte Treffer und auf ähnliche
Namen. „Kavo" wäre dieselbe Wand gewesen — exakt belegt in beiden Stores, in
Deutschland von KaVo Dental GmbH. „Flare" ist mit 42 ähnlichen Apps je Store
der am dichtesten besetzte Name der ganzen Runde. **Flaro** ist in beiden
frei und liest sich als eigenes Wort, nicht als Flare mit Tippfehler.

Das bleibt eine Store-Suche und keine Markenrecherche — reservierte Namen
ohne veröffentlichte App sieht sie nicht. Aber es ist mehr Prüfung, als beim
letzten Mal stattfand.

---

2160 Textvorkommen in 273 Dateien, 49 Pfade. Betroffen ist diesmal auch der
Server: `Flaro.Api`, `Flaro.Core`, `Flaro.Data`, `Flaro.Media`,
`Flaro.Metadata`, `FlaroContext`, `FlaroPaths`, `flaro.db`, die Variablen
`FLARO_DATA`, `FLARO_CACHE`, `FLARO_TRANSCODE`, `FLARO_WRITE_SIDECARS`, und
das Image.

**Vor dem Ersetzen geprüft**, ob „halo" irgendwo als Teil eines anderen
Wortes steckt — und ob es im TMDB-Token vorkommt. Tat es nicht; ein blindes
Ersetzen hätte den sonst zerlegt.

`docs/superpowers/specs/` bleibt wie beim letzten Mal unangetastet: datierte
Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen. Das
Server-Dokument hat stattdessen einen Nachtrag bekommen, damit es niemanden
in die Irre führt.

**Die Bundle-ID wandert mit**, zum zweiten Mal an einem Tag:
`com.scarriffleservices.flaro`. Wer die vorige Fassung installiert hat, hat
jetzt zwei Karteileichen auf dem Gerät — beide löschen, dann ist Ruhe.

308 Server-Tests grün, 368 im Swift-Paket, null Warnungen. iOS und tvOS
bauen, das Image baut, und ein frischer Container läuft durch Einrichtung
und Anmeldung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 19:38:27 +02:00

182 lines
7.5 KiB
Swift

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