Files
Flaro/FlixyTV/Views/TVRootView.swift
T
ScarriffleandClaude Opus 5 6373566d1c Suchen am Fernseher — und die Leiste, die es dafür braucht
Das Backend konnte die Suche längst; es fehlte die Oberfläche.

**`.searchable`, keine eigene Tastatur.** tvOS macht daraus einen eigenen
Bildschirm mit Rastertastatur oben und Ergebnissen darunter — die
Bedienung, die jeder von Apples Apps kennt. Etwas Eigenes zu bauen hiesse,
sie nachzuahmen und dabei die Diktierfunktion zu verlieren.

**Herausgelöst statt kopiert**, wie schon bei Start und Mediathek:
`SearchFeed` trägt das Entprellen, die Mindestlänge und die zuletzt
gesuchten Begriffe. Drei Stellen, an denen es nicht offensichtlich ist —
und die dritte ist die wichtigste: geprüft wird auf `Task.isCancelled`
und nicht auf `CancellationError`, weil eine abgebrochene Netzanfrage als
Übertragungsfehler zurückkommt. Die naheliegende Prüfung lief ins Leere,
und jeder Tastendruck leerte kurz die Liste.

**Und erst jetzt eine Reiterleiste.** Bis hierher gab es genau einen
Bildschirm, und eine Leiste mit einem Eintrag wäre Zierrat gewesen. Der
Suchreiter trägt `role: .search`: tvOS stellt ihn dadurch ans Ende und
gibt ihm die Lupe, dieselbe Stelle wie überall sonst. Keinen dritten
Reiter für die Mediatheken — die stehen als Reihe auf der Startseite, und
ein Reiter verspräche einen eigenen Bereich, hinter dem dasselbe Raster
läge.

Der offene Reiter wird ausdrücklich gehalten, nicht dem System
überlassen: ein Link aus dem Regal über der App führt zur Startseite, und
stünde gerade die Suche offen, passierte sichtbar nichts.

Nebenbei bekommt die iOS-Suche einen Knopf zum Vergessen des Verlaufs.
Wischen zum Löschen gab es dort, am Fernseher gibt es das nicht — und
eine Liste, die sich nur auf einer Plattform leeren lässt, ist keine.

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

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:
TVShell(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)
}
}