**Intro überspringen gab es für Jellyfin nie.** Der Knopf, die Marken,
das automatische Überspringen, die Einstellungen dazu — alles stand seit
Langem im Player und auf dem Fernseher. Nur kam bei Jellyfin nie eine
Marke an: `JellyfinItem+Media.swift` setzte `markers: []`, weil Jellyfin
sie nicht am Titel liefert.
Seit 10.10 gibt es `/MediaSegments/{itemId}`, gefüllt von einem Anbieter
im Server wie dem Intro-Skipper. Die Anfrage läuft **neben** dem Abruf
des Titels, nicht danach — sonst wartete die Detailseite zweimal.
Zwei Dinge, die sonst eine Stunde kosten: `includeSegmentTypes` muss
**zweimal** stehen, nicht einmal mit Komma (mit Komma antwortet Jellyfin
mit null Treffern), und die Abfrage wirft nicht. Ein Server ohne Anbieter
antwortet leer, einer vor 10.10 mit einem 404 — und ein Film ohne Intro
ist kein Fehler, sondern der Normalfall.
Geprüft im Simulator: die Attrappe meldet jetzt Segmente, und im Player
steht „Intro überspringen".
---
**„Aus Weiterschauen entfernen".** Eine halbe Stunde gesehen und keine
Lust mehr — der Titel ist weder gesehen noch ungesehen, er soll nur nicht
ewig oben stehen. Beide Server können es, auf verschiedenen Wegen:
- **Plex** hat einen eigenen: `PUT /actions/removeFromContinueWatching`.
**Nicht** `/:/unscrobble` — das setzt zugleich auf ungesehen.
- **Jellyfin** hat keinen. Was es gibt, ist das Benutzerdatenblatt; die
Liste führt, was eine gemerkte Stelle grösser null hat. Also wird das
Blatt gelesen, bekommt eine Null und geht so, wie es war, zurück.
**Erst lesen, dann schreiben**, weil dort auch „gesehen", „Favorit" und
die Bewertung stehen — wer seinen Favoriten verliert, weil er einen
Film aus einer Liste genommen hat, sucht den Grund nie dort.
Der Eintrag erscheint nur, wenn es eine gemerkte Stelle gibt.
---
**Die Mediatheken für den Aufmacher taten zu wenig.** Die Regel „Reihen
ohne Mediathek bleiben immer drin" war freundlich gemeint und falsch: bei
„nur Filme" stand weiter eine Serie oben, weil sie aus „Weiterschauen"
kam. Gemessen und nicht vermutet — es lag nicht am Zeitfenster der
Auswahl, wie zuerst angenommen.
Jetzt gilt: ist etwas gewählt, speist sich der Aufmacher **nur** daraus.
Wer Weiterschauen oben haben will, wählt keine Mediathek. Der Hinweis
unter der Auswahl sagt das auch.
---
**Der „Bearbeiten"-Knopf stand über allem und tat dort nichts.** Er gilt
nur für die Reihen; jetzt steht er an deren Überschrift.
---
Die Attrappe kann drei Dinge mehr, sonst wäre nichts davon prüfbar:
Segmente, `parentId` bei den Neuzugängen (vorher stand in jeder Mediathek
dasselbe) und damit überhaupt eine Mediathekenauswahl, die man ansehen
kann.
iOS und tvOS bauen, 468 Tests grün.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
194 lines
7.6 KiB
Swift
194 lines
7.6 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] = []
|
|
@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: {
|
|
Text("home.heroLibrariesExplanation")
|
|
}
|
|
.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)
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
}
|
|
|
|
private func load() async {
|
|
defer { isLoading = false }
|
|
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)
|
|
}
|
|
}
|
|
|
|
extension HomeLayoutSettings.HeroSource {
|
|
var label: LocalizedStringKey {
|
|
switch self {
|
|
case .continueFirst: "home.heroSource.continueFirst"
|
|
case .latestOnly: "home.heroSource.latestOnly"
|
|
}
|
|
}
|
|
}
|