import SwiftUI import FlixyDesign import FlixyDownloads import MediaKit 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: MediaItem var mediaIndex: Int = 0 /// Ob die Dateiangaben erst noch geholt werden müssen. /// /// **Für Zeilen, die nur eine Zusammenfassung tragen.** Die Folgenliste /// einer Staffel kommt ohne Fassungen und ohne Dateigrößen — mit so einem /// Titel wäre die Qualitätsliste leer und die erwartete Größe null, was /// die Prüfung auf freien Speicherplatz stillschweigend überspringt. /// `DownloadService.downloadAll` holt jede Folge aus genau diesem Grund /// einzeln nach. /// /// Geholt wird erst beim ersten Tipp: zwanzig Zeilen beim Erscheinen /// nachzuladen wären zwanzig Anfragen für einen Knopf, den man meistens /// nicht drückt. var loadsDetails: Bool = false @Environment(FlixySession.self) private var session @Environment(DownloadCoordinator.self) private var downloads @Environment(LanguagePreferences.self) private var languages @Environment(\.flixyAccent) private var accent @State private var refusal: DownloadRefusal? @State private var showsQualityChoice = false /// Der nachgeholte Titel — gilt ab dann statt `item`. @State private var detailed: MediaItem? @State private var isLoadingDetails = false private var existing: DownloadItem? { downloads.items.first { $0.ratingKey == item.id } } /// Der Titel, aus dem Fassungen und Größen gelesen werden. /// /// Die Kennung ist in beiden dieselbe — nur die Dateiangaben fehlen dem /// einen. private var source: MediaItem { detailed ?? item } var body: some View { Button { act() } label: { Group { if isLoadingDetails { ProgressView().controlSize(.small) } else { Label(label, systemImage: symbol) .labelStyle(.iconOnly) .font(.flixyCardTitle) } } // Feste Größe, damit der Knopf beim Nachladen nicht springt: der // Kreisel ist schmaler als das Symbol, und eine Zeile, die während // des Wartens die Breite wechselt, sieht aus wie ein Fehler. .frame(width: 22, height: 22) .padding(FlixySpacing.snug) } .disabled(isLoadingDetails) .buttonStyle(.bordered) .tint(existing?.state == .completed ? accent : Color.flixyTextSecondary) .accessibilityLabel(Text(label)) .confirmationDialog( Text("downloads.quality.title"), isPresented: $showsQualityChoice, titleVisibility: .visible ) { ForEach(choice.available) { quality in Button(choice.label(for: 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: continues ? "play.circle" : "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: continues ? "downloads.resume" : "action.retry" case nil: "downloads.download" } } /// Ob ein Tipp dort weitermacht, wo es aufgehört hat. /// /// Ein gescheiterter Download hat seinen Wiederaufnahme-Datensatz nur, /// wenn ihn das Netz unterbrochen hat — hat der Server abgelehnt, fängt /// es von vorn an, und dann heißt der Knopf auch so. private var continues: Bool { existing?.resumeData != nil } 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. guard loadsDetails, detailed == nil else { showsQualityChoice = true return } Task { await loadDetailsThenAsk() } 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) } } } /// Holt die Dateiangaben nach und fragt dann nach der Qualität. /// /// Scheitert das Nachholen, wird trotzdem gefragt — dann steht nur /// „Original" zur Wahl, und die Ablehnung kommt mit einer Begründung aus /// `DownloadService`. Das ist besser als ein Knopf, der nach einem Tipp /// kommentarlos nichts tut. private func loadDetailsThenAsk() async { isLoadingDetails = true detailed = try? await session.backend?.item(id: item.id) isLoadingDetails = false showsQualityChoice = true } private func enqueue(quality: DownloadQuality) { let service = DownloadService( backend: session.backend, coordinator: downloads, languages: languages ) do { _ = try service.download(source, mediaIndex: mediaIndex, quality: quality) } catch { refusal = error } } /// Was zur Wahl steht — siehe `DownloadQualityChoice`. private var choice: DownloadQualityChoice { DownloadQualityChoice(item: source, mediaIndex: mediaIndex, backend: session.backend) } } 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: MediaItem @Environment(FlixySession.self) private var session @Environment(DownloadCoordinator.self) private var downloads @Environment(LanguagePreferences.self) private var languages @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( backend: session.backend, coordinator: downloads, languages: languages ) 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)))") } } }