Files
Flaro/Flixy/Views/Navigation/RootView.swift
T
ScarriffleandClaude Opus 5 ab0adef8d4 Kein schwarzer Spalt mehr zwischen Bild und Leiste
Die Maske hörte an der Inhaltskante auf, die Leiste endet aber zwölf Punkte
davor — dazwischen stand nichts. Jetzt reicht das Bild bis an ihre Kante heran.

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

531 lines
22 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
/// Was der Nutzer an der Navigation umgestellt hat: Reihenfolge und was
/// überhaupt dastehen soll.
@State private var navigationLayout = NavigationLayoutSettings()
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 nur dort, wo Platz für sie ist.
//
// Die Leiste unten hat fünf Plätze. Mit der Suche wäre einer davon
// für etwas belegt, das man in einer Mediathek sucht und nicht in
// einer Leiste — deshalb sitzt sie auf dem iPhone über der
// Mediathek, neben Filter und Ansicht.
if sizeClass == .regular {
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) }
.mask(alignment: .topLeading) { chromeMask }
.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,
layout: navigationLayout
)
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.map(visible),
selection: $selection,
placement: $placement
)
Spacer(minLength: 0)
}
}
}
/// Wo der Inhalt überhaupt zu sehen sein darf.
///
/// Der Aufmacher greift nach links unter die Seitenleiste. Er scrollt
/// dabei, die Leiste nicht — also blieb über und unter ihr je ein
/// Streifen Bild stehen, den nichts abdeckte. Eine Maske kann das
/// abschneiden, weil sie **hier** liegt, im festen Teil, und nicht im
/// Inhalt, der wandert.
///
/// Sichtbar ist die Vereinigung aus zwei Flächen: alles ab der rechten
/// Kante der Leiste, und dazu genau die Fläche der Leiste. Links davon —
/// der schmale Streifen zum Fensterrand, ober- und unterhalb der Leiste —
/// bleibt nichts übrig.
@ViewBuilder
private var chromeMask: some View {
switch placement {
case .sidebar:
ZStack(alignment: .topLeading) {
Color.black
// Bis an die **Kante der Leiste**, nicht bis an die
// Inhaltskante: dazwischen liegen zwölf Punkte, und die
// standen als schwarze Lücke zwischen Bild und Leiste.
// Jetzt reicht das Bild bis an sie heran.
.padding(.leading, FlixySidebar.width + FlixySidebar.margin)
RoundedRectangle(cornerRadius: FlixyRadius.panel, style: .continuous)
.frame(width: FlixySidebar.width)
.padding(FlixySidebar.margin)
}
case .top:
// Oben greift nichts über den Rand hinaus; eine Maske hätte nichts
// zu tun und könnte nur schaden.
Color.black
}
}
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", plainTitle: String(localized: "sidebar.home")),
NavigationEntry(destination: .downloads, title: Text("sidebar.downloads"), symbol: "arrow.down.circle", plainTitle: String(localized: "sidebar.downloads")),
NavigationEntry(destination: .settings, title: Text("sidebar.settings"), symbol: "gearshape", plainTitle: String(localized: "sidebar.settings")),
NavigationEntry(destination: .search, title: Text("sidebar.search"), symbol: "magnifyingglass", plainTitle: String(localized: "sidebar.search")),
])
]
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),
plainTitle: library.title
)
}
if session.supportsLiveTV {
media.append(NavigationEntry(
destination: .liveTV,
title: Text("sidebar.liveTV"),
symbol: "dot.radiowaves.left.and.right",
plainTitle: String(localized: "sidebar.liveTV")
))
}
if !media.isEmpty {
groups.append(NavigationGroup(id: "libraries", title: "sidebar.libraries", entries: media))
}
return groups
}
/// Eine Gruppe so, wie sie dastehen soll — ausgeblendete Einträge raus,
/// Reihenfolge angewandt. Die Seitenleiste macht das selbst, weil sie zum
/// Bearbeiten auch die ausgeblendeten braucht.
private func visible(_ group: NavigationGroup) -> NavigationGroup {
NavigationGroup(
id: group.id,
title: group.title,
entries: navigationLayout.apply(to: group.entries)
)
}
/// 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() }
}
.navigationDestination(for: SearchRoute.self) { _ in
chromed { SearchView() }
}
}
}
/// 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"
}
}
}