Files
Flaro/Halo/Session/HomeFeed.swift
T
ScarriffleandClaude Opus 5 4b9a1e7b67 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>
2026-10-03 11:12:32 +02:00

166 lines
7.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,
// **Die Mediathek muss mit.** Sie fehlte hier, und das
// war teuer: dieser Zweig baut genau die Reihen neu, die
// als einzige eine tragen — und der Aufmacher wirft seit
// Neuestem jede Reihe ohne Mediathek weg, sobald eine
// ausgewählt ist. „Gesehenes ausblenden" und „Mediathek
// für den Aufmacher" zusammen ergaben deshalb gar keinen
// Aufmacher mehr. Beide Schalter stehen im selben
// Bildschirm untereinander.
libraryID: row.libraryID,
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 }
// `moreKey` und `libraryID` wie oben: was die Reihe mitbrachte,
// soll sie behalten. Ohne `moreKey` fiel bei zusammengelegten
// Reihen „Alle anzeigen" weg — ein eigener, älterer Fehler.
result.append(MediaRowGroup(
id: row.id,
title: row.title,
moreKey: row.moreKey,
libraryID: row.libraryID,
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))
}
}
}