From 825fb04c0d91c108a8c1b2428da2f0058e898d5a Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 8 Mar 2026 13:25:07 +0500 Subject: [PATCH] iOS: add functional AirPlay and sleep timer to full player AirPlay: - Replaced placeholder AirPlay button with AVRoutePickerView - Users can now cast audio to AirPlay devices - Icon changes color when AirPlay is active Sleep Timer: - Added sleep timer sheet with multiple options - Chapter-based: 1, 2, 3, or 4 chapters - Time-based: 20 mins, 40 mins, 1 hour, 2 hours - Moon icon fills when timer is active and turns amber - Timer stops playback when target is reached - Chapter-based timer tracks chapters played from start - Time-based timer uses async task with proper cancellation - Timer is cancelled when playback stops manually - SleepTimerOption enum supports both timer types --- .../Services/AudioPlayerService.swift | 57 +++++++ .../LibNovel/Views/Player/PlayerViews.swift | 139 ++++++++++++++++-- 2 files changed, 182 insertions(+), 14 deletions(-) diff --git a/ios/LibNovel/LibNovel/Services/AudioPlayerService.swift b/ios/LibNovel/LibNovel/Services/AudioPlayerService.swift index a18b686..ffcee16 100644 --- a/ios/LibNovel/LibNovel/Services/AudioPlayerService.swift +++ b/ios/LibNovel/LibNovel/Services/AudioPlayerService.swift @@ -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? + 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") diff --git a/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift b/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift index 4065187..015d5a2 100644 --- a/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift +++ b/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift @@ -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