Files
Flaro/Flixy/Session/HomeFeed.swift
T
ScarriffleandClaude Opus 5 e801502c7a Ein Regal über der App — und ein Weg aus dem Player heraus
**Das Regal.** Wer im tvOS-Menü über der App steht, sah bisher das Icon
noch einmal gross. Jetzt stehen dort „Weiterschauen", „Als Nächstes" und
die Neuzugänge als Plakate, mit Fortschrittsbalken; ein Druck spielt ab,
ein Wisch nach unten öffnet die Detailseite.

**Warum es dafür eine App-Gruppe braucht.** Das Regal zeichnet eine
eigene Erweiterung, also ein anderer Prozess mit eigener Sandbox — und
tvOS ruft sie auf, während die App **nicht** läuft, mit weniger als einer
Sekunde Zeit. Kein Netzabruf, keine Anmeldung, kein Blättern. Also legt
die App beim Laden der Startseite hin, was das Regal braucht, und sonst
nichts: Titel, Bildadresse, Kennung, Fortschritt. Eine Datei, ein
`JSONDecoder`, fertig.

Die Bildadressen tragen den Zugangsschlüssel, weil Jellyfin ihn für
Bilder verlangt und die Erweiterung ihn sonst nirgends herbekäme. Die
Datei liegt in der App-Gruppe — erreichbar für die App und ihre
Erweiterungen, für sonst niemanden. Der Schlüsselbund bleibt, wo er ist.

**Und der Fehler, den du gefunden hast.** Solange der Stream noch
ausgehandelt wurde, gab es keinen Weg zurück: schwarzes Bild, Kreisel,
und der Menü-Knopf tat nichts. `AVPlayerViewController` fängt ihn nämlich
selbst ab — aber erst, wenn er einen Player mit einem Item hat, und genau
den gibt es in dem Moment noch nicht. Der Griff liegt jetzt eine Ebene
darüber, wo es ihn vom ersten Bild an gibt. Doppelt ist er nicht: greift
Apples Player zu, kommt der Befehl dort gar nicht mehr an.

**Was am Gerät noch offen ist.** Die App-Gruppe muss im Entwicklerkonto
eingetragen sein, sonst bleibt das Regal leer — im Simulator legt das
System sie von selbst an, auf einem echten Apple TV nicht. Geprüft ist,
dass die App die Datei schreibt (drei Reihen, richtige Adressen) und dass
tvOS das Schema kennt. Dass das Regal beim Darüberstehen auch zeichnet,
lässt sich ohne Fernbedienung nicht auslösen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-29 20:14:38 +02:00

144 lines
6.0 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] {
let hubs = rows(for: layout)
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))
}
}
}