diff --git a/Flaro/Resources/Localizable.xcstrings b/Flaro/Resources/Localizable.xcstrings index dd6d12f..966c424 100644 --- a/Flaro/Resources/Localizable.xcstrings +++ b/Flaro/Resources/Localizable.xcstrings @@ -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", diff --git a/Flaro/Session/LanguagePreferences.swift b/Flaro/Session/LanguagePreferences.swift index 40ec1d4..cb6787a 100644 --- a/Flaro/Session/LanguagePreferences.swift +++ b/Flaro/Session/LanguagePreferences.swift @@ -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] } diff --git a/Flaro/Views/Detail/PlayableDetailView.swift b/Flaro/Views/Detail/PlayableDetailView.swift index ec38065..96c2150 100644 --- a/Flaro/Views/Detail/PlayableDetailView.swift +++ b/Flaro/Views/Detail/PlayableDetailView.swift @@ -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() + 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, diff --git a/Flaro/Views/Detail/SeriesDetailView.swift b/Flaro/Views/Detail/SeriesDetailView.swift index c9812d2..0ca1e7f 100644 --- a/Flaro/Views/Detail/SeriesDetailView.swift +++ b/Flaro/Views/Detail/SeriesDetailView.swift @@ -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 { - AudioLanguagePicker( - scope: .series(item.id), - title: "languages.forThisSeries" - ) + VStack(alignment: .leading, spacing: FlaroSpacing.snug) { + AudioLanguagePicker( + scope: .series(item.id), + 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 diff --git a/Flaro/Views/Player/PlayerModel.swift b/Flaro/Views/Player/PlayerModel.swift index 19e6302..7b3a4c2 100644 --- a/Flaro/Views/Player/PlayerModel.swift +++ b/Flaro/Views/Player/PlayerModel.swift @@ -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) diff --git a/Flaro/Views/Settings/AudioLanguagePicker.swift b/Flaro/Views/Settings/AudioLanguagePicker.swift index c89b244..f46410a 100644 --- a/Flaro/Views/Settings/AudioLanguagePicker.swift +++ b/Flaro/Views/Settings/AudioLanguagePicker.swift @@ -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 } } diff --git a/Packages/FlaroKit/Sources/FlaroServerKit/FlaroBackend.swift b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroBackend.swift index b18b8d1..6a87e12 100644 --- a/Packages/FlaroKit/Sources/FlaroServerKit/FlaroBackend.swift +++ b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroBackend.swift @@ -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, diff --git a/Packages/FlaroKit/Sources/FlaroServerKit/FlaroContracts.swift b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroContracts.swift index 54b1f6a..08447e1 100644 --- a/Packages/FlaroKit/Sources/FlaroServerKit/FlaroContracts.swift +++ b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroContracts.swift @@ -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 diff --git a/Packages/FlaroKit/Sources/MediaKit/MediaBackend.swift b/Packages/FlaroKit/Sources/MediaKit/MediaBackend.swift index cbcd157..9942e1a 100644 --- a/Packages/FlaroKit/Sources/MediaKit/MediaBackend.swift +++ b/Packages/FlaroKit/Sources/MediaKit/MediaBackend.swift @@ -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 + } +}