Die Tonspur wird aus der Datei gewählt, nicht aus dem Stream
Im Spurenblatt stand eine einzige Zeile: „Unbekannt". Das ist der Name, den AVFoundation einer namenlosen Spur gibt — und bei einem umgerechneten Stream liefert der Server genau eine. Der Player hatte nichts zur Auswahl, weil die Wahl längst gefallen war, bevor er gefragt wurde. Gewählt wird jetzt aus den Spuren, die **in der Datei** stehen, und zwar bevor der Stream ausgehandelt wird. Die Nummer geht als `audioStreamIndex` mit hinaus; wer mitten im Film umstellt, bekommt den Stream neu — ausser bei unveränderter Wiedergabe, wo der Player ohne Unterbrechung umschaltet. Die Abwägung selbst macht `AudioTrackChooser`: gemerkte Spur, dann Wunschsprache, dann Rangliste, dann Standardspur — und Audiodeskription mit deutlichem Abzug statt Ausschluss, damit eine Sprache, die es nur so gibt, immer noch besser ist als Stille. Drei Ebenen in `LanguagePreferences`: Serie, Mediathek, allgemein. Gefragt wird von unten nach oben. Die Mediathek wird nur nachgeschlagen, wenn überhaupt jemand eine Sprache je Mediathek gesetzt hat — sonst kostete jede Wiedergabe eine Anfrage für eine Ebene, die niemand benutzt. Wer von Hand umstellt, dessen Wahl wird für die Serie gemerkt, ohne Nachfrage. Der Dialog „für diese Serie merken?" ist genau die Art, die man nach dem dritten Mal wegklickt. Sichtbar ist es trotzdem: das Spurenblatt zeigt es und lässt es löschen. Dazu die Rangliste in den Einstellungen, ziehbar, und der Schalter „Audiodeskription meiden". Und die Player-Oberfläche blendet endlich als Ganzes über. Die Kopfzeile hatte gar keine Überblendung — sie sprang herein, während die Bedienung darunter verblasste. Jetzt steht die Animation an einer Stelle statt an sechs, und sie läuft über Deckkraft statt über Einfügen und Entfernen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2dbe260901
commit
596a143545
@@ -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)
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Void, Never>?
|
||||
private var reportTask: Task<Void, Never>?
|
||||
/// 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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user