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 // Der Platz für die Leiste: als **echter** Rand am ganzen // TabView, nicht als Sicherheitsrand je Bildschirm. // // Ein Sicherheitsrand wandert nicht in einen geschobenen // Bildschirm mit. Die Einstellungen schieben ihre // Unterseiten über gewöhnliche Verweise, und dort fehlte // er: die Beschriftungen lagen hinter der Leiste und waren // von der Maske abgeschnitten. Ein Rand am TabView gilt // für alles, was darin aufgeht. .padding(chromeInsets) .background { TabChromeSuppressor().frame(width: 0, height: 0) } .mask(alignment: .topLeading) { chromeMask } // Immer dieselbe Ausrichtung: wechselte sie mit der // Stellung, sprang die Leiste im Übergang an die neue Ecke, // statt dorthin zu wandern. .overlay(alignment: .topLeading) { 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 ) // Sie kommt von der Seite herein und geht dorthin zurück. Ohne das // erschien sie schlagartig an ihrem Platz, und beim Umschalten war // nicht zu sehen, dass dasselbe umgezogen ist. .transition(.move(edge: .leading).combined(with: .opacity)) 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) } .transition(.move(edge: .top).combined(with: .opacity)) } } /// 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(@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. /// Sagt einem Bildschirm, wie weit er nach links ausbrechen darf. /// /// Nur noch das: den Platz nimmt sich der Rand am TabView. Hier bleibt die /// Angabe für den Aufmacher, der bewusst darüber hinausgreift. @ViewBuilder private func chromed(@ViewBuilder content: () -> Content) -> some View { #if os(iOS) content() .environment( \.flixyChromeInset, sizeClass == .regular ? max(chromeInsets.leading - FlixySidebar.margin, 0) : 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" } } }