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 @Environment(FlixySession.self) private var session @Environment(DownloadCoordinator.self) private var downloads @Environment(\.flixyAccent) private var accent @State private var refusal: DownloadRefusal? @State private var showsQualityChoice = false private var existing: DownloadItem? { downloads.items.first { $0.ratingKey == item.id } } 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)) .confirmationDialog( Text("downloads.quality.title"), isPresented: $showsQualityChoice, titleVisibility: .visible ) { ForEach(availableQualities) { quality in Button(qualityLabel(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: "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 { // 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. showsQualityChoice = true 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(quality: DownloadQuality) { let service = DownloadService(backend: session.backend, coordinator: downloads) do { _ = try service.download(item, mediaIndex: mediaIndex, quality: quality) } catch { refusal = error } } private var currentMedia: MediaVersion? { item.versions.indices.contains(mediaIndex) ? item.versions[mediaIndex] : item.preferredVersion } /// Nur Stufen, die unter der Auflösung der Datei liegen. /// /// Und nur, wenn der Server verkleinerte Fassungen überhaupt herausgibt. /// Jellyfin nennt die Adresse dafür erst nach einer eigenen Anfrage — die /// Wahl anzubieten und dann abzulehnen wäre eine leere Zusage. private var availableQualities: [DownloadQuality] { let all = DownloadQuality.available(forSourceHeight: currentMedia?.pixelHeight) guard canDownloadCompressed else { return [.original] } return all } private var canDownloadCompressed: Bool { session.backend?.compressedDownloadURL( itemID: item.id, versionIndex: mediaIndex, maximumBitrate: 8_000, resolution: nil ) != nil } /// Die Beschriftung samt Auflösung und geschätzter Größe. /// /// Beim Original steht dabei, was es überhaupt ist: „Original · 720p". /// Ohne diese Angabe ist die Wahl blind — man weiß nicht, ob man gerade /// herunterrechnet oder nicht. private func qualityLabel(_ quality: DownloadQuality) -> String { let originalBytes = currentMedia?.files.first?.size ?? 0 let bytes = quality.estimatedBytes( durationMilliseconds: item.duration, originalBytes: originalBytes ) var name = String(localized: quality.localizedName) if quality == .original, let resolution = currentMedia?.resolutionLabel { name += " · \(resolution)" } guard bytes > 0 else { return name } let size = bytes.formatted(.byteCount(style: .file)) return quality == .original ? "\(name) · \(size)" : String(localized: "downloads.quality.approx \(name) \(size)") } } 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(\.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) 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)))") } } }