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