Die Startseite am Fernseher — und die Ladeschicht nur einmal
Aufmacher, Reihen, Mediatheken, Raster. Der sichtbare Teil ist neu; der Teil darunter ist derselbe wie am Telefon, und das war die eigentliche Arbeit. **Herausgelöst statt kopiert.** `HomeFeed` und `LibraryFeed` tragen jetzt, was vorher in `HomeView` und `LibraryView` stand: laden, ordnen, den Aufmacher auslosen, den Zwischenspeicher abziehen, entdoppeln, nach einem Fehlschlag sperren. Das zu kopieren wäre der schlimmste Ausgang dieses Vorhabens gewesen — an mindestens drei dieser Stellen klebte schon je ein Fehler, der sich als „es sind nicht alle Filme drin" äusserte und sonst nichts. Einer davon, das Los des Aufmachers an der Position statt am Titel, wäre in einer Kopie ein zweites Mal zu finden gewesen. Mitgewandert sind `rowTitle`, `rowArtworkPath`, `posterArtworkPath`, `runtimeLabel` und `HeroArtwork`: reine Ableitungen, die in einer Ansichtsdatei lagen und deshalb nur das Telefon übersetzte. **Nachgeladen wird am Fokus, nicht am Erscheinen.** Die alte Regel hiess „die letzten fünf" und hing daran, dass eine Kachel sichtbar wird. Beim ersten Zeichnen erscheinen aber alle sechzig, auch die letzten fünf — das Nachladen lief sofort los statt beim Blättern. Auf einem Raster aus sechs Spalten sind fünf Einträge ausserdem nicht einmal eine Zeile. `PagingWindow` rechnet deshalb in Zeilen; sechs Tests halten es fest, darunter der Fall, der die alte Regel entlarvt. **Der Fokus setzt den Aufmacher.** Wer durch „Zuletzt hinzugefügt" wandert, sieht oben, was er gerade anschaut. Kein Wechsel im Sieben- sekundentakt wie am Telefon: vor einem Fernseher wäre ein Bild, das sich von allein austauscht, während man es liest, eine Zumutung. **Drei Dinge, die erst der Simulator zeigte.** Die Seiten müssen bildfüllend laufen, weil ein Aufmacher, der achtzig Punkte vor der Bildschirmkante aufhört, nach Fehler aussieht — also trägt `screenMargin` auf tvOS den Überscan mit. Die Karten brauchen einen Fokus-Rückruf, weil `\.isFocused` den nächsten fokussierbaren Vorfahren meint und das in einer Reihe aus Karten nicht verlässlich die Karte ist. Und der Bau scheiterte an „Unable to resolve module dependency: VLCKit" — der Abhängigkeits- Sucher prüft `canImport(FlixyVLC)` auch für ein Ziel, das es nie verlinkt. Ein `&& !os(tvOS)` genügt; das Paket selbst bleibt unverändert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ab4fcfb3b8
commit
87f23afe90
@@ -2811,6 +2811,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"library.title": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Mediatheken"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Libraries"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"library.toggleLayout": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
|
||||
@@ -4,6 +4,11 @@ import MediaKit
|
||||
|
||||
/// Woher der Aufmacher seine Bilder nimmt.
|
||||
///
|
||||
/// **Liegt bei den Modellen, obwohl der Name nach Ansicht klingt.** Hier steht
|
||||
/// keine Zeile SwiftUI, nur die Frage, welche Adresse in welcher Grösse
|
||||
/// angefordert wird — und die beantwortet der Fernseher genauso wie das
|
||||
/// Telefon.
|
||||
///
|
||||
/// An **einer** Stelle, weil zwei Stellen es schon zweimal falsch gemacht
|
||||
/// haben:
|
||||
///
|
||||
@@ -0,0 +1,136 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import MediaKit
|
||||
import Observation
|
||||
|
||||
/// Eine Mediathek als Modell: laden, nachladen, entdoppeln.
|
||||
///
|
||||
/// **Der teuerste Brocken, den man hätte kopieren können.** Hier stecken der
|
||||
/// Abzug aus dem Zwischenspeicher, der Seitenversatz, die Entdoppelung gegen
|
||||
/// Plex-Serverscans und die Sperre nach einem Fehlschlag — und an mindestens
|
||||
/// zwei davon hat schon je ein Fehler geklebt, der sich als „es sind nicht
|
||||
/// alle Filme drin" äusserte und sonst nichts. Ein zweites Mal wären sie in
|
||||
/// einer Kopie zu finden gewesen.
|
||||
///
|
||||
/// **Was das Nachladen auslöst, steht hier nicht.** Am Telefon ist es das
|
||||
/// Erscheinen einer Kachel, am Fernseher der Fokus. Die Regel selbst —
|
||||
/// `PagingWindow` — ist ein reiner Typ mit Tests.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class LibraryFeed {
|
||||
let library: MediaLibrary
|
||||
|
||||
private(set) var items: [MediaItem] = []
|
||||
private(set) var state: LoadState<Void> = .loading
|
||||
private(set) var page: MediaPage?
|
||||
/// Ob der letzte Nachladeversuch misslungen ist.
|
||||
///
|
||||
/// Dann passiert von selbst nichts mehr, und es braucht einen Knopf: die
|
||||
/// Kacheln, die das Nachladen auslösen, sind längst erschienen und
|
||||
/// erscheinen kein zweites Mal.
|
||||
private(set) var loadMoreFailed = false
|
||||
private(set) var isLoadingMore = false
|
||||
|
||||
var query = MediaQuery()
|
||||
|
||||
init(library: MediaLibrary) {
|
||||
self.library = library
|
||||
}
|
||||
|
||||
/// Wie viele Platzhalter noch unter dem Geladenen stehen.
|
||||
var pendingCount: Int {
|
||||
guard let page else { return 0 }
|
||||
return max(0, page.totalCount - items.count)
|
||||
}
|
||||
|
||||
func reload(from backend: (any MediaBackend)?) async {
|
||||
guard let server = backend else { return }
|
||||
let schlüssel = query.cacheKey
|
||||
let kennung = server.identity
|
||||
|
||||
// Nur wenn nichts dasteht. Beim Aktualisieren und beim Filterwechsel
|
||||
// ist das Gezeigte aktueller als jeder Abzug.
|
||||
if items.isEmpty,
|
||||
let stand = await LibraryCache.shared.snapshot(
|
||||
server: kennung, library: library.id, queryKey: schlüssel
|
||||
) {
|
||||
items = stand.items
|
||||
page = MediaPage(items: stand.items, totalCount: stand.totalCount)
|
||||
state = .loaded(())
|
||||
} else if items.isEmpty {
|
||||
state = .loading
|
||||
}
|
||||
|
||||
do {
|
||||
let erste = try await server.items(
|
||||
inLibrary: library, query: query, offset: 0, limit: 60
|
||||
)
|
||||
items = erste.items
|
||||
page = erste
|
||||
loadMoreFailed = false
|
||||
state = .loaded(())
|
||||
|
||||
await LibraryCache.shared.store(
|
||||
LibraryCache.Snapshot(
|
||||
queryKey: schlüssel, items: erste.items, totalCount: erste.totalCount
|
||||
),
|
||||
server: kennung,
|
||||
library: library.id
|
||||
)
|
||||
} catch {
|
||||
// Siehe `LoadState.load`: eine abgebrochene Netzanfrage meldet
|
||||
// sich als Übertragungsfehler, nicht als Abbruch.
|
||||
if Task.isCancelled { return }
|
||||
// Steht schon etwas da — aus dem Speicher oder von vorhin —, dann
|
||||
// bleibt es. Den Fehler über eine brauchbare Liste zu legen nähme
|
||||
// dem Nutzer etwas weg, statt ihm etwas zu sagen.
|
||||
guard items.isEmpty else { return }
|
||||
state = .failed(message: ErrorText.describe(error))
|
||||
}
|
||||
}
|
||||
|
||||
func loadMore(from backend: (any MediaBackend)?) async {
|
||||
guard let server = backend, let page, !isLoadingMore,
|
||||
items.count < page.totalCount
|
||||
else { return }
|
||||
|
||||
isLoadingMore = true
|
||||
loadMoreFailed = false
|
||||
defer { isLoadingMore = false }
|
||||
|
||||
do {
|
||||
let nächste = try await server.items(
|
||||
inLibrary: library, query: query, offset: page.nextOffset, limit: 60
|
||||
)
|
||||
// **Anhängen, nie ersetzen.** Plex kann bei gleichzeitigem
|
||||
// Serverscan denselben Titel auf zwei Seiten liefern; ohne diese
|
||||
// Prüfung stünde er doppelt im Raster, und SwiftUI beschwert sich
|
||||
// über doppelte Kennungen. Und auf dem Fernseher hinge daran noch
|
||||
// mehr: würde `items` als Ganzes ersetzt, verlöre der Fokus mitten
|
||||
// im Scrollen seinen Halt und spränge an den Anfang.
|
||||
let bekannt = Set(items.map(\.id))
|
||||
items.append(contentsOf: nächste.items.filter { !bekannt.contains($0.id) })
|
||||
self.page = nächste
|
||||
} catch {
|
||||
// **Nicht `page = nil`.** Das stand hier einmal, und es beendete
|
||||
// das Nachladen **endgültig**: eine einzige misslungene Anfrage
|
||||
// auf einer wackeligen Leitung, und die Mediathek blieb für immer
|
||||
// bei sechzig Titeln stehen — lautlos. Genau so sieht „es sind
|
||||
// nicht alle Filme drin" aus.
|
||||
loadMoreFailed = true
|
||||
}
|
||||
}
|
||||
|
||||
/// Ob der Eintrag an dieser Stelle nah genug am Ende liegt.
|
||||
///
|
||||
/// Die Regel steht in `PagingWindow`; hier steht nur, woher die Zahlen
|
||||
/// kommen. **Was das auslöst**, entscheidet die Ansicht.
|
||||
func shouldLoadMore(at index: Int, columns: Int) -> Bool {
|
||||
guard !loadMoreFailed, !isLoadingMore, let page else { return false }
|
||||
return PagingWindow(columns: columns)
|
||||
.shouldLoad(focusedIndex: index, loaded: items.count, total: page.totalCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import MediaKit
|
||||
|
||||
/// Welcher Titel und welches Bild auf eine Kachel gehören.
|
||||
///
|
||||
/// **Hier und nicht bei einer Ansicht.** Das stand in `MediaRow.swift` — und
|
||||
/// damit in einer Datei, die nur das Telefon übersetzt. Der Fernseher zeigt
|
||||
/// dieselben Kacheln mit denselben Rückfallketten; sie dort noch einmal
|
||||
/// hinzuschreiben hiesse, zwei Stellen gleich zu halten, an denen die Fehler
|
||||
/// erst nach Wochen auffallen: ein Plakat, das bei einer Staffel grau bleibt,
|
||||
/// sieht nicht nach einem Fehler aus, sondern nach einem Server ohne Bild.
|
||||
///
|
||||
/// Reine Ableitungen aus `MediaItem`, keine Ansicht — deshalb neben den
|
||||
/// übrigen Modellen und nicht in FlixyDesign, das `MediaItem` gar nicht kennt.
|
||||
extension MediaItem {
|
||||
/// Der Titel auf einer Kachel in einer Reihe.
|
||||
///
|
||||
/// Groß steht, worum es geht: bei einer Folge ihr eigener Titel, bei
|
||||
/// einer Staffel der Name der Serie. Die Einordnung — Serie, Staffel —
|
||||
/// gehört darunter. „Staffel 10" allein als Überschrift sagt nichts
|
||||
/// darüber, welche Serie gemeint ist.
|
||||
var rowTitle: String {
|
||||
switch kind {
|
||||
case .episode: title
|
||||
// Die Serie steht je nach Antwort mal unter `parentTitle`, mal unter
|
||||
// `seriesTitle`. Fehlt beides, bleibt „Staffel 2" — besser als
|
||||
// gar keine Beschriftung.
|
||||
case .season: parentTitle ?? seriesTitle ?? title
|
||||
default: title
|
||||
}
|
||||
}
|
||||
|
||||
/// Welches Bild auf die Querformat-Kachel gehört.
|
||||
///
|
||||
/// Bei Folgen ist `thumb` das Szenenbild der Folge — genau richtig. Bei
|
||||
/// Filmen ist `thumb` das Hochkant-Plakat, das im Querformat beschnitten
|
||||
/// würde; dort ist `art` (das Hintergrundbild) die bessere Wahl.
|
||||
var rowArtworkPath: String? {
|
||||
switch kind {
|
||||
case .episode: thumb ?? seriesThumb
|
||||
// Eine Staffel hat bei Jellyfin oft gar kein eigenes Bild — sie leiht
|
||||
// sich das der Serie. Ohne diesen Rückfall blieb „Nachtstreife,
|
||||
// Staffel 7" eine graue Fläche, obwohl die Serie ein Plakat hat.
|
||||
case .season: art ?? thumb ?? seriesArt ?? seriesThumb
|
||||
default: art ?? thumb
|
||||
}
|
||||
}
|
||||
|
||||
/// Welches Bild auf die Hochkant-Kachel gehört.
|
||||
///
|
||||
/// Bei Folgen das Plakat der Serie statt des Szenenbilds: ein 16:9-Bild
|
||||
/// hochkant beschnitten zeigt einen zufälligen Ausschnitt und ist zum
|
||||
/// Wiedererkennen wertlos.
|
||||
var posterArtworkPath: String? {
|
||||
switch kind {
|
||||
case .episode: seriesThumb ?? parentThumb ?? thumb
|
||||
// Das eigene Staffelplakat zuerst: es zeigt die Staffel. Gibt es
|
||||
// keines, ist das der Serie allemal besser als nichts.
|
||||
case .season: thumb ?? seriesThumb
|
||||
default: thumb
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Laufzeiten als Text.
|
||||
///
|
||||
/// Aus derselben Ansichtsdatei hierher geholt wie die Kachel-Ableitungen
|
||||
/// darüber: am Fernseher steht dieselbe Zeile unter demselben Aufmacher.
|
||||
extension Duration {
|
||||
/// Laufzeit als „2 Std 46 Min" bzw. „46 Min".
|
||||
var runtimeLabel: String {
|
||||
let totalMinutes = Int(components.seconds / 60)
|
||||
let hours = totalMinutes / 60
|
||||
let minutes = totalMinutes % 60
|
||||
if hours > 0 {
|
||||
return String(localized: "runtime.hoursMinutes \(hours) \(minutes)")
|
||||
}
|
||||
return String(localized: "runtime.minutes \(minutes)")
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import Foundation
|
||||
#if canImport(FlixyVLC)
|
||||
// **`!os(tvOS)` und nicht nur `canImport`.** Der Fernseher verlinkt FlixyVLC
|
||||
// nicht, aber der Abhängigkeits-Sucher von Clang prüft den Import trotzdem —
|
||||
// und bricht dann an VLCKit ab, das für dieses Ziel gar nicht gebaut wird.
|
||||
// „Unable to resolve module dependency: VLCKit" nennt dabei weder diese Datei
|
||||
// noch VLC als Grund.
|
||||
#if canImport(FlixyVLC) && !os(tvOS)
|
||||
import FlixyVLC
|
||||
#endif
|
||||
import Observation
|
||||
@@ -62,8 +67,12 @@ final class PlaybackSettings {
|
||||
/// Server: MKV und DTS werden dann umgerechnet statt unverändert
|
||||
/// übertragen. Das kostet ihn Rechenzeit und sieht man nicht, aber es
|
||||
/// spielt.
|
||||
///
|
||||
/// Auf dem Fernseher ebenfalls nicht: 920 MB Binärpaket für einen
|
||||
/// Rückfallweg, der auf einem Apple TV selten greift, sind die Bauzeit
|
||||
/// nicht wert.
|
||||
static var hasFallbackEngine: Bool {
|
||||
#if canImport(FlixyVLC)
|
||||
#if canImport(FlixyVLC) && !os(tvOS)
|
||||
true
|
||||
#else
|
||||
false
|
||||
|
||||
@@ -74,16 +74,3 @@ struct HeroSection: View {
|
||||
return pieces.isEmpty ? nil : pieces.joined(separator: " · ")
|
||||
}
|
||||
}
|
||||
|
||||
extension Duration {
|
||||
/// Laufzeit als „2 Std 46 Min" bzw. „46 Min".
|
||||
var runtimeLabel: String {
|
||||
let totalMinutes = Int(components.seconds / 60)
|
||||
let hours = totalMinutes / 60
|
||||
let minutes = totalMinutes % 60
|
||||
if hours > 0 {
|
||||
return String(localized: "runtime.hoursMinutes \(hours) \(minutes)")
|
||||
}
|
||||
return String(localized: "runtime.minutes \(minutes)")
|
||||
}
|
||||
}
|
||||
|
||||
+13
-107
@@ -3,14 +3,19 @@ import FlixyDesign
|
||||
import MediaKit
|
||||
|
||||
/// Die Startseite: Aufmacher oben, darunter die Reihen vom Server.
|
||||
///
|
||||
/// **Nur noch Darstellung.** Was auf der Startseite steht — laden, ordnen,
|
||||
/// den Aufmacher auslosen — liegt in `HomeFeed`, weil der Fernseher dieselben
|
||||
/// Reihen zeigt und die Logik sonst zweimal dastünde. Kopiert wäre dabei
|
||||
/// ausgerechnet der Teil gewesen, an dem schon mehrere Fehler steckten.
|
||||
struct HomeView: View {
|
||||
@Environment(FlixySession.self) private var session
|
||||
@Environment(HomeLayoutSettings.self) private var layout
|
||||
@State private var state: LoadState<[MediaRowGroup]> = .loading
|
||||
@State private var feed = HomeFeed()
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch state {
|
||||
switch feed.state {
|
||||
case .loading:
|
||||
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
@@ -20,11 +25,11 @@ struct HomeView: View {
|
||||
} description: {
|
||||
Text(message)
|
||||
} actions: {
|
||||
Button("action.retry") { Task { await load() } }
|
||||
Button("action.retry") { Task { await feed.load(from: session.backend) } }
|
||||
}
|
||||
|
||||
case .loaded(let allRows):
|
||||
let hubs = layout.apply(to: prepared(allRows))
|
||||
case .loaded:
|
||||
let hubs = feed.rows(for: layout)
|
||||
if hubs.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label("home.empty.title", systemImage: "film.stack")
|
||||
@@ -54,14 +59,14 @@ struct HomeView: View {
|
||||
// daneben, und genau diese Kante sah man. Auf den geschobenen
|
||||
// Bildschirmen bleibt sie, dort trägt sie den Zurück-Weg.
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
.task(id: session.serverName) { await load() }
|
||||
.refreshable { await load() }
|
||||
.task(id: session.serverName) { await feed.load(from: session.backend) }
|
||||
.refreshable { await feed.load(from: session.backend) }
|
||||
}
|
||||
|
||||
private func content(hubs: [MediaRowGroup]) -> some View {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: FlixySpacing.section) {
|
||||
let featured = heroItems(in: hubs)
|
||||
let featured = feed.heroItems(for: layout)
|
||||
if !featured.isEmpty {
|
||||
HeroCarousel(items: featured)
|
||||
}
|
||||
@@ -75,103 +80,4 @@ struct HomeView: View {
|
||||
.scrollIndicators(.never)
|
||||
.flixyRevealsTopChrome()
|
||||
}
|
||||
|
||||
/// 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]) -> [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
|
||||
}
|
||||
|
||||
/// 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.
|
||||
private func heroItems(in hubs: [MediaRowGroup]) -> [MediaItem] {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
guard let backend = session.backend else { return }
|
||||
state = await LoadState.load(keepingPrevious: state) {
|
||||
try await backend.homeRows()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,53 +77,3 @@ struct MediaRow: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MediaItem {
|
||||
/// Der Titel auf einer Kachel in einer Reihe.
|
||||
///
|
||||
/// Groß steht, worum es geht: bei einer Folge ihr eigener Titel, bei
|
||||
/// einer Staffel der Name der Serie. Die Einordnung — Serie, Staffel —
|
||||
/// gehört darunter. „Staffel 10" allein als Überschrift sagt nichts
|
||||
/// darüber, welche Serie gemeint ist.
|
||||
var rowTitle: String {
|
||||
switch kind {
|
||||
case .episode: title
|
||||
// Die Serie steht je nach Antwort mal unter `parentTitle`, mal unter
|
||||
// `seriesTitle`. Fehlt beides, bleibt „Staffel 2" — besser als
|
||||
// gar keine Beschriftung.
|
||||
case .season: parentTitle ?? seriesTitle ?? title
|
||||
default: title
|
||||
}
|
||||
}
|
||||
|
||||
/// Welches Bild auf die Querformat-Kachel gehört.
|
||||
///
|
||||
/// Bei Folgen ist `thumb` das Szenenbild der Folge — genau richtig. Bei
|
||||
/// Filmen ist `thumb` das Hochkant-Plakat, das im Querformat beschnitten
|
||||
/// würde; dort ist `art` (das Hintergrundbild) die bessere Wahl.
|
||||
var rowArtworkPath: String? {
|
||||
switch kind {
|
||||
case .episode: thumb ?? seriesThumb
|
||||
// Eine Staffel hat bei Jellyfin oft gar kein eigenes Bild — sie leiht
|
||||
// sich das der Serie. Ohne diesen Rückfall blieb „Nachtstreife,
|
||||
// Staffel 7" eine graue Fläche, obwohl die Serie ein Plakat hat.
|
||||
case .season: art ?? thumb ?? seriesArt ?? seriesThumb
|
||||
default: art ?? thumb
|
||||
}
|
||||
}
|
||||
|
||||
/// Welches Bild auf die Hochkant-Kachel gehört.
|
||||
///
|
||||
/// Bei Folgen das Plakat der Serie statt des Szenenbilds: ein 16:9-Bild
|
||||
/// hochkant beschnitten zeigt einen zufälligen Ausschnitt und ist zum
|
||||
/// Wiedererkennen wertlos.
|
||||
var posterArtworkPath: String? {
|
||||
switch kind {
|
||||
case .episode: seriesThumb ?? parentThumb ?? thumb
|
||||
// Das eigene Staffelplakat zuerst: es zeigt die Staffel. Gibt es
|
||||
// keines, ist das der Serie allemal besser als nichts.
|
||||
case .season: thumb ?? seriesThumb
|
||||
default: thumb
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user