diff --git a/.gitignore b/.gitignore
index 6f82855..456abb2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,4 +8,8 @@ xcuserdata/
# Von XcodeGen aus project.yml erzeugt — nicht eingecheckt, damit es keine
# Konflikte in der pbxproj gibt. Nach dem Klonen: xcodegen generate
+#
+# Flixy.xcworkspace daneben ist dagegen von Hand geschrieben und gehört dazu:
+# er verweist nur auf das Projekt und überlebt jedes Neuerzeugen. Warum das
+# nötig ist, steht in Flixy.xcworkspace/README.md.
Flixy.xcodeproj/
diff --git a/Flixy.xcworkspace/README.md b/Flixy.xcworkspace/README.md
new file mode 100644
index 0000000..fe3028d
--- /dev/null
+++ b/Flixy.xcworkspace/README.md
@@ -0,0 +1,19 @@
+# Diesen Arbeitsbereich öffnen, nicht das Projekt
+
+`Flixy.xcodeproj` wird von XcodeGen aus `project.yml` erzeugt — und dabei
+**ersetzt**, nicht bearbeitet: jede Datei darin bekommt einen neuen Inode,
+auch die, deren Inhalt gleich bleibt. Hat Xcode den Arbeitsbereich **im**
+Projekt offen (`Flixy.xcodeproj/project.xcworkspace`), meldet es danach
+
+> The workspace file … has disappeared. Do you want to re-save the container,
+> or close it?
+
+und zwar nach jedem einzelnen Lauf.
+
+Dieser Arbeitsbereich liegt **daneben** statt darin. Er verweist nur auf das
+Projekt und wird von XcodeGen nie angefasst — Xcode lädt das Projekt danach
+still nach, ohne zu fragen.
+
+**Falls der Dialog doch einmal kommt: „Close", nie „Re-save".** Re-save
+schreibt Xcodes Kopie aus dem Arbeitsspeicher über das frisch erzeugte
+Projekt; alle Änderungen aus `project.yml` wären damit wieder weg.
diff --git a/Flixy.xcworkspace/contents.xcworkspacedata b/Flixy.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..ae372ab
--- /dev/null
+++ b/Flixy.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings
index 6df1172..cd41544 100644
--- a/Flixy/Resources/Localizable.xcstrings
+++ b/Flixy/Resources/Localizable.xcstrings
@@ -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",
diff --git a/Flixy/Views/Player/PlayerModel.swift b/Flixy/Views/Player/PlayerModel.swift
index 941a540..a15eeb6 100644
--- a/Flixy/Views/Player/PlayerModel.swift
+++ b/Flixy/Views/Player/PlayerModel.swift
@@ -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
diff --git a/Flixy/Views/Player/VideoSurface.swift b/Flixy/Views/Player/VideoSurface.swift
index 97e5098..a6107ba 100644
--- a/Flixy/Views/Player/VideoSurface.swift
+++ b/Flixy/Views/Player/VideoSurface.swift
@@ -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 {
diff --git a/FlixyTV/Views/Library/TVLibraryView.swift b/FlixyTV/Views/Library/TVLibraryView.swift
index 654bea8..f30385c 100644
--- a/FlixyTV/Views/Library/TVLibraryView.swift
+++ b/FlixyTV/Views/Library/TVLibraryView.swift
@@ -13,6 +13,8 @@ struct TVLibraryView: View {
let libraryID: String
@Environment(FlixySession.self) private var session
+ @Environment(PlaybackCoordinator.self) private var playback
+ @Environment(QuickPlay.self) private var quickPlay
@State private var feed: LibraryFeed?
@FocusState private var fokus: String?
@@ -78,10 +80,17 @@ struct TVLibraryView: View {
),
progress: item.watchProgress
) {
- // In Fassung eins führt die Karte zur Detailseite.
+ // **Über QuickPlay, nicht unmittelbar.** Steht im
+ // Raster eine Serie, hat die keine Fassungen; der
+ // direkte Weg schickte ihre eigene Kennung an den
+ // Player und scheiterte mit „keine abspielbare
+ // Datei". QuickPlay löst stattdessen die nächste
+ // ungesehene Folge auf.
+ //
// Ein Kontextmenü wie am Telefon gibt es hier
// nicht: langes Drücken ist auf der Fernbedienung
// keine verlässliche Geste.
+ quickPlay.start(item, backend: session.backend, coordinator: playback)
}
.focused($fokus, equals: item.id)
.onChange(of: fokus) { _, neu in
diff --git a/FlixyTV/Views/Player/TVPlayerCoordinator.swift b/FlixyTV/Views/Player/TVPlayerCoordinator.swift
new file mode 100644
index 0000000..77a611f
--- /dev/null
+++ b/FlixyTV/Views/Player/TVPlayerCoordinator.swift
@@ -0,0 +1,199 @@
+import AVKit
+import UIKit
+import FlixyPlayback
+import MediaKit
+
+/// Führt Apples Player — und hält ihn über einen Neuaufbau hinweg am Leben.
+///
+/// **Das Problem, um das es hier geht.** Unsere Ton- und Untertitelwahl geht
+/// als `audioStreamIndex` an Jellyfin und verlangt bei einem Transcode einen
+/// **neuen Stream**. Apples eingebauter Spurwähler zeigt dagegen die
+/// `AVMediaSelectionGroup`s des **laufenden** Streams — beim Transcode genau
+/// eine. Er ist also die richtige Oberfläche für die falsche Liste.
+///
+/// Deshalb bekommt die Transportleiste unsere eigenen Menüs über
+/// `transportBarCustomMenuItems`. Nicht `customInfoViewControllers`: die Wahl
+/// der Tonspur ist ein Bedienelement der Wiedergabe, kein Begleitmaterial —
+/// Apples eigene Beispiele dafür sind Bonusinhalte und Verwandtes, und dort
+/// erwartet niemand „Sprache".
+@MainActor
+final class TVPlayerCoordinator: NSObject, @MainActor AVPlayerViewControllerDelegate {
+ let controller = AVPlayerViewController()
+
+ private let onDismiss: () -> Void
+ private var letzteAufnahme: TVPlayerSnapshot?
+ /// Wacht darüber, wann es etwas zu beschriften gibt.
+ ///
+ /// **Nicht beim Austausch des Players.** In dem Moment ist `currentItem`
+ /// noch `nil` — das Item entsteht erst, wenn das Asset geladen ist, und
+ /// bis dahin zeigt Apples Titelzeile, was es selbst aus der Datei liest:
+ /// „Unbekannt". Also wird gewartet, bis das Item wirklich da ist.
+ private var itemBeobachter: NSKeyValueObservation?
+ /// Was drinstehen soll, sobald es ein Item gibt.
+ private var beschriftung: (titel: String, untertitel: String?)?
+ /// Die Abdeckung für das schwarze Bild während eines Neuaufbaus.
+ private lazy var abdeckung = TVRebuildOverlay()
+ private var abdeckungsAufgabe: Task?
+
+ init(onDismiss: @escaping () -> Void) {
+ self.onDismiss = onDismiss
+ super.init()
+ controller.delegate = self
+ controller.videoGravity = .resizeAspect
+ // Nie auf dem Fernseher — ein Apple TV hat kein zweites Fenster.
+ controller.allowsPictureInPicturePlayback = false
+ controller.transportBarIncludesTitleView = true
+
+ // **Hinter den Bedienelementen und ohne Fokus.** `contentOverlayView`
+ // liegt über dem Bild, aber unter Apples Leiste, und nimmt keine
+ // Eingaben an. `customOverlayViewController` täte beides nicht: der
+ // ersetzt die ganze Überlagerung und zieht den Fokus an sich — damit
+ // wäre die Fernbedienung während des Umschaltens tot.
+ if let ebene = controller.contentOverlayView {
+ abdeckung.translatesAutoresizingMaskIntoConstraints = false
+ ebene.addSubview(abdeckung)
+ NSLayoutConstraint.activate([
+ abdeckung.topAnchor.constraint(equalTo: ebene.topAnchor),
+ abdeckung.bottomAnchor.constraint(equalTo: ebene.bottomAnchor),
+ abdeckung.leadingAnchor.constraint(equalTo: ebene.leadingAnchor),
+ abdeckung.trailingAnchor.constraint(equalTo: ebene.trailingAnchor),
+ ])
+ }
+ }
+
+ // MARK: - Nachführen
+
+ func apply(_ aufnahme: TVPlayerSnapshot, model: PlayerModel) {
+ defer { letzteAufnahme = aufnahme }
+
+ // **Den Player nur austauschen, wenn es ein anderer ist.** `restart()`
+ // baut eine neue Engine mit einem neuen `AVPlayer`; genau dann — und
+ // nur dann — wird das Bild schwarz.
+ if aufnahme.playerIdentity != letzteAufnahme?.playerIdentity {
+ controller.player = model.systemPlayer
+ beobachteItem()
+ }
+ beschriftung = (aufnahme.title, aufnahme.subtitle)
+ beschrifte()
+
+ zeigeAbdeckung(aufnahme.isRebuilding)
+ controller.transportBarCustomMenuItems = TVTrackMenuBuilder.items(
+ for: aufnahme,
+ onAudio: { [weak model] index in
+ guard let model, let stream = model.audioStreams.first(where: { $0.index == index })
+ else { return }
+ Task { await model.selectAudioStream(stream) }
+ },
+ onSubtitle: { [weak model] index in
+ guard let model else { return }
+ let stream = index.flatMap { i in
+ model.subtitleStreams.first { $0.index == i }
+ }
+ Task { await model.selectSubtitleStream(stream) }
+ }
+ )
+ controller.contextualActions = TVTrackMenuBuilder.contextualActions(
+ for: aufnahme,
+ onSkip: { [weak model] in model?.skipActiveMarker() }
+ )
+ }
+
+ private func beobachteItem() {
+ itemBeobachter = controller.player?.observe(\.currentItem, options: [.initial, .new]) {
+ [weak self] _, _ in
+ MainActor.assumeIsolated { self?.beschrifte() }
+ }
+ }
+
+ /// Titel und Untertitel in Apples Titelzeile.
+ ///
+ /// Über `externalMetadata` am `AVPlayerItem`, weil die Transportleiste
+ /// genau dort nachsieht. Auf iOS zeichnet unsere eigene Kopfzeile das;
+ /// hier gäbe es sonst gar keine Auskunft darüber, was läuft — ausser dem,
+ /// was AVKit selbst aus der Datei liest, und das ist bei einem
+ /// Transcode-Stream „Unbekannt".
+ private func beschrifte() {
+ guard let item = controller.player?.currentItem,
+ let beschriftung
+ else { return }
+ var einträge: [AVMetadataItem] = [eintrag(.commonIdentifierTitle, beschriftung.titel)]
+ if let untertitel = beschriftung.untertitel {
+ einträge.append(eintrag(.iTunesMetadataTrackSubTitle, untertitel))
+ }
+ item.externalMetadata = einträge
+ }
+
+ private func eintrag(_ kennung: AVMetadataIdentifier, _ wert: String) -> AVMetadataItem {
+ let eintrag = AVMutableMetadataItem()
+ eintrag.identifier = kennung
+ eintrag.value = wert as NSString
+ eintrag.extendedLanguageTag = "und"
+ return eintrag
+ }
+
+ /// Blendet die Abdeckung ein — aber erst nach einer Viertelsekunde.
+ ///
+ /// Der häufige Fall (unverändert gespielt, unter fünfzig Millisekunden)
+ /// soll nicht aufblitzen. Wer nur umschaltet, sieht dann gar nichts.
+ private func zeigeAbdeckung(_ sichtbar: Bool) {
+ abdeckungsAufgabe?.cancel()
+ guard sichtbar else {
+ abdeckung.setVisible(false)
+ return
+ }
+ abdeckungsAufgabe = Task { [weak self] in
+ try? await Task.sleep(for: .milliseconds(250))
+ guard !Task.isCancelled else { return }
+ self?.abdeckung.setVisible(true)
+ }
+ }
+
+ // MARK: - Der Menü-Knopf der Fernbedienung
+
+ /// Fängt den Menü-Knopf ab, statt in SwiftUI `onExitCommand` zu raten.
+ func playerViewControllerShouldDismiss(_ controller: AVPlayerViewController) -> Bool {
+ onDismiss()
+ return true
+ }
+}
+
+/// Die Abdeckung, die das schwarze Bild während eines Neuaufbaus zudeckt.
+///
+/// Ohne sie sieht man Sekunden lang nichts und hält es für einen Absturz —
+/// dabei ist es die angekündigte Folge einer Spurwahl.
+@MainActor
+final class TVRebuildOverlay: UIView {
+ private let kreisel = UIActivityIndicatorView(style: .large)
+ private let zeile = UILabel()
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+ backgroundColor = UIColor.black.withAlphaComponent(0.75)
+ isUserInteractionEnabled = false
+ alpha = 0
+
+ kreisel.color = .white
+ kreisel.startAnimating()
+ zeile.text = String(localized: "player.rebuilding")
+ zeile.textColor = .white
+ zeile.font = .preferredFont(forTextStyle: .headline)
+
+ let stapel = UIStackView(arrangedSubviews: [kreisel, zeile])
+ stapel.axis = .vertical
+ stapel.alignment = .center
+ stapel.spacing = 24
+ stapel.translatesAutoresizingMaskIntoConstraints = false
+ addSubview(stapel)
+ NSLayoutConstraint.activate([
+ stapel.centerXAnchor.constraint(equalTo: centerXAnchor),
+ stapel.centerYAnchor.constraint(equalTo: centerYAnchor),
+ ])
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) { fatalError("nicht aus einem Storyboard") }
+
+ func setVisible(_ sichtbar: Bool) {
+ UIView.animate(withDuration: 0.2) { self.alpha = sichtbar ? 1 : 0 }
+ }
+}
diff --git a/FlixyTV/Views/Player/TVPlayerHost.swift b/FlixyTV/Views/Player/TVPlayerHost.swift
new file mode 100644
index 0000000..22b86cb
--- /dev/null
+++ b/FlixyTV/Views/Player/TVPlayerHost.swift
@@ -0,0 +1,79 @@
+import AVKit
+import SwiftUI
+import FlixyPlayback
+import MediaKit
+
+/// Hängt Apples Player in den SwiftUI-Baum.
+///
+/// **Wem was gehört.** Der `AVPlayerViewController` gehört dem Coordinator,
+/// nicht der SwiftUI-Struktur — die wird bei jedem Neuzeichnen neu gebaut —
+/// und nicht dem `PlayerModel`, das frei von UIKit bleiben soll. Das Modell
+/// gibt nur seinen `AVPlayer` heraus, und das ist AVFoundation.
+@MainActor
+struct TVPlayerHost: UIViewControllerRepresentable {
+ let model: PlayerModel
+ /// Eine Momentaufnahme dessen, was den Controller betrifft.
+ ///
+ /// **Als Wert, nicht als Verweis.** SwiftUI ruft `updateUIViewController`
+ /// nur, wenn sich am Repräsentanten etwas geändert hat. Stünde hier allein
+ /// das Modell, änderte sich nie etwas: es ist eine Klasse, und ihre
+ /// Kennung bleibt über jeden Spurwechsel hinweg dieselbe. Der Wert wird im
+ /// Rumpf der umgebenden Ansicht gebildet — dort und nur dort entsteht die
+ /// Beobachtungsbeziehung zu `@Observable`.
+ let snapshot: TVPlayerSnapshot
+ let onDismiss: () -> Void
+
+ func makeUIViewController(context: Context) -> AVPlayerViewController {
+ context.coordinator.controller
+ }
+
+ func updateUIViewController(_ controller: AVPlayerViewController, context: Context) {
+ context.coordinator.apply(snapshot, model: model)
+ }
+
+ func makeCoordinator() -> TVPlayerCoordinator {
+ TVPlayerCoordinator(onDismiss: onDismiss)
+ }
+}
+
+/// Was der Controller von der Wiedergabe wissen muss — und mehr nicht.
+struct TVPlayerSnapshot: Equatable {
+ /// Wechselt bei jedem Neuaufbau. Daran hängt der Austausch des Players.
+ let playerIdentity: ObjectIdentifier?
+ let audio: [TrackMenu.Entry]
+ let subtitles: [TrackMenu.Entry]
+ /// Läuft gerade ein Neuaufbau? Dann ist das Bild schwarz und das Menü
+ /// gesperrt.
+ let isRebuilding: Bool
+ let title: String
+ let subtitle: String?
+ /// Der Zeitbereich einer Marke — für „Intro überspringen".
+ let markerLabel: String?
+
+ /// `@MainActor`, weil das Modell es ist: gelesen wird hier zwölfmal aus
+ /// ihm, und zwar im Rumpf einer View — also ohnehin auf dem Hauptakteur.
+ @MainActor
+ init(_ model: PlayerModel, offLabel: String) {
+ playerIdentity = model.systemPlayer.map(ObjectIdentifier.init)
+ let direkt: Bool = if case .directPlay = model.decision?.method { true } else { false }
+ audio = TrackMenu.audio(
+ from: model.audioStreams,
+ selected: model.selectedAudioStream,
+ isDirectPlay: direkt,
+ playerTrackCount: model.audioTracks.count
+ )
+ subtitles = TrackMenu.subtitles(
+ from: model.subtitleStreams,
+ selected: model.selectedSubtitleStream,
+ isDirectPlay: direkt,
+ playerTrackLanguages: model.subtitleTracks.map(\.languageCode),
+ offLabel: offLabel
+ )
+ isRebuilding = model.isChangingQuality
+ title = model.title
+ subtitle = model.subtitle
+ markerLabel = model.activeMarker.map {
+ String(localized: $0.isIntro ? "player.skipIntro" : "player.skipCredits")
+ }
+ }
+}
diff --git a/FlixyTV/Views/Player/TVPlayerScreen.swift b/FlixyTV/Views/Player/TVPlayerScreen.swift
new file mode 100644
index 0000000..6c874cc
--- /dev/null
+++ b/FlixyTV/Views/Player/TVPlayerScreen.swift
@@ -0,0 +1,70 @@
+import SwiftUI
+import FlixyDesign
+import FlixyPlayback
+import MediaKit
+
+/// Der Player auf dem Fernseher.
+///
+/// **Das Modell lebt hier und stirbt hier.** Auf dem Telefon hält `RootView`
+/// es fest, damit die Wiedergabe den Wechsel zwischen Kachel und Vollbild
+/// überlebt. Ohne Mini-Player gibt es diesen Wechsel nicht, und ein
+/// hochgehaltenes Modell hielte nur eine tote Engine, einen laufenden
+/// Transcoder und eine offene Jellyfin-Sitzung am Leben, nachdem der Player
+/// längst weg ist.
+struct TVPlayerScreen: View {
+ let request: PlaybackRequest
+
+ @Environment(FlixySession.self) private var session
+ @Environment(PlaybackCoordinator.self) private var playback
+ @Environment(PlaybackSettings.self) private var settings
+ @Environment(LanguagePreferences.self) private var languages
+ @Environment(SubtitleAppearance.self) private var subtitleAppearance
+ @Environment(NetworkConditions.self) private var network
+ @Environment(PlaybackDiagnostics.self) private var diagnostics
+
+ @State private var model: PlayerModel?
+
+ var body: some View {
+ ZStack {
+ Color.black.ignoresSafeArea()
+
+ if let model {
+ // **Die Momentaufnahme entsteht hier, im Rumpf einer View.**
+ // Nur so entsteht die Beobachtungsbeziehung zu `@Observable`
+ // — ein Repräsentant, der bloss das Modell hält, bekäme nie
+ // ein `updateUIViewController`.
+ TVPlayerHost(
+ model: model,
+ snapshot: TVPlayerSnapshot(model, offLabel: String(localized: "player.subtitlesOff")),
+ onDismiss: { playback.close() }
+ )
+ .ignoresSafeArea()
+ } else {
+ ProgressView()
+ }
+ }
+ .task {
+ guard model == nil else { return }
+ let frisch = PlayerModel(
+ request: request,
+ backend: session.backend,
+ liveTV: nil, // Kein Live-TV in Fassung eins.
+ downloads: nil, // Nie auf dem Fernseher.
+ diagnostics: diagnostics,
+ policy: settings.policy(for: network),
+ settings: settings,
+ languages: languages,
+ subtitles: subtitleAppearance,
+ isLocalConnection: session.isLocalConnection,
+ connectionDescription: session.connectionDescription,
+ unavailableMessage: session.playbackUnavailableMessage
+ )
+ model = frisch
+ await frisch.start()
+ }
+ // Beim Verschwinden wirklich Schluss: Engine abräumen, Fortschritt
+ // melden, die Jellyfin-Sitzung schliessen. Ohne das liefe der
+ // Transcoder auf dem Server weiter, obwohl niemand mehr zusieht.
+ .onDisappear { [model] in Task { await model?.stop() } }
+ }
+}
diff --git a/FlixyTV/Views/Player/TVTrackMenuBuilder.swift b/FlixyTV/Views/Player/TVTrackMenuBuilder.swift
new file mode 100644
index 0000000..513a2e5
--- /dev/null
+++ b/FlixyTV/Views/Player/TVTrackMenuBuilder.swift
@@ -0,0 +1,85 @@
+import AVKit
+import UIKit
+import FlixyPlayback
+
+/// Baut aus unseren Spurlisten die Menüs für Apples Transportleiste.
+///
+/// Die reine Abbildung steht in `TrackMenu` und ist dort geprüft; hier geht es
+/// nur noch um UIKit.
+@MainActor
+enum TVTrackMenuBuilder {
+ static func items(
+ for aufnahme: TVPlayerSnapshot,
+ onAudio: @escaping (Int?) -> Void,
+ onSubtitle: @escaping (Int?) -> Void
+ ) -> [UIMenuElement] {
+ var menüs: [UIMenuElement] = []
+ if aufnahme.audio.count > 1 {
+ menüs.append(menü(
+ titel: String(localized: "player.audioTracks"),
+ symbol: "waveform",
+ einträge: aufnahme.audio,
+ gesperrt: aufnahme.isRebuilding,
+ aktion: onAudio
+ ))
+ }
+ if aufnahme.subtitles.count > 1 {
+ menüs.append(menü(
+ titel: String(localized: "player.subtitleTracks"),
+ symbol: "captions.bubble",
+ einträge: aufnahme.subtitles,
+ gesperrt: aufnahme.isRebuilding,
+ aktion: onSubtitle
+ ))
+ }
+ return menüs
+ }
+
+ private static func menü(
+ titel: String,
+ symbol: String,
+ einträge: [TrackMenu.Entry],
+ gesperrt: Bool,
+ aktion: @escaping (Int?) -> Void
+ ) -> UIMenu {
+ UIMenu(
+ title: titel,
+ image: UIImage(systemName: symbol),
+ options: [.singleSelection],
+ children: einträge.map { eintrag in
+ UIAction(
+ title: eintrag.title,
+ // **Vorher ansagen, nicht hinterher erklären.** Steht hier
+ // nichts, ist die Sekundenpause danach eine Panne; steht
+ // es hier, ist sie eine angekündigte Folge. Das ist der
+ // billigste Teil dieser ganzen Sache und der wirksamste.
+ subtitle: eintrag.rebuilds
+ ? String(localized: "player.rebuilds")
+ : nil,
+ // **Keine zweite Bestellung, solange die erste läuft.**
+ // Sonst räumt `restart()` eine Engine ab, die eine andere
+ // gerade baut.
+ attributes: gesperrt ? [.disabled] : [],
+ state: eintrag.isSelected ? .on : .off
+ ) { auslöser in
+ // **Sofort quittieren.** Das Menü schliesst sich beim
+ // Drücken, und darin ist kein Platz für einen Kreisel.
+ // Der Haken wandert, bevor sich am Bild etwas tut.
+ auslöser.state = .on
+ aktion(eintrag.id)
+ }
+ }
+ )
+ }
+
+ /// „Intro überspringen" unten rechts, solange eine Marke läuft.
+ ///
+ /// Die tvOS-Entsprechung zu dem Knopf, den der iOS-Player selbst zeichnet.
+ static func contextualActions(
+ for aufnahme: TVPlayerSnapshot,
+ onSkip: @escaping () -> Void
+ ) -> [UIAction] {
+ guard let beschriftung = aufnahme.markerLabel else { return [] }
+ return [UIAction(title: beschriftung) { _ in onSkip() }]
+ }
+}
diff --git a/FlixyTV/Views/TVRootView.swift b/FlixyTV/Views/TVRootView.swift
index 25ac5e8..700abb9 100644
--- a/FlixyTV/Views/TVRootView.swift
+++ b/FlixyTV/Views/TVRootView.swift
@@ -11,6 +11,7 @@ import FlixyDesign
/// in derselben Datei.
struct TVRootView: View {
@Environment(FlixySession.self) private var session
+ @Environment(PlaybackCoordinator.self) private var playback
var body: some View {
ZStack {
@@ -38,6 +39,20 @@ struct TVRootView: View {
}
}
.task { await session.restore() }
+ // **Hier und nicht in der Startseite.** Von einer Kachel, aus dem
+ // Aufmacher und aus der Detailseite wird dasselbe gestartet; läge der
+ // Übergang weiter innen, gäbe es ihn dreimal.
+ //
+ // `fullScreenCover(item:)` gibt es auf tvOS seit 14, und
+ // `PlaybackRequest` ist bereits `Identifiable`.
+ .fullScreenCover(
+ item: Binding(
+ get: { playback.request },
+ set: { if $0 == nil { playback.close() } }
+ )
+ ) { request in
+ TVPlayerScreen(request: request)
+ }
}
private func verbinden(mit name: String) -> some View {
diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Decision/TrackMenu.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/TrackMenu.swift
new file mode 100644
index 0000000..68a9382
--- /dev/null
+++ b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/TrackMenu.swift
@@ -0,0 +1,99 @@
+import Foundation
+import MediaKit
+
+/// Was in einem Spurenmenü steht — und was ein Umschalten kostet.
+///
+/// **Warum es diesen Typ gibt.** Auf dem Fernseher sitzt die Spurwahl in
+/// Apples Transportleiste, und die nimmt `UIMenu`-Einträge entgegen. Die
+/// Abbildung von Spuren auf Einträge hat mit UIKit aber nichts zu tun: sie
+/// beantwortet, welche Spuren zur Wahl stehen, welche gerade gilt, und — das
+/// ist der Teil, der sonst nirgends steht — **ob das Umschalten Sekunden mit
+/// schwarzem Bild kostet**.
+///
+/// Diese Regel lag bisher nur mitten im Vollzug von
+/// `PlayerModel.selectAudioStream` und war damit weder ablesbar noch prüfbar:
+/// auslösen lässt sie sich nur an einem echten Server mit einer Datei, die
+/// mehrere Tonspuren hat. Hier steht sie als Auskunft — und die Oberfläche
+/// kann sie **vorher** anzeigen, statt den Nutzer hinterher vor ein schwarzes
+/// Bild zu setzen.
+///
+/// Liegt neben `AudioTrackChooser` und `DowngradeRule`, weil es dasselbe ist:
+/// eine Regel über die Wiedergabe, ohne Oberfläche und ohne Netz.
+public enum TrackMenu {
+ public struct Entry: Equatable, Sendable, Identifiable {
+ /// Der Index der Spur beim Server. `nil` heisst „Aus".
+ public let id: Int?
+ public let title: String
+ public let isSelected: Bool
+ /// Ob das Umschalten den Stream neu aufbaut.
+ public let rebuilds: Bool
+
+ public init(id: Int?, title: String, isSelected: Bool, rebuilds: Bool) {
+ self.id = id
+ self.title = title
+ self.isSelected = isSelected
+ self.rebuilds = rebuilds
+ }
+ }
+
+ /// Die Tonspuren **der Datei**, nicht die des Players.
+ ///
+ /// Rechnet der Server um, kennt der Player genau eine Spur — die, für die
+ /// er sich entschieden hat. Zur Wahl steht, was in der Datei liegt.
+ ///
+ /// - Parameter playerTrackCount: Wie viele Tonspuren der Player meldet.
+ /// Weicht die Zahl ab, stimmt die Zuordnung über die Reihenfolge nicht,
+ /// und es muss neu aufgebaut werden — lieber eine Unterbrechung als die
+ /// falsche Sprache.
+ public static func audio(
+ from streams: [MediaStream],
+ selected: MediaStream?,
+ isDirectPlay: Bool,
+ playerTrackCount: Int
+ ) -> [Entry] {
+ let billig = isDirectPlay && playerTrackCount == streams.count
+ return streams.enumerated().map { stelle, stream in
+ Entry(
+ id: stream.index ?? stelle,
+ title: stream.name,
+ isSelected: stream == selected,
+ rebuilds: !billig
+ )
+ }
+ }
+
+ /// Die Untertitelspuren, mit „Aus" an erster Stelle.
+ ///
+ /// - Parameter offLabel: Wie „Aus" in der Sprache des Nutzers heisst.
+ /// Als Argument, weil dieses Paket keinen Textkatalog der App kennt.
+ /// - Parameter playerTrackLanguages: Die Sprachkürzel der Spuren, die der
+ /// Player wirklich hat. Nur was sich darin abbilden lässt, schaltet ohne
+ /// Neuaufbau um — sonst stünde der falsche Untertitel im Bild, und das
+ /// ist schlimmer als eine kurze Unterbrechung.
+ public static func subtitles(
+ from streams: [MediaStream],
+ selected: MediaStream?,
+ isDirectPlay: Bool,
+ playerTrackLanguages: [String?] = [],
+ offLabel: String
+ ) -> [Entry] {
+ // „Aus" kostet nie einen Neuaufbau: abschalten kann der Player immer,
+ // auch eine Spur, die er nie hatte.
+ var einträge = [
+ Entry(id: nil, title: offLabel, isSelected: selected == nil, rebuilds: false)
+ ]
+
+ einträge += streams.enumerated().map { stelle, stream in
+ let abbildbar = isDirectPlay && playerTrackLanguages.contains {
+ LanguageCode.matches(stream.languageCode, $0)
+ }
+ return Entry(
+ id: stream.index ?? stelle,
+ title: stream.name,
+ isSelected: stream == selected,
+ rebuilds: !abbildbar
+ )
+ }
+ return einträge
+ }
+}
diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Engine/AVPlayerEngine.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Engine/AVPlayerEngine.swift
index c836328..cc12c01 100644
--- a/Packages/FlixyKit/Sources/FlixyPlayback/Engine/AVPlayerEngine.swift
+++ b/Packages/FlixyKit/Sources/FlixyPlayback/Engine/AVPlayerEngine.swift
@@ -586,12 +586,18 @@ public final class AVPlayerEngine: VideoPlayerEngine {
/// Die Tonsitzung des Systems.
///
-/// Nur auf iOS vorhanden. Gekapselt statt an drei Stellen mit `#if` versehen —
+/// Auf iOS **und tvOS**. Gekapselt statt an drei Stellen mit `#if` versehen —
/// macOS steht in diesem Paket ohnehin nur, damit die Logik-Tests ohne
/// Simulator laufen.
+///
+/// **tvOS stand hier lange nicht**, und das war ein stiller Fehler: die
+/// Kategorie wäre dort verfügbar (`AVAudioSessionCategoryPlayback` ist
+/// `tvos(9.0)`), wurde aber nie gesetzt. Man hätte es nicht am Übersetzer
+/// gemerkt, sondern erst daran, dass sich die Wiedergabe gegen anderen Ton
+/// nicht durchsetzt.
enum AudioSession {
static func activate() {
- #if os(iOS)
+ #if os(iOS) || os(tvOS)
// Ohne diese Kategorie schweigt die Wiedergabe, sobald der
// Stummschalter am Gerät umgelegt ist — und Bild-in-Bild gäbe es gar
// nicht.
@@ -601,7 +607,7 @@ enum AudioSession {
}
static func deactivate() {
- #if os(iOS)
+ #if os(iOS) || os(tvOS)
// `notifyOthersOnDeactivation` lässt die Musik-App wieder anlaufen,
// die vor dem Film gespielt hat.
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
diff --git a/Packages/FlixyKit/Tests/FlixyPlaybackTests/TrackMenuTests.swift b/Packages/FlixyKit/Tests/FlixyPlaybackTests/TrackMenuTests.swift
new file mode 100644
index 0000000..b96e363
--- /dev/null
+++ b/Packages/FlixyKit/Tests/FlixyPlaybackTests/TrackMenuTests.swift
@@ -0,0 +1,115 @@
+import Testing
+import MediaKit
+@testable import FlixyPlayback
+
+@Suite("Was im Spurenmenü steht")
+struct TrackMenuTests {
+
+ private func spur(
+ _ id: String,
+ kind: MediaStream.Kind = .audio,
+ sprache: String? = "Deutsch",
+ code: String? = "deu",
+ name: String? = nil,
+ codec: String? = "AC3"
+ ) -> MediaStream {
+ MediaStream(
+ id: id, kind: kind, codec: codec, language: sprache, languageCode: code,
+ displayTitle: name, channels: nil, isSelected: false, isForced: false,
+ title: nil, isDefault: false, isHearingImpaired: false, isOriginal: false
+ )
+ }
+
+ // MARK: - Wann es einen Neuaufbau kostet
+
+ /// **Der Fall, der bisher nirgends stand.** Rechnet der Server um, liegt
+ /// im Stream genau eine Tonspur — jede andere Wahl verlangt eine neue
+ /// Bestellung und damit Sekunden mit schwarzem Bild.
+ @Test("Beim Umrechnen kostet jede Wahl einen Neuaufbau")
+ func transcodeAlwaysRebuilds() {
+ let einträge = TrackMenu.audio(
+ from: [spur("1"), spur("2")],
+ selected: nil, isDirectPlay: false, playerTrackCount: 1
+ )
+ #expect(einträge.allSatisfy { $0.rebuilds })
+ }
+
+ @Test("Unverändert gespielt und gleiche Anzahl: kein Neuaufbau")
+ func directPlayMatchingCountIsCheap() {
+ let einträge = TrackMenu.audio(
+ from: [spur("1"), spur("2")],
+ selected: nil, isDirectPlay: true, playerTrackCount: 2
+ )
+ #expect(einträge.allSatisfy { !$0.rebuilds })
+ }
+
+ /// **Die Regel aus `PlayerModel`, zum ersten Mal prüfbar.** Die Zuordnung
+ /// geht über die Reihenfolge; stimmt die Anzahl nicht, ist die Annahme
+ /// falsch, und es wäre die falsche Sprache statt einer Unterbrechung.
+ /// Auslösen liess sich das bisher nur an einem echten Server.
+ @Test("Abweichende Anzahl heißt Neuaufbau, auch unverändert gespielt")
+ func directPlayMismatchRebuilds() {
+ let einträge = TrackMenu.audio(
+ from: [spur("1"), spur("2"), spur("3")],
+ selected: nil, isDirectPlay: true, playerTrackCount: 2
+ )
+ #expect(einträge.allSatisfy { $0.rebuilds })
+ }
+
+ // MARK: - Untertitel
+
+ @Test("„Aus“ steht vorn und kostet nie einen Neuaufbau")
+ func offComesFirstAndIsFree() {
+ let einträge = TrackMenu.subtitles(
+ from: [spur("1", kind: .subtitle)],
+ selected: nil, isDirectPlay: false, offLabel: "Aus"
+ )
+ #expect(einträge.first?.id == nil)
+ #expect(einträge.first?.title == "Aus")
+ #expect(einträge.first?.isSelected == true)
+ #expect(einträge.first?.rebuilds == false)
+ }
+
+ /// Nur was der Player wirklich hat, lässt sich ohne Neuaufbau schalten.
+ @Test("Eine Untertitelspur, die der Player nicht hat, kostet einen Neuaufbau")
+ func unmappableSubtitleRebuilds() {
+ let deutsch = spur("1", kind: .subtitle, sprache: "Deutsch", code: "deu")
+ let englisch = spur("2", kind: .subtitle, sprache: "English", code: "eng")
+ let einträge = TrackMenu.subtitles(
+ from: [deutsch, englisch],
+ selected: nil, isDirectPlay: true,
+ playerTrackLanguages: ["de"], offLabel: "Aus"
+ )
+ // „deu" und „de" sind dieselbe Sprache — das entscheidet LanguageCode.
+ #expect(einträge[1].rebuilds == false)
+ #expect(einträge[2].rebuilds == true)
+ }
+
+ // MARK: - Beschriftung
+
+ @Test("Der Name des Servers gewinnt, sonst Sprache und Codec")
+ func fallsBackToLanguageAndCodec() {
+ let mitNamen = spur("1", name: "Deutsch (AC3 5.1)")
+ let ohneNamen = spur("2", sprache: "English", codec: "eac3")
+ let einträge = TrackMenu.audio(
+ from: [mitNamen, ohneNamen],
+ selected: mitNamen, isDirectPlay: true, playerTrackCount: 2
+ )
+ #expect(einträge[0].title == "Deutsch (AC3 5.1)")
+ #expect(einträge[1].title == "English · EAC3")
+ #expect(einträge[0].isSelected)
+ #expect(!einträge[1].isSelected)
+ }
+
+ /// Ohne alles bleibt ein Strich — besser als eine leere Zeile, bei der
+ /// niemand weiss, ob sie anklickbar ist.
+ @Test("Ohne Sprache und Codec bleibt ein Strich")
+ func namelessStreamStillHasALabel() {
+ let leer = spur("7", sprache: nil, code: nil, codec: nil)
+ let einträge = TrackMenu.audio(
+ from: [leer], selected: nil, isDirectPlay: true, playerTrackCount: 1
+ )
+ #expect(einträge[0].title == "—")
+ #expect(einträge[0].id == 7)
+ }
+}
diff --git a/Packages/FlixyVLC/Package.resolved b/Packages/FlixyVLC/Package.resolved
index c362a13..613e4ba 100644
--- a/Packages/FlixyVLC/Package.resolved
+++ b/Packages/FlixyVLC/Package.resolved
@@ -1,5 +1,5 @@
{
- "originHash" : "b573b58f837dec84b5981e792f7548e0bb126412c181987af9b265133a652e02",
+ "originHash" : "8b778ccd4dce9d26be9db57d4587b19c4a4366fcdaf8b6017e1df36fa1eddb4f",
"pins" : [
{
"identity" : "vlckit",
diff --git a/project.yml b/project.yml
index a09d1b7..0cc7f17 100644
--- a/project.yml
+++ b/project.yml
@@ -175,16 +175,26 @@ targets:
excludes:
# Ein Fernseher dreht sich nicht.
- "OrientationLock.swift"
- # Downloads gibt es hier nicht.
- - "DownloadService.swift"
- - "DownloadRefiller.swift"
- # Kommt mit dem Player in Schritt sieben. Solange es ihn nicht gibt,
- # hätte die Anmeldung beim System nichts anzumelden.
- - "NowPlaying.swift"
+ # Heruntergeladen wird auf dem Fernseher nichts.
+ #
+ # `DownloadRefiller.swift` und `DownloadService.swift` kommen
+ # trotzdem mit: `PlayerModel` nennt den Nachfüller in einem Zweig,
+ # der auf tvOS nie läuft (`downloads` ist dort `nil`), und der
+ # Nachfüller wiederum den Dienst. Sie auszuschliessen hiesse, eine
+ # Plattformfrage mitten in zweitausend Zeilen Wiedergabelogik zu
+ # tragen — teurer als zwei Dateien mitzuübersetzen, die niemand
+ # aufruft. Beide sind reine Übersetzer ohne UIKit.
# Reines Modell trotz des Ordnernamens: LoadState, ErrorText und
# MediaRoute liegen zusammen in einer Datei.
- path: Flixy/Views/LoadState.swift
type: file
+ # Der Player. **Ohne PlayerView** — die zeichnet auf iOS das Bild und
+ # alle Bedienelemente selbst; auf dem Fernseher macht das Apples
+ # AVPlayerViewController. Das Modell darunter ist dasselbe.
+ - path: Flixy/Views/Player/PlayerModel.swift
+ type: file
+ - path: Flixy/Views/Player/VideoSurface.swift
+ type: file
- path: Flixy/Branding.swift
type: file
# Die Texte. Dieselben Schlüssel, dieselben Übersetzungen.
@@ -206,6 +216,11 @@ targets:
product: FlixyDesign
- package: FlixyKit
product: FlixyPlayback
+ # Nur damit `PlayerModel` den Typ nennen darf. Angelegt wird nie einer:
+ # FlixyTVApp reicht `nil` durch, und damit gibt es auf tvOS auch keinen
+ # ModelContainer und kein SwiftData.
+ - package: FlixyKit
+ product: FlixyDownloads
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.scarriffleservices.flixy.tv