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