Player hing sich auf Die Qualitätsauswahl war ein Menu. Schloss man es durch einen Tipp ins Bild, blieb eine unsichtbare Ebene zurück, die alle weiteren Tipps schluckte — die Bedienelemente kamen nicht mehr, pausieren ging nicht mehr. Jetzt ein confirmationDialog, der sich selbst wegräumt. Nach dem Schließen werden die Bedienelemente eingeblendet und die Ausblendfrist neu gestartet. Downloads galten als fertig, waren aber leer Zwei Fehler übereinander. Erstens gilt ein URLSessionDownloadTask auch bei 404 oder 500 als erfolgreich und legt die Fehlerseite als Datei ab. Zweitens überschrieb ich danach die erwartete Größe mit der empfangenen — womit die Anzeige auf hundert Prozent stand und der Beweis weg war. 873 MB waren in einer Sekunde „fertig", und beim Abspielen kam nur „Wiedergabe wurde unterbrochen". Jetzt wird der Statuscode geprüft, die erwartete Größe bleibt stehen, und eine unplausible Datei wird verworfen statt verbucht: unter 100 KB ist keine Videodatei, unter der Hälfte der angekündigten Größe ist eine abgebrochene Übertragung. Sechs Tests dazu. Stufenliste ergab keinen Sinn Für einen 720p-Inhalt standen Original, Full HD und HD mit derselben geschätzten Größe untereinander — das sah aus, als wäre die Schätzung kaputt. Sie war es nicht, die Stufen waren es: hochrechnen bringt nur eine größere Datei. Angeboten wird jetzt nur noch, was unter der Auflösung der Datei liegt, und beim Original steht dabei, was es ist: „Original · 720p · 873 MB". Kopfleiste Kein Hintergrund mehr statt eines weichgezeichneten — die Knöpfe bringen ihre eigene Glasfläche mit. Der Titel ist weg: der Name der App gehört auf den Homescreen, nicht über jeden Bildschirm. 145 Tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
229 lines
7.9 KiB
Swift
229 lines
7.9 KiB
Swift
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?
|
|
@State private var showsQualityChoice = false
|
|
|
|
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))
|
|
.confirmationDialog(
|
|
Text("downloads.quality.title"),
|
|
isPresented: $showsQualityChoice,
|
|
titleVisibility: .visible
|
|
) {
|
|
ForEach(availableQualities) { quality in
|
|
Button(qualityLabel(quality)) { enqueue(quality: quality) }
|
|
}
|
|
Button("action.cancel", role: .cancel) {}
|
|
} message: {
|
|
Text("downloads.quality.hint")
|
|
}
|
|
.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 {
|
|
// Vor dem Laden fragen. Der Unterschied zwischen einem
|
|
// 40-GB-Remux und 2 GB in 720p ist auf einem Telefon keine
|
|
// Nebensache — und hinterher lässt er sich nicht mehr ändern.
|
|
showsQualityChoice = true
|
|
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(quality: DownloadQuality) {
|
|
let service = DownloadService(server: session.server, coordinator: downloads)
|
|
do {
|
|
_ = try service.download(item, mediaIndex: mediaIndex, quality: quality)
|
|
} catch {
|
|
refusal = error
|
|
}
|
|
}
|
|
|
|
private var currentMedia: PlexMedia? {
|
|
item.media.indices.contains(mediaIndex) ? item.media[mediaIndex] : item.preferredMedia
|
|
}
|
|
|
|
/// Nur Stufen, die unter der Auflösung der Datei liegen.
|
|
private var availableQualities: [DownloadQuality] {
|
|
DownloadQuality.available(forSourceHeight: currentMedia?.pixelHeight)
|
|
}
|
|
|
|
/// Die Beschriftung samt Auflösung und geschätzter Größe.
|
|
///
|
|
/// Beim Original steht dabei, was es überhaupt ist: „Original · 720p".
|
|
/// Ohne diese Angabe ist die Wahl blind — man weiß nicht, ob man gerade
|
|
/// herunterrechnet oder nicht.
|
|
private func qualityLabel(_ quality: DownloadQuality) -> String {
|
|
let originalBytes = currentMedia?.parts.first?.size ?? 0
|
|
let bytes = quality.estimatedBytes(
|
|
durationMilliseconds: item.duration,
|
|
originalBytes: originalBytes
|
|
)
|
|
|
|
var name = String(localized: quality.localizedName)
|
|
if quality == .original, let resolution = currentMedia?.resolutionLabel {
|
|
name += " · \(resolution)"
|
|
}
|
|
|
|
guard bytes > 0 else { return name }
|
|
|
|
let size = bytes.formatted(.byteCount(style: .file))
|
|
return quality == .original
|
|
? "\(name) · \(size)"
|
|
: String(localized: "downloads.quality.approx \(name) \(size)")
|
|
}
|
|
}
|
|
|
|
extension DownloadQuality {
|
|
var localizedName: String.LocalizationValue {
|
|
switch self {
|
|
case .original: "quality.original"
|
|
case .fullHD: "quality.fullHD"
|
|
case .hd: "quality.hd"
|
|
case .low: "quality.lowRes"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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 .noDownloadableFile(let reason):
|
|
reason
|
|
case .insufficientDeviceSpace(let required, let available):
|
|
String(localized: "downloads.refused.space \(required.formatted(.byteCount(style: .file))) \(available.formatted(.byteCount(style: .file)))")
|
|
}
|
|
}
|
|
}
|