Reine Umbenennung, kein Verhalten geändert — bewusst als eigener Commit, damit „umbenannt" und „umgebaut" im Nachhinein auseinanderzuhalten sind. 712 mal `Flixy`, 778 mal `flixy`, 7 mal `FLIXY` in 169 Dateien, dazu 25 Pfade. Ziele: `Halo`, `HaloTV`, `HaloTopShelf`, `HaloUITests`, `HaloKit` mit `HaloDesign`, `HaloDownloads`, `HaloPlayback` und `HaloProbe`. `MediaKit`, `JellyfinKit` und `DownloadWidget` tragen den Namen nicht und bleiben. `docs/superpowers/specs/` ist ausgenommen. Datierte Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen; sie nachträglich umzuschreiben hiesse, die Vergangenheit zu glätten. **Die Bundle-ID wandert mit** — ausdrücklich so gewollt, mit bekanntem Preis: `com.scarriffleservices.halo` ist für iOS eine *andere* App. Die installierte Fassung bleibt daneben stehen, mit Downloads und Anmeldung in ihrem Container; Halo startet leer. Einmal neu anmelden, heruntergeladene Folgen einmal neu laden. Dasselbe gilt für die App-Gruppe, die sich TV-App und Top Shelf teilen. **Eine Folge davon, die beim Entscheiden noch nicht auf dem Tisch lag:** der Aufräumschritt aus dem vorigen Commit kommt an die alten Plex-Token nicht mehr heran. Der Schlüsselbund grenzt nach Team und Bundle-Kennung ab — was unter `…flixy` liegt, ist für `…halo` unerreichbar. Erledigt wird es trotzdem, nur anders: **die alte App löschen.** iOS räumt deren Schlüsselbund-Einträge beim Deinstallieren mit ab. Der Code bleibt stehen, weil er für jeden Stand vor dieser Umbenennung richtig ist und im Jahr einen Startvergleich kostet. Die Projektdatei ist aus `project.yml` neu erzeugt, der alte Ordner gelöscht statt umbenannt — sie steht ohnehin nicht im Repo. Das Verzeichnis des Repos selbst heißt weiter `Flixy`; das umzubenennen ist ein Handgriff ausserhalb des Projekts. 368 Tests grün. iOS und tvOS bauen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
458 lines
18 KiB
Swift
458 lines
18 KiB
Swift
import SwiftUI
|
|
import HaloDesign
|
|
import MediaKit
|
|
|
|
/// Wo die Navigation sitzt.
|
|
///
|
|
/// Die Wahl gehört dem Nutzer und muss den Neustart überleben — deshalb eine
|
|
/// eigene, gespeicherte Größe statt eines Zustands, der mit der Ansicht stirbt.
|
|
enum NavigationPlacement: String {
|
|
case sidebar
|
|
case top
|
|
}
|
|
|
|
/// Ein Eintrag in der Navigation.
|
|
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.
|
|
struct NavigationGroup: Identifiable {
|
|
let id: String
|
|
let title: LocalizedStringKey?
|
|
let entries: [NavigationEntry]
|
|
}
|
|
|
|
// MARK: - Seitenleiste
|
|
|
|
/// Die Seitenleiste, wie sie auf dem iPad aussieht — überall.
|
|
///
|
|
/// Selbst gezeichnet, weil das System sie auf dem Mac angeklebt an den
|
|
/// Fensterrand setzt und sich das nicht umstellen lässt. Siehe
|
|
/// `TabChromeSuppressor`: das Verhalten kommt weiter vom `TabView`, nur das
|
|
/// Kleid ist unseres.
|
|
///
|
|
/// Eine schwebende Fläche mit Rand ringsum — genau das war die Bitte: „eine
|
|
/// eigene Bubble mit einem klaren Rand, der nicht der Fensterrand ist".
|
|
///
|
|
/// Der Rand kommt von `glassEffect` und nicht von einem gezeichneten Strich.
|
|
/// Ein Material mit einer halben Linie Kontur darum sah aus wie eine Fläche
|
|
/// mit Kontur; Glas bringt seine Kante selbst mit — sie hellt auf, wo Licht
|
|
/// fällt, und verschwindet, wo keins ist. Das ist der Unterschied zwischen
|
|
/// „umrandet" und „aus Glas".
|
|
struct HaloSidebar: 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(\.haloAccent) 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.
|
|
///
|
|
/// Von Hand gebaut und nicht mit `glassEffect`. Der zeichnet an seiner
|
|
/// Kante einen Lichtsaum, und über einem hellen Aufmacher wurde daraus ein
|
|
/// heller Balken von mehreren Punkten Breite, der neben der Leiste stand —
|
|
/// kein Rand, sondern ein Streifen. Gleichzeitig war von einer Kante nichts
|
|
/// zu erkennen.
|
|
///
|
|
/// Drei Lagen: ein trübes Material, damit die Leiste eine eigene Fläche
|
|
/// ist; ein Schleier, damit die durchziehende Startseite nicht mitgelesen
|
|
/// wird; und ein Haarstrich, der oben links aufhellt und unten rechts
|
|
/// nachlässt — so fängt Glas das Licht. Dazu ein Schatten, denn was
|
|
/// schwebt, wirft einen.
|
|
@ViewBuilder
|
|
static func surface(in shape: some InsettableShape) -> some View {
|
|
shape
|
|
.fill(.ultraThinMaterial)
|
|
.overlay { shape.fill(Color.haloCanvas.opacity(0.55)) }
|
|
.overlay {
|
|
shape.strokeBorder(
|
|
LinearGradient(
|
|
stops: [
|
|
.init(color: .white.opacity(0.30), location: 0),
|
|
.init(color: .white.opacity(0.05), location: 0.5),
|
|
.init(color: .white.opacity(0.16), location: 1),
|
|
],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
),
|
|
lineWidth: 1
|
|
)
|
|
}
|
|
}
|
|
|
|
static let width: CGFloat = 235
|
|
/// Der Abstand zum Fensterrand. Er ist der eigentliche Punkt der Sache.
|
|
static let margin: CGFloat = 12
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
header
|
|
|
|
// **Doch mit Bildlaufansicht.** Hier stand „die Einträge sind
|
|
// wenige und passen" — und das stimmte auf einem iPad und im
|
|
// Hochformat. Auf einem grossen iPhone **quer** ist der
|
|
// Bildschirm vierhundertvierzig Punkte hoch, und die Leiste
|
|
// braucht mit Kopf, Gruppen, Trenner und Kontozeile gut
|
|
// fünfhundertdreissig.
|
|
//
|
|
// Eine `VStack` schrumpft dann nicht, sie **überragt**: der
|
|
// `HStack` daneben nimmt das Maximum, und der ganze Wurzelbaum
|
|
// wurde hundertachtunddreissig Punkte höher als der Bildschirm.
|
|
// Was darin als Überlagerung liegt — der Player — ging oben und
|
|
// unten hinaus, und Kopfzeile wie Zeitangaben waren weg. Der
|
|
// Fehler sass also in der Navigation und zeigte sich im Player.
|
|
//
|
|
// `basedOnSize`: es wird nur gescrollt, wenn es nötig ist. Wo die
|
|
// Einträge passen, sieht man keinen Unterschied.
|
|
//
|
|
// Nachgerechnet, damit es nicht wiederkommt: scrollbarer Teil
|
|
// 421 Punkte (acht Zeilen à 36,33, eine Gruppenüberschrift
|
|
// 22,33, acht Zwischenräume à 12, unten 12), starrer Rest
|
|
// 137,6 (Kopfzeile 48,33, Trenner 1, Kontozeile 64,27, aussen
|
|
// zweimal 12). Zusammen 558,6 — gemessen wurden 558,67. Nach dem
|
|
// Umbau bleiben 137,6 starr, bei 420 verfügbaren: auch mit
|
|
// doppelt so vielen Mediatheken passt das.
|
|
//
|
|
// Beim Bearbeiten braucht es eine Liste — nur die bringt das
|
|
// Ziehen zum Umsortieren mit, und die scrollt von sich aus.
|
|
if isEditing {
|
|
Text("sidebar.editHint")
|
|
.font(.haloCaption)
|
|
.foregroundStyle(Color.haloTextMuted)
|
|
.padding(.horizontal, HaloSpacing.regular)
|
|
.padding(.bottom, HaloSpacing.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(.haloCaption)
|
|
.foregroundStyle(Color.haloTextMuted)
|
|
}
|
|
}
|
|
.listRowBackground(Color.clear)
|
|
.listRowSeparator(.hidden)
|
|
}
|
|
}
|
|
.listStyle(.plain)
|
|
.scrollContentBackground(.hidden)
|
|
.environment(\.editMode, .constant(.active))
|
|
} else {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
|
|
ForEach(groups) { group in
|
|
section(visible(group))
|
|
}
|
|
}
|
|
.padding(.horizontal, HaloSpacing.snug)
|
|
.padding(.bottom, HaloSpacing.regular)
|
|
}
|
|
.scrollBounceBehavior(.basedOnSize)
|
|
.scrollIndicators(.never)
|
|
}
|
|
|
|
Spacer(minLength: 0)
|
|
|
|
Divider().overlay(Color.haloSeparator)
|
|
AccountBar(selection: $selection)
|
|
.padding(.horizontal, HaloSpacing.snug)
|
|
.padding(.vertical, HaloSpacing.snug)
|
|
}
|
|
.frame(width: Self.width)
|
|
.background { Self.surface(in: .rect(cornerRadius: HaloRadius.panel, style: .continuous)) }
|
|
.clipShape(.rect(cornerRadius: HaloRadius.panel, style: .continuous))
|
|
.shadow(color: .black.opacity(0.45), radius: 22, y: 8)
|
|
// **Kein** Abstand nach rechts: dort schließt der Inhalt unmittelbar
|
|
// an. Ein Abstand dorthin war der schwarze Spalt zwischen Aufmacher
|
|
// und Leiste. Der schwebende Eindruck kommt von den übrigen drei
|
|
// Seiten und vom Schatten.
|
|
.padding(.leading, Self.margin)
|
|
.padding(.vertical, Self.margin)
|
|
}
|
|
|
|
private var header: some View {
|
|
HStack(spacing: HaloSpacing.snug) {
|
|
Button {
|
|
withAnimation(.snappy(duration: 0.25)) { isEditing.toggle() }
|
|
} label: {
|
|
Text(isEditing ? "action.done" : "sidebar.edit")
|
|
.font(.haloCaption)
|
|
.padding(.horizontal, HaloSpacing.snug)
|
|
.padding(.vertical, HaloSpacing.tight)
|
|
.contentShape(Capsule())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.foregroundStyle(isEditing ? accent : Color.haloTextSecondary)
|
|
|
|
Spacer(minLength: 0)
|
|
|
|
if !isEditing {
|
|
Button {
|
|
placement = .top
|
|
} label: {
|
|
Image(systemName: "rectangle.topthird.inset.filled")
|
|
.font(.haloRowHeader)
|
|
.padding(HaloSpacing.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.haloTextSecondary)
|
|
.accessibilityLabel(Text("sidebar.moveToTop"))
|
|
}
|
|
}
|
|
.padding(.horizontal, HaloSpacing.regular)
|
|
.padding(.top, HaloSpacing.regular)
|
|
.padding(.bottom, HaloSpacing.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 {
|
|
Button {
|
|
withAnimation(.snappy(duration: 0.25)) {
|
|
if collapsed.contains(group.id) {
|
|
collapsed.remove(group.id)
|
|
} else {
|
|
collapsed.insert(group.id)
|
|
}
|
|
}
|
|
} label: {
|
|
HStack {
|
|
Text(title)
|
|
.font(.haloCaption)
|
|
.foregroundStyle(Color.haloTextMuted)
|
|
Spacer()
|
|
Image(systemName: "chevron.down")
|
|
.font(.haloCaption)
|
|
.foregroundStyle(Color.haloTextMuted)
|
|
.rotationEffect(.degrees(collapsed.contains(group.id) ? -90 : 0))
|
|
}
|
|
.padding(.horizontal, HaloSpacing.regular)
|
|
.padding(.top, HaloSpacing.snug)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
if !collapsed.contains(group.id) {
|
|
ForEach(group.entries) { entry in
|
|
row(entry)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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: HaloSpacing.snug) {
|
|
Button {
|
|
layout.setHidden(!isHidden, for: entry.arrangementID)
|
|
} label: {
|
|
Image(systemName: isHidden ? "circle" : "checkmark.circle.fill")
|
|
.foregroundStyle(isHidden ? Color.haloTextMuted : 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(.haloBody)
|
|
.foregroundStyle(isHidden ? Color.haloTextMuted : Color.haloTextPrimary)
|
|
}
|
|
|
|
private func row(_ entry: NavigationEntry) -> some View {
|
|
let isSelected = selection == entry.destination
|
|
|
|
return Button {
|
|
selection = entry.destination
|
|
} label: {
|
|
HStack(spacing: HaloSpacing.snug) {
|
|
Image(systemName: entry.symbol)
|
|
.font(.haloBody)
|
|
.frame(width: 22, alignment: .center)
|
|
entry.title
|
|
.font(.haloBody)
|
|
// Ein langer Mediathekenname darf die Leiste nicht
|
|
// breiter machen; sie hat eine feste Breite.
|
|
.lineLimit(1)
|
|
Spacer(minLength: 0)
|
|
}
|
|
.foregroundStyle(isSelected ? accent : Color.haloTextPrimary)
|
|
.padding(.horizontal, HaloSpacing.regular)
|
|
.padding(.vertical, HaloSpacing.snug)
|
|
.background {
|
|
if isSelected {
|
|
Capsule().fill(Color.haloElevated)
|
|
}
|
|
}
|
|
.contentShape(Capsule())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
// MARK: - Leiste oben
|
|
|
|
/// Dieselben Einträge als schwebende Leiste oben.
|
|
///
|
|
/// Mediatheken stehen hier in einem Menü statt einzeln: oben ist der Platz
|
|
/// waagerecht begrenzt, und eine Reihe, die bei acht Mediatheken über den Rand
|
|
/// hinauswächst, wäre schlechter als ein Menü.
|
|
struct HaloTopBar: View {
|
|
let groups: [NavigationGroup]
|
|
@Binding var selection: SidebarDestination
|
|
@Binding var placement: NavigationPlacement
|
|
|
|
@Environment(\.haloAccent) private var accent
|
|
|
|
var body: some View {
|
|
HStack(spacing: HaloSpacing.tight) {
|
|
Button {
|
|
placement = .sidebar
|
|
} label: {
|
|
Image(systemName: "sidebar.leading")
|
|
.font(.haloBody)
|
|
.padding(.horizontal, HaloSpacing.regular)
|
|
.padding(.vertical, HaloSpacing.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.haloTextSecondary)
|
|
.accessibilityLabel(Text("sidebar.moveToSide"))
|
|
|
|
ForEach(groups) { group in
|
|
if group.title == nil {
|
|
ForEach(group.entries) { entry in
|
|
pill(entry)
|
|
}
|
|
} else {
|
|
menu(group)
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, HaloSpacing.snug)
|
|
.padding(.vertical, HaloSpacing.snug)
|
|
.background { HaloSidebar.surface(in: .capsule) }
|
|
.clipShape(.capsule)
|
|
.shadow(color: .black.opacity(0.45), radius: 18, y: 6)
|
|
.padding(.top, HaloSpacing.snug)
|
|
}
|
|
|
|
private func pill(_ entry: NavigationEntry) -> some View {
|
|
let isSelected = selection == entry.destination
|
|
|
|
return Button {
|
|
selection = entry.destination
|
|
} label: {
|
|
entry.title
|
|
.font(.haloBody)
|
|
.lineLimit(1)
|
|
.foregroundStyle(isSelected ? accent : Color.haloTextPrimary)
|
|
.padding(.horizontal, HaloSpacing.loose)
|
|
.padding(.vertical, HaloSpacing.snug)
|
|
.background {
|
|
if isSelected { Capsule().fill(Color.haloElevated) }
|
|
}
|
|
.contentShape(Capsule())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
private func menu(_ group: NavigationGroup) -> some View {
|
|
let containsSelection = group.entries.contains { $0.destination == selection }
|
|
|
|
return Menu {
|
|
ForEach(group.entries) { entry in
|
|
Button { selection = entry.destination } label: {
|
|
Label { entry.title } icon: { Image(systemName: entry.symbol) }
|
|
}
|
|
}
|
|
} label: {
|
|
HStack(spacing: HaloSpacing.tight) {
|
|
if let title = group.title {
|
|
// Ohne das wurde aus „Mediatheken" ein „Mediathe…".
|
|
// Die Leiste ist so breit wie ihr Inhalt; sie hat keinen
|
|
// Grund, an einem Wort zu sparen.
|
|
Text(title).fixedSize()
|
|
}
|
|
Image(systemName: "chevron.down").font(.haloCaption)
|
|
}
|
|
.font(.haloBody)
|
|
.foregroundStyle(containsSelection ? accent : Color.haloTextPrimary)
|
|
.padding(.horizontal, HaloSpacing.loose)
|
|
.padding(.vertical, HaloSpacing.snug)
|
|
.background {
|
|
if containsSelection { Capsule().fill(Color.haloElevated) }
|
|
}
|
|
.contentShape(Capsule())
|
|
}
|
|
.menuStyle(.button)
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|