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:
co-authored by
Claude Opus 5
parent
fb8ad1c831
commit
77593105de
@@ -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" : {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user