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)))") } } }