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:
Scarriffle
2026-09-28 16:31:38 +02:00
co-authored by Claude Opus 5
parent 2dbe260901
commit 596a143545
12 changed files with 755 additions and 120 deletions
+119 -20
View File
@@ -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.
+40 -13
View File
@@ -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
}
}
}
+27 -4
View File
@@ -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