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