import SwiftUI import FlixyDesign import FlixyDownloads import FlixyPlayback import PlexKit /// Die Wurzelansicht. /// /// Entscheidet zuerst anhand des Sitzungszustands, ob überhaupt etwas zu /// zeigen ist — und erst danach, in welcher Form die Seitenleiste erscheint: /// auf dem iPhone als ausfahrbares Blatt, auf dem iPad dauerhaft neben dem /// Inhalt. Beide Wege nutzen dieselbe `SidebarView`. 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 @State private var isSidebarOpen = false 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, server: session.server, downloads: downloads, diagnostics: diagnostics, policy: settings.policy(for: network), settings: settings, isLocalConnection: session.server?.connection.local ?? false ) // 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() } @ViewBuilder private var library: some View { if sizeClass == .regular { splitLayout } else { drawerLayout } } private var sidebarDestinations: [SidebarDestination] { session.mediaLibraries.map { .library(key: $0.id, title: $0.title) } } /// iPad: die Seitenleiste ist Teil des Fensters. private var splitLayout: some View { NavigationSplitView { SidebarView( libraries: sidebarDestinations, selection: $selection, showsHeader: false ) {} .navigationTitle(Branding.appName) } detail: { NavigationStack { destination } .miniPlayer(player, isVisible: playback.presentation == .mini) } } /// iPhone: die Seitenleiste fährt über den Inhalt. private var drawerLayout: some View { DrawerContainer(isOpen: $isSidebarOpen) { SidebarView(libraries: sidebarDestinations, selection: $selection) { isSidebarOpen = false } } content: { NavigationStack { destination .toolbar { ToolbarItem(placement: .topBarLeading) { Button { withAnimation(.snappy(duration: 0.28)) { isSidebarOpen = true } } label: { Image(systemName: "line.3.horizontal") } .accessibilityLabel(Text("sidebar.open")) } } } // Innerhalb des Inhalts, nicht darüber: fährt die Seitenleiste // aus, soll sie die Kachel verdecken und nicht umgekehrt. .miniPlayer(player, isVisible: playback.presentation == .mini) } } @ViewBuilder private var destination: some View { switch selection { case .home: HomeView() case .library(let key, let title): if let library = session.mediaLibraries.first(where: { $0.id == key }) { LibraryView(library: library) } else { PlaceholderScreen(destination: .library(key: key, title: title)) } case .liveTV: LiveTVView() case .downloads: DownloadsView() case .settings: SettingsView() } } }