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 } } } /// 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) { NavigationStack { HomeView() } } .customizationID("flixy.tab.home") TabSection { ForEach(session.libraries) { library in Tab( library.title, systemImage: symbol(for: library.kind), value: SidebarDestination.library(key: library.id, title: library.title) ) { NavigationStack { 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 ) { NavigationStack { LiveTVView() } } .customizationID("flixy.tab.liveTV") } Tab( "sidebar.downloads", systemImage: "arrow.down.circle", value: SidebarDestination.downloads ) { NavigationStack { DownloadsView() } } .customizationID("flixy.tab.downloads") Tab("sidebar.settings", systemImage: "gearshape", value: SidebarDestination.settings) { NavigationStack { 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) { NavigationStack { SearchView() } } .customizationID("flixy.tab.search") } .tabViewStyle(.sidebarAdaptable) .tabViewCustomization($customization) .miniPlayer(player, isVisible: playback.presentation == .mini) } private func symbol(for kind: MediaLibrary.Kind) -> String { switch kind { case .movie: "film" case .show: "tv" default: "rectangle.stack" } } }