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>
238 lines
9.8 KiB
Swift
238 lines
9.8 KiB
Swift
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"
|
|
}
|
|
}
|
|
}
|