Sprachwahl in der App — bei Serien und bei Filmen
Auf der Serienseite stand ein Menü für den Ton, gefüllt mit zwanzig Sprachen, von denen die Serie zwei hatte. Für Untertitel gab es keines, und auf der Filmseite überhaupt nichts. Jetzt beides an beiden Orten, und zwar nur mit dem, was es wirklich gibt: Bei einer **Serie** über den neuen Endpunkt — die Schnittmenge über alle Folgen. Was dadurch wegfällt, steht als Satz darunter, sonst sähe eine gekürzte Liste aus wie die ganze Wahrheit. Bei einem **Film** aus den Spuren der Datei. Kein Serverweg nötig: dort ist die Datei der Titel, und ihre Spuren stehen in der Antwort, die die Seite ohnehin geholt hat. Ton und Untertitel teilen sich einen Typ. Sie unterscheiden sich in genau zwei Punkten — woher der Wert kommt und ob es „Keine" gibt; ein zweiter Typ wäre dieselbe Datei mit zwei geänderten Zeilen. „Keine" gibt es nur beim Untertitel: ein Film ohne Tonspur ist kein Wunsch, sondern ein Fehler. Die Untertitelsprache liegt je Titel, in einem Topf für Serien und Filme — eine Filmkennung kollidiert mit keiner Serie, und zwei Wörterbücher für dieselbe Frage liefen auseinander. Der Spieler liest sie jetzt: vorher kannte er nur die allgemeine, und eine Serie im Original mit deutschen Untertiteln liess sich nicht einstellen, ohne sie überall einzuschalten. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
95d7eee8c3
commit
84612335ec
@@ -2621,6 +2621,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"languages.forThisTitle": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Tonsprache für diesen Titel"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Audio language for this title"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"languages.headerExplanation": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
@@ -2655,6 +2672,40 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"languages.none": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Keine"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "None"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"languages.partial %lld": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Nur Sprachen, die in jeder Folge vorkommen. %lld weitere gibt es nur in einem Teil."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Only languages present in every episode. %lld more exist in some episodes only."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"languages.serverDefault": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
@@ -2704,6 +2755,40 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"languages.subtitlesForThisSeries": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Untertitel für diese Serie"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Subtitles for this series"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"languages.subtitlesForThisTitle": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Untertitel für diesen Titel"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Subtitles for this title"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"languages.subtitlesHeader": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
@@ -5039,6 +5124,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"season.short %@": {
|
||||
"comment": "A label that shows the number of the selected season.",
|
||||
"isCommentAutoGenerated": true
|
||||
},
|
||||
"season.short %lld": {
|
||||
"comment": "Platzhalter: Staffelnummer",
|
||||
"extractionState": "stale",
|
||||
|
||||
@@ -34,6 +34,7 @@ final class LanguagePreferences {
|
||||
static let avoidsAudioDescription = "flaro.languages.avoidsAudioDescription"
|
||||
static let perLibrary = "flaro.languages.library"
|
||||
static let perSeries = "flaro.languages.series"
|
||||
static let perSeriesSubtitles = "flaro.languages.seriesSubtitles"
|
||||
static let rememberedTracks = "flaro.languages.seriesTrack"
|
||||
}
|
||||
|
||||
@@ -98,6 +99,14 @@ final class LanguagePreferences {
|
||||
/// deutsch ausgezeichnete Spur die mit Audiodeskription und die als
|
||||
/// englisch ausgezeichnete die eigentliche deutsche. Eine Sprache
|
||||
/// abzulegen hiesse dort, den Fehler festzuschreiben.
|
||||
/// Die Untertitelsprache je Titel — Serie oder Film.
|
||||
///
|
||||
/// **Ein Topf für beide.** Die Kennung eines Films kollidiert mit keiner
|
||||
/// Serie; zwei Wörterbücher für dieselbe Frage liefen auseinander.
|
||||
private var perSeriesSubtitles: [String: String] {
|
||||
didSet { defaults.set(perSeriesSubtitles, forKey: Key.perSeriesSubtitles) }
|
||||
}
|
||||
|
||||
private var rememberedTracks: [String: AudioTrackChooser.TrackSignature] {
|
||||
didSet {
|
||||
guard let daten = try? JSONEncoder().encode(rememberedTracks) else { return }
|
||||
@@ -117,6 +126,8 @@ final class LanguagePreferences {
|
||||
(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.perSeriesSubtitles =
|
||||
defaults.dictionary(forKey: Key.perSeriesSubtitles) as? [String: String] ?? [:]
|
||||
self.rememberedTracks = (defaults.data(forKey: Key.rememberedTracks)).flatMap {
|
||||
try? JSONDecoder().decode([String: AudioTrackChooser.TrackSignature].self, from: $0)
|
||||
} ?? [:]
|
||||
@@ -136,6 +147,34 @@ final class LanguagePreferences {
|
||||
perSeries[id] = code
|
||||
}
|
||||
|
||||
/// Was „keine Untertitel" im Speicher heisst.
|
||||
///
|
||||
/// **Abgewählt ist eine Aussage, kein Fehlen einer.** Wer sie für diese
|
||||
/// Serie abgeschaltet hat, will sie auch in Folge 7 nicht; ein fehlender
|
||||
/// Eintrag hiesse dagegen „nie etwas gesagt", und dann darf eine
|
||||
/// erzwungene Spur durchaus erscheinen.
|
||||
static let noSubtitles = "keine"
|
||||
|
||||
func subtitleLanguage(forSeries id: String) -> String? { perSeriesSubtitles[id] }
|
||||
|
||||
func setSubtitleLanguage(_ code: String?, forSeries id: String) {
|
||||
perSeriesSubtitles[id] = code
|
||||
}
|
||||
|
||||
/// Welche Untertitelsprache für diesen Titel gilt — `nil` heisst: keine.
|
||||
///
|
||||
/// **Die Ebene schlägt das Allgemeine.** Dieselbe Rangfolge wie beim Ton,
|
||||
/// nur ohne Mediathek: eine Untertitelsprache je Mediathek hat noch
|
||||
/// niemand verlangt, und eine Einstellung, die niemand sucht, ist eine
|
||||
/// Stelle mehr, an der etwas auseinanderlaufen kann.
|
||||
func subtitleWish(titleID: String?) -> String? {
|
||||
if let eigen = titleID.flatMap({ perSeriesSubtitles[$0] }) {
|
||||
return eigen == Self.noSubtitles ? nil : eigen
|
||||
}
|
||||
|
||||
return subtitleLanguage.isEmpty ? nil : subtitleLanguage
|
||||
}
|
||||
|
||||
func rememberedTrack(forSeries id: String) -> AudioTrackChooser.TrackSignature? {
|
||||
rememberedTracks[id]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import SwiftUI
|
||||
import FlaroDesign
|
||||
import MediaKit
|
||||
import FlaroPlayback
|
||||
|
||||
/// Die Detailseite von etwas, das sich direkt abspielen lässt — Film oder
|
||||
/// einzelne Folge.
|
||||
@@ -43,6 +44,39 @@ struct PlayableDetailView: View {
|
||||
TagStrip(tags: item.genres)
|
||||
}
|
||||
|
||||
// **Die Sprache vor dem Start, nicht erst mitten im Film.**
|
||||
//
|
||||
// Im Spieler umzustellen heisst, den Strom neu aufzubauen —
|
||||
// und bei einem Film, den man immer im Original sehen will,
|
||||
// jedes Mal von Neuem. Welche Spuren es gibt, steht hier
|
||||
// ohnehin schon.
|
||||
//
|
||||
// **Nur bei einem Film.** Bei einer Folge gilt, was für die
|
||||
// Serie eingestellt ist; dieselbe Frage zwanzigmal zu
|
||||
// stellen wäre keine Wahl, sondern eine Prüfung der Geduld.
|
||||
if item.kind != .episode, let angebot = sprachen {
|
||||
VStack(alignment: .leading, spacing: FlaroSpacing.snug) {
|
||||
if !angebot.audio.isEmpty {
|
||||
AudioLanguagePicker(
|
||||
scope: .series(item.id),
|
||||
title: "languages.forThisTitle",
|
||||
kind: .audio,
|
||||
available: angebot.audio
|
||||
)
|
||||
}
|
||||
|
||||
if !angebot.subtitles.isEmpty {
|
||||
AudioLanguagePicker(
|
||||
scope: .series(item.id),
|
||||
title: "languages.subtitlesForThisTitle",
|
||||
kind: .subtitles,
|
||||
available: angebot.subtitles
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||||
}
|
||||
|
||||
if !item.cast.isEmpty {
|
||||
CastRow(cast: item.cast)
|
||||
}
|
||||
@@ -67,6 +101,41 @@ struct PlayableDetailView: View {
|
||||
return item.versions[selectedVersion]
|
||||
}
|
||||
|
||||
/// Welche Sprachen in dieser Datei liegen.
|
||||
///
|
||||
/// **Kein Serverweg nötig.** Bei einem Film ist die Datei der Titel; ihre
|
||||
/// Spuren stehen in der Antwort, die diese Seite ohnehin geholt hat. Nur
|
||||
/// bei einer Serie muss gerechnet werden, und dafür gibt es den
|
||||
/// Endpunkt.
|
||||
private var sprachen: AvailableLanguages? {
|
||||
guard let part = currentMedia?.files.first else { return nil }
|
||||
|
||||
return AvailableLanguages(
|
||||
audio: optionen(part.audioStreams),
|
||||
subtitles: optionen(part.subtitleStreams),
|
||||
partial: 0)
|
||||
}
|
||||
|
||||
/// Aus Spuren werden Sprachen — jede einmal, auch bei zwei Tonspuren.
|
||||
private func optionen(_ spuren: [MediaStream]) -> [LanguageOption] {
|
||||
var gesehen = Set<String>()
|
||||
var liste: [LanguageOption] = []
|
||||
|
||||
for spur in spuren {
|
||||
guard let roh = spur.languageCode ?? spur.language,
|
||||
let code = LanguageCode.normalized(roh),
|
||||
gesehen.insert(code).inserted
|
||||
else { continue }
|
||||
|
||||
let name = Locale.current.localizedString(forLanguageCode: code)?.capitalized
|
||||
?? spur.language
|
||||
?? code.uppercased()
|
||||
liste.append(LanguageOption(code: code, name: name))
|
||||
}
|
||||
|
||||
return liste
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HeroBanner(
|
||||
title: item.rowTitle,
|
||||
|
||||
@@ -40,6 +40,10 @@ struct SeriesDetailView: View {
|
||||
/// kam, ohne dass irgendwo stünde, dass dazwischen eine Staffel endet.
|
||||
@State private var gewaehlteStaffel: Int?
|
||||
|
||||
/// Welche Sprachen diese Serie durchgehend führt — `nil`, solange es
|
||||
/// niemand weiss.
|
||||
@State private var sprachen: AvailableLanguages?
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: FlaroSpacing.section) {
|
||||
@@ -61,11 +65,35 @@ struct SeriesDetailView: View {
|
||||
// dieselbe Einstellung zu haben, von denen einer nie gelesen
|
||||
// wird.
|
||||
if item.kind == .show {
|
||||
VStack(alignment: .leading, spacing: FlaroSpacing.snug) {
|
||||
AudioLanguagePicker(
|
||||
scope: .series(item.id),
|
||||
title: "languages.forThisSeries"
|
||||
title: "languages.forThisSeries",
|
||||
kind: .audio,
|
||||
available: sprachen?.audio
|
||||
)
|
||||
|
||||
AudioLanguagePicker(
|
||||
scope: .series(item.id),
|
||||
title: "languages.subtitlesForThisSeries",
|
||||
kind: .subtitles,
|
||||
available: sprachen?.subtitles
|
||||
)
|
||||
|
||||
// **Was fehlt, wird gesagt.** Eine gekürzte Liste
|
||||
// sieht sonst aus wie die ganze Wahrheit, und wer
|
||||
// eine Sprache vermisst, die er in Staffel 1 gesehen
|
||||
// hat, hält es für einen Fehler.
|
||||
if let partial = sprachen?.partial, partial > 0 {
|
||||
Text("languages.partial \(partial)")
|
||||
.font(.flaroCaption)
|
||||
.foregroundStyle(Color.flaroTextMuted)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||||
.task(id: item.id) {
|
||||
sprachen = await session.backend?.availableLanguages(inSeries: item.id)
|
||||
}
|
||||
}
|
||||
|
||||
childList
|
||||
|
||||
@@ -2121,9 +2121,15 @@ final class PlayerModel {
|
||||
/// Nur beim ersten Mal: hat jemand während der Wiedergabe umgestellt, darf
|
||||
/// eine später gemeldete Spurliste das nicht zurücksetzen.
|
||||
private func applyPreferredLanguages() {
|
||||
guard selectedSubtitleTrack == nil, !languages.subtitleLanguage.isEmpty,
|
||||
// **Die Ebene schlägt das Allgemeine.** Hier stand nur die
|
||||
// allgemeine Sprache; eine Serie, die man im Original mit deutschen
|
||||
// Untertiteln sieht, liess sich damit nicht einstellen, ohne sie für
|
||||
// alles andere auch einzuschalten. Bei einer Folge gilt die Serie,
|
||||
// bei einem Film der Titel selbst.
|
||||
guard selectedSubtitleTrack == nil,
|
||||
let wunsch = languages.subtitleWish(titleID: item?.seriesID ?? item?.id),
|
||||
let treffer = subtitleTracks.first(where: {
|
||||
LanguageCode.matches(languages.subtitleLanguage, $0.languageCode)
|
||||
LanguageCode.matches(wunsch, $0.languageCode)
|
||||
})
|
||||
else { return }
|
||||
selectSubtitleTrack(treffer)
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import SwiftUI
|
||||
import FlaroDesign
|
||||
import MediaKit
|
||||
|
||||
/// „Welche Tonsprache gilt hier?" — an genau zwei Orten dieselbe Frage.
|
||||
/// „Welche Sprache gilt hier?" — an mehreren 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.
|
||||
/// **Warum ein gemeinsamer Typ.** Die Frage steht auf der Serienseite, auf
|
||||
/// der Filmseite und bei der Mediathek, und die Antwort bedeutet jedes Mal
|
||||
/// dasselbe: diese Ebene setzt sich gegen die darüber durch, oder sie tut es
|
||||
/// nicht. Mehrere Auswahlknöpfe mit demselben Inhalt liefen unweigerlich
|
||||
/// auseinander — einer bekäme eine Sprache dazu, der andere nicht.
|
||||
///
|
||||
/// **Ton und Untertitel im selben Typ.** Sie unterscheiden sich in genau
|
||||
/// zwei Punkten: woher der Wert kommt, und ob es „keine" gibt. Ein zweiter
|
||||
/// Typ dafür wäre dieselbe Datei mit zwei geänderten Zeilen.
|
||||
///
|
||||
/// **Ein Menü, kein `Picker`.** Die Serienseite ist eine `ScrollView`, kein
|
||||
/// `Form`; dort hätte ein `Picker` kein Aussehen, auf das man sich verlassen
|
||||
@@ -15,12 +20,28 @@ import FlaroDesign
|
||||
struct AudioLanguagePicker: View {
|
||||
/// Für welche Ebene gewählt wird.
|
||||
enum Scope {
|
||||
/// Eine Serie oder ein Film — beides eine Kennung, beides ein Titel.
|
||||
case series(String)
|
||||
case library(String)
|
||||
}
|
||||
|
||||
/// Ton oder Text.
|
||||
enum Kind {
|
||||
case audio
|
||||
case subtitles
|
||||
}
|
||||
|
||||
let scope: Scope
|
||||
let title: LocalizedStringKey
|
||||
var kind: Kind = .audio
|
||||
|
||||
/// Welche Sprachen es wirklich gibt — `nil` heisst: alle anbieten.
|
||||
///
|
||||
/// **Eine Liste mit zwanzig Sprachen für eine Serie mit zweien** ist
|
||||
/// keine Auswahl, sondern eine Suche. Weiss niemand, welche es gibt,
|
||||
/// bleibt es bei der vollen Liste: eine zu lange ist besser als eine
|
||||
/// leere.
|
||||
var available: [LanguageOption]?
|
||||
|
||||
@Environment(LanguagePreferences.self) private var languages
|
||||
@Environment(\.flaroAccent) private var accent
|
||||
@@ -43,13 +64,31 @@ struct AudioLanguagePicker: View {
|
||||
Label("languages.inherit", systemImage: current == nil ? "checkmark" : "")
|
||||
}
|
||||
|
||||
// **„Keine" nur beim Untertitel.** Beim Ton gibt es sie
|
||||
// nicht: ein Film ohne Tonspur ist kein Wunsch, sondern ein
|
||||
// Fehler.
|
||||
if kind == .subtitles {
|
||||
Button {
|
||||
set(LanguagePreferences.noSubtitles)
|
||||
} label: {
|
||||
Label(
|
||||
"languages.none",
|
||||
systemImage: current == LanguagePreferences.noSubtitles
|
||||
? "checkmark" : ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
ForEach(LanguagePreferences.offeredCodes, id: \.self) { code in
|
||||
ForEach(options, id: \.code) { option in
|
||||
Button {
|
||||
set(code)
|
||||
set(option.code)
|
||||
} label: {
|
||||
Label(name(of: code), systemImage: current == code ? "checkmark" : "")
|
||||
Label(
|
||||
option.name,
|
||||
systemImage: current == option.code ? "checkmark" : ""
|
||||
)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
@@ -64,21 +103,35 @@ struct AudioLanguagePicker: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Was zur Wahl steht — das Vorhandene, sonst die allgemeine Liste.
|
||||
private var options: [LanguageOption] {
|
||||
available ?? LanguagePreferences.offeredCodes.map {
|
||||
LanguageOption(code: $0, name: name(of: $0))
|
||||
}
|
||||
}
|
||||
|
||||
private var current: String? {
|
||||
switch scope {
|
||||
case .series(let id): languages.audioLanguage(forSeries: id)
|
||||
case .library(let id): languages.audioLanguage(forLibrary: id)
|
||||
switch (scope, kind) {
|
||||
case (.series(let id), .audio): languages.audioLanguage(forSeries: id)
|
||||
case (.series(let id), .subtitles): languages.subtitleLanguage(forSeries: id)
|
||||
case (.library(let id), .audio): languages.audioLanguage(forLibrary: id)
|
||||
// Je Mediathek gibt es keine Untertitelsprache — siehe `subtitleWish`.
|
||||
case (.library, .subtitles): nil
|
||||
}
|
||||
}
|
||||
|
||||
private var currentLabel: String {
|
||||
current.map(name(of:)) ?? String(localized: "languages.inherit")
|
||||
guard let current else { return String(localized: "languages.inherit") }
|
||||
if current == LanguagePreferences.noSubtitles { return String(localized: "languages.none") }
|
||||
return options.first { $0.code == current }?.name ?? name(of: current)
|
||||
}
|
||||
|
||||
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)
|
||||
switch (scope, kind) {
|
||||
case (.series(let id), .audio): languages.setAudioLanguage(code, forSeries: id)
|
||||
case (.series(let id), .subtitles): languages.setSubtitleLanguage(code, forSeries: id)
|
||||
case (.library(let id), .audio): languages.setAudioLanguage(code, forLibrary: id)
|
||||
case (.library, .subtitles): break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -147,6 +147,18 @@ public actor FlaroBackend: MediaBackend {
|
||||
items: titel)
|
||||
}
|
||||
|
||||
public func availableLanguages(inSeries id: String) async -> AvailableLanguages? {
|
||||
guard let roh = try? await client.send(
|
||||
FlaroRequest(path: "/api/v1/series/\(id)/languages"),
|
||||
as: Wire.Sprachauswahl.self)
|
||||
else { return nil }
|
||||
|
||||
return AvailableLanguages(
|
||||
audio: roh.audio.map { LanguageOption(code: $0.code, name: $0.name) },
|
||||
subtitles: roh.subtitles.map { LanguageOption(code: $0.code, name: $0.name) },
|
||||
partial: roh.partial)
|
||||
}
|
||||
|
||||
public func items(
|
||||
inLibrary library: MediaLibrary,
|
||||
query: MediaQuery,
|
||||
|
||||
@@ -212,6 +212,17 @@ enum Wire {
|
||||
let subtitleStreams: [Stromspur]
|
||||
}
|
||||
|
||||
struct Sprachangabe: Decodable, Sendable {
|
||||
let code: String
|
||||
let name: String
|
||||
}
|
||||
|
||||
struct Sprachauswahl: Decodable, Sendable {
|
||||
let audio: [Sprachangabe]
|
||||
let subtitles: [Sprachangabe]
|
||||
let partial: Int
|
||||
}
|
||||
|
||||
struct LoginRequest: Encodable, Sendable {
|
||||
let userName: String
|
||||
let password: String
|
||||
|
||||
@@ -60,6 +60,9 @@ public protocol MediaBackend: Sendable {
|
||||
/// dreizehn Anfragen — für einen Klick auf eine Kachel ist das zu viel.
|
||||
/// Ein Server, der es in einer Anfrage kann, soll das dürfen.
|
||||
func episodes(inSeries id: String) async throws(MediaBackendError) -> [MediaItem]
|
||||
|
||||
/// Welche Sprachen eine Serie durchgehend führt — für die Vorwahl.
|
||||
func availableLanguages(inSeries id: String) async -> AvailableLanguages?
|
||||
/// Die nächste Folge nach dieser, sofern es eine gibt.
|
||||
func nextEpisode(after item: MediaItem) async throws(MediaBackendError) -> MediaItem?
|
||||
|
||||
@@ -227,6 +230,13 @@ public extension MediaBackend {
|
||||
subtitleStreamIndex: Int?
|
||||
) -> URL? { nil }
|
||||
|
||||
/// Welche Sprachen eine Serie durchgehend führt.
|
||||
///
|
||||
/// **`nil` heisst: der Server weiss es nicht.** Dann bleibt es bei der
|
||||
/// vollen Liste — eine leere Auswahl anzubieten wäre schlimmer als eine
|
||||
/// zu lange.
|
||||
func availableLanguages(inSeries id: String) async -> AvailableLanguages? { nil }
|
||||
|
||||
/// Der allgemeine Weg: Staffeln, dann je Staffel die Folgen.
|
||||
///
|
||||
/// Langsam, aber überall gültig — wer es besser kann, überschreibt es.
|
||||
@@ -261,3 +271,41 @@ public struct MediaFilterOption: Sendable, Equatable, Identifiable {
|
||||
self.title = title
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Eine Sprache, wie sie in einem Menü steht.
|
||||
public struct LanguageOption: Sendable, Equatable, Identifiable, Hashable {
|
||||
public let code: String
|
||||
public let name: String
|
||||
|
||||
public var id: String { code }
|
||||
|
||||
public init(code: String, name: String) {
|
||||
self.code = code
|
||||
self.name = name
|
||||
}
|
||||
}
|
||||
|
||||
/// Welche Sprachen ein Titel durchgehend führt.
|
||||
///
|
||||
/// **Durchgehend, nicht irgendwo.** Bei einer Serie ist das die
|
||||
/// Schnittmenge über alle Folgen: eine Sprache anzubieten, die es nur in
|
||||
/// Staffel 1 gibt, hiesse eine Einstellung für die Serie, die ab Staffel 2
|
||||
/// stillschweigend etwas anderes tut.
|
||||
public struct AvailableLanguages: Sendable, Equatable {
|
||||
public let audio: [LanguageOption]
|
||||
public let subtitles: [LanguageOption]
|
||||
|
||||
/// Wie viele Sprachen es nur in einem Teil der Folgen gibt.
|
||||
///
|
||||
/// **Damit die Oberfläche es sagen kann.** Eine gekürzte Liste sieht
|
||||
/// sonst aus wie die ganze Wahrheit, und wer eine Sprache vermisst, die
|
||||
/// er in Staffel 1 gesehen hat, hält es für einen Fehler.
|
||||
public let partial: Int
|
||||
|
||||
public init(audio: [LanguageOption], subtitles: [LanguageOption], partial: Int) {
|
||||
self.audio = audio
|
||||
self.subtitles = subtitles
|
||||
self.partial = partial
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user