Gemessen, weil „es tut nichts" und „es tut etwas anderes" von aussen
gleich aussehen. Gegen die Attrappe, mit laufendem Mitschnitt:
Auswahl ["lib-shows","lib-movies"] → übrig [latest.lib-movies, latest.lib-shows]
Auswahl ["lib-shows"] → übrig [latest.lib-shows]
Und der Aufmacher wechselte dabei **sofort** von „Nachtzug nach Genf"
(Film) zu „Küstenwache" (Serie) — ohne Neustart, mit offener App. Der
Weg stimmt also; was gemeldet wurde, muss eine andere Ursache haben.
Zwei Dinge, die diese Messung erst möglich gemacht haben:
**Die Attrappe hatte in jeder Mediathek dasselbe stehen.** „Neu in Filme",
„Neu in Serien" und „Neu in Gemischtes" lieferten dieselben Titel — damit
sieht ein wirkungsloser Filter genauso aus wie ein wirksamer. Jetzt gibt
es zwei Titel, die es **nur** in „Gemischtes" gibt, und jede Mediathek
führt, was zu ihr gehört.
**Ein Zwischenergebnis, das ich fast für den Fehler gehalten hätte:** ein
Neuinstallieren über `simctl install` löscht die Voreinstellungen nicht.
Die vermeintlich frische Auswahl war in Wahrheit die alte plus die neue —
zwei Messungen lang.
**Und der Punkt, der dem gemeldeten Verhalten am nächsten kommt:** trifft
die Auswahl keine einzige Reihe, gilt sie seit gestern als wirkungslos und
der Aufmacher zeigt alles. Das verhindert einen leeren Aufmacher, sieht
aber genau aus wie ein Schalter, der nichts tut. Jetzt steht es dort, wo
man es einstellt: ein Hinweis unter der Auswahl, sobald für die
angehakten Mediatheken keine eigene Reihe auf der Startseite existiert.
Welche Mediatheken überhaupt eine Reihe stellen, wird dafür aus den
geladenen Reihen gelesen, nicht angenommen.
iOS und tvOS bauen, 469 Tests grün.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
238 lines
9.8 KiB
Swift
238 lines
9.8 KiB
Swift
import SwiftUI
|
|
import FlixyDesign
|
|
import MediaKit
|
|
|
|
/// Reihen der Startseite ein- und ausblenden und umsortieren.
|
|
struct HomeLayoutView: View {
|
|
@Environment(FlixySession.self) private var session
|
|
@Environment(\.flixyAccent) 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.flixyCanvas)
|
|
.navigationTitle(Text("homeLayout.title"))
|
|
.flixyInlineTitle()
|
|
.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.flixyTextSecondary)
|
|
.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.flixySurface)
|
|
|
|
// **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.flixyTextPrimary)
|
|
}
|
|
// 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: FlixySpacing.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.flixyDestructive)
|
|
}
|
|
}
|
|
}
|
|
.listRowBackground(Color.flixySurface)
|
|
}
|
|
|
|
Section {
|
|
Toggle("home.combineContinue", isOn: $layout.combinesContinueRows)
|
|
Toggle("home.hideWatched", isOn: $layout.hidesWatchedInLatest)
|
|
}
|
|
.listRowBackground(Color.flixySurface)
|
|
|
|
if isLoading {
|
|
ProgressView()
|
|
.frame(maxWidth: .infinity)
|
|
.listRowBackground(Color.clear)
|
|
} else if rows.isEmpty {
|
|
Text("homeLayout.empty")
|
|
.foregroundStyle(Color.flixyTextSecondary)
|
|
.listRowBackground(Color.flixySurface)
|
|
}
|
|
|
|
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.flixyTextPrimary)
|
|
}
|
|
.listRowBackground(Color.flixySurface)
|
|
}
|
|
.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(.flixyCaption)
|
|
.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"
|
|
}
|
|
}
|
|
}
|