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
+12
View File
@@ -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) }
+25 -6
View File
@@ -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<MediaQuery>, libraryKind: MediaLibrary.Kind?) {
init(query: Binding<MediaQuery>, 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)) ?? []
}
}
+1 -1
View File
@@ -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
+11 -5
View File
@@ -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.
///
@@ -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()
}
}