Ein Download sah aus wie eine Videodatei, nicht wie ein Film
Vier Dinge, die beim Ausprobieren auffielen. **Die Plakate blieben grau.** Sie lagen auf der Platte, der Lader wollte sie aber nicht: er verlangte von jeder Antwort eine HTTPURLResponse, und eine Datei antwortet nicht so. Jede lokale Adresse kam als `nil` zurück. **Eine Serie hatte ohnehin nie ein Plakat.** Heruntergeladen wird nie die Serie, nur ihre Folgen — und deren Bild ist das Szenenbild, quer. Jetzt kommt das Serienplakat unter dem Serienschlüssel mit und geht mit der letzten Folge wieder. **Zwanzig Folgen waren zwanzig Kacheln.** Jetzt steht die Serie einmal da, darunter wie viele Folgen bereitliegen; welche genau, zeigt ihre Seite — auch ohne Server, dann aus dem, was auf dem Gerät liegt. **Das Bild wurde schwarz, sobald jemand auf „Bildfüllend" wechselte.** Daran war ein eigener ViewModifier schuld, der zwischen `ignoresSafeArea()` und gar nichts umschaltete. Genau das ist in SwiftUI ein Zweig: der Baum darunter wurde neu gebaut, die Bildebene mit ihm. `edges: []` ignoriert nichts und bleibt doch derselbe Modifikator. Dazu „Als Nächstes": die Reihe begann am Anfang der Serie statt dort, wo der Benutzer steht. Wer bei Folge 2 war und Folge 1 nie abgehakt hatte, bekam Folge 1 vorgeschlagen — und das Abhaken half nicht, weil die Kachel sich nur selbst einen Haken malte, statt zu verschwinden. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8f0568cb6c
commit
79d7d04583
@@ -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" : {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String>()
|
||||
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
/// </remarks>
|
||||
/// <summary>Ob der Benutzer diese Folge überhaupt schon angefasst hat.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Eine Zeile allein genügt nicht: sie entsteht auch, wenn jemand nur
|
||||
/// einen Stern vergibt. Angefasst heisst gesehen, angefangen oder
|
||||
/// ausdrücklich weggelegt.
|
||||
/// </remarks>
|
||||
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);
|
||||
|
||||
/// <summary>Macht aus Zuständen Einträge mit Titel und Bild.</summary>
|
||||
|
||||
@@ -764,6 +764,92 @@ public class PlaybackTests
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Wer bei Folge 2 steht, bekommt nicht Folge 1 angeboten.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Der Fall aus dem Gebrauch.</b> 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.
|
||||
/// </remarks>
|
||||
[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<Page<SeriesResponse>>("/api/v1/series");
|
||||
var folgen = await client.GetFromJsonAsync<Page<EpisodeResponse>>(
|
||||
$"/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<Page<ContinueEntry>>("/api/v1/home/next");
|
||||
Assert.That(naechste!.Items, Is.Empty,
|
||||
"Folge 2 läuft und steht in Weiterschauen, also hat Als Naechstes nichts anzubieten");
|
||||
}
|
||||
|
||||
/// <summary>Und abhaken holt die Serie nicht zurück.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Wer Folge 1 nachträglich abhakt, hat damit nicht gesagt, dass er
|
||||
/// Folge 2 noch einmal anfangen will — die läuft ja.
|
||||
/// </remarks>
|
||||
[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<Page<SeriesResponse>>("/api/v1/series");
|
||||
var folgen = await client.GetFromJsonAsync<Page<EpisodeResponse>>(
|
||||
$"/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<Page<ContinueEntry>>("/api/v1/home/next");
|
||||
Assert.That(naechste!.Items, Is.Empty);
|
||||
}
|
||||
|
||||
/// <summary>Nach der letzten angefassten Folge geht es weiter.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[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<Page<SeriesResponse>>("/api/v1/series");
|
||||
var folgen = await client.GetFromJsonAsync<Page<EpisodeResponse>>(
|
||||
$"/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<Page<ContinueEntry>>("/api/v1/home/next");
|
||||
Assert.That(naechste!.Items.Single().Episode, Is.EqualTo(3));
|
||||
}
|
||||
|
||||
/// <summary>Was in „Weiterschauen" steht, steht nicht nochmal daneben.</summary>
|
||||
[Test]
|
||||
public async Task Eine_angefangene_Folge_erscheint_nur_in_einer_Reihe()
|
||||
|
||||
+15
-2
@@ -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);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user