v2 #1

Open
kamil wants to merge 231 commits from v2 into main
2 changed files with 182 additions and 14 deletions
Showing only changes of commit 825fb04c0d - Show all commits

View File

@@ -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")

View File

@@ -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