Files
libnovel/ios/LibNovel/LibNovel/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

2061 lines
85 KiB
Swift
Raw 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 Kingfisher // used directly for blurred background in FullPlayerView
import AVKit // for AVRoutePickerView (AirPlay)
// MARK: - Mini player bar (Spotify-style, fixed above tab bar)
// Replaces the old FloatingPlayerButton + CompactPlayerControls.
// Swipe up → full player. Swipe down → stop. Tap cover/track info → full player.
// All transport buttons are plain Buttons with no competing gesture recognisers.
struct MiniPlayerBar: View {
@Binding var showFullPlayer: Bool
@EnvironmentObject var audioPlayer: AudioPlayerService
@EnvironmentObject var downloadService: AudioDownloadService
/// Live vertical drag offset while the user swipes up/down.
@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) {
// ── Thin amber progress strip at the very top ────────────────
MiniBarProgress(progress: audioPlayer.progress)
// ── Main content row ─────────────────────────────────────────
HStack(spacing: 12) {
// Cover art — tap opens full player
Button { showFullPlayer = true } label: {
AsyncCoverImage(url: audioPlayer.coverURL)
.frame(width: 44, height: 44)
.clipShape(RoundedRectangle(cornerRadius: 9))
.shadow(color: .black.opacity(0.18), radius: 6, y: 2)
}
.buttonStyle(.plain)
// Track info — tap opens full player
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)
// ── Transport controls ───────────────────────────────────
// Previous chapter
Button {
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: 36)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(audioPlayer.prevChapter == nil)
.opacity(audioPlayer.prevChapter == nil ? 0.3 : 1)
// Play / pause (isolated observer — only re-renders this button)
MiniBarPlayPause(progress: audioPlayer.progress) {
audioPlayer.togglePlayPause()
UIImpactFeedbackGenerator(style: .light).impactOccurred()
}
.disabled(audioPlayer.status == .generating)
// Next chapter
Button {
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: 36)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(audioPlayer.nextChapter == nil)
.opacity(audioPlayer.nextChapter == nil ? 0.3 : 1)
}
.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 }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { 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 (observes PlaybackProgress directly)
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 (observes PlaybackProgress directly)
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: 36)
.contentShape(Rectangle())
.contentTransition(.symbolEffect(.replace.downUp))
}
.buttonStyle(.plain)
}
}
// CompactPlayerControls removed — replaced by MiniPlayerBar above.
// MARK: - Full player sheet
struct FullPlayerView: View {
@EnvironmentObject var audioPlayer: AudioPlayerService
@EnvironmentObject var downloadService: AudioDownloadService
@EnvironmentObject var authStore: AuthStore
/// Called when the view wants to close itself (Done button or drag-to-dismiss).
var onDismiss: () -> Void = {}
@State private var showingChaptersList = false
@State private var showingSleepTimer = false
@State private var showingVoiceSelector = false
@StateObject 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 {
// ── Background: blurred cover art ──────────────────────────
KFImage(URL(string: audioPlayer.coverURL))
.resizable()
.scaledToFill()
.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) // re-render background on track change
// ── Content ────────────────────────────────────────────────
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 {
KFImage(URL(string: audioPlayer.coverURL))
.resizable()
.placeholder {
RoundedRectangle(cornerRadius: 22)
.fill(.white.opacity(0.08))
.overlay(
Image(systemName: "book.closed")
.font(.system(size: 56))
.foregroundStyle(.white.opacity(0.25))
)
}
.scaledToFill()
.frame(width: coverSize, height: coverSize)
.clipShape(RoundedRectangle(cornerRadius: 22))
.shadow(color: .black.opacity(0.55), radius: 36, y: 18)
.overlay(
RoundedRectangle(cornerRadius: 22)
.fill(Color.black.opacity(audioPlayer.status == .generating ? 0.5 : 0))
.animation(.easeInOut(duration: 0.3), value: audioPlayer.status == .generating)
)
// Subtle scale pulse while playing
.scaleEffect(audioPlayer.progress.isPlaying && !coverAppeared ? 1.0 : (audioPlayer.progress.isPlaying ? 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 — bottom-left corner
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))
}
// Download badge
if let progress = currentDownloadProgress {
Label("\(Int(progress.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 {
Task {
try? await downloadService.download(
slug: audioPlayer.slug,
chapter: audioPlayer.chapter,
voice: audioPlayer.voice
)
}
UIImpactFeedbackGenerator(style: .light).impactOccurred()
} label: {
Image(systemName: "arrow.down.circle")
.font(.system(size: 24))
.foregroundStyle(.white.opacity(0.65))
}
.buttonStyle(.plain)
}
// 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))
}
.buttonStyle(.plain)
}
.padding(.horizontal, 28)
.padding(.top, 22)
// ── Seek bar ───────────────────────────────────────────
PlayerProgressSection(
progress: audioPlayer.progress,
onSeek: { audioPlayer.seek(to: $0) }
)
.padding(.top, 18)
.opacity(audioPlayer.status == .generating ? 0.3 : 1)
.allowsHitTesting(audioPlayer.status != .generating)
// ── Transport controls ─────────────────────────────────
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,
onToggle: {
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)
// Speed
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
Button {
withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) {
showingVoiceSelector.toggle()
}
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)
// Collapse
Button { 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)
// Chapters list
Button { showingChaptersList = true } label: {
Image(systemName: "list.bullet")
.font(.system(size: 20))
.foregroundStyle(.white.opacity(0.65))
.frame(maxWidth: .infinity)
.frame(height: 44)
}
.buttonStyle(.plain)
// Sleep timer
Button { 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)
}
.padding(.horizontal, 12)
.padding(.bottom, showingVoiceSelector ? 0 : 8)
// ── Voice selection panel (expandable) ────────────────
if showingVoiceSelector {
VoiceSelectorPanel(
voices: voiceVM.voices,
selectedVoice: audioPlayer.voice,
playingVoice: voiceVM.playingVoice,
voiceVM: voiceVM,
onSelectVoice: { voice in
voiceVM.stopSample()
audioPlayer.voice = voice
BookVoicePreferences.shared.setVoice(voice, for: audioPlayer.slug)
Task {
var settings = authStore.settings
settings.voice = voice
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) {
ChaptersListSheet(
chapters: audioPlayer.chapters,
currentChapter: audioPlayer.chapter,
onChapterSelect: { selectedChapter in
showingChaptersList = false
guard selectedChapter != audioPlayer.chapter else { return }
let chapterTitle = audioPlayer.chapters
.first(where: { $0.number == selectedChapter })?.title ?? ""
let nextChapter = audioPlayer.chapters
.filter({ $0.number > selectedChapter })
.min(by: { $0.number < $1.number })?.number
let prevChapter: Int? = selectedChapter > 1 ? selectedChapter - 1 : nil
audioPlayer.load(
slug: audioPlayer.slug,
chapter: selectedChapter,
chapterTitle: chapterTitle,
bookTitle: audioPlayer.bookTitle,
coverURL: audioPlayer.coverURL,
voice: audioPlayer.voice,
speed: audioPlayer.speed,
chapters: audioPlayer.chapters,
nextChapter: nextChapter,
prevChapter: prevChapter
)
}
)
.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 components = audioPlayer.voice.split(separator: "_")
if components.count > 1 { return String(components[1]).capitalized }
return audioPlayer.voice.capitalized
}
private var sleepTimerIcon: String {
audioPlayer.sleepTimer != nil ? "moon.zzz.fill" : "moon.zzz"
}
}
// MARK: - Small 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: - Medium 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(named: "AccentColor") ?? 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: - 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 card ──────────────────────────────────────────
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) { minutes in
let isSelected: Bool = {
if case .minutes(let m) = audioPlayer.sleepTimer { return m == minutes }
return false
}()
TimerOptionRow(
label: formatTimerOption(minutes),
systemImage: "clock",
isSelected: isSelected
) {
audioPlayer.setSleepTimer(.minutes(minutes))
dismiss()
}
if minutes != 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 hours = minutes / 60
return "\(hours) \(hours == 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))
}
}
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(action: {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
action()
}) {
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: - Chapters List Sheet
// Apple Books-style: chapters grouped into blocks of 100 with a sticky jump
// bar along the right edge. A search bar filters by number or title.
struct ChaptersListSheet: View {
let chapters: [ChapterIndexBrief]
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 showingDownloadAll = false
/// The block label the jump bar is currently scrolling to (e.g. "1–100").
@State private var activeBlock: String? = nil
// MARK: Derived data
/// Count of downloaded chapters for this book
private var downloadedCount: Int {
chapters.filter { ch in
downloadService.isDownloaded(
slug: audioPlayer.slug,
chapter: ch.number,
voice: audioPlayer.voice
)
}.count
}
/// Count of downloading chapters
private var downloadingCount: Int {
downloadService.downloads.filter { key, _ in
key.hasPrefix("\(audioPlayer.slug)::")
}.count
}
/// Chapters matching the current search query (or all chapters if empty).
private var filtered: [ChapterIndexBrief] {
var result = chapters
// Apply offline filter
if filterOfflineOnly {
result = result.filter { ch in
downloadService.isDownloaded(
slug: audioPlayer.slug,
chapter: ch.number,
voice: audioPlayer.voice
)
}
}
// Apply search filter
if !searchText.isEmpty {
let q = searchText.lowercased()
result = result.filter {
"\($0.number)".contains(q) || $0.title.lowercased().contains(q)
}
}
return result
}
/// Chapters grouped into blocks of 100: ["1–100": [...], "101–200": [...], …]
/// When the user is searching or filtering we use a single "Results" group so the jump
/// bar hides and the flat list is shown directly.
private var groups: [(label: String, chapters: [ChapterIndexBrief])] {
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
// Round down to the nearest block boundary for the first block start.
let firstBlock = ((minN - 1) / blockSize) * blockSize + 1
var result: [(label: String, chapters: [ChapterIndexBrief])] = []
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
}
/// Jump-bar labels (shown only when not searching/filtering).
private var jumpLabels: [String] { groups.map(\.label) }
// MARK: Body
var body: some View {
NavigationStack {
ZStack(alignment: .trailing) {
// ── Main chapter list ──────────────────────────────────────
List {
// Download summary section
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 {
showingDownloadAll = 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)
}
}
// Quick filter toggle
Toggle("Show offline only", isOn: $filterOfflineOnly)
.font(.subheadline)
.tint(.amber)
}
.padding(.vertical, 8)
}
}
ForEach(groups, id: \.label) { group in
// Section header — shows block range (e.g. "1–100")
Section {
ForEach(group.chapters, id: \.number) { ch in
ChapterRow(
chapter: ch,
isCurrent: ch.number == currentChapter,
onSelect: { onChapterSelect(ch.number) }
)
.id(group.label) // anchor for jump-bar scrollTo
}
} 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)
// ── Right-edge jump bar (hidden while searching/filtering) ───────────
if searchText.isEmpty && !filterOfflineOnly && jumpLabels.count > 1 {
JumpBar(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: $showingDownloadAll) {
DownloadManagementSheet(
chapters: chapters,
slug: audioPlayer.slug,
voice: Binding(
get: { audioPlayer.voice },
set: { audioPlayer.voice = $0 }
)
)
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
}
// Scroll to the currently playing chapter's block on first appear.
.onAppear {
if let block = groups.first(where: { g in
g.chapters.contains(where: { $0.number == currentChapter })
}) {
activeBlock = block.label
}
}
}
}
}
// MARK: - Individual chapter row with download status
private struct ChapterRow: View {
let chapter: ChapterIndexBrief
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 with download indicator
ZStack {
Text("\(chapter.number)")
.font(.caption.bold())
.foregroundStyle(isCurrent ? .white : .secondary)
.frame(width: 40, height: 40)
.background(
Circle().fill(isCurrent ? Color.amber : Color(.systemGray5))
)
// Download progress ring
if isDownloading, let progress = downloadProgress {
Circle()
.trim(from: 0, to: progress.progress)
.stroke(Color.blue, style: StrokeStyle(lineWidth: 2, lineCap: .round))
.rotationEffect(.degrees(-90))
.frame(width: 44, height: 44)
.animation(.easeInOut(duration: 0.3), value: progress.progress)
}
}
// Title + status subtitle
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(.amber)
.symbolEffect(.variableColor.cumulative, isActive: isCurrent)
}
if isDownloading, let progress = downloadProgress {
Label("\(Int(progress.progress * 100))%", systemImage: "arrow.down.circle")
.font(.caption2)
.foregroundStyle(.blue)
} else if isDownloaded {
Label("Downloaded", systemImage: "checkmark.circle.fill")
.font(.caption2)
.foregroundStyle(.green)
}
}
}
Spacer()
// Right side indicator
if isCurrent {
Image(systemName: "waveform")
.font(.caption.bold())
.foregroundStyle(.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) {
// Download/Delete action
if isDownloaded {
Button(role: .destructive) {
Task {
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: - Right-edge jump bar
// A thin vertical strip on the right side of the sheet with block labels.
// Tapping or dragging a label jumps the list to that block instantly —
// exactly like the Contacts A–Z bar or Apple Books chapter scrubber.
private struct JumpBar: View {
let labels: [String]
let currentChapter: Int
let groups: [(label: String, chapters: [ChapterIndexBrief])]
let onSelect: (String) -> Void
@State private var isDragging = false
/// Short display label for each block: "1–100" → "1" etc.
private func shortLabel(_ full: String) -> String {
full.components(separatedBy: "–").first ?? full
}
/// Which block contains the currently playing chapter.
private var currentBlock: String? {
groups.first(where: { g in g.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 itemHeight: CGFloat = 28
let index = Int(value.location.y / itemHeight)
let clamped = max(0, min(labels.count - 1, index))
onSelect(labels[clamped])
}
.onEnded { _ in isDragging = false }
)
.animation(.easeInOut(duration: 0.15), value: isDragging)
}
}
// MARK: - Custom seek slider
// A thicker, rounded-thumb slider that matches the amber design language.
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 clampedFraction = max(0, min(1, fraction))
let filled = width * clampedFraction
let thumbSize: CGFloat = isDragging ? 26 : 20
let trackHeight: CGFloat = isDragging ? 5 : 4
ZStack(alignment: .leading) {
// Track background
Capsule()
.fill(Color.white.opacity(0.2))
.frame(height: trackHeight)
// Filled portion — amber gradient
Capsule()
.fill(
LinearGradient(
colors: [Color.amber.opacity(0.9), Color.amber],
startPoint: .leading,
endPoint: .trailing
)
)
.frame(width: max(filled, thumbSize / 2), height: trackHeight)
// Thumb
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) // generous touch target
.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
let clamped = max(0, min(1, raw))
value = range.lowerBound + clamped * (range.upperBound - range.lowerBound)
}
.onEnded { _ in
isDragging = false
didFireHaptic = false
}
)
}
.frame(height: 36)
}
}
// MARK: - Isolated mini-player progress bar background
private struct MiniPlayerProgressBar: View {
@ObservedObject var progress: PlaybackProgress
var body: some View {
GeometryReader { geo in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 40)
.fill(Color.white.opacity(0.2))
RoundedRectangle(cornerRadius: 6)
.fill(Color.amber.opacity(0.3))
.frame(width: max(0, geo.size.width * fraction))
}
.clipShape(RoundedRectangle(cornerRadius: 40))
}
}
private var fraction: CGFloat {
guard progress.duration > 0 else { return 0 }
return CGFloat(progress.currentTime / progress.duration)
}
}
// MARK: - Isolated progress section (seek bar + timestamps)
// Observes PlaybackProgress directly so the 0.5-second time ticks only
// invalidate this small view — not the menus or controls around it.
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: - Isolated play/pause button
// Observes PlaybackProgress so isPlaying changes only re-render this button.
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 {
// Outer glow ring (visible while playing)
Circle()
.fill(Color.amber.opacity(progress.isPlaying ? 0.18 : 0))
.frame(width: 80, height: 80)
.animation(.easeInOut(duration: 0.35), value: progress.isPlaying)
// Main fill circle
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: {})
}
}
// MARK: - Isolated mini-player play/pause button
private struct MiniPlayerPlayPauseButton: View {
@ObservedObject var progress: PlaybackProgress
let onToggle: () -> Void
var body: some View {
Button { onToggle() } label: {
Image(systemName: progress.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
// MARK: - Download Management Sheet
struct DownloadManagementSheet: View {
let chapters: [ChapterIndexBrief]
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
@StateObject private var voiceVM = VoiceSelectionViewModel()
// Range selection state
@State private var rangeStart: Int = 1
@State private var rangeEnd: Int = 1
private var downloadedChapters: [ChapterIndexBrief] {
chapters.filter { ch in
downloadService.isDownloaded(slug: slug, chapter: ch.number, voice: voice)
}
}
private var notDownloadedChapters: [ChapterIndexBrief] {
chapters.filter { ch in
!downloadService.isDownloaded(slug: slug, chapter: ch.number, voice: voice)
}
}
var body: some View {
NavigationStack {
List {
// Voice info section
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))
.foregroundStyle(.primary)
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. All downloads will use the selected voice for this book.")
.font(.caption)
}
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()
// Circular completion indicator
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)
}
if downloadedChapters.count > 0 {
Section {
ForEach(downloadedChapters, id: \.number) { chapter in
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Chapter \(chapter.number)")
.font(.subheadline.weight(.semibold))
Text(chapter.title.strippingTrailingDate())
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer()
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
}
}
.onDelete { indexSet in
deleteChapters(at: indexSet, from: downloadedChapters)
}
} 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) {
deleteAllDownloads()
}
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
// Save per-book voice override
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 downloadRange(from start: Int, to end: Int) {
isDownloadingAll = true
Task {
let chaptersToDownload = notDownloadedChapters.filter { ch in
ch.number >= start && ch.number <= end
}
for chapter in chaptersToDownload {
try? await downloadService.download(slug: slug, chapter: chapter.number, voice: voice)
try? await Task.sleep(nanoseconds: 500_000_000) // 0.5s
}
await MainActor.run {
isDownloadingAll = false
}
}
}
private func downloadAllRemaining() {
isDownloadingAll = true
Task {
for chapter in notDownloadedChapters {
try? await downloadService.download(slug: slug, chapter: chapter.number, voice: voice)
// Small delay to avoid overwhelming the API
try? await Task.sleep(nanoseconds: 500_000_000) // 0.5s
}
await MainActor.run {
isDownloadingAll = false
}
}
}
private func deleteChapters(at indexSet: IndexSet, from chapters: [ChapterIndexBrief]) {
for index in indexSet {
let chapter = chapters[index]
try? downloadService.deleteDownload(slug: slug, chapter: chapter.number, voice: voice)
}
}
private func deleteAllDownloads() {
for chapter in downloadedChapters {
try? downloadService.deleteDownload(slug: slug, chapter: chapter.number, voice: voice)
}
}
// Voice label formatting (matches VoiceSelectionViewModel)
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: " ")
// Parse prefix for language/gender info
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 Panel (inline, expandable)
private struct VoiceSelectorPanel: View {
let voices: [String]
let selectedVoice: String
let playingVoice: String?
let voiceVM: VoiceSelectionViewModel
let onSelectVoice: (String) -> Void
var body: some View {
VStack(spacing: 0) {
// Header
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)
// Voice list (scrollable)
ScrollView {
VStack(spacing: 0) {
ForEach(voices, id: \.self) { voice in
VoiceOptionRow(
voice: voice,
isSelected: selectedVoice == voice,
isPlaying: playingVoice == voice,
voiceLabel: voiceVM.voiceLabel(voice),
voiceId: voiceVM.voiceId(voice),
onSelect: { onSelectVoice(voice) },
onPlaySample: {
Task { await voiceVM.playSample(voice) }
}
)
if voice != voices.last {
Divider()
.overlay(Color.white.opacity(0.08))
.padding(.leading, 52)
}
}
}
}
.frame(maxHeight: 220)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14))
.padding(.horizontal, 16)
// Footer note
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 (for inline panel)
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) {
// Selection indicator with spring bounce
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)
// Voice info
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()
// Play/Stop sample button
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))
}
.buttonStyle(.plain)
}
.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: - Voice Selector Sheet (for download management)
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) {
// Checkmark for selected voice
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()
// Play/stop button
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)
}
.buttonStyle(.plain)
}
.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: [ChapterIndexBrief]
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: [ChapterIndexBrief], slug: String, voice: String, onDownload: @escaping (Int, Int) -> Void) {
self.chapters = chapters
self.slug = slug
self.voice = voice
self.onDownload = onDownload
let firstChapter = chapters.first?.number ?? 1
let lastChapter = chapters.last?.number ?? 1
_startChapter = State(initialValue: firstChapter)
_endChapter = State(initialValue: min(firstChapter + 9, lastChapter))
}
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) { num in
Text("Chapter \(num)").tag(num)
}
}
Picker("End Chapter", selection: $endChapter) {
ForEach(chapterRange.filter { $0 >= startChapter }, id: \.self) { num in
Text("Chapter \(num)").tag(num)
}
}
} 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() }
}
}
}
}
}