Heruntergeladenes lag auf dem Gerät und war nirgends zu sehen

Downloads gab es nur im Downloads-Bereich. Wer wissen wollte, was im
Flugzeug läuft, musste zwei Listen vergleichen: die Mediathek und die
Downloads.

Jetzt sind sie eine Reihe auf der Startseite, tragen in jeder Kachel
eine Marke, und in der Mediathek gibt es „Nur Heruntergeladene" — der
Filter fragt den Server gar nicht erst, weil der nicht weiss, was auf
diesem Gerät liegt.

Dabei kam heraus, dass Downloads nie ein Bild mitbrachten: der
Dateiname dafür stand im Modell, gesetzt hat ihn niemand. Neue holen es
mit, alten wird es beim nächsten Start nachgereicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-12 19:28:45 +02:00
co-authored by Claude Opus 5
parent fb8ad1c831
commit 77593105de
11 changed files with 335 additions and 14 deletions
+32
View File
@@ -1886,6 +1886,22 @@
}
}
},
"filter.downloadedOnly" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Nur Heruntergeladene"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Downloaded only"
}
}
}
},
"filter.filters" : {
"extractionState" : "stale",
"localizations" : {
@@ -2130,6 +2146,22 @@
}
}
},
"home.downloaded" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Heruntergeladen"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Downloaded"
}
}
}
},
"home.empty.description" : {
"localizations" : {
"de" : {
+35 -1
View File
@@ -196,9 +196,42 @@ struct DownloadService {
quality: DownloadQuality = .original,
audioStream: MediaStream? = nil
) throws(DownloadRefusal) -> DownloadItem {
try coordinator.enqueue(
let eintrag = try coordinator.enqueue(
try plan(for: item, mediaIndex: mediaIndex, quality: quality, audioStream: audioStream)
)
plakatHolen(for: item)
return eintrag
}
/// Holt das Plakat mit herunter.
///
/// **Ohne Verbindung gibt es keine Adresse mehr, von der eines käme.**
/// Ein Download ohne Bild ist offline eine graue Fläche mit einem Titel
/// darunter — und genau dann, wenn man etwas zum Ansehen sucht, ist das
/// Bild die halbe Auskunft. Ein paar Kilobyte je Eintrag.
///
/// 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) }
}
/// Holt Plakate nach, die früheren Downloads fehlen.
///
/// **Weil bis vor Kurzem gar keines mitkam.** Der Dateiname dafür stand
/// im Modell, gesetzt hat ihn nie jemand — alles, was vorher
/// heruntergeladen wurde, hat kein Bild und stünde in der neuen Reihe
/// als graue Fläche.
///
/// Einmal je Serverwechsel, nur für die Einträge ohne Bild; danach
/// findet die Schleife nichts mehr.
func plakateNachholen() async {
guard let backend else { return }
for eintrag in coordinator.items where eintrag.artworkFileName == nil {
guard let titel = try? await backend.item(id: eintrag.ratingKey) else { continue }
plakatHolen(for: titel)
}
}
/// Ob der Server beim Herunterladen alle Spuren mitgibt.
@@ -294,6 +327,7 @@ struct DownloadService {
_ = try coordinator.enqueue(
try plan(for: detailed, quality: quality, audioStream: ton)
)
plakatHolen(for: detailed)
accepted += 1
} catch DownloadRefusal.noDownloadableFile {
// Einzelne Folgen ohne Datei stoppen den Stapel nicht — sie
+71
View File
@@ -0,0 +1,71 @@
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) }
}
/// 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
)
}
/// 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)
}
}
}
+24 -2
View File
@@ -1,3 +1,4 @@
import Foundation
import MediaKit
import Observation
@@ -38,10 +39,31 @@ final class HomeFeed {
#endif
}
/// Die Kennung der Downloadreihe.
///
/// Fest und nicht vom Server: nur so lässt sie sich wie jede andere
/// Reihe verschieben oder ausblenden.
static let downloadsRow = "flaro.downloads"
/// Die Reihen in der eingestellten Form.
func rows(for layout: HomeLayoutSettings) -> [MediaRowGroup] {
///
/// `downloaded` kommt von aussen, weil `HomeFeed` nichts von Downloads
/// weiss und auch nichts davon wissen muss — es ist eine Reihe wie jede
/// andere, nur mit einer anderen Quelle.
func rows(for layout: HomeLayoutSettings, downloaded: [MediaItem] = []) -> [MediaRowGroup] {
guard case .loaded(let alle) = state else { return [] }
return layout.apply(to: prepared(alle, layout: layout))
var reihen = prepared(alle, layout: layout)
if !downloaded.isEmpty {
reihen.insert(
MediaRowGroup(
id: Self.downloadsRow,
title: String(localized: "home.downloaded"),
items: downloaded
),
at: 0
)
}
return layout.apply(to: reihen)
}
// MARK: - Ordnen
@@ -89,6 +89,11 @@ struct QuickPlayTile<Card: View>: View {
.overlay(alignment: .topTrailing) {
if isPlayable, isMac { playButton }
}
// **Links, weil rechts schon etwas sitzt** — am Mac der Abspielknopf
// beim Überfahren, unten der Fortschrittsbalken.
.overlay(alignment: .topLeading) {
if Heruntergeladenes.liegtBereit(item.id, in: downloads) { downloadMarke }
}
.onContinuousHover { phase in
// Zur Laufzeit, nicht beim Übersetzen: „für iPad angepasst" ist
// der iOS-Bau und läuft trotzdem auf dem Mac.
@@ -247,6 +252,20 @@ struct QuickPlayTile<Card: View>: View {
.accessibilityHidden(true)
}
/// Das Zeichen für „liegt auf diesem Gerät".
///
/// **Damit man es sieht, ohne danach zu suchen.** Bisher stand ein
/// Download nur im Downloads-Bereich; in der Mediathek sah eine
/// heruntergeladene Folge aus wie jede andere, und wer wissen wollte,
/// was im Flugzeug läuft, musste zwei Listen vergleichen.
private var downloadMarke: some View {
Image(systemName: "arrow.down.circle.fill")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.white, Color.black.opacity(0.55))
.padding(FlaroSpacing.snug)
.accessibilityLabel(Text("downloads.available"))
}
/// Ob es etwas herunterzuladen gibt — und ob es nicht schon da ist.
///
/// Sammlungen, Serien und Staffeln fallen heraus: dort lädt man nicht
+22 -2
View File
@@ -1,5 +1,6 @@
import SwiftUI
import FlaroDesign
import FlaroDownloads
import MediaKit
/// Die Startseite: Aufmacher oben, darunter die Reihen vom Server.
@@ -11,8 +12,20 @@ import MediaKit
struct HomeView: View {
@Environment(FlaroSession.self) private var session
@Environment(HomeLayoutSettings.self) private var layout
@Environment(DownloadCoordinator.self) private var downloads
@State private var feed = HomeFeed()
/// Was auf dem Gerät liegt, als Reihe ganz oben.
///
/// Wird bei jeder Änderung an den Downloads neu berechnet, weil
/// `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)
}
}
var body: some View {
Group {
switch feed.state {
@@ -29,7 +42,7 @@ struct HomeView: View {
}
case .loaded:
let hubs = feed.rows(for: layout)
let hubs = feed.rows(for: layout, downloaded: heruntergeladen)
if hubs.isEmpty {
ContentUnavailableView {
Label("home.empty.title", systemImage: "film.stack")
@@ -59,7 +72,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 feed.load(from: session.backend) }
.task(id: session.serverName) {
await feed.load(from: session.backend)
// Siehe `DownloadService.plakateNachholen`: ältere Downloads
// haben kein Bild, und hier ist die Stelle, an der man es merken
// würde.
await DownloadService(backend: session.backend, coordinator: downloads)
.plakateNachholen()
}
.refreshable { await feed.load(from: session.backend) }
// Was eine Kachel am Server ändert, ändert auch, was hier steht:
// eine abgehakte Folge gehört nicht mehr in „Weiterschauen".
+2 -2
View File
@@ -59,7 +59,7 @@ struct MediaRow: View {
LandscapeCard(
title: item.rowTitle,
subtitle: item.cardSubtitle,
artworkURL: session.backend?.artworkURL(
artworkURL: Heruntergeladenes.bildAdresse(item) ?? session.backend?.artworkURL(
path: item.rowArtworkPath,
width: 440,
height: 248
@@ -71,7 +71,7 @@ struct MediaRow: View {
PosterCard(
title: item.rowTitle,
subtitle: item.cardSubtitle,
artworkURL: session.backend?.artworkURL(
artworkURL: Heruntergeladenes.bildAdresse(item) ?? session.backend?.artworkURL(
path: item.posterArtworkPath,
width: 300,
height: 450
+15 -2
View File
@@ -5,6 +5,9 @@ import MediaKit
/// Filter und Sortierung einer Mediathek.
struct LibraryFilterSheet: View {
@Binding var query: MediaQuery
/// Steht getrennt von `query`, weil der Server ihn nicht auswerten kann —
/// siehe `LibraryView.nurHeruntergeladene`.
@Binding var nurHeruntergeladene: Bool
/// Die Mediathek selbst, nicht nur ihre Art.
///
/// Vorher stand hier die Art, und die Genres wurden über *irgendeine*
@@ -23,11 +26,18 @@ struct LibraryFilterSheet: View {
/// Sonst löste jeder Tipp im Sheet sofort eine neue Serverabfrage aus —
/// bei drei Änderungen hintereinander drei verworfene Ladevorgänge.
@State private var draft: MediaQuery
@State private var entwurfHeruntergeladene: Bool
init(query: Binding<MediaQuery>, library: MediaLibrary) {
init(
query: Binding<MediaQuery>,
nurHeruntergeladene: Binding<Bool>,
library: MediaLibrary
) {
self._query = query
self._nurHeruntergeladene = nurHeruntergeladene
self.library = library
self._draft = State(initialValue: query.wrappedValue)
self._entwurfHeruntergeladene = State(initialValue: nurHeruntergeladene.wrappedValue)
}
var body: some View {
@@ -45,6 +55,7 @@ struct LibraryFilterSheet: View {
Section("filter.filters") {
Toggle("filter.unwatchedOnly", isOn: $draft.unwatchedOnly)
Toggle("filter.downloadedOnly", isOn: $entwurfHeruntergeladene)
Picker("filter.genre", selection: $draft.genre) {
Text("filter.any").tag(String?.none)
@@ -80,8 +91,9 @@ struct LibraryFilterSheet: View {
Section {
Button("filter.reset", role: .destructive) {
draft = MediaQuery(sort: draft.sort)
entwurfHeruntergeladene = false
}
.disabled(!draft.isFiltering)
.disabled(!draft.isFiltering && !entwurfHeruntergeladene)
}
}
// Ausdrücklich, nicht nur geerbt.
@@ -102,6 +114,7 @@ struct LibraryFilterSheet: View {
}
ToolbarItem(placement: .confirmationAction) {
Button("action.apply") {
nurHeruntergeladene = entwurfHeruntergeladene
query = draft
dismiss()
}
+58 -5
View File
@@ -1,5 +1,6 @@
import SwiftUI
import FlaroDesign
import FlaroDownloads
import MediaKit
/// Eine Mediathek zum Durchblättern.
@@ -10,6 +11,7 @@ struct LibraryView: View {
/// Nur für die Kachelgröße. „layout“ heißt hier schon die Wahl
/// zwischen Raster und Liste.
@Environment(HomeLayoutSettings.self) private var sizing
@Environment(DownloadCoordinator.self) private var downloads
@AppStorage("flaro.library.layout") private var layout: LibraryLayout = .grid
@Environment(\.horizontalSizeClass) private var sizeClass
@@ -25,6 +27,14 @@ struct LibraryView: View {
@State private var loadMoreFailed = false
@State private var isLoadingMore = false
@State private var showsFilters = false
/// Ob nur gezeigt wird, was auf diesem Gerät liegt.
///
/// **Kein Teil von `MediaQuery`**, und das ist der Kern der Sache: die
/// Abfrage wertet der Server aus, und der weiss nicht, was auf diesem
/// Gerät liegt. Steht der Filter an, wird gar nicht erst gefragt — die
/// Liste kommt aus den Downloads. Das ist genau, vollständig, und
/// funktioniert ohne Verbindung.
@State private var nurHeruntergeladene = false
var body: some View {
Group {
@@ -48,8 +58,9 @@ struct LibraryView: View {
} description: {
Text("library.empty.description")
} actions: {
if query.isFiltering {
if query.isFiltering || nurHeruntergeladene {
Button("library.clearFilters") {
nurHeruntergeladene = false
query = MediaQuery(sort: query.sort)
}
}
@@ -64,7 +75,11 @@ struct LibraryView: View {
.flaroInlineTitle()
.toolbar { toolbar }
.sheet(isPresented: $showsFilters) {
LibraryFilterSheet(query: $query, library: library)
LibraryFilterSheet(
query: $query,
nurHeruntergeladene: $nurHeruntergeladene,
library: library
)
}
.task(id: library.id) {
// Beim Wechsel auf eine andere Mediathek zuerst leeren: sonst hält
@@ -78,6 +93,11 @@ struct LibraryView: View {
// Liste — Nachladen mit altem Versatz würde sonst Einträge aus zwei
// verschiedenen Abfragen vermischen.
.onChange(of: query) { _, _ in Task { await reload() } }
.onChange(of: nurHeruntergeladene) { _, _ in
items = []
page = nil
Task { await reload() }
}
.refreshable { await reload() }
// Siehe `HomeView`: was eine Kachel ändert, muss im Raster ankommen.
.environment(\.flaroItemChanged, { await reload() })
@@ -105,7 +125,8 @@ struct LibraryView: View {
PosterCard(
title: item.title,
subtitle: item.cardSubtitle,
artworkURL: session.backend?.artworkURL(path: item.thumb, width: 300, height: 450),
artworkURL: Heruntergeladenes.bildAdresse(item)
?? session.backend?.artworkURL(path: item.thumb, width: 300, height: 450),
progress: item.resumeProgress
)
}
@@ -232,7 +253,7 @@ struct LibraryView: View {
Button {
showsFilters = true
} label: {
Image(systemName: query.isFiltering
Image(systemName: query.isFiltering || nurHeruntergeladene
? "line.3.horizontal.decrease.circle.fill"
: "line.3.horizontal.decrease.circle")
}
@@ -276,6 +297,16 @@ struct LibraryView: View {
/// stehen. Eine Liste von gestern ist im Zug brauchbarer als ein
/// Fehlerbildschirm.
private func reload() async {
// **Vor dem Server, nicht danach.** Was auf dem Gerät liegt, weiss
// nur das Gerät — und es weiss es auch im Flugmodus.
if nurHeruntergeladene {
items = geraeteliste
page = MediaPage(items: items, totalCount: items.count)
loadMoreFailed = false
state = .loaded(())
return
}
guard let server = session.backend else { return }
let schlüssel = query.cacheKey
let kennung = server.identity
@@ -319,6 +350,27 @@ struct LibraryView: View {
}
}
/// Die Downloads, die in diese Mediathek gehören.
///
/// **Zugeordnet über die Art der Mediathek**, weil ein Download seine
/// Mediathek nicht kennt: er führt einen Serienschlüssel oder keinen,
/// und mehr braucht es nicht. Eine Folge gehört in eine Serien-,
/// ein Film in eine Filmmediathek.
///
/// 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
}
}
.map { Heruntergeladenes.titel($0, in: downloads) }
}
/// Lädt nach, sobald die Ansicht sich dem Ende der Liste nähert.
private func loadMoreIfNeeded(reaching item: MediaItem) async {
guard
@@ -385,7 +437,8 @@ private struct LibraryListRow: View {
var body: some View {
HStack(spacing: FlaroSpacing.regular) {
FlaroArtwork(
url: session.backend?.artworkURL(path: item.thumb, width: 120, height: 180),
url: Heruntergeladenes.bildAdresse(item)
?? session.backend?.artworkURL(path: item.thumb, width: 120, height: 180),
aspectRatio: PosterCard.aspectRatio
)
.frame(width: 44)
@@ -422,6 +422,40 @@ public final class DownloadCoordinator {
return fileStore.url(forFileName: fileName)
}
/// Das lokale Plakat eines Downloads — `nil`, wenn keines geholt wurde.
///
/// **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.
public func artworkURL(forRatingKey key: String) -> URL? {
guard let name = items.first(where: { $0.ratingKey == key })?.artworkFileName,
fileStore.exists(fileName: name)
else { return nil }
return fileStore.url(forFileName: name)
}
/// Holt das Plakat zu einem Eintrag nach und legt es ab.
///
/// **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.
public func fetchArtwork(for ratingKey: String, from url: URL) async {
guard let item = items.first(where: { $0.ratingKey == ratingKey }),
item.artworkFileName == nil
else { 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 }
item.artworkFileName = name
save()
}
public func offlineMedia(forRatingKey key: String) -> OfflineMedia? {
guard let data = items.first(where: { $0.ratingKey == key })?.offlineMediaData else { return nil }
return try? JSONDecoder().decode(OfflineMedia.self, from: data)
@@ -58,6 +58,29 @@ public struct DownloadFileStore: Sendable {
return "\(key).\(ext)"
}
/// Der Name des Plakats zu einem Titel.
///
/// **Eine eigene Datei, kein Anhängsel am Film.** Sie ist ein paar
/// Kilobyte gross und wird gebraucht, solange der Eintrag steht — auch
/// während der Film noch lädt und noch gar keine Datei hat.
public func artworkFileName(forRatingKey key: String) -> String { "\(key).bild" }
/// Legt ein geholtes Plakat ab. Schlägt es fehl, gibt es eben keines.
///
/// **Wirft nicht.** Ein Download ohne Bild ist ein Download; ein
/// Download, der am Bild scheitert, wäre keiner.
@discardableResult
public func store(_ data: Data, as name: String) -> URL? {
let ziel = url(forFileName: name)
do {
try createRootIfNeeded()
try data.write(to: ziel, options: .atomic)
return ziel
} catch {
return nil
}
}
/// Verschiebt die fertige Datei aus dem temporären Ordner an ihren Platz.
///
/// Verschieben, nicht kopieren: iOS löscht die temporäre Datei, sobald der