All checks were successful
CI / Scraper / Lint (push) Successful in 10s
CI / Scraper / Test (push) Successful in 14s
Release / Scraper / Test (push) Successful in 18s
CI / Scraper / Lint (pull_request) Successful in 18s
Release / UI / Build (push) Successful in 23s
CI / Scraper / Test (pull_request) Successful in 15s
CI / UI / Build (pull_request) Successful in 32s
Release / Scraper / Docker (push) Successful in 55s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
CI / Scraper / Docker Push (push) Successful in 1m5s
Release / UI / Docker (push) Successful in 1m12s
iOS CI / Build (push) Successful in 4m18s
iOS CI / Build (pull_request) Successful in 4m25s
iOS CI / Test (push) Successful in 8m11s
iOS CI / Test (pull_request) Successful in 8m21s
341 lines
12 KiB
Swift
341 lines
12 KiB
Swift
import SwiftUI
|
|
|
|
// MARK: - Download Queue Toolbar Button
|
|
// Compact toolbar button that shows active download status and opens queue management sheet.
|
|
// Shows:
|
|
// - Download icon with badge count when downloads are active
|
|
// - Progress ring around icon
|
|
// - Taps opens DownloadQueueSheet for management
|
|
|
|
struct DownloadQueueButton: View {
|
|
@StateObject private var downloadService = AudioDownloadService.shared
|
|
@State private var showQueue = false
|
|
|
|
private var activeDownloads: [DownloadProgress] {
|
|
downloadService.downloads.values.filter { $0.status == .downloading }
|
|
}
|
|
|
|
private var hasActiveDownloads: Bool {
|
|
!activeDownloads.isEmpty
|
|
}
|
|
|
|
private var averageProgress: Double {
|
|
guard !activeDownloads.isEmpty else { return 0 }
|
|
let total = activeDownloads.reduce(0.0) { $0 + $1.progress }
|
|
return total / Double(activeDownloads.count)
|
|
}
|
|
|
|
var body: some View {
|
|
Button {
|
|
showQueue = true
|
|
} label: {
|
|
ZStack {
|
|
// Progress ring (only shown when downloading)
|
|
if hasActiveDownloads {
|
|
Circle()
|
|
.stroke(Color.amber.opacity(0.3), lineWidth: 2)
|
|
.frame(width: 30, height: 30)
|
|
|
|
Circle()
|
|
.trim(from: 0, to: averageProgress)
|
|
.stroke(Color.amber, style: StrokeStyle(lineWidth: 2, lineCap: .round))
|
|
.frame(width: 30, height: 30)
|
|
.rotationEffect(.degrees(-90))
|
|
.animation(.easeInOut(duration: 0.3), value: averageProgress)
|
|
}
|
|
|
|
// Download icon
|
|
Image(systemName: hasActiveDownloads ? "arrow.down.circle.fill" : "arrow.down.circle")
|
|
.font(.system(size: 22))
|
|
.foregroundStyle(hasActiveDownloads ? .amber : .secondary)
|
|
.symbolRenderingMode(.hierarchical)
|
|
|
|
// Badge count (top-right corner)
|
|
if activeDownloads.count > 0 {
|
|
VStack {
|
|
HStack {
|
|
Spacer()
|
|
Text("\(activeDownloads.count)")
|
|
.font(.system(size: 10, weight: .bold))
|
|
.foregroundStyle(.white)
|
|
.padding(3)
|
|
.frame(minWidth: 16)
|
|
.background(Circle().fill(Color.red))
|
|
.offset(x: 6, y: -6)
|
|
}
|
|
Spacer()
|
|
}
|
|
.frame(width: 30, height: 30)
|
|
}
|
|
}
|
|
}
|
|
.opacity(hasActiveDownloads || downloadService.downloadedChapters.count > 0 ? 1 : 0.6)
|
|
.sheet(isPresented: $showQueue) {
|
|
DownloadQueueSheet()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Download Queue Management Sheet
|
|
// Bottom sheet showing active downloads and quick management options
|
|
|
|
struct DownloadQueueSheet: View {
|
|
@StateObject private var downloadService = AudioDownloadService.shared
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
private var activeDownloads: [(key: String, value: DownloadProgress)] {
|
|
downloadService.downloads
|
|
.filter { $0.value.status == .downloading }
|
|
.sorted { $0.key < $1.key }
|
|
}
|
|
|
|
private var failedDownloads: [(key: String, value: DownloadProgress)] {
|
|
downloadService.downloads.compactMap { key, value in
|
|
if case .failed = value.status {
|
|
return (key, value)
|
|
}
|
|
return nil
|
|
}
|
|
.sorted { $0.key < $1.key }
|
|
}
|
|
|
|
private var totalDownloaded: Int {
|
|
downloadService.downloadedChapters.count
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Group {
|
|
if activeDownloads.isEmpty && failedDownloads.isEmpty && totalDownloaded == 0 {
|
|
emptyState
|
|
} else {
|
|
List {
|
|
// Active downloads section
|
|
if !activeDownloads.isEmpty {
|
|
Section {
|
|
ForEach(activeDownloads, id: \.key) { key, progress in
|
|
ActiveDownloadRow(progress: progress)
|
|
}
|
|
} header: {
|
|
HStack {
|
|
Text("Downloading")
|
|
Spacer()
|
|
Text("\(activeDownloads.count)")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Failed downloads section
|
|
if !failedDownloads.isEmpty {
|
|
Section("Failed") {
|
|
ForEach(failedDownloads, id: \.key) { key, progress in
|
|
FailedDownloadRow(progress: progress, key: key)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Quick stats section
|
|
Section {
|
|
NavigationLink {
|
|
DownloadsView()
|
|
} label: {
|
|
HStack {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundStyle(.green)
|
|
Text("Downloaded Chapters")
|
|
Spacer()
|
|
Text("\(totalDownloaded)")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
HStack {
|
|
Image(systemName: "internaldrive")
|
|
.foregroundStyle(.amber)
|
|
Text("Storage Used")
|
|
Spacer()
|
|
Text(storageUsedFormatted)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
// Cancel all option (only show if there are active downloads)
|
|
if !activeDownloads.isEmpty {
|
|
Section {
|
|
Button(role: .destructive) {
|
|
activeDownloads.forEach { key, progress in
|
|
downloadService.cancelDownload(
|
|
slug: progress.slug,
|
|
chapter: progress.chapter,
|
|
voice: progress.voice
|
|
)
|
|
}
|
|
} label: {
|
|
HStack {
|
|
Spacer()
|
|
Text("Cancel All Downloads")
|
|
.font(.subheadline.bold())
|
|
Spacer()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Download Queue")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
Button("Done") {
|
|
dismiss()
|
|
}
|
|
.foregroundStyle(.amber)
|
|
}
|
|
}
|
|
}
|
|
.presentationDetents([.medium, .large])
|
|
.presentationDragIndicator(.visible)
|
|
}
|
|
|
|
// MARK: - Empty State
|
|
|
|
@ViewBuilder
|
|
private var emptyState: some View {
|
|
VStack(spacing: 16) {
|
|
Image(systemName: "arrow.down.circle")
|
|
.font(.system(size: 56))
|
|
.foregroundStyle(.secondary.opacity(0.5))
|
|
Text("No Active Downloads")
|
|
.font(.title2.bold())
|
|
.foregroundStyle(.primary)
|
|
Text("Audio chapters you download will appear here")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
.padding(.horizontal, 40)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private var storageUsedFormatted: String {
|
|
let bytes = downloadService.getTotalStorageUsed()
|
|
return ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file)
|
|
}
|
|
}
|
|
|
|
// MARK: - Active Download Row
|
|
|
|
private struct ActiveDownloadRow: View {
|
|
let progress: DownloadProgress
|
|
@StateObject private var downloadService = AudioDownloadService.shared
|
|
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
// Book/Chapter info
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(formatSlug(progress.slug))
|
|
.font(.subheadline.bold())
|
|
.lineLimit(1)
|
|
Text("Chapter \(progress.chapter)")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Progress indicator
|
|
VStack(alignment: .trailing, spacing: 4) {
|
|
Text("\(Int(progress.progress * 100))%")
|
|
.font(.caption.bold())
|
|
.foregroundStyle(.amber)
|
|
.monospacedDigit()
|
|
|
|
ProgressView(value: progress.progress)
|
|
.frame(width: 60)
|
|
.tint(.amber)
|
|
}
|
|
|
|
// Cancel button
|
|
Button {
|
|
downloadService.cancelDownload(
|
|
slug: progress.slug,
|
|
chapter: progress.chapter,
|
|
voice: progress.voice
|
|
)
|
|
} label: {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.font(.system(size: 20))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
|
|
private func formatSlug(_ slug: String) -> String {
|
|
// Convert slug to readable title (e.g., "my-book-title" -> "My Book Title")
|
|
slug.split(separator: "-")
|
|
.map { $0.capitalized }
|
|
.joined(separator: " ")
|
|
}
|
|
}
|
|
|
|
// MARK: - Failed Download Row
|
|
|
|
private struct FailedDownloadRow: View {
|
|
let progress: DownloadProgress
|
|
let key: String
|
|
@StateObject private var downloadService = AudioDownloadService.shared
|
|
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.foregroundStyle(.red)
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(formatSlug(progress.slug))
|
|
.font(.subheadline.bold())
|
|
.lineLimit(1)
|
|
Text("Chapter \(progress.chapter)")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Retry button
|
|
Button {
|
|
Task {
|
|
// Remove failed status
|
|
downloadService.downloads.removeValue(forKey: key)
|
|
// Retry download
|
|
try? await downloadService.download(
|
|
slug: progress.slug,
|
|
chapter: progress.chapter,
|
|
voice: progress.voice
|
|
)
|
|
}
|
|
} label: {
|
|
Text("Retry")
|
|
.font(.caption.bold())
|
|
.foregroundStyle(.amber)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
.background(Color.amber.opacity(0.15), in: Capsule())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
|
|
private func formatSlug(_ slug: String) -> String {
|
|
slug.split(separator: "-")
|
|
.map { $0.capitalized }
|
|
.joined(separator: " ")
|
|
}
|
|
}
|