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() 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. private var library: 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( library.title, systemImage: symbol(for: library.kind), value: SidebarDestination.library(key: library.id, title: library.title) ) { mediaStack { LibraryView(library: library) } } .customizationID("flixy.tab.library.\(library.id)") } } header: { Text("sidebar.libraries") } .customizationID("flixy.tab.libraries") if session.supportsLiveTV { Tab( "sidebar.liveTV", systemImage: "dot.radiowaves.left.and.right", value: SidebarDestination.liveTV ) { mediaStack { LiveTVView() } } .customizationID("flixy.tab.liveTV") } } else { Tab( "sidebar.library", systemImage: "books.vertical", value: SidebarDestination.libraryHub ) { mediaStack { LibraryHubView() } } .customizationID("flixy.tab.libraryHub") } Tab( "sidebar.downloads", systemImage: "arrow.down.circle", value: SidebarDestination.downloads ) { mediaStack { DownloadsView() } } .customizationID("flixy.tab.downloads") Tab("sidebar.settings", systemImage: "gearshape", value: SidebarDestination.settings) { mediaStack { SettingsView() } } .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) // Das Konto sitzt unten in der Seitenleiste, nicht als Eintrag // zwischen den Mediatheken. Dort sucht man es. .tabViewSidebarBottomBar { AccountBar(selection: $selection) .padding(.horizontal, FlixySpacing.snug) } .miniPlayer(player, isVisible: playback.presentation == .mini) } /// 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(@ViewBuilder content: () -> Content) -> some View { NavigationStack { content() .navigationDestination(for: MediaRoute.self) { MediaDetailView(route: $0) } .navigationDestination(for: LibraryRoute.self) { route in if let library = session.libraries.first(where: { $0.id == route.id }) { LibraryView(library: library) } } .navigationDestination(for: LiveTVRoute.self) { _ in LiveTVView() } } } private func symbol(for kind: MediaLibrary.Kind) -> String { switch kind { case .movie: "film" case .show: "tv" default: "rectangle.stack" } } }