Files
Flaro/FlixyTV/Views/Player/TVPlayerScreen.swift
T
ScarriffleandClaude Opus 5 e801502c7a Ein Regal über der App — und ein Weg aus dem Player heraus
**Das Regal.** Wer im tvOS-Menü über der App steht, sah bisher das Icon
noch einmal gross. Jetzt stehen dort „Weiterschauen", „Als Nächstes" und
die Neuzugänge als Plakate, mit Fortschrittsbalken; ein Druck spielt ab,
ein Wisch nach unten öffnet die Detailseite.

**Warum es dafür eine App-Gruppe braucht.** Das Regal zeichnet eine
eigene Erweiterung, also ein anderer Prozess mit eigener Sandbox — und
tvOS ruft sie auf, während die App **nicht** läuft, mit weniger als einer
Sekunde Zeit. Kein Netzabruf, keine Anmeldung, kein Blättern. Also legt
die App beim Laden der Startseite hin, was das Regal braucht, und sonst
nichts: Titel, Bildadresse, Kennung, Fortschritt. Eine Datei, ein
`JSONDecoder`, fertig.

Die Bildadressen tragen den Zugangsschlüssel, weil Jellyfin ihn für
Bilder verlangt und die Erweiterung ihn sonst nirgends herbekäme. Die
Datei liegt in der App-Gruppe — erreichbar für die App und ihre
Erweiterungen, für sonst niemanden. Der Schlüsselbund bleibt, wo er ist.

**Und der Fehler, den du gefunden hast.** Solange der Stream noch
ausgehandelt wurde, gab es keinen Weg zurück: schwarzes Bild, Kreisel,
und der Menü-Knopf tat nichts. `AVPlayerViewController` fängt ihn nämlich
selbst ab — aber erst, wenn er einen Player mit einem Item hat, und genau
den gibt es in dem Moment noch nicht. Der Griff liegt jetzt eine Ebene
darüber, wo es ihn vom ersten Bild an gibt. Doppelt ist er nicht: greift
Apples Player zu, kommt der Befehl dort gar nicht mehr an.

**Was am Gerät noch offen ist.** Die App-Gruppe muss im Entwicklerkonto
eingetragen sein, sonst bleibt das Regal leer — im Simulator legt das
System sie von selbst an, auf einem echten Apple TV nicht. Geprüft ist,
dass die App die Datei schreibt (drei Reihen, richtige Adressen) und dass
tvOS das Schema kennt. Dass das Regal beim Darüberstehen auch zeichnet,
lässt sich ohne Fernbedienung nicht auslösen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-29 20:14:38 +02:00

101 lines
4.3 KiB
Swift

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,
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() } }
}
}