import SwiftUI import FlaroDesign import FlaroPlayback 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(FlaroSession.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, appearance: subtitleAppearance, offLabel: String(localized: "player.subtitlesOff") ), appearance: subtitleAppearance, onDismiss: { playback.close() } ) .ignoresSafeArea() } else { ProgressView() } } // **Der Stil wirkt sofort, ohne Neuaufbau.** // // Auf `style` und nicht auf die Klasse: ein `@Observable` meldet jede // Berührung, und dann liefe das hier auch, wenn sich nichts geändert // hat. `SubtitleStyle` ist vergleichbar und feuert nur bei einer // echten Änderung. // // Dass es hier steht und nicht im Coordinator, hat denselben Grund // wie die Momentaufnahme: nur im Rumpf einer View entsteht die // Beobachtungsbeziehung. .onChange(of: subtitleAppearance.style) { _, neu in model?.engine?.applySubtitleStyle(neu) } // **Der Menü-Knopf wirkt auch, solange noch nichts läuft.** // // `AVPlayerViewController` fängt ihn selbst ab — aber erst, wenn er // einen Player mit einem Item hat. Solange der Stream noch // ausgehandelt wird oder der Server erst anfängt zu rechnen, gibt es // den nicht, und man sass fest: schwarzes Bild, Kreisel, kein Weg // zurück. Hier liegt der Griff eine Ebene darüber, wo es ihn vom // ersten Bild an gibt. // // Doppelt ist er nicht: greift Apples Player zu, kommt der Befehl // hier gar nicht mehr an. .onExitCommand { playback.close() } .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() } } } }