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:
Scarriffle
2026-10-03 11:12:32 +02:00
co-authored by Claude Opus 5
parent 2fcf6821c8
commit 4b9a1e7b67
280 changed files with 1456 additions and 1456 deletions
+237
View File
@@ -0,0 +1,237 @@
import SwiftUI
import HaloDesign
import MediaKit
/// Reihen der Startseite ein- und ausblenden und umsortieren.
struct HomeLayoutView: View {
@Environment(HaloSession.self) private var session
@Environment(\.haloAccent) private var accent
@Environment(HomeLayoutSettings.self) private var layout
@State private var rows: [HomeRow] = []
/// Welche Mediatheken überhaupt eine Reihe auf der Startseite stellen.
///
/// **Gemessen, nicht angenommen.** Nur Reihen, die der Server einer
/// Mediathek zuordnet, kann der Aufmacher auseinanderhalten — bei
/// Jellyfin sind das die „Neu in …"-Reihen. Gibt es für eine angehakte
/// Mediathek keine, greift die Auswahl ins Leere, und das sieht aus wie
/// ein kaputter Schalter.
@State private var libraryBackedRows: Set<String> = []
@State private var isLoading = true
var body: some View {
list
.background(Color.haloCanvas)
.navigationTitle(Text("homeLayout.title"))
.haloInlineTitle()
.task { await load() }
}
private var list: some View {
List {
@Bindable var layout = layout
Section {
Picker("home.heroSource", selection: $layout.heroSource) {
ForEach(HomeLayoutSettings.HeroSource.allCases) { source in
Text(source.label).tag(source)
}
}
Picker("home.heroCount", selection: $layout.heroCount) {
ForEach(HomeLayoutSettings.heroCountChoices, id: \.self) { count in
Text(count.formatted()).tag(count)
}
}
// Ein Regler und keine Auswahl aus festen Stufen: es gibt hier
// keinen richtigen Wert, nur den, bei dem die erste Reihe
// gerade so weit hereinlugt, wie man es mag.
LabeledContent {
Text(layout.heroHeight.formatted(.percent.precision(.fractionLength(0))))
.foregroundStyle(Color.haloTextSecondary)
.monospacedDigit()
} label: {
Text("home.heroHeight")
}
Slider(
value: $layout.heroHeight,
in: HomeLayoutSettings.heroHeightRange,
step: 0.05
)
.accessibilityLabel(Text("home.heroHeight"))
} header: {
Text("home.hero")
} footer: {
Text("home.heroExplanation")
}
.listRowBackground(Color.haloSurface)
// **Mehrfachauswahl, und leer heisst alle.** Ein `Picker` könnte
// nur eine Mediathek, und „keine" wäre dort ein eigener Eintrag,
// den man versehentlich trifft. Hier sind Haken: keiner gesetzt
// ist der Normalfall und bedeutet, dass alles infrage kommt.
if !session.libraries.isEmpty {
Section {
ForEach(session.libraries) { library in
Button {
toggleHeroLibrary(library.id)
} label: {
LabeledContent {
if layout.heroLibraries.contains(library.id) {
Image(systemName: "checkmark")
.font(.body.weight(.semibold))
.foregroundStyle(accent)
}
} label: {
Text(library.title)
.foregroundStyle(Color.haloTextPrimary)
}
// Damit der ganze Streifen trifft und nicht nur
// die Schrift — eine Zeile, bei der nur ein Wort
// reagiert, liest sich als kaputt.
.contentShape(.rect)
}
.buttonStyle(.plain)
.accessibilityAddTraits(
layout.heroLibraries.contains(library.id) ? .isSelected : []
)
}
} header: {
Text("home.heroLibraries")
} footer: {
VStack(alignment: .leading, spacing: HaloSpacing.snug) {
Text("home.heroLibrariesExplanation")
// Lieber eine Erklärung als ein Aufmacher, der
// heimlich etwas anderes zeigt: trifft die Auswahl
// keine einzige Reihe, gilt sie als wirkungslos — und
// dann muss dastehen, warum.
if greiftInsLeere {
Text("home.heroLibrariesNoMatch")
.foregroundStyle(Color.haloDestructive)
}
}
}
.listRowBackground(Color.haloSurface)
}
Section {
Toggle("home.combineContinue", isOn: $layout.combinesContinueRows)
Toggle("home.hideWatched", isOn: $layout.hidesWatchedInLatest)
}
.listRowBackground(Color.haloSurface)
if isLoading {
ProgressView()
.frame(maxWidth: .infinity)
.listRowBackground(Color.clear)
} else if rows.isEmpty {
Text("homeLayout.empty")
.foregroundStyle(Color.haloTextSecondary)
.listRowBackground(Color.haloSurface)
}
Section {
ForEach($rows) { $row in
Toggle(isOn: Binding(
get: { !row.isHidden },
set: {
row.isHidden = !$0
layout.setHidden(!$0, for: row.id)
}
)) {
Text(row.title)
.foregroundStyle(Color.haloTextPrimary)
}
.listRowBackground(Color.haloSurface)
}
.onMove { indices, destination in
rows.move(fromOffsets: indices, toOffset: destination)
layout.setOrder(rows.map(\.id))
}
} header: {
// **Beim Abschnitt, auf den er wirkt.** In der Werkzeugleiste
// stand er über allem — über dem Aufmacher, über den
// Mediatheken — und tat dort nichts. Ein Knopf, der nur für
// einen Teil des Bildschirms gilt, gehört an diesen Teil.
//
// Auf dem Mac gibt es ihn nicht: dort zieht man Zeilen ohne
// vorherigen Moduswechsel.
HStack {
Text("home.rowsHeader")
#if os(iOS)
if !rows.isEmpty {
Spacer()
EditButton()
.font(.haloCaption)
.textCase(nil)
}
#endif
}
} footer: {
Text("homeLayout.explanation")
}
}
// Ausdrücklich, nicht nur geerbt.
//
// Listen und Formulare zeichnen ihre Zeilen teilweise in einem
// eigenen Darstellungskontext, in dem eigene Umgebungswerte verloren
// gehen können — sichtbar an Picker-Werten, die in der Standardfarbe
// leuchteten, während die Schalter daneben schon die gewählte trugen.
.tint(accent)
.scrollContentBackground(.hidden)
}
/// Ob die Auswahl keine einzige Reihe trifft.
private var greiftInsLeere: Bool {
!layout.heroLibraries.isEmpty
&& !libraryBackedRows.isEmpty
&& layout.heroLibraries.isDisjoint(with: libraryBackedRows)
}
/// Kreuzt eine Mediathek für den Aufmacher an oder ab.
private func toggleHeroLibrary(_ identifier: String) {
if layout.heroLibraries.contains(identifier) {
layout.heroLibraries.remove(identifier)
} else {
layout.heroLibraries.insert(identifier)
}
}
/// Wirft gespeicherte Kennungen weg, die es nicht mehr gibt.
///
/// **Sonst wirken sie unsichtbar weiter.** Nach einem Serverwechsel
/// bleibt in den Voreinstellungen eine Mediathekskennung stehen, die zu
/// nichts mehr passt — sie steht in keiner Zeile, lässt sich also nicht
/// abwählen, und schränkt den Aufmacher trotzdem ein.
///
/// Nur, wenn überhaupt Mediatheken geladen sind: eine leere Liste heisst
/// „noch nicht da", nicht „gibt es nicht".
private func pruneHeroLibraries() {
guard !session.libraries.isEmpty else { return }
let vorhanden = Set(session.libraries.map(\.id))
let bereinigt = layout.heroLibraries.intersection(vorhanden)
guard bereinigt != layout.heroLibraries else { return }
layout.heroLibraries = bereinigt
}
private func load() async {
defer { isLoading = false }
pruneHeroLibraries()
guard let backend = session.backend else { return }
// Die Reihen kommen vom Server — es gibt keine feste Liste, die sich
// vorweg anzeigen ließe. Was hier steht, hängt davon ab, welche
// Mediatheken und Sammlungen angelegt sind.
let hubs = (try? await backend.homeRows()) ?? []
rows = layout.rows(from: hubs)
libraryBackedRows = Set(hubs.compactMap(\.libraryID))
}
}
extension HomeLayoutSettings.HeroSource {
var label: LocalizedStringKey {
switch self {
case .continueFirst: "home.heroSource.continueFirst"
case .latestOnly: "home.heroSource.latestOnly"
}
}
}