Files
Flaro/FlixyTV/Views/Home/TVHomeView.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

162 lines
6.2 KiB
Swift

import SwiftUI
import FlixyDesign
import MediaKit
/// Die Startseite auf dem Fernseher.
///
/// **Dasselbe Modell wie am Telefon.** `HomeFeed` lädt, ordnet und lost den
/// Aufmacher aus; hier steht nur, wie das aussieht. Der Unterschied ist die
/// Bedienart, nicht der Inhalt.
struct TVHomeView: View {
/// Wohin ein Link aus dem Regal führen soll.
///
/// **Als Bindung von aussen**, nicht als eigener Zustand: das Regal
/// erreicht die Wurzel, der Navigationsstapel liegt hier. Einen zweiten
/// Stapel eine Ebene höher anzulegen, nur um ein Ziel durchzureichen,
/// hiesse zwei Stapel gleich zu halten.
@Binding var deepLinkTarget: MediaRoute?
@Environment(FlixySession.self) private var session
@Environment(HomeLayoutSettings.self) private var layout
@Environment(PlaybackCoordinator.self) private var playback
@Environment(QuickPlay.self) private var quickPlay
@State private var feed = HomeFeed()
@State private var pfad = NavigationPath()
/// Welcher Titel gerade oben gross steht.
///
/// **Der Fokus bestimmt ihn, nicht eine Uhr.** Am Telefon wechselt der
/// Aufmacher alle sieben Sekunden von selbst — dort blättert man ihn auch
/// mit dem Daumen. Vor einem Fernseher wäre ein Bild, das sich von allein
/// austauscht, während man es ansieht, eine Zumutung: man verliert den
/// Titel, den man gerade lesen wollte.
@State private var aufmacher: MediaItem?
var body: some View {
NavigationStack(path: $pfad) {
inhalt
.navigationDestination(for: MediaRoute.self) { route in
TVDetailView(route: route)
}
.navigationDestination(for: LibraryRoute.self) { route in
TVLibraryView(libraryID: route.id)
}
}
.task(id: session.serverName) { await feed.load(from: session.backend) }
.onChange(of: deepLinkTarget) { _, neu in
guard let neu else { return }
pfad.append(neu)
deepLinkTarget = nil
}
}
@ViewBuilder
private var inhalt: some View {
switch feed.state {
case .loading:
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
case .failed(let meldung):
VStack(spacing: FlixySpacing.regular) {
Text(meldung).font(.flixyBody).foregroundStyle(.white)
Button("action.retry") {
Task { await feed.load(from: session.backend) }
}
}
case .loaded:
let reihen = feed.rows(for: layout)
if reihen.isEmpty && session.libraries.isEmpty {
Text("home.empty.title").font(.flixyRowHeader).foregroundStyle(.white)
} else {
seite(reihen: reihen)
}
}
}
private func seite(reihen: [MediaRowGroup]) -> some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: FlixySpacing.section) {
if let gezeigt = aufmacher ?? feed.heroItems(for: layout).first {
TVHeroSection(
item: gezeigt,
onPlay: { spiele(gezeigt) },
onDetails: { zeige(gezeigt) }
)
.frame(height: 720)
}
ForEach(reihen) { reihe in
// **Der Fokus setzt den Aufmacher.** Wer durch „Zuletzt
// hinzugefügt" wandert, sieht oben, was er gerade
// anschaut — ohne irgendwo hindrücken zu müssen.
TVMediaRow(
row: reihe,
onSelect: { spiele($0) },
onFocus: { aufmacher = $0 }
)
}
if !session.libraries.isEmpty {
mediatheken
}
}
.padding(.bottom, FlixySpacing.screenMargin)
}
.scrollIndicators(.never)
// **Bildfüllend, und der Rand steckt in `screenMargin`.** Der
// Aufmacher muss bis an die Bildschirmkante reichen; ein Bild, das
// achtzig Punkte davor aufhört, sieht nach Fehler aus.
.ignoresSafeArea()
}
/// Die Mediatheken als eigene Reihe.
///
/// Unten und nicht oben: wer den Fernseher einschaltet, will meistens
/// weiterschauen, nicht stöbern. Und als Reihe statt als Registerkarten,
/// weil eine Reihe am Fokus hängt und Registerkarten eine zweite Bedienart
/// wären.
private var mediatheken: some View {
VStack(alignment: .leading, spacing: FlixySpacing.regular) {
Text("library.title")
.font(.flixyRowHeader)
.foregroundStyle(Color.flixyTextPrimary)
.padding(.horizontal, FlixySpacing.screenMargin)
ScrollView(.horizontal) {
LazyHStack(spacing: FlixySpacing.regular) {
ForEach(session.libraries, id: \.id) { mediathek in
Button {
pfad.append(LibraryRoute(id: mediathek.id))
} label: {
Text(mediathek.title)
.font(.flixyRowHeader)
.frame(width: 360, height: 180)
}
.buttonStyle(.card)
}
}
.padding(.horizontal, FlixySpacing.screenMargin)
.padding(.vertical, FlixySpacing.regular)
}
.scrollClipDisabled()
.scrollIndicators(.never)
}
}
// MARK: - Was ein Druck auslöst
/// **Über QuickPlay, nicht unmittelbar.** Steht in der Reihe eine Serie —
/// und Jellyfins „Zuletzt hinzugefügt" liefert neue Folgen als ihre Serie
/// —, schickte der direkte Weg deren eigene Kennung an den Player, und der
/// scheiterte mit „keine abspielbare Datei". Eine Serie hat keine
/// Fassungen.
private func spiele(_ item: MediaItem) {
quickPlay.start(item, backend: session.backend, coordinator: playback)
}
private func zeige(_ item: MediaItem) {
pfad.append(MediaRoute(ratingKey: item.id, title: item.title))
}
}