Mehrfachauswahl in den Einstellungen unter „Startseite": welche Mediatheken den Aufmacher speisen dürfen. Haken statt Auswahlliste — eine Liste könnte nur eine, und „keine" wäre dort ein Eintrag, den man versehentlich trifft. Dafür musste eine Reihe sagen können, woher sie kommt: `MediaRowGroup` trägt jetzt eine `libraryID`. Jellyfin setzt sie an den „Neu in …"-Reihen — die baut die App ohnehin je Mediathek. Zwei Regeln, und beide sind Entscheidungen, keine Nebenwirkungen: **Leer heisst alle.** Wer nichts ankreuzt, meint „mir ist es gleich", nicht „nimm nichts". Ein leerer Aufmacher wäre der Fehler, den niemand mit dieser Einstellung in Verbindung brächte. **Reihen ohne Mediathek bleiben immer drin.** Weiterschauen, Als Nächstes und Favoriten gehen quer durch alle, und aus welcher Mediathek eine angefangene Folge stammt, sagt der Server an keiner Stelle. Sie wegzuwerfen hiesse, jemandem das Angefangene zu nehmen, weil er „Musikvideos" abgewählt hat. Der Hinweis unter der Auswahl sagt das auch dem, der nicht in den Quelltext sieht. Eingeschränkt wird **nur der Aufmacher**, nicht die Startseite: die Reihe selbst bleibt stehen. Wer sie ganz loswerden will, blendet sie aus — das ist eine andere Frage und hat ihren eigenen Schalter. Fünf Tests an `HeroSelection.rows(_:limitedTo:)` halten die Regeln fest, darunter der Fall einer gespeicherten Kennung, die es nicht mehr gibt. **Plex bleibt aussen vor:** dort kommen die Reihen als fertige Hubs vom Server, und `librarySectionID` wird in `PlexHub` nicht gelesen. Die Auswahl schränkt dort also nichts ein. Nachzuholen, wenn Plex wieder dran ist. iOS und tvOS bauen, 468 Tests grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
148 lines
6.3 KiB
Swift
148 lines
6.3 KiB
Swift
import MediaKit
|
|
import Observation
|
|
|
|
/// Die Startseite als Modell: laden, ordnen, auswählen.
|
|
///
|
|
/// **Warum das nicht mehr in `HomeView` steht.** Der Fernseher zeigt dieselben
|
|
/// Reihen wie das Telefon, nur ganz anders — und ohne diesen Schnitt hiesse
|
|
/// „auch auf tvOS" schlicht: hundert Zeilen kopieren. Kopiert wäre dabei
|
|
/// ausgerechnet der Teil, an dem schon mehrere Fehler steckten: die Reihenfolge
|
|
/// des Aufmachers, das Zusammenlegen der Weiterschauen-Reihen, das Ausblenden
|
|
/// von Gesehenem. Einer davon — das Los des Aufmachers hing an der Position in
|
|
/// der Liste statt am Titel — wäre in einer Kopie ein zweites Mal zu finden
|
|
/// gewesen.
|
|
///
|
|
/// **Die Naht verläuft zwischen Laden und Zeigen.** Hier steht, *was* auf der
|
|
/// Startseite steht; wie es aussieht, entscheidet jede Plattform für sich.
|
|
@MainActor
|
|
@Observable
|
|
final class HomeFeed {
|
|
private(set) var state: LoadState<[MediaRowGroup]> = .loading
|
|
|
|
/// Holt die Reihen vom Server.
|
|
///
|
|
/// Der Server kommt als Argument, nicht als gespeicherter Wert — dasselbe
|
|
/// Muster wie bei `QuickPlay.start`: so bleibt der Typ ohne Abhängigkeiten
|
|
/// baubar, und ein Serverwechsel veraltet hier nichts.
|
|
func load(from backend: (any MediaBackend)?) async {
|
|
guard let backend else { return }
|
|
state = await LoadState.load(keepingPrevious: state) {
|
|
try await backend.homeRows()
|
|
}
|
|
#if os(tvOS)
|
|
// Das Regal über der App im Menü. Es wird gezeichnet, wenn die App
|
|
// **nicht** läuft — also legen wir hier ab, was dort stehen soll.
|
|
if case .loaded(let reihen) = state {
|
|
TopShelfWriter.update(rows: reihen, backend: backend)
|
|
}
|
|
#endif
|
|
}
|
|
|
|
/// Die Reihen in der eingestellten Form.
|
|
func rows(for layout: HomeLayoutSettings) -> [MediaRowGroup] {
|
|
guard case .loaded(let alle) = state else { return [] }
|
|
return layout.apply(to: prepared(alle, layout: layout))
|
|
}
|
|
|
|
// MARK: - Ordnen
|
|
|
|
/// Bringt die Reihen vom Server in die Form, die eingestellt ist.
|
|
///
|
|
/// Hier und nicht im Backend: „Weiterschauen und Als Nächstes zusammen"
|
|
/// und „Gesehenes ausblenden" sind Fragen der Darstellung, keine des
|
|
/// Servers. Beides hier zu erledigen hält den Vertrag schmal und gilt für
|
|
/// Plex und Jellyfin gleichermaßen.
|
|
private func prepared(_ rows: [MediaRowGroup], layout: HomeLayoutSettings) -> [MediaRowGroup] {
|
|
var rows = rows
|
|
|
|
if layout.hidesWatchedInLatest {
|
|
rows = rows.map { row in
|
|
guard row.id.hasPrefix("latest") else { return row }
|
|
return MediaRowGroup(
|
|
id: row.id,
|
|
title: row.title,
|
|
moreKey: row.moreKey,
|
|
items: row.items.filter { !$0.isWatched }
|
|
)
|
|
}
|
|
}
|
|
|
|
if layout.combinesContinueRows {
|
|
rows = combiningContinueRows(rows)
|
|
}
|
|
|
|
return rows.filter { !$0.items.isEmpty }
|
|
}
|
|
|
|
/// Legt „Weiterschauen" und „Als Nächstes" in eine Reihe.
|
|
///
|
|
/// Die zusammengelegte Reihe behält die Kennung der ersten — sonst wäre
|
|
/// sie für die gespeicherte Reihenfolge eine neue und stünde plötzlich
|
|
/// wieder ganz unten.
|
|
private func combiningContinueRows(_ rows: [MediaRowGroup]) -> [MediaRowGroup] {
|
|
let continuing = rows.filter(\.isContinueWatching)
|
|
guard continuing.count > 1, let first = continuing.first else { return rows }
|
|
|
|
var seen = Set<String>()
|
|
let merged = continuing.flatMap(\.items).filter { seen.insert($0.id).inserted }
|
|
|
|
var result: [MediaRowGroup] = []
|
|
for row in rows {
|
|
guard row.isContinueWatching else {
|
|
result.append(row)
|
|
continue
|
|
}
|
|
guard row.id == first.id else { continue }
|
|
result.append(MediaRowGroup(id: row.id, title: row.title, items: merged))
|
|
}
|
|
return result
|
|
}
|
|
|
|
// MARK: - Der Aufmacher
|
|
|
|
/// Was oben groß gezeigt wird.
|
|
///
|
|
/// Angefangenes zuerst — wer die App öffnet, will meistens weiterschauen.
|
|
/// Aufgefüllt wird aus den übrigen Reihen: bei einer einzigen angefangenen
|
|
/// Folge gäbe es sonst nichts zu blättern. Wer den Aufmacher lieber als
|
|
/// Schaufenster versteht, stellt auf Neuzugänge um.
|
|
func heroItems(for layout: HomeLayoutSettings) -> [MediaItem] {
|
|
// **Nur hier, nicht an den Reihen selbst.** Eine abgewählte Mediathek
|
|
// verschwindet nicht von der Startseite — sie soll nur nicht mehr
|
|
// oben gross stehen. Wer sie ganz loswerden will, blendet die Reihe
|
|
// aus; das ist eine andere Frage und hat ihren eigenen Schalter.
|
|
let hubs = HeroSelection.rows(rows(for: layout), limitedTo: layout.heroLibraries)
|
|
let candidates: [MediaItem]
|
|
switch layout.heroSource {
|
|
case .continueFirst:
|
|
let continuing = hubs.first(where: \.isContinueWatching)?.items ?? []
|
|
let rest = hubs.filter { !$0.isContinueWatching }.flatMap(\.items)
|
|
candidates = continuing + rest
|
|
case .latestOnly:
|
|
candidates = hubs.filter { $0.id.hasPrefix("latest") }.flatMap(\.items)
|
|
}
|
|
|
|
var seen = Set<String>()
|
|
let unique = candidates.filter { seen.insert($0.id).inserted }
|
|
|
|
// **Gemischt, aber nicht bei jedem Blick.**
|
|
//
|
|
// Wie gemischt wird, steht in `HeroSelection` — einem reinen Typ mit
|
|
// Tests. Der Grund dafür ist ein Fehler, den man hier nie gefunden
|
|
// hätte: das Los hing an der **Position** in der Liste, nicht am
|
|
// Titel. Kam einer dazu oder änderte sich ein Fortschritt, bekamen
|
|
// alle dahinter ein anderes Los — und der Aufmacher war nach jedem
|
|
// Start ein anderer, obwohl das Zeitfenster dasselbe war.
|
|
switch layout.heroSource {
|
|
case .continueFirst:
|
|
// Innerhalb der Gruppen ziehen, nicht über sie hinweg:
|
|
// Angefangenes bleibt vorn, wechselt aber untereinander.
|
|
let ordered = HeroSelection.ordered(unique.filter { $0.watchProgress != nil })
|
|
+ HeroSelection.ordered(unique.filter { $0.watchProgress == nil })
|
|
return Array(ordered.prefix(layout.heroCount))
|
|
case .latestOnly:
|
|
return Array(HeroSelection.ordered(unique).prefix(layout.heroCount))
|
|
}
|
|
}
|
|
}
|