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 @State private var itemPendingRemoval: DownloadItem? 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")) .navigationBarTitleDisplayMode(.inline) .confirmationDialog( Text("downloads.removeConfirm"), isPresented: Binding( get: { itemPendingRemoval != nil }, set: { if !$0 { itemPendingRemoval = nil } } ), titleVisibility: .visible ) { Button("action.delete", role: .destructive) { if let item = itemPendingRemoval { Task { await downloads.cancel(item) } } itemPendingRemoval = nil } Button("action.cancel", role: .cancel) { itemPendingRemoval = nil } } } /// 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: { itemPendingRemoval = 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: { itemPendingRemoval = item }) .contentShape(.rect) .onTapGesture { play(item) } } } } } .scrollContentBackground(.hidden) .listRowBackground(Color.flixySurface) } 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.request = 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: Button { Task { await downloads.resume(item) } } label: { Image(systemName: "arrow.clockwise.circle").font(.title3) } .buttonStyle(.plain) .foregroundStyle(accent) .accessibilityLabel(Text("downloads.resume")) 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) } } 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)))" case .downloading: item.receivedBytes.formatted(.byteCount(style: .file)) 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) } }