From 87f23afe90c11846adbb1e830e82aefc31a1ea42 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Tue, 29 Sep 2026 19:36:49 +0200 Subject: [PATCH] =?UTF-8?q?Die=20Startseite=20am=20Fernseher=20=E2=80=94?= =?UTF-8?q?=20und=20die=20Ladeschicht=20nur=20einmal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Flixy/Resources/Localizable.xcstrings | 17 ++ .../{Views/Home => Session}/HeroArtwork.swift | 5 + Flixy/Session/HomeFeed.swift | 136 +++++++++++++++ Flixy/Session/LibraryFeed.swift | 131 ++++++++++++++ Flixy/Session/MediaItem+Presentation.swift | 79 +++++++++ Flixy/Session/PlaybackSettings.swift | 13 +- Flixy/Views/Home/HeroSection.swift | 13 -- Flixy/Views/Home/HomeView.swift | 120 ++----------- Flixy/Views/Home/MediaRow.swift | 50 ------ FlixyTV/Views/Detail/TVDetailView.swift | 49 ++++++ FlixyTV/Views/Home/TVHeroSection.swift | 62 +++++++ FlixyTV/Views/Home/TVHomeView.swift | 148 ++++++++++++++++ FlixyTV/Views/Home/TVMediaRow.swift | 73 ++++++++ FlixyTV/Views/Library/TVLibraryView.swift | 121 +++++++++++++ FlixyTV/Views/TVRootView.swift | 22 --- .../FlixyDesign/Components/TVCards.swift | 163 ++++++++++++++++++ .../Sources/FlixyDesign/FlixyFont.swift | 19 +- .../Sources/MediaKit/PagingWindow.swift | 37 ++++ .../MediaKitTests/PagingWindowTests.swift | 49 ++++++ 19 files changed, 1107 insertions(+), 200 deletions(-) rename Flixy/{Views/Home => Session}/HeroArtwork.swift (93%) create mode 100644 Flixy/Session/HomeFeed.swift create mode 100644 Flixy/Session/LibraryFeed.swift create mode 100644 Flixy/Session/MediaItem+Presentation.swift create mode 100644 FlixyTV/Views/Detail/TVDetailView.swift create mode 100644 FlixyTV/Views/Home/TVHeroSection.swift create mode 100644 FlixyTV/Views/Home/TVHomeView.swift create mode 100644 FlixyTV/Views/Home/TVMediaRow.swift create mode 100644 FlixyTV/Views/Library/TVLibraryView.swift create mode 100644 Packages/FlixyKit/Sources/FlixyDesign/Components/TVCards.swift create mode 100644 Packages/FlixyKit/Sources/MediaKit/PagingWindow.swift create mode 100644 Packages/FlixyKit/Tests/MediaKitTests/PagingWindowTests.swift diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings index 5a12a4f..6df1172 100644 --- a/Flixy/Resources/Localizable.xcstrings +++ b/Flixy/Resources/Localizable.xcstrings @@ -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": { diff --git a/Flixy/Views/Home/HeroArtwork.swift b/Flixy/Session/HeroArtwork.swift similarity index 93% rename from Flixy/Views/Home/HeroArtwork.swift rename to Flixy/Session/HeroArtwork.swift index 8a29991..524e0de 100644 --- a/Flixy/Views/Home/HeroArtwork.swift +++ b/Flixy/Session/HeroArtwork.swift @@ -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: /// diff --git a/Flixy/Session/HomeFeed.swift b/Flixy/Session/HomeFeed.swift new file mode 100644 index 0000000..68788f8 --- /dev/null +++ b/Flixy/Session/HomeFeed.swift @@ -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() + 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)) + } + } +} diff --git a/Flixy/Session/LibraryFeed.swift b/Flixy/Session/LibraryFeed.swift new file mode 100644 index 0000000..a3a14fb --- /dev/null +++ b/Flixy/Session/LibraryFeed.swift @@ -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 = .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) + } +} diff --git a/Flixy/Session/MediaItem+Presentation.swift b/Flixy/Session/MediaItem+Presentation.swift new file mode 100644 index 0000000..ca41a52 --- /dev/null +++ b/Flixy/Session/MediaItem+Presentation.swift @@ -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)") + } +} diff --git a/Flixy/Session/PlaybackSettings.swift b/Flixy/Session/PlaybackSettings.swift index 82bf671..54489f0 100644 --- a/Flixy/Session/PlaybackSettings.swift +++ b/Flixy/Session/PlaybackSettings.swift @@ -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 diff --git a/Flixy/Views/Home/HeroSection.swift b/Flixy/Views/Home/HeroSection.swift index d4df1f3..07bf5d6 100644 --- a/Flixy/Views/Home/HeroSection.swift +++ b/Flixy/Views/Home/HeroSection.swift @@ -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)") - } -} diff --git a/Flixy/Views/Home/HomeView.swift b/Flixy/Views/Home/HomeView.swift index 5310878..6d3fcdd 100644 --- a/Flixy/Views/Home/HomeView.swift +++ b/Flixy/Views/Home/HomeView.swift @@ -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() - 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() - 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() - } - } } diff --git a/Flixy/Views/Home/MediaRow.swift b/Flixy/Views/Home/MediaRow.swift index 84d31a5..8d484ca 100644 --- a/Flixy/Views/Home/MediaRow.swift +++ b/Flixy/Views/Home/MediaRow.swift @@ -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 - } - } -} diff --git a/FlixyTV/Views/Detail/TVDetailView.swift b/FlixyTV/Views/Detail/TVDetailView.swift new file mode 100644 index 0000000..7006268 --- /dev/null +++ b/FlixyTV/Views/Detail/TVDetailView.swift @@ -0,0 +1,49 @@ +import SwiftUI +import FlixyDesign +import MediaKit + +/// Die Detailseite auf dem Fernseher. +/// +/// Fassung eins zeigt, worum es geht, und lässt es abspielen. Staffeln und +/// Folgen kommen im nächsten Schritt — bis dahin führt eine Serie über +/// `QuickPlay` zur nächsten ungesehenen Folge, genau wie die Kachel. +struct TVDetailView: View { + let route: MediaRoute + + @Environment(FlixySession.self) private var session + @Environment(PlaybackCoordinator.self) private var playback + @Environment(QuickPlay.self) private var quickPlay + + @State private var state: LoadState = .loading + + var body: some View { + Group { + switch state { + case .loading: + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + case .failed(let meldung): + VStack(spacing: FlixySpacing.regular) { + Text(meldung).font(.flixyBody).foregroundStyle(.white) + Button("action.retry") { Task { await lade() } } + } + case .loaded(let item): + TVHeroSection( + item: item, + onPlay: { + quickPlay.start(item, backend: session.backend, coordinator: playback) + }, + onDetails: {} + ) + .ignoresSafeArea() + } + } + .task { await lade() } + } + + private func lade() async { + guard let backend = session.backend else { return } + state = await LoadState.load(keepingPrevious: state) { + try await backend.item(id: route.ratingKey) + } + } +} diff --git a/FlixyTV/Views/Home/TVHeroSection.swift b/FlixyTV/Views/Home/TVHeroSection.swift new file mode 100644 index 0000000..bd90130 --- /dev/null +++ b/FlixyTV/Views/Home/TVHeroSection.swift @@ -0,0 +1,62 @@ +import SwiftUI +import FlixyDesign +import MediaKit + +/// Der Aufmacher oben auf der Startseite des Fernsehers. +/// +/// Derselbe `HeroBanner` wie am Telefon — das Bauteil kennt keine Plattform, +/// es setzt Bild, Verlauf und Text. Anders sind nur die Knöpfe: hier sind es +/// Fokusziele, und sie müssen gross genug sein, dass man aus drei Metern +/// sieht, welcher gerade dran ist. +struct TVHeroSection: View { + let item: MediaItem + let onPlay: () -> Void + let onDetails: () -> Void + + @Environment(FlixySession.self) private var session + @Environment(\.flixyAccent) private var accent + + var body: some View { + HeroBanner( + title: item.rowTitle, + metadata: metadaten, + summary: item.summary, + artworkURLs: session.backend.map { + HeroArtwork.backdropURLs(for: item, backend: $0, compact: false) + } ?? [], + logoURL: session.backend.flatMap { HeroArtwork.logoURL(for: item, backend: $0) }, + aspectRatio: FlixyAspectRatio.hero, + // Der Aufmacher nimmt die Höhe, die das Karussell ihm gibt. + fillsAvailableSpace: true + ) { + Button(action: onPlay) { + Label(abspielTitel, systemImage: "play.fill") + } + .buttonStyle(.borderedProminent) + .tint(accent) + + Button(action: onDetails) { + Label("hero.details", systemImage: "info.circle") + } + .buttonStyle(.bordered) + } + } + + /// „Weiterschauen" statt „Abspielen", wenn schon etwas gesehen wurde. + private var abspielTitel: LocalizedStringKey { + item.watchProgress == nil ? "hero.play" : "hero.resume" + } + + private var metadaten: String? { + var teile: [String] = [] + if item.kind == .episode, let staffel = item.parentIndex, let folge = item.index { + teile.append(String(localized: "episode.short \(staffel) \(folge)")) + } + if let jahr = item.year { teile.append(String(jahr)) } + if let dauer = item.duration { + teile.append(Duration.milliseconds(dauer).runtimeLabel) + } + if let freigabe = item.contentRating { teile.append(freigabe) } + return teile.isEmpty ? nil : teile.joined(separator: " · ") + } +} diff --git a/FlixyTV/Views/Home/TVHomeView.swift b/FlixyTV/Views/Home/TVHomeView.swift new file mode 100644 index 0000000..bb30ddd --- /dev/null +++ b/FlixyTV/Views/Home/TVHomeView.swift @@ -0,0 +1,148 @@ +import SwiftUI +import FlixyDesign +import MediaKit + +/// Die Startseite auf dem Fernseher. +/// +/// **Dasselbe Modell wie am Telefon.** `HomeFeed` lädt, ordnet und lost den +/// Aufmacher aus; hier steht nur, wie das aussieht. Der Unterschied ist die +/// Bedienart, nicht der Inhalt. +struct TVHomeView: View { + @Environment(FlixySession.self) private var session + @Environment(HomeLayoutSettings.self) private var layout + @Environment(PlaybackCoordinator.self) private var playback + @Environment(QuickPlay.self) private var quickPlay + + @State private var feed = HomeFeed() + @State private var pfad = NavigationPath() + /// Welcher Titel gerade oben gross steht. + /// + /// **Der Fokus bestimmt ihn, nicht eine Uhr.** Am Telefon wechselt der + /// Aufmacher alle sieben Sekunden von selbst — dort blättert man ihn auch + /// mit dem Daumen. Vor einem Fernseher wäre ein Bild, das sich von allein + /// austauscht, während man es ansieht, eine Zumutung: man verliert den + /// Titel, den man gerade lesen wollte. + @State private var aufmacher: MediaItem? + + var body: some View { + NavigationStack(path: $pfad) { + inhalt + .navigationDestination(for: MediaRoute.self) { route in + TVDetailView(route: route) + } + .navigationDestination(for: LibraryRoute.self) { route in + TVLibraryView(libraryID: route.id) + } + } + .task(id: session.serverName) { await feed.load(from: session.backend) } + } + + @ViewBuilder + private var inhalt: some View { + switch feed.state { + case .loading: + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + + case .failed(let meldung): + VStack(spacing: FlixySpacing.regular) { + Text(meldung).font(.flixyBody).foregroundStyle(.white) + Button("action.retry") { + Task { await feed.load(from: session.backend) } + } + } + + case .loaded: + let reihen = feed.rows(for: layout) + if reihen.isEmpty && session.libraries.isEmpty { + Text("home.empty.title").font(.flixyRowHeader).foregroundStyle(.white) + } else { + seite(reihen: reihen) + } + } + } + + private func seite(reihen: [MediaRowGroup]) -> some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: FlixySpacing.section) { + if let gezeigt = aufmacher ?? feed.heroItems(for: layout).first { + TVHeroSection( + item: gezeigt, + onPlay: { spiele(gezeigt) }, + onDetails: { zeige(gezeigt) } + ) + .frame(height: 720) + } + + ForEach(reihen) { reihe in + // **Der Fokus setzt den Aufmacher.** Wer durch „Zuletzt + // hinzugefügt" wandert, sieht oben, was er gerade + // anschaut — ohne irgendwo hindrücken zu müssen. + TVMediaRow( + row: reihe, + onSelect: { spiele($0) }, + onFocus: { aufmacher = $0 } + ) + } + + if !session.libraries.isEmpty { + mediatheken + } + } + .padding(.bottom, FlixySpacing.screenMargin) + } + .scrollIndicators(.never) + // **Bildfüllend, und der Rand steckt in `screenMargin`.** Der + // Aufmacher muss bis an die Bildschirmkante reichen; ein Bild, das + // achtzig Punkte davor aufhört, sieht nach Fehler aus. + .ignoresSafeArea() + } + + /// Die Mediatheken als eigene Reihe. + /// + /// Unten und nicht oben: wer den Fernseher einschaltet, will meistens + /// weiterschauen, nicht stöbern. Und als Reihe statt als Registerkarten, + /// weil eine Reihe am Fokus hängt und Registerkarten eine zweite Bedienart + /// wären. + private var mediatheken: some View { + VStack(alignment: .leading, spacing: FlixySpacing.regular) { + Text("library.title") + .font(.flixyRowHeader) + .foregroundStyle(Color.flixyTextPrimary) + .padding(.horizontal, FlixySpacing.screenMargin) + + ScrollView(.horizontal) { + LazyHStack(spacing: FlixySpacing.regular) { + ForEach(session.libraries, id: \.id) { mediathek in + Button { + pfad.append(LibraryRoute(id: mediathek.id)) + } label: { + Text(mediathek.title) + .font(.flixyRowHeader) + .frame(width: 360, height: 180) + } + .buttonStyle(.card) + } + } + .padding(.horizontal, FlixySpacing.screenMargin) + .padding(.vertical, FlixySpacing.regular) + } + .scrollClipDisabled() + .scrollIndicators(.never) + } + } + + // MARK: - Was ein Druck auslöst + + /// **Über QuickPlay, nicht unmittelbar.** Steht in der Reihe eine Serie — + /// und Jellyfins „Zuletzt hinzugefügt" liefert neue Folgen als ihre Serie + /// —, schickte der direkte Weg deren eigene Kennung an den Player, und der + /// scheiterte mit „keine abspielbare Datei". Eine Serie hat keine + /// Fassungen. + private func spiele(_ item: MediaItem) { + quickPlay.start(item, backend: session.backend, coordinator: playback) + } + + private func zeige(_ item: MediaItem) { + pfad.append(MediaRoute(ratingKey: item.id, title: item.title)) + } +} diff --git a/FlixyTV/Views/Home/TVMediaRow.swift b/FlixyTV/Views/Home/TVMediaRow.swift new file mode 100644 index 0000000..b432fef --- /dev/null +++ b/FlixyTV/Views/Home/TVMediaRow.swift @@ -0,0 +1,73 @@ +import SwiftUI +import FlixyDesign +import MediaKit + +/// Eine waagerechte Reihe von Titeln auf dem Fernseher. +/// +/// Dieselben Daten wie `MediaRow` am Telefon, aber eine andere Bedienung: es +/// gibt keinen Finger, der die Reihe schiebt, sondern einen Fokus, der von +/// Karte zu Karte springt und die Reihe hinter sich herzieht. +struct TVMediaRow: View { + let row: MediaRowGroup + let onSelect: (MediaItem) -> Void + /// Wer gerade dran ist — damit der Aufmacher oben mitwandert. + var onFocus: (MediaItem) -> Void = { _ in } + + @Environment(FlixySession.self) private var session + + /// Breit genug, dass die übernächste Karte angeschnitten ist — das zeigt, + /// dass es weitergeht, ohne einen Pfeil dafür zu brauchen. + private var breite: CGFloat { row.isContinueWatching ? 500 : 280 } + + var body: some View { + VStack(alignment: .leading, spacing: FlixySpacing.regular) { + Text(row.title) + .font(.flixyRowHeader) + .foregroundStyle(Color.flixyTextPrimary) + .padding(.horizontal, FlixySpacing.screenMargin) + + ScrollView(.horizontal) { + LazyHStack(spacing: FlixySpacing.regular) { + ForEach(row.items) { item in + karte(for: item).frame(width: breite) + } + } + .padding(.horizontal, FlixySpacing.screenMargin) + // **Platz zum Anheben.** Eine fokussierte Karte wächst über + // ihren Rahmen hinaus, und eine ScrollView beschneidet am + // Rand. Ohne Polster und ohne das abgeschaltete Beschneiden + // fehlt der obersten Reihe die Anhebung — und zwar nur ihr, + // was wie ein Zufall aussieht statt wie ein Fehler. + .padding(.vertical, FlixySpacing.regular) + } + .scrollClipDisabled() + .scrollIndicators(.never) + } + } + + /// Szenenbild beim Weiterschauen, sonst das Plakat. + @ViewBuilder + private func karte(for item: MediaItem) -> some View { + if row.isContinueWatching { + TVLandscapeCard( + title: item.rowTitle, + subtitle: item.cardSubtitle, + artworkURL: session.backend?.artworkURL( + path: item.rowArtworkPath, width: 880, height: 496 + ), + progress: item.watchProgress, + onFocusChange: { if $0 { onFocus(item) } } + ) { onSelect(item) } + } else { + TVPosterCard( + title: item.rowTitle, + subtitle: item.cardSubtitle, + artworkURL: session.backend?.artworkURL( + path: item.thumb, width: 560, height: 840 + ), + progress: item.watchProgress, + onFocusChange: { if $0 { onFocus(item) } } + ) { onSelect(item) } + } + } +} diff --git a/FlixyTV/Views/Library/TVLibraryView.swift b/FlixyTV/Views/Library/TVLibraryView.swift new file mode 100644 index 0000000..654bea8 --- /dev/null +++ b/FlixyTV/Views/Library/TVLibraryView.swift @@ -0,0 +1,121 @@ +import SwiftUI +import FlixyDesign +import MediaKit + +/// Eine Mediathek als Raster auf dem Fernseher. +/// +/// **Nachgeladen wird am Fokus, nicht am Erscheinen.** Am Telefon hängt es +/// daran, dass eine Kachel sichtbar wird — bei sechzig Kacheln auf einem +/// breiten Raster erscheinen die aber alle beim ersten Zeichnen, und das +/// Nachladen liefe sofort los statt beim Blättern. Der Fokus steht dagegen +/// immer nur an einer Stelle, und genau die ist die Frage. +struct TVLibraryView: View { + let libraryID: String + + @Environment(FlixySession.self) private var session + + @State private var feed: LibraryFeed? + @FocusState private var fokus: String? + + /// Sechs Spalten auf 1760 Punkten Nutzbreite: rund 250 Punkte je Plakat, + /// aus drei Metern noch erkennbar. + private static let spalten = 6 + + private var raster: [GridItem] { + Array(repeating: GridItem(.flexible(), spacing: FlixySpacing.section), count: Self.spalten) + } + + var body: some View { + Group { + if let feed { + inhalt(feed: feed) + } else { + ProgressView() + } + } + .task { + guard feed == nil, + let mediathek = session.libraries.first(where: { $0.id == libraryID }) + else { return } + let frisch = LibraryFeed(library: mediathek) + feed = frisch + await frisch.reload(from: session.backend) + } + } + + @ViewBuilder + private func inhalt(feed: LibraryFeed) -> some View { + switch feed.state { + case .loading where feed.items.isEmpty: + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + + case .failed(let meldung) where feed.items.isEmpty: + VStack(spacing: FlixySpacing.regular) { + Text(meldung).font(.flixyBody).foregroundStyle(.white) + Button("action.retry") { Task { await feed.reload(from: session.backend) } } + } + + default: + raster(feed: feed) + } + } + + private func raster(feed: LibraryFeed) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: FlixySpacing.section) { + Text(feed.library.title) + .font(.flixyHero) + .foregroundStyle(.white) + .padding(.horizontal, FlixySpacing.screenMargin) + + LazyVGrid(columns: raster, spacing: FlixySpacing.section) { + ForEach(Array(feed.items.enumerated()), id: \.element.id) { stelle, item in + TVPosterCard( + title: item.rowTitle, + subtitle: item.cardSubtitle, + artworkURL: session.backend?.artworkURL( + path: item.posterArtworkPath, width: 560, height: 840 + ), + progress: item.watchProgress + ) { + // In Fassung eins führt die Karte zur Detailseite. + // Ein Kontextmenü wie am Telefon gibt es hier + // nicht: langes Drücken ist auf der Fernbedienung + // keine verlässliche Geste. + } + .focused($fokus, equals: item.id) + .onChange(of: fokus) { _, neu in + guard neu == item.id, + feed.shouldLoadMore(at: stelle, columns: Self.spalten) + else { return } + Task { await feed.loadMore(from: session.backend) } + } + } + + // **Die Platzhalter bleiben.** Am Fernseher sind sie + // wichtiger als am Telefon: hört das Raster einfach auf, + // läuft der Fokus gegen eine unsichtbare Wand. Sie dürfen + // ihn aber nicht annehmen, sonst landet er auf einem + // grauen Kasten. + ForEach(0.. Void + private let action: () -> Void + + /// **Ausdrücklich und nicht über `\.isFocused`.** Die Umgebungsvariable + /// beantwortet die Frage nach dem nächstgelegenen fokussierbaren + /// Vorfahren; in einer Reihe aus Karten ist das nicht verlässlich diese. + @FocusState private var isFocused: Bool + + public init( + title: String, + subtitle: String? = nil, + artworkURL: URL?, + progress: Double? = nil, + onFocusChange: @escaping (Bool) -> Void = { _ in }, + action: @escaping () -> Void + ) { + self.title = title + self.subtitle = subtitle + self.artworkURL = artworkURL + self.progress = progress + self.onFocusChange = onFocusChange + self.action = action + } + + public var body: some View { + TVCard( + aspectRatio: PosterCard.aspectRatio, + title: title, + subtitle: subtitle, + artworkURL: artworkURL, + progress: progress, + isFocused: $isFocused, + onFocusChange: onFocusChange, + action: action + ) + } +} + +/// Dasselbe im Querformat — für „Weiterschauen". +/// +/// Angefangenes erkennt man an der Szene, an der man aufgehört hat; wer +/// dagegen aussucht, was als Nächstes kommt, sucht nach Plakaten. +public struct TVLandscapeCard: View { + private let title: String + private let subtitle: String? + private let artworkURL: URL? + private let progress: Double? + /// Meldet nach oben, wenn diese Karte den Fokus bekommt oder verliert. + /// + /// **Nach aussen als Rückruf, innen als `@FocusState`.** Die Umgebung + /// könnte es nicht beantworten — `\.isFocused` meint den nächsten + /// fokussierbaren Vorfahren, und das ist in einer Reihe aus Karten nicht + /// verlässlich diese. Wer es wissen will, bekommt es deshalb gesagt. + private let onFocusChange: (Bool) -> Void + private let action: () -> Void + + @FocusState private var isFocused: Bool + + public init( + title: String, + subtitle: String? = nil, + artworkURL: URL?, + progress: Double? = nil, + onFocusChange: @escaping (Bool) -> Void = { _ in }, + action: @escaping () -> Void + ) { + self.title = title + self.subtitle = subtitle + self.artworkURL = artworkURL + self.progress = progress + self.onFocusChange = onFocusChange + self.action = action + } + + public var body: some View { + TVCard( + aspectRatio: FlixyAspectRatio.landscape, + title: title, + subtitle: subtitle, + artworkURL: artworkURL, + progress: progress, + isFocused: $isFocused, + onFocusChange: onFocusChange, + action: action + ) + } +} + +/// Was beide gemeinsam haben. +private struct TVCard: View { + let aspectRatio: CGFloat + let title: String + let subtitle: String? + let artworkURL: URL? + let progress: Double? + @FocusState.Binding var isFocused: Bool + let onFocusChange: (Bool) -> Void + let action: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: FlixySpacing.snug) { + Button(action: action) { + FlixyArtwork(url: artworkURL, aspectRatio: aspectRatio) + .overlay(alignment: .bottom) { + if let progress { WatchProgressBar(fraction: progress) } + } + .clipShape(.rect(cornerRadius: FlixyRadius.card)) + } + .buttonStyle(.card) + .focused($isFocused) + + // **Die Höhe wird immer belegt, der Text nicht immer gezeigt.** + // + // Sonst wächst die Karte beim Fokussieren nach unten und schiebt + // die ganze Reihe — und man sieht nicht den Fokus, sondern ein + // Zucken. Und dauerhaft beschriftet wären zwanzig Plakate aus drei + // Metern eine Textwand. + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.flixyCardTitle) + .foregroundStyle(Color.flixyTextPrimary) + .lineLimit(1) + if let subtitle { + Text(subtitle) + .font(.flixyCaption) + .foregroundStyle(Color.flixyTextMuted) + .lineLimit(1) + } + } + .opacity(isFocused ? 1 : 0) + .animation(.easeOut(duration: 0.15), value: isFocused) + } + .onChange(of: isFocused) { _, neu in onFocusChange(neu) } + .accessibilityElement(children: .combine) + } +} +#endif diff --git a/Packages/FlixyKit/Sources/FlixyDesign/FlixyFont.swift b/Packages/FlixyKit/Sources/FlixyDesign/FlixyFont.swift index f2b1afa..30dea19 100644 --- a/Packages/FlixyKit/Sources/FlixyDesign/FlixyFont.swift +++ b/Packages/FlixyKit/Sources/FlixyDesign/FlixyFont.swift @@ -87,13 +87,20 @@ public enum FlixySpacing { public static let regular: CGFloat = 24 public static let loose: CGFloat = 32 public static let section: CGFloat = 48 - /// Der seitliche Rand des Inhalts — **zusätzlich** zum Sicherheitsrand. + /// Der seitliche Rand des Inhalts — **vom Bildschirmrand aus**. /// - /// **Nicht 90.** Die neunzig Punkte des Überscan-Bereichs liefert das - /// System als Sicherheitsrand; wer sie hier noch einmal aufschlägt, hat - /// hundertachtzig und ein Raster, das in der Bildmitte klebt. Hier steht - /// nur die Rinne innerhalb des sicheren Bereichs. - public static let screenMargin: CGFloat = 48 + /// Achtzig Punkte Überscan plus achtundvierzig Punkte Rinne. + /// + /// **Warum die achtzig hier drinstehen.** SwiftUI legt sie von selbst als + /// Sicherheitsrand an, und normalerweise nähme man sie geschenkt. Der + /// Aufmacher muss aber bis an die Bildschirmkante reichen — ein Bild, das + /// achtzig Punkte vor dem Rand aufhört, sieht auf einem Fernseher nach + /// Fehler aus. Also laufen die Seiten dieser App bildfüllend + /// (`ignoresSafeArea`), und dann muss der Rand hier wieder dazu. + /// + /// Das heisst zugleich: wer diesen Wert benutzt, ignoriert den + /// Sicherheitsrand. Beides gehört zusammen. + public static let screenMargin: CGFloat = 128 #else public static let tight: CGFloat = 4 public static let snug: CGFloat = 8 diff --git a/Packages/FlixyKit/Sources/MediaKit/PagingWindow.swift b/Packages/FlixyKit/Sources/MediaKit/PagingWindow.swift new file mode 100644 index 0000000..28df2e3 --- /dev/null +++ b/Packages/FlixyKit/Sources/MediaKit/PagingWindow.swift @@ -0,0 +1,37 @@ +import Foundation + +/// Wann nachgeladen wird. +/// +/// **Nicht „die letzten fünf".** So stand es bisher, und zwei Dinge daran +/// gehen schief. Erstens ist das auf einem Raster aus sechs Spalten nicht +/// einmal eine Zeile: wer dort ankommt, wartet trotzdem. Zweitens — und das +/// ist der eigentliche Fehler — hing es am **Erscheinen** der Kachel. Beim +/// ersten Zeichnen erscheinen alle sechzig gleichzeitig, auch die letzten +/// fünf; das Nachladen lief also sofort los statt beim Scrollen. +/// +/// Gemessen wird deshalb in **Zeilen**, und der Auslöser ist auf dem +/// Fernseher der Fokus statt des Erscheinens. Auf einem breiten Raster ist +/// das nicht Feinschliff: dort sind sechs Kacheln eine Zeile, und mit der +/// alten Regel wäre schon die erste Bildschirmseite „fast am Ende". +public struct PagingWindow: Sendable, Equatable { + /// Wie viele Kacheln nebeneinander stehen. + public let columns: Int + /// Wie viele Zeilen Vorlauf gewünscht sind. + public let rowsAhead: Int + + public init(columns: Int, rowsAhead: Int = 2) { + self.columns = columns + self.rowsAhead = rowsAhead + } + + /// - Parameter focusedIndex: Wo der Fokus gerade steht. + /// - Parameter loaded: Wie viele Einträge schon dastehen. + /// - Parameter total: Wie viele der Server insgesamt meldet. + public func shouldLoad(focusedIndex: Int, loaded: Int, total: Int) -> Bool { + // `columns <= 0` ist keine sinnvolle Frage, aber eine mögliche: ein + // Raster kennt seine Spaltenzahl erst nach dem ersten Zeichnen. Null + // zurückzugeben ist richtiger, als durch null zu teilen. + guard columns > 0, loaded > 0, loaded < total else { return false } + return focusedIndex >= loaded - columns * rowsAhead + } +} diff --git a/Packages/FlixyKit/Tests/MediaKitTests/PagingWindowTests.swift b/Packages/FlixyKit/Tests/MediaKitTests/PagingWindowTests.swift new file mode 100644 index 0000000..b8e43ec --- /dev/null +++ b/Packages/FlixyKit/Tests/MediaKitTests/PagingWindowTests.swift @@ -0,0 +1,49 @@ +import Testing +@testable import MediaKit + +@Suite("Wann nachgeladen wird") +struct PagingWindowTests { + private let fenster = PagingWindow(columns: 6, rowsAhead: 2) + + /// **Der Fall, der die alte Regel entlarvt.** Wer ganz oben steht, ist + /// nicht am Ende — auch wenn die letzten fünf Kacheln längst gezeichnet + /// sind. Genau daran lag es, dass das Nachladen sofort losging. + @Test("Am Anfang wird nicht nachgeladen") + func notAtTheStart() { + #expect(!fenster.shouldLoad(focusedIndex: 0, loaded: 60, total: 300)) + } + + /// Zwei Zeilen vor Schluss bei sechs Spalten: ab Eintrag 48 von 60. + @Test("Zwei Zeilen vor Schluss wird nachgeladen") + func loadsTwoRowsEarly() { + #expect(!fenster.shouldLoad(focusedIndex: 47, loaded: 60, total: 300)) + #expect(fenster.shouldLoad(focusedIndex: 48, loaded: 60, total: 300)) + } + + /// Ein breiteres Raster braucht mehr Vorlauf — das ist der ganze Grund, + /// in Zeilen zu rechnen statt in Einträgen. + @Test("Mehr Spalten heißt früher nachladen") + func widerGridLoadsEarlier() { + let schmal = PagingWindow(columns: 3) + let breit = PagingWindow(columns: 8) + #expect(!schmal.shouldLoad(focusedIndex: 50, loaded: 60, total: 300)) + #expect(breit.shouldLoad(focusedIndex: 50, loaded: 60, total: 300)) + } + + @Test("Ist alles da, wird nicht mehr gefragt") + func stopsWhenComplete() { + #expect(!fenster.shouldLoad(focusedIndex: 59, loaded: 60, total: 60)) + } + + /// Ein Raster kennt seine Spaltenzahl erst nach dem ersten Zeichnen. + /// Falsch zu antworten ist besser als durch null zu teilen. + @Test("Ohne Spalten wird nichts entschieden") + func survivesZeroColumns() { + #expect(!PagingWindow(columns: 0).shouldLoad(focusedIndex: 10, loaded: 60, total: 300)) + } + + @Test("Eine leere Mediathek löst nichts aus") + func emptyLibrary() { + #expect(!fenster.shouldLoad(focusedIndex: 0, loaded: 0, total: 0)) + } +}