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() 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() 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)) } } }