Dein Bericht: bei rund 3 GB abgebrochen, beim Fortsetzen wieder bei null,
und später „plötzlich bei 60 Prozent fertig". Beide Hälften standen so im
Code.
**Fertig war sie nicht, sie war halb.**
if expected > 0, Double(written) < Double(expected) * 0.5 { verwerfen }
Alles über der Hälfte galt als vollständig. Diese Regel war nie für diesen
Fall gedacht, sondern für die **hochgerechnete** Größe der Transcoder-Stufen
(Bitrate × Dauer) — dort ist Abweichung normal. Beim Original nennt der
Server die Dateigröße in den Metadaten, und da ist jedes fehlende Byte ein
Abriss.
Das unterscheidet jetzt `expectedIsExact`, gesetzt beim Planen und
mitgeführt am Eintrag. Dazu merkt sich der Koordinator, für welche Titel der
Server eine `Content-Length` geschickt hat — auch das ist eine Zusage.
**„Fortsetzen" war eine Zusage, die der Code nicht halten konnte.**
`NSURLSessionDownloadTaskResumeData` kam im ganzen Projekt nicht vor; den
Wiederaufnahme-Datensatz gab es nur beim Pausieren von Hand. Nach einem
Netzfehler war er weg, und der Knopf daneben hieß trotzdem „Fortsetzen".
Jetzt wird er aus dem Fehler gerettet, und wo es ihn nicht gibt, heißt der
Knopf „Erneut versuchen".
**Und es merkte niemand, wenn das WLAN zurückkam.** `RootView` reicht das
jetzt weiter — nur für Einträge mit Wiederaufnahme-Datensatz, damit ein 404
sich nicht im Kreis versucht.
**Rate und Restzeit** stehen in der Zeile: „12 MB / 20 MB · 3,4 MB/s ·
gleich fertig". `DownloadRate` ist ein reiner Wertetyp mit Uhr von außen —
geglättet, weil rohe Differenzen um ein Vielfaches springen, und ohne Zahl,
solange nichts gemessen ist. Neun Tests.
**Zwei Dinge, die erst die Messung gezeigt hat** — und die beide nicht in
der App liegen:
Erstens: reißt eine Übertragung ab, **die eine `Content-Length` angekündigt
hatte**, wiederholt die Hintergrundsitzung von iOS sie selbst — stumm, ohne
den Delegaten zu fragen, und **jedes Mal ab Byte null**. Gemessen: sieben
Anläufe, jeder die vollen 12 MB. Das ist genau dein „fing wieder bei null
an", und dagegen hilft kein Wiederaufnahme-Datensatz.
Zweitens: ein Fortsetzen braucht eine Prüfmarke vom Server. Ohne `ETag`
oder `Last-Modified` kann iOS sein `If-Range` nicht stellen und lädt alles
noch einmal — gemessen, 20 MB zweimal. Mit Marke kam die zweite Anfrage als
`Range: bytes=5636096-` und holte nur den Rest.
Die Attrappe kann das jetzt alles nachstellen: eine beliebig große Datei,
die auf Wunsch mittendrin abreißt, mit oder ohne angekündigte Länge, und
gedrosselt. Ohne sie ist nichts davon beweisbar — und genau daran ist der
Fehler vorbeigekommen.
Im Simulator gegen die Attrappe: 12 von 20 MB → „Fehlgeschlagen:
Übertragung unvollständig", nicht Haken. Vollständig → Haken. iOS und tvOS
bauen, 489 Tests grün.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
357 lines
13 KiB
Swift
357 lines
13 KiB
Swift
import SwiftUI
|
|
import FlixyDesign
|
|
import FlixyDownloads
|
|
import FlixyPlayback
|
|
|
|
/// Was offline ist und was gerade geladen wird.
|
|
struct DownloadsView: View {
|
|
@Environment(DownloadCoordinator.self) private var downloads
|
|
@Environment(PlaybackCoordinator.self) private var playback
|
|
@Environment(NetworkConditions.self) private var network
|
|
@Environment(\.flixyAccent) private var accent
|
|
@Environment(\.horizontalSizeClass) private var sizeClass
|
|
|
|
|
|
var body: some View {
|
|
Group {
|
|
if downloads.items.isEmpty {
|
|
ContentUnavailableView {
|
|
Label("downloads.empty.title", systemImage: "arrow.down.circle")
|
|
} description: {
|
|
Text("downloads.empty.description")
|
|
}
|
|
} else {
|
|
list
|
|
}
|
|
}
|
|
.background(Color.flixyCanvas)
|
|
.navigationTitle(Text("sidebar.downloads"))
|
|
.toolbar {
|
|
// Nur auf dem iPhone: dort gibt es den Einstellungen-Tab nicht
|
|
// mehr, und dies ist der Weg dorthin.
|
|
if sizeClass == .compact {
|
|
ToolbarItem(placement: .flixyTrailing) { AccountToolbarButton() }
|
|
}
|
|
}
|
|
.flixyInlineTitle()
|
|
// Keine Rückfrage beim Entfernen.
|
|
//
|
|
// Sie stand hier, weil „löschen" nach etwas Endgültigem klingt. Es ist
|
|
// aber nur die heruntergeladene Kopie: der Titel bleibt auf dem Server
|
|
// und ist mit einem Tippen wieder da. Wer nach links wischt und auf
|
|
// „Löschen" tippt, hat zweimal gesagt, was er will — ein drittes Mal
|
|
// zu fragen ist keine Vorsicht, sondern Aufhalten.
|
|
}
|
|
|
|
/// Was die Übertragung gerade aufhält.
|
|
private var hold: TransferHold {
|
|
TransferHold.current(
|
|
isWiFiOnly: downloads.isWiFiOnly,
|
|
isReachable: network.isReachable,
|
|
isExpensive: network.isExpensive
|
|
)
|
|
}
|
|
|
|
private var list: some View {
|
|
List {
|
|
StorageSection()
|
|
|
|
// Erst was läuft, dann was fertig ist. Wer die Ansicht öffnet,
|
|
// während etwas lädt, will meistens genau das sehen.
|
|
if !active.isEmpty {
|
|
Section("downloads.active") {
|
|
// Die Hintergrund-Sitzung wartet in diesen Fällen von
|
|
// sich aus: kein Fehler, nur Stillstand. Ohne diesen
|
|
// Hinweis sieht ein wartender Download genauso aus wie
|
|
// ein hängender.
|
|
if hold.isHolding { HoldNotice(hold: hold) }
|
|
|
|
ForEach(active) { item in
|
|
DownloadRow(
|
|
item: item,
|
|
hold: hold,
|
|
onRemove: { Task { await downloads.cancel(item) } }
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
ForEach(completedGroups, id: \.title) { group in
|
|
Section(group.title) {
|
|
ForEach(group.items) { item in
|
|
// Fertige Downloads wartet nichts mehr auf.
|
|
DownloadRow(item: item, hold: .none, onRemove: { Task { await downloads.cancel(item) } })
|
|
.contentShape(.rect)
|
|
.onTapGesture { play(item) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.scrollContentBackground(.hidden)
|
|
.listRowBackground(Color.flixySurface)
|
|
.flixyRevealsTopChrome()
|
|
}
|
|
|
|
private var active: [DownloadItem] {
|
|
downloads.items.filter { $0.state != .completed }
|
|
}
|
|
|
|
/// Fertige Downloads, nach Serie gruppiert; Filme stehen zusammen.
|
|
private var completedGroups: [(title: String, items: [DownloadItem])] {
|
|
let completed = downloads.items.filter { $0.state == .completed }
|
|
let grouped = Dictionary(grouping: completed) {
|
|
$0.seriesTitle ?? String(localized: "downloads.movies")
|
|
}
|
|
return grouped
|
|
.map { (title: $0.key, items: $0.value.sorted(by: episodeOrder)) }
|
|
.sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending }
|
|
}
|
|
|
|
private func episodeOrder(_ lhs: DownloadItem, _ rhs: DownloadItem) -> Bool {
|
|
(lhs.seasonIndex ?? 0, lhs.episodeIndex ?? 0) < (rhs.seasonIndex ?? 0, rhs.episodeIndex ?? 0)
|
|
}
|
|
|
|
private func play(_ item: DownloadItem) {
|
|
guard item.state == .completed else { return }
|
|
playback.play(PlaybackRequest(
|
|
ratingKey: item.ratingKey,
|
|
title: item.title,
|
|
mediaIndex: 0,
|
|
startTime: .zero
|
|
))
|
|
}
|
|
}
|
|
|
|
/// Die Kopfzeile mit Speicherbelegung und Schaltern.
|
|
private struct StorageSection: View {
|
|
@Environment(DownloadCoordinator.self) private var downloads
|
|
@Environment(\.flixyAccent) private var accent
|
|
|
|
var body: some View {
|
|
@Bindable var downloads = downloads
|
|
|
|
Section {
|
|
HStack {
|
|
Text("downloads.used")
|
|
.foregroundStyle(Color.flixyTextSecondary)
|
|
Spacer()
|
|
Text(downloads.usedBytes.formatted(.byteCount(style: .file)))
|
|
.foregroundStyle(Color.flixyTextPrimary)
|
|
}
|
|
|
|
if let limit = downloads.storageLimitBytes, limit > 0 {
|
|
ProgressView(value: min(Double(downloads.usedBytes) / Double(limit), 1))
|
|
.tint(accent)
|
|
.accessibilityLabel(Text("downloads.used"))
|
|
}
|
|
|
|
Toggle("downloads.wifiOnly", isOn: $downloads.isWiFiOnly)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Eine Zeile in der Downloads-Ansicht.
|
|
private struct DownloadRow: View {
|
|
let item: DownloadItem
|
|
let hold: TransferHold
|
|
let onRemove: () -> Void
|
|
|
|
@Environment(DownloadCoordinator.self) private var downloads
|
|
@Environment(\.flixyAccent) private var accent
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: FlixySpacing.snug) {
|
|
HStack(spacing: FlixySpacing.regular) {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(item.title)
|
|
.font(.flixyCardTitle)
|
|
.foregroundStyle(Color.flixyTextPrimary)
|
|
.lineLimit(1)
|
|
if let subtitle = item.subtitle {
|
|
Text(subtitle)
|
|
.font(.flixyCaption)
|
|
.foregroundStyle(Color.flixyTextMuted)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
action
|
|
}
|
|
|
|
if item.state != .completed {
|
|
progress
|
|
}
|
|
|
|
// Nur bei dem, was tatsächlich warten würde. Ein vom Nutzer
|
|
// angehaltener Download wartet nicht auf WLAN, sondern auf ihn.
|
|
if hold.isHolding, item.state == .downloading || item.state == .queued {
|
|
Label(
|
|
hold == .offline ? "downloads.hold.offline" : "downloads.hold.wifi",
|
|
systemImage: hold == .offline ? "wifi.slash" : "wifi.exclamationmark"
|
|
)
|
|
.font(.flixyCaption)
|
|
.foregroundStyle(Color.flixyTextMuted)
|
|
}
|
|
|
|
if let message = item.failureMessage {
|
|
Text(message)
|
|
.font(.flixyCaption)
|
|
.foregroundStyle(Color.flixyDestructive)
|
|
.lineLimit(2)
|
|
}
|
|
}
|
|
.padding(.vertical, FlixySpacing.tight)
|
|
.listRowBackground(Color.flixySurface)
|
|
.swipeActions(edge: .trailing) {
|
|
Button("action.delete", role: .destructive, action: onRemove)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var action: some View {
|
|
switch item.state {
|
|
case .downloading:
|
|
Button {
|
|
Task { await downloads.pause(item) }
|
|
} label: {
|
|
Image(systemName: "pause.circle").font(.title3)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.foregroundStyle(accent)
|
|
.accessibilityLabel(Text("downloads.pause"))
|
|
|
|
case .paused, .failed:
|
|
// **Fortsetzen und erneut versuchen sind nicht dasselbe**, und
|
|
// der Knopf hat beides gleich genannt. „Fortsetzen" stand auch
|
|
// dort, wo es nichts fortzusetzen gab — eine Zusage, die der Code
|
|
// nicht halten konnte: ein gescheiterter Download fing wieder bei
|
|
// null an. Jetzt entscheidet der Wiederaufnahme-Datensatz, und er
|
|
// entsteht genau dann, wenn eine Übertragung unterbrochen wurde.
|
|
Button {
|
|
Task { await downloads.resume(item) }
|
|
} label: {
|
|
Image(systemName: continues ? "play.circle" : "arrow.clockwise.circle")
|
|
.font(.title3)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.foregroundStyle(accent)
|
|
.accessibilityLabel(Text(continues ? "downloads.resume" : "action.retry"))
|
|
|
|
case .completed:
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundStyle(Color.flixyTextMuted)
|
|
.accessibilityLabel(Text("downloads.available"))
|
|
|
|
case .queued, .waiting:
|
|
Image(systemName: "clock")
|
|
.foregroundStyle(Color.flixyTextMuted)
|
|
.accessibilityLabel(Text("downloads.queued"))
|
|
}
|
|
}
|
|
|
|
private var progress: some View {
|
|
VStack(alignment: .leading, spacing: FlixySpacing.tight) {
|
|
// Ohne bekannte Gesamtgröße ein unbestimmter Balken: ein Balken bei
|
|
// null Prozent, der sich nie bewegt, sieht aus wie ein hängender
|
|
// Download.
|
|
if let value = item.progress {
|
|
ProgressView(value: value).tint(accent)
|
|
} else {
|
|
ProgressView().progressViewStyle(.linear).tint(accent)
|
|
}
|
|
|
|
Text(statusLine)
|
|
.font(.flixyCaption)
|
|
.foregroundStyle(Color.flixyTextMuted)
|
|
}
|
|
}
|
|
|
|
/// Ob ein Antippen dort weitermacht, wo es aufgehört hat.
|
|
///
|
|
/// Ein angehaltener Download hat seinen Wiederaufnahme-Datensatz immer;
|
|
/// ein gescheiterter nur, wenn ihn das Netz unterbrochen hat und nicht der
|
|
/// Server abgelehnt.
|
|
private var continues: Bool {
|
|
item.state == .paused || item.resumeData != nil
|
|
}
|
|
|
|
/// Wie schnell es gerade geht — `nil`, solange nichts gemessen ist.
|
|
private var speed: String? {
|
|
guard let perSecond = downloads.rate(forRatingKey: item.ratingKey)?.bytesPerSecond,
|
|
perSecond > 0
|
|
else { return nil }
|
|
let menge = Int64(perSecond).formatted(.byteCount(style: .file))
|
|
return String(localized: "downloads.perSecond \(menge)")
|
|
}
|
|
|
|
/// Wie lange es bei diesem Tempo noch dauert.
|
|
private var remaining: String? {
|
|
guard let rate = downloads.rate(forRatingKey: item.ratingKey),
|
|
let rest = rate.remaining(received: item.receivedBytes, total: item.totalBytes)
|
|
else { return nil }
|
|
// Unter einer Minute keine Zahl: „noch 0 Min" wäre falsch und „noch
|
|
// 43 Sek" genauer, als die Schätzung ist.
|
|
guard rest >= .seconds(60) else {
|
|
return String(localized: "downloads.remaining.underMinute")
|
|
}
|
|
return String(localized: "downloads.remaining \(rest.runtimeLabel)")
|
|
}
|
|
|
|
private var statusLine: String {
|
|
switch item.state {
|
|
case .downloading where item.totalBytes > 0:
|
|
[
|
|
"\(item.receivedBytes.formatted(.byteCount(style: .file))) / \(item.totalBytes.formatted(.byteCount(style: .file)))",
|
|
speed,
|
|
remaining,
|
|
].compactMap(\.self).joined(separator: " · ")
|
|
case .downloading:
|
|
// Ohne bekannte Gesamtgröße gibt es keine Restzeit — das Tempo
|
|
// aber schon, und es ist genau dort die einzige Auskunft darüber,
|
|
// ob überhaupt etwas durchgeht.
|
|
[item.receivedBytes.formatted(.byteCount(style: .file)), speed]
|
|
.compactMap(\.self).joined(separator: " · ")
|
|
case .paused:
|
|
String(localized: "downloads.paused")
|
|
case .queued, .waiting:
|
|
String(localized: "downloads.queued")
|
|
case .failed:
|
|
String(localized: "downloads.failed")
|
|
case .completed:
|
|
item.totalBytes.formatted(.byteCount(style: .file))
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
/// Der Hinweis über der Liste, wenn nichts läuft und nichts kaputt ist.
|
|
private struct HoldNotice: View {
|
|
let hold: TransferHold
|
|
|
|
@Environment(\.flixyAccent) private var accent
|
|
|
|
var body: some View {
|
|
HStack(alignment: .top, spacing: FlixySpacing.regular) {
|
|
Image(systemName: hold == .offline ? "wifi.slash" : "wifi.exclamationmark")
|
|
.font(.title3)
|
|
.foregroundStyle(accent)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(hold == .offline ? "downloads.hold.offline" : "downloads.hold.wifi")
|
|
.font(.flixyCardTitle)
|
|
.foregroundStyle(Color.flixyTextPrimary)
|
|
|
|
Text(hold == .offline
|
|
? "downloads.hold.offline.description"
|
|
: "downloads.hold.wifi.description")
|
|
.font(.flixyCaption)
|
|
.foregroundStyle(Color.flixyTextSecondary)
|
|
}
|
|
}
|
|
.padding(.vertical, FlixySpacing.tight)
|
|
.listRowBackground(Color.flixySurface)
|
|
.accessibilityElement(children: .combine)
|
|
}
|
|
}
|