Phase 3: Download-Engine und Offline-Wiedergabe
Engine - Hintergrund-URLSession: läuft weiter, wenn die App zu ist. Der ganze Zustand liegt in SwiftData, weil es nach einem Neustart kein anderes Gedächtnis gibt. - Pausieren über cancel(byProducingResumeData:) — nur so lässt sich weiterladen statt neu zu beginnen. Bei 40 GB der Unterschied zwischen brauchbar und unbrauchbar. - Zwei Übertragungen gleichzeitig, Folgen in Staffel-/Folgenreihenfolge. Alles parallel zu laden hieße: keine Folge wird fertig. - Speicherlimit und Geräteplatz werden vor dem Annehmen geprüft, ein Gigabyte bleibt als Puffer frei. - Verwaiste Dateien werden beim Start entfernt; sie entstehen, wenn die App genau beim Abschluss beendet wird. - Downloads liegen in Application Support, nicht in Caches (iOS räumt den eigenmächtig leer) und sind von der iCloud-Sicherung ausgenommen. Oberfläche - Download-Knopf auf Detailseiten mit vier Zuständen in einem Knopf. - „Alle Folgen laden" für Staffel und Serie; ein abgelehnter Titel stoppt den Stapel nicht. - Downloads-Ansicht mit Belegung, WLAN-Schalter, Pausieren, Fortsetzen, Löschen — nach Serie gruppiert. Offline - Der Player bevorzugt die lokale Datei, auch ohne erreichbaren Server. - Beim Herunterladen wird ein Abzug der Serverangaben mitgesichert: offline lässt sich nichts nachfragen, und der PlaybackDecider braucht Container und Codecs. Ein Fehler, den die Tests aufgedeckt haben: der Koordinator hielt nur den ModelContext, nicht den ModelContainer. Ein ModelContext hält seinen Container nicht am Leben — wird der anderswo freigegeben, stürzt jeder Zugriff auf einen Eintrag ab, und die Meldung zeigt auf ModelContext.reset statt auf die Ursache. Jetzt liegt die Zuständigkeit beim Koordinator. 102 Tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
import SwiftUI
|
||||
import FlixyDesign
|
||||
import FlixyDownloads
|
||||
import PlexKit
|
||||
|
||||
/// Der Download-Knopf auf einer Detailseite.
|
||||
///
|
||||
/// Zeigt vier Zustände in einem Knopf: nicht geladen, lädt gerade, offline
|
||||
/// verfügbar, gescheitert. Ein getrennter Knopf je Zustand würde die
|
||||
/// Kopfzeile überfüllen — und der Zustand ist ohnehin immer genau einer.
|
||||
struct DownloadButton: View {
|
||||
let item: PlexItem
|
||||
var mediaIndex: Int = 0
|
||||
|
||||
@Environment(FlixySession.self) private var session
|
||||
@Environment(DownloadCoordinator.self) private var downloads
|
||||
@Environment(\.flixyAccent) private var accent
|
||||
|
||||
@State private var refusal: DownloadRefusal?
|
||||
|
||||
private var existing: DownloadItem? {
|
||||
downloads.items.first { $0.ratingKey == item.ratingKey }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
act()
|
||||
} label: {
|
||||
Label(label, systemImage: symbol)
|
||||
.labelStyle(.iconOnly)
|
||||
.font(.flixyCardTitle)
|
||||
.padding(FlixySpacing.snug)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(existing?.state == .completed ? accent : Color.flixyTextSecondary)
|
||||
.accessibilityLabel(Text(label))
|
||||
.alert(
|
||||
Text("downloads.refused.title"),
|
||||
isPresented: Binding(get: { refusal != nil }, set: { if !$0 { refusal = nil } })
|
||||
) {
|
||||
Button("action.close") { refusal = nil }
|
||||
} message: {
|
||||
if let refusal { Text(refusal.explanation) }
|
||||
}
|
||||
}
|
||||
|
||||
private var symbol: String {
|
||||
switch existing?.state {
|
||||
case .completed: "checkmark.circle.fill"
|
||||
case .downloading, .queued, .waiting: "stop.circle"
|
||||
case .paused: "play.circle"
|
||||
case .failed: "exclamationmark.arrow.trianglehead.2.clockwise.rotate.90"
|
||||
case nil: "arrow.down.circle"
|
||||
}
|
||||
}
|
||||
|
||||
private var label: LocalizedStringKey {
|
||||
switch existing?.state {
|
||||
case .completed: "downloads.available"
|
||||
case .downloading, .queued, .waiting: "downloads.cancel"
|
||||
case .paused: "downloads.resume"
|
||||
case .failed: "action.retry"
|
||||
case nil: "downloads.download"
|
||||
}
|
||||
}
|
||||
|
||||
private func act() {
|
||||
guard let existing else {
|
||||
enqueue()
|
||||
return
|
||||
}
|
||||
|
||||
switch existing.state {
|
||||
case .completed:
|
||||
// Ein fertiger Download wird hier nicht angetastet. Löschen geht
|
||||
// über die Downloads-Ansicht — ein Fehlgriff in der Kopfzeile
|
||||
// soll nicht 40 GB verwerfen.
|
||||
break
|
||||
case .downloading, .queued, .waiting:
|
||||
Task { await downloads.cancel(existing) }
|
||||
case .paused, .failed:
|
||||
Task { await downloads.resume(existing) }
|
||||
}
|
||||
}
|
||||
|
||||
private func enqueue() {
|
||||
let service = DownloadService(server: session.server, coordinator: downloads)
|
||||
do {
|
||||
_ = try service.download(item, mediaIndex: mediaIndex)
|
||||
} catch {
|
||||
refusal = error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Der Knopf für eine ganze Staffel oder Serie.
|
||||
struct DownloadAllButton: View {
|
||||
let container: PlexItem
|
||||
|
||||
@Environment(FlixySession.self) private var session
|
||||
@Environment(DownloadCoordinator.self) private var downloads
|
||||
@Environment(\.flixyAccent) private var accent
|
||||
|
||||
@State private var isWorking = false
|
||||
@State private var summary: String?
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
Task { await downloadAll() }
|
||||
} label: {
|
||||
if isWorking {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Label(
|
||||
container.kind == .show ? "downloads.allEpisodes" : "downloads.season",
|
||||
systemImage: "arrow.down.circle"
|
||||
)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(Color.flixyTextSecondary)
|
||||
.disabled(isWorking)
|
||||
.alert(
|
||||
Text("downloads.batch.title"),
|
||||
isPresented: Binding(get: { summary != nil }, set: { if !$0 { summary = nil } })
|
||||
) {
|
||||
Button("action.close") { summary = nil }
|
||||
} message: {
|
||||
if let summary { Text(summary) }
|
||||
}
|
||||
}
|
||||
|
||||
private func downloadAll() async {
|
||||
isWorking = true
|
||||
defer { isWorking = false }
|
||||
|
||||
let service = DownloadService(server: session.server, coordinator: downloads)
|
||||
let result = await service.downloadAll(in: container)
|
||||
|
||||
var lines = [String(localized: "downloads.batch.queued \(result.accepted)")]
|
||||
if !result.refused.isEmpty {
|
||||
// Der erste Grund reicht: bei einem erreichten Speicherlimit ist
|
||||
// er für alle übrigen derselbe.
|
||||
lines.append(result.refused[0].1.explanation)
|
||||
}
|
||||
if result.unavailable > 0 {
|
||||
lines.append(String(localized: "downloads.batch.unavailable \(result.unavailable)"))
|
||||
}
|
||||
summary = lines.joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
|
||||
extension DownloadRefusal {
|
||||
/// Der Grund im Klartext.
|
||||
var explanation: String {
|
||||
switch self {
|
||||
case .alreadyExists:
|
||||
String(localized: "downloads.refused.exists")
|
||||
case .storageLimitReached(let limit, let used):
|
||||
String(localized: "downloads.refused.limit \(used.formatted(.byteCount(style: .file))) \(limit.formatted(.byteCount(style: .file)))")
|
||||
case .insufficientDeviceSpace(let required, let available):
|
||||
String(localized: "downloads.refused.space \(required.formatted(.byteCount(style: .file))) \(available.formatted(.byteCount(style: .file)))")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user