Files
libnovel/ios/LibNovelV2/Views/Player/PlayerViews.swift
Admin 7413313100
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
fix: update integration_test.go to match server.New signature (version, commit args)
2026-03-14 14:25:46 +05:00

1827 lines
76 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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() }
}
}
}
}
}