From 983015b9add7cc90caaef086dc28003fc1d01a0b Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 28 Sep 2026 17:12:17 +0200 Subject: [PATCH] =?UTF-8?q?Die=20Sprache=20l=C3=A4sst=20sich=20jetzt=20auc?= =?UTF-8?q?h=20je=20Serie=20und=20je=20Mediathek=20sagen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Flixy/Resources/Localizable.xcstrings | 89 +++++++++++++++++++ Flixy/Session/LanguagePreferences.swift | 15 ++-- Flixy/Views/Detail/SeriesDetailView.swift | 12 +++ Flixy/Views/Library/LibraryFilterSheet.swift | 31 +++++-- Flixy/Views/Library/LibraryView.swift | 2 +- Flixy/Views/Player/PlayerModel.swift | 16 ++-- .../Views/Settings/AudioLanguagePicker.swift | 88 ++++++++++++++++++ .../Decision/AudioTrackChooser.swift | 36 ++++++++ .../AudioTrackChooserTests.swift | 61 +++++++++++++ 9 files changed, 329 insertions(+), 21 deletions(-) create mode 100644 Flixy/Views/Settings/AudioLanguagePicker.swift diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings index 870b848..c68da3c 100644 --- a/Flixy/Resources/Localizable.xcstrings +++ b/Flixy/Resources/Localizable.xcstrings @@ -5,6 +5,10 @@ "comment": "A separator between different parts of the episode metadata.", "isCommentAutoGenerated": true }, + "%@": { + "comment": "A number in a list.", + "isCommentAutoGenerated": true + }, "%@ GB": { "comment": "A picker option in the settings view that lets the user select the maximum amount of storage space that can be used for downloaded content. The value is given in gigabytes.", "isCommentAutoGenerated": true @@ -1751,6 +1755,23 @@ } } }, + "filter.librarySettings": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Diese Mediathek" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "This library" + } + } + } + }, "filter.reset": { "extractionState": "stale", "localizations": { @@ -2301,6 +2322,57 @@ } } }, + "languages.forThisLibrary": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Tonsprache für diese Mediathek" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Audio language for this library" + } + } + } + }, + "languages.forThisLibraryExplanation": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Gilt für alle Titel dieser Mediathek. Eine Serie kann es überstimmen, und was du im Player von Hand wählst, überstimmt beides." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Applies to every title in this library. A series can override it, and what you pick by hand in the player overrides both." + } + } + } + }, + "languages.forThisSeries": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Tonsprache für diese Serie" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Audio language for this series" + } + } + } + }, "languages.headerExplanation": { "extractionState": "stale", "localizations": { @@ -2318,6 +2390,23 @@ } } }, + "languages.inherit": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Wie allgemein" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "As set generally" + } + } + } + }, "languages.serverDefault": { "localizations": { "de": { diff --git a/Flixy/Session/LanguagePreferences.swift b/Flixy/Session/LanguagePreferences.swift index b3fcc9f..60e2954 100644 --- a/Flixy/Session/LanguagePreferences.swift +++ b/Flixy/Session/LanguagePreferences.swift @@ -159,16 +159,13 @@ final class LanguagePreferences { /// Der Wunsch für einen bestimmten Titel. /// - /// Hier laufen die drei Ebenen zusammen — und zwar **nur** hier. Wer - /// wissen will, in welcher Reihenfolge gefragt wird, liest diese Methode - /// und sonst nichts. + /// 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 { - let ausdrücklich = seriesID.flatMap { perSeries[$0] } - ?? libraryID.flatMap { perLibrary[$0] } - ?? (audioLanguage.isEmpty ? nil : audioLanguage) - - return AudioTrackChooser.Wish( - language: ausdrücklich, + AudioTrackChooser.resolve( + series: seriesID.flatMap { perSeries[$0] }, + library: libraryID.flatMap { perLibrary[$0] }, + general: audioLanguage, fallbacks: fallbacks, remembered: seriesID.flatMap { rememberedTracks[$0] }, avoidsAudioDescription: avoidsAudioDescription diff --git a/Flixy/Views/Detail/SeriesDetailView.swift b/Flixy/Views/Detail/SeriesDetailView.swift index 1f890f0..07e4791 100644 --- a/Flixy/Views/Detail/SeriesDetailView.swift +++ b/Flixy/Views/Detail/SeriesDetailView.swift @@ -29,6 +29,18 @@ struct SeriesDetailView: View { if !item.genres.isEmpty { TagStrip(tags: item.genres) } + // **Nur bei der Serie, nicht bei der Staffel.** Die Ebene + // heisst Serie; sie zweimal anzubieten hiesse, zwei Orte für + // dieselbe Einstellung zu haben, von denen einer nie gelesen + // wird. + if item.kind == .show { + AudioLanguagePicker( + scope: .series(item.id), + title: "languages.forThisSeries" + ) + .padding(.horizontal, FlixySpacing.screenMargin) + } + childList if !item.cast.isEmpty { CastRow(cast: item.cast) } diff --git a/Flixy/Views/Library/LibraryFilterSheet.swift b/Flixy/Views/Library/LibraryFilterSheet.swift index 211a7a5..d9d77b2 100644 --- a/Flixy/Views/Library/LibraryFilterSheet.swift +++ b/Flixy/Views/Library/LibraryFilterSheet.swift @@ -5,7 +5,13 @@ import MediaKit /// Filter und Sortierung einer Mediathek. struct LibraryFilterSheet: View { @Binding var query: MediaQuery - let libraryKind: MediaLibrary.Kind? + /// Die Mediathek selbst, nicht nur ihre Art. + /// + /// Vorher stand hier die Art, und die Genres wurden über *irgendeine* + /// Mediathek dieser Art nachgeschlagen — bei zwei Filmmediatheken also + /// über die falsche. Die Sprache je Mediathek braucht die Kennung + /// ohnehin, und damit verschwindet die Suche nach der Art gleich mit. + let library: MediaLibrary @Environment(\.dismiss) private var dismiss @Environment(\.flixyAccent) private var accent @@ -18,9 +24,9 @@ struct LibraryFilterSheet: View { /// bei drei Änderungen hintereinander drei verworfene Ladevorgänge. @State private var draft: MediaQuery - init(query: Binding, libraryKind: MediaLibrary.Kind?) { + init(query: Binding, library: MediaLibrary) { self._query = query - self.libraryKind = libraryKind + self.library = library self._draft = State(initialValue: query.wrappedValue) } @@ -56,6 +62,21 @@ struct LibraryFilterSheet: View { } } + // **Kein Filter, und deshalb ein eigener Abschnitt.** Ein + // Filter schränkt ein, was zu sehen ist; das hier bestimmt, + // was zu hören ist. Zwischen die Filter gestellt läse es sich + // als „nur Titel mit deutscher Tonspur zeigen". + Section { + AudioLanguagePicker( + scope: .library(library.id), + title: "languages.forThisLibrary" + ) + } header: { + Text("filter.librarySettings") + } footer: { + Text("languages.forThisLibraryExplanation") + } + Section { Button("filter.reset", role: .destructive) { draft = MediaQuery(sort: draft.sort) @@ -92,9 +113,7 @@ struct LibraryFilterSheet: View { } private func loadGenres() async { - guard let server = session.backend, - let library = session.libraries.first(where: { $0.kind == libraryKind }) - else { return } + guard let server = session.backend else { return } genres = (try? await server.filterOptions(inLibrary: library, field: .genre)) ?? [] } } diff --git a/Flixy/Views/Library/LibraryView.swift b/Flixy/Views/Library/LibraryView.swift index c59cde2..b843cff 100644 --- a/Flixy/Views/Library/LibraryView.swift +++ b/Flixy/Views/Library/LibraryView.swift @@ -64,7 +64,7 @@ struct LibraryView: View { .flixyInlineTitle() .toolbar { toolbar } .sheet(isPresented: $showsFilters) { - LibraryFilterSheet(query: $query, libraryKind: library.kind) + LibraryFilterSheet(query: $query, library: library) } .task(id: library.id) { // Beim Wechsel auf eine andere Mediathek zuerst leeren: sonst hält diff --git a/Flixy/Views/Player/PlayerModel.swift b/Flixy/Views/Player/PlayerModel.swift index bda0f52..30204c1 100644 --- a/Flixy/Views/Player/PlayerModel.swift +++ b/Flixy/Views/Player/PlayerModel.swift @@ -198,11 +198,17 @@ final class PlayerModel { /// Vorrat hieße dort eine halbe Minute Verzug und ein Umschalten, das /// sich zäh anfühlt. Eine Datei von der Platte braucht keinen Vorrat. /// - /// Fünfundzwanzig Sekunden sind ein Anfangswert, kein Messergebnis: genug, - /// um ein Loch von einigen Sekunden zu überbrücken, wenig genug, dass der - /// Start nicht spürbar später kommt. Die richtige Zahl gehört mit einer - /// gedrosselten Leitung ermittelt. - private static let forwardBuffer: Duration = .seconds(25) + /// **Ein Mindestwunsch, keine Obergrenze.** AVPlayer darf mehr holen; wir + /// sagen ihm nur, dass er sich nicht mit weniger zufriedengeben soll, als + /// er von sich aus täte. + /// + /// Eine Minute. Der Preis ist Datenvolumen, das beim Spulen verfällt, und + /// Speicher — beides tragbar. Der harte Deckel liegt ohnehin woanders: + /// rechnet der Server gerade um, existieren die Schnipsel noch gar nicht, + /// und weiter voraus als ffmpeg produziert hat kommt niemand. Jellyfins + /// eigene Drosselung ist dabei nicht im Spiel — sie ist standardmässig + /// aus und griffe erst bei 180 Sekunden. + private static let forwardBuffer: Duration = .seconds(60) /// Wie oft der Zustand des Streams geprüft wird. /// diff --git a/Flixy/Views/Settings/AudioLanguagePicker.swift b/Flixy/Views/Settings/AudioLanguagePicker.swift new file mode 100644 index 0000000..f2661f7 --- /dev/null +++ b/Flixy/Views/Settings/AudioLanguagePicker.swift @@ -0,0 +1,88 @@ +import SwiftUI +import FlixyDesign + +/// „Welche Tonsprache gilt hier?" — an genau zwei Orten dieselbe Frage. +/// +/// **Warum ein gemeinsamer Typ.** Die Frage steht einmal auf der Serienseite +/// und einmal bei der Mediathek, und die Antwort bedeutet beide Male dasselbe: +/// diese Ebene setzt sich gegen die darüber durch, oder sie tut es nicht. Zwei +/// Auswahlknöpfe mit demselben Inhalt liefen unweigerlich auseinander — einer +/// bekäme eine Sprache dazu, der andere nicht. +/// +/// **Ein Menü, kein `Picker`.** Die Serienseite ist eine `ScrollView`, kein +/// `Form`; dort hätte ein `Picker` kein Aussehen, auf das man sich verlassen +/// kann. Ein Menü sieht überall gleich aus. +struct AudioLanguagePicker: View { + /// Für welche Ebene gewählt wird. + enum Scope { + case series(String) + case library(String) + } + + let scope: Scope + let title: LocalizedStringKey + + @Environment(LanguagePreferences.self) private var languages + @Environment(\.flixyAccent) private var accent + + var body: some View { + HStack(spacing: FlixySpacing.regular) { + Text(title) + .font(.flixyBody) + .foregroundStyle(Color.flixyTextPrimary) + + Spacer(minLength: FlixySpacing.regular) + + Menu { + // „Wie allgemein" zuerst und als Vorgabe: die meisten Ebenen + // sollen nichts Eigenes sagen, sondern die Frage nach oben + // durchreichen. + Button { + set(nil) + } label: { + Label("languages.inherit", systemImage: current == nil ? "checkmark" : "") + } + + Divider() + + ForEach(LanguagePreferences.offeredCodes, id: \.self) { code in + Button { + set(code) + } label: { + Label(name(of: code), systemImage: current == code ? "checkmark" : "") + } + } + } label: { + HStack(spacing: FlixySpacing.tight) { + Text(currentLabel) + .font(.flixyBody) + Image(systemName: "chevron.up.chevron.down") + .font(.flixyCaption) + } + .foregroundStyle(accent) + } + } + } + + private var current: String? { + switch scope { + case .series(let id): languages.audioLanguage(forSeries: id) + case .library(let id): languages.audioLanguage(forLibrary: id) + } + } + + private var currentLabel: String { + current.map(name(of:)) ?? String(localized: "languages.inherit") + } + + private func set(_ code: String?) { + switch scope { + case .series(let id): languages.setAudioLanguage(code, forSeries: id) + case .library(let id): languages.setAudioLanguage(code, forLibrary: id) + } + } + + private func name(of code: String) -> String { + Locale.current.localizedString(forLanguageCode: code)?.capitalized ?? code.uppercased() + } +} diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Decision/AudioTrackChooser.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/AudioTrackChooser.swift index 8cd9b62..47f8139 100644 --- a/Packages/FlixyKit/Sources/FlixyPlayback/Decision/AudioTrackChooser.swift +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/AudioTrackChooser.swift @@ -44,6 +44,42 @@ public enum AudioTrackChooser { } } + /// Drei Ebenen, eine Antwort. + /// + /// 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 die Rangfolge hier steht und nicht bei den Einstellungen.** + /// Weil sie eine Regel ist, keine Ablage. Im App-Ziel liesse sie sich + /// nicht mit einer Wertefolge durchspielen — hier schon, und das ist der + /// Unterschied zwischen einer geprüften und einer geglaubten Rangfolge. + /// + /// Eine leere Zeichenkette zählt als „nichts gesagt": in den Einstellungen + /// steht sie für „soll der Server entscheiden", und das ist keine Sprache, + /// sondern das Ausbleiben einer Antwort. + public static func resolve( + series: String?, + library: String?, + general: String?, + fallbacks: [String] = [], + remembered: TrackSignature? = nil, + avoidsAudioDescription: Bool = true + ) -> Wish { + func gesagt(_ wert: String?) -> String? { + guard let wert, !wert.trimmingCharacters(in: .whitespaces).isEmpty else { return nil } + return wert + } + + return Wish( + language: gesagt(series) ?? gesagt(library) ?? gesagt(general), + fallbacks: fallbacks, + remembered: remembered, + avoidsAudioDescription: avoidsAudioDescription + ) + } + /// Wie eine Spur aussieht — genug, um sie in der nächsten Folge /// wiederzuerkennen. /// diff --git a/Packages/FlixyKit/Tests/FlixyPlaybackTests/AudioTrackChooserTests.swift b/Packages/FlixyKit/Tests/FlixyPlaybackTests/AudioTrackChooserTests.swift index b520231..0f3cfac 100644 --- a/Packages/FlixyKit/Tests/FlixyPlaybackTests/AudioTrackChooserTests.swift +++ b/Packages/FlixyKit/Tests/FlixyPlaybackTests/AudioTrackChooserTests.swift @@ -200,3 +200,64 @@ struct AudioTrackChooserTests { #expect(AudioTrackChooser.choose(from: spuren, wish: .init(language: "de"))?.id == "0") } } + +@Suite("Rangfolge der drei Ebenen") +struct WishResolutionTests { + /// Serie schlägt Mediathek schlägt allgemein. Das ist der ganze Zweck der + /// Abstufung — und der einzige Ort, an dem sie steht. + @Test("Die Serie gewinnt") + func seriesWins() { + let wunsch = AudioTrackChooser.resolve(series: "en", library: "fr", general: "de") + #expect(wunsch.language == "en") + } + + @Test("Ohne Serie gewinnt die Mediathek") + func libraryWinsWithoutSeries() { + #expect(AudioTrackChooser.resolve(series: nil, library: "fr", general: "de").language == "fr") + } + + @Test("Ohne beides gilt die allgemeine") + func generalIsTheFloor() { + #expect(AudioTrackChooser.resolve(series: nil, library: nil, general: "de").language == "de") + } + + /// In den Einstellungen steht eine leere Zeichenkette für „soll der Server + /// entscheiden". Sie als Sprache zu behandeln hiesse, die Ebene darüber + /// stillschweigend abzuschalten — genau der Fehler, den man erst bemerkt, + /// wenn eine Einstellung scheinbar wirkungslos ist. + @Test("Leer heisst nichts gesagt, nicht eine Sprache namens leer") + func emptyIsNotAnAnswer() { + #expect(AudioTrackChooser.resolve(series: "", library: "fr", general: "de").language == "fr") + #expect(AudioTrackChooser.resolve(series: " ", library: "", general: "de").language == "de") + #expect(AudioTrackChooser.resolve(series: nil, library: nil, general: "").language == nil) + } + + /// Die Rangliste ist keine vierte Ebene: sie gilt überall gleich und wird + /// von keiner Ebene überstimmt. + @Test("Rangliste und Schalter gehen unverändert durch") + func theRestPassesThrough() { + let wunsch = AudioTrackChooser.resolve( + series: nil, library: nil, general: "de", + fallbacks: ["gsw", "en"], avoidsAudioDescription: false + ) + #expect(wunsch.fallbacks == ["gsw", "en"]) + #expect(wunsch.avoidsAudioDescription == false) + } + + /// Der ZDF-Fall durch die ganze Kette: die Mediathek sagt Deutsch, die + /// gemerkte Spur sagt etwas anderes — und behält recht. + @Test("Die gemerkte Spur überlebt die Rangfolge") + func rememberedSurvives() { + let spuren = [ + MediaStream(id: "0", kind: .audio, language: "ger", languageCode: "ger", + channels: 2, title: "Deutsch (Audiodeskription)", isDefault: true), + MediaStream(id: "1", kind: .audio, language: "eng", languageCode: "eng", + channels: 2, title: "Deutsch"), + ] + let wunsch = AudioTrackChooser.resolve( + series: nil, library: "de", general: "en", + remembered: AudioTrackChooser.TrackSignature(spuren[1]) + ) + #expect(AudioTrackChooser.choose(from: spuren, wish: wunsch)?.id == "1") + } +}