v2 #1
@@ -34,6 +34,8 @@ final class AudioPlayerService: ObservableObject {
|
||||
@Published var autoNext: Bool = false
|
||||
@Published var nextChapter: Int? = nil
|
||||
@Published var prevChapter: Int? = nil
|
||||
|
||||
@Published var sleepTimer: SleepTimerOption? = nil
|
||||
|
||||
@Published var nextPrefetchStatus: NextPrefetchStatus = .none
|
||||
@Published var nextAudioURL: String = ""
|
||||
@@ -61,6 +63,10 @@ final class AudioPlayerService: ObservableObject {
|
||||
// updateNowPlaying() call so we don't re-download on every play/pause/seek.
|
||||
private var cachedCoverArtwork: MPMediaItemArtwork?
|
||||
private var cachedCoverURL: String = ""
|
||||
|
||||
// Sleep timer tracking
|
||||
private var sleepTimerTask: Task<Void, Never>?
|
||||
private var sleepTimerStartChapter: Int = 0
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
@@ -92,6 +98,11 @@ final class AudioPlayerService: ObservableObject {
|
||||
self.nextPrefetchStatus = .none
|
||||
self.nextAudioURL = ""
|
||||
self.nextPrefetchedChapter = nil
|
||||
|
||||
// Reset sleep timer start chapter if it's a chapter-based timer
|
||||
if case .chapters = sleepTimer {
|
||||
sleepTimerStartChapter = chapter
|
||||
}
|
||||
|
||||
status = .generating
|
||||
generationProgress = 0
|
||||
@@ -141,6 +152,33 @@ final class AudioPlayerService: ObservableObject {
|
||||
if isPlaying { player?.rate = Float(newSpeed) }
|
||||
updateNowPlaying()
|
||||
}
|
||||
|
||||
func setSleepTimer(_ option: SleepTimerOption?) {
|
||||
// Cancel existing timer
|
||||
sleepTimerTask?.cancel()
|
||||
sleepTimerTask = nil
|
||||
|
||||
sleepTimer = option
|
||||
|
||||
guard let option else { return }
|
||||
|
||||
// Start timer based on option
|
||||
switch option {
|
||||
case .chapters(let count):
|
||||
sleepTimerStartChapter = chapter
|
||||
// Monitor chapter changes in handlePlaybackFinished
|
||||
|
||||
case .minutes(let minutes):
|
||||
sleepTimerTask = Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: UInt64(minutes) * 60 * 1_000_000_000)
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
await MainActor.run {
|
||||
self.stop()
|
||||
self.sleepTimer = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
player?.pause()
|
||||
@@ -150,6 +188,11 @@ final class AudioPlayerService: ObservableObject {
|
||||
duration = 0
|
||||
audioURL = ""
|
||||
status = .idle
|
||||
|
||||
// Cancel sleep timer
|
||||
sleepTimerTask?.cancel()
|
||||
sleepTimerTask = nil
|
||||
sleepTimer = nil
|
||||
}
|
||||
|
||||
// MARK: - Audio generation
|
||||
@@ -315,6 +358,15 @@ final class AudioPlayerService: ObservableObject {
|
||||
isPlaying = false
|
||||
|
||||
guard let next = nextChapter else { return }
|
||||
|
||||
// Check chapter-based sleep timer
|
||||
if case .chapters(let count) = sleepTimer {
|
||||
let chaptersPlayed = chapter - sleepTimerStartChapter + 1
|
||||
if chaptersPlayed >= count {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Always notify the view that the chapter finished (it may update UI).
|
||||
NotificationCenter.default.post(
|
||||
@@ -465,6 +517,11 @@ enum AudioPlayerStatus: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
enum SleepTimerOption: Equatable {
|
||||
case chapters(Int) // Stop after N chapters
|
||||
case minutes(Int) // Stop after N minutes
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static let audioDidFinishChapter = Notification.Name("audioDidFinishChapter")
|
||||
static let skipToNextChapter = Notification.Name("skipToNextChapter")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import SwiftUI
|
||||
import Kingfisher // used directly for blurred background in FullPlayerView
|
||||
import AVKit // for AVRoutePickerView (AirPlay)
|
||||
|
||||
// MARK: - Mini player bar (pinned above tab bar)
|
||||
|
||||
@@ -191,6 +192,7 @@ struct FullPlayerView: View {
|
||||
@State private var isLiked = false
|
||||
@State private var showingSpeedMenu = false
|
||||
@State private var showingChaptersList = false
|
||||
@State private var showingSleepTimer = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@@ -463,15 +465,9 @@ struct FullPlayerView: View {
|
||||
|
||||
// Bottom toolbar
|
||||
HStack(spacing: 0) {
|
||||
// AirPlay (placeholder)
|
||||
Button {} label: {
|
||||
Image(systemName: "airplayaudio")
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(true)
|
||||
// AirPlay
|
||||
AirPlayButton()
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
// Settings (Speed control)
|
||||
Menu {
|
||||
@@ -526,15 +522,16 @@ struct FullPlayerView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
// Sleep timer (placeholder)
|
||||
Button {} label: {
|
||||
Image(systemName: "moon.zzz")
|
||||
// Sleep timer
|
||||
Button {
|
||||
showingSleepTimer = true
|
||||
} label: {
|
||||
Image(systemName: sleepTimerIcon)
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
.foregroundStyle(audioPlayer.sleepTimer != nil ? .amber : .white.opacity(0.7))
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(true)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 24)
|
||||
@@ -565,6 +562,11 @@ struct FullPlayerView: View {
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
.sheet(isPresented: $showingSleepTimer) {
|
||||
SleepTimerSheet(audioPlayer: audioPlayer)
|
||||
.presentationDetents([.height(500)])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
}
|
||||
|
||||
private func formatTime(_ seconds: Double) -> String {
|
||||
@@ -600,6 +602,115 @@ struct FullPlayerView: View {
|
||||
formatter.dateFormat = "yyyy"
|
||||
return formatter.string(from: Date())
|
||||
}
|
||||
|
||||
private var sleepTimerIcon: String {
|
||||
audioPlayer.sleepTimer != nil ? "moon.zzz.fill" : "moon.zzz"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AirPlay Button
|
||||
|
||||
struct AirPlayButton: UIViewRepresentable {
|
||||
func makeUIView(context: Context) -> AVRoutePickerView {
|
||||
let picker = AVRoutePickerView()
|
||||
picker.tintColor = UIColor.white.withAlphaComponent(0.7)
|
||||
picker.activeTintColor = UIColor(named: "AccentColor") ?? UIColor.systemOrange
|
||||
picker.prioritizesVideoDevices = false
|
||||
return picker
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: AVRoutePickerView, 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
|
||||
|
||||
Reference in New Issue
Block a user