Flixy heißt ab hier Halo
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2fcf6821c8
commit
4b9a1e7b67
@@ -0,0 +1,457 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user