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
2061 lines
85 KiB
Swift
2061 lines
85 KiB
Swift
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() }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|