Die Sprache lässt sich jetzt auch je Serie und je Mediathek sagen

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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-28 17:12:17 +02:00
co-authored by Claude Opus 5
parent 596a143545
commit 983015b9ad
9 changed files with 329 additions and 21 deletions
+89
View File
@@ -5,6 +5,10 @@
"comment": "A separator between different parts of the episode metadata.", "comment": "A separator between different parts of the episode metadata.",
"isCommentAutoGenerated": true "isCommentAutoGenerated": true
}, },
"%@": {
"comment": "A number in a list.",
"isCommentAutoGenerated": true
},
"%@ GB": { "%@ 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.", "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 "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": { "filter.reset": {
"extractionState": "stale", "extractionState": "stale",
"localizations": { "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": { "languages.headerExplanation": {
"extractionState": "stale", "extractionState": "stale",
"localizations": { "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": { "languages.serverDefault": {
"localizations": { "localizations": {
"de": { "de": {
+6 -9
View File
@@ -159,16 +159,13 @@ final class LanguagePreferences {
/// Der Wunsch für einen bestimmten Titel. /// Der Wunsch für einen bestimmten Titel.
/// ///
/// Hier laufen die drei Ebenen zusammen — und zwar **nur** hier. Wer /// Die Werte kommen von hier, die Rangfolge steht in
/// wissen will, in welcher Reihenfolge gefragt wird, liest diese Methode /// `AudioTrackChooser.resolve` — dort lässt sie sich prüfen.
/// und sonst nichts.
func wish(seriesID: String?, libraryID: String?) -> AudioTrackChooser.Wish { func wish(seriesID: String?, libraryID: String?) -> AudioTrackChooser.Wish {
let ausdrücklich = seriesID.flatMap { perSeries[$0] } AudioTrackChooser.resolve(
?? libraryID.flatMap { perLibrary[$0] } series: seriesID.flatMap { perSeries[$0] },
?? (audioLanguage.isEmpty ? nil : audioLanguage) library: libraryID.flatMap { perLibrary[$0] },
general: audioLanguage,
return AudioTrackChooser.Wish(
language: ausdrücklich,
fallbacks: fallbacks, fallbacks: fallbacks,
remembered: seriesID.flatMap { rememberedTracks[$0] }, remembered: seriesID.flatMap { rememberedTracks[$0] },
avoidsAudioDescription: avoidsAudioDescription avoidsAudioDescription: avoidsAudioDescription
+12
View File
@@ -29,6 +29,18 @@ struct SeriesDetailView: View {
if !item.genres.isEmpty { TagStrip(tags: item.genres) } 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 childList
if !item.cast.isEmpty { CastRow(cast: item.cast) } if !item.cast.isEmpty { CastRow(cast: item.cast) }
+25 -6
View File
@@ -5,7 +5,13 @@ import MediaKit
/// Filter und Sortierung einer Mediathek. /// Filter und Sortierung einer Mediathek.
struct LibraryFilterSheet: View { struct LibraryFilterSheet: View {
@Binding var query: MediaQuery @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(\.dismiss) private var dismiss
@Environment(\.flixyAccent) private var accent @Environment(\.flixyAccent) private var accent
@@ -18,9 +24,9 @@ struct LibraryFilterSheet: View {
/// bei drei Änderungen hintereinander drei verworfene Ladevorgänge. /// bei drei Änderungen hintereinander drei verworfene Ladevorgänge.
@State private var draft: MediaQuery @State private var draft: MediaQuery
init(query: Binding<MediaQuery>, libraryKind: MediaLibrary.Kind?) { init(query: Binding<MediaQuery>, library: MediaLibrary) {
self._query = query self._query = query
self.libraryKind = libraryKind self.library = library
self._draft = State(initialValue: query.wrappedValue) 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 { Section {
Button("filter.reset", role: .destructive) { Button("filter.reset", role: .destructive) {
draft = MediaQuery(sort: draft.sort) draft = MediaQuery(sort: draft.sort)
@@ -92,9 +113,7 @@ struct LibraryFilterSheet: View {
} }
private func loadGenres() async { private func loadGenres() async {
guard let server = session.backend, guard let server = session.backend else { return }
let library = session.libraries.first(where: { $0.kind == libraryKind })
else { return }
genres = (try? await server.filterOptions(inLibrary: library, field: .genre)) ?? [] genres = (try? await server.filterOptions(inLibrary: library, field: .genre)) ?? []
} }
} }
+1 -1
View File
@@ -64,7 +64,7 @@ struct LibraryView: View {
.flixyInlineTitle() .flixyInlineTitle()
.toolbar { toolbar } .toolbar { toolbar }
.sheet(isPresented: $showsFilters) { .sheet(isPresented: $showsFilters) {
LibraryFilterSheet(query: $query, libraryKind: library.kind) LibraryFilterSheet(query: $query, library: library)
} }
.task(id: library.id) { .task(id: library.id) {
// Beim Wechsel auf eine andere Mediathek zuerst leeren: sonst hält // Beim Wechsel auf eine andere Mediathek zuerst leeren: sonst hält
+11 -5
View File
@@ -198,11 +198,17 @@ final class PlayerModel {
/// Vorrat hieße dort eine halbe Minute Verzug und ein Umschalten, das /// 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. /// sich zäh anfühlt. Eine Datei von der Platte braucht keinen Vorrat.
/// ///
/// Fünfundzwanzig Sekunden sind ein Anfangswert, kein Messergebnis: genug, /// **Ein Mindestwunsch, keine Obergrenze.** AVPlayer darf mehr holen; wir
/// um ein Loch von einigen Sekunden zu überbrücken, wenig genug, dass der /// sagen ihm nur, dass er sich nicht mit weniger zufriedengeben soll, als
/// Start nicht spürbar später kommt. Die richtige Zahl gehört mit einer /// er von sich aus täte.
/// gedrosselten Leitung ermittelt. ///
private static let forwardBuffer: Duration = .seconds(25) /// 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. /// Wie oft der Zustand des Streams geprüft wird.
/// ///
@@ -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()
}
}
@@ -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 /// Wie eine Spur aussieht — genug, um sie in der nächsten Folge
/// wiederzuerkennen. /// wiederzuerkennen.
/// ///
@@ -200,3 +200,64 @@ struct AudioTrackChooserTests {
#expect(AudioTrackChooser.choose(from: spuren, wish: .init(language: "de"))?.id == "0") #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")
}
}