Bearbeiten, Suche auf dem Handy, und zwei Knöpfe, die man treffen kann

Die Seitenleiste lässt sich wieder bearbeiten: Ziehen zum Umsortieren, Haken
zum Ein- und Ausblenden. Das Anordnen macht `Arrangement` aus MediaKit — dieselbe
Rechnung samt Tests, die schon die Reihen der Startseite ordnet, mit ihren
kniffligen Fällen: eine Mediathek, die neu auftaucht, und eine, die aus der
gespeicherten Reihenfolge verschwindet. Gespeichert wird über eine feste
Kennung **ohne** den Titel, damit eine umbenannte Mediathek ihren Platz behält.

Die Suche ist auf dem iPhone kein eigener Reiter mehr. Die Leiste unten hat
fünf Plätze, und einer davon für etwas, das man in einer Mediathek sucht, ist
einer zu viel — sie sitzt jetzt über der Mediathek, neben Filter und Ansicht.

Der Umschalter zwischen Seite und oben ließ sich auf dem Mac kaum treffen: ein
Knopf aus einem Zeichen mit Rand hat ohne `contentShape` nur das Zeichen als
Ziel. Beide Umschalter haben jetzt eine Fläche.

Die Leiste oben trägt dieselbe Schriftgröße wie die Seitenleiste. In
Beschriftungsgröße war sie auf einem großen Bildschirm winzig.

Und eine Maske: der Aufmacher greift nach links unter die Seitenleiste, scrollt
dabei aber, während die Leiste steht — über und unter ihr blieb je ein Streifen
Bild stehen. Die Maske sitzt im festen Teil und lässt genau zwei Flächen übrig:
alles ab der Inhaltskante, und die Fläche der Leiste.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-13 20:33:33 +02:00
co-authored by Claude Opus 5
parent dba645d168
commit e2779f2519
6 changed files with 4156 additions and 3794 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,64 @@
import Foundation
import Observation
import MediaKit
/// Welche Einträge die Navigation zeigt und in welcher Reihenfolge.
///
/// Hält nur die Einstellung und ihre Speicherung. Das Anordnen selbst macht
/// `Arrangement` in MediaKit — dieselbe Rechnung, die schon die Reihen der
/// Startseite ordnet, samt ihrer Tests. Dort liegen die kniffligen Fälle:
/// eine Mediathek, die neu auf dem Server auftaucht, und eine, die aus der
/// gespeicherten Reihenfolge verschwindet.
@MainActor
@Observable
final class NavigationLayoutSettings {
private enum Key {
static let hidden = "flixy.navigation.hidden"
static let order = "flixy.navigation.order"
}
private let defaults: UserDefaults
private(set) var arrangement: Arrangement
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
arrangement = Arrangement(
hidden: Set(defaults.stringArray(forKey: Key.hidden) ?? []),
order: defaults.stringArray(forKey: Key.order) ?? []
)
}
func isHidden(_ id: String) -> Bool {
arrangement.hidden.contains(id)
}
func setHidden(_ hidden: Bool, for id: String) {
if hidden {
arrangement.hidden.insert(id)
} else {
arrangement.hidden.remove(id)
}
defaults.set(Array(arrangement.hidden), forKey: Key.hidden)
}
/// Legt die Reihenfolge neu fest.
///
/// Gespeichert wird die **vollständige** Liste, auch die ausgeblendeten
/// Einträge. Sonst rutschte ein Eintrag, den man aus- und wieder
/// einblendet, ans Ende — er stünde in der Reihenfolge nicht mehr drin.
func setOrder(_ ids: [String]) {
arrangement.order = ids
defaults.set(ids, forKey: Key.order)
}
/// Wendet Auswahl und Reihenfolge an.
func apply<Element: Arrangeable>(to elements: [Element]) -> [Element] {
arrangement.apply(to: elements)
}
/// Nur die Reihenfolge, ohne auszublenden — für den Bearbeiten-Modus, in
/// dem auch die ausgeblendeten Einträge zu sehen sein müssen.
func ordered<Element: Arrangeable>(_ elements: [Element]) -> [Element] {
Arrangement(hidden: [], order: arrangement.order).apply(to: elements)
}
}
+14
View File
@@ -8,6 +8,7 @@ struct LibraryView: View {
@Environment(FlixySession.self) private var session
@AppStorage("flixy.library.layout") private var layout: LibraryLayout = .grid
@Environment(\.horizontalSizeClass) private var sizeClass
@State private var items: [MediaItem] = []
@State private var state: LoadState<Void> = .loading
@@ -120,6 +121,19 @@ 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.
if sizeClass == .compact {
ToolbarItem(placement: .flixyTrailing) {
NavigationLink(value: SearchRoute()) {
Image(systemName: "magnifyingglass")
}
.accessibilityLabel(Text("library.search"))
}
}
ToolbarItem(placement: .flixyTrailing) {
Button {
showsFilters = true
+145 -34
View File
@@ -12,12 +12,17 @@ enum NavigationPlacement: String {
}
/// Ein Eintrag in der Navigation.
struct NavigationEntry: Identifiable {
struct NavigationEntry: Identifiable, Arrangeable {
let destination: SidebarDestination
let title: Text
let symbol: String
/// Der Titel als reiner Text — für die Sortierlogik und für die
/// Bedienungshilfen. `Text` selbst lässt sich nicht auslesen.
let plainTitle: String
var id: SidebarDestination { destination }
var arrangementID: String { destination.storageKey }
var arrangementTitle: String { plainTitle }
}
/// Eine Gruppe von Einträgen, mit oder ohne Überschrift.
@@ -45,14 +50,18 @@ struct NavigationGroup: Identifiable {
/// fällt, und verschwindet, wo keins ist. Das ist der Unterschied zwischen
/// „umrandet" und „aus Glas".
struct FlixySidebar: View {
/// Alle Einträge, **ungefiltert**. Was davon zu sehen ist, entscheidet
/// `layout` — beim Bearbeiten müssen auch die ausgeblendeten dastehen.
let groups: [NavigationGroup]
@Binding var selection: SidebarDestination
@Binding var placement: NavigationPlacement
let layout: NavigationLayoutSettings
@Environment(\.flixyAccent) private var accent
/// Welche Gruppen eingeklappt sind. Nach Kennung, nicht nach Stellung:
/// kommt eine Mediathek dazu, soll nicht plötzlich die falsche zuklappen.
@State private var collapsed: Set<String> = []
@State private var isEditing = false
/// Die Fläche, auf der die Leiste liegt.
///
@@ -96,24 +105,46 @@ struct FlixySidebar: View {
VStack(alignment: .leading, spacing: 0) {
header
// Ohne Bildlaufansicht.
//
// Sie war der helle Streifen an der rechten Kante: ihr Anzeiger,
// zwölf Punkte breit, in gleichbleibender Helligkeit, genau so
// hoch wie die Liste. `.scrollIndicators(.never)` half nicht — auf
// dem Mac zeichnet das System ihn trotzdem.
//
// Gebraucht wird sie ohnehin nicht: die Einträge sind wenige und
// passen. Sollten es einmal so viele Mediatheken werden, dass sie
// nicht passen, ist das eine eigene Aufgabe — und dann mit
// abgeschaltetem Anzeiger von Hand.
VStack(alignment: .leading, spacing: FlixySpacing.regular) {
ForEach(groups) { group in
section(group)
// Ohne Bildlaufansicht im Ruhezustand: die Einträge sind wenige
// und passen. Beim Bearbeiten braucht es eine Liste — nur die
// bringt das Ziehen zum Umsortieren mit.
if isEditing {
Text("sidebar.editHint")
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
.padding(.horizontal, FlixySpacing.regular)
.padding(.bottom, FlixySpacing.snug)
List {
ForEach(groups) { group in
Section {
ForEach(editable(group)) { entry in
editRow(entry)
}
.onMove { move(in: group, from: $0, to: $1) }
} header: {
if let title = group.title {
Text(title)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
}
}
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.environment(\.editMode, .constant(.active))
} else {
VStack(alignment: .leading, spacing: FlixySpacing.regular) {
ForEach(groups) { group in
section(visible(group))
}
}
.padding(.horizontal, FlixySpacing.snug)
.padding(.bottom, FlixySpacing.regular)
}
.padding(.horizontal, FlixySpacing.snug)
.padding(.bottom, FlixySpacing.regular)
Spacer(minLength: 0)
@@ -130,23 +161,69 @@ struct FlixySidebar: View {
}
private var header: some View {
HStack {
Spacer()
HStack(spacing: FlixySpacing.snug) {
Button {
withAnimation(.snappy(duration: 0.25)) { placement = .top }
withAnimation(.snappy(duration: 0.25)) { isEditing.toggle() }
} label: {
Image(systemName: "rectangle.topthird.inset.filled")
.font(.flixyRowHeader)
Text(isEditing ? "action.done" : "sidebar.edit")
.font(.flixyCaption)
.padding(.horizontal, FlixySpacing.snug)
.padding(.vertical, FlixySpacing.tight)
.contentShape(Capsule())
}
.buttonStyle(.plain)
.foregroundStyle(Color.flixyTextSecondary)
.accessibilityLabel(Text("sidebar.moveToTop"))
.foregroundStyle(isEditing ? accent : Color.flixyTextSecondary)
Spacer(minLength: 0)
if !isEditing {
Button {
withAnimation(.snappy(duration: 0.25)) { placement = .top }
} label: {
Image(systemName: "rectangle.topthird.inset.filled")
.font(.flixyRowHeader)
.padding(FlixySpacing.tight)
// Ohne eigene Klickfläche reicht das Ziel nur so weit
// wie das Zeichen selbst — auf dem Mac trifft man das
// mit dem Zeiger kaum.
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.foregroundStyle(Color.flixyTextSecondary)
.accessibilityLabel(Text("sidebar.moveToTop"))
}
}
.padding(.horizontal, FlixySpacing.regular)
.padding(.top, FlixySpacing.regular)
.padding(.bottom, FlixySpacing.snug)
}
/// Die Einträge einer Gruppe beim Bearbeiten: alle, in der gespeicherten
/// Reihenfolge, auch die ausgeblendeten.
private func editable(_ group: NavigationGroup) -> [NavigationEntry] {
layout.ordered(group.entries)
}
/// Verschiebt einen Eintrag innerhalb seiner Gruppe.
///
/// Gespeichert wird die Reihenfolge **aller** Gruppen hintereinander.
/// Innerhalb einer Gruppe zu sortieren genügt für die Anzeige, aber eine
/// Liste je Gruppe zu speichern hieße, beim Hinzukommen einer Gruppe
/// wieder eine Sonderregel zu haben.
private func move(in group: NavigationGroup, from source: IndexSet, to destination: Int) {
var entries = editable(group)
entries.move(fromOffsets: source, toOffset: destination)
var order: [String] = []
for other in groups {
let ids = other.id == group.id
? entries.map(\.arrangementID)
: editable(other).map(\.arrangementID)
order.append(contentsOf: ids)
}
layout.setOrder(order)
}
@ViewBuilder
private func section(_ group: NavigationGroup) -> some View {
if let title = group.title {
@@ -182,6 +259,36 @@ struct FlixySidebar: View {
}
}
/// Eine Gruppe, wie sie im Ruhezustand dasteht: ausgeblendete raus,
/// Reihenfolge angewandt.
private func visible(_ group: NavigationGroup) -> NavigationGroup {
NavigationGroup(id: group.id, title: group.title, entries: layout.apply(to: group.entries))
}
private func editRow(_ entry: NavigationEntry) -> some View {
let isHidden = layout.isHidden(entry.arrangementID)
return HStack(spacing: FlixySpacing.snug) {
Button {
layout.setHidden(!isHidden, for: entry.arrangementID)
} label: {
Image(systemName: isHidden ? "circle" : "checkmark.circle.fill")
.foregroundStyle(isHidden ? Color.flixyTextMuted : accent)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(Text(isHidden ? "sidebar.show" : "sidebar.hide"))
Image(systemName: entry.symbol)
.frame(width: 22)
entry.title
.lineLimit(1)
Spacer(minLength: 0)
}
.font(.flixyBody)
.foregroundStyle(isHidden ? Color.flixyTextMuted : Color.flixyTextPrimary)
}
private func row(_ entry: NavigationEntry) -> some View {
let isSelected = selection == entry.destination
@@ -233,9 +340,13 @@ struct FlixyTopBar: View {
withAnimation(.snappy(duration: 0.25)) { placement = .sidebar }
} label: {
Image(systemName: "sidebar.leading")
.font(.flixyCaption)
.padding(.horizontal, FlixySpacing.snug)
.padding(.vertical, FlixySpacing.tight)
.font(.flixyBody)
.padding(.horizontal, FlixySpacing.regular)
.padding(.vertical, FlixySpacing.snug)
// Ohne eigene Klickfläche reicht das Ziel nur so weit wie
// das Zeichen; auf dem Mac war der Umschalter damit
// praktisch nicht zu treffen.
.contentShape(Capsule())
}
.buttonStyle(.plain)
.foregroundStyle(Color.flixyTextSecondary)
@@ -252,7 +363,7 @@ struct FlixyTopBar: View {
}
}
.padding(.horizontal, FlixySpacing.snug)
.padding(.vertical, FlixySpacing.tight)
.padding(.vertical, FlixySpacing.snug)
.background { FlixySidebar.surface(in: .capsule) }
.clipShape(.capsule)
.shadow(color: .black.opacity(0.45), radius: 18, y: 6)
@@ -266,11 +377,11 @@ struct FlixyTopBar: View {
selection = entry.destination
} label: {
entry.title
.font(.flixyCaption)
.font(.flixyBody)
.lineLimit(1)
.foregroundStyle(isSelected ? accent : Color.flixyTextPrimary)
.padding(.horizontal, FlixySpacing.regular)
.padding(.vertical, FlixySpacing.tight)
.padding(.horizontal, FlixySpacing.loose)
.padding(.vertical, FlixySpacing.snug)
.background {
if isSelected { Capsule().fill(Color.flixyElevated) }
}
@@ -293,10 +404,10 @@ struct FlixyTopBar: View {
if let title = group.title { Text(title) }
Image(systemName: "chevron.down").font(.flixyCaption)
}
.font(.flixyCaption)
.font(.flixyBody)
.foregroundStyle(containsSelection ? accent : Color.flixyTextPrimary)
.padding(.horizontal, FlixySpacing.regular)
.padding(.vertical, FlixySpacing.tight)
.padding(.horizontal, FlixySpacing.loose)
.padding(.vertical, FlixySpacing.snug)
.background {
if containsSelection { Capsule().fill(Color.flixyElevated) }
}
+78 -14
View File
@@ -43,6 +43,10 @@ struct RootView: View {
@AppStorage("flixy.navigation.placement")
private var placement: NavigationPlacement = .sidebar
/// Was der Nutzer an der Navigation umgestellt hat: Reihenfolge und was
/// überhaupt dastehen soll.
@State private var navigationLayout = NavigationLayoutSettings()
var body: some View {
Group {
switch session.phase {
@@ -245,13 +249,18 @@ struct RootView: View {
}
.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) {
mediaStack { SearchView() }
// 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")
}
.customizationID("flixy.tab.search")
}
.tabViewStyle(.sidebarAdaptable)
.tabViewCustomization($customization)
@@ -277,6 +286,7 @@ struct RootView: View {
if sizeClass == .regular {
tabs
.background { TabChromeSuppressor().frame(width: 0, height: 0) }
.mask(alignment: .topLeading) { chromeMask }
.overlay(alignment: placement == .sidebar ? .topLeading : .top) {
chrome
}
@@ -294,19 +304,57 @@ struct RootView: View {
private var chrome: some View {
switch placement {
case .sidebar:
FlixySidebar(groups: navigationGroups, selection: $selection, placement: $placement)
FlixySidebar(
groups: navigationGroups,
selection: $selection,
placement: $placement,
layout: navigationLayout
)
case .top:
// In der Mitte, und zwar erzwungen: die Leiste ist so breit wie
// ihr Inhalt, und eine Überlagerung richtet sie dann nicht
// zuverlässig mittig aus.
HStack {
Spacer(minLength: 0)
FlixyTopBar(groups: navigationGroups, selection: $selection, placement: $placement)
FlixyTopBar(
groups: navigationGroups.map(visible),
selection: $selection,
placement: $placement
)
Spacer(minLength: 0)
}
}
}
/// Wo der Inhalt überhaupt zu sehen sein darf.
///
/// Der Aufmacher greift nach links unter die Seitenleiste. Er scrollt
/// dabei, die Leiste nicht — also blieb über und unter ihr je ein
/// Streifen Bild stehen, den nichts abdeckte. Eine Maske kann das
/// abschneiden, weil sie **hier** liegt, im festen Teil, und nicht im
/// Inhalt, der wandert.
///
/// Sichtbar ist die Vereinigung aus zwei Flächen: alles ab der
/// Inhaltskante, und dazu genau die Fläche der Leiste. Dazwischen — links
/// der Inhaltskante, ober- und unterhalb der Leiste — bleibt nichts übrig.
@ViewBuilder
private var chromeMask: some View {
switch placement {
case .sidebar:
ZStack(alignment: .topLeading) {
Color.black
.padding(.leading, FlixySidebar.width + FlixySidebar.margin * 2)
RoundedRectangle(cornerRadius: FlixyRadius.panel, style: .continuous)
.frame(width: FlixySidebar.width)
.padding(FlixySidebar.margin)
}
case .top:
// Oben greift nichts über den Rand hinaus; eine Maske hätte nichts
// zu tun und könnte nur schaden.
Color.black
}
}
private var chromeInsets: EdgeInsets {
switch placement {
case .sidebar:
@@ -329,10 +377,10 @@ struct RootView: View {
private var navigationGroups: [NavigationGroup] {
var groups: [NavigationGroup] = [
NavigationGroup(id: "main", title: nil, entries: [
NavigationEntry(destination: .home, title: Text("sidebar.home"), symbol: "house"),
NavigationEntry(destination: .downloads, title: Text("sidebar.downloads"), symbol: "arrow.down.circle"),
NavigationEntry(destination: .settings, title: Text("sidebar.settings"), symbol: "gearshape"),
NavigationEntry(destination: .search, title: Text("sidebar.search"), symbol: "magnifyingglass"),
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")),
])
]
@@ -340,14 +388,16 @@ struct RootView: View {
NavigationEntry(
destination: .library(key: library.id, title: library.title),
title: Text(library.title),
symbol: symbol(for: library.kind)
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"
symbol: "dot.radiowaves.left.and.right",
plainTitle: String(localized: "sidebar.liveTV")
))
}
if !media.isEmpty {
@@ -357,6 +407,17 @@ struct RootView: View {
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
@@ -380,6 +441,9 @@ struct RootView: View {
.navigationDestination(for: LiveTVRoute.self) { _ in
chromed { LiveTVView() }
}
.navigationDestination(for: SearchRoute.self) { _ in
chromed { SearchView() }
}
}
}
@@ -14,6 +14,23 @@ enum SidebarDestination: Hashable {
case downloads
case settings
/// Eine feste Kennung zum Speichern.
///
/// **Ohne** den Titel: benennt jemand eine Mediathek auf dem Server um,
/// soll sie ihren Platz in der Seitenleiste behalten und nicht als neuer
/// Eintrag ganz unten auftauchen.
var storageKey: String {
switch self {
case .home: "home"
case .library(let key, _): "library:\(key)"
case .libraryHub: "libraryHub"
case .search: "search"
case .liveTV: "liveTV"
case .downloads: "downloads"
case .settings: "settings"
}
}
/// Der Titel in der Navigationsleiste.
///
/// Auf der Startseite steht dort der Name der App — in der Seitenleiste
@@ -51,3 +68,10 @@ enum SidebarDestination: Hashable {
}
}
}
/// Der Weg zur Suche, wenn sie kein eigener Eintrag ist.
///
/// Auf dem iPhone hat die Leiste unten fünf Plätze, und einer davon für die
/// Suche ist einer zu viel — sie sitzt dort, wo man sie sucht: über der
/// Mediathek, neben Filter und Ansicht.
struct SearchRoute: Hashable {}