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,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user