diff --git a/Flixy/Session/MediaItem+Presentation.swift b/Flixy/Session/MediaItem+Presentation.swift index ca41a52..36f3cbf 100644 --- a/Flixy/Session/MediaItem+Presentation.swift +++ b/Flixy/Session/MediaItem+Presentation.swift @@ -77,3 +77,23 @@ extension Duration { return String(localized: "runtime.minutes \(minutes)") } } + +extension MediaLibrary.Kind { + /// Das Symbol, an dem man eine Mediathek erkennt. + /// + /// **Eine Zuordnung, nicht drei.** Dieselbe Entscheidung stand bisher an + /// drei Stellen — in der Seitenleiste, im Zwischenbildschirm und am + /// Navigationsziel —, und die dritte war falsch: sie gab jeder Mediathek + /// „film", auch einer mit Serien. Drei Kopien einer Zuordnung halten sich + /// nicht von allein gleich. + public var symbol: String { + switch self { + case .movie: "film" + case .show: "tv" + case .music: "music.note" + case .photo: "photo" + case .liveTV: "dot.radiowaves.left.and.right" + case .other: "rectangle.stack" + } + } +} diff --git a/Flixy/Views/Downloads/DownloadsView.swift b/Flixy/Views/Downloads/DownloadsView.swift index 1640946..bf7c7b6 100644 --- a/Flixy/Views/Downloads/DownloadsView.swift +++ b/Flixy/Views/Downloads/DownloadsView.swift @@ -9,6 +9,7 @@ struct DownloadsView: View { @Environment(PlaybackCoordinator.self) private var playback @Environment(NetworkConditions.self) private var network @Environment(\.flixyAccent) private var accent + @Environment(\.horizontalSizeClass) private var sizeClass var body: some View { @@ -25,6 +26,13 @@ struct DownloadsView: View { } .background(Color.flixyCanvas) .navigationTitle(Text("sidebar.downloads")) + .toolbar { + // Nur auf dem iPhone: dort gibt es den Einstellungen-Tab nicht + // mehr, und dies ist der Weg dorthin. + if sizeClass == .compact { + ToolbarItem(placement: .flixyTrailing) { AccountToolbarButton() } + } + } .flixyInlineTitle() // Keine Rückfrage beim Entfernen. // diff --git a/Flixy/Views/Library/LibraryBrowser.swift b/Flixy/Views/Library/LibraryBrowser.swift new file mode 100644 index 0000000..71f8f93 --- /dev/null +++ b/Flixy/Views/Library/LibraryBrowser.swift @@ -0,0 +1,154 @@ +import SwiftUI +import FlixyDesign +import MediaKit + +/// Die Mediatheken auf dem iPhone — mit einer Leiste zum Wechseln. +/// +/// **Was hier wegfällt, ist ein Zwischenschritt.** Vorher stand an dieser +/// Stelle eine Liste aller Mediatheken; wer Filme sehen wollte, tippte auf +/// „Bibliothek", dann auf „Filme", und bekam erst dann ein Raster. Zurück und +/// in eine andere hiess: zweimal tippen. Der Zwischenbildschirm hat nie etwas +/// erzählt, was die Leiste nicht auch zeigen kann. +/// +/// Auf dem iPad und am Mac gibt es ihn ohnehin nicht — dort steht jede +/// Mediathek einzeln in der Seitenleiste, und auf dem Fernseher nebeneinander +/// in der oberen Leiste. Das hier ist dieselbe Idee in der Form, die ein +/// Telefon hergibt. +/// +/// **Warum eine zweite Leiste und nicht Plätze in der unteren.** Die hat +/// fünf, und alles darüber schiebt das System in ein „Mehr"-Verzeichnis, +/// dessen Einträge zwei Navigationsleisten übereinander tragen. Eine selbst +/// gezeichnete Leiste kennt diese Grenze nicht. +struct LibraryBrowser: View { + @Environment(FlixySession.self) private var session + + /// Was gerade gezeigt wird. + /// + /// `nil` heisst „noch nichts gewählt" — dann gilt die erste Mediathek. + /// Eine Vorauswahl beim Anlegen ginge nicht: die Mediatheken kommen vom + /// Server und stehen beim ersten Aufbau noch nicht fest. + @State private var auswahl: Auswahl? + + private enum Auswahl: Hashable { + case library(String) + case liveTV + } + + var body: some View { + inhalt + .background(Color.flixyCanvas) + // **Als Sicherheitsrand, nicht als Überzug.** So endet die Liste + // über der Leiste, statt dahinter zu verschwinden — und das gilt + // auch für die Ladeanzeige und den Fuss „alles geladen". + .safeAreaInset(edge: .bottom, spacing: 0) { + if eintraege.count > 1 { leiste } + } + } + + @ViewBuilder + private var inhalt: some View { + switch aktuell { + case .library(let id): + if let library = session.libraries.first(where: { $0.id == id }) { + // **Mit `id`, damit der Wechsel sauber neu aufsetzt.** Ohne + // das behielte die Ansicht die Titel der vorigen Mediathek, + // bis der Server antwortet — und für einen Augenblick stünden + // Filme unter „Serien". + LibraryView(library: library).id(library.id) + } + case .liveTV: + LiveTVView() + case nil: + ContentUnavailableView { + Label("libraryHub.empty", systemImage: "books.vertical") + } + } + } + + // MARK: - Die Leiste + + private var leiste: some View { + ScrollView(.horizontal) { + HStack(spacing: 0) { + ForEach(eintraege, id: \.auswahl) { eintrag in + pille(eintrag) + } + } + .padding(.horizontal, FlixySpacing.snug) + .padding(.vertical, FlixySpacing.snug) + } + // Bei vier Einträgen passt alles; die Leiste soll trotzdem nicht + // brechen, wenn jemand sieben Mediatheken führt. + .scrollBounceBehavior(.basedOnSize) + .scrollIndicators(.never) + // Dieselbe Fläche wie die Insel auf dem iPad — ein `static`-Bauteil, + // kein nachgebautes Aussehen. + .background { FlixySidebar.surface(in: .capsule) } + .clipShape(.capsule) + .shadow(color: .black.opacity(0.45), radius: 18, y: 6) + .padding(.horizontal, FlixySpacing.regular) + .padding(.bottom, FlixySpacing.snug) + } + + private func pille(_ eintrag: Eintrag) -> some View { + let gewaehlt = eintrag.auswahl == aktuell + + return Button { + auswahl = eintrag.auswahl + } label: { + Label { + Text(eintrag.titel).lineLimit(1) + } icon: { + Image(systemName: eintrag.symbol) + } + // Kleiner als in der Leiste auf dem iPad: dort stehen drei + // Einträge, hier bis zu fünf — und auf einem Telefon. + .font(.flixyCaption) + .foregroundStyle(gewaehlt ? Color.flixyTextPrimary : Color.flixyTextSecondary) + .padding(.horizontal, FlixySpacing.snug) + .padding(.vertical, FlixySpacing.snug) + .background { + if gewaehlt { Capsule().fill(Color.flixyElevated) } + } + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .accessibilityAddTraits(gewaehlt ? .isSelected : []) + } + + // MARK: - Was in der Leiste steht + + private struct Eintrag { + let auswahl: Auswahl + let titel: String + let symbol: String + } + + private var eintraege: [Eintrag] { + var liste = session.libraries.map { + Eintrag(auswahl: .library($0.id), titel: $0.title, symbol: $0.kind.symbol) + } + // Live-TV wohnte im Zwischenbildschirm und bräuchte sonst ein neues + // Zuhause. Hier steht es, wo man es sucht: neben den Mediatheken. + if session.supportsLiveTV { + liste.append(Eintrag( + auswahl: .liveTV, + titel: String(localized: "sidebar.liveTV"), + symbol: MediaLibrary.Kind.liveTV.symbol + )) + } + return liste + } + + /// Die gültige Auswahl — die gespeicherte, solange es sie noch gibt. + /// + /// **Nicht einfach `auswahl`.** Nach einem Serverwechsel zeigt die + /// gespeicherte Kennung auf eine Mediathek, die es nicht mehr gibt; dann + /// stünde ein leerer Bildschirm da, ohne dass irgendetwas erklärt, warum. + private var aktuell: Auswahl? { + if let auswahl, eintraege.contains(where: { $0.auswahl == auswahl }) { + return auswahl + } + return eintraege.first?.auswahl + } +} diff --git a/Flixy/Views/Library/LibraryHubView.swift b/Flixy/Views/Library/LibraryHubView.swift deleted file mode 100644 index 247ba99..0000000 --- a/Flixy/Views/Library/LibraryHubView.swift +++ /dev/null @@ -1,60 +0,0 @@ -import SwiftUI -import FlixyDesign -import MediaKit - -/// Alle Mediatheken auf einem Bildschirm. -/// -/// Auf dem iPad hat die Seitenleiste Platz für jede Mediathek einzeln. Auf dem -/// iPhone hat die Leiste unten fünf Plätze — und sobald es mehr Ziele gibt, -/// schiebt das System den Rest in ein „Mehr"-Verzeichnis. Das sah nicht nur -/// gedrängt aus, es war kaputt: die Einträge dort bekamen **zwei** -/// Navigationsleisten übereinander, eine vom System und eine von uns. -/// -/// Dieser Bildschirm fasst sie zu einem Ziel zusammen. Damit bleibt die Leiste -/// unter fünf Einträgen, und die Mediatheken stehen trotzdem alle da — mit -/// mehr Platz für ihre Namen, als eine Leiste je hätte. -struct LibraryHubView: View { - @Environment(FlixySession.self) private var session - @Environment(\.flixyAccent) private var accent - - var body: some View { - List { - if session.libraries.isEmpty { - Text("libraryHub.empty") - .foregroundStyle(Color.flixyTextSecondary) - .listRowBackground(Color.flixySurface) - } else { - Section("sidebar.libraries") { - ForEach(session.libraries) { library in - NavigationLink(value: LibraryRoute(id: library.id)) { - Label(library.title, systemImage: symbol(for: library.kind)) - } - .listRowBackground(Color.flixySurface) - } - } - } - - if session.supportsLiveTV { - Section { - NavigationLink(value: LiveTVRoute()) { - Label("sidebar.liveTV", systemImage: "dot.radiowaves.left.and.right") - } - .listRowBackground(Color.flixySurface) - } - } - } - .tint(accent) - .scrollContentBackground(.hidden) - .background(Color.flixyCanvas) - .navigationTitle(Text("sidebar.library")) - .flixyInlineTitle() - } - - private func symbol(for kind: MediaLibrary.Kind) -> String { - switch kind { - case .movie: "film" - case .show: "tv" - default: "rectangle.stack" - } - } -} diff --git a/Flixy/Views/Library/LibraryView.swift b/Flixy/Views/Library/LibraryView.swift index 475df56..b635c1c 100644 --- a/Flixy/Views/Library/LibraryView.swift +++ b/Flixy/Views/Library/LibraryView.swift @@ -199,9 +199,12 @@ struct LibraryView: View { @ToolbarContentBuilder private var toolbar: some ToolbarContent { // Nur auf schmalen Geräten: dort hat die Suche keinen eigenen Eintrag - // in der Leiste unten, weil deren fünf Plätze für die Mediatheken - // gebraucht werden. Auf dem iPad steht sie in der Seitenleiste, und - // ein zweiter Weg dorthin wäre nur Verdopplung. + // in der Leiste unten. Die Begründung dafür war lange, die fünf + // Plätze würden für die Mediatheken gebraucht — seit die in einer + // eigenen Leiste darüber stehen, stimmt das nicht mehr. Geblieben + // ist der bessere Grund: gesucht wird in einer Mediathek, und dort + // steht der Knopf. Auf dem iPad steht die Suche in der Seitenleiste, + // und ein zweiter Weg dorthin wäre nur Verdopplung. if sizeClass == .compact { ToolbarItem(placement: .flixyTrailing) { NavigationLink(value: SearchRoute()) { @@ -245,6 +248,15 @@ struct LibraryView: View { .accessibilityLabel(Text("library.toggleLayout")) } + // **Ganz rechts, und deshalb hier unten.** Das Monogramm ist der Weg + // in die Einstellungen, seit es auf dem iPhone keinen Tab dafür mehr + // gibt. Von aussen angehängt stünde es **vor** den übrigen Knöpfen — + // SwiftUI reiht Werkzeugleisten von aussen nach innen. Wo in der + // Reihe etwas steht, kann nur der entscheiden, der die Reihe + // schreibt. + if sizeClass == .compact { + ToolbarItem(placement: .flixyTrailing) { AccountToolbarButton() } + } } // MARK: - Laden diff --git a/Flixy/Views/Navigation/AccountAvatar.swift b/Flixy/Views/Navigation/AccountAvatar.swift index 3b12821..42ff18e 100644 --- a/Flixy/Views/Navigation/AccountAvatar.swift +++ b/Flixy/Views/Navigation/AccountAvatar.swift @@ -30,3 +30,32 @@ struct AccountAvatar: View { .background(accent, in: .circle) } } + +extension EnvironmentValues { + /// Öffnet die Einstellungen. + /// + /// **Über die Umgebung, weil der Knopf an mehreren Stellen steht.** Das + /// Blatt gehört der Wurzel — nur sie überlebt den Wechsel des Bereichs. + /// Jeder Bildschirm, der den Knopf zeigt, müsste den Weg dorthin sonst + /// als Argument durchgereicht bekommen. + @Entry var flixyOpenSettings: () -> Void = {} +} + +/// Das Monogramm als Knopf für eine Werkzeugleiste. +/// +/// **Als Ansicht und nicht als Modifikator.** Ein Modifikator hängt seine +/// Einträge aussen an, und SwiftUI reiht aussen zuerst — das Monogramm stand +/// damit **vor** Suche, Filter und Ansicht statt dahinter. Wo in der Reihe es +/// steht, kann nur der entscheiden, der die Reihe schreibt. +struct AccountToolbarButton: View { + @Environment(FlixySession.self) private var session + @Environment(\.flixyOpenSettings) private var openSettings + + var body: some View { + Button(action: openSettings) { + AccountAvatar(name: session.accountName ?? session.serverName, diameter: 34) + } + .buttonStyle(.plain) + .accessibilityLabel(Text("sidebar.settings")) + } +} diff --git a/Flixy/Views/Navigation/RootView.swift b/Flixy/Views/Navigation/RootView.swift index 2a65963..c472454 100644 --- a/Flixy/Views/Navigation/RootView.swift +++ b/Flixy/Views/Navigation/RootView.swift @@ -275,6 +275,14 @@ struct RootView: View { TabView(selection: $selection) { Tab("sidebar.home", systemImage: "house", value: SidebarDestination.home) { mediaStack { HomeView() } + // **Nur hier.** Die Startseite hat keine + // Navigationsleiste — sie war leer und belegte trotzdem + // Höhe —, also ist ein Überzug der einzige Platz. Die + // übrigen Bildschirme haben eine, und dort stünde er + // entweder über deren Knöpfen oder mitten im Inhalt. Wer + // zu den Einstellungen will, geht auf Start; das ist ein + // Schritt und kein Umweg. + .overlay(alignment: .topTrailing) { profileButton } } .customizationID("flixy.tab.home") @@ -292,7 +300,7 @@ struct RootView: View { ) { mediaStack { LibraryView(library: library) } } label: { - sidebarLabel(Text(library.title), symbol: symbol(for: library.kind)) + sidebarLabel(Text(library.title), symbol: library.kind.symbol) } .customizationID("flixy.tab.library.\(library.id)") } @@ -311,7 +319,7 @@ struct RootView: View { } } else { Tab(value: SidebarDestination.libraryHub) { - mediaStack { LibraryHubView() } + mediaStack { LibraryBrowser() } } label: { tabLabel("sidebar.library", symbol: "books.vertical") } @@ -449,7 +457,7 @@ struct RootView: View { // Insel erreichen: Inhalt kam dort nie hin. Jetzt kommt er. .ignoresSafeArea(.container, edges: .top) } else { - tabs.overlay(alignment: .topTrailing) { profileButton } + tabs } #else tabs @@ -460,6 +468,7 @@ struct RootView: View { // Navigationsstapel um sich — die Medienziele aus `mediaStack` ruft // es nie auf. Und beim Schliessen steht man wieder dort, wo man war, // statt in einem Bereich, den man erst wieder verlassen muss. + .environment(\.flixyOpenSettings, { showsSettings = true }) .sheet(isPresented: $showsSettings) { NavigationStack { SettingsView() @@ -500,14 +509,17 @@ struct RootView: View { Button { showsSettings = true } label: { - AccountAvatar(name: session.accountName ?? session.serverName, diameter: 32) + AccountAvatar(name: session.accountName ?? session.serverName, diameter: 34) .padding(FlixySpacing.snug) // Damit er auch über einem hellen Aufmacher steht. .background(.black.opacity(0.28), in: .circle) } .buttonStyle(.plain) .padding(.trailing, FlixySpacing.regular) - .padding(.top, statusBarHeight + FlixySpacing.snug) + // **Ohne die Statusleiste.** Auf dem iPhone sitzt der Baum innerhalb + // des Sicherheitsrands; sie noch einmal dazuzurechnen schöbe den + // Knopf um ihre Höhe nach unten, mitten ins Bild. + .padding(.top, FlixySpacing.snug) .opacity(topChrome.isRevealed ? 1 : 0) .allowsHitTesting(topChrome.isRevealed) .animation(.snappy(duration: 0.28), value: topChrome.isRevealed) @@ -543,7 +555,7 @@ struct RootView: View { NavigationEntry( destination: .library(key: library.id, title: library.title), title: Text(library.title), - symbol: symbol(for: library.kind), + symbol: library.kind.symbol, plainTitle: library.title ) } @@ -631,11 +643,4 @@ struct RootView: View { #endif } - private func symbol(for kind: MediaLibrary.Kind) -> String { - switch kind { - case .movie: "film" - case .show: "tv" - default: "rectangle.stack" - } - } }