Abspielen am Fernseher — mit Apples Player und unseren Spuren

Schritt sieben. Wiedergabe, Spurwahl, Titelzeile, „Intro überspringen".

**Die schwierige Stelle war die Spurwahl.** Unsere Ton- und Untertitelwahl
geht als `audioStreamIndex` an Jellyfin und verlangt bei einem Transcode
einen **neuen Stream**. Apples eingebauter Spurwähler zeigt dagegen die
Auswahlgruppen des **laufenden** Streams — beim Transcode genau eine. Er
ist die richtige Oberfläche für die falsche Liste. Unsere Menüs sitzen
deshalb über `transportBarCustomMenuItems` daneben; `customInfoViewControllers`
wäre falsch, denn die Tonspur ist ein Bedienelement der Wiedergabe und kein
Begleitmaterial.

**Der Neuaufbau wird angesagt, nicht erklärt.** Jeder Eintrag, der Sekunden
mit schwarzem Bild kostet, trägt das als Untertitel — vorher, nicht
hinterher. Wer drückt, sieht den Haken sofort wandern, weil das Menü sich
schliesst und darin kein Platz für einen Kreisel ist. Das schwarze Bild
deckt eine Überlagerung in `contentOverlayView` zu, die hinter den
Bedienelementen liegt und keinen Fokus nimmt — `customOverlayViewController`
täte beides nicht und machte die Fernbedienung währenddessen tot. Und
solange umgebaut wird, sind alle Einträge gesperrt: sonst räumt `restart()`
eine Engine ab, die eine andere gerade baut.

**Die Regel dahinter ist jetzt prüfbar.** „Wann kostet ein Spurwechsel einen
Neuaufbau" lag bisher mitten im Vollzug von `selectAudioStream` und liess
sich nur an einem echten Server mit mehreren Tonspuren auslösen. Als
`TrackMenu` steht sie neben den übrigen Entscheidungsregeln, mit sieben
Tests — darunter der Fall, auf den der Kommentar dort seit jeher hinweist:
stimmt die Spurzahl nicht überein, ist die Zuordnung über die Reihenfolge
falsch, und es muss neu aufgebaut werden.

**Zwei stille Fehler nebenbei.** Die Tonsitzung stand hinter `#if os(iOS)`
und wäre auf tvOS nie gesetzt worden — verfügbar ist sie dort seit 9.0, man
hätte es nur daran gemerkt, dass sich die Wiedergabe gegen anderen Ton
nicht durchsetzt. Und `PlayerModel.downloads` ist jetzt optional statt
eines leeren Koordinators: ein leerer bräuchte einen `ModelContainer`, und
der ist das Einzige, wofür diese App SwiftData benutzt.

**Belegt statt vermutet.** Im Simulator lief die Wiedergabe gegen die
Attrappe, und die Titelzeile zeigte „Unbekannt". Statt an AVKit zu drehen,
hat ein Protokolleintrag die Frage beantwortet: `externalMetadata` wird
gesetzt, der Wert stimmt, und „Unbekannt" ist wörtlich das, was die
Attrappe für unbekannte Kennungen liefert. Geblieben ist davon nur die
Erkenntnis, dass die Beschriftung auf das Item warten muss — beim
Austausch des Players gibt es noch keines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-29 19:55:02 +02:00
co-authored by Claude Opus 5
parent 87f23afe90
commit 12bfc9e530
17 changed files with 799 additions and 23 deletions
+34 -1
View File
@@ -1975,7 +1975,6 @@
}
},
"home.empty.title": {
"extractionState": "stale",
"localizations": {
"de": {
"stringUnit": {
@@ -3841,6 +3840,40 @@
}
}
},
"player.rebuilding": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Stream wird neu aufgebaut …"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Rebuilding stream …"
}
}
}
},
"player.rebuilds": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Stream wird neu aufgebaut"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Stream will be rebuilt"
}
}
}
},
"player.relayWarning": {
"comment": "Warnung im Player, wenn die Verbindung über die Plex-Vermittlung läuft",
"extractionState": "stale",
+31 -9
View File
@@ -4,7 +4,9 @@ import MediaKit
import Observation
import FlixyDownloads
import FlixyPlayback
#if canImport(FlixyVLC)
// Siehe `PlaybackSettings`: `!os(tvOS)` muss dazu, sonst bricht der
// Abhängigkeits-Sucher an VLCKit ab, das für dieses Ziel gar nicht gebaut wird.
#if canImport(FlixyVLC) && !os(tvOS)
import FlixyVLC
#endif
import PlexKit
@@ -154,6 +156,17 @@ final class PlayerModel {
/// bei jedem Wechsel eine neue `AVPlayerLayer`, und das Bild setzte neu an.
let surface = PlayerLayerView()
/// Der Systemplayer der laufenden Engine — oder nichts, solange keine läuft.
///
/// **AVFoundation, nicht UIKit.** Das Modell weiss weiterhin nicht, wer
/// das Bild zeichnet: auf dem Telefon hängt die gehaltene `AVPlayerLayer`
/// daran, auf dem Fernseher ein `AVPlayerViewController`. Beide holen sich
/// hier dasselbe ab.
///
/// `nil` bei VLC — kein Sonderfall, sondern dieselbe Auskunft, die
/// `VideoSurface` heute schon auswertet.
var systemPlayer: AVPlayer? { (engine as? AVPlayerEngine)?.player }
/// Wie das Bild in die Fläche gelegt wird.
///
/// **Im Modell und nicht in der Ansicht**, aus demselben Grund wie die
@@ -201,7 +214,14 @@ final class PlayerModel {
/// gibt, steht hier kein Plex-Server mehr, sondern wer auch immer
/// Live-TV kann.
private let liveTV: (any LiveTVBackend)?
private let downloads: DownloadCoordinator
/// Der Bestand auf der Platte — auf dem Fernseher gibt es keinen.
///
/// **Optional und nicht ein leerer Koordinator.** Ein leerer bräuchte
/// einen `ModelContainer`, und der ist das Einzige, wofür diese App
/// SwiftData überhaupt benutzt; auf tvOS bliebe damit beides weg. Ein
/// Protokoll mit Null-Objekt wäre die dritte Möglichkeit und die
/// schlechteste: ein Typ mehr, um drei `if let` zu vermeiden.
private let downloads: DownloadCoordinator?
private let diagnostics: PlaybackDiagnostics
private let policy: PlaybackPolicy
private let settings: PlaybackSettings
@@ -454,7 +474,7 @@ final class PlayerModel {
request: PlaybackRequest,
backend: (any MediaBackend)?,
liveTV: (any LiveTVBackend)?,
downloads: DownloadCoordinator,
downloads: DownloadCoordinator? = nil,
diagnostics: PlaybackDiagnostics,
policy: PlaybackPolicy,
settings: PlaybackSettings,
@@ -491,7 +511,7 @@ final class PlayerModel {
// Die lokale Datei hat immer Vorrang. Sie ist schneller, kostet kein
// Datenvolumen und funktioniert auch dann, wenn der Server gerade
// nicht erreichbar ist — der eigentliche Sinn des Herunterladens.
if let localURL = downloads.localURL(forRatingKey: request.ratingKey) {
if let downloads, let localURL = downloads.localURL(forRatingKey: request.ratingKey) {
await startLocal(url: localURL)
return
}
@@ -713,7 +733,7 @@ final class PlayerModel {
}
private func startLocal(url: URL) async {
let offline = downloads.offlineMedia(forRatingKey: request.ratingKey)
let offline = downloads?.offlineMedia(forRatingKey: request.ratingKey) ?? nil
let decision = PlaybackDecider().decide(
// Früher standen hier **erfundene** Plex-Modelle: eine Media mit
@@ -1746,7 +1766,7 @@ final class PlayerModel {
}
private func makeEngine(for decision: PlaybackDecision) -> any VideoPlayerEngine {
#if canImport(FlixyVLC)
#if canImport(FlixyVLC) && !os(tvOS)
switch decision.engine {
case .vlc: VLCPlayerEngine()
// Auch der Transcode-Weg endet beim Systemplayer: er liefert HLS, und
@@ -1842,9 +1862,11 @@ final class PlayerModel {
// Grundlage fürs automatische Löschen nach dem Ansehen —
// und der Auslöser fürs Nachladen: der Vorrat ist jetzt
// um eine Folge kleiner.
downloads.markWatched(ratingKey: request.ratingKey)
Task { [backend, downloads] in
await DownloadRefiller(backend: backend, coordinator: downloads).refill()
if let downloads {
downloads.markWatched(ratingKey: request.ratingKey)
Task { [backend, downloads] in
await DownloadRefiller(backend: backend, coordinator: downloads).refill()
}
}
case .tracksResolved(let audio, let subtitle):
audioTracks = audio
+4 -2
View File
@@ -6,7 +6,9 @@ import UIKit
#else
import AppKit
#endif
#if canImport(FlixyVLC)
// Siehe `PlaybackSettings`: `!os(tvOS)` muss dazu, sonst bricht der
// Abhängigkeits-Sucher an VLCKit ab, das für dieses Ziel gar nicht gebaut wird.
#if canImport(FlixyVLC) && !os(tvOS)
import FlixyVLC
#endif
@@ -59,7 +61,7 @@ struct VideoSurface: View {
var bottomInset: CGFloat = 0
var body: some View {
#if canImport(FlixyVLC)
#if canImport(FlixyVLC) && !os(tvOS)
if let vlcEngine = engine as? VLCPlayerEngine {
SurfaceContainer(view: vlcEngine.videoView, isActive: isActive, bottomInset: bottomInset)
} else if engine is AVPlayerEngine {