diff --git a/Flaro/Resources/Localizable.xcstrings b/Flaro/Resources/Localizable.xcstrings index 3316577..48ad665 100644 --- a/Flaro/Resources/Localizable.xcstrings +++ b/Flaro/Resources/Localizable.xcstrings @@ -1241,6 +1241,22 @@ } } }, + "downloads.episodeCount %lld" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Folgen geladen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld episodes downloaded" + } + } + } + }, "downloads.failed" : { "extractionState" : "stale", "localizations" : { @@ -1887,6 +1903,7 @@ } }, "filter.downloadedOnly" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -2146,22 +2163,6 @@ } } }, - "home.offline" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kein Server erreichbar. Was hier steht, stammt vom letzten Mal — ansehen lässt sich nur, was auf dem Gerät liegt." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "No server reachable. What you see is from last time — only what is on this device can be played." - } - } - } - }, "home.downloaded" : { "localizations" : { "de" : { @@ -2395,6 +2396,22 @@ } } }, + "home.offline" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kein Server erreichbar. Was hier steht, stammt vom letzten Mal — ansehen lässt sich nur, was auf dem Gerät liegt." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No server reachable. What you see is from last time — only what is on this device can be played." + } + } + } + }, "home.rowsHeader" : { "localizations" : { "de" : { diff --git a/Flaro/Session/DownloadService.swift b/Flaro/Session/DownloadService.swift index f09b745..94a9a5e 100644 --- a/Flaro/Session/DownloadService.swift +++ b/Flaro/Session/DownloadService.swift @@ -213,8 +213,22 @@ struct DownloadService { /// Nebenher und ohne Fehlerweg: scheitert es, fehlt ein Bild. Den /// Download deshalb scheitern zu lassen wäre die schlechtere Hälfte. func plakatHolen(for item: MediaItem) { - guard let adresse = backend?.rawImageURL(path: item.thumb) else { return } - Task { await coordinator.fetchArtwork(for: item.id, from: adresse) } + if let adresse = backend?.rawImageURL(path: item.thumb) { + Task { await coordinator.fetchArtwork(for: item.id, from: adresse) } + } + + // **Und das Plakat der Serie, unter deren Schlüssel.** + // + // `thumb` ist bei einer Folge ihr Szenenbild — quer, und hochkant + // beschnitten zeigt es einen zufälligen Ausschnitt. Die Downloads + // stehen aber als Serie in der Reihe, und eine Serienkachel ist ein + // Plakat. Ohne diesen zweiten Griff gäbe es für die Serie gar kein + // Bild: heruntergeladen wird sie nie, nur ihre Folgen. + if let serie = item.seriesID, + let pfad = item.seriesThumb ?? item.parentThumb, + let adresse = backend?.rawImageURL(path: pfad) { + Task { await coordinator.fetchArtwork(for: serie, from: adresse) } + } } /// Holt Plakate nach, die früheren Downloads fehlen. @@ -228,12 +242,21 @@ struct DownloadService { /// findet die Schleife nichts mehr. func plakateNachholen() async { guard let backend else { return } - for eintrag in coordinator.items where eintrag.artworkFileName == nil { + for eintrag in coordinator.items where fehltEinBild(eintrag) { guard let titel = try? await backend.item(id: eintrag.ratingKey) else { continue } plakatHolen(for: titel) } } + /// Ob zu diesem Eintrag noch ein Bild fehlt — seines oder das seiner + /// Serie. + private func fehltEinBild(_ eintrag: DownloadItem) -> Bool { + if coordinator.artworkURL(forRatingKey: eintrag.ratingKey) == nil { return true } + if let serie = eintrag.seriesKey, + coordinator.artworkURL(forRatingKey: serie) == nil { return true } + return false + } + /// Ob der Server beim Herunterladen alle Spuren mitgibt. /// /// **Der Unterschied, um dessentwillen Flaro gebaut wurde.** Flaros diff --git a/Flaro/Session/Heruntergeladenes.swift b/Flaro/Session/Heruntergeladenes.swift index 12a1ee0..fce1758 100644 --- a/Flaro/Session/Heruntergeladenes.swift +++ b/Flaro/Session/Heruntergeladenes.swift @@ -26,6 +26,78 @@ enum Heruntergeladenes { .sorted { ($0.completedAt ?? $0.addedAt) > ($1.completedAt ?? $1.addedAt) } } + /// Was auf dem Gerät liegt — Filme einzeln, Serien als **eine** Kachel. + /// + /// **Zwanzig Folgen sind keine zwanzig Titel.** Vorher stand jede + /// einzeln in der Reihe, und eine heruntergeladene Staffel schob alles + /// andere aus dem Bild. Was man im Kopf hat, ist die Serie; welche + /// Folgen davon da sind, ist die Frage danach — und die beantwortet die + /// Seite der Serie. + static func reihe(in coordinator: DownloadCoordinator) -> [MediaItem] { + var ergebnis: [MediaItem] = [] + var gesehen = Set() + + for eintrag in fertige(in: coordinator) { + guard let serie = eintrag.seriesKey else { + ergebnis.append(titel(eintrag, in: coordinator)) + continue + } + // Die Reihenfolge der Folgen ist die der Serie: die zuletzt + // fertige Folge bestimmt, wo die Serie steht. + guard gesehen.insert(serie).inserted else { continue } + ergebnis.append(self.serie(serie, in: coordinator)) + } + return ergebnis + } + + /// Eine Serie, von der Folgen auf dem Gerät liegen. + static func serie(_ schluessel: String, in coordinator: DownloadCoordinator) -> MediaItem { + let folgen = folgenEintraege(derSerie: schluessel, in: coordinator) + return MediaItem( + id: schluessel, + kind: .show, + title: folgen.first?.seriesTitle ?? folgen.first?.title ?? "", + // **Erst das Plakat der Serie, dann irgendein Folgenbild.** Ein + // Szenenbild hochkant ist ein zufälliger Ausschnitt — aber immer + // noch besser als eine graue Fläche. + thumb: coordinator.artworkURL(forRatingKey: schluessel)?.absoluteString + ?? folgen.lazy + .compactMap { coordinator.artworkURL(forRatingKey: $0.ratingKey)?.absoluteString } + .first + ) + } + + /// Wie viele Folgen dieser Serie hier liegen — als Zeile unter der + /// Kachel. + /// + /// `nil` für alles, was keine Serie mit Downloads ist: dann gilt, was + /// sonst unter einer Kachel steht. + static func untertitel(für titel: MediaItem, in coordinator: DownloadCoordinator) -> String? { + guard titel.kind == .show else { return nil } + let anzahl = folgenEintraege(derSerie: titel.id, in: coordinator).count + guard anzahl > 0 else { return nil } + return String(localized: "downloads.episodeCount \(anzahl)") + } + + /// Die heruntergeladenen Folgen einer Serie, aufsteigend sortiert. + /// + /// Für die Seite der Serie, wenn kein Server antwortet: dann ist das + /// hier die ganze Liste, die es gibt. + static func folgen(derSerie schluessel: String, in coordinator: DownloadCoordinator) -> [MediaItem] { + folgenEintraege(derSerie: schluessel, in: coordinator) + .sorted { + ($0.seasonIndex ?? 0, $0.episodeIndex ?? 0) < ($1.seasonIndex ?? 0, $1.episodeIndex ?? 0) + } + .map { titel($0, in: coordinator) } + } + + private static func folgenEintraege( + derSerie schluessel: String, + in coordinator: DownloadCoordinator + ) -> [DownloadItem] { + fertige(in: coordinator).filter { $0.seriesKey == schluessel } + } + /// Ein Download als Titel. /// /// **Das Bild kommt von der Platte, nicht vom Server.** `thumb` ist sonst diff --git a/Flaro/Views/Detail/MediaDetailView.swift b/Flaro/Views/Detail/MediaDetailView.swift index 17a6ce3..5611fdf 100644 --- a/Flaro/Views/Detail/MediaDetailView.swift +++ b/Flaro/Views/Detail/MediaDetailView.swift @@ -76,9 +76,20 @@ struct MediaDetailView: View { } /// Was über diesen Titel auf dem Gerät liegt. + /// + /// **Auch eine Serie**, obwohl keine Serie je heruntergeladen wird: in + /// der Downloadreihe steht sie als eine Kachel, und ein Tipp darauf + /// führt hierher. Ohne diesen Zweig endete er ohne Verbindung im + /// Nichts. private var vomGeraet: MediaItem? { - downloads.items - .first { $0.isComplete && $0.ratingKey == route.ratingKey } - .map { Heruntergeladenes.titel($0, in: downloads) } + if let eintrag = downloads.items.first( + where: { $0.isComplete && $0.ratingKey == route.ratingKey } + ) { + return Heruntergeladenes.titel(eintrag, in: downloads) + } + guard downloads.items.contains( + where: { $0.isComplete && $0.seriesKey == route.ratingKey } + ) else { return nil } + return Heruntergeladenes.serie(route.ratingKey, in: downloads) } } diff --git a/Flaro/Views/Detail/SeriesDetailView.swift b/Flaro/Views/Detail/SeriesDetailView.swift index 0ca1e7f..fc381e2 100644 --- a/Flaro/Views/Detail/SeriesDetailView.swift +++ b/Flaro/Views/Detail/SeriesDetailView.swift @@ -470,11 +470,31 @@ struct SeriesDetailView: View { return pieces.isEmpty ? nil : pieces.joined(separator: " · ") } + /// Holt die Staffeln oder Folgen — und sonst, was auf dem Gerät liegt. + /// + /// **Sonst wäre die Seite einer heruntergeladenen Serie leer.** Sie ist + /// das Ziel, auf das die Downloadreihe zeigt; dort anzukommen und einen + /// Fehler zu lesen, während die Folgen daneben auf der Platte liegen, + /// wäre die Umkehrung dessen, wofür man sie geladen hat. private func loadChildren() async { - guard let server = session.backend else { return } + guard let server = session.backend else { + children = vomGeraet.isEmpty + ? .failed(message: String(localized: "error.serverUnreachable")) + : .loaded(vomGeraet) + return + } children = await LoadState.load { try await server.children(of: item.id) } + if case .failed = children, !vomGeraet.isEmpty { + children = .loaded(vomGeraet) + } + } + + /// Die Folgen dieser Serie, die auf dem Gerät liegen. + private var vomGeraet: [MediaItem] { + guard item.kind == .show else { return [] } + return Heruntergeladenes.folgen(derSerie: item.id, in: downloads) } } diff --git a/Flaro/Views/Home/HomeView.swift b/Flaro/Views/Home/HomeView.swift index e0d702a..79dd6cd 100644 --- a/Flaro/Views/Home/HomeView.swift +++ b/Flaro/Views/Home/HomeView.swift @@ -22,9 +22,7 @@ struct HomeView: View { /// `DownloadCoordinator` beobachtet wird — eine fertige Folge steht /// ohne Neuladen der Startseite da. private var heruntergeladen: [MediaItem] { - Heruntergeladenes.fertige(in: downloads).map { - Heruntergeladenes.titel($0, in: downloads) - } + Heruntergeladenes.reihe(in: downloads) } var body: some View { diff --git a/Flaro/Views/Home/MediaRow.swift b/Flaro/Views/Home/MediaRow.swift index bb7b436..e5672ad 100644 --- a/Flaro/Views/Home/MediaRow.swift +++ b/Flaro/Views/Home/MediaRow.swift @@ -1,5 +1,6 @@ import SwiftUI import FlaroDesign +import FlaroDownloads import MediaKit /// Eine waagerechte Reihe von Titeln. @@ -14,6 +15,7 @@ struct MediaRow: View { @Environment(FlaroSession.self) private var session @Environment(HomeLayoutSettings.self) private var layout + @Environment(DownloadCoordinator.self) private var downloads /// Breit genug, dass die übernächste Kachel angeschnitten ist — das zeigt, /// dass es weitergeht, ohne einen Pfeil dafür zu brauchen. @@ -58,7 +60,7 @@ struct MediaRow: View { if row.isContinueWatching { LandscapeCard( title: item.rowTitle, - subtitle: item.cardSubtitle, + subtitle: untertitel(for: item), artworkURL: Heruntergeladenes.bildAdresse(item) ?? session.backend?.artworkURL( path: item.rowArtworkPath, width: 440, @@ -70,7 +72,7 @@ struct MediaRow: View { } else { PosterCard( title: item.rowTitle, - subtitle: item.cardSubtitle, + subtitle: untertitel(for: item), artworkURL: Heruntergeladenes.bildAdresse(item) ?? session.backend?.artworkURL( path: item.posterArtworkPath, width: 300, @@ -81,4 +83,18 @@ struct MediaRow: View { .frame(width: posterWidth) } } + + /// Was unter der Kachel steht. + /// + /// **In der Downloadreihe zählt, wie viel da ist.** Eine Serie steht + /// dort als eine Kachel; „2019" darunter beantwortet nicht die Frage, + /// die man an dieser Stelle hat — nämlich wie viele Folgen man dabei + /// hat. + private func untertitel(for item: MediaItem) -> String? { + if row.id == HomeFeed.downloadsRow, + let zahl = Heruntergeladenes.untertitel(für: item, in: downloads) { + return zahl + } + return item.cardSubtitle + } } diff --git a/Flaro/Views/Library/LibraryView.swift b/Flaro/Views/Library/LibraryView.swift index 2c4ea88..8c600d4 100644 --- a/Flaro/Views/Library/LibraryView.swift +++ b/Flaro/Views/Library/LibraryView.swift @@ -124,7 +124,10 @@ struct LibraryView: View { QuickPlayTile(item: item) { PosterCard( title: item.title, - subtitle: item.cardSubtitle, + subtitle: nurHeruntergeladene + ? Heruntergeladenes.untertitel(für: item, in: downloads) + ?? item.cardSubtitle + : item.cardSubtitle, artworkURL: Heruntergeladenes.bildAdresse(item) ?? session.backend?.artworkURL(path: item.thumb, width: 300, height: 450), progress: item.resumeProgress @@ -360,15 +363,13 @@ struct LibraryView: View { /// shortcut: zwei Filmmediatheken bekämen beide dieselbe Liste — erst /// wenn `DownloadItem` die Mediathek mitführt, lässt sich das trennen. private var geraeteliste: [MediaItem] { - Heruntergeladenes.fertige(in: downloads) - .filter { eintrag in - switch library.kind { - case .movie: eintrag.seriesKey == nil - case .show: eintrag.seriesKey != nil - default: true - } + Heruntergeladenes.reihe(in: downloads).filter { titel in + switch library.kind { + case .movie: titel.kind != .show + case .show: titel.kind == .show + default: true } - .map { Heruntergeladenes.titel($0, in: downloads) } + } } /// Lädt nach, sobald die Ansicht sich dem Ende der Liste nähert. diff --git a/Flaro/Views/Player/PlayerView.swift b/Flaro/Views/Player/PlayerView.swift index 5cb4e36..e072bd0 100644 --- a/Flaro/Views/Player/PlayerView.swift +++ b/Flaro/Views/Player/PlayerView.swift @@ -117,7 +117,18 @@ struct PlayerView: View { // Anspruch gerade, dass nichts fehlt — dann darf auch // nichts verdeckt sein. Plex macht es genauso. Color.clear - .modifier(Randlos(an: model.videoFit == .fill)) + // + // **Ein Modifikator mit leeren Kanten, kein `if`.** + // Hier stand ein eigener `ViewModifier`, der + // zwischen `ignoresSafeArea()` und gar nichts + // umschaltete — und genau das ist in SwiftUI ein + // Zweig: beim Umschalten wurde der Baum darunter + // neu aufgebaut, die Bildebene mit ihm, und der + // Spieler stand ohne sie da. Schwarz, sobald + // jemand auf „Bildfüllend" wechselte. `edges: []` + // ignoriert nichts und ist doch derselbe + // Modifikator — die Ansicht bleibt dieselbe. + .ignoresSafeArea(.container, edges: model.videoFit == .fill ? .all : []) .overlay { surface(for: model) .contentShape(.rect) @@ -1059,16 +1070,3 @@ extension Duration { return String(format: "%d:%02d", minutes, seconds) } } - -/// Lässt eine Fläche über die Sicherheitsränder hinausreichen — oder nicht. -/// -/// **Ein eigener Modifikator, weil `ignoresSafeArea` keinen Schalter hat.** -/// Ein `if` im Baum wäre zwei verschiedene Ansichten, und SwiftUI baut die -/// beim Umschalten neu auf — die Wiedergabe begänne von vorn. -private struct Randlos: ViewModifier { - let an: Bool - - func body(content: Content) -> some View { - if an { content.ignoresSafeArea() } else { content } - } -} diff --git a/Packages/FlaroKit/Sources/FlaroDesign/Components/ArtworkLoader.swift b/Packages/FlaroKit/Sources/FlaroDesign/Components/ArtworkLoader.swift index ae6c0ea..b7f6ba2 100644 --- a/Packages/FlaroKit/Sources/FlaroDesign/Components/ArtworkLoader.swift +++ b/Packages/FlaroKit/Sources/FlaroDesign/Components/ArtworkLoader.swift @@ -101,6 +101,19 @@ public actor ArtworkLoader { using session: URLSession, attempts: Int = 3 ) async -> PlatformImage? { + // **Eine Datei auf der Platte ist kein Server.** + // + // Hier stand nur der Weg über HTTP, und die Antwort einer + // `file:`-Adresse ist keine `HTTPURLResponse` — die Prüfung darunter + // gab also `nil` zurück, immer. Sichtbar wurde das erst, als die + // Downloads ihre Plakate mitbrachten: sie lagen auf der Platte, und + // die Kacheln blieben trotzdem grau. Kein Wiederholen, kein + // Zeitablauf: eine Datei ist entweder da oder nicht. + if url.isFileURL { + guard let data = try? Data(contentsOf: url) else { return nil } + return PlatformImage(data: data) + } + for attempt in 1...attempts { if Task.isCancelled { return nil } diff --git a/Packages/FlaroKit/Sources/FlaroDownloads/Engine/DownloadCoordinator.swift b/Packages/FlaroKit/Sources/FlaroDownloads/Engine/DownloadCoordinator.swift index bfd5256..4e697f3 100644 --- a/Packages/FlaroKit/Sources/FlaroDownloads/Engine/DownloadCoordinator.swift +++ b/Packages/FlaroKit/Sources/FlaroDownloads/Engine/DownloadCoordinator.swift @@ -395,9 +395,16 @@ public final class DownloadCoordinator { forgetTransfer(item.ratingKey) fileStore.remove(fileName: item.localFileName) fileStore.remove(fileName: item.artworkFileName) + let serie = item.seriesKey context.delete(item) save() reloadItems() + + // **Das Serienplakat geht mit der letzten Folge.** Es hängt an + // keinem Eintrag — ohne diese Zeile bliebe es für immer liegen. + if let serie, !items.contains(where: { $0.seriesKey == serie }) { + fileStore.remove(fileName: fileStore.artworkFileName(forRatingKey: serie)) + } refreshUsage() } @@ -427,10 +434,15 @@ public final class DownloadCoordinator { /// **Weil eine Reihe ohne Bilder keine Reihe ist.** Ohne Verbindung gibt /// es keine Adresse, von der ein Plakat nachzuladen wäre; was nicht beim /// Herunterladen mitkam, bleibt eine graue Fläche. + /// **Nach der Datei gefragt, nicht nach dem Eintrag.** Hier wurde erst + /// der Download gesucht und dann sein Dateiname gelesen — damit gab es + /// nur Bilder zu Dingen, die selbst heruntergeladen wurden. Das Plakat + /// einer **Serie** gehört aber keiner einzelnen Folge; es liegt unter + /// dem Serienschlüssel, zu dem es keinen Eintrag gibt und nie geben + /// wird. public func artworkURL(forRatingKey key: String) -> URL? { - guard let name = items.first(where: { $0.ratingKey == key })?.artworkFileName, - fileStore.exists(fileName: name) - else { return nil } + let name = fileStore.artworkFileName(forRatingKey: key) + guard fileStore.exists(fileName: name) else { return nil } return fileStore.url(forFileName: name) } @@ -439,19 +451,32 @@ public final class DownloadCoordinator { /// **Beim Anlegen, nicht erst beim Fertigwerden.** Die Liste zeigt den /// Eintrag, sobald er in der Warteschlange steht; bis dahin wäre sonst /// nichts zu sehen. Es sind ein paar Kilobyte. + /// Der Schlüssel muss zu keinem Download gehören: unter dem Schlüssel + /// einer Serie liegt ihr Plakat, obwohl die Serie selbst nie + /// heruntergeladen wird. public func fetchArtwork(for ratingKey: String, from url: URL) async { - guard let item = items.first(where: { $0.ratingKey == ratingKey }), - item.artworkFileName == nil - else { return } + let name = fileStore.artworkFileName(forRatingKey: ratingKey) + guard !fileStore.exists(fileName: name) else { + vermerken(name, bei: ratingKey) + return + } guard let (daten, antwort) = try? await URLSession.shared.data(from: url), (antwort as? HTTPURLResponse).map({ (200..<300).contains($0.statusCode) }) ?? true, !daten.isEmpty else { return } - let name = fileStore.artworkFileName(forRatingKey: ratingKey) guard fileStore.store(daten, as: name) != nil else { return } + vermerken(name, bei: ratingKey) + } + /// Hält am Eintrag fest, dass es ein Bild gibt — damit es beim Entfernen + /// mitgeht. Zum Serienschlüssel gibt es keinen Eintrag; dann bleibt die + /// Datei stehen, bis die letzte Folge der Serie geht. + private func vermerken(_ name: String, bei ratingKey: String) { + guard let item = items.first(where: { $0.ratingKey == ratingKey }), + item.artworkFileName != name + else { return } item.artworkFileName = name save() } diff --git a/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs b/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs index 88e6d03..c103490 100644 --- a/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs @@ -363,15 +363,35 @@ public static class PlaybackEndpoints { if (serienzustaende.GetValueOrDefault(x.SeriesId) is { Dismissed: true }) continue; - var naechste = folgen + var derSerie = folgen .Where(f => f.SeriesId == x.SeriesId) .OrderBy(f => f.SeasonNumber).ThenBy(f => f.EpisodeNumber) + .ToList(); + + // **Die Reihe fängt dort an, wo der Benutzer steht — nicht + // am Anfang der Serie.** + // + // Hier stand „die erste Folge ohne Häkchen". Wer bei Folge 2 + // mittendrin ist und Folge 1 nie abgehakt hat — weil er sie + // nicht ganz zu Ende gesehen hat, oder weil er gleich bei + // Folge 2 eingestiegen ist —, bekam Folge 1 vorgeschlagen. + // Und das Abhaken von Folge 1 half nicht: dann kam Folge 2, + // obwohl die längst in „Weiterschauen" steht. + // + // Massgeblich ist die **letzte** angefasste Folge. Ist sie + // noch offen, läuft sie — dann gehört die Serie nach + // „Weiterschauen" und nicht hierher. Ist sie durch, ist die + // Folge danach dran. + var letzte = derSerie.FindLastIndex( + f => Angefasst(zustände.GetValueOrDefault(f.Id))); + if (letzte < 0) continue; + if (zustände.GetValueOrDefault(derSerie[letzte].Id) is not { Watched: true }) continue; + + var naechste = derSerie.Skip(letzte + 1) .FirstOrDefault(f => zustände.GetValueOrDefault(f.Id) is not { Watched: true }); // Nichts offen — die Serie ist durch. if (naechste is null) continue; - // Mittendrin: steht bereits in „Weiterschauen". - if (zustände.GetValueOrDefault(naechste.Id) is { InProgress: true }) continue; if (!serien.TryGetValue(x.SeriesId, out var serie)) continue; var zustand = zustände.GetValueOrDefault(naechste.Id); @@ -564,6 +584,18 @@ public static class PlaybackEndpoints /// Folge" zeigt überwiegend Folgen ohne Stand, und ein Client, der für /// jede davon einen Sonderfall braucht, vergisst ihn irgendwo. /// + /// Ob der Benutzer diese Folge überhaupt schon angefasst hat. + /// + /// + /// Eine Zeile allein genügt nicht: sie entsteht auch, wenn jemand nur + /// einen Stern vergibt. Angefasst heisst gesehen, angefangen oder + /// ausdrücklich weggelegt. + /// + private static bool Angefasst(UserItemState? zustand) => + zustand is not null + && (zustand.Watched || zustand.InProgress || zustand.Dismissed + || zustand.Position > TimeSpan.Zero); + private static ItemStateResponse Leer() => new(0, null, false, 0, false, null, false, null); /// Macht aus Zuständen Einträge mit Titel und Bild. diff --git a/server/tests/Flaro.Api.Tests/PlaybackTests.cs b/server/tests/Flaro.Api.Tests/PlaybackTests.cs index 1590a47..6f27d86 100644 --- a/server/tests/Flaro.Api.Tests/PlaybackTests.cs +++ b/server/tests/Flaro.Api.Tests/PlaybackTests.cs @@ -764,6 +764,92 @@ public class PlaybackTests }); } + /// Wer bei Folge 2 steht, bekommt nicht Folge 1 angeboten. + /// + /// + /// Der Fall aus dem Gebrauch. Folge 1 halb gesehen und liegen + /// gelassen, dann bei Folge 2 weitergemacht — und „Als Nächstes" schlug + /// Folge 1 vor, weil sie die erste ohne Häkchen war. Die Reihe sagt aber + /// nicht, was man übersprungen hat, sondern was als Nächstes dran ist. + /// + [Test] + public async Task Wer_bei_Folge_zwei_steht_bekommt_nicht_Folge_eins_vorgeschlagen() + { + Datei("Dark/Season 01/Dark - S01E01 - Geheimnisse.mkv"); + Datei("Dark/Season 01/Dark - S01E02 - Luegen.mkv"); + Datei("Dark/Season 01/Dark - S01E03 - Gestern.mkv"); + + using var server = new FlaroServer(); + var client = await Eingelesen(server, "Shows"); + + var serien = await client.GetFromJsonAsync>("/api/v1/series"); + var folgen = await client.GetFromJsonAsync>( + $"/api/v1/series/{serien!.Items[0].Id}/episodes"); + + // Folge 1 angefangen und liegen gelassen, Folge 2 läuft gerade. + await Melden(client, folgen!.Items[0].Id, 300, 2700); + await Melden(client, folgen.Items[1].Id, 900, 2700); + + var naechste = await client.GetFromJsonAsync>("/api/v1/home/next"); + Assert.That(naechste!.Items, Is.Empty, + "Folge 2 läuft und steht in Weiterschauen, also hat Als Naechstes nichts anzubieten"); + } + + /// Und abhaken holt die Serie nicht zurück. + /// + /// + /// Wer Folge 1 nachträglich abhakt, hat damit nicht gesagt, dass er + /// Folge 2 noch einmal anfangen will — die läuft ja. + /// + [Test] + public async Task Folge_eins_abhaken_holt_die_Serie_nicht_zurueck() + { + Datei("Dark/Season 01/Dark - S01E01 - Geheimnisse.mkv"); + Datei("Dark/Season 01/Dark - S01E02 - Luegen.mkv"); + + using var server = new FlaroServer(); + var client = await Eingelesen(server, "Shows"); + + var serien = await client.GetFromJsonAsync>("/api/v1/series"); + var folgen = await client.GetFromJsonAsync>( + $"/api/v1/series/{serien!.Items[0].Id}/episodes"); + + await Melden(client, folgen!.Items[1].Id, 900, 2700); + await client.PutAsJsonAsync($"/api/v1/items/{folgen.Items[0].Id}/watched", + new WatchedRequest(true)); + + var naechste = await client.GetFromJsonAsync>("/api/v1/home/next"); + Assert.That(naechste!.Items, Is.Empty); + } + + /// Nach der letzten angefassten Folge geht es weiter. + /// + /// + /// Folge 1 und 2 durch, Folge 3 nie angefasst: dann ist Folge 3 dran — + /// und nicht Folge 1, nur weil dort irgendwann einmal ein Häkchen + /// fehlte. + /// + [Test] + public async Task Nach_der_letzten_gesehenen_Folge_kommt_die_naechste() + { + Datei("Dark/Season 01/Dark - S01E01 - Geheimnisse.mkv"); + Datei("Dark/Season 01/Dark - S01E02 - Luegen.mkv"); + Datei("Dark/Season 01/Dark - S01E03 - Gestern.mkv"); + + using var server = new FlaroServer(); + var client = await Eingelesen(server, "Shows"); + + var serien = await client.GetFromJsonAsync>("/api/v1/series"); + var folgen = await client.GetFromJsonAsync>( + $"/api/v1/series/{serien!.Items[0].Id}/episodes"); + + await client.PutAsJsonAsync($"/api/v1/items/{folgen!.Items[1].Id}/watched", + new WatchedRequest(true)); + + var naechste = await client.GetFromJsonAsync>("/api/v1/home/next"); + Assert.That(naechste!.Items.Single().Episode, Is.EqualTo(3)); + } + /// Was in „Weiterschauen" steht, steht nicht nochmal daneben. [Test] public async Task Eine_angefangene_Folge_erscheint_nur_in_einer_Reihe() diff --git a/web/app/src/card.ts b/web/app/src/card.ts index 8390799..df4aa0a 100644 --- a/web/app/src/card.ts +++ b/web/app/src/card.ts @@ -296,8 +296,21 @@ function kachelpunkte( // Serie zieht das Häkchen alle Folgen mit; was dabei herauskommt, // weiss nur der Server. k.stand = await gesehen(k.standId ?? k.id, !istGesehen); - // **Die Kachel spiegelt den neuen Zustand selbst.** Die Liste neu zu - // laden verlöre Bildlauf und Filter — für einen Haken. + + // **In „Als Nächstes" ist ein Haken ein Abschied.** + // + // Die Reihe zeigt, was dran ist. Eine abgehakte Folge ist nicht mehr + // dran — sie mit einem Haken stehen zu lassen sah aus, als hätte das + // Abhaken nicht gewirkt. Gemessen: der Server lieferte beim nächsten + // Laden längst die richtige Folge, nur sah man das nie, weil die + // Reihe nicht neu geladen wurde. + if (k.reihe === 'naechste' && !istGesehen) { + knopf.remove(); + return; + } + + // **Sonst spiegelt die Kachel den neuen Zustand selbst.** Die Liste + // neu zu laden verlöre Bildlauf und Filter — für einen Haken. k.offen = istGesehen ? (k.gesamt ?? k.offen) : 0; hakenZeigen(knopf, k, !istGesehen); },