Compare commits
2 Commits
v4.1.7
...
db06e3ecdc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db06e3ecdc | ||
|
|
3d9d8ab365 |
@@ -23,19 +23,19 @@ struct RootTabView: View {
|
||||
ZStack(alignment: .bottom) {
|
||||
TabView(selection: $selectedTab) {
|
||||
HomeView()
|
||||
.tabItem { Label("Home", systemImage: "house.fill") }
|
||||
.tabItem { Label("Home", systemImage: "house") }
|
||||
.tag(Tab.home)
|
||||
|
||||
LibraryView()
|
||||
.tabItem { Label("Library", systemImage: "books.vertical.fill") }
|
||||
.tabItem { Label("Library", systemImage: "books.vertical") }
|
||||
.tag(Tab.library)
|
||||
|
||||
BrowseView()
|
||||
.tabItem { Label("Discover", systemImage: "globe") }
|
||||
.tabItem { Label("Discover", systemImage: "sparkles") }
|
||||
.tag(Tab.browse)
|
||||
|
||||
ProfileView()
|
||||
.tabItem { Label("Profile", systemImage: "person.fill") }
|
||||
.tabItem { Label("Profile", systemImage: "person.crop.circle") }
|
||||
.tag(Tab.profile)
|
||||
}
|
||||
// Reserve space for the mini-player above the tab bar so scroll content
|
||||
@@ -49,7 +49,8 @@ struct RootTabView: View {
|
||||
// Mini-player pinned above the tab bar (hidden while full player is open)
|
||||
if audioPlayer.isActive && !showFullPlayer {
|
||||
MiniPlayerView(showFullPlayer: $showFullPlayer)
|
||||
.padding(.bottom, tabBarHeight)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, max(tabBarHeight - 2, 0))
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: audioPlayer.isActive)
|
||||
}
|
||||
|
||||
@@ -36,5 +36,5 @@ extension String {
|
||||
enum AppLayout {
|
||||
/// Height of the persistent mini-player bar:
|
||||
/// 2pt progress line + 20pt vertical padding + ~44pt content row.
|
||||
static let miniPlayerBarHeight: CGFloat = 66
|
||||
static let miniPlayerBarHeight: CGFloat = 56
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ final class AudioPlayerService: ObservableObject {
|
||||
@Published var autoNext: Bool = false
|
||||
@Published var nextChapter: Int? = nil
|
||||
|
||||
@Published var sleepTimerOption: SleepTimerOption = .off
|
||||
|
||||
@Published var nextPrefetchStatus: NextPrefetchStatus = .none
|
||||
@Published var nextAudioURL: String = ""
|
||||
@Published var nextPrefetchedChapter: Int? = nil
|
||||
@@ -56,6 +58,9 @@ final class AudioPlayerService: ObservableObject {
|
||||
private var generationTask: Task<Void, Never>?
|
||||
private var prefetchTask: Task<Void, Never>?
|
||||
|
||||
private var sleepTimer: Timer?
|
||||
private var sleepRemainingChapters: Int?
|
||||
|
||||
// Cached cover image — downloaded once per chapter load, reused on every
|
||||
// updateNowPlaying() call so we don't re-download on every play/pause/seek.
|
||||
private var cachedCoverArtwork: MPMediaItemArtwork?
|
||||
@@ -148,6 +153,49 @@ final class AudioPlayerService: ObservableObject {
|
||||
duration = 0
|
||||
audioURL = ""
|
||||
status = .idle
|
||||
clearSleepTimerIfNeeded()
|
||||
}
|
||||
|
||||
func playChapter(number: Int) {
|
||||
guard !slug.isEmpty else { return }
|
||||
let title = chapters.first(where: { $0.number == number })?.title ?? ""
|
||||
let next = chapters.first(where: { $0.number > number })?.number
|
||||
load(
|
||||
slug: slug,
|
||||
chapter: number,
|
||||
chapterTitle: title,
|
||||
bookTitle: bookTitle,
|
||||
coverURL: coverURL,
|
||||
voice: voice,
|
||||
speed: speed,
|
||||
chapters: chapters,
|
||||
nextChapter: next
|
||||
)
|
||||
}
|
||||
|
||||
func setSleepTimer(_ option: SleepTimerOption) {
|
||||
sleepTimer?.invalidate()
|
||||
sleepTimer = nil
|
||||
sleepRemainingChapters = nil
|
||||
sleepTimerOption = option
|
||||
|
||||
switch option {
|
||||
case .off:
|
||||
break
|
||||
case .endOfChapter:
|
||||
sleepRemainingChapters = 1
|
||||
case .chapters(let count):
|
||||
sleepRemainingChapters = max(1, count)
|
||||
case .minutes(let minutes):
|
||||
let seconds = max(60, minutes * 60)
|
||||
sleepTimer = Timer.scheduledTimer(withTimeInterval: TimeInterval(seconds), repeats: false) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.stop()
|
||||
self?.sleepTimerOption = .off
|
||||
self?.sleepRemainingChapters = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Audio generation
|
||||
@@ -309,22 +357,48 @@ final class AudioPlayerService: ObservableObject {
|
||||
playerItem = nil
|
||||
}
|
||||
|
||||
private func clearSleepTimerIfNeeded() {
|
||||
sleepTimer?.invalidate()
|
||||
sleepTimer = nil
|
||||
sleepRemainingChapters = nil
|
||||
sleepTimerOption = .off
|
||||
}
|
||||
|
||||
private func handlePlaybackFinished() {
|
||||
isPlaying = false
|
||||
|
||||
var shouldAutoNext = autoNext
|
||||
var didTriggerSleepStop = false
|
||||
if let remaining = sleepRemainingChapters {
|
||||
let updated = remaining - 1
|
||||
if updated <= 0 {
|
||||
sleepRemainingChapters = nil
|
||||
sleepTimerOption = .off
|
||||
didTriggerSleepStop = true
|
||||
shouldAutoNext = false
|
||||
} else {
|
||||
sleepRemainingChapters = updated
|
||||
}
|
||||
}
|
||||
|
||||
if didTriggerSleepStop {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
|
||||
guard let next = nextChapter else { return }
|
||||
|
||||
// Always notify the view that the chapter finished (it may update UI).
|
||||
NotificationCenter.default.post(
|
||||
name: .audioDidFinishChapter,
|
||||
object: nil,
|
||||
userInfo: ["next": next, "autoNext": autoNext]
|
||||
userInfo: ["next": next, "autoNext": shouldAutoNext]
|
||||
)
|
||||
|
||||
// If autoNext is on, load the next chapter internally right away.
|
||||
// We already have the metadata in `chapters`, so we can reconstruct
|
||||
// everything without waiting for the view to navigate.
|
||||
guard autoNext else { return }
|
||||
guard shouldAutoNext else { return }
|
||||
|
||||
let nextTitle = chapters.first(where: { $0.number == next })?.title ?? ""
|
||||
let nextNextChapter = chapters.first(where: { $0.number > next })?.number
|
||||
@@ -460,6 +534,13 @@ enum AudioPlayerStatus: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
enum SleepTimerOption: Equatable {
|
||||
case off
|
||||
case endOfChapter
|
||||
case chapters(Int)
|
||||
case minutes(Int)
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static let audioDidFinishChapter = Notification.Name("audioDidFinishChapter")
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import SwiftUI
|
||||
import Kingfisher // used directly for blurred background in FullPlayerView
|
||||
import AVKit
|
||||
|
||||
// MARK: - Mini player bar (pinned above tab bar)
|
||||
|
||||
@@ -7,28 +8,30 @@ struct MiniPlayerView: View {
|
||||
@Binding var showFullPlayer: Bool
|
||||
@EnvironmentObject var audioPlayer: AudioPlayerService
|
||||
|
||||
private let capsuleRadius: CGFloat = 22
|
||||
|
||||
/// Live drag offset while the user is swiping up (negative = moving up).
|
||||
@State private var dragOffset: CGFloat = 0
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
VStack(spacing: 4) {
|
||||
// Progress line — thin amber strip at the very top
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
Rectangle().fill(Color.white.opacity(0.08)).frame(height: 2)
|
||||
Rectangle()
|
||||
Capsule().fill(Color.white.opacity(0.12)).frame(height: 2)
|
||||
Capsule()
|
||||
.fill(Color.amber)
|
||||
.frame(width: geo.size.width * progress, height: 2)
|
||||
}
|
||||
}
|
||||
.frame(height: 2)
|
||||
|
||||
HStack(spacing: 14) {
|
||||
HStack(spacing: 12) {
|
||||
// Cover thumbnail
|
||||
Button { showFullPlayer = true } label: {
|
||||
AsyncCoverImage(url: audioPlayer.coverURL)
|
||||
.frame(width: 44, height: 44)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.frame(width: 40, height: 40)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 9, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
@@ -39,7 +42,7 @@ struct MiniPlayerView: View {
|
||||
.lineLimit(1)
|
||||
Text(chapterLabel)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.foregroundStyle(.white.opacity(0.65))
|
||||
.lineLimit(1)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
@@ -51,20 +54,21 @@ struct MiniPlayerView: View {
|
||||
case .generating:
|
||||
ProgressView()
|
||||
.tint(.amber)
|
||||
.scaleEffect(0.85)
|
||||
.frame(width: 36, height: 36)
|
||||
.scaleEffect(0.8)
|
||||
.frame(width: 32, height: 32)
|
||||
case .ready:
|
||||
Button { audioPlayer.togglePlayPause() } label: {
|
||||
Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.frame(width: 36, height: 36)
|
||||
Image(systemName: audioPlayer.isPlaying ? "pause.circle.fill" : "play.circle.fill")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(.white.opacity(0.12), in: Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
case .error:
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.foregroundStyle(.red)
|
||||
.frame(width: 36, height: 36)
|
||||
.frame(width: 32, height: 32)
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
@@ -73,17 +77,34 @@ struct MiniPlayerView: View {
|
||||
// Dismiss
|
||||
Button { audioPlayer.stop() } label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 30, height: 30)
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
.frame(width: 26, height: 26)
|
||||
.background(.white.opacity(0.12), in: Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.background(.ultraThinMaterial)
|
||||
.shadow(color: .black.opacity(0.15), radius: 6, y: -2)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: capsuleRadius, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay(Color.black.opacity(0.18))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: capsuleRadius, style: .continuous)
|
||||
.stroke(Color.white.opacity(0.12), lineWidth: 1)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: capsuleRadius, style: .continuous)
|
||||
.stroke(Color.white.opacity(0.06), lineWidth: 1)
|
||||
.blur(radius: 0.8)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.25), radius: 16, y: 8)
|
||||
// Follow finger upward while dragging, spring back or expand on release
|
||||
.offset(y: min(dragOffset, 0))
|
||||
.gesture(
|
||||
@@ -131,69 +152,94 @@ struct FullPlayerView: View {
|
||||
/// Called when the view wants to close itself (Done button or drag-to-dismiss).
|
||||
var onDismiss: () -> Void = {}
|
||||
|
||||
@State private var isShuffled = false
|
||||
@State private var isRepeating = false
|
||||
@State private var showChapterList = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// ── Background: blurred cover art ──────────────────────────────
|
||||
GeometryReader { geo in
|
||||
// Dynamic background derived from cover art colors.
|
||||
ZStack {
|
||||
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))
|
||||
.blur(radius: 60)
|
||||
.saturation(1.4)
|
||||
.overlay(Color.black.opacity(0.35))
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
|
||||
// ── Content ────────────────────────────────────────────────────
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.black.opacity(0.05),
|
||||
Color.black.opacity(0.55)
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
|
||||
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)
|
||||
.frame(width: 46, height: 5)
|
||||
.padding(.top, 12)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
.padding(.bottom, 14)
|
||||
|
||||
// Cover art
|
||||
KFImage(URL(string: audioPlayer.coverURL))
|
||||
.resizable()
|
||||
.placeholder {
|
||||
RoundedRectangle(cornerRadius: 18)
|
||||
.fill(.white.opacity(0.1))
|
||||
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
||||
.fill(.white.opacity(0.12))
|
||||
.overlay(
|
||||
Image(systemName: "book.closed")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.white.opacity(0.4))
|
||||
Image(systemName: "music.note")
|
||||
.font(.system(size: 52))
|
||||
.foregroundStyle(.white.opacity(0.35))
|
||||
)
|
||||
}
|
||||
.scaledToFill()
|
||||
.frame(width: 260, height: 260)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 18))
|
||||
.shadow(color: .black.opacity(0.5), radius: 24, y: 12)
|
||||
.padding(.horizontal, 48)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
.frame(width: 310, height: 310)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
|
||||
.shadow(color: .black.opacity(0.45), radius: 26, y: 16)
|
||||
.padding(.horizontal, 32)
|
||||
|
||||
// Title block
|
||||
VStack(spacing: 6) {
|
||||
VStack(spacing: 8) {
|
||||
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))
|
||||
.font(.title3.weight(.regular))
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
.padding(.top, 28)
|
||||
.padding(.top, 22)
|
||||
|
||||
// Actions row
|
||||
HStack(spacing: 22) {
|
||||
Button {} label: {
|
||||
Image(systemName: "heart")
|
||||
.font(.system(size: 20, weight: .regular))
|
||||
}
|
||||
Button {} label: {
|
||||
Image(systemName: "star")
|
||||
.font(.system(size: 20, weight: .regular))
|
||||
}
|
||||
Button {} label: {
|
||||
Image(systemName: "ellipsis")
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.white.opacity(0.9))
|
||||
.padding(.top, 18)
|
||||
|
||||
// Seek bar
|
||||
VStack(spacing: 6) {
|
||||
VStack(spacing: 8) {
|
||||
PlayerSlider(
|
||||
value: Binding(
|
||||
get: { audioPlayer.currentTime },
|
||||
@@ -204,40 +250,41 @@ struct FullPlayerView: View {
|
||||
HStack {
|
||||
Text(formatTime(audioPlayer.currentTime))
|
||||
Spacer()
|
||||
Text(formatTime(audioPlayer.duration))
|
||||
Text("-\(formatTime(max(audioPlayer.duration - audioPlayer.currentTime, 0)))")
|
||||
}
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
.foregroundStyle(.white.opacity(0.65))
|
||||
}
|
||||
.padding(.horizontal, 28)
|
||||
.padding(.top, 24)
|
||||
.padding(.top, 18)
|
||||
|
||||
// Controls
|
||||
HStack(spacing: 0) {
|
||||
// ← skip back 15s
|
||||
HStack(spacing: 26) {
|
||||
Button { isShuffled.toggle() } label: {
|
||||
Image(systemName: "shuffle")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(isShuffled ? .white : .white.opacity(0.45))
|
||||
}
|
||||
|
||||
Button { audioPlayer.skip(by: -15) } label: {
|
||||
Image(systemName: "gobackward.15")
|
||||
.font(.system(size: 26, weight: .regular))
|
||||
.foregroundStyle(.white.opacity(0.9))
|
||||
.frame(maxWidth: .infinity)
|
||||
.font(.system(size: 24, weight: .regular))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
// play / pause
|
||||
Button { audioPlayer.togglePlayPause() } label: {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.amber)
|
||||
.fill(Color.white)
|
||||
.frame(width: 72, height: 72)
|
||||
.shadow(color: Color.amber.opacity(0.45), radius: 14, y: 4)
|
||||
.shadow(color: .black.opacity(0.3), radius: 10, y: 6)
|
||||
if audioPlayer.status == .generating {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
.scaleEffect(1.1)
|
||||
.tint(Color.black.opacity(0.7))
|
||||
} else {
|
||||
Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill")
|
||||
.font(.system(size: 28, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.foregroundStyle(Color.black.opacity(0.85))
|
||||
.offset(x: audioPlayer.isPlaying ? 0 : 2)
|
||||
}
|
||||
}
|
||||
@@ -245,78 +292,71 @@ struct FullPlayerView: View {
|
||||
.buttonStyle(.plain)
|
||||
.disabled(audioPlayer.status == .generating)
|
||||
|
||||
// → skip forward 30s
|
||||
Button { audioPlayer.skip(by: 30) } label: {
|
||||
Image(systemName: "goforward.30")
|
||||
.font(.system(size: 26, weight: .regular))
|
||||
.foregroundStyle(.white.opacity(0.9))
|
||||
.frame(maxWidth: .infinity)
|
||||
.font(.system(size: 24, weight: .regular))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
Button { isRepeating.toggle() } label: {
|
||||
Image(systemName: "repeat")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(isRepeating ? .white : .white.opacity(0.45))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 32)
|
||||
.padding(.top, 18)
|
||||
|
||||
// Speed + Auto-next
|
||||
HStack(spacing: 20) {
|
||||
// Utility row
|
||||
HStack(spacing: 30) {
|
||||
AirPlayRoutePicker()
|
||||
.frame(width: 24, height: 24)
|
||||
Button {} label: { Image(systemName: "gearshape") }
|
||||
Button { onDismiss() } label: { Image(systemName: "chevron.down") }
|
||||
Button {} label: { Image(systemName: "quote.bubble") }
|
||||
Button { showChapterList = true } label: { Image(systemName: "list.bullet") }
|
||||
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")×")
|
||||
}
|
||||
}
|
||||
}
|
||||
sleepTimerButton(label: "Off", option: .off)
|
||||
sleepTimerButton(label: "End of Chapter", option: .endOfChapter)
|
||||
sleepTimerButton(label: "2 chapters", option: .chapters(2))
|
||||
sleepTimerButton(label: "3 chapters", option: .chapters(3))
|
||||
sleepTimerButton(label: "4 chapters", option: .chapters(4))
|
||||
sleepTimerButton(label: "5 chapters", option: .chapters(5))
|
||||
Divider()
|
||||
sleepTimerButton(label: "20 mins", option: .minutes(20))
|
||||
sleepTimerButton(label: "30 mins", option: .minutes(30))
|
||||
sleepTimerButton(label: "1 hour", option: .minutes(60))
|
||||
} label: {
|
||||
Text("\(audioPlayer.speed, specifier: "%.2g")×")
|
||||
.font(.subheadline.weight(.bold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(.white.opacity(0.18), in: Capsule())
|
||||
Image(systemName: "moon.zzz")
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Auto-next toggle
|
||||
Button {
|
||||
audioPlayer.autoNext.toggle()
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "forward.end")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
Text("Auto-next")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
.foregroundStyle(audioPlayer.autoNext ? Color.amber : .white.opacity(0.55))
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(
|
||||
audioPlayer.autoNext
|
||||
? Color.amber.opacity(0.18)
|
||||
: Color.white.opacity(0.1),
|
||||
in: Capsule()
|
||||
)
|
||||
.overlay(
|
||||
Capsule()
|
||||
.stroke(
|
||||
audioPlayer.autoNext ? Color.amber.opacity(0.5) : Color.clear,
|
||||
lineWidth: 1
|
||||
)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 28)
|
||||
.padding(.top, 28)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.75))
|
||||
.padding(.top, 24)
|
||||
|
||||
Spacer(minLength: 32)
|
||||
Spacer(minLength: 26)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showChapterList) {
|
||||
ChapterListSheet(
|
||||
chapters: audioPlayer.chapters,
|
||||
current: audioPlayer.chapter,
|
||||
onSelect: { chapter in
|
||||
audioPlayer.playChapter(number: chapter)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sleepTimerButton(label: String, option: SleepTimerOption) -> some View {
|
||||
if audioPlayer.sleepTimerOption == option {
|
||||
Button { audioPlayer.setSleepTimer(option) } label: {
|
||||
Label(label, systemImage: "checkmark")
|
||||
}
|
||||
} else {
|
||||
Button(label) { audioPlayer.setSleepTimer(option) }
|
||||
}
|
||||
}
|
||||
|
||||
private func formatTime(_ seconds: Double) -> String {
|
||||
@@ -381,3 +421,57 @@ struct PlayerSlider: View {
|
||||
.frame(height: 28)
|
||||
}
|
||||
}
|
||||
|
||||
struct AirPlayRoutePicker: UIViewRepresentable {
|
||||
func makeUIView(context: Context) -> AVRoutePickerView {
|
||||
let view = AVRoutePickerView()
|
||||
view.prioritizesVideoDevices = false
|
||||
view.activeTintColor = UIColor.white
|
||||
view.tintColor = UIColor.white.withAlphaComponent(0.7)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: AVRoutePickerView, context: Context) {}
|
||||
}
|
||||
|
||||
struct ChapterListSheet: View {
|
||||
let chapters: [ChapterIndexBrief]
|
||||
let current: Int
|
||||
let onSelect: (Int) -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
if chapters.isEmpty {
|
||||
Text("No chapters available")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(chapters, id: \.self) { chapter in
|
||||
Button {
|
||||
onSelect(chapter.number)
|
||||
dismiss()
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
Text("Ch. \(chapter.number)")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(chapter.title.strippingTrailingDate())
|
||||
.font(.subheadline)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
if chapter.number == current {
|
||||
Image(systemName: "speaker.wave.2.fill")
|
||||
.foregroundStyle(.amber)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Chapters")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user