import SwiftUI import HaloDesign import HaloDownloads import HaloPlayback /// 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(\.haloAccent) 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.haloCanvas) .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: .haloTrailing) { AccountToolbarButton() } } } .haloInlineTitle() // 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.haloSurface) .haloRevealsTopChrome() } 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(\.haloAccent) private var accent var body: some View { @Bindable var downloads = downloads Section { HStack { Text("downloads.used") .foregroundStyle(Color.haloTextSecondary) Spacer() Text(downloads.usedBytes.formatted(.byteCount(style: .file))) .foregroundStyle(Color.haloTextPrimary) } 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(\.haloAccent) private var accent var body: some View { VStack(alignment: .leading, spacing: HaloSpacing.snug) { HStack(spacing: HaloSpacing.regular) { VStack(alignment: .leading, spacing: 2) { Text(item.title) .font(.haloCardTitle) .foregroundStyle(Color.haloTextPrimary) .lineLimit(1) if let subtitle = item.subtitle { Text(subtitle) .font(.haloCaption) .foregroundStyle(Color.haloTextMuted) .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(.haloCaption) .foregroundStyle(Color.haloTextMuted) } if let message = item.failureMessage { Text(message) .font(.haloCaption) .foregroundStyle(Color.haloDestructive) .lineLimit(2) } } .padding(.vertical, HaloSpacing.tight) .listRowBackground(Color.haloSurface) .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.haloTextMuted) .accessibilityLabel(Text("downloads.available")) case .queued, .waiting: Image(systemName: "clock") .foregroundStyle(Color.haloTextMuted) .accessibilityLabel(Text("downloads.queued")) } } private var progress: some View { VStack(alignment: .leading, spacing: HaloSpacing.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) } // **Links die Zeile, rechts die Prozente — und beides steht // still.** // // Vorher war es eine einzige linksbündige Zeile, und sie zappelte // bei jeder Aktualisierung: in einer Proportionalschrift ist die // 1 schmal und die 8 breit, also verschob „181,2 → 190,1" alles // dahinter. Zweimal je Sekunde. // // Drei Dinge halten es jetzt ruhig: Ziffern in fester Breite, ein // gemeinsames Mass für beide Zahlen (siehe `unit`), und die // Prozente am rechten Rand — die wachsen nach innen und schieben // nichts. HStack(alignment: .firstTextBaseline, spacing: HaloSpacing.snug) { Text(statusLine) .lineLimit(1) Spacer(minLength: HaloSpacing.snug) if let percentage { Text(percentage) } } .font(.haloCaption) .monospacedDigit() .foregroundStyle(Color.haloTextMuted) } } /// Der Fortschritt in Prozent — `nil`, solange die Gesamtgröße fehlt. /// /// Die Zahl, nach der man eigentlich sieht. „181,2 MB von 890,2 MB" sagt /// dasselbe, aber man muss es ausrechnen. private var percentage: String? { guard item.state != .completed, let anteil = item.progress else { return nil } return anteil.formatted(.percent.precision(.fractionLength(0))) } /// 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 } /// Eine Byte-Zahl mit **fester** Nachkommastelle. /// /// **Der letzte Rest des Zappelns.** `.byteCount` lässt die Null hinter /// dem Komma weg: aus „186,0 MB" wird „186 MB", und die Zeile ist zwei /// Zeichen kürzer. Gemessen an sechs aufeinanderfolgenden Sekunden war das /// die einzige, die noch sprang. /// /// `usage: .asProvided`, damit die Einheit bleibt, die hier gewählt wurde /// — sonst rechnet die Formatierung eigenmächtig um. private func size(_ value: Int64) -> String { let (menge, einheit): (Double, UnitInformationStorage) = switch value { case 1_000_000_000...: (Double(value) / 1_000_000_000, .gigabytes) case 1_000_000...: (Double(value) / 1_000_000, .megabytes) case 1_000...: (Double(value) / 1_000, .kilobytes) default: (Double(value), .bytes) } return Measurement(value: menge, unit: einheit).formatted( .measurement( width: .abbreviated, usage: .asProvided, numberFormatStyle: .number.precision( .fractionLength(einheit == .bytes ? 0 : 1) ) ) ) } /// 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 } return String(localized: "downloads.perSecond \(size(Int64(perSecond)))") } /// 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: // **Jede Zahl in ihrem eigenen Mass.** Beide auf das des Ziels zu // zwingen war der naheliegende Weg gegen den Einheitenwechsel — // und er machte den Anfang unlesbar: bei einem Ziel von 2 GB // stand dort „0,03 GB / 2 GB". Gegen das Zappeln hilft die feste // Ziffernbreite; der Wechsel von MB auf GB kommt einmal je // Download und ist der kleinere Preis. ["\(size(item.receivedBytes)) / \(size(item.totalBytes))", 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. [size(item.receivedBytes), 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(\.haloAccent) private var accent var body: some View { HStack(alignment: .top, spacing: HaloSpacing.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(.haloCardTitle) .foregroundStyle(Color.haloTextPrimary) Text(hold == .offline ? "downloads.hold.offline.description" : "downloads.hold.wifi.description") .font(.haloCaption) .foregroundStyle(Color.haloTextSecondary) } } .padding(.vertical, HaloSpacing.tight) .listRowBackground(Color.haloSurface) .accessibilityElement(children: .combine) } }