Navigation: Glas, oben oder seitlich, und mehr Reihen auf der Startseite

Drei Dinge aus dem letzten Durchgang.

**Die Startseite hatte zu wenig anzubieten.** Es gab eine Reihe „Zuletzt
hinzugefügt" für alles zusammen. Jetzt gibt es eine je Mediathek — „Neu in
Filme" und „Neu in Serien" sind zwei verschiedene Fragen, und wer nach einem
Film sucht, blättert in einer gemeinsamen Reihe an Folgen vorbei. Dazu
Favoriten, die es bei Jellyfin ohnehin gibt. Die drei Abfragen laufen
nebeneinander; nur die Mediatheken werden streng geholt, weil sie die Probe
darauf sind, ob der Server überhaupt antwortet.

**Die Seitenleiste ist jetzt echtes Glas**, nicht das alte Material: im
ausfahrbaren Blatt über `glassEffect`, in der geteilten Ansicht auf dem iPad
vom System selbst — dort lag mein eigener Hintergrund darüber und machte ihn
stumpf. Die zum Inhalt zeigende Kante ist gerundet, damit das Blatt sichtbar
über der Seite liegt statt daneben zu stehen.

**Die Navigation kann nach oben.** Eine Einstellung unter Erscheinungsbild,
seitlich oder oben. Oben stehen alle Einträge gleichzeitig da, als Glaskapseln
in einer waagerecht scrollenden Reihe — die Zahl der Mediatheken kommt vom
Server, und zusammengequetschte Beschriftungen wären die schlechtere Antwort
als eine Reihe, die weiterläuft.

Angesehen auf iPhone und iPad: beide Formen, das Glas in beiden Leisten, die
neuen Reihen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-13 14:31:09 +02:00
co-authored by Claude Opus 5
parent 5763d866bf
commit 5cebfed36b
9 changed files with 3490 additions and 3176 deletions
+37 -4
View File
@@ -18,6 +18,7 @@ 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.
///
@@ -142,13 +143,44 @@ struct RootView: View {
@ViewBuilder
private var library: some View {
if sizeClass == .regular {
splitLayout
} else {
drawerLayout
switch navigation.placement {
case .top:
topLayout
case .side:
if sizeClass == .regular {
splitLayout
} else {
drawerLayout
}
}
}
/// Die Navigation als Reihe über dem Inhalt.
///
/// 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)
}
}
.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) }
}
@@ -157,6 +189,7 @@ struct RootView: View {
private var splitLayout: some View {
NavigationSplitView {
SidebarView(
presentation: .column,
libraries: sidebarDestinations,
showsLiveTV: session.supportsLiveTV,
selection: $selection,
+37 -4
View File
@@ -7,6 +7,14 @@ import FlixyDesign
/// 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.
///
@@ -40,10 +48,7 @@ struct SidebarView: View {
.padding(.vertical, FlixySpacing.section)
}
.scrollBounceBehavior(.basedOnSize)
// Milchig statt undurchsichtig: der Inhalt dahinter bleibt zu ahnen,
// und die Leiste wirkt wie eine Schicht darüber statt wie eine zweite
// Seite daneben. Ein Vollton stand vorher wie eine Wand im Bild.
.background(.ultraThinMaterial)
.modifier(SidebarBackground(presentation: presentation))
}
private var header: some View {
@@ -106,3 +111,31 @@ private struct SidebarRow: View {
.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
}
}
}
@@ -0,0 +1,57 @@
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)
}
}
+24
View File
@@ -9,6 +9,7 @@ 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
@@ -16,6 +17,20 @@ 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
@@ -142,3 +157,12 @@ private struct PreviewRow: View {
.listRowBackground(Color.flixySurface)
}
}
extension NavigationSettings.Placement {
var label: LocalizedStringKey {
switch self {
case .side: "appearance.navigationSide"
case .top: "appearance.navigationTop"
}
}
}