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>
142 lines
5.1 KiB
Swift
142 lines
5.1 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|