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 @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: 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() } } } } } }