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:
co-authored by
Claude Opus 5
parent
596a143545
commit
983015b9ad
@@ -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": {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
|||||||
@@ -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)) ?? []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user