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
360 lines
12 KiB
Swift
360 lines
12 KiB
Swift
import SwiftUI
|
|
|
|
// MARK: - DownloadsView
|
|
// Shows active downloads (in-progress), downloaded chapters grouped by book, and storage usage.
|
|
// Purely local — no network calls needed.
|
|
|
|
struct DownloadsView: View {
|
|
@ObservedObject private var downloadService = AudioDownloadService.shared
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
// Completed chapters grouped by slug, sorted alphabetically
|
|
private var groupedDownloads: [(slug: String, keys: [String])] {
|
|
let slugs = downloadService.offlineBookSlugs()
|
|
return slugs.map { slug in
|
|
let keys = downloadService.downloadedChapters
|
|
.filter { $0.hasPrefix("\(slug)::") }
|
|
.sorted { lhs, rhs in
|
|
let lhsChapter = chapterNumber(from: lhs)
|
|
let rhsChapter = chapterNumber(from: rhs)
|
|
return lhsChapter < rhsChapter
|
|
}
|
|
return (slug: slug, keys: keys)
|
|
}
|
|
}
|
|
|
|
private var activeDownloads: [(key: String, progress: DownloadProgress)] {
|
|
downloadService.downloads
|
|
.sorted { $0.key < $1.key }
|
|
.map { (key: $0.key, progress: $0.value) }
|
|
}
|
|
|
|
private var storageFormatted: String {
|
|
ByteCountFormatter.string(
|
|
fromByteCount: downloadService.totalStorageUsed(),
|
|
countStyle: .file
|
|
)
|
|
}
|
|
|
|
private var hasAnyContent: Bool {
|
|
!downloadService.downloadedChapters.isEmpty || !downloadService.downloads.isEmpty
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Group {
|
|
if hasAnyContent {
|
|
contentList
|
|
} else {
|
|
emptyState
|
|
}
|
|
}
|
|
.background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1)))
|
|
.navigationTitle("Downloads")
|
|
.navigationBarTitleDisplayMode(.large)
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
Button("Done") {
|
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
|
dismiss()
|
|
}
|
|
.foregroundStyle(Color.amber)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Content list
|
|
|
|
private var contentList: some View {
|
|
List {
|
|
// Storage info
|
|
storageSection
|
|
|
|
// Active downloads
|
|
if !activeDownloads.isEmpty {
|
|
Section {
|
|
ForEach(activeDownloads, id: \.key) { item in
|
|
ActiveDownloadRow(key: item.key, progress: item.progress)
|
|
}
|
|
} header: {
|
|
Text("Downloading")
|
|
.font(.subheadline.bold())
|
|
.foregroundStyle(Color.amber)
|
|
.textCase(nil)
|
|
}
|
|
}
|
|
|
|
// Completed, grouped by book
|
|
ForEach(groupedDownloads, id: \.slug) { group in
|
|
Section {
|
|
ForEach(group.keys, id: \.self) { key in
|
|
DownloadedChapterRow(key: key)
|
|
}
|
|
} header: {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "book.closed.fill")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
Text(group.slug)
|
|
.font(.subheadline.bold())
|
|
.foregroundStyle(.primary)
|
|
.textCase(nil)
|
|
Spacer()
|
|
Text("\(group.keys.count) ch.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.textCase(nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Delete all
|
|
if !downloadService.downloadedChapters.isEmpty {
|
|
Section {
|
|
Button(role: .destructive) {
|
|
UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
|
|
try? downloadService.deleteAllDownloads()
|
|
} label: {
|
|
HStack {
|
|
Spacer()
|
|
Label("Delete All Downloads", systemImage: "trash.fill")
|
|
.font(.subheadline.bold())
|
|
Spacer()
|
|
}
|
|
}
|
|
.accessibilityLabel("Delete all downloaded audio chapters")
|
|
}
|
|
}
|
|
}
|
|
.scrollContentBackground(.hidden)
|
|
.listStyle(.insetGrouped)
|
|
}
|
|
|
|
// MARK: - Storage section
|
|
|
|
private var storageSection: some View {
|
|
Section {
|
|
HStack(spacing: 12) {
|
|
Image(systemName: "internaldrive.fill")
|
|
.font(.body)
|
|
.foregroundStyle(Color.amber)
|
|
.frame(width: 28)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("Storage Used")
|
|
.font(.subheadline)
|
|
Text(storageFormatted)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Text("\(downloadService.downloadedChapters.count) chapters")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
|
|
// MARK: - Empty state
|
|
|
|
private var emptyState: some View {
|
|
VStack(spacing: 0) {
|
|
Spacer()
|
|
EmptyStateView(
|
|
icon: "arrow.down.circle",
|
|
title: "No Downloads",
|
|
message: "Downloaded audio chapters appear here for offline listening."
|
|
)
|
|
Spacer()
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func chapterNumber(from key: String) -> Int {
|
|
let parts = key.split(separator: "::")
|
|
guard parts.count >= 2, let n = Int(parts[1]) else { return 0 }
|
|
return n
|
|
}
|
|
}
|
|
|
|
// MARK: - ActiveDownloadRow
|
|
|
|
private struct ActiveDownloadRow: View {
|
|
let key: String
|
|
let progress: DownloadProgress
|
|
@ObservedObject private var downloadService = AudioDownloadService.shared
|
|
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
// Icon with status
|
|
ZStack {
|
|
Circle()
|
|
.fill(statusColor.opacity(0.15))
|
|
.frame(width: 36, height: 36)
|
|
Image(systemName: statusIcon)
|
|
.font(.subheadline.bold())
|
|
.foregroundStyle(statusColor)
|
|
.contentTransition(.symbolEffect(.replace.downUp))
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text("Chapter \(progress.chapter)")
|
|
.font(.subheadline.bold())
|
|
.lineLimit(1)
|
|
|
|
HStack(spacing: 4) {
|
|
Text(progress.slug)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
Text("·")
|
|
.font(.caption)
|
|
.foregroundStyle(.tertiary)
|
|
Text(formatVoice(progress.voice))
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Progress or error indicator
|
|
if case .failed(let msg) = progress.status {
|
|
VStack(alignment: .trailing, spacing: 2) {
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.red)
|
|
.symbolEffect(.pulse)
|
|
Text("Failed")
|
|
.font(.caption2)
|
|
.foregroundStyle(.red)
|
|
}
|
|
.accessibilityLabel("Download failed: \(msg)")
|
|
} else {
|
|
VStack(alignment: .trailing, spacing: 4) {
|
|
Text("\(Int(progress.progress * 100))%")
|
|
.font(.caption.monospacedDigit())
|
|
.foregroundStyle(.secondary)
|
|
ProgressView(value: progress.progress)
|
|
.tint(Color.amber)
|
|
.frame(width: 64)
|
|
}
|
|
}
|
|
|
|
// Cancel button (only while downloading)
|
|
if progress.status == .downloading {
|
|
Button {
|
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
|
downloadService.cancelDownload(
|
|
slug: progress.slug,
|
|
chapter: progress.chapter,
|
|
voice: progress.voice
|
|
)
|
|
} label: {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.font(.title3)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.frame(minWidth: 44, minHeight: 44)
|
|
.accessibilityLabel("Cancel download for chapter \(progress.chapter)")
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
|
|
private var statusColor: Color {
|
|
if case .failed = progress.status { return .red }
|
|
return Color.amber
|
|
}
|
|
|
|
private var statusIcon: String {
|
|
if case .failed = progress.status { return "exclamationmark.triangle" }
|
|
return "arrow.down"
|
|
}
|
|
}
|
|
|
|
// MARK: - DownloadedChapterRow
|
|
|
|
private struct DownloadedChapterRow: View {
|
|
let key: String
|
|
@ObservedObject private var downloadService = AudioDownloadService.shared
|
|
|
|
// Parse "slug::chapterNumber::voice" — v2 keys use "::" separator
|
|
private var components: (slug: String, chapter: Int, voice: String) {
|
|
let parts = key.split(separator: "::")
|
|
guard parts.count == 3, let chapter = Int(parts[1]) else {
|
|
return ("", 0, "")
|
|
}
|
|
return (String(parts[0]), chapter, String(parts[2]))
|
|
}
|
|
|
|
var body: some View {
|
|
let c = components
|
|
HStack(spacing: 12) {
|
|
// Checkmark badge
|
|
ZStack {
|
|
Circle()
|
|
.fill(Color.green.opacity(0.15))
|
|
.frame(width: 36, height: 36)
|
|
Image(systemName: "checkmark")
|
|
.font(.caption.bold())
|
|
.foregroundStyle(.green)
|
|
}
|
|
.accessibilityHidden(true)
|
|
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text("Chapter \(c.chapter)")
|
|
.font(.subheadline.bold())
|
|
.lineLimit(1)
|
|
|
|
Text(formatVoice(c.voice))
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Image(systemName: "waveform")
|
|
.font(.caption)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
.padding(.vertical, 4)
|
|
.accessibilityLabel("Chapter \(c.chapter), voice \(formatVoice(c.voice)), downloaded")
|
|
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
|
Button(role: .destructive) {
|
|
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
|
try? downloadService.deleteDownload(
|
|
slug: c.slug,
|
|
chapter: c.chapter,
|
|
voice: c.voice
|
|
)
|
|
} label: {
|
|
Label("Delete", systemImage: "trash")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Shared voice formatter
|
|
|
|
private func formatVoice(_ voice: String) -> String {
|
|
let parts = voice.split(separator: "_")
|
|
guard parts.count == 2 else { return voice }
|
|
let prefix = String(parts[0])
|
|
let name = String(parts[1]).capitalized
|
|
let gender = prefix.hasSuffix("f") ? "F" : prefix.hasSuffix("m") ? "M" : ""
|
|
let accent = prefix.hasPrefix("af") || prefix.hasPrefix("am") ? "US"
|
|
: prefix.hasPrefix("bf") || prefix.hasPrefix("bm") ? "UK"
|
|
: ""
|
|
if !gender.isEmpty && !accent.isEmpty { return "\(name) (\(accent) \(gender))" }
|
|
if !gender.isEmpty { return "\(name) (\(gender))" }
|
|
return name
|
|
}
|