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(LanguagePreferences.self) private var languages @Environment(QuickPlay.self) private var quickPlay @Environment(SubtitleAppearance.self) private var subtitleAppearance @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. /// /// Die Wahl gehört dem Nutzer und muss den Neustart überleben. @AppStorage("flixy.navigation.placement") private var storedPlacement: NavigationPlacement = .sidebar /// Dieselbe Wahl, aber als Zustand dieser Ansicht. /// /// Der Umweg ist nötig, damit das Umschalten überhaupt zu sehen ist: /// `@AppStorage` schreibt in die Voreinstellungen und meldet die Änderung /// erst danach zurück. Sie kommt also außerhalb der Animation an, und die /// Leiste sprang von einer Stellung in die andere, statt zu wandern. /// /// Hier ändert sich der Zustand sofort und im selben Zug; gespeichert wird /// hinterher. @State private var placement: NavigationPlacement = .sidebar /// Wie die Leiste umzieht. Kurz, aber sichtbar. private static let placementAnimation: Animation = .snappy(duration: 0.32) /// Was der Nutzer an der Navigation umgestellt hat: Reihenfolge und was /// überhaupt dastehen soll. @State private var navigationLayout = NavigationLayoutSettings() /// Die Insel oben: wie hoch sie baut und ob sie gerade zu sehen ist. @State private var topChrome = TopChrome() /// Wie hoch die Statusleiste baut. /// /// Gemessen und nicht angenommen: es sind nicht überall dieselben Punkte, /// und beim Drehen ändert sich der Wert. Gemessen wird **außerhalb** des /// Bereichs, der den Sicherheitsrand gleich darauf ignoriert — innerhalb /// wäre er längst null. @State private var statusBarHeight: CGFloat = 0 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) .environment(topChrome) // **Aus dem Fenster, nicht aus dem Layout.** // // Hier stand `proxy.safeAreaInsets.top` — ein Wert, der beim Drehen // erst eine Runde später stimmt. Nachgemessen sah man das genau // einmal: quer war die Seitenleiste zuerst 620,67 Punkte hoch und // dann 558,67. Die Differenz sind zweiundsechzig, und die kamen von // `.padding(.top, statusBarHeight)` an der Leiste weiter unten, das // noch den Wert aus dem Hochformat trug. // // Das Fenster kennt seinen Rand ohne Umweg über das Layout, das er // selbst beeinflusst. Die Grösse bleibt der Anlass zum Nachfragen, // der **Wert** kommt von aussen — und stimmt damit im ersten // Durchgang. // // **Nicht** die Ursache des Players, der oben und unten hinausging: // das war die Seitenleiste selbst, siehe `FlixySidebar`. Diese Zeile // spart eine Layoutrunde, mehr nicht. .onGeometryChange(for: CGSize.self) { proxy in proxy.size } action: { _ in statusBarHeight = ScreenSafeArea.current.top } .onAppear { statusBarHeight = ScreenSafeArea.current.top } // Hier statt in der Scene: eine View wertet Beobachtungsänderungen // zuverlässig neu aus, ein Scene-Body nicht. .flixyAccent(theme) .tint(theme.accentColor) // **Einer für alle Kacheln.** Einer je Kachel wäre bei einem // Serverausfall ein Stapel aus zwanzig gleichen Meldungen. .alert( Text("quickPlay.failed"), isPresented: Binding( get: { quickPlay.failure != nil }, set: { if !$0 { quickPlay.failure = nil } } ) ) { Button("action.close", role: .cancel) { quickPlay.failure = nil } } message: { if let grund = quickPlay.failure { Text(grund) } } // 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 { // **Bildfüllend, und der Player passt drinnen selbst auf.** // // Ohne diese Zeile verliert die Bildfläche ihr // `ignoresSafeArea` mit — das Bild wäre im Querformat um die // Notch-Breite schmaler. Wo der Schirm aufhört, holt sich der // Player deshalb aus dem Fenster; siehe `ScreenSafeArea`. 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, liveTV: session.liveTV, downloads: downloads, diagnostics: diagnostics, policy: settings.policy(for: network), settings: settings, languages: languages, subtitles: subtitleAppearance, // **Nicht mehr über `server`.** Der ist ein `PlexServer` und bei // Jellyfin immer `nil` — damit galt dort jede Wiedergabe als // entfernt, auch zuhause am Kabel, und „Auto" hätte ein // 67-Mbit-Remux ohne Not heruntergerechnet. isLocalConnection: session.isLocalConnection, 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 Sheet, 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 { HStack(spacing: 0) { if placement == .sidebar { FlixySidebar( groups: navigationGroups, selection: $selection, placement: $placement.animation(Self.placementAnimation), layout: navigationLayout ) // Sie klappt nach **oben** weg und kommt von oben // zurück — und die Leiste oben schiebt sich seitwärts // herein beziehungsweise hinaus. Zusammen sieht es aus, // als zöge dieselbe Leiste um: erst zusammenklappen, // dann hinüberschieben. .transition(.move(edge: .top).combined(with: .opacity)) // Die Statusleiste gehört ihr nicht. Seit der Baum den // Sicherheitsrand ignoriert, säße ihre Oberkante sonst // unter der Uhrzeit. .padding(.top, statusBarHeight) } tabs // Der Platz oben als **Sicherheitsrand**, nicht als // Abstand. // // Der Unterschied ist der schwarze Balken, über den du // dich zu Recht gewundert hast: mit einem Abstand hört // der Inhalt dort auf und die Fläche darüber bleibt für // immer leer. Als Sicherheitsrand fängt er im // Ruhezustand ebenso darunter an — aber beim Scrollen // wandert er **hindurch**, bis an den Bildschirmrand. // Die Leiste ist eine Insel; hinter einer Insel läuft // das Wasser durch. // // Anders als `safeAreaPadding` erreicht das auch die // geschobenen Bildschirme: deren Navigationsleisten // richten sich danach. // // Die Statusleiste ist mit eingerechnet, weil der Baum // sie gleich unten ignoriert. Ohne sie begänne der // Inhalt hinter der Uhrzeit. .safeAreaInset(edge: .top, spacing: 0) { Color.clear.frame(height: topInset) } .background { TabChromeSuppressor().frame(width: 0, height: 0) } .overlay(alignment: .top) { if placement == .top { FlixyTopBar( groups: navigationGroups.map(visible), selection: $selection, placement: $placement.animation(Self.placementAnimation) ) .transition(.move(edge: .leading).combined(with: .opacity)) .padding(.top, statusBarHeight) // Beim Scrollen fährt sie nach oben weg. // // Nur sie: der Sicherheitsrand darunter bleibt, // wie er ist. Schrumpfte er mit, rückte der // Inhalt bei jedem Ein- und Ausblenden nach — // man scrollt, und die Seite ruckt einem unter // dem Finger weg. .offset(y: topChrome.isRevealed ? 0 : -topInset) .opacity(topChrome.isRevealed ? 1 : 0) .animation(.snappy(duration: 0.28), value: topChrome.isRevealed) } } } // Hier reißt der Baum bis an den Bildschirmrand auf. // // Das ist der eigentliche Griff. Vorher saß alles — Leiste, // Bildschirme, Bildlaufansichten — innerhalb des // Sicherheitsrands, und deshalb konnte kein noch so gut // gesetzter Rand weiter unten den schwarzen Streifen über der // Insel erreichen: Inhalt kam dort nie hin. Jetzt kommt er. .ignoresSafeArea(.container, edges: .top) } else { tabs } #else tabs #endif } .miniPlayer(player, isVisible: playback.presentation == .mini) .onAppear { placement = storedPlacement } .onChange(of: placement) { _, new in storedPlacement = new // Zieht die Navigation um, soll die neue Leiste nicht // ausgeblendet ankommen. topChrome.isRevealed = true } // Weitergereicht, damit der Aufmacher weiß, worunter er greifen muss. .onChange(of: topInset, initial: true) { _, new in topChrome.inset = new } } /// Wie viel Platz die Leiste oben braucht. private static let topBarHeight: CGFloat = 52 /// Wie tief der Inhalt unter dem oberen Bildschirmrand beginnt. /// /// Die Statusleiste plus das, was die Navigation dort belegt — bei der /// Seitenleiste nur ihr Rand, bei der Leiste oben deren volle Höhe. /// /// Auf dem iPhone null: dort sitzt die Leiste unten, und über dem Inhalt /// schwebt nichts, worunter man greifen müsste. private var topInset: CGFloat { guard sizeClass == .regular else { return 0 } return statusBarHeight + (placement == .sidebar ? FlixySidebar.margin : Self.topBarHeight) } 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 { 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() } .navigationDestination(for: SearchRoute.self) { _ in SearchView() } } } /// 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" } } }