diff --git a/Flixy/FlixyApp.swift b/Flixy/FlixyApp.swift index fc93617..062350f 100644 --- a/Flixy/FlixyApp.swift +++ b/Flixy/FlixyApp.swift @@ -25,7 +25,6 @@ struct FlixyApp: App { /// läuft statt bei jedem Öffnen des Players neu zu starten. @State private var network = NetworkConditions() @State private var homeLayout = HomeLayoutSettings() - @State private var navigation = NavigationSettings() @State private var diagnostics = PlaybackDiagnostics() @State private var downloads: DownloadCoordinator @@ -67,7 +66,6 @@ struct FlixyApp: App { .environment(network) .environment(downloads) .environment(homeLayout) - .environment(navigation) .environment(diagnostics) .environment(theme) // Akzentfarbe und Tönung setzt RootView, nicht diese Stelle. diff --git a/Flixy/Session/NavigationSettings.swift b/Flixy/Session/NavigationSettings.swift deleted file mode 100644 index 68baa94..0000000 --- a/Flixy/Session/NavigationSettings.swift +++ /dev/null @@ -1,36 +0,0 @@ -import Foundation -import Observation - -/// Wo die Navigation sitzt. -/// -/// Eine Einstellung, kein fester Aufbau: auf einem großen Bildschirm ist eine -/// Leiste an der Seite richtig, auf einem kleinen nimmt sie Platz weg, den man -/// lieber dem Bild gäbe. Wer welche Form will, entscheidet nicht die App. -@MainActor -@Observable -final class NavigationSettings { - enum Placement: String, CaseIterable, Identifiable, Sendable { - /// Seitlich — auf dem iPhone ausfahrbar, auf dem iPad dauerhaft. - case side - /// Oben als Reihe über dem Inhalt. - case top - - var id: String { rawValue } - } - - private enum Key { - static let placement = "flixy.navigation.placement" - } - - private let defaults: UserDefaults - - var placement: Placement { - didSet { defaults.set(placement.rawValue, forKey: Key.placement) } - } - - init(defaults: UserDefaults = .standard) { - self.defaults = defaults - self.placement = defaults.string(forKey: Key.placement) - .flatMap(Placement.init(rawValue:)) ?? .side - } -} diff --git a/Flixy/Views/Navigation/DrawerContainer.swift b/Flixy/Views/Navigation/DrawerContainer.swift deleted file mode 100644 index 7a93288..0000000 --- a/Flixy/Views/Navigation/DrawerContainer.swift +++ /dev/null @@ -1,91 +0,0 @@ -import SwiftUI -import FlixyDesign - -/// Die ausfahrbare Seitenleiste auf dem iPhone. -/// -/// Sie legt sich über den Inhalt und dunkelt ihn ab, statt ihn zur Seite zu -/// schieben — dadurch bleibt die Position im Inhalt erhalten und das Zumachen -/// fühlt sich an wie das Wegwischen eines Blattes. -struct DrawerContainer: View { - @Binding var isOpen: Bool - - @ViewBuilder let sidebar: Sidebar - @ViewBuilder let content: Content - - /// Breit genug für lange Mediatheksnamen, schmal genug, dass vom Inhalt - /// dahinter noch etwas zu sehen bleibt. - private let width: CGFloat = 280 - /// Ab wie weit gezogen die Leiste offen bleibt, statt zurückzuschnappen. - private let commitThreshold: CGFloat = 0.4 - - @GestureState private var drag: CGFloat = 0 - @Environment(\.layoutDirection) private var layoutDirection - - /// 0 = zu, 1 = offen. Fasst Zustand und laufende Geste zusammen, damit - /// Abdunklung und Position immer zueinander passen. - private var openness: CGFloat { - let base = isOpen ? width : 0 - return ((base + drag) / width).clamped() - } - - var body: some View { - ZStack(alignment: .leading) { - content - .accessibilityHidden(isOpen) - - if openness > 0 { - Color.flixyScrim - // Schwächer als früher: die Leiste darüber ist jetzt - // durchscheinend, und ein voller Schleier machte aus dem - // Milchglas wieder eine Wand. - .opacity(openness * 0.7) - .ignoresSafeArea() - .onTapGesture { setOpen(false) } - // Ohne diese Beschriftung ist die Fläche für VoiceOver ein - // stummer Block, der den Inhalt verdeckt. - .accessibilityLabel(Text("sidebar.close")) - .accessibilityAddTraits(.isButton) - .accessibilityAction { setOpen(false) } - } - - sidebar - .frame(width: width) - .frame(maxHeight: .infinity) - .offset(x: (openness - 1) * width) - .accessibilityHidden(!isOpen) - } - .gesture(dragGesture) - .animation(.snappy(duration: 0.28), value: isOpen) - } - - private var dragGesture: some Gesture { - DragGesture(minimumDistance: 12, coordinateSpace: .global) - .updating($drag) { value, state, _ in - // Bei geschlossener Leiste zählt nur ein Zug, der am linken - // Rand beginnt. Sonst würde jedes waagerechte Wischen in einer - // Reihe die Seitenleiste hervorziehen. - if !isOpen { - guard value.startLocation.x < 24 else { return } - state = max(0, value.translation.width) - } else { - state = min(0, value.translation.width) - } - } - .onEnded { value in - guard isOpen || value.startLocation.x < 24 else { return } - let projected = openness + (value.predictedEndTranslation.width - value.translation.width) / width - setOpen(projected > commitThreshold) - } - } - - private func setOpen(_ open: Bool) { - guard open != isOpen else { return } - withAnimation(.snappy(duration: 0.28)) { isOpen = open } - } -} - -extension CGFloat { - fileprivate func clamped(to range: ClosedRange = 0...1) -> CGFloat { - Swift.min(Swift.max(self, range.lowerBound), range.upperBound) - } -} diff --git a/Flixy/Views/Navigation/RootView.swift b/Flixy/Views/Navigation/RootView.swift index 3ce04e2..7cab607 100644 --- a/Flixy/Views/Navigation/RootView.swift +++ b/Flixy/Views/Navigation/RootView.swift @@ -1,15 +1,15 @@ import SwiftUI import FlixyDesign +import MediaKit 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`. +/// 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 @@ -18,7 +18,6 @@ struct RootView: View { @Environment(NetworkConditions.self) private var network @Environment(DownloadCoordinator.self) private var downloads @Environment(PlaybackDiagnostics.self) private var diagnostics - @Environment(NavigationSettings.self) private var navigation /// Der laufende Player — hier gehalten, weil diese Ansicht immer da ist. /// @@ -29,7 +28,13 @@ struct RootView: View { @Environment(\.horizontalSizeClass) private var sizeClass @State private var selection: SidebarDestination = .home - @State private var isSidebarOpen = false + /// 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 { @@ -141,114 +146,86 @@ struct RootView: View { await fresh.start() } - @ViewBuilder - private var library: some View { - switch navigation.placement { - case .top: - topLayout - case .side: - if sizeClass == .regular { - splitLayout - } else { - drawerLayout - } - } - } - - /// Die Navigation als Reihe über dem Inhalt. + /// Die Navigation. /// - /// Ohne Seitenleiste und ohne Blatt: die Einträge stehen alle gleichzeitig - /// da. Auf einem Fernseher und auf einem breiten Fenster ist das die - /// natürlichere Form — und auf dem Handy für den, der lieber tippt als - /// wischt. - private var topLayout: some View { - NavigationStack { - destination - .safeAreaInset(edge: .top, spacing: 0) { - TopNavigationBar(items: allDestinations, selection: $selection) + /// 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) } - /// Alle Einträge in einer Reihe — für die Navigation oben, die keine - /// Abschnitte kennt. - private var allDestinations: [SidebarDestination] { - var items: [SidebarDestination] = [.home] - items.append(contentsOf: sidebarDestinations) - if session.supportsLiveTV { items.append(.liveTV) } - items.append(contentsOf: [.downloads, .settings]) - return items - } - - private var sidebarDestinations: [SidebarDestination] { - session.libraries.map { .library(key: $0.id, title: $0.title) } - } - - /// iPad: die Seitenleiste ist Teil des Fensters. - private var splitLayout: some View { - NavigationSplitView { - SidebarView( - presentation: .column, - libraries: sidebarDestinations, - showsLiveTV: session.supportsLiveTV, - 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, - showsLiveTV: session.supportsLiveTV, - 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.libraries.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() + private func symbol(for kind: MediaLibrary.Kind) -> String { + switch kind { + case .movie: "film" + case .show: "tv" + default: "rectangle.stack" } } } diff --git a/Flixy/Views/Navigation/SidebarDestination.swift b/Flixy/Views/Navigation/SidebarDestination.swift index 06be92b..c195187 100644 --- a/Flixy/Views/Navigation/SidebarDestination.swift +++ b/Flixy/Views/Navigation/SidebarDestination.swift @@ -7,6 +7,7 @@ import Foundation enum SidebarDestination: Hashable { case home case library(key: String, title: String) + case search case liveTV case downloads case settings @@ -20,6 +21,7 @@ enum SidebarDestination: Hashable { switch self { case .home: Branding.appName case .library(_, let title): title + case .search: String(localized: "search.title") case .liveTV: String(localized: "sidebar.liveTV") case .downloads: String(localized: "sidebar.downloads") case .settings: String(localized: "sidebar.settings") @@ -38,6 +40,7 @@ enum SidebarDestination: Hashable { switch self { case .home: "house" case .library: "film" + case .search: "magnifyingglass" case .liveTV: "dot.radiowaves.left.and.right" case .downloads: "arrow.down.circle" case .settings: "gearshape" diff --git a/Flixy/Views/Navigation/SidebarView.swift b/Flixy/Views/Navigation/SidebarView.swift deleted file mode 100644 index 6c75678..0000000 --- a/Flixy/Views/Navigation/SidebarView.swift +++ /dev/null @@ -1,141 +0,0 @@ -import SwiftUI -import FlixyDesign - -/// Der Inhalt der Seitenleiste. -/// -/// Kennt keine Navigation — sie meldet nur, was ausgewählt wurde. Dadurch -/// lässt sie sich auf dem iPhone in den Drawer und auf dem iPad in die -/// `NavigationSplitView` stecken, ohne etwas zu ändern. -struct SidebarView: View { - /// Wo diese Leiste steckt. - /// - /// In der geteilten Ansicht bringt das System die Glasfläche selbst mit — - /// eine eigene läge darüber und machte sie stumpf. Im ausfahrbaren Blatt - /// gibt es keine, also kommt sie von hier. - enum Presentation { case drawer, column } - - var presentation: Presentation = .drawer - let libraries: [SidebarDestination] - /// Ob der aktive Server Live-TV anbietet. - /// - /// Ein Eintrag, der zu einem leeren Bildschirm führt, ist schlimmer als - /// keiner — und bei Jellyfin führt er dorthin, bis der Programmführer - /// umgestellt ist. - var showsLiveTV: Bool = true - @Binding var selection: SidebarDestination - /// Auf dem iPad trägt die Navigationsleiste den Namen bereits — dort wäre - /// eine eigene Überschrift die dritte Nennung auf demselben Bildschirm. - var showsHeader: Bool = true - let onSelect: () -> Void - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: FlixySpacing.section) { - if showsHeader { header } - - section(title: nil, items: [.home]) - - if !libraries.isEmpty { - section(title: String(localized: "sidebar.libraries"), items: libraries) - } - - section( - title: String(localized: "sidebar.more"), - items: showsLiveTV ? [.liveTV, .downloads, .settings] : [.downloads, .settings] - ) - } - .padding(.horizontal, FlixySpacing.regular) - .padding(.vertical, FlixySpacing.section) - } - .scrollBounceBehavior(.basedOnSize) - .modifier(SidebarBackground(presentation: presentation)) - } - - private var header: some View { - Text(Branding.appName) - .font(.flixyTitle) - .foregroundStyle(Color.flixyTextPrimary) - .padding(.horizontal, FlixySpacing.snug) - } - - @ViewBuilder - private func section(title: String?, items: [SidebarDestination]) -> some View { - VStack(alignment: .leading, spacing: FlixySpacing.tight) { - if let title { - Text(title) - .font(.flixyCaption) - .foregroundStyle(Color.flixyTextMuted) - .textCase(.uppercase) - .padding(.horizontal, FlixySpacing.snug) - .padding(.bottom, FlixySpacing.tight) - } - ForEach(items, id: \.self) { item in - SidebarRow(destination: item, isSelected: item == selection) { - selection = item - onSelect() - } - } - } - } -} - -private struct SidebarRow: View { - let destination: SidebarDestination - let isSelected: Bool - let action: () -> Void - - @Environment(\.flixyAccent) private var accent - - var body: some View { - Button(action: action) { - Label { - Text(destination.sidebarLabel).font(.flixyBody) - } icon: { - Image(systemName: destination.symbol) - // Feste Breite, damit die Beschriftungen auf einer Linie - // stehen — SF-Symbole sind unterschiedlich breit. - .frame(width: 22) - } - .foregroundStyle(isSelected ? accent : Color.flixyTextSecondary) - .padding(.horizontal, FlixySpacing.snug) - .padding(.vertical, FlixySpacing.regular) - .frame(maxWidth: .infinity, alignment: .leading) - .background { - if isSelected { - RoundedRectangle(cornerRadius: FlixyRadius.control) - .fill(accent.opacity(0.14)) - } - } - } - .buttonStyle(.plain) - .accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton) - } -} - -/// Die Fläche hinter der Seitenleiste. -/// -/// Ein eigener Modifikator, weil die beiden Fälle verschiedene Ergebnistypen -/// haben und ein `if` im Ansichtskörper sie nicht zusammenbringt. -private struct SidebarBackground: ViewModifier { - let presentation: SidebarView.Presentation - - func body(content: Content) -> some View { - switch presentation { - case .drawer: - // Die zum Inhalt zeigende Kante ist gerundet: so liegt das Blatt - // sichtbar **über** der Seite, statt eine zweite Seite daneben zu - // sein. Ein Vollton stand vorher wie eine Wand im Bild. - content.glassEffect( - .regular, - in: .rect( - topLeadingRadius: 0, - bottomLeadingRadius: 0, - bottomTrailingRadius: FlixyRadius.sheet, - topTrailingRadius: FlixyRadius.sheet - ) - ) - case .column: - content - } - } -} diff --git a/Flixy/Views/Navigation/TopNavigationBar.swift b/Flixy/Views/Navigation/TopNavigationBar.swift deleted file mode 100644 index de59634..0000000 --- a/Flixy/Views/Navigation/TopNavigationBar.swift +++ /dev/null @@ -1,57 +0,0 @@ -import SwiftUI -import FlixyDesign - -/// Die Navigation als Reihe über dem Inhalt. -/// -/// Dieselben Einträge wie in der Seitenleiste, nur nebeneinander statt -/// untereinander. Sie scrollt waagerecht, weil die Zahl der Mediatheken vom -/// Server kommt und niemand weiß, wie viele es sind — zusammengequetschte -/// Beschriftungen wären die schlechtere Antwort als eine Reihe, die weiterläuft. -struct TopNavigationBar: View { - let items: [SidebarDestination] - @Binding var selection: SidebarDestination - - @Environment(\.flixyAccent) private var accent - @Namespace private var glass - - var body: some View { - ScrollView(.horizontal) { - // Ein gemeinsamer Behälter: nebeneinanderliegende Glasflächen - // verschmelzen darin, statt als einzelne Scheiben übereinander zu - // liegen. - GlassEffectContainer(spacing: FlixySpacing.snug) { - HStack(spacing: FlixySpacing.snug) { - ForEach(items, id: \.self) { item in - chip(for: item) - } - } - } - .padding(.horizontal, FlixySpacing.screenMargin) - .padding(.vertical, FlixySpacing.snug) - } - .scrollIndicators(.hidden) - .scrollClipDisabled() - } - - private func chip(for item: SidebarDestination) -> some View { - let isSelected = item == selection - - return Button { - selection = item - } label: { - Label(item.sidebarLabel, systemImage: item.symbol) - .font(.flixyCardTitle) - .lineLimit(1) - .foregroundStyle(isSelected ? accent : Color.flixyTextSecondary) - .padding(.horizontal, FlixySpacing.regular) - .padding(.vertical, FlixySpacing.snug) - } - .buttonStyle(.plain) - .glassEffect( - isSelected ? .regular.tint(accent.opacity(0.28)).interactive() : .regular.interactive(), - in: .capsule - ) - .glassEffectID(item, in: glass) - .accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton) - } -} diff --git a/Flixy/Views/Settings/AppearanceView.swift b/Flixy/Views/Settings/AppearanceView.swift index 956bcbc..efdb63f 100644 --- a/Flixy/Views/Settings/AppearanceView.swift +++ b/Flixy/Views/Settings/AppearanceView.swift @@ -9,7 +9,6 @@ import FlixyDesign /// stillschweigend zu verändern. struct AppearanceView: View { @Environment(FlixyTheme.self) private var theme - @Environment(NavigationSettings.self) private var navigation @State private var customHex: String = "" @State private var showsCustomField = false @@ -17,20 +16,6 @@ struct AppearanceView: View { var body: some View { Form { - Section { - Picker("appearance.navigation", selection: Bindable(navigation).placement) { - ForEach(NavigationSettings.Placement.allCases) { placement in - Text(placement.label).tag(placement) - } - } - .pickerStyle(.segmented) - .padding(.vertical, FlixySpacing.tight) - } header: { - Text("appearance.navigation") - } footer: { - Text("appearance.navigationExplanation") - } - Section { LazyVGrid(columns: columns, spacing: FlixySpacing.regular) { ForEach(AccentOption.palette) { option in @@ -158,11 +143,3 @@ private struct PreviewRow: View { } } -extension NavigationSettings.Placement { - var label: LocalizedStringKey { - switch self { - case .side: "appearance.navigationSide" - case .top: "appearance.navigationTop" - } - } -}