Some checks failed
CI / UI / Build (pull_request) Failing after 6s
CI / Scraper / Test (pull_request) Failing after 6s
CI / Scraper / Lint (pull_request) Failing after 9s
CI / Scraper / Build (pull_request) Has been skipped
iOS CI / Build (push) Has been cancelled
iOS CI / Test (push) Has been cancelled
iOS CI / Build (pull_request) Failing after 2s
iOS CI / Test (pull_request) Has been skipped
940 lines
39 KiB
Swift
940 lines
39 KiB
Swift
import SwiftUI
|
||
import Kingfisher // used directly for blurred background in FullPlayerView
|
||
import AVKit // for AVRoutePickerView (AirPlay)
|
||
|
||
// MARK: - Mini player bar (pinned above tab bar)
|
||
|
||
struct MiniPlayerView: View {
|
||
@Binding var showFullPlayer: Bool
|
||
@EnvironmentObject var audioPlayer: AudioPlayerService
|
||
|
||
/// Live drag offset while the user is swiping up/down (negative = moving up).
|
||
@State private var dragOffset: CGFloat = 0
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
// Static progress bar as background (full bleed behind content)
|
||
MiniPlayerProgressBar(progress: audioPlayer.progress)
|
||
|
||
// Content layer
|
||
HStack(spacing: 16) {
|
||
// Cover thumbnail with rounded corners
|
||
Button { showFullPlayer = true } label: {
|
||
AsyncCoverImage(url: audioPlayer.coverURL)
|
||
.frame(width: 56, height: 56)
|
||
.clipShape(RoundedRectangle(cornerRadius: 40))
|
||
}
|
||
.buttonStyle(.plain)
|
||
|
||
// Track info
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text(chapterLabel)
|
||
.font(.subheadline.weight(.semibold))
|
||
.lineLimit(1)
|
||
.foregroundStyle(.primary)
|
||
Text(audioPlayer.bookTitle)
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
.lineLimit(1)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.onTapGesture { showFullPlayer = true }
|
||
|
||
Spacer(minLength: 8)
|
||
|
||
// Control buttons - compact group
|
||
HStack(spacing: 12) {
|
||
// Previous chapter button
|
||
if audioPlayer.status == .ready {
|
||
Button {
|
||
if let prev = audioPlayer.absolutePrevChapter {
|
||
NotificationCenter.default.post(
|
||
name: .skipToPrevChapter,
|
||
object: nil,
|
||
userInfo: ["prev": prev]
|
||
)
|
||
}
|
||
} label: {
|
||
Image(systemName: "backward.end.fill")
|
||
.font(.system(size: 20, weight: .semibold))
|
||
.foregroundStyle(.white)
|
||
.frame(width: 40, height: 40)
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(audioPlayer.absolutePrevChapter == nil)
|
||
.opacity(audioPlayer.absolutePrevChapter == nil ? 0.4 : 1.0)
|
||
}
|
||
|
||
// Status indicator or play/pause control
|
||
Group {
|
||
switch audioPlayer.status {
|
||
case .generating:
|
||
ProgressView()
|
||
.tint(.white)
|
||
.scaleEffect(1.0)
|
||
.frame(width: 44, height: 44)
|
||
case .ready:
|
||
MiniPlayerPlayPauseButton(
|
||
progress: audioPlayer.progress,
|
||
onToggle: { audioPlayer.togglePlayPause() }
|
||
)
|
||
case .error:
|
||
Image(systemName: "exclamationmark.circle.fill")
|
||
.font(.system(size: 24))
|
||
.foregroundStyle(.red)
|
||
.frame(width: 44, height: 44)
|
||
default:
|
||
EmptyView()
|
||
}
|
||
}
|
||
|
||
// Next chapter button
|
||
if audioPlayer.status == .ready {
|
||
Button {
|
||
if let next = audioPlayer.absoluteNextChapter {
|
||
NotificationCenter.default.post(
|
||
name: .skipToNextChapter,
|
||
object: nil,
|
||
userInfo: ["next": next]
|
||
)
|
||
}
|
||
} label: {
|
||
ZStack {
|
||
Image(systemName: "forward.end.fill")
|
||
.font(.system(size: 20, weight: .semibold))
|
||
.foregroundStyle(.white)
|
||
|
||
// Show small loading indicator if next chapter is being prefetched
|
||
if audioPlayer.nextPrefetchStatus == .prefetching {
|
||
VStack {
|
||
Spacer()
|
||
HStack {
|
||
Spacer()
|
||
ProgressView()
|
||
.scaleEffect(0.5)
|
||
.tint(.amber)
|
||
.padding(2)
|
||
.background(Circle().fill(.black.opacity(0.6)))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.frame(width: 40, height: 40)
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(audioPlayer.absoluteNextChapter == nil)
|
||
.opacity(audioPlayer.absoluteNextChapter == nil ? 0.4 : 1.0)
|
||
}
|
||
}
|
||
}
|
||
.padding(.horizontal, 20)
|
||
.padding(.vertical, 12)
|
||
}
|
||
.background(
|
||
// Dark rounded background (pill-shaped with full circular ends)
|
||
RoundedRectangle(cornerRadius: 40)
|
||
.fill(.ultraThinMaterial)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 40)
|
||
.fill(Color.black.opacity(0.3))
|
||
)
|
||
)
|
||
.frame(height: 20)
|
||
.shadow(color: .black.opacity(0.3), radius: 12, y: 4)
|
||
// Follow finger in both directions while dragging vertically
|
||
.offset(y: dragOffset)
|
||
// Visual feedback: fade out and scale down slightly when dragging down to dismiss
|
||
.opacity(dragOffset > 0 ? max(0.3, 1.0 - (dragOffset / 200)) : 1.0)
|
||
.scaleEffect(dragOffset > 0 ? max(0.95, 1.0 - (dragOffset / 800)) : 1.0)
|
||
.simultaneousGesture(
|
||
DragGesture(minimumDistance: 10, coordinateSpace: .local)
|
||
.onChanged { value in
|
||
// Only handle vertical drags (not horizontal seeks)
|
||
if abs(value.translation.height) > abs(value.translation.width) {
|
||
if value.translation.height < 0 {
|
||
// Upward swipe: rubberband resistance (opens full player)
|
||
dragOffset = value.translation.height * 0.4
|
||
} else {
|
||
// Downward swipe: less resistance for easier dismiss
|
||
dragOffset = value.translation.height * 0.8
|
||
}
|
||
}
|
||
}
|
||
.onEnded { value in
|
||
let velocity = value.predictedEndTranslation.height - value.translation.height
|
||
if value.translation.height < -40 || velocity < -200 {
|
||
// Swipe up: open full player
|
||
withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) {
|
||
dragOffset = 0
|
||
}
|
||
showFullPlayer = true
|
||
} else if value.translation.height > 60 || velocity > 200 {
|
||
// Swipe down: dismiss with animation
|
||
withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) {
|
||
dragOffset = 300 // Slide out completely
|
||
}
|
||
// Stop audio after animation starts
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||
audioPlayer.stop()
|
||
}
|
||
} else {
|
||
// Not enough distance: spring back
|
||
withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) {
|
||
dragOffset = 0
|
||
}
|
||
}
|
||
}
|
||
)
|
||
}
|
||
|
||
private var chapterLabel: String {
|
||
let raw = audioPlayer.chapterTitle.isEmpty
|
||
? "Chapter \(audioPlayer.chapter)"
|
||
: audioPlayer.chapterTitle
|
||
return raw.strippingTrailingDate()
|
||
}
|
||
}
|
||
|
||
// MARK: - Full player sheet
|
||
|
||
struct FullPlayerView: View {
|
||
@EnvironmentObject var audioPlayer: AudioPlayerService
|
||
/// Called when the view wants to close itself (Done button or drag-to-dismiss).
|
||
var onDismiss: () -> Void = {}
|
||
|
||
@State private var showingSpeedMenu = false
|
||
@State private var showingChaptersList = false
|
||
@State private var showingSleepTimer = false
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
// ── Background: blurred cover art ──────────────────────────────
|
||
GeometryReader { geo in
|
||
KFImage(URL(string: audioPlayer.coverURL))
|
||
.resizable()
|
||
.scaledToFill()
|
||
.frame(width: geo.size.width, height: geo.size.height)
|
||
.clipped()
|
||
.blur(radius: 40, opaque: true)
|
||
.overlay(Color.black.opacity(0.55))
|
||
.ignoresSafeArea()
|
||
}
|
||
.ignoresSafeArea()
|
||
|
||
// ── Content ────────────────────────────────────────────────────
|
||
VStack(spacing: 0) {
|
||
// Drag handle pill — visual cue that you can swipe down to close
|
||
Capsule()
|
||
.fill(Color.white.opacity(0.35))
|
||
.frame(width: 36, height: 4)
|
||
.padding(.top, 12)
|
||
.padding(.bottom, 16)
|
||
|
||
// Cover art with watermark
|
||
ZStack(alignment: .bottomLeading) {
|
||
KFImage(URL(string: audioPlayer.coverURL))
|
||
.resizable()
|
||
.placeholder {
|
||
RoundedRectangle(cornerRadius: 18)
|
||
.fill(.white.opacity(0.1))
|
||
.overlay(
|
||
Image(systemName: "book.closed")
|
||
.font(.system(size: 48))
|
||
.foregroundStyle(.white.opacity(0.4))
|
||
)
|
||
}
|
||
.scaledToFill()
|
||
.frame(width: 240, height: 240)
|
||
.clipShape(RoundedRectangle(cornerRadius: 18))
|
||
.shadow(color: .black.opacity(0.5), radius: 24, y: 12)
|
||
|
||
// Watermark (voice name from audio player)
|
||
Text(voiceName)
|
||
.font(.custom("Snell Roundhand", size: 20))
|
||
.foregroundStyle(.white.opacity(0.7))
|
||
.shadow(color: .black.opacity(0.4), radius: 2)
|
||
.padding(14)
|
||
}
|
||
.padding(.horizontal, 48)
|
||
|
||
// Title block
|
||
VStack(spacing: 4) {
|
||
Text((audioPlayer.chapterTitle.isEmpty ? "Chapter \(audioPlayer.chapter)" : audioPlayer.chapterTitle).strippingTrailingDate())
|
||
.font(.title3.weight(.bold))
|
||
.foregroundStyle(.white)
|
||
.multilineTextAlignment(.center)
|
||
.lineLimit(2)
|
||
Text(audioPlayer.bookTitle)
|
||
.font(.subheadline)
|
||
.foregroundStyle(.white.opacity(0.65))
|
||
.lineLimit(1)
|
||
}
|
||
.padding(.horizontal, 32)
|
||
.padding(.top, 20)
|
||
|
||
// Action buttons row + metadata inline
|
||
HStack(spacing: 0) {
|
||
Spacer()
|
||
|
||
// Metadata pill (only when ready)
|
||
if audioPlayer.status != .generating {
|
||
Text("\(yearText) · \(cacheStatusText) · OPUS")
|
||
.font(.caption2)
|
||
.foregroundStyle(.white.opacity(0.35))
|
||
.padding(.horizontal, 8)
|
||
}
|
||
|
||
Menu {
|
||
Button {
|
||
audioPlayer.autoNext.toggle()
|
||
} label: {
|
||
Label(
|
||
audioPlayer.autoNext ? "Disable Auto-next" : "Enable Auto-next",
|
||
systemImage: audioPlayer.autoNext ? "checkmark" : ""
|
||
)
|
||
}
|
||
} label: {
|
||
Image(systemName: "ellipsis.circle")
|
||
.font(.system(size: 22))
|
||
.foregroundStyle(.white.opacity(0.6))
|
||
.frame(width: 40, height: 40)
|
||
}
|
||
.buttonStyle(.plain)
|
||
|
||
Spacer()
|
||
}
|
||
.padding(.top, 10)
|
||
|
||
// Seek bar — hidden while generating
|
||
if audioPlayer.status != .generating {
|
||
PlayerProgressSection(
|
||
progress: audioPlayer.progress,
|
||
onSeek: { audioPlayer.seek(to: $0) }
|
||
)
|
||
} else {
|
||
// Generating state: compact progress indicator with label
|
||
VStack(spacing: 8) {
|
||
ProgressView()
|
||
.tint(.white.opacity(0.7))
|
||
.scaleEffect(1.1)
|
||
Text("Generating audio…")
|
||
.font(.caption)
|
||
.foregroundStyle(.white.opacity(0.5))
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.top, 20)
|
||
.padding(.bottom, 4)
|
||
}
|
||
|
||
// Controls
|
||
HStack(spacing: 0) {
|
||
// ← skip back 15s
|
||
Button { audioPlayer.skip(by: -15) } label: {
|
||
Image(systemName: "gobackward.15")
|
||
.font(.system(size: 22, weight: .regular))
|
||
.foregroundStyle(.white.opacity(audioPlayer.status == .generating ? 0.3 : 0.9))
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(audioPlayer.status == .generating)
|
||
|
||
// ← previous chapter
|
||
Button {
|
||
if let prev = audioPlayer.absolutePrevChapter {
|
||
onDismiss()
|
||
NotificationCenter.default.post(
|
||
name: .skipToPrevChapter,
|
||
object: nil,
|
||
userInfo: ["prev": prev]
|
||
)
|
||
}
|
||
} label: {
|
||
Image(systemName: "backward.end.fill")
|
||
.font(.system(size: 28, weight: .regular))
|
||
.foregroundStyle(.white.opacity(0.9))
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(audioPlayer.absolutePrevChapter == nil)
|
||
.opacity(audioPlayer.absolutePrevChapter == nil ? 0.4 : 1.0)
|
||
|
||
// play / pause — large circle button
|
||
PlayerPlayPauseButton(
|
||
progress: audioPlayer.progress,
|
||
isGenerating: audioPlayer.status == .generating,
|
||
onToggle: { audioPlayer.togglePlayPause() }
|
||
)
|
||
|
||
// → next chapter
|
||
Button {
|
||
if let next = audioPlayer.absoluteNextChapter {
|
||
onDismiss()
|
||
NotificationCenter.default.post(
|
||
name: .skipToNextChapter,
|
||
object: nil,
|
||
userInfo: ["next": next]
|
||
)
|
||
}
|
||
} label: {
|
||
ZStack {
|
||
Image(systemName: "forward.end.fill")
|
||
.font(.system(size: 28, weight: .regular))
|
||
.foregroundStyle(.white.opacity(0.9))
|
||
|
||
if audioPlayer.nextPrefetchStatus == .prefetching {
|
||
VStack {
|
||
Spacer()
|
||
HStack {
|
||
Spacer()
|
||
ProgressView()
|
||
.scaleEffect(0.55)
|
||
.tint(.amber)
|
||
.padding(3)
|
||
.background(Circle().fill(.black.opacity(0.6)))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(audioPlayer.absoluteNextChapter == nil)
|
||
.opacity(audioPlayer.absoluteNextChapter == nil ? 0.4 : 1.0)
|
||
|
||
// → skip forward 15s
|
||
Button { audioPlayer.skip(by: 15) } label: {
|
||
Image(systemName: "goforward.15")
|
||
.font(.system(size: 22, weight: .regular))
|
||
.foregroundStyle(.white.opacity(audioPlayer.status == .generating ? 0.3 : 0.9))
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(audioPlayer.status == .generating)
|
||
}
|
||
.padding(.horizontal, 20)
|
||
.padding(.top, 20)
|
||
.padding(.bottom, 20)
|
||
|
||
// Bottom toolbar
|
||
HStack(spacing: 0) {
|
||
// AirPlay
|
||
AirPlayButton()
|
||
.frame(width: 22, height: 22)
|
||
.frame(maxWidth: .infinity)
|
||
|
||
// Speed control
|
||
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: {
|
||
// Show current speed as a badge instead of gear icon
|
||
Text("\(audioPlayer.speed, specifier: "%.2g")×")
|
||
.font(.system(size: 14, weight: .semibold))
|
||
.foregroundStyle(.white.opacity(0.7))
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
.buttonStyle(.plain)
|
||
|
||
// Collapse
|
||
Button { onDismiss() } label: {
|
||
Image(systemName: "chevron.down")
|
||
.font(.system(size: 22, weight: .semibold))
|
||
.foregroundStyle(.white.opacity(0.7))
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
.buttonStyle(.plain)
|
||
|
||
// Queue (show chapters list)
|
||
Button { showingChaptersList = true } label: {
|
||
Image(systemName: "list.bullet")
|
||
.font(.system(size: 22))
|
||
.foregroundStyle(.white.opacity(0.7))
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
.buttonStyle(.plain)
|
||
|
||
// Sleep timer
|
||
Button { showingSleepTimer = true } label: {
|
||
Image(systemName: sleepTimerIcon)
|
||
.font(.system(size: 22))
|
||
.foregroundStyle(audioPlayer.sleepTimer != nil ? .amber : .white.opacity(0.7))
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.padding(.bottom, 12)
|
||
}
|
||
.ignoresSafeArea(edges: .bottom)
|
||
}
|
||
.sheet(isPresented: $showingChaptersList) {
|
||
ChaptersListSheet(
|
||
chapters: audioPlayer.chapters,
|
||
currentChapter: audioPlayer.chapter,
|
||
onChapterSelect: { selectedChapter in
|
||
showingChaptersList = false
|
||
guard selectedChapter != audioPlayer.chapter else { return }
|
||
let currentAudioChapter = audioPlayer.chapter
|
||
|
||
// Find the chapter metadata from the loaded list
|
||
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
|
||
|
||
// Load & start playing the selected chapter directly
|
||
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
|
||
)
|
||
|
||
// Also navigate the text reader if it's open
|
||
let notifName: Notification.Name = selectedChapter > currentAudioChapter
|
||
? .skipToNextChapter
|
||
: .skipToPrevChapter
|
||
let key = selectedChapter > currentAudioChapter ? "next" : "prev"
|
||
NotificationCenter.default.post(
|
||
name: notifName,
|
||
object: nil,
|
||
userInfo: [key: selectedChapter]
|
||
)
|
||
}
|
||
)
|
||
.presentationDetents([.medium, .large])
|
||
.presentationDragIndicator(.visible)
|
||
}
|
||
.sheet(isPresented: $showingSleepTimer) {
|
||
SleepTimerSheet(audioPlayer: audioPlayer)
|
||
.presentationDetents([.height(500)])
|
||
.presentationDragIndicator(.visible)
|
||
}
|
||
}
|
||
|
||
private var voiceName: String {
|
||
// Extract voice name from audioPlayer.voice (e.g., "af_bella" -> "Bella")
|
||
let components = audioPlayer.voice.split(separator: "_")
|
||
if components.count > 1 {
|
||
return String(components[1]).capitalized
|
||
}
|
||
return audioPlayer.voice.capitalized
|
||
}
|
||
|
||
private var cacheStatusText: String {
|
||
switch audioPlayer.status {
|
||
case .ready:
|
||
return "Cache"
|
||
case .generating:
|
||
return "Generating"
|
||
default:
|
||
return "Unknown"
|
||
}
|
||
}
|
||
|
||
private static let yearFormatter: DateFormatter = {
|
||
let f = DateFormatter()
|
||
f.dateFormat = "yyyy"
|
||
return f
|
||
}()
|
||
|
||
private var yearText: String {
|
||
// TODO: Could fetch actual publication year from book metadata
|
||
// For now, return current year or placeholder
|
||
return Self.yearFormatter.string(from: Date())
|
||
}
|
||
|
||
private var sleepTimerIcon: String {
|
||
audioPlayer.sleepTimer != nil ? "moon.zzz.fill" : "moon.zzz"
|
||
}
|
||
}
|
||
|
||
// 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 {
|
||
List {
|
||
Section {
|
||
Button {
|
||
audioPlayer.setSleepTimer(nil)
|
||
dismiss()
|
||
} label: {
|
||
HStack {
|
||
Text("Off")
|
||
.foregroundStyle(.primary)
|
||
Spacer()
|
||
if audioPlayer.sleepTimer == nil {
|
||
Image(systemName: "checkmark")
|
||
.foregroundStyle(.amber)
|
||
}
|
||
}
|
||
}
|
||
} header: {
|
||
Text("Chapter-based")
|
||
}
|
||
|
||
Section {
|
||
ForEach([1, 2, 3, 4], id: \.self) { count in
|
||
Button {
|
||
audioPlayer.setSleepTimer(.chapters(count))
|
||
dismiss()
|
||
} label: {
|
||
HStack {
|
||
Text("\(count) \(count == 1 ? "chapter" : "chapters")")
|
||
.foregroundStyle(.primary)
|
||
Spacer()
|
||
if case .chapters(let c) = audioPlayer.sleepTimer, c == count {
|
||
Image(systemName: "checkmark")
|
||
.foregroundStyle(.amber)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Section {
|
||
ForEach([20, 40, 60, 120], id: \.self) { minutes in
|
||
Button {
|
||
audioPlayer.setSleepTimer(.minutes(minutes))
|
||
dismiss()
|
||
} label: {
|
||
HStack {
|
||
Text(formatTimerOption(minutes))
|
||
.foregroundStyle(.primary)
|
||
Spacer()
|
||
if case .minutes(let m) = audioPlayer.sleepTimer, m == minutes {
|
||
Image(systemName: "checkmark")
|
||
.foregroundStyle(.amber)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} header: {
|
||
Text("Time-based")
|
||
}
|
||
}
|
||
.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"
|
||
} else {
|
||
let hours = minutes / 60
|
||
return "\(hours) \(hours == 1 ? "hour" : "hours")"
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Chapters List Sheet
|
||
|
||
struct ChaptersListSheet: View {
|
||
let chapters: [ChapterIndexBrief]
|
||
let currentChapter: Int
|
||
let onChapterSelect: (Int) -> Void
|
||
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
// Initialize scroll position to current chapter immediately (before view appears)
|
||
init(chapters: [ChapterIndexBrief], currentChapter: Int, onChapterSelect: @escaping (Int) -> Void) {
|
||
self.chapters = chapters
|
||
self.currentChapter = currentChapter
|
||
self.onChapterSelect = onChapterSelect
|
||
// Set initial scroll position state before view renders
|
||
_scrollPosition = State(initialValue: currentChapter)
|
||
}
|
||
|
||
@State private var scrollPosition: Int?
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
List {
|
||
ForEach(chapters, id: \.number) { chapter in
|
||
Button {
|
||
onChapterSelect(chapter.number)
|
||
} label: {
|
||
HStack(spacing: 12) {
|
||
// Chapter number badge
|
||
Text("\(chapter.number)")
|
||
.font(.caption.bold())
|
||
.foregroundStyle(chapter.number == currentChapter ? .white : .secondary)
|
||
.frame(width: 44, height: 44)
|
||
.background(
|
||
Circle()
|
||
.fill(chapter.number == currentChapter ? Color.amber : Color.gray.opacity(0.2))
|
||
)
|
||
|
||
// Chapter title
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text(chapter.title.strippingTrailingDate())
|
||
.font(.subheadline.weight(chapter.number == currentChapter ? .semibold : .regular))
|
||
.foregroundStyle(chapter.number == currentChapter ? .primary : .primary)
|
||
.lineLimit(2)
|
||
|
||
if chapter.number == currentChapter {
|
||
Text("Now Playing")
|
||
.font(.caption2)
|
||
.foregroundStyle(.amber)
|
||
}
|
||
}
|
||
|
||
Spacer()
|
||
|
||
// Checkmark for current chapter
|
||
if chapter.number == currentChapter {
|
||
Image(systemName: "checkmark")
|
||
.font(.caption.bold())
|
||
.foregroundStyle(.amber)
|
||
}
|
||
}
|
||
.padding(.vertical, 8)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.listRowBackground(
|
||
chapter.number == currentChapter
|
||
? Color.amber.opacity(0.1)
|
||
: Color.clear
|
||
)
|
||
.id(chapter.number)
|
||
}
|
||
}
|
||
.listStyle(.plain)
|
||
.scrollPosition(id: $scrollPosition, anchor: .center)
|
||
.navigationTitle("Chapters")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
Button("Done") {
|
||
dismiss()
|
||
}
|
||
.fontWeight(.semibold)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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
|
||
|
||
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 ? 22 : 22
|
||
let trackHeight: CGFloat = isDragging ? 5 : 4
|
||
|
||
ZStack(alignment: .leading) {
|
||
// Track
|
||
Capsule()
|
||
.fill(Color.white.opacity(0.2))
|
||
.frame(height: trackHeight)
|
||
|
||
// Fill
|
||
Capsule()
|
||
.fill(Color.amber)
|
||
.frame(width: max(filled, thumbSize / 2), height: trackHeight)
|
||
|
||
// Thumb
|
||
Circle()
|
||
.fill(Color.white)
|
||
.frame(width: thumbSize, height: thumbSize)
|
||
.shadow(color: .black.opacity(0.25), radius: 3, y: 1)
|
||
.offset(x: max(0, filled - thumbSize / 2))
|
||
.animation(.spring(response: 0.2), value: isDragging)
|
||
}
|
||
.frame(height: 28) // generous touch target
|
||
.contentShape(Rectangle())
|
||
.gesture(
|
||
DragGesture(minimumDistance: 0)
|
||
.onChanged { drag in
|
||
isDragging = 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
|
||
}
|
||
)
|
||
}
|
||
.frame(height: 28)
|
||
}
|
||
}
|
||
|
||
// 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
|
||
|
||
var body: some View {
|
||
Button { onToggle() } label: {
|
||
ZStack {
|
||
Circle()
|
||
.fill(.white.opacity(0.15))
|
||
.frame(width: 64, height: 64)
|
||
if isGenerating {
|
||
ProgressView()
|
||
.tint(.white)
|
||
.scaleEffect(1.2)
|
||
} else {
|
||
Image(systemName: progress.isPlaying ? "pause.fill" : "play.fill")
|
||
.font(.system(size: 30, weight: .bold))
|
||
.foregroundStyle(.white)
|
||
.offset(x: progress.isPlaying ? 0 : 2)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(isGenerating)
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|