diff --git a/Flixy/FlixyApp.swift b/Flixy/FlixyApp.swift index 1f7a026..4150fd7 100644 --- a/Flixy/FlixyApp.swift +++ b/Flixy/FlixyApp.swift @@ -28,6 +28,7 @@ struct FlixyApp: App { @State private var session: FlixySession @State private var playback = PlaybackCoordinator() @State private var playbackSettings = PlaybackSettings() + @State private var languages = LanguagePreferences() /// Beobachtet WLAN und Mobilfunk. Lebt hier, damit der Beobachter einmal /// läuft statt bei jedem Öffnen des Players neu zu starten. @State private var network = NetworkConditions() @@ -86,6 +87,7 @@ struct FlixyApp: App { .environment(session) .environment(playback) .environment(playbackSettings) + .environment(languages) .environment(network) .environment(downloads) .environment(homeLayout) diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings index 16dde82..870b848 100644 --- a/Flixy/Resources/Localizable.xcstrings +++ b/Flixy/Resources/Localizable.xcstrings @@ -5,6 +5,10 @@ "comment": "A separator between different parts of the episode metadata.", "isCommentAutoGenerated": true }, + "%@ 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.", + "isCommentAutoGenerated": true + }, "%@ → %@": { "comment": "A line in a diagnostic trace that shows a network request path and its result. The first argument is the network request path. The second argument is the network response result.", "isCommentAutoGenerated": true, @@ -17,20 +21,20 @@ } } }, - "%@ 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.", - "isCommentAutoGenerated": true - }, "4K": { "comment": "A label for a 4K video.", "isCommentAutoGenerated": true }, - "720p": { - "comment": "A label for a 720p video.", + "Full HD": { + "comment": "A label for a media resolution.", "isCommentAutoGenerated": true }, - "1080p": { - "comment": "A label for a 1080p video.", + "HD": { + "comment": "A label for a media resolution.", + "isCommentAutoGenerated": true + }, + "SD": { + "comment": "A label for a 720p movie.", "isCommentAutoGenerated": true }, "account.hint": { @@ -2195,6 +2199,108 @@ } } }, + "languages.avoidAudioDescription": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Audiodeskription meiden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Avoid audio description" + } + } + } + }, + "languages.avoidAudioDescriptionExplanation": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Spuren, die eine Szenenbeschreibung für Sehbehinderte enthalten, werden nur genommen, wenn es keine andere in dieser Sprache gibt. Erkannt wird das am Namen der Spur — der Server kennzeichnet es nicht." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Tracks that describe the scene for blind viewers are only used when there is no other track in that language. They are recognised by name — the server doesn't flag them." + } + } + } + }, + "languages.fallbackAdd": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Sprache hinzufügen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Add language" + } + } + } + }, + "languages.fallbackEmpty": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Ausweichsprachen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No fallback languages" + } + } + } + }, + "languages.fallbackExplanation": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Gilt nur, wenn ein Titel die gewünschte Tonsprache gar nicht hat. Dann wird von oben nach unten gesucht." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Applies only when a title doesn't have your preferred audio language at all. Then it works down the list." + } + } + } + }, + "languages.fallbackHeader": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Rangliste" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Fallback order" + } + } + } + }, "languages.headerExplanation": { "extractionState": "stale", "localizations": { @@ -2922,6 +3028,23 @@ } } }, + "liveTV.recordSeries": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Serie aufnehmen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Record series" + } + } + } + }, "liveTV.recording.inProgress": { "extractionState": "manual", "localizations": { @@ -2990,23 +3113,6 @@ } } }, - "liveTV.recordSeries": { - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Serie aufnehmen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Record series" - } - } - } - }, "liveTV.runningNow": { "extractionState": "stale", "localizations": { @@ -3309,6 +3415,23 @@ } } }, + "player.forgetRememberedTrack": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Nicht mehr merken" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Stop remembering" + } + } + } + }, "player.forward10": { "localizations": { "de": { @@ -3511,6 +3634,40 @@ } } }, + "player.rememberedTrack": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Für diese Serie gemerkt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Remembered for this series" + } + } + } + }, + "player.rememberedTrackExplanation %@": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Seit du „%@“ gewählt hast, wird diese Spur in jeder Folge genommen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Since you picked “%@”, that track is used for every episode." + } + } + } + }, "player.restoring": { "localizations": { "de": { @@ -3611,6 +3768,23 @@ } } }, + "player.subtitleTracks": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Untertitel" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Subtitles" + } + } + } + }, "player.subtitlesOff": { "comment": "Eintrag, der die Untertitel abschaltet", "extractionState": "stale", @@ -3629,23 +3803,6 @@ } } }, - "player.subtitleTracks": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Untertitel" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Subtitles" - } - } - } - }, "player.tracks": { "extractionState": "stale", "localizations": { @@ -3730,23 +3887,6 @@ } } }, - "quality.automatic": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Automatisch" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Automatic" - } - } - } - }, "quality.autoShort": { "extractionState": "stale", "localizations": { @@ -3764,6 +3904,23 @@ } } }, + "quality.automatic": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Automatisch" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Automatic" + } + } + } + }, "quality.current": { "localizations": { "de": { @@ -4190,10 +4347,6 @@ } } }, - "SD": { - "comment": "A label for a 720p movie.", - "isCommentAutoGenerated": true - }, "search.hint": { "localizations": { "de": { diff --git a/Flixy/Session/LanguagePreferences.swift b/Flixy/Session/LanguagePreferences.swift new file mode 100644 index 0000000..b3fcc9f --- /dev/null +++ b/Flixy/Session/LanguagePreferences.swift @@ -0,0 +1,184 @@ +import Foundation +import Observation +import MediaKit +import FlixyPlayback + +/// Welche Tonspur gewählt werden soll — und auf welcher Ebene das jemand +/// gesagt hat. +/// +/// **Drei Ebenen, eine Frage.** +/// +/// 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 ein eigener Typ und nicht in `PlaybackSettings`.** Weil das hier +/// keine Einstellung ist, sondern ein kleines Regelwerk: drei Ebenen, eine +/// Rangliste darunter, und ein Gedächtnis, das sich ohne Nachfrage füllt. Das +/// neben Sprungweiten und Abspielgeschwindigkeit zu legen hiesse, beim Lesen +/// jedes Mal beides gleichzeitig im Kopf zu haben. +/// +/// **Die Schlüssel der allgemeinen Sprachen bleiben die alten.** Wer sie +/// eingestellt hat, soll sie nach dem Umzug wiederfinden — ein Umzug, den +/// niemand bemerkt, ist ein guter Umzug. +@MainActor +@Observable +final class LanguagePreferences { + private enum Key { + /// Absichtlich die Schlüssel aus `PlaybackSettings`: hier ist nur der + /// Besitzer gewechselt, nicht der Wert. + static let audio = "flixy.playback.audioLanguage" + static let subtitles = "flixy.playback.subtitleLanguage" + static let fallbacks = "flixy.languages.fallbacks" + static let avoidsAudioDescription = "flixy.languages.avoidsAudioDescription" + static let perLibrary = "flixy.languages.library" + static let perSeries = "flixy.languages.series" + static let rememberedTracks = "flixy.languages.seriesTrack" + } + + /// Die Sprachen, die zur Auswahl stehen. + /// + /// Keine vollständige Liste aller Sprachen der Welt: ein Auswahlrad mit + /// zweihundert Einträgen, um „Deutsch" zu finden, ist keine Hilfe. + /// Schweizerdeutsch steht dabei, weil es in dieser Sammlung vorkommt und + /// **keine** Schreibweise von Deutsch ist — wer es will, muss es nennen + /// können. + static let offeredCodes = [ + "de", "gsw", "en", "fr", "it", "es", "nl", "pt", "pl", "ru", "sv", + "da", "no", "fi", "tr", "ja", "ko", "zh", "cs", "hu", + ] + + private let defaults: UserDefaults + + // MARK: - Allgemein + + var audioLanguage: String { + didSet { defaults.set(audioLanguage, forKey: Key.audio) } + } + + var subtitleLanguage: String { + didSet { defaults.set(subtitleLanguage, forKey: Key.subtitles) } + } + + /// Die Rangliste für den Fall, dass die Wunschsprache fehlt. + /// + /// **Nur einmal, für alles.** Sie ist keine dritte Einstellung je + /// Mediathek oder Serie: dort steht der *Wunsch*, die Rangliste ist der + /// Boden darunter und gilt überall gleich. Eine Reihenfolge je Mediathek + /// zu pflegen wäre Arbeit für einen Fall, den es nicht gibt. + var fallbacks: [String] { + didSet { defaults.set(fallbacks, forKey: Key.fallbacks) } + } + + /// Ob Spuren mit Audiodeskription gemieden werden. + /// + /// An, und das ist die richtige Vorgabe: wer Audiodeskription braucht, + /// weiss das und findet den Schalter. Wer sie nicht braucht, ahnt nicht + /// einmal, dass es sie gibt — und bekäme sie sonst untergeschoben, weil + /// sie in manchen Dateien als die deutsche Spur ausgezeichnet ist. + var avoidsAudioDescription: Bool { + didSet { defaults.set(avoidsAudioDescription, forKey: Key.avoidsAudioDescription) } + } + + // MARK: - Je Mediathek und je Serie + + private var perLibrary: [String: String] { + didSet { defaults.set(perLibrary, forKey: Key.perLibrary) } + } + + private var perSeries: [String: String] { + didSet { defaults.set(perSeries, forKey: Key.perSeries) } + } + + /// Was zuletzt **von Hand** für eine Serie gewählt wurde. + /// + /// Keine Sprache, sondern eine Beschreibung der Spur. Das ist die + /// Kernentscheidung des ganzen Entwurfs: bei manchen Titeln ist die als + /// deutsch ausgezeichnete Spur die mit Audiodeskription und die als + /// englisch ausgezeichnete die eigentliche deutsche. Eine Sprache + /// abzulegen hiesse dort, den Fehler festzuschreiben. + private var rememberedTracks: [String: AudioTrackChooser.TrackSignature] { + didSet { + guard let daten = try? JSONEncoder().encode(rememberedTracks) else { return } + defaults.set(daten, forKey: Key.rememberedTracks) + } + } + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + self.audioLanguage = defaults.string(forKey: Key.audio) ?? "" + self.subtitleLanguage = defaults.string(forKey: Key.subtitles) ?? "" + self.fallbacks = defaults.stringArray(forKey: Key.fallbacks) ?? [] + // `object(forKey:)` statt `bool(forKey:)`: das gäbe für „nie gesetzt" + // ein `false` zurück, und die Vorgabe wäre damit still das Gegenteil + // der beabsichtigten. + self.avoidsAudioDescription = + (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.rememberedTracks = (defaults.data(forKey: Key.rememberedTracks)).flatMap { + try? JSONDecoder().decode([String: AudioTrackChooser.TrackSignature].self, from: $0) + } ?? [:] + } + + // MARK: - Lesen und Setzen je Ebene + + func audioLanguage(forLibrary id: String) -> String? { perLibrary[id] } + + func setAudioLanguage(_ code: String?, forLibrary id: String) { + perLibrary[id] = code + } + + func audioLanguage(forSeries id: String) -> String? { perSeries[id] } + + func setAudioLanguage(_ code: String?, forSeries id: String) { + perSeries[id] = code + } + + func rememberedTrack(forSeries id: String) -> AudioTrackChooser.TrackSignature? { + rememberedTracks[id] + } + + /// Merkt sich, was jemand von Hand gewählt hat — ohne zu fragen. + /// + /// **Ohne Nachfrage**, weil „für diese Serie merken?" bei jeder Umstellung + /// genau die Art Dialog ist, die man nach dem dritten Mal wegklickt. + /// Sichtbar wird es trotzdem: das Spurenblatt zeigt es an und lässt es + /// löschen. Eine automatische Handlung, die man nicht sehen kann, ist von + /// einem Fehler nicht zu unterscheiden. + func remember(_ stream: MediaStream, forSeries id: String) { + rememberedTracks[id] = AudioTrackChooser.TrackSignature(stream) + } + + func forgetTrack(forSeries id: String) { + rememberedTracks[id] = nil + } + + // MARK: - Was dabei herauskommt + + /// Der Wunsch für einen bestimmten Titel. + /// + /// Hier laufen die drei Ebenen zusammen — und zwar **nur** hier. Wer + /// wissen will, in welcher Reihenfolge gefragt wird, liest diese Methode + /// und sonst nichts. + func wish(seriesID: String?, libraryID: String?) -> AudioTrackChooser.Wish { + let ausdrücklich = seriesID.flatMap { perSeries[$0] } + ?? libraryID.flatMap { perLibrary[$0] } + ?? (audioLanguage.isEmpty ? nil : audioLanguage) + + return AudioTrackChooser.Wish( + language: ausdrücklich, + fallbacks: fallbacks, + remembered: seriesID.flatMap { rememberedTracks[$0] }, + avoidsAudioDescription: avoidsAudioDescription + ) + } + + /// Ob überhaupt eine Mediathek ausschlaggebend sein kann. + /// + /// Wer nie eine Sprache je Mediathek gesetzt hat — der Normalfall —, für + /// den braucht auch niemand herauszufinden, in welcher Mediathek ein Titel + /// liegt. Das spart bei jeder Wiedergabe eine Serveranfrage. + var usesLibraryLevel: Bool { !perLibrary.isEmpty } +} diff --git a/Flixy/Session/PlaybackSettings.swift b/Flixy/Session/PlaybackSettings.swift index e59938b..82bf671 100644 --- a/Flixy/Session/PlaybackSettings.swift +++ b/Flixy/Session/PlaybackSettings.swift @@ -36,8 +36,6 @@ final class PlaybackSettings { static let autoSkipIntro = "flixy.playback.autoSkipIntro" static let autoSkipCredits = "flixy.playback.autoSkipCredits" static let autoPlayNext = "flixy.playback.autoPlayNext" - static let preferredAudioLanguage = "flixy.playback.audioLanguage" - static let preferredSubtitleLanguage = "flixy.playback.subtitleLanguage" static let locksLandscape = "flixy.playback.locksLandscape" static let rewindSeconds = "flixy.playback.rewindSeconds" static let forwardSeconds = "flixy.playback.forwardSeconds" @@ -80,8 +78,6 @@ final class PlaybackSettings { var autoSkipCredits: Bool { didSet { defaults.set(autoSkipCredits, forKey: Key.autoSkipCredits) } } var autoPlayNext: Bool { didSet { defaults.set(autoPlayNext, forKey: Key.autoPlayNext) } } /// ISO-Sprachcode, etwa `deu`. Leer heißt: nehmen, was der Server vorgibt. - var preferredAudioLanguage: String { didSet { defaults.set(preferredAudioLanguage, forKey: Key.preferredAudioLanguage) } } - var preferredSubtitleLanguage: String { didSet { defaults.set(preferredSubtitleLanguage, forKey: Key.preferredSubtitleLanguage) } } /// Ob der Player beim Öffnen ins Querformat dreht und dort bleibt. /// @@ -140,8 +136,6 @@ final class PlaybackSettings { self.autoSkipIntro = defaults.object(forKey: Key.autoSkipIntro) as? Bool ?? false self.autoSkipCredits = defaults.object(forKey: Key.autoSkipCredits) as? Bool ?? false self.autoPlayNext = defaults.object(forKey: Key.autoPlayNext) as? Bool ?? true - self.preferredAudioLanguage = defaults.string(forKey: Key.preferredAudioLanguage) ?? "" - self.preferredSubtitleLanguage = defaults.string(forKey: Key.preferredSubtitleLanguage) ?? "" self.locksLandscape = defaults.object(forKey: Key.locksLandscape) as? Bool ?? true self.rewindSeconds = defaults.object(forKey: Key.rewindSeconds) as? Int ?? 10 diff --git a/Flixy/Views/Navigation/RootView.swift b/Flixy/Views/Navigation/RootView.swift index 1f7c60a..b0028cf 100644 --- a/Flixy/Views/Navigation/RootView.swift +++ b/Flixy/Views/Navigation/RootView.swift @@ -15,6 +15,7 @@ struct RootView: View { @Environment(FlixyTheme.self) private var theme @Environment(PlaybackCoordinator.self) private var playback @Environment(PlaybackSettings.self) private var settings + @Environment(LanguagePreferences.self) private var languages @Environment(NetworkConditions.self) private var network @Environment(DownloadCoordinator.self) private var downloads @Environment(PlaybackDiagnostics.self) private var diagnostics @@ -185,6 +186,7 @@ struct RootView: View { diagnostics: diagnostics, policy: settings.policy(for: network), settings: settings, + languages: languages, // **Nicht mehr über `server`.** Der ist ein `PlexServer` und bei // Jellyfin immer `nil` — damit galt dort jede Wiedergabe als // entfernt, auch zuhause am Kabel, und „Auto" hätte ein diff --git a/Flixy/Views/Player/PlayerModel.swift b/Flixy/Views/Player/PlayerModel.swift index f538d9d..bda0f52 100644 --- a/Flixy/Views/Player/PlayerModel.swift +++ b/Flixy/Views/Player/PlayerModel.swift @@ -42,6 +42,17 @@ final class PlayerModel { private(set) var audioTracks: [MediaTrack] = [] private(set) var subtitleTracks: [MediaTrack] = [] private(set) var selectedAudioTrack: MediaTrack? + + /// Die Tonspuren, wie sie **in der Datei** stehen. + /// + /// **Nicht dasselbe wie `audioTracks`.** Die kommen vom laufenden Player, + /// und bei einem umgerechneten Stream ist dort genau eine — der Server + /// hat sich längst für eine entschieden. Im Player wäre dann nichts mehr + /// umzustellen. Gewählt werden muss also aus dem, was die Datei hergibt, + /// und die Wahl muss **vor** dem Aushandeln stattfinden. + private(set) var audioStreams: [MediaStream] = [] + /// Welche davon gerade klingt. + private(set) var selectedAudioStream: MediaStream? private(set) var selectedSubtitleTrack: MediaTrack? /// Die Marke, in der wir uns gerade befinden — Grundlage für den @@ -139,6 +150,7 @@ final class PlayerModel { private let diagnostics: PlaybackDiagnostics private let policy: PlaybackPolicy private let settings: PlaybackSettings + private let languages: LanguagePreferences private let isLocalConnection: Bool /// Wie die Verbindung in der Diagnose heißt. /// @@ -161,6 +173,8 @@ final class PlayerModel { /// ohne die Metadaten erneut zu holen. private var currentMedia: MediaVersion? private var currentPart: MediaFile? + /// In welcher Mediathek der Titel liegt — einmal nachgeschlagen. + private var libraryID: String? private var eventTask: Task? private var reportTask: Task? /// Was der Server für diese Wiedergabe angelegt hat. @@ -334,6 +348,7 @@ final class PlayerModel { diagnostics: PlaybackDiagnostics, policy: PlaybackPolicy, settings: PlaybackSettings, + languages: LanguagePreferences, isLocalConnection: Bool, connectionDescription: String?, unavailableMessage: String @@ -345,6 +360,7 @@ final class PlayerModel { self.diagnostics = diagnostics self.policy = policy self.settings = settings + self.languages = languages self.isLocalConnection = isLocalConnection self.connectionDescription = connectionDescription self.unavailableMessage = unavailableMessage @@ -400,6 +416,7 @@ final class PlayerModel { currentMedia = media currentPart = part + await chooseAudioStream(in: part, of: item, backend: backend) // Die zuletzt gewählte Stufe, falls sie gemerkt werden soll. **Vor** // der Entscheidung, nicht danach: hinterher umzustellen hieße, den @@ -642,7 +659,10 @@ final class PlayerModel { file: part, transcode: transcode, startTime: start, - isLocalConnection: isLocalConnection + isLocalConnection: isLocalConnection, + // Ohne diese Angabe wählt der Server, und bei einem + // umgerechneten Stream kommt danach nur noch **eine** Spur an. + audioStreamIndex: selectedAudioStream?.index )) playbackSession = target.sessionID playbackVersionID = target.versionID @@ -1366,19 +1386,110 @@ final class PlayerModel { } } - /// Wählt die bevorzugten Sprachen, sofern es sie gibt. + /// Wählt die Untertitelspur, sofern eine gewünscht ist. + /// + /// **Nur noch Untertitel.** Die Tonspur wird nicht mehr hier gewählt, + /// sondern in `chooseAudioStream(in:of:backend:)` — also **bevor** der + /// Stream ausgehandelt wird. Hier wäre es zu spät: bei einem umgerechneten + /// Stream meldet der Player genau eine Tonspur, und die hat der Server + /// längst ausgesucht. /// /// Nur beim ersten Mal: hat jemand während der Wiedergabe umgestellt, darf /// eine später gemeldete Spurliste das nicht zurücksetzen. private func applyPreferredLanguages() { - if selectedAudioTrack == nil, !settings.preferredAudioLanguage.isEmpty, - let match = audioTracks.first(where: { $0.matches(language: settings.preferredAudioLanguage) }) { - selectAudioTrack(match) + guard selectedSubtitleTrack == nil, !languages.subtitleLanguage.isEmpty, + let treffer = subtitleTracks.first(where: { + LanguageCode.matches(languages.subtitleLanguage, $0.languageCode) + }) + else { return } + selectSubtitleTrack(treffer) + } + + // MARK: - Die Tonspur + + /// Entscheidet, welche Tonspur bestellt wird. + /// + /// **Vor dem Aushandeln**, und das ist der ganze Punkt: rechnet der Server + /// um, liefert er genau eine Tonspur aus. Wer erst im Player wählen will, + /// hat dann nichts mehr zur Auswahl. Die Entscheidung muss in die Anfrage. + /// + /// Die Abwägung selbst steht in `AudioTrackChooser` — ein reiner Typ ohne + /// Netz und ohne Einstellungen, damit sich der ZDF-Fall wörtlich + /// hinschreiben und durchspielen lässt. Hier steht nur, woher der Wunsch + /// kommt. + private func chooseAudioStream( + in part: MediaFile, + of item: MediaItem, + backend: any MediaBackend + ) async { + audioStreams = part.audioStreams + guard !audioStreams.isEmpty else { + selectedAudioStream = nil + return } - if selectedSubtitleTrack == nil, !settings.preferredSubtitleLanguage.isEmpty, - let match = subtitleTracks.first(where: { $0.matches(language: settings.preferredSubtitleLanguage) }) { - selectSubtitleTrack(match) + + // Die Mediathek nur nachschlagen, wenn überhaupt jemand eine Sprache + // je Mediathek gesetzt hat. Sonst kostete jede Wiedergabe eine + // Anfrage für eine Ebene, die niemand benutzt. + if languages.usesLibraryLevel, libraryID == nil { + libraryID = await backend.libraryID(for: item) } + + selectedAudioStream = AudioTrackChooser.choose( + from: part.streams, + wish: languages.wish(seriesID: item.seriesID, libraryID: libraryID) + ) + + if let gewählt = selectedAudioStream { + diagnostics.note("Tonspur \(gewählt.index.map(String.init) ?? "?"): \(gewählt.name)") + } + } + + /// Stellt auf eine andere Tonspur um. + /// + /// **Und merkt sie sich für die Serie** — ohne zu fragen. Die Frage „für + /// diese Serie merken?" bei jeder Umstellung ist genau die Art Dialog, die + /// man nach dem dritten Mal wegklickt. Sichtbar ist es trotzdem: das + /// Spurenblatt zeigt es an und lässt es löschen. + /// + /// Bei Filmen wird nichts gemerkt — ein Film wird einmal gesehen. + func selectAudioStream(_ stream: MediaStream) async { + guard stream != selectedAudioStream else { return } + selectedAudioStream = stream + + if let seriesID = item?.seriesID { + languages.remember(stream, forSeries: seriesID) + } + + // **Der billige Weg, wenn er offensteht.** Wird die Datei unverändert + // gespielt, liegen alle Spuren im Stream, und der Player schaltet + // ohne Unterbrechung um. Die Zuordnung geht über die Reihenfolge: + // bei unveränderter Wiedergabe reicht AVFoundation die Spuren in der + // Reihenfolge der Datei durch. Stimmt die Anzahl nicht überein, ist + // die Annahme falsch — dann lieber neu aufbauen als die falsche Spur + // schalten. + if case .directPlay = decision?.method, + audioTracks.count == audioStreams.count, + let position = audioStreams.firstIndex(of: stream) { + selectAudioTrack(audioTracks[position]) + return + } + + await restart() + } + + /// Was für diese Serie gemerkt ist — für die Anzeige im Spurenblatt. + var rememberedTrackName: String? { + guard let seriesID = item?.seriesID, + let signatur = languages.rememberedTrack(forSeries: seriesID) + else { return nil } + return signatur.title ?? signatur.languageCode + } + + /// Vergisst die gemerkte Spur dieser Serie. + func forgetRememberedTrack() { + guard let seriesID = item?.seriesID else { return } + languages.forgetTrack(forSeries: seriesID) } private func playerSubtitle(for item: MediaItem) -> String? { @@ -1390,18 +1501,6 @@ final class PlayerModel { } } -extension MediaTrack { - /// Ob die Spur zu einem Sprachcode passt. - /// - /// Verglichen wird nachsichtig: Plex meldet mal `de`, mal `deu`, mal - /// `ger`, und AVFoundation liefert manchmal `de-DE`. Ein strenger - /// Vergleich fände die Spur in der Hälfte der Fälle nicht. - func matches(language code: String) -> Bool { - guard let languageCode, !code.isEmpty else { return false } - let normalized = languageCode.lowercased().prefix(2) - return normalized == code.lowercased().prefix(2) - } -} /// Verbindet Bild-in-Bild mit der App. diff --git a/Flixy/Views/Player/PlayerView.swift b/Flixy/Views/Player/PlayerView.swift index e432ab7..c39f8c0 100644 --- a/Flixy/Views/Player/PlayerView.swift +++ b/Flixy/Views/Player/PlayerView.swift @@ -126,9 +126,22 @@ struct PlayerView: View { .transition(.opacity) } - if showsControls { - controls(for: model).transition(.opacity) - } + // **Deckkraft statt Einfügen und Entfernen.** + // + // Eine Übergangsanimation beim Einfügen hängt daran, + // dass SwiftUI die Ansicht als dieselbe wiedererkennt + // und der Einfügevorgang in einer animierten + // Transaktion steckt. Bleibt eine der beiden + // Bedingungen aus, erscheint die Bedienung schlagartig + // — und genau so sah es aus. Deckkraft hat diese + // Bedingungen nicht: sie ist ein Wert, und Werte + // lassen sich immer überblenden. + controls(for: model) + .opacity(showsControls ? 1 : 0) + // Unsichtbar heisst auch unantastbar. Sonst läge + // der Scrubber weiterhin über dem Bild und + // schluckte jeden Klick. + .allowsHitTesting(showsControls) // Überspringen und „Nächste Folge" liegen außerhalb // der Bedienelemente: sie sollen auch dann erscheinen, // wenn die ausgeblendet sind — sonst müsste man erst @@ -140,11 +153,18 @@ struct PlayerView: View { // Die Kopfzeile liegt über allem und erscheint auch während // des Ladens. Ohne sie sitzt man bei langsamer Verbindung // fest: kein Zurück, kein Abbrechen, nur ein Ladekreis. - if showsControls || !isPlayingPhase(model) { + // Sie hatte bisher gar keine Überblendung: sie sprang + // herein, während die Bedienung darunter verblasste. Zwei + // Hälften derselben Oberfläche, die sich verschieden + // verhalten, lesen sich als „es fadet nicht". + do { + let zeigt = showsControls || !isPlayingPhase(model) VStack { topBar(for: model) Spacer() } + .opacity(zeigt ? 1 : 0) + .allowsHitTesting(zeigt) .padding(FlixySpacing.loose) // **Unter der Fensterleiste beginnen.** // @@ -165,6 +185,17 @@ struct PlayerView: View { // Die Uhrzeit über einem laufenden Film hat dort nichts zu suchen — // und im Querformat erst recht nicht. .animation(.easeInOut(duration: 0.2), value: model.isBuffering) + // **Herein sanft, hinaus zügig.** Das Einblenden begleitet eine + // Bewegung und soll der Hand entgegenkommen; das Ausblenden ist ein + // Aufräumen, und Aufräumen darf man nicht zusehen müssen. + // + // Hier, nicht an jeder einzelnen Umschaltstelle: die gab es sechsmal, + // mit vier verschiedenen Dauern, und eine davon stand an einer Stelle, + // die gar nicht animiert wurde. + .animation( + showsControls ? .easeOut(duration: 0.45) : .easeIn(duration: 0.28), + value: showsControls + ) #if os(iOS) // Die Statusleiste gehört nicht über einen Film. Auf dem Mac gibt es // keine. @@ -239,7 +270,7 @@ struct PlayerView: View { // Ausblendfrist neu starten. Ohne das verschwänden sie sofort, // weil die alte Frist während des Dialogs abgelaufen ist. guard !isShown else { return } - withAnimation(.easeInOut(duration: 0.2)) { showsControls = true } + showsControls = true scheduleControlsHide() } .task { scheduleControlsHide() } @@ -787,7 +818,7 @@ struct PlayerView: View { private func toggleControls() { if showsControls { - withAnimation(.easeInOut(duration: 0.2)) { showsControls = false } + showsControls = false } else { revealControls() } @@ -796,12 +827,8 @@ struct PlayerView: View { /// Zeigt die Bedienelemente und setzt die Ausblendfrist neu. private func revealControls() { if !showsControls { - // **Herein sanft, hinaus zügig.** Das Einblenden begleitet eine - // Bewegung des Zeigers und soll sich anfühlen, als käme die - // Bedienung der Hand entgegen — zwei Zehntel wirken dabei wie ein - // Aufblitzen. Das Ausblenden dagegen ist ein Aufräumen, und - // Aufräumen darf man nicht zusehen müssen. - withAnimation(.easeOut(duration: 0.45)) { showsControls = true } + // Wie überblendet wird, steht am Player selbst — siehe dort. + showsControls = true // Beim Aufdecken nachsehen, was inzwischen läuft — genau dann // will man es wissen. if model.isLive { @@ -832,7 +859,7 @@ struct PlayerView: View { hideTask = Task { try? await Task.sleep(for: .seconds(3.5)) guard !Task.isCancelled, model.isPlaying else { return } - withAnimation(.easeInOut(duration: 0.3)) { showsControls = false } + showsControls = false } } } diff --git a/Flixy/Views/Player/TrackPickerSheet.swift b/Flixy/Views/Player/TrackPickerSheet.swift index 3380455..9570397 100644 --- a/Flixy/Views/Player/TrackPickerSheet.swift +++ b/Flixy/Views/Player/TrackPickerSheet.swift @@ -1,6 +1,7 @@ import SwiftUI import FlixyDesign import FlixyPlayback +import MediaKit /// Auswahl einer Spur — Ton **oder** Untertitel, nicht beides. /// @@ -33,16 +34,38 @@ struct TrackPickerSheet: View { List { switch kind { case .audio: - ForEach(model.audioTracks) { track in + // **Die Spuren der Datei, nicht die des Players.** Rechnet + // der Server um, kennt der Player genau eine — die, für + // die er sich entschieden hat. Zur Auswahl steht, was in + // der Datei liegt. + ForEach(model.audioStreams) { stream in row( - title: track.displayName, - isSelected: model.selectedAudioTrack == track + title: stream.name, + isSelected: model.selectedAudioStream == stream ) { - model.selectAudioTrack(track) + Task { await model.selectAudioStream(stream) } dismiss() } } + if let gemerkt = model.rememberedTrackName { + Section { + Button(role: .destructive) { + model.forgetRememberedTrack() + } label: { + Label("player.forgetRememberedTrack", systemImage: "arrow.uturn.backward") + } + .listRowBackground(Color.flixySurface) + } header: { + Text("player.rememberedTrack") + } footer: { + // Eine automatische Handlung, die man nicht sehen + // kann, ist von einem Fehler nicht zu + // unterscheiden. + Text("player.rememberedTrackExplanation \(gemerkt)") + } + } + case .subtitles: // „Aus" gehört in dieselbe Liste wie die Spuren, nicht als // eigener Schalter daneben: es ist eine Auswahl unter diff --git a/Flixy/Views/Settings/LanguagesView.swift b/Flixy/Views/Settings/LanguagesView.swift index b3fc37f..65a7d11 100644 --- a/Flixy/Views/Settings/LanguagesView.swift +++ b/Flixy/Views/Settings/LanguagesView.swift @@ -8,18 +8,12 @@ import FlixyDesign /// sie stand in den Voreinstellungen und blieb dort für immer leer. Eine /// Vorliebe, die niemand äußern kann, ist keine. struct LanguagesView: View { - @Environment(PlaybackSettings.self) private var settings + @Environment(LanguagePreferences.self) private var settings @Environment(\.flixyAccent) private var accent - /// Die Sprachen, die in Mediatheken tatsächlich vorkommen. - /// - /// Keine vollständige Liste aller Sprachen der Welt: ein Auswahlrad mit - /// zweihundert Einträgen, um „Deutsch" zu finden, ist keine Hilfe. Was - /// fehlt, lässt sich später ergänzen — dann mit einem Suchfeld. - private static let codes = [ - "de", "en", "fr", "es", "it", "nl", "pl", "pt", "ru", "sv", "da", "tr", - "ja", "ko", "zh", - ] + @State private var isAdding = false + + private var codes: [String] { LanguagePreferences.offeredCodes } var body: some View { @Bindable var settings = settings @@ -33,9 +27,9 @@ struct LanguagesView: View { ) Section { - Picker("languages.audio", selection: $settings.preferredAudioLanguage) { + Picker("languages.audio", selection: $settings.audioLanguage) { Text("languages.serverDefault").tag("") - ForEach(Self.codes, id: \.self) { code in + ForEach(codes, id: \.self) { code in Text(name(of: code)).tag(code) } } @@ -45,10 +39,18 @@ struct LanguagesView: View { Text("languages.audioExplanation") } + fallbackSection + Section { - Picker("languages.subtitles", selection: $settings.preferredSubtitleLanguage) { + Toggle("languages.avoidAudioDescription", isOn: $settings.avoidsAudioDescription) + } footer: { + Text("languages.avoidAudioDescriptionExplanation") + } + + Section { + Picker("languages.subtitles", selection: $settings.subtitleLanguage) { Text("languages.subtitlesOff").tag("") - ForEach(Self.codes, id: \.self) { code in + ForEach(codes, id: \.self) { code in Text(name(of: code)).tag(code) } } @@ -64,6 +66,71 @@ struct LanguagesView: View { .background(Color.flixyCanvas) .navigationTitle(Text("languages.title")) .flixyInlineTitle() + // Eine Rangliste ohne Umsortieren wäre keine. Auf dem iPhone braucht + // das Ziehen den Bearbeitungsmodus nicht — `onMove` in einer `Form` + // genügt, sobald die Liste bearbeitbar ist. + .environment(\.editMode, .constant(.active)) + } + + /// Die Rangliste. + /// + /// **Was sie ist:** der Boden unter der Wunschsprache. Sie greift nur, + /// wenn ein Titel die gewünschte Sprache gar nicht hat. + /// + /// **Was sie nicht ist:** eine zweite Wunschsprache. Steht die + /// Wunschsprache zur Verfügung, wird die Liste nicht einmal angesehen. + @ViewBuilder + private var fallbackSection: some View { + @Bindable var settings = settings + + Section { + if settings.fallbacks.isEmpty { + Text("languages.fallbackEmpty") + .foregroundStyle(Color.flixyTextSecondary) + } else { + ForEach(Array(settings.fallbacks.enumerated()), id: \.element) { position, code in + HStack(spacing: FlixySpacing.regular) { + // Die Nummer, weil die Reihenfolge der ganze Zweck + // ist — eine Liste ohne sichtbaren Rang liest sich + // wie eine Menge. + Text("\(position + 1)") + .font(.flixyCaption.monospacedDigit()) + .foregroundStyle(Color.flixyTextSecondary) + .frame(minWidth: 18, alignment: .trailing) + Text(name(of: code)) + .foregroundStyle(Color.flixyTextPrimary) + } + } + .onMove { von, nach in + settings.fallbacks.move(fromOffsets: von, toOffset: nach) + } + .onDelete { settings.fallbacks.remove(atOffsets: $0) } + } + + Button { + isAdding = true + } label: { + Label("languages.fallbackAdd", systemImage: "plus") + } + .disabled(remaining.isEmpty) + } header: { + Text("languages.fallbackHeader") + } footer: { + Text("languages.fallbackExplanation") + } + .confirmationDialog("languages.fallbackAdd", isPresented: $isAdding, titleVisibility: .visible) { + ForEach(remaining, id: \.self) { code in + Button(name(of: code)) { settings.fallbacks.append(code) } + } + } + } + + /// Was noch nicht in der Liste steht. + /// + /// Eine Sprache zweimal in einer Rangliste hat keine Bedeutung — und die + /// Liste benutzt den Code als Kennung, was bei Dubletten bräche. + private var remaining: [String] { + codes.filter { !settings.fallbacks.contains($0) } } /// Der Name der Sprache in der Sprache des Geräts. diff --git a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift index aa96e66..8d2c6b8 100644 --- a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift +++ b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift @@ -8,6 +8,12 @@ public struct JellyfinBackend: MediaBackend { /// Nicht `private`: Live-TV und Aufnahmen liegen in eigenen Dateien und /// brauchen dieselben Bildpfade. let images = JellyfinImagePaths() + /// Wo ein Titel hingehört — einmal gefragt, dann gemerkt. + /// + /// Ein Actor, also ein Referenztyp: Kopien dieses Structs teilen sich + /// denselben und damit das Gedächtnis. Ohne das fragte jede Folge einer + /// Serie dasselbe noch einmal. + let ancestors = JellyfinLibraryMemo() public let serverName: String /// Konto **und** Adresse: dasselbe Konto auf zwei Servern ist nicht @@ -213,6 +219,28 @@ public func isInLocalNetwork() async -> Bool { return MediaPage(items: mapped, offset: offset, totalCount: total) } + /// Die Mediathek über die Ahnenkette. + /// + /// Jellyfin beantwortet das mit `/Items/{id}/Ancestors`: Staffel, Serie, + /// Mediathek, Wurzel. Gesucht ist der Eintrag vom Typ `CollectionFolder` + /// — das ist eine Mediathek. `UserRootFolder` ist die Wurzel darüber und + /// wäre für alle Titel dieselbe, also nutzlos. + /// + /// Die Antwort wird gemerkt: innerhalb einer Serie fragt sonst jede Folge + /// dasselbe noch einmal. + public func libraryID(for item: MediaItem) async -> String? { + let schlüssel = item.seriesID ?? item.id + if let gemerkt = await ancestors.recorded(schlüssel) { return gemerkt.value } + + let kette = try? await client.send( + JellyfinRequest(path: "/Items/\(schlüssel)/Ancestors"), + as: [JellyfinItem].self + ) + let gefunden = kette?.first { $0.type == "CollectionFolder" }?.id + await ancestors.store(gefunden, for: schlüssel) + return gefunden + } + public func item(id: String) async throws(MediaBackendError) -> MediaItem { let item = try await client.send( JellyfinRequest( diff --git a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinLibraryMemo.swift b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinLibraryMemo.swift new file mode 100644 index 0000000..6db7fe0 --- /dev/null +++ b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinLibraryMemo.swift @@ -0,0 +1,32 @@ +import Foundation + +/// Merkt sich, in welcher Mediathek ein Titel liegt. +/// +/// **Warum überhaupt.** Die Antwort kostet eine Serveranfrage und ändert sich +/// praktisch nie — ein Titel wandert nicht von den Filmen zu den Serien. Ohne +/// Gedächtnis fragte jede Folge einer Serie dasselbe noch einmal, und zwar +/// genau in dem Augenblick, in dem der Film anlaufen soll. +/// +/// **Auch das Nichts wird gemerkt.** Ein Server, der die Frage nicht +/// beantworten kann, beantwortet sie beim zweiten Mal auch nicht — sonst +/// wiederholte sich die vergebliche Anfrage bei jedem Titel. Deshalb die +/// doppelte Optionalität: „nie gefragt" und „gefragt, keine Antwort" sind +/// zwei verschiedene Dinge. +public actor JellyfinLibraryMemo { + /// Eine Antwort, die es gab — der Inhalt darf leer sein. + public struct Answer: Sendable { + public let value: String? + } + + private var seen: [String: String?] = [:] + + public init() {} + + public func recorded(_ key: String) -> Answer? { + seen[key].map(Answer.init) + } + + public func store(_ value: String?, for key: String) { + seen[key] = value + } +} diff --git a/Packages/FlixyKit/Sources/MediaKit/MediaBackend.swift b/Packages/FlixyKit/Sources/MediaKit/MediaBackend.swift index be54ee1..dff3dba 100644 --- a/Packages/FlixyKit/Sources/MediaKit/MediaBackend.swift +++ b/Packages/FlixyKit/Sources/MediaKit/MediaBackend.swift @@ -31,6 +31,21 @@ public protocol MediaBackend: Sendable { limit: Int ) async throws(MediaBackendError) -> MediaPage func item(id: String) async throws(MediaBackendError) -> MediaItem + /// In welcher Mediathek ein Titel liegt. + /// + /// **Warum das eine Frage ist.** Ein Titel weiss es nicht von selbst: er + /// kennt seinen unmittelbaren Vorgesetzten — bei einer Folge die Staffel, + /// bei einem Film die Mediathek — und mehr nicht. Wer die Mediathek einer + /// Folge wissen will, muss die Kette hinaufgehen, und das ist eine + /// Serveranfrage. + /// + /// Deshalb steht sie hier als eigene Frage und nicht als Feld an + /// `MediaItem`: sie wird selten gebraucht, kostet etwas, und ein Feld, + /// das meistens `nil` ist, verschweigt genau das. + /// + /// `nil`, wenn der Server es nicht sagen kann — dann fällt die Ebene weg, + /// statt zu raten. + func libraryID(for item: MediaItem) async -> String? func children(of id: String) async throws(MediaBackendError) -> [MediaItem] func search(_ text: String, limit: Int) async throws(MediaBackendError) -> [MediaRowGroup] /// Die möglichen Werte eines Filters, etwa alle Genres einer Mediathek. @@ -143,6 +158,15 @@ public struct MediaPage: Sendable, Equatable { public var nextOffset: Int { offset + items.count } } +public extension MediaBackend { + /// Ohne eigene Antwort keine Mediathek. + /// + /// Plex beantwortet das heute nicht, und eine falsche Antwort wäre + /// schlechter als keine: sie stellte die Sprache einer fremden Mediathek + /// über den allgemeinen Wunsch. + func libraryID(for item: MediaItem) async -> String? { nil } +} + /// Ein möglicher Wert für einen Filter. public struct MediaFilterOption: Sendable, Equatable, Identifiable { public let id: String