Files
Flaro/Flixy/Views/Navigation/RootView.swift
T
ScarriffleandClaude Opus 5 d0f732402c Der Aufmacher endet an der Leiste, und durch sie sieht man nur noch gedämpft
Vier Dinge, die zusammengehören:

Die Spiegelung ist weg. `backgroundExtensionEffect` füllt die Fläche unter der
Leiste, indem er den Bildrand gespiegelt weiterzeichnet — und je größer die zu
füllende Fläche wurde, desto mehr übernahm der Spiegel das Bild. Auf dem iPhone
legte er sich ohne jede Seitenleiste über den Statusbalken. Jetzt läuft das
Bild selbst weiter, über einen negativen Rand.

Es endet dabei an der **Kante der Leiste**, nicht am Fensterrand. Der
Unterschied sind zwölf Punkte, und die waren deutlich zu sehen: neben der
schwebenden Leiste stand ein heller Streifen Bild, den nichts abdeckte.

Das Seitenverhältnis im Karussell musste weichen. Es legte den Aufmacher auf
die Breite des Inhaltsbereichs fest; weil er nach links unter die Leiste
greift, blieb rechts genau so viel schwarz übrig, wie er links gewann.

Und die Maße, in denen das Hintergrundbild angefordert wird, richten sich jetzt
nach dem Rahmen, in dem es landet. Vorher wurden 900×1200 geholt — hochkant —
und in einem 16:9-Rahmen gezeigt: zu sehen war ein Streifen aus der Mitte, oben
und unten fehlte je ein Drittel.

Dazu: auf schmalen Geräten ist der Aufmacher hochkant. In 16:9 blieben auf
einem iPhone keine zweihundertzwanzig Punkte Höhe, und Schriftzug, Zeile,
Beschreibung und zwei Knöpfe lagen übereinander.

Und ein Schleier unter dem Glas der Leiste. Reines Glas zeigt alles, was
dahinter vorbeizieht, und beim Scrollen zieht die ganze Startseite vorbei — aus
den ruhigen Beschriftungen wurde ein unruhiger Brei. Der Schleier nimmt der
Bewegung die Schärfe, ohne die Fläche zuzumauern.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:05:07 +02:00

462 lines
19 KiB
Swift

import SwiftUI
import FlixyDesign
import MediaKit
import FlixyDownloads
import FlixyPlayback
import PlexKit
/// Die Wurzelansicht.
///
/// Entscheidet anhand des Sitzungszustands, ob überhaupt etwas zu zeigen ist.
/// Welche Form die Navigation danach annimmt — Seitenleiste, Leiste oben,
/// Leiste unten —, entscheidet das System und der Nutzer, nicht diese Datei.
struct RootView: View {
@Environment(FlixySession.self) private var session
@Environment(FlixyTheme.self) private var theme
@Environment(PlaybackCoordinator.self) private var playback
@Environment(PlaybackSettings.self) private var settings
@Environment(NetworkConditions.self) private var network
@Environment(DownloadCoordinator.self) private var downloads
@Environment(PlaybackDiagnostics.self) private var diagnostics
/// Der laufende Player — hier gehalten, weil diese Ansicht immer da ist.
///
/// Damit überlebt die Wiedergabe den Wechsel zwischen Kachel und
/// Vollbild. Läge das Modell im Player selbst, stürbe es mit ihm, und
/// genau das ist vorher passiert.
@State private var player: PlayerModel?
@Environment(\.horizontalSizeClass) private var sizeClass
@State private var selection: SidebarDestination = .home
/// Was der Nutzer an der Navigation umgestellt hat.
///
/// Reihenfolge, Ein- und Ausblenden, und ob ein Eintrag in der Leiste oben
/// liegt: das System schreibt es hier hinein, wir geben ihm nur einen Ort
/// zum Aufbewahren.
@AppStorage("flixy.navigation.customization")
private var customization = TabViewCustomization()
/// Ob die Navigation an der Seite oder oben sitzt.
///
/// Gespeichert, weil die Wahl dem Nutzer gehört und den Neustart
/// überleben muss.
@AppStorage("flixy.navigation.placement")
private var placement: NavigationPlacement = .sidebar
var body: some View {
Group {
switch session.phase {
case .launching:
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.flixyCanvas)
case .signedOut, .authenticating:
SignInView(
phase: session.phase,
manualCode: session.showsManualCode ? session.pendingCode : nil,
onSignIn: { Task { await session.beginSignIn() } },
onCancel: { session.cancelSignIn() }
)
case .choosingServer(let servers):
NavigationStack {
ServerPickerView(servers: servers) { resource in
Task { await session.connect(to: resource) }
}
}
case .connecting(let name):
ConnectingView(serverName: name)
case .failed(let message):
ConnectionFailureView(
message: message,
onRetry: { Task { await session.retry() } },
onSignOut: { session.signOut() }
)
case .ready:
library
}
}
.background(Color.flixyCanvas)
// Hier statt in der Scene: eine View wertet Beobachtungsänderungen
// zuverlässig neu aus, ein Scene-Body nicht.
.flixyAccent(theme)
.tint(theme.accentColor)
// Einmal an der Wurzel statt in jedem Bildschirm, von dem aus sich
// etwas starten lässt — sonst gäbe es vier Kopien desselben Übergangs.
// Kein `fullScreenCover` mehr: das ist eine eigene Hierarchie, und
// die Bildfläche müsste bei jedem Wechsel zwischen Kachel und
// Vollbild umziehen. Als Schicht im selben Baum bleibt sie, wo sie
// ist, und der Übergang ist ruhig.
.overlay {
if playback.presentation == .full, let player {
PlayerView(model: player)
.ignoresSafeArea()
.transition(.move(edge: .bottom))
}
}
.animation(.snappy(duration: 0.3), value: playback.presentation)
.onChange(of: playback.request) { _, request in
Task { await handOver(to: request) }
}
.task { await session.restore() }
.onChange(of: session.phase) { _, newPhase in
// Nach einer Neuanmeldung darf nicht eine Mediathek ausgewählt
// bleiben, die es auf dem neuen Server gar nicht gibt.
if newPhase == .ready, case .library = selection { selection = .home }
}
.onChange(of: sizeClass) { _, _ in
// Beim Drehen oder Teilen des Fensters wechselt die Form der
// Navigation — und mit ihr, welche Ziele es überhaupt gibt. Eine
// Auswahl, die auf der anderen Seite nicht vorkommt, zeigte sonst
// einen leeren Bereich.
switch selection {
case .library, .libraryHub, .liveTV: selection = .home
default: break
}
}
}
/// Wechselt den laufenden Titel.
///
/// Das Neue wird zuerst aufgebaut, das Alte **nebenher** beendet. Anders
/// herum säße man nach dem Antippen sekundenlang vor einem leeren Bild:
/// das Abräumen spricht mit dem Server — Transcoder anhalten, beim
/// Live-TV den Tuner freigeben —, und das darf den neuen Titel nicht
/// aufhalten. Ein kurzzeitig doppelt belegter Tuner ist der kleinere
/// Preis; der Sender, auf den umgeschaltet wird, ist ohnehin schon
/// abgestimmt, bevor es hier ankommt.
private func handOver(to request: PlaybackRequest?) async {
// Zuerst und ohne Warten: den alten Engine stilllegen. Das Aufräumen am
// Server darf dauern, das Abschalten nicht — läuft der alte Engine
// weiter, während der neue startet, streiten sich zwei um dieselbe
// Audiositzung. Zu hören war das als alter Ton, der weiterlief, und
// als neue Wiedergabe, die stumm blieb.
let previous = player
previous?.silence()
player = nil
if let previous { Task { await previous.releaseOnServer() } }
guard let request else { return }
let fresh = PlayerModel(
request: request,
backend: session.backend,
// Live-TV spricht als Einziges noch unmittelbar mit Plex.
liveTV: session.server,
downloads: downloads,
diagnostics: diagnostics,
policy: settings.policy(for: network),
settings: settings,
isLocalConnection: session.server?.connection.local ?? false,
connectionDescription: session.connectionDescription,
unavailableMessage: session.playbackUnavailableMessage
)
// Aus Bild-in-Bild zurückzukehren soll im Vollbild landen, nicht in
// der Kachel — dort kam man vorher gar nicht mehr heraus.
fresh.onPictureInPictureRestore { playback.expand() }
player = fresh
await fresh.start()
}
/// Die Navigation.
///
/// Sie kommt vom System, nicht von uns. `sidebarAdaptable` ist genau die
/// Form, die man aus JellyTV kennt: auf dem iPad eine Seitenleiste aus
/// Glas, über deren Kopfzeile sich mit einem Knopf auf eine Leiste **oben**
/// umschalten lässt, auf dem iPhone die gewohnte Leiste unten. Dazu gibt
/// es „Bearbeiten" mit Ziehen, Ein- und Ausblenden — alles vom System,
/// übersetzt, barrierefrei und mit der Gestaltung, die Apple in diesem
/// Jahr vorgibt.
///
/// Vorher standen hier drei selbstgebaute Formen nebeneinander: ein
/// ausfahrbares Blatt, eine geteilte Ansicht und eine Reihe oben. Sie
/// konnten zusammen weniger als das eine Bauteil und mussten dreimal
/// gepflegt werden.
/// Läuft diese App gerade auf einem Mac?
///
/// Nicht über `#if` zu haben: „Designed for iPad" **ist** der iOS-Bau,
/// unverändert, nur auf anderer Hardware. Erst zur Laufzeit ist der
/// Unterschied sichtbar.
static let isOnMac = ProcessInfo.processInfo.isMacCatalystApp
private var tabs: some View {
TabView(selection: $selection) {
Tab("sidebar.home", systemImage: "house", value: SidebarDestination.home) {
mediaStack { HomeView() }
}
.customizationID("flixy.tab.home")
// Auf dem iPad jede Mediathek einzeln, auf dem iPhone alle
// zusammen hinter einem Eintrag. Der Grund ist nicht Geschmack:
// die Leiste unten hat fünf Plätze, und alles darüber schiebt das
// System in ein „Mehr"-Verzeichnis — dessen Einträge dann zwei
// Navigationsleisten übereinander tragen, eine vom System und eine
// von uns.
if sizeClass == .regular {
TabSection {
ForEach(session.libraries) { library in
Tab(
value: SidebarDestination.library(key: library.id, title: library.title)
) {
mediaStack { LibraryView(library: library) }
} label: {
sidebarLabel(Text(library.title), symbol: symbol(for: library.kind))
}
.customizationID("flixy.tab.library.\(library.id)")
}
} header: {
Text("sidebar.libraries")
}
.customizationID("flixy.tab.libraries")
if session.supportsLiveTV {
Tab(value: SidebarDestination.liveTV) {
mediaStack { LiveTVView() }
} label: {
tabLabel("sidebar.liveTV", symbol: "dot.radiowaves.left.and.right")
}
.customizationID("flixy.tab.liveTV")
}
} else {
Tab(value: SidebarDestination.libraryHub) {
mediaStack { LibraryHubView() }
} label: {
tabLabel("sidebar.library", symbol: "books.vertical")
}
.customizationID("flixy.tab.libraryHub")
}
Tab(value: SidebarDestination.downloads) {
mediaStack { DownloadsView() }
} label: {
tabLabel("sidebar.downloads", symbol: "arrow.down.circle")
}
.customizationID("flixy.tab.downloads")
Tab(value: SidebarDestination.settings) {
mediaStack { SettingsView() }
} label: {
tabLabel("sidebar.settings", symbol: "gearshape")
}
.customizationID("flixy.tab.settings")
// Die Suche bekommt ihre eigene Rolle: das System stellt sie
// getrennt von den übrigen Einträgen dar, so wie man es aus jeder
// anderen App kennt.
Tab(value: SidebarDestination.search, role: .search) {
mediaStack { SearchView() }
}
.customizationID("flixy.tab.search")
}
.tabViewStyle(.sidebarAdaptable)
.tabViewCustomization($customization)
}
/// Die Navigation, wie **wir** sie zeichnen.
///
/// Der `TabView` darunter bleibt stehen und macht weiter, was er kann:
/// Auswahl, ein eigener Navigationsstapel je Eintrag, erhaltener
/// Scrollstand beim Wechseln. Nur sein Kleid ist ausgeblendet — siehe
/// `TabChromeSuppressor`. Gezeichnet wird darüber.
///
/// Der Grund steht dort ausführlich: die System-Seitenleiste sitzt auf dem
/// iPad als schwebende Fläche und auf dem Mac angeklebt am Fensterrand,
/// und das lässt sich nicht umstellen. Da beides gleich aussehen soll,
/// zeichnen wir es selbst.
///
/// Auf dem iPhone bleibt alles beim System: dort gibt es keine
/// Seitenleiste, sondern die Leiste unten, und die ist überall dieselbe.
private var library: some View {
Group {
#if os(iOS)
if sizeClass == .regular {
tabs
.background { TabChromeSuppressor().frame(width: 0, height: 0) }
.overlay(alignment: placement == .sidebar ? .topLeading : .top) {
chrome
}
} else {
tabs
}
#else
tabs
#endif
}
.miniPlayer(player, isVisible: playback.presentation == .mini)
}
@ViewBuilder
private var chrome: some View {
switch placement {
case .sidebar:
FlixySidebar(groups: navigationGroups, selection: $selection, placement: $placement)
case .top:
// In der Mitte, und zwar erzwungen: die Leiste ist so breit wie
// ihr Inhalt, und eine Überlagerung richtet sie dann nicht
// zuverlässig mittig aus.
HStack {
Spacer(minLength: 0)
FlixyTopBar(groups: navigationGroups, selection: $selection, placement: $placement)
Spacer(minLength: 0)
}
}
}
private var chromeInsets: EdgeInsets {
switch placement {
case .sidebar:
EdgeInsets(
top: 0,
leading: FlixySidebar.width + FlixySidebar.margin * 2,
bottom: 0,
trailing: 0
)
case .top:
EdgeInsets(top: 52, leading: 0, bottom: 0, trailing: 0)
}
}
/// Die Einträge, aus denen die Leiste gebaut wird.
///
/// Dieselbe Liste für beide Formen: was an der Seite steht, steht auch
/// oben. Sonst wäre das Umschalten ein Wechsel der App und nicht nur
/// einer der Anordnung.
private var navigationGroups: [NavigationGroup] {
var groups: [NavigationGroup] = [
NavigationGroup(id: "main", title: nil, entries: [
NavigationEntry(destination: .home, title: Text("sidebar.home"), symbol: "house"),
NavigationEntry(destination: .downloads, title: Text("sidebar.downloads"), symbol: "arrow.down.circle"),
NavigationEntry(destination: .settings, title: Text("sidebar.settings"), symbol: "gearshape"),
NavigationEntry(destination: .search, title: Text("sidebar.search"), symbol: "magnifyingglass"),
])
]
var media = session.libraries.map { library in
NavigationEntry(
destination: .library(key: library.id, title: library.title),
title: Text(library.title),
symbol: symbol(for: library.kind)
)
}
if session.supportsLiveTV {
media.append(NavigationEntry(
destination: .liveTV,
title: Text("sidebar.liveTV"),
symbol: "dot.radiowaves.left.and.right"
))
}
if !media.isEmpty {
groups.append(NavigationGroup(id: "libraries", title: "sidebar.libraries", entries: media))
}
return groups
}
/// Ein Navigationsstapel, in dem Detailseiten aufgehen.
///
/// Das Ziel wird **einmal je Stapel** angemeldet, an seiner Wurzel. Vorher
/// tat es jeder Bildschirm für sich — und in dem Moment, in dem zwei davon
/// im selben Stapel übereinanderlagen (die Mediathek in der Bibliothek),
/// führte kein Antippen einer Kachel mehr irgendwohin. SwiftUI meldet das
/// nicht; es passiert einfach nichts.
private func mediaStack<Content: View>(@ViewBuilder content: () -> Content) -> some View {
NavigationStack {
chromed { content() }
.navigationDestination(for: MediaRoute.self) { route in
chromed { MediaDetailView(route: route) }
}
.navigationDestination(for: LibraryRoute.self) { route in
chromed {
if let library = session.libraries.first(where: { $0.id == route.id }) {
LibraryView(library: library)
}
}
}
.navigationDestination(for: LiveTVRoute.self) { _ in
chromed { LiveTVView() }
}
}
}
/// Gibt einem Bildschirm den Platz, den unsere Leiste einnimmt.
///
/// Je Bildschirm und nicht einmal um den Stapel herum: weder der `TabView`
/// noch der `NavigationStack` reichen einen Sicherheitsrand an ihren Inhalt
/// weiter — der Aufmacher lag beide Male zur Hälfte hinter der Leiste.
///
/// Als **Sicherheitsrand**, nicht als Abstand. Der Unterschied ist der
/// ganze Witz: eine Reihe bleibt damit fensterbreit und schiebt ihre
/// Kacheln beim Wischen hinter die Leiste, stellt aber im Ruhezustand
/// keine dorthin. Mit einem gewöhnlichen Abstand endete sie an der Kante.
@ViewBuilder
private func chromed<Content: View>(@ViewBuilder content: () -> Content) -> some View {
#if os(iOS)
let insets = sizeClass == .regular ? chromeInsets : EdgeInsets()
content()
.safeAreaPadding(insets)
// Wie weit der Aufmacher nach links ausbrechen darf: bis genau an
// die **Kante der Leiste**, nicht bis an den Fensterrand.
//
// Der Unterschied ist ein Streifen von zwölf Punkten, und der war
// deutlich zu sehen: die Leiste schwebt mit Abstand zum Rand, das
// Bild lief daran vorbei, und neben der Leiste stand ein heller
// Streifen Bild, den nichts abdeckte. Endet das Bild an ihrer
// Kante, sieht man es nur noch **durch** sie hindurch — und genau
// so soll es sein.
.environment(\.flixyChromeInset, max(insets.leading - FlixySidebar.margin, 0))
#else
content()
#endif
}
/// Die Breite der Seitenleiste auf dem Mac.
///
/// Sie richtet sich nach ihrem Inhalt. Ohne Untergrenze fällt sie bei
/// kurzen Wörtern wie „Start" so weit zusammen, dass der Kontoname darunter
/// abgeschnitten wird; **ohne Obergrenze** ließ sie sich über das halbe
/// Fenster ziehen. Beide Zahlen sind nötig, und die obere entspricht dem,
/// was eine Seitenleiste auf dem iPad breit ist.
#if os(macOS) || targetEnvironment(macCatalyst)
private static let sidebarWidth: (min: CGFloat, ideal: CGFloat, max: CGFloat) =
(min: 190, ideal: 230, max: 320)
#endif
/// Die Beschriftung eines Eintrags.
///
/// **Ein** Aufruf, nicht zwei. Es gab hier eine zweite Fassung für Titel,
/// die vom Server kommen — mit `String` statt `LocalizedStringKey`. Swift
/// darf sich bei einem Literal für beide entscheiden, und es entschied sich
/// nicht überall gleich: in der Seitenleiste stand plötzlich
/// „sidebar.downloads" statt „Downloads". Wer einen fertigen Text hat,
/// baut sein `Text` jetzt selbst.
private func tabLabel(_ title: LocalizedStringKey, symbol: String) -> some View {
sidebarLabel(Text(title), symbol: symbol)
}
private func sidebarLabel(_ title: Text, symbol: String) -> some View {
let label = Label { title } icon: { Image(systemName: symbol) }
#if os(macOS)
return label.frame(
minWidth: Self.sidebarWidth.min - 40,
maxWidth: Self.sidebarWidth.max - 40,
alignment: .leading
)
#else
return label
#endif
}
private func symbol(for kind: MediaLibrary.Kind) -> String {
switch kind {
case .movie: "film"
case .show: "tv"
default: "rectangle.stack"
}
}
}