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>
166 lines
7.3 KiB
Swift
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))
|
|
}
|
|
}
|
|
}
|