**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>
106 lines
3.9 KiB
Swift
106 lines
3.9 KiB
Swift
import SwiftUI
|
|
import FlixyDesign
|
|
|
|
/// Was auf dem Fernseher zu sehen ist.
|
|
///
|
|
/// **Nur der Zustandswechsel, keine Navigation.** Das ist alles, was von
|
|
/// `RootView` auf tvOS übrig bleibt: dort sind es 556 Zeilen Seitenleiste,
|
|
/// Tab-Anpassung, gemessene Statusleistenhöhe und Mini-Player-Übergang, und
|
|
/// nichts davon gibt es hier. Deshalb eine eigene Datei und kein `#if` in der
|
|
/// bestehenden — ein `#if` wäre kein Zweig gewesen, sondern ein zweiter Rumpf
|
|
/// in derselben Datei.
|
|
struct TVRootView: View {
|
|
@Environment(FlixySession.self) private var session
|
|
@Environment(PlaybackCoordinator.self) private var playback
|
|
@Environment(QuickPlay.self) private var quickPlay
|
|
|
|
/// Wohin ein Link aus dem Regal führt, sobald der Titel geladen ist.
|
|
@State private var ziel: MediaRoute?
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
Color.flixyCanvas.ignoresSafeArea()
|
|
|
|
switch session.phase {
|
|
case .launching:
|
|
ProgressView()
|
|
|
|
case .signedOut, .authenticating, .choosingServer:
|
|
// Plex bietet die erste Fassung nicht an: die Anmeldung dort
|
|
// läuft über ein Browserfenster, und das gibt es auf tvOS
|
|
// nicht. Alle drei Zustände führen deshalb zur
|
|
// Jellyfin-Anmeldung.
|
|
TVSignInView()
|
|
|
|
case .connecting(let name):
|
|
verbinden(mit: name)
|
|
|
|
case .failed(let meldung):
|
|
fehlgeschlagen(meldung)
|
|
|
|
case .ready:
|
|
TVHomeView(deepLinkTarget: $ziel)
|
|
}
|
|
}
|
|
.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)
|
|
}
|
|
// Der Weg zurück aus dem Regal über der App.
|
|
.onOpenURL { adresse in
|
|
guard let link = TVDeepLink(adresse) else { return }
|
|
Task { await öffne(link) }
|
|
}
|
|
}
|
|
|
|
/// Holt den Titel und macht damit, was der Link sagt.
|
|
///
|
|
/// **Nachgeschlagen, nicht geraten.** Im Regal steht nur eine Kennung;
|
|
/// was dahintersteckt — Film, Serie, Folge — weiss erst der Server. Und
|
|
/// bei einer Serie ist „abspielen" die nächste ungesehene Folge, was
|
|
/// wiederum `QuickPlay` entscheidet.
|
|
private func öffne(_ link: TVDeepLink) async {
|
|
guard let backend = session.backend else { return }
|
|
let kennung = switch link {
|
|
case .play(let id), .detail(let id): id
|
|
}
|
|
guard let item = try? await backend.item(id: kennung) else { return }
|
|
|
|
switch link {
|
|
case .play:
|
|
quickPlay.start(item, backend: backend, coordinator: playback)
|
|
case .detail:
|
|
ziel = MediaRoute(ratingKey: item.id, title: item.title)
|
|
}
|
|
}
|
|
|
|
private func verbinden(mit name: String) -> some View {
|
|
VStack(spacing: FlixySpacing.regular) {
|
|
ProgressView()
|
|
Text(name).font(.flixyRowHeader).foregroundStyle(.white)
|
|
}
|
|
}
|
|
|
|
private func fehlgeschlagen(_ meldung: String) -> some View {
|
|
VStack(spacing: FlixySpacing.regular) {
|
|
Text(meldung).font(.flixyBody).foregroundStyle(.white)
|
|
HStack(spacing: FlixySpacing.regular) {
|
|
Button("action.retry") { Task { await session.retry() } }
|
|
Button("action.signOut") { session.signOut() }
|
|
}
|
|
}
|
|
.frame(maxWidth: 900)
|
|
}
|
|
}
|