Files
Flaro/Flaro/Session/Heruntergeladenes.swift
T
ScarriffleandClaude Opus 5 79d7d04583 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>
2026-10-12 20:20:27 +02:00

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