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
1827 lines
76 KiB
Swift
1827 lines
76 KiB
Swift
import SwiftUI
|
||
import AVFoundation
|
||
import AVKit // AVRoutePickerView
|
||
|
||
// MARK: - VoiceSelectionViewModel
|
||
// Minimal inline VM for the FullPlayerView voice panel and DownloadManagementSheet.
|
||
// New type → @Observable (iOS 17+).
|
||
|
||
@Observable @MainActor
|
||
final class VoiceSelectionViewModel {
|
||
var voices: [String] = []
|
||
var isLoading = false
|
||
var error: String?
|
||
var playingVoice: String?
|
||
|
||
private var audioPlayer: AVPlayer?
|
||
private var endObserverToken: NSObjectProtocol?
|
||
|
||
func voiceLabel(_ voice: String) -> String {
|
||
let parts = voice.split(separator: "_")
|
||
guard parts.count >= 2 else { return voice }
|
||
let prefix = String(parts[0])
|
||
let name = parts.dropFirst().map { $0.capitalized }.joined(separator: " ")
|
||
var info = ""
|
||
switch prefix {
|
||
case "af": info = "US F"
|
||
case "am": info = "US M"
|
||
case "bf": info = "UK F"
|
||
case "bm": info = "UK M"
|
||
default: info = prefix.uppercased()
|
||
}
|
||
return "\(name) (\(info))"
|
||
}
|
||
|
||
func voiceId(_ voice: String) -> String { voice }
|
||
|
||
func loadVoices() async {
|
||
isLoading = true
|
||
error = nil
|
||
defer { isLoading = false }
|
||
do {
|
||
let fetched = try await APIClient.shared.voices()
|
||
voices = fetched.isEmpty ? fallbackVoices() : fetched
|
||
} catch {
|
||
self.error = error.localizedDescription
|
||
voices = fallbackVoices()
|
||
}
|
||
}
|
||
|
||
func playSample(_ voice: String) async {
|
||
if playingVoice == voice { stopSample(); return }
|
||
stopSample()
|
||
playingVoice = voice
|
||
do {
|
||
let url = try await APIClient.shared.presignVoiceSample(voice: voice)
|
||
guard let parsed = URL(string: url) else { playingVoice = nil; return }
|
||
let item = AVPlayerItem(url: parsed)
|
||
audioPlayer = AVPlayer(playerItem: item)
|
||
endObserverToken = NotificationCenter.default.addObserver(
|
||
forName: .AVPlayerItemDidPlayToEndTime, object: item, queue: .main
|
||
) { [weak self] _ in
|
||
Task { @MainActor [weak self] in self?.stopSample() }
|
||
}
|
||
audioPlayer?.play()
|
||
} catch {
|
||
playingVoice = nil
|
||
}
|
||
}
|
||
|
||
func stopSample() {
|
||
audioPlayer?.pause()
|
||
audioPlayer = nil
|
||
if let token = endObserverToken {
|
||
NotificationCenter.default.removeObserver(token)
|
||
endObserverToken = nil
|
||
}
|
||
playingVoice = nil
|
||
}
|
||
|
||
private func fallbackVoices() -> [String] {
|
||
["af_bella", "af_sarah", "af_nicole",
|
||
"am_adam", "am_michael",
|
||
"bf_emma", "bf_isabella",
|
||
"bm_george", "bm_lewis", "af_sky"]
|
||
}
|
||
}
|
||
|
||
// MARK: - MiniPlayerBar
|
||
// Spotify-style bar fixed above the tab bar.
|
||
// Swipe up → full player. Swipe down → stop.
|
||
|
||
struct MiniPlayerBar: View {
|
||
@Binding var showFullPlayer: Bool
|
||
@EnvironmentObject var audioPlayer: AudioPlayerService
|
||
@EnvironmentObject var downloadService: AudioDownloadService
|
||
|
||
@State private var dragOffset: CGFloat = 0
|
||
|
||
private var isCurrentChapterDownloaded: Bool {
|
||
downloadService.isDownloaded(
|
||
slug: audioPlayer.slug,
|
||
chapter: audioPlayer.chapter,
|
||
voice: audioPlayer.voice
|
||
)
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
// Amber progress strip
|
||
MiniBarProgress(progress: audioPlayer.progress)
|
||
|
||
HStack(spacing: 12) {
|
||
// Cover art
|
||
Button { showFullPlayer = true } label: {
|
||
AsyncCoverImage(url: audioPlayer.coverURL)
|
||
.frame(width: 44, height: 44)
|
||
.clipShape(RoundedRectangle(cornerRadius: 9, style: .continuous))
|
||
.shadow(color: .black.opacity(0.18), radius: 6, y: 2)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel("Open full player")
|
||
|
||
// Track info
|
||
Button { showFullPlayer = true } label: {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(chapterLabel)
|
||
.font(.subheadline.weight(.semibold))
|
||
.foregroundStyle(.primary)
|
||
.lineLimit(1)
|
||
HStack(spacing: 4) {
|
||
Text(audioPlayer.bookTitle)
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
.lineLimit(1)
|
||
if isCurrentChapterDownloaded {
|
||
Image(systemName: "checkmark.circle.fill")
|
||
.font(.system(size: 9))
|
||
.foregroundStyle(.green)
|
||
}
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
.buttonStyle(.plain)
|
||
|
||
// Prev chapter
|
||
Button {
|
||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||
if let prev = audioPlayer.prevChapter {
|
||
NotificationCenter.default.post(
|
||
name: .skipToPrevChapter, object: nil,
|
||
userInfo: ["prev": prev]
|
||
)
|
||
}
|
||
} label: {
|
||
Image(systemName: "backward.end.fill")
|
||
.font(.system(size: 19, weight: .semibold))
|
||
.foregroundStyle(.primary)
|
||
.frame(width: 36, height: 44)
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(audioPlayer.prevChapter == nil)
|
||
.opacity(audioPlayer.prevChapter == nil ? 0.3 : 1)
|
||
.accessibilityLabel("Previous chapter")
|
||
|
||
// Play / Pause — isolated observer
|
||
MiniBarPlayPause(progress: audioPlayer.progress) {
|
||
audioPlayer.togglePlayPause()
|
||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||
}
|
||
.disabled(audioPlayer.status == .generating)
|
||
|
||
// Next chapter
|
||
Button {
|
||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||
if let next = audioPlayer.nextChapter {
|
||
NotificationCenter.default.post(
|
||
name: .skipToNextChapter, object: nil,
|
||
userInfo: ["next": next]
|
||
)
|
||
}
|
||
} label: {
|
||
Image(systemName: "forward.end.fill")
|
||
.font(.system(size: 19, weight: .semibold))
|
||
.foregroundStyle(.primary)
|
||
.frame(width: 36, height: 44)
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(audioPlayer.nextChapter == nil)
|
||
.opacity(audioPlayer.nextChapter == nil ? 0.3 : 1)
|
||
.accessibilityLabel("Next chapter")
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.padding(.vertical, 10)
|
||
}
|
||
.background(.regularMaterial)
|
||
.offset(y: dragOffset)
|
||
.opacity(dragOffset > 0 ? max(0.3, 1 - dragOffset / 200) : 1)
|
||
.gesture(
|
||
DragGesture(minimumDistance: 8, coordinateSpace: .local)
|
||
.onChanged { value in
|
||
let dy = value.translation.height
|
||
dragOffset = dy < 0 ? dy * 0.25 : dy * 0.7
|
||
}
|
||
.onEnded { value in
|
||
let dy = value.translation.height
|
||
let velocity = value.predictedEndTranslation.height - value.translation.height
|
||
if dy < -30 || velocity < -150 {
|
||
withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) { dragOffset = 0 }
|
||
showFullPlayer = true
|
||
} else if dy > 60 || velocity > 200 {
|
||
withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { dragOffset = 200 }
|
||
Task { @MainActor in
|
||
try? await Task.sleep(nanoseconds: 150_000_000)
|
||
audioPlayer.stop()
|
||
}
|
||
} else {
|
||
withAnimation(.spring(response: 0.3, dampingFraction: 0.75)) { dragOffset = 0 }
|
||
}
|
||
}
|
||
)
|
||
}
|
||
|
||
private var chapterLabel: String {
|
||
let raw = audioPlayer.chapterTitle.isEmpty
|
||
? "Chapter \(audioPlayer.chapter)"
|
||
: audioPlayer.chapterTitle
|
||
return raw.strippingTrailingDate()
|
||
}
|
||
}
|
||
|
||
// MARK: - Isolated progress strip
|
||
|
||
private struct MiniBarProgress: View {
|
||
@ObservedObject var progress: PlaybackProgress
|
||
|
||
var body: some View {
|
||
GeometryReader { geo in
|
||
let fraction = progress.duration > 0
|
||
? CGFloat(progress.currentTime / progress.duration)
|
||
: 0
|
||
Rectangle()
|
||
.fill(Color.amber)
|
||
.frame(width: geo.size.width * max(0, min(1, fraction)), height: 2)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
.frame(height: 2)
|
||
}
|
||
}
|
||
|
||
// MARK: - Isolated play/pause for mini bar
|
||
|
||
private struct MiniBarPlayPause: View {
|
||
@ObservedObject var progress: PlaybackProgress
|
||
let onToggle: () -> Void
|
||
|
||
var body: some View {
|
||
Button(action: onToggle) {
|
||
Image(systemName: progress.isPlaying ? "pause.fill" : "play.fill")
|
||
.font(.system(size: 21, weight: .semibold))
|
||
.foregroundStyle(.primary)
|
||
.frame(width: 36, height: 44)
|
||
.contentShape(Rectangle())
|
||
.contentTransition(.symbolEffect(.replace.downUp))
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel(progress.isPlaying ? "Pause" : "Play")
|
||
}
|
||
}
|
||
|
||
// MARK: - FullPlayerView
|
||
|
||
struct FullPlayerView: View {
|
||
@EnvironmentObject var audioPlayer: AudioPlayerService
|
||
@EnvironmentObject var downloadService: AudioDownloadService
|
||
@EnvironmentObject var authStore: AuthStore
|
||
var onDismiss: () -> Void = {}
|
||
|
||
@State private var showingChaptersList = false
|
||
@State private var showingSleepTimer = false
|
||
@State private var showingVoiceSelector = false
|
||
@State private var voiceVM = VoiceSelectionViewModel()
|
||
@State private var coverAppeared = false
|
||
|
||
private var isCurrentChapterDownloaded: Bool {
|
||
downloadService.isDownloaded(
|
||
slug: audioPlayer.slug,
|
||
chapter: audioPlayer.chapter,
|
||
voice: audioPlayer.voice
|
||
)
|
||
}
|
||
|
||
private var currentDownloadProgress: DownloadProgress? {
|
||
let key = downloadService.makeKey(
|
||
slug: audioPlayer.slug,
|
||
chapter: audioPlayer.chapter,
|
||
voice: audioPlayer.voice
|
||
)
|
||
return downloadService.downloads[key]
|
||
}
|
||
|
||
var body: some View {
|
||
GeometryReader { geo in
|
||
ZStack {
|
||
// Blurred cover background
|
||
AsyncCoverImage(url: audioPlayer.coverURL, isBackground: true)
|
||
.frame(width: geo.size.width, height: geo.size.height)
|
||
.clipped()
|
||
.blur(radius: 55, opaque: true)
|
||
.overlay(Color.black.opacity(0.55))
|
||
.ignoresSafeArea()
|
||
.id(audioPlayer.coverURL)
|
||
|
||
VStack(spacing: 0) {
|
||
// Drag handle
|
||
Capsule()
|
||
.fill(Color.white.opacity(0.25))
|
||
.frame(width: 36, height: 4)
|
||
.padding(.top, 14)
|
||
|
||
// Cover art
|
||
let coverSize = min(geo.size.width - 56, geo.size.height * 0.42)
|
||
ZStack {
|
||
AsyncCoverImage(url: audioPlayer.coverURL)
|
||
.frame(width: coverSize, height: coverSize)
|
||
.clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
|
||
.shadow(color: .black.opacity(0.55), radius: 36, y: 18)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
||
.fill(Color.black.opacity(audioPlayer.status == .generating ? 0.5 : 0))
|
||
.animation(.easeInOut(duration: 0.3), value: audioPlayer.status == .generating)
|
||
)
|
||
.scaleEffect(audioPlayer.progress.isPlaying && coverAppeared ? 1.02 : 0.97)
|
||
.animation(.spring(response: 0.45, dampingFraction: 0.7), value: audioPlayer.progress.isPlaying)
|
||
|
||
// Generating overlay
|
||
if audioPlayer.status == .generating {
|
||
VStack(spacing: 10) {
|
||
ProgressView()
|
||
.tint(.white)
|
||
.scaleEffect(1.4)
|
||
Text("Generating audio…")
|
||
.font(.caption.weight(.medium))
|
||
.foregroundStyle(.white.opacity(0.8))
|
||
}
|
||
.transition(.opacity)
|
||
}
|
||
|
||
// Voice watermark
|
||
VStack {
|
||
Spacer()
|
||
HStack {
|
||
Text(voiceName)
|
||
.font(.custom("Snell Roundhand", size: 17))
|
||
.foregroundStyle(.white.opacity(0.5))
|
||
.shadow(color: .black.opacity(0.5), radius: 2)
|
||
.padding(12)
|
||
Spacer()
|
||
}
|
||
}
|
||
.frame(width: coverSize, height: coverSize)
|
||
}
|
||
.frame(width: coverSize, height: coverSize)
|
||
.padding(.top, 18)
|
||
.onAppear {
|
||
withAnimation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.1)) {
|
||
coverAppeared = true
|
||
}
|
||
}
|
||
.onChange(of: audioPlayer.slug) { _, _ in
|
||
coverAppeared = false
|
||
withAnimation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.05)) {
|
||
coverAppeared = true
|
||
}
|
||
}
|
||
|
||
// Title block
|
||
HStack(alignment: .center, spacing: 12) {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text((audioPlayer.chapterTitle.isEmpty
|
||
? "Chapter \(audioPlayer.chapter)"
|
||
: audioPlayer.chapterTitle).strippingTrailingDate())
|
||
.font(.title3.weight(.bold))
|
||
.foregroundStyle(.white)
|
||
.lineLimit(2)
|
||
Text(audioPlayer.bookTitle)
|
||
.font(.subheadline)
|
||
.foregroundStyle(.white.opacity(0.55))
|
||
.lineLimit(1)
|
||
|
||
HStack(spacing: 8) {
|
||
if !audioPlayer.chapters.isEmpty {
|
||
Text(chapterPositionText)
|
||
.font(.caption2.monospacedDigit())
|
||
.foregroundStyle(.white.opacity(0.3))
|
||
}
|
||
if let p = currentDownloadProgress {
|
||
Label("\(Int(p.progress * 100))%", systemImage: "arrow.down.circle")
|
||
.font(.caption2)
|
||
.foregroundStyle(.blue)
|
||
} else if isCurrentChapterDownloaded {
|
||
Label("Offline", systemImage: "checkmark.circle.fill")
|
||
.font(.caption2)
|
||
.foregroundStyle(.green)
|
||
}
|
||
}
|
||
.padding(.top, 1)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
|
||
// Quick download
|
||
if !isCurrentChapterDownloaded && currentDownloadProgress == nil {
|
||
Button {
|
||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||
Task {
|
||
try? await downloadService.download(
|
||
slug: audioPlayer.slug,
|
||
chapter: audioPlayer.chapter,
|
||
voice: audioPlayer.voice
|
||
)
|
||
}
|
||
} label: {
|
||
Image(systemName: "arrow.down.circle")
|
||
.font(.system(size: 24))
|
||
.foregroundStyle(.white.opacity(0.65))
|
||
.frame(minWidth: 44, minHeight: 44)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel("Download chapter")
|
||
}
|
||
|
||
// Auto-next toggle
|
||
Button {
|
||
audioPlayer.autoNext.toggle()
|
||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||
} label: {
|
||
Image(systemName: audioPlayer.autoNext ? "infinity.circle.fill" : "infinity.circle")
|
||
.font(.system(size: 28))
|
||
.foregroundStyle(audioPlayer.autoNext ? Color.amber : .white.opacity(0.4))
|
||
.contentTransition(.symbolEffect(.replace))
|
||
.frame(minWidth: 44, minHeight: 44)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel(audioPlayer.autoNext ? "Auto-next on" : "Auto-next off")
|
||
}
|
||
.padding(.horizontal, 28)
|
||
.padding(.top, 22)
|
||
|
||
// Seek bar (isolated)
|
||
PlayerProgressSection(
|
||
progress: audioPlayer.progress,
|
||
onSeek: { audioPlayer.seek(to: $0) }
|
||
)
|
||
.padding(.top, 18)
|
||
.opacity(audioPlayer.status == .generating ? 0.3 : 1)
|
||
.allowsHitTesting(audioPlayer.status != .generating)
|
||
|
||
// Transport row
|
||
HStack(spacing: 0) {
|
||
PlayerSecondaryButton(systemName: "gobackward.15", size: 24,
|
||
disabled: audioPlayer.status == .generating) {
|
||
audioPlayer.skip(by: -15)
|
||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||
}
|
||
|
||
PlayerChapterSkipButton(systemName: "backward.end.fill", size: 30,
|
||
disabled: audioPlayer.prevChapter == nil) {
|
||
if let prev = audioPlayer.prevChapter {
|
||
NotificationCenter.default.post(
|
||
name: .skipToPrevChapter, object: nil, userInfo: ["prev": prev])
|
||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||
}
|
||
}
|
||
|
||
PlayerPlayPauseButton(
|
||
progress: audioPlayer.progress,
|
||
isGenerating: audioPlayer.status == .generating
|
||
) {
|
||
audioPlayer.togglePlayPause()
|
||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||
}
|
||
|
||
PlayerChapterSkipButton(
|
||
systemName: "forward.end.fill", size: 30,
|
||
disabled: audioPlayer.nextChapter == nil,
|
||
prefetching: audioPlayer.nextPrefetchStatus == .prefetching
|
||
) {
|
||
if let next = audioPlayer.nextChapter {
|
||
NotificationCenter.default.post(
|
||
name: .skipToNextChapter, object: nil, userInfo: ["next": next])
|
||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||
}
|
||
}
|
||
|
||
PlayerSecondaryButton(systemName: "goforward.15", size: 24,
|
||
disabled: audioPlayer.status == .generating) {
|
||
audioPlayer.skip(by: 15)
|
||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||
}
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.padding(.top, 16)
|
||
.padding(.bottom, 8)
|
||
|
||
// Bottom toolbar
|
||
HStack(spacing: 0) {
|
||
// AirPlay
|
||
AirPlayButton()
|
||
.frame(width: 24, height: 24)
|
||
.frame(maxWidth: .infinity, minHeight: 44)
|
||
.accessibilityLabel("AirPlay")
|
||
|
||
// Speed picker
|
||
Menu {
|
||
ForEach([0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0], id: \.self) { s in
|
||
Button {
|
||
audioPlayer.setSpeed(s)
|
||
} label: {
|
||
if s == audioPlayer.speed {
|
||
Label("\(s, specifier: "%.2g")×", systemImage: "checkmark")
|
||
} else {
|
||
Text("\(s, specifier: "%.2g")×")
|
||
}
|
||
}
|
||
}
|
||
} label: {
|
||
Text("\(audioPlayer.speed, specifier: "%.2g")×")
|
||
.font(.system(size: 14, weight: .semibold))
|
||
.foregroundStyle(.white.opacity(0.65))
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 6)
|
||
.background(Capsule().fill(.white.opacity(0.12)))
|
||
.frame(maxWidth: .infinity)
|
||
.frame(height: 44)
|
||
}
|
||
.buttonStyle(.plain)
|
||
|
||
// Voice selector toggle
|
||
Button {
|
||
withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) {
|
||
showingVoiceSelector.toggle()
|
||
}
|
||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||
if !showingVoiceSelector { voiceVM.stopSample() }
|
||
} label: {
|
||
Image(systemName: showingVoiceSelector ? "mic.fill" : "mic")
|
||
.font(.system(size: 20))
|
||
.foregroundStyle(showingVoiceSelector ? Color.amber : .white.opacity(0.65))
|
||
.frame(maxWidth: .infinity)
|
||
.frame(height: 44)
|
||
.contentTransition(.symbolEffect(.replace))
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel(showingVoiceSelector ? "Hide voice selector" : "Select voice")
|
||
|
||
// Collapse
|
||
Button {
|
||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||
onDismiss()
|
||
} label: {
|
||
Image(systemName: "chevron.down")
|
||
.font(.system(size: 18, weight: .semibold))
|
||
.foregroundStyle(.white.opacity(0.65))
|
||
.frame(maxWidth: .infinity)
|
||
.frame(height: 44)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel("Collapse player")
|
||
|
||
// Chapters list
|
||
Button {
|
||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||
showingChaptersList = true
|
||
} label: {
|
||
Image(systemName: "list.bullet")
|
||
.font(.system(size: 20))
|
||
.foregroundStyle(.white.opacity(0.65))
|
||
.frame(maxWidth: .infinity)
|
||
.frame(height: 44)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel("Chapters list")
|
||
|
||
// Sleep timer
|
||
Button {
|
||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||
showingSleepTimer = true
|
||
} label: {
|
||
VStack(spacing: 1) {
|
||
Image(systemName: sleepTimerIcon)
|
||
.font(.system(size: 20))
|
||
.foregroundStyle(audioPlayer.sleepTimer != nil ? Color.amber : .white.opacity(0.65))
|
||
.contentTransition(.symbolEffect(.replace))
|
||
if !audioPlayer.sleepTimerRemainingText.isEmpty {
|
||
Text(audioPlayer.sleepTimerRemainingText)
|
||
.font(.system(size: 9, weight: .semibold).monospacedDigit())
|
||
.foregroundStyle(Color.amber)
|
||
.lineLimit(1)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
.frame(height: 44)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel(audioPlayer.sleepTimer != nil ? "Sleep timer active" : "Sleep timer")
|
||
}
|
||
.padding(.horizontal, 12)
|
||
.padding(.bottom, showingVoiceSelector ? 0 : 8)
|
||
|
||
// Voice selector panel (expandable)
|
||
if showingVoiceSelector {
|
||
VoiceSelectorPanel(
|
||
voiceVM: voiceVM,
|
||
selectedVoice: audioPlayer.voice,
|
||
onSelectVoice: { newVoice in
|
||
voiceVM.stopSample()
|
||
audioPlayer.voice = newVoice
|
||
BookVoicePreferences.shared.setVoice(newVoice, for: audioPlayer.slug)
|
||
Task {
|
||
var settings = authStore.settings
|
||
settings.voice = newVoice
|
||
await authStore.saveSettings(settings)
|
||
}
|
||
}
|
||
)
|
||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||
.task {
|
||
if voiceVM.voices.isEmpty { await voiceVM.loadVoices() }
|
||
}
|
||
}
|
||
}
|
||
.ignoresSafeArea(edges: .bottom)
|
||
}
|
||
}
|
||
.ignoresSafeArea()
|
||
.sheet(isPresented: $showingChaptersList) {
|
||
PlayerChaptersListSheet(
|
||
chapters: audioPlayer.chapters,
|
||
currentChapter: audioPlayer.chapter,
|
||
onChapterSelect: { selected in
|
||
showingChaptersList = false
|
||
guard selected != audioPlayer.chapter else { return }
|
||
let title = audioPlayer.chapters.first(where: { $0.number == selected })?.title ?? ""
|
||
let next = audioPlayer.chapters.filter({ $0.number > selected }).min(by: { $0.number < $1.number })?.number
|
||
let prev: Int? = selected > 1 ? selected - 1 : nil
|
||
audioPlayer.load(
|
||
slug: audioPlayer.slug, chapter: selected, chapterTitle: title,
|
||
bookTitle: audioPlayer.bookTitle, coverURL: audioPlayer.coverURL,
|
||
voice: audioPlayer.voice, speed: audioPlayer.speed,
|
||
chapters: audioPlayer.chapters, nextChapter: next, prevChapter: prev
|
||
)
|
||
}
|
||
)
|
||
.presentationDetents([.medium, .large])
|
||
.presentationDragIndicator(.visible)
|
||
}
|
||
.sheet(isPresented: $showingSleepTimer) {
|
||
SleepTimerSheet(audioPlayer: audioPlayer)
|
||
.presentationDetents([.height(500)])
|
||
.presentationDragIndicator(.visible)
|
||
}
|
||
}
|
||
|
||
// MARK: - Helpers
|
||
|
||
private var chapterPositionText: String {
|
||
let total = audioPlayer.chapters.count
|
||
guard total > 0 else { return "" }
|
||
let sorted = audioPlayer.chapters.sorted(by: { $0.number < $1.number })
|
||
let idx = (sorted.firstIndex(where: { $0.number == audioPlayer.chapter }) ?? 0) + 1
|
||
return "Chapter \(idx) of \(total)"
|
||
}
|
||
|
||
private var voiceName: String {
|
||
let parts = audioPlayer.voice.split(separator: "_")
|
||
if parts.count > 1 { return String(parts[1]).capitalized }
|
||
return audioPlayer.voice.capitalized
|
||
}
|
||
|
||
private var sleepTimerIcon: String {
|
||
audioPlayer.sleepTimer != nil ? "moon.zzz.fill" : "moon.zzz"
|
||
}
|
||
}
|
||
|
||
// MARK: - Secondary transport button (±15 s skips)
|
||
|
||
private struct PlayerSecondaryButton: View {
|
||
let systemName: String
|
||
let size: CGFloat
|
||
let disabled: Bool
|
||
let action: () -> Void
|
||
|
||
var body: some View {
|
||
Button(action: action) {
|
||
Image(systemName: systemName)
|
||
.font(.system(size: size, weight: .regular))
|
||
.foregroundStyle(.white.opacity(disabled ? 0.3 : 0.85))
|
||
.frame(maxWidth: .infinity)
|
||
.frame(height: 64)
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(disabled)
|
||
}
|
||
}
|
||
|
||
// MARK: - Chapter-skip button (prev / next chapter)
|
||
|
||
private struct PlayerChapterSkipButton: View {
|
||
let systemName: String
|
||
let size: CGFloat
|
||
let disabled: Bool
|
||
var prefetching: Bool = false
|
||
let action: () -> Void
|
||
|
||
var body: some View {
|
||
Button(action: action) {
|
||
ZStack {
|
||
Image(systemName: systemName)
|
||
.font(.system(size: size, weight: .regular))
|
||
.foregroundStyle(.white.opacity(disabled ? 0.3 : 0.9))
|
||
|
||
if prefetching {
|
||
VStack {
|
||
Spacer()
|
||
HStack {
|
||
Spacer()
|
||
ProgressView()
|
||
.scaleEffect(0.55)
|
||
.tint(.amber)
|
||
.padding(3)
|
||
.background(Circle().fill(.black.opacity(0.6)))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
.frame(height: 64)
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(disabled)
|
||
.opacity(disabled ? 0.4 : 1.0)
|
||
}
|
||
}
|
||
|
||
// MARK: - AirPlay Button
|
||
|
||
struct AirPlayButton: UIViewControllerRepresentable {
|
||
func makeUIViewController(context: Context) -> UIViewController {
|
||
let vc = UIViewController()
|
||
vc.view.backgroundColor = .clear
|
||
let picker = AVRoutePickerView()
|
||
picker.tintColor = UIColor.white.withAlphaComponent(0.7)
|
||
picker.activeTintColor = UIColor.systemOrange
|
||
picker.prioritizesVideoDevices = false
|
||
picker.translatesAutoresizingMaskIntoConstraints = false
|
||
vc.view.addSubview(picker)
|
||
NSLayoutConstraint.activate([
|
||
picker.leadingAnchor.constraint(equalTo: vc.view.leadingAnchor),
|
||
picker.trailingAnchor.constraint(equalTo: vc.view.trailingAnchor),
|
||
picker.topAnchor.constraint(equalTo: vc.view.topAnchor),
|
||
picker.bottomAnchor.constraint(equalTo: vc.view.bottomAnchor),
|
||
])
|
||
return vc
|
||
}
|
||
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
|
||
}
|
||
|
||
// MARK: - Isolated seek bar + timestamps
|
||
|
||
private struct PlayerProgressSection: View {
|
||
@ObservedObject var progress: PlaybackProgress
|
||
let onSeek: (Double) -> Void
|
||
|
||
var body: some View {
|
||
VStack(spacing: 4) {
|
||
PlayerSlider(
|
||
value: Binding(get: { progress.currentTime }, set: { onSeek($0) }),
|
||
range: 0...max(progress.duration, 1)
|
||
)
|
||
HStack {
|
||
Text(formatTime(progress.currentTime))
|
||
Spacer()
|
||
Text("-" + formatTime(progress.duration - progress.currentTime))
|
||
}
|
||
.font(.caption.monospacedDigit())
|
||
.foregroundStyle(.white.opacity(0.5))
|
||
}
|
||
.padding(.horizontal, 28)
|
||
}
|
||
|
||
private func formatTime(_ seconds: Double) -> String {
|
||
guard seconds.isFinite, seconds >= 0 else { return "0:00" }
|
||
let s = Int(seconds)
|
||
return "\(s / 60):\(String(format: "%02d", s % 60))"
|
||
}
|
||
}
|
||
|
||
// MARK: - Custom amber seek slider
|
||
|
||
struct PlayerSlider: View {
|
||
@Binding var value: Double
|
||
let range: ClosedRange<Double>
|
||
|
||
@State private var isDragging = false
|
||
@State private var didFireHaptic = false
|
||
|
||
var body: some View {
|
||
GeometryReader { geo in
|
||
let width = geo.size.width
|
||
let fraction = (value - range.lowerBound) / (range.upperBound - range.lowerBound)
|
||
let clamped = max(0, min(1, fraction))
|
||
let filled = width * clamped
|
||
let thumbSize: CGFloat = isDragging ? 26 : 20
|
||
let trackHeight: CGFloat = isDragging ? 5 : 4
|
||
|
||
ZStack(alignment: .leading) {
|
||
Capsule()
|
||
.fill(Color.white.opacity(0.2))
|
||
.frame(height: trackHeight)
|
||
|
||
Capsule()
|
||
.fill(LinearGradient(
|
||
colors: [Color.amber.opacity(0.9), Color.amber],
|
||
startPoint: .leading, endPoint: .trailing
|
||
))
|
||
.frame(width: max(filled, thumbSize / 2), height: trackHeight)
|
||
|
||
Circle()
|
||
.fill(Color.white)
|
||
.frame(width: thumbSize, height: thumbSize)
|
||
.shadow(color: .black.opacity(0.3), radius: isDragging ? 6 : 3,
|
||
y: isDragging ? 2 : 1)
|
||
.offset(x: max(0, filled - thumbSize / 2))
|
||
.animation(.spring(response: 0.2, dampingFraction: 0.65), value: isDragging)
|
||
}
|
||
.frame(height: 36)
|
||
.contentShape(Rectangle())
|
||
.gesture(
|
||
DragGesture(minimumDistance: 0)
|
||
.onChanged { drag in
|
||
if !isDragging {
|
||
isDragging = true
|
||
if !didFireHaptic {
|
||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||
didFireHaptic = true
|
||
}
|
||
}
|
||
let raw = drag.location.x / width
|
||
value = range.lowerBound + max(0, min(1, raw)) * (range.upperBound - range.lowerBound)
|
||
}
|
||
.onEnded { _ in isDragging = false; didFireHaptic = false }
|
||
)
|
||
}
|
||
.frame(height: 36)
|
||
}
|
||
}
|
||
|
||
// MARK: - Isolated play/pause button (full player)
|
||
|
||
private struct PlayerPlayPauseButton: View {
|
||
@ObservedObject var progress: PlaybackProgress
|
||
let isGenerating: Bool
|
||
let onToggle: () -> Void
|
||
|
||
@State private var isPressed = false
|
||
|
||
var body: some View {
|
||
Button { onToggle() } label: {
|
||
ZStack {
|
||
Circle()
|
||
.fill(Color.amber.opacity(progress.isPlaying ? 0.18 : 0))
|
||
.frame(width: 80, height: 80)
|
||
.animation(.easeInOut(duration: 0.35), value: progress.isPlaying)
|
||
|
||
Circle()
|
||
.fill(LinearGradient(
|
||
colors: [Color.amber.opacity(0.9), Color.amber.opacity(0.65)],
|
||
startPoint: .topLeading, endPoint: .bottomTrailing
|
||
))
|
||
.frame(width: 64, height: 64)
|
||
.shadow(color: Color.amber.opacity(0.45), radius: 12, y: 4)
|
||
.scaleEffect(isPressed ? 0.92 : 1.0)
|
||
.animation(.spring(response: 0.2, dampingFraction: 0.6), value: isPressed)
|
||
|
||
if isGenerating {
|
||
ProgressView().tint(.white).scaleEffect(1.2)
|
||
} else {
|
||
Image(systemName: progress.isPlaying ? "pause.fill" : "play.fill")
|
||
.font(.system(size: 28, weight: .bold))
|
||
.foregroundStyle(.white)
|
||
.offset(x: progress.isPlaying ? 0 : 2)
|
||
.contentTransition(.symbolEffect(.replace.downUp))
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(isGenerating)
|
||
._onButtonGesture(pressing: { isPressed = $0 }, perform: {})
|
||
.accessibilityLabel(progress.isPlaying ? "Pause" : "Play")
|
||
}
|
||
}
|
||
|
||
// MARK: - Sleep Timer Sheet
|
||
|
||
struct SleepTimerSheet: View {
|
||
@ObservedObject var audioPlayer: AudioPlayerService
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
ScrollView {
|
||
VStack(spacing: 20) {
|
||
// Off
|
||
TimerCard {
|
||
TimerOptionRow(
|
||
label: "Off", systemImage: "moon.zzz",
|
||
isSelected: audioPlayer.sleepTimer == nil
|
||
) {
|
||
audioPlayer.setSleepTimer(nil)
|
||
dismiss()
|
||
}
|
||
}
|
||
|
||
// Chapter-based
|
||
VStack(spacing: 0) {
|
||
SectionLabel("Chapter-based")
|
||
TimerCard {
|
||
ForEach([1, 2, 3, 4], id: \.self) { count in
|
||
let isSelected: Bool = {
|
||
if case .chapters(let c) = audioPlayer.sleepTimer { return c == count }
|
||
return false
|
||
}()
|
||
TimerOptionRow(
|
||
label: "\(count) \(count == 1 ? "chapter" : "chapters")",
|
||
systemImage: "book", isSelected: isSelected
|
||
) {
|
||
audioPlayer.setSleepTimer(.chapters(count))
|
||
dismiss()
|
||
}
|
||
if count < 4 { Divider().padding(.leading, 56) }
|
||
}
|
||
}
|
||
}
|
||
|
||
// Time-based
|
||
VStack(spacing: 0) {
|
||
SectionLabel("Time-based")
|
||
TimerCard {
|
||
ForEach([20, 40, 60, 120], id: \.self) { mins in
|
||
let isSelected: Bool = {
|
||
if case .minutes(let m) = audioPlayer.sleepTimer { return m == mins }
|
||
return false
|
||
}()
|
||
TimerOptionRow(
|
||
label: formatTimerOption(mins), systemImage: "clock",
|
||
isSelected: isSelected
|
||
) {
|
||
audioPlayer.setSleepTimer(.minutes(mins))
|
||
dismiss()
|
||
}
|
||
if mins != 120 { Divider().padding(.leading, 56) }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.padding(20)
|
||
}
|
||
.background(Color(.systemGroupedBackground))
|
||
.navigationTitle("Sleep Timer")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
Button("Done") { dismiss() }.fontWeight(.semibold)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func formatTimerOption(_ minutes: Int) -> String {
|
||
if minutes < 60 { return "\(minutes) mins" }
|
||
let h = minutes / 60
|
||
return "\(h) \(h == 1 ? "hour" : "hours")"
|
||
}
|
||
}
|
||
|
||
// MARK: - Sleep timer helper views
|
||
|
||
private struct TimerCard<Content: View>: View {
|
||
@ViewBuilder let content: Content
|
||
var body: some View {
|
||
VStack(spacing: 0) { content }
|
||
.background(Color(.secondarySystemGroupedBackground))
|
||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||
}
|
||
}
|
||
|
||
private struct SectionLabel: View {
|
||
let text: String
|
||
init(_ text: String) { self.text = text }
|
||
var body: some View {
|
||
Text(text.uppercased())
|
||
.font(.caption.weight(.semibold))
|
||
.foregroundStyle(.secondary)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(.leading, 4)
|
||
.padding(.bottom, 8)
|
||
}
|
||
}
|
||
|
||
private struct TimerOptionRow: View {
|
||
let label: String
|
||
let systemImage: String
|
||
let isSelected: Bool
|
||
let action: () -> Void
|
||
|
||
var body: some View {
|
||
Button {
|
||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||
action()
|
||
} label: {
|
||
HStack(spacing: 14) {
|
||
Image(systemName: systemImage)
|
||
.font(.system(size: 16))
|
||
.foregroundStyle(isSelected ? Color.amber : .secondary)
|
||
.frame(width: 28)
|
||
Text(label)
|
||
.font(.body)
|
||
.foregroundStyle(.primary)
|
||
Spacer()
|
||
if isSelected {
|
||
Image(systemName: "checkmark")
|
||
.font(.system(size: 14, weight: .semibold))
|
||
.foregroundStyle(Color.amber)
|
||
.transition(.scale.combined(with: .opacity))
|
||
}
|
||
}
|
||
.padding(.horizontal, 18)
|
||
.padding(.vertical, 14)
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: isSelected)
|
||
}
|
||
}
|
||
|
||
// MARK: - Player Chapters List Sheet
|
||
// Groups chapters into blocks of 100 with a right-edge jump bar.
|
||
// Includes per-chapter download status and swipe actions.
|
||
|
||
struct PlayerChaptersListSheet: View {
|
||
let chapters: [ChapterBrief]
|
||
let currentChapter: Int
|
||
let onChapterSelect: (Int) -> Void
|
||
|
||
@Environment(\.dismiss) private var dismiss
|
||
@EnvironmentObject var audioPlayer: AudioPlayerService
|
||
@EnvironmentObject var downloadService: AudioDownloadService
|
||
|
||
@State private var searchText: String = ""
|
||
@State private var filterOfflineOnly = false
|
||
@State private var showingManage = false
|
||
@State private var activeBlock: String? = nil
|
||
|
||
// MARK: Derived data
|
||
|
||
private var downloadedCount: Int {
|
||
chapters.filter {
|
||
downloadService.isDownloaded(slug: audioPlayer.slug, chapter: $0.number, voice: audioPlayer.voice)
|
||
}.count
|
||
}
|
||
|
||
private var downloadingCount: Int {
|
||
downloadService.downloads.filter { key, _ in key.hasPrefix("\(audioPlayer.slug)::") }.count
|
||
}
|
||
|
||
private var filtered: [ChapterBrief] {
|
||
var result = chapters
|
||
if filterOfflineOnly {
|
||
result = result.filter {
|
||
downloadService.isDownloaded(slug: audioPlayer.slug, chapter: $0.number, voice: audioPlayer.voice)
|
||
}
|
||
}
|
||
if !searchText.isEmpty {
|
||
let q = searchText.lowercased()
|
||
result = result.filter { "\($0.number)".contains(q) || $0.title.lowercased().contains(q) }
|
||
}
|
||
return result
|
||
}
|
||
|
||
private var groups: [(label: String, chapters: [ChapterBrief])] {
|
||
guard searchText.isEmpty && !filterOfflineOnly else {
|
||
return filtered.isEmpty ? [] : [("Results", filtered)]
|
||
}
|
||
guard !filtered.isEmpty else { return [] }
|
||
let blockSize = 100
|
||
let minN = filtered.map(\.number).min() ?? 1
|
||
let maxN = filtered.map(\.number).max() ?? 1
|
||
let firstBlock = ((minN - 1) / blockSize) * blockSize + 1
|
||
var result: [(label: String, chapters: [ChapterBrief])] = []
|
||
var blockStart = firstBlock
|
||
while blockStart <= maxN {
|
||
let blockEnd = blockStart + blockSize - 1
|
||
let slice = filtered.filter { $0.number >= blockStart && $0.number <= blockEnd }
|
||
if !slice.isEmpty { result.append(("\(blockStart)–\(blockEnd)", slice)) }
|
||
blockStart += blockSize
|
||
}
|
||
return result
|
||
}
|
||
|
||
private var jumpLabels: [String] { groups.map(\.label) }
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
ZStack(alignment: .trailing) {
|
||
List {
|
||
// Download summary
|
||
if downloadedCount > 0 || downloadingCount > 0 {
|
||
Section {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
HStack {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text("Offline Downloads").font(.headline)
|
||
Text("\(downloadedCount) of \(chapters.count) chapters")
|
||
.font(.subheadline).foregroundStyle(.secondary)
|
||
}
|
||
Spacer()
|
||
Button { showingManage = true } label: {
|
||
Label("Manage", systemImage: "arrow.down.circle")
|
||
.font(.subheadline.weight(.semibold))
|
||
}
|
||
.buttonStyle(.bordered).tint(.blue)
|
||
}
|
||
if downloadingCount > 0 {
|
||
HStack(spacing: 8) {
|
||
ProgressView().scaleEffect(0.8)
|
||
Text("Downloading \(downloadingCount) \(downloadingCount == 1 ? "chapter" : "chapters")")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
Toggle("Show offline only", isOn: $filterOfflineOnly)
|
||
.font(.subheadline).tint(Color.amber)
|
||
}
|
||
.padding(.vertical, 8)
|
||
}
|
||
}
|
||
|
||
ForEach(groups, id: \.label) { group in
|
||
Section {
|
||
ForEach(group.chapters, id: \.number) { ch in
|
||
PlayerChapterRow(
|
||
chapter: ch,
|
||
isCurrent: ch.number == currentChapter,
|
||
onSelect: { onChapterSelect(ch.number) }
|
||
)
|
||
.id(group.label)
|
||
}
|
||
} header: {
|
||
if searchText.isEmpty && !filterOfflineOnly {
|
||
Text(group.label)
|
||
.font(.caption.bold())
|
||
.foregroundStyle(.secondary)
|
||
.id("header_\(group.label)")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.listStyle(.plain)
|
||
.searchable(text: $searchText,
|
||
placement: .navigationBarDrawer(displayMode: .always),
|
||
prompt: "Chapter number or title")
|
||
.scrollPosition(id: $activeBlock, anchor: .top)
|
||
|
||
// Jump bar
|
||
if searchText.isEmpty && !filterOfflineOnly && jumpLabels.count > 1 {
|
||
PlayerJumpBar(labels: jumpLabels, currentChapter: currentChapter, groups: groups) { label in
|
||
withAnimation { activeBlock = label }
|
||
}
|
||
.padding(.trailing, 4)
|
||
}
|
||
}
|
||
.navigationTitle("Chapters (\(filtered.count))")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
Button("Done") { dismiss() }.fontWeight(.semibold)
|
||
}
|
||
}
|
||
.sheet(isPresented: $showingManage) {
|
||
DownloadManagementSheet(
|
||
chapters: chapters, slug: audioPlayer.slug,
|
||
voice: Binding(get: { audioPlayer.voice }, set: { audioPlayer.voice = $0 })
|
||
)
|
||
.presentationDetents([.medium, .large])
|
||
.presentationDragIndicator(.visible)
|
||
}
|
||
.onAppear {
|
||
if let block = groups.first(where: { g in
|
||
g.chapters.contains(where: { $0.number == currentChapter })
|
||
}) {
|
||
activeBlock = block.label
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Individual chapter row (player chapters list)
|
||
|
||
private struct PlayerChapterRow: View {
|
||
let chapter: ChapterBrief
|
||
let isCurrent: Bool
|
||
let onSelect: () -> Void
|
||
|
||
@EnvironmentObject var audioPlayer: AudioPlayerService
|
||
@EnvironmentObject var downloadService: AudioDownloadService
|
||
|
||
private var isDownloaded: Bool {
|
||
downloadService.isDownloaded(slug: audioPlayer.slug, chapter: chapter.number, voice: audioPlayer.voice)
|
||
}
|
||
private var downloadProgress: DownloadProgress? {
|
||
let key = downloadService.makeKey(slug: audioPlayer.slug, chapter: chapter.number, voice: audioPlayer.voice)
|
||
return downloadService.downloads[key]
|
||
}
|
||
private var isDownloading: Bool { downloadProgress != nil }
|
||
|
||
var body: some View {
|
||
Button(action: onSelect) {
|
||
HStack(spacing: 14) {
|
||
// Number badge
|
||
ZStack {
|
||
Text("\(chapter.number)")
|
||
.font(.caption.bold())
|
||
.foregroundStyle(isCurrent ? .white : .secondary)
|
||
.frame(width: 40, height: 40)
|
||
.background(Circle().fill(isCurrent ? Color.amber : Color(.systemGray5)))
|
||
|
||
if isDownloading, let p = downloadProgress {
|
||
Circle()
|
||
.trim(from: 0, to: p.progress)
|
||
.stroke(Color.blue, style: StrokeStyle(lineWidth: 2, lineCap: .round))
|
||
.rotationEffect(.degrees(-90))
|
||
.frame(width: 44, height: 44)
|
||
.animation(.easeInOut(duration: 0.3), value: p.progress)
|
||
}
|
||
}
|
||
|
||
// Title + status
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
Text(chapter.title.strippingTrailingDate())
|
||
.font(.subheadline.weight(isCurrent ? .semibold : .regular))
|
||
.foregroundStyle(.primary)
|
||
.lineLimit(2)
|
||
|
||
HStack(spacing: 8) {
|
||
if isCurrent {
|
||
Label("Now Playing", systemImage: "waveform")
|
||
.font(.caption2)
|
||
.foregroundStyle(Color.amber)
|
||
.symbolEffect(.variableColor.cumulative, isActive: isCurrent)
|
||
}
|
||
if isDownloading, let p = downloadProgress {
|
||
Label("\(Int(p.progress * 100))%", systemImage: "arrow.down.circle")
|
||
.font(.caption2).foregroundStyle(.blue)
|
||
} else if isDownloaded {
|
||
Label("Downloaded", systemImage: "checkmark.circle.fill")
|
||
.font(.caption2).foregroundStyle(.green)
|
||
}
|
||
}
|
||
}
|
||
|
||
Spacer()
|
||
|
||
if isCurrent {
|
||
Image(systemName: "waveform")
|
||
.font(.caption.bold())
|
||
.foregroundStyle(Color.amber)
|
||
.symbolEffect(.variableColor.cumulative, isActive: isCurrent)
|
||
} else if isDownloaded {
|
||
Image(systemName: "arrow.down.circle.fill")
|
||
.font(.body).foregroundStyle(.green)
|
||
} else if isDownloading {
|
||
ProgressView().scaleEffect(0.8)
|
||
}
|
||
}
|
||
.padding(.vertical, 6)
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.listRowBackground(isCurrent ? Color.amber.opacity(0.08) : Color.clear)
|
||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||
if isDownloaded {
|
||
Button(role: .destructive) {
|
||
try? downloadService.deleteDownload(
|
||
slug: audioPlayer.slug, chapter: chapter.number, voice: audioPlayer.voice)
|
||
} label: { Label("Delete", systemImage: "trash") }
|
||
} else if isDownloading {
|
||
Button(role: .destructive) {
|
||
downloadService.cancelDownload(
|
||
slug: audioPlayer.slug, chapter: chapter.number, voice: audioPlayer.voice)
|
||
} label: { Label("Cancel", systemImage: "xmark") }
|
||
} else {
|
||
Button {
|
||
Task {
|
||
try? await downloadService.download(
|
||
slug: audioPlayer.slug, chapter: chapter.number, voice: audioPlayer.voice)
|
||
}
|
||
} label: { Label("Download", systemImage: "arrow.down.circle") }
|
||
.tint(.blue)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Jump bar (right edge)
|
||
|
||
private struct PlayerJumpBar: View {
|
||
let labels: [String]
|
||
let currentChapter: Int
|
||
let groups: [(label: String, chapters: [ChapterBrief])]
|
||
let onSelect: (String) -> Void
|
||
|
||
@State private var isDragging = false
|
||
|
||
private func shortLabel(_ full: String) -> String {
|
||
full.components(separatedBy: "–").first ?? full
|
||
}
|
||
|
||
private var currentBlock: String? {
|
||
groups.first(where: { $0.chapters.contains(where: { $0.number == currentChapter }) })?.label
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
ForEach(labels, id: \.self) { label in
|
||
let isCurrent = label == currentBlock
|
||
Text(shortLabel(label))
|
||
.font(.system(size: 10, weight: isCurrent ? .bold : .regular))
|
||
.foregroundStyle(isCurrent ? Color.amber : Color.secondary)
|
||
.frame(width: 28, height: 28)
|
||
.contentShape(Rectangle())
|
||
.onTapGesture { onSelect(label) }
|
||
}
|
||
}
|
||
.padding(.vertical, 6)
|
||
.background(Capsule().fill(.ultraThinMaterial).shadow(color: .black.opacity(0.15), radius: 4))
|
||
.gesture(
|
||
DragGesture(minimumDistance: 0, coordinateSpace: .local)
|
||
.onChanged { value in
|
||
isDragging = true
|
||
let index = max(0, min(labels.count - 1, Int(value.location.y / 28)))
|
||
onSelect(labels[index])
|
||
}
|
||
.onEnded { _ in isDragging = false }
|
||
)
|
||
.animation(.easeInOut(duration: 0.15), value: isDragging)
|
||
}
|
||
}
|
||
|
||
// MARK: - Voice selector panel (inline, expandable inside FullPlayerView)
|
||
|
||
private struct VoiceSelectorPanel: View {
|
||
let voiceVM: VoiceSelectionViewModel
|
||
let selectedVoice: String
|
||
let onSelectVoice: (String) -> Void
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
HStack {
|
||
Text("Choose Voice")
|
||
.font(.caption.weight(.semibold))
|
||
.foregroundStyle(.white.opacity(0.45))
|
||
.textCase(.uppercase)
|
||
.tracking(0.8)
|
||
Spacer()
|
||
}
|
||
.padding(.horizontal, 18)
|
||
.padding(.top, 10)
|
||
.padding(.bottom, 6)
|
||
|
||
ScrollView {
|
||
VStack(spacing: 0) {
|
||
ForEach(voiceVM.voices, id: \.self) { voice in
|
||
VoiceOptionRow(
|
||
voice: voice,
|
||
isSelected: selectedVoice == voice,
|
||
isPlaying: voiceVM.playingVoice == voice,
|
||
voiceLabel: voiceVM.voiceLabel(voice),
|
||
voiceId: voiceVM.voiceId(voice),
|
||
onSelect: { onSelectVoice(voice) },
|
||
onPlaySample: { Task { await voiceVM.playSample(voice) } }
|
||
)
|
||
if voice != voiceVM.voices.last {
|
||
Divider().overlay(Color.white.opacity(0.08)).padding(.leading, 52)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.frame(maxHeight: 220)
|
||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||
.padding(.horizontal, 16)
|
||
|
||
Text("New voice applies on next chapter")
|
||
.font(.caption2)
|
||
.foregroundStyle(.white.opacity(0.35))
|
||
.padding(.top, 7)
|
||
.padding(.bottom, 10)
|
||
}
|
||
.background(.ultraThinMaterial)
|
||
}
|
||
}
|
||
|
||
// MARK: - Voice option row (inside VoiceSelectorPanel)
|
||
|
||
private struct VoiceOptionRow: View {
|
||
let voice: String
|
||
let isSelected: Bool
|
||
let isPlaying: Bool
|
||
let voiceLabel: String
|
||
let voiceId: String
|
||
let onSelect: () -> Void
|
||
let onPlaySample: () -> Void
|
||
|
||
var body: some View {
|
||
Button {
|
||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||
onSelect()
|
||
} label: {
|
||
HStack(spacing: 12) {
|
||
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
|
||
.font(.system(size: 18))
|
||
.foregroundStyle(isSelected ? Color.amber : .white.opacity(0.25))
|
||
.scaleEffect(isSelected ? 1.1 : 1.0)
|
||
.animation(.spring(response: 0.3, dampingFraction: 0.55), value: isSelected)
|
||
.frame(width: 24)
|
||
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(voiceLabel)
|
||
.font(.subheadline)
|
||
.foregroundStyle(isSelected ? Color.amber : .white)
|
||
.fontWeight(isSelected ? .semibold : .regular)
|
||
.animation(.easeInOut(duration: 0.2), value: isSelected)
|
||
Text(voiceId)
|
||
.font(.caption2)
|
||
.fontDesign(.monospaced)
|
||
.foregroundStyle(.white.opacity(0.4))
|
||
}
|
||
|
||
Spacer()
|
||
|
||
Button { onPlaySample() } label: {
|
||
Image(systemName: isPlaying ? "stop.circle.fill" : "play.circle.fill")
|
||
.font(.system(size: 24))
|
||
.foregroundStyle(isPlaying ? Color.red : Color.amber.opacity(0.8))
|
||
.contentTransition(.symbolEffect(.replace.downUp))
|
||
.frame(minWidth: 44, minHeight: 44)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel(isPlaying ? "Stop sample" : "Play sample")
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.padding(.vertical, 10)
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.background(isSelected ? Color.amber.opacity(0.08) : Color.clear)
|
||
.animation(.easeInOut(duration: 0.2), value: isSelected)
|
||
}
|
||
}
|
||
|
||
// MARK: - Download Management Sheet
|
||
|
||
struct DownloadManagementSheet: View {
|
||
let chapters: [ChapterBrief]
|
||
let slug: String
|
||
@Binding var voice: String
|
||
|
||
@Environment(\.dismiss) private var dismiss
|
||
@EnvironmentObject var downloadService: AudioDownloadService
|
||
@EnvironmentObject var authStore: AuthStore
|
||
|
||
@State private var showingDeleteAll = false
|
||
@State private var isDownloadingAll = false
|
||
@State private var showingVoiceSelector = false
|
||
@State private var showingRangeSelector = false
|
||
@State private var voiceVM = VoiceSelectionViewModel()
|
||
|
||
private var downloadedChapters: [ChapterBrief] {
|
||
chapters.filter { downloadService.isDownloaded(slug: slug, chapter: $0.number, voice: voice) }
|
||
}
|
||
private var notDownloadedChapters: [ChapterBrief] {
|
||
chapters.filter { !downloadService.isDownloaded(slug: slug, chapter: $0.number, voice: voice) }
|
||
}
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
List {
|
||
// Voice info
|
||
Section {
|
||
Button { showingVoiceSelector = true } label: {
|
||
HStack {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text("Download Voice").font(.subheadline).foregroundStyle(.secondary)
|
||
HStack(spacing: 6) {
|
||
Text(voiceLabel(voice)).font(.body.weight(.semibold))
|
||
if BookVoicePreferences.shared.hasOverride(for: slug) {
|
||
Text("(Custom)").font(.caption).foregroundStyle(.blue)
|
||
}
|
||
}
|
||
}
|
||
Spacer()
|
||
Image(systemName: "chevron.right")
|
||
.font(.caption.weight(.semibold)).foregroundStyle(.tertiary)
|
||
}
|
||
.padding(.vertical, 4)
|
||
}
|
||
.buttonStyle(.plain)
|
||
} footer: {
|
||
Text("Tap to change voice. Downloads will use the selected voice for this book.")
|
||
.font(.caption)
|
||
}
|
||
|
||
// Stats + actions
|
||
Section {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
HStack {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text("\(downloadedChapters.count) Downloaded").font(.title2.bold())
|
||
Text("\(notDownloadedChapters.count) remaining")
|
||
.font(.subheadline).foregroundStyle(.secondary)
|
||
}
|
||
Spacer()
|
||
ZStack {
|
||
Circle().stroke(Color(.systemGray5), lineWidth: 4)
|
||
Circle()
|
||
.trim(from: 0, to: chapters.isEmpty ? 0 : CGFloat(downloadedChapters.count) / CGFloat(chapters.count))
|
||
.stroke(Color.green, style: StrokeStyle(lineWidth: 4, lineCap: .round))
|
||
.rotationEffect(.degrees(-90))
|
||
.animation(.easeInOut(duration: 0.4), value: downloadedChapters.count)
|
||
Text("\(chapters.isEmpty ? 0 : Int(Double(downloadedChapters.count) / Double(chapters.count) * 100))%")
|
||
.font(.caption2.bold()).foregroundStyle(.secondary)
|
||
}
|
||
.frame(width: 44, height: 44)
|
||
}
|
||
|
||
HStack(spacing: 10) {
|
||
if notDownloadedChapters.count > 0 {
|
||
Button { showingRangeSelector = true } label: {
|
||
Label("Range", systemImage: "list.number").frame(maxWidth: .infinity)
|
||
}
|
||
.buttonStyle(.bordered).tint(.blue)
|
||
|
||
Button { downloadAllRemaining() } label: {
|
||
HStack(spacing: 6) {
|
||
if isDownloadingAll { ProgressView().scaleEffect(0.75) }
|
||
else { Image(systemName: "arrow.down.circle.fill") }
|
||
Text("All (\(notDownloadedChapters.count))")
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
.buttonStyle(.borderedProminent).tint(.blue)
|
||
.disabled(isDownloadingAll)
|
||
}
|
||
|
||
if downloadedChapters.count > 0 {
|
||
Button { showingDeleteAll = true } label: {
|
||
Label("Delete All", systemImage: "trash").frame(maxWidth: .infinity)
|
||
}
|
||
.buttonStyle(.bordered).tint(.red)
|
||
}
|
||
}
|
||
}
|
||
.padding(.vertical, 8)
|
||
}
|
||
|
||
// Downloaded list
|
||
if downloadedChapters.count > 0 {
|
||
Section {
|
||
ForEach(downloadedChapters, id: \.number) { ch in
|
||
HStack {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text("Chapter \(ch.number)").font(.subheadline.weight(.semibold))
|
||
Text(ch.title.strippingTrailingDate())
|
||
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||
}
|
||
Spacer()
|
||
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
|
||
}
|
||
}
|
||
.onDelete { indexSet in
|
||
for i in indexSet {
|
||
let ch = downloadedChapters[i]
|
||
try? downloadService.deleteDownload(slug: slug, chapter: ch.number, voice: voice)
|
||
}
|
||
}
|
||
} header: {
|
||
Text("Downloaded (\(downloadedChapters.count))")
|
||
}
|
||
}
|
||
}
|
||
.navigationTitle("Manage Downloads")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
Button("Done") { dismiss() }.fontWeight(.semibold)
|
||
}
|
||
}
|
||
.confirmationDialog("Delete all downloads?", isPresented: $showingDeleteAll, titleVisibility: .visible) {
|
||
Button("Delete All Downloads", role: .destructive) {
|
||
for ch in downloadedChapters {
|
||
try? downloadService.deleteDownload(slug: slug, chapter: ch.number, voice: voice)
|
||
}
|
||
}
|
||
Button("Cancel", role: .cancel) {}
|
||
} message: {
|
||
Text("This will delete \(downloadedChapters.count) downloaded chapters. You can re-download them later.")
|
||
}
|
||
.sheet(isPresented: $showingVoiceSelector) {
|
||
VoiceSelectorSheet(
|
||
selectedVoice: voice, slug: slug, voiceVM: voiceVM,
|
||
onSelectVoice: { newVoice in
|
||
voice = newVoice
|
||
BookVoicePreferences.shared.setVoice(newVoice, for: slug)
|
||
showingVoiceSelector = false
|
||
}
|
||
)
|
||
}
|
||
.sheet(isPresented: $showingRangeSelector) {
|
||
RangeDownloadSheet(
|
||
chapters: notDownloadedChapters, slug: slug, voice: voice,
|
||
onDownload: { start, end in
|
||
downloadRange(from: start, to: end)
|
||
showingRangeSelector = false
|
||
}
|
||
)
|
||
.presentationDetents([.medium])
|
||
}
|
||
}
|
||
}
|
||
|
||
private func downloadAllRemaining() {
|
||
isDownloadingAll = true
|
||
Task {
|
||
for ch in notDownloadedChapters {
|
||
try? await downloadService.download(slug: slug, chapter: ch.number, voice: voice)
|
||
try? await Task.sleep(nanoseconds: 500_000_000)
|
||
}
|
||
isDownloadingAll = false
|
||
}
|
||
}
|
||
|
||
private func downloadRange(from start: Int, to end: Int) {
|
||
isDownloadingAll = true
|
||
Task {
|
||
let toDownload = notDownloadedChapters.filter { $0.number >= start && $0.number <= end }
|
||
for ch in toDownload {
|
||
try? await downloadService.download(slug: slug, chapter: ch.number, voice: voice)
|
||
try? await Task.sleep(nanoseconds: 500_000_000)
|
||
}
|
||
isDownloadingAll = false
|
||
}
|
||
}
|
||
|
||
private func voiceLabel(_ voice: String) -> String {
|
||
let parts = voice.split(separator: "_")
|
||
guard parts.count >= 2 else { return voice }
|
||
let prefix = String(parts[0])
|
||
let name = parts.dropFirst().map { $0.capitalized }.joined(separator: " ")
|
||
var info = ""
|
||
switch prefix {
|
||
case "af": info = "US F"; case "am": info = "US M"
|
||
case "bf": info = "UK F"; case "bm": info = "UK M"
|
||
default: info = prefix.uppercased()
|
||
}
|
||
return "\(name) (\(info))"
|
||
}
|
||
}
|
||
|
||
// MARK: - Voice Selector Sheet (for DownloadManagementSheet)
|
||
|
||
private struct VoiceSelectorSheet: View {
|
||
let selectedVoice: String
|
||
let slug: String
|
||
let voiceVM: VoiceSelectionViewModel
|
||
let onSelectVoice: (String) -> Void
|
||
|
||
@Environment(\.dismiss) private var dismiss
|
||
@EnvironmentObject var authStore: AuthStore
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
List {
|
||
Section {
|
||
ForEach(voiceVM.voices, id: \.self) { voice in
|
||
Button { onSelectVoice(voice) } label: {
|
||
HStack(spacing: 12) {
|
||
Image(systemName: "checkmark")
|
||
.font(.system(size: 16, weight: .semibold))
|
||
.foregroundStyle(.blue)
|
||
.opacity(voice == selectedVoice ? 1 : 0)
|
||
.frame(width: 20)
|
||
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(voiceVM.voiceLabel(voice)).font(.body).foregroundStyle(.primary)
|
||
Text(voice).font(.caption.monospaced()).foregroundStyle(.secondary)
|
||
}
|
||
|
||
Spacer()
|
||
|
||
Button {
|
||
Task { await voiceVM.playSample(voice) }
|
||
} label: {
|
||
Image(systemName: voiceVM.playingVoice == voice ? "stop.circle.fill" : "play.circle")
|
||
.font(.system(size: 24))
|
||
.foregroundStyle(voiceVM.playingVoice == voice ? .red : .blue)
|
||
.frame(minWidth: 44, minHeight: 44)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel(voiceVM.playingVoice == voice ? "Stop sample" : "Play sample")
|
||
}
|
||
.padding(.vertical, 4)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
} header: {
|
||
Text("Select Voice")
|
||
} footer: {
|
||
if BookVoicePreferences.shared.hasOverride(for: slug) {
|
||
Button("Reset to Global Voice") {
|
||
BookVoicePreferences.shared.removeVoice(for: slug)
|
||
onSelectVoice(authStore.settings.voice)
|
||
}
|
||
.font(.subheadline)
|
||
}
|
||
}
|
||
}
|
||
.navigationTitle("Download Voice")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
Button("Done") { voiceVM.stopSample(); dismiss() }.fontWeight(.semibold)
|
||
}
|
||
}
|
||
.task {
|
||
if voiceVM.voices.isEmpty { await voiceVM.loadVoices() }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Range Download Sheet
|
||
|
||
private struct RangeDownloadSheet: View {
|
||
let chapters: [ChapterBrief]
|
||
let slug: String
|
||
let voice: String
|
||
let onDownload: (Int, Int) -> Void
|
||
|
||
@Environment(\.dismiss) private var dismiss
|
||
@State private var startChapter: Int
|
||
@State private var endChapter: Int
|
||
|
||
init(chapters: [ChapterBrief], slug: String, voice: String, onDownload: @escaping (Int, Int) -> Void) {
|
||
self.chapters = chapters
|
||
self.slug = slug
|
||
self.voice = voice
|
||
self.onDownload = onDownload
|
||
let first = chapters.first?.number ?? 1
|
||
let last = chapters.last?.number ?? 1
|
||
_startChapter = State(initialValue: first)
|
||
_endChapter = State(initialValue: min(first + 9, last))
|
||
}
|
||
|
||
private var chapterRange: [Int] {
|
||
guard let first = chapters.first?.number, let last = chapters.last?.number else { return [] }
|
||
return Array(first...last)
|
||
}
|
||
private var selectedCount: Int {
|
||
guard startChapter <= endChapter else { return 0 }
|
||
return endChapter - startChapter + 1
|
||
}
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
Form {
|
||
Section {
|
||
Picker("Start Chapter", selection: $startChapter) {
|
||
ForEach(chapterRange, id: \.self) { n in Text("Chapter \(n)").tag(n) }
|
||
}
|
||
Picker("End Chapter", selection: $endChapter) {
|
||
ForEach(chapterRange.filter { $0 >= startChapter }, id: \.self) { n in
|
||
Text("Chapter \(n)").tag(n)
|
||
}
|
||
}
|
||
} header: { Text("Select Range") }
|
||
footer: { Text("\(selectedCount) chapters will be downloaded") }
|
||
|
||
Section {
|
||
Button {
|
||
onDownload(startChapter, endChapter)
|
||
dismiss()
|
||
} label: {
|
||
HStack {
|
||
Spacer()
|
||
Image(systemName: "arrow.down.circle.fill")
|
||
Text("Download \(selectedCount) Chapters")
|
||
Spacer()
|
||
}
|
||
}
|
||
.disabled(selectedCount == 0)
|
||
}
|
||
}
|
||
.navigationTitle("Download Range")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
Button("Cancel") { dismiss() }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|