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>
174 lines
7.6 KiB
Swift
174 lines
7.6 KiB
Swift
import FlaroDownloads
|
|
import Foundation
|
|
import MediaKit
|
|
|
|
/// Was auf dem Gerät liegt, als gewöhnliche Titel.
|
|
///
|
|
/// **Damit ein Download aussieht wie alles andere.** Bisher waren Downloads
|
|
/// eine eigene Welt: eine eigene Liste, eine eigene Zeile, ein eigener
|
|
/// Bereich. Wer etwas ansehen wollte, musste erst wissen, dass er dorthin
|
|
/// wechseln muss. Als `MediaItem` fügt sich ein Download in jede Reihe und
|
|
/// jedes Raster ein, das es ohnehin gibt — Kachel, Fortschrittsbalken,
|
|
/// Kontextmenü inbegriffen.
|
|
///
|
|
/// **Und es ist die Grundlage fürs Offline-Arbeiten.** Ohne Server gibt es
|
|
/// keine andere Quelle für einen Titel; alles, was dann noch dasteht, kommt
|
|
/// von hier.
|
|
@MainActor
|
|
enum Heruntergeladenes {
|
|
/// Die fertigen Downloads, neueste zuerst.
|
|
///
|
|
/// **Nur fertige.** Ein halb geladener Film ist nichts zum Ansehen; er
|
|
/// steht im Downloads-Bereich mit seinem Balken, und dort gehört er hin.
|
|
static func fertige(in coordinator: DownloadCoordinator) -> [DownloadItem] {
|
|
coordinator.items
|
|
.filter(\.isComplete)
|
|
.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
|
|
/// ein Pfad, den erst ein Server zu einer Adresse macht; hier steht
|
|
/// stattdessen die fertige lokale Adresse. Ansichten, die `rawImageURL`
|
|
/// darauf anwenden, müssen das wissen — siehe `bildAdresse`.
|
|
static func titel(_ eintrag: DownloadItem, in coordinator: DownloadCoordinator) -> MediaItem {
|
|
MediaItem(
|
|
id: eintrag.ratingKey,
|
|
kind: eintrag.seriesKey == nil ? .movie : .episode,
|
|
title: eintrag.title,
|
|
duration: eintrag.durationMilliseconds,
|
|
thumb: coordinator.artworkURL(forRatingKey: eintrag.ratingKey)?.absoluteString,
|
|
index: eintrag.episodeIndex,
|
|
parentIndex: eintrag.seasonIndex,
|
|
parentTitle: eintrag.seriesTitle,
|
|
seriesTitle: eintrag.seriesTitle,
|
|
seriesID: eintrag.seriesKey,
|
|
versions: [fassung(eintrag)]
|
|
)
|
|
}
|
|
|
|
/// Die Datei auf dem Gerät als Fassung.
|
|
///
|
|
/// **Ohne sie bleibt der Abspielknopf grau.** Die Detailseite schaltet
|
|
/// ihn an `currentMedia?.files.first` fest, und das ist richtig: ein
|
|
/// Titel ohne Datei lässt sich nicht abspielen. Offline *gibt* es die
|
|
/// Datei aber — sie liegt nur woanders. Was der Server beim Herunterladen
|
|
/// über sie gesagt hat, steht in `offlineMediaData`.
|
|
private static func fassung(_ eintrag: DownloadItem) -> MediaVersion {
|
|
let angaben = eintrag.offlineMediaData.flatMap {
|
|
try? JSONDecoder().decode(OfflineMedia.self, from: $0)
|
|
}
|
|
return MediaVersion(
|
|
id: eintrag.ratingKey,
|
|
container: angaben?.container,
|
|
videoCodec: angaben?.videoCodec,
|
|
audioCodec: angaben?.audioCodec,
|
|
videoResolution: angaben?.videoResolution,
|
|
bitrate: angaben?.bitrate,
|
|
duration: eintrag.durationMilliseconds,
|
|
files: [MediaFile(
|
|
id: eintrag.ratingKey,
|
|
itemID: eintrag.ratingKey,
|
|
container: angaben?.container,
|
|
size: eintrag.totalBytes > 0 ? eintrag.totalBytes : nil,
|
|
duration: eintrag.durationMilliseconds
|
|
)]
|
|
)
|
|
}
|
|
|
|
/// Die Adresse eines Bildes, das schon eine ist.
|
|
///
|
|
/// **Ein Umweg, der nötig ist.** Die Kacheln bauen ihre Bildadresse über
|
|
/// den Server (`rawImageURL`), weil `thumb` dort ein Pfad ist. Bei einem
|
|
/// Download steht darin bereits eine `file:`-Adresse — durch denselben
|
|
/// Bau geschickt käme Unsinn heraus.
|
|
static func bildAdresse(_ titel: MediaItem) -> URL? {
|
|
guard let roh = titel.thumb, roh.hasPrefix("file:") else { return nil }
|
|
return URL(string: roh)
|
|
}
|
|
|
|
/// Ob dieser Titel auf dem Gerät liegt.
|
|
///
|
|
/// **Eine Serie gilt als da, wenn Folgen von ihr da sind.** Die Serie
|
|
/// selbst wird nie heruntergeladen — ihre Folgen schon, und wer die
|
|
/// Kachel sieht, will wissen, ob er etwas davon ansehen kann.
|
|
static func liegtBereit(_ titelID: String, in coordinator: DownloadCoordinator) -> Bool {
|
|
coordinator.items.contains {
|
|
$0.isComplete && ($0.ratingKey == titelID || $0.seriesKey == titelID)
|
|
}
|
|
}
|
|
}
|