import Foundation import AVFoundation import MediaPlayer import Combine // MARK: - PlaybackProgress // High-frequency playback state isolated into its own ObservableObject so that // the 0.5-second time-observer ticks only invalidate views that explicitly // subscribe to this object (seek bar, play/pause button), leaving menus and // other stable UI untouched. @MainActor final class PlaybackProgress: ObservableObject { @Published var currentTime: Double = 0 @Published var duration: Double = 0 @Published var isPlaying: Bool = false } // MARK: - AudioPlayerService // Central singleton owning AVPlayer, lock-screen controls (NowPlayingInfoCenter // + MPRemoteCommandCenter), and next-chapter prefetch. @MainActor final class AudioPlayerService: ObservableObject { // MARK: - Published state @Published var slug: String = "" @Published var chapter: Int = 0 @Published var chapterTitle: String = "" @Published var bookTitle: String = "" @Published var coverURL: String = "" @Published var voice: String = "af_bella" @Published var speed: Double = 1.0 @Published var chapters: [ChapterBrief] = [] @Published var status: AudioPlayerStatus = .idle @Published var audioURL: String = "" @Published var errorMessage: String = "" @Published var generationProgress: Double = 0 /// High-frequency playback state — subscribe directly to avoid re-rendering parents. let progress = PlaybackProgress() // Convenience forwarders for callers that don't need granular isolation. var currentTime: Double { get { progress.currentTime } set { progress.currentTime = newValue } } var duration: Double { get { progress.duration } set { progress.duration = newValue } } var isPlaying: Bool { get { progress.isPlaying } set { progress.isPlaying = newValue } } @Published var autoNext: Bool = false @Published var nextChapter: Int? = nil @Published var prevChapter: Int? = nil @Published var sleepTimer: SleepTimerOption? = nil @Published var sleepTimerRemainingText: String = "" @Published var nextPrefetchStatus: NextPrefetchStatus = .none @Published var nextAudioURL: String = "" @Published var nextPrefetchedChapter: Int? = nil var isActive: Bool { if case .idle = status { return false } return true } // MARK: - Private private var player: AVPlayer? private var playerItem: AVPlayerItem? private var timeObserver: Any? private var statusObserver: AnyCancellable? private var durationObserver: AnyCancellable? private var finishObserver: AnyCancellable? private var generationTask: Task? private var prefetchTask: Task? private var cachedCoverArtwork: MPMediaItemArtwork? private var cachedCoverURL: String = "" private var sleepTimerTask: Task? private var sleepTimerStartChapter: Int = 0 private var sleepTimerDeadline: Date? = nil private var sleepTimerCountdownTask: Task? = nil // MARK: - Init init() { configureAudioSession() setupRemoteCommandCenter() } // MARK: - Public API func load(slug: String, chapter: Int, chapterTitle: String, bookTitle: String, coverURL: String, voice: String, speed: Double, chapters: [ChapterBrief], nextChapter: Int?, prevChapter: Int?) { generationTask?.cancel() prefetchTask?.cancel() stop() self.slug = slug self.chapter = chapter self.chapterTitle = chapterTitle self.bookTitle = bookTitle self.coverURL = coverURL self.voice = voice self.speed = speed self.chapters = chapters self.nextChapter = nextChapter self.prevChapter = prevChapter self.nextPrefetchStatus = .none self.nextAudioURL = "" self.nextPrefetchedChapter = nil if case .chapters = sleepTimer { sleepTimerStartChapter = chapter } status = .generating generationProgress = 0 if coverURL != cachedCoverURL { cachedCoverArtwork = nil cachedCoverURL = coverURL Task { await prefetchCoverArtwork(from: coverURL) } } generationTask = Task { await generateAudio() } } func play() { player?.play() player?.rate = Float(speed) isPlaying = true updateNowPlaying() } func pause() { player?.pause() isPlaying = false updateNowPlaying() } func togglePlayPause() { isPlaying ? pause() : play() } func seek(to seconds: Double) { let time = CMTime(seconds: seconds, preferredTimescale: 600) currentTime = seconds player?.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] _ in guard let self else { return } Task { @MainActor in self.updateNowPlaying() } } } func skip(by seconds: Double) { seek(to: max(0, min(currentTime + seconds, duration))) } func setSpeed(_ newSpeed: Double) { speed = newSpeed if isPlaying { player?.rate = Float(newSpeed) } updateNowPlaying() } func setSleepTimer(_ option: SleepTimerOption?) { sleepTimerTask?.cancel(); sleepTimerTask = nil sleepTimerCountdownTask?.cancel(); sleepTimerCountdownTask = nil sleepTimerDeadline = nil sleepTimer = option guard let option else { sleepTimerRemainingText = ""; return } switch option { case .chapters(let count): sleepTimerStartChapter = chapter updateChapterTimerLabel(chaptersRemaining: count) case .minutes(let minutes): let deadline = Date().addingTimeInterval(Double(minutes) * 60) sleepTimerDeadline = deadline 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; self.sleepTimerRemainingText = "" } } sleepTimerCountdownTask = Task { [weak self] in while !Task.isCancelled { try? await Task.sleep(nanoseconds: 1_000_000_000) guard let self, !Task.isCancelled else { return } await MainActor.run { guard let d = self.sleepTimerDeadline else { return } self.sleepTimerRemainingText = Self.formatCountdown(max(0, d.timeIntervalSinceNow)) } } } sleepTimerRemainingText = Self.formatCountdown(Double(minutes) * 60) } } func stop() { player?.pause() teardownPlayer() isPlaying = false currentTime = 0 duration = 0 audioURL = "" status = .idle sleepTimerTask?.cancel(); sleepTimerTask = nil sleepTimerCountdownTask?.cancel(); sleepTimerCountdownTask = nil sleepTimerDeadline = nil sleepTimer = nil sleepTimerRemainingText = "" } // MARK: - Private helpers private func updateChapterTimerLabel(chaptersRemaining: Int) { sleepTimerRemainingText = chaptersRemaining == 1 ? "1 ch left" : "\(chaptersRemaining) ch left" } private static func formatCountdown(_ seconds: Double) -> String { let s = Int(max(0, seconds)) return "\(s / 60):\(String(format: "%02d", s % 60))" } // MARK: - Audio generation private func generateAudio() async { guard !slug.isEmpty, chapter > 0 else { return } // Local file first (offline download) if let localURL = AudioDownloadService.shared.localURL(slug: slug, chapter: chapter, voice: voice) { audioURL = localURL.absoluteString status = .ready generationProgress = 100 await playURL(localURL.absoluteString) await prefetchNext() return } do { // Fast path: audio already in MinIO if let presigned = try? await APIClient.shared.presignAudio( slug: slug, chapter: chapter, voice: voice) { audioURL = presigned status = .ready generationProgress = 100 await playURL(presigned) await prefetchNext() return } // Slow path: trigger TTS generation status = .generating generationProgress = 10 let trigger = try await APIClient.shared.triggerAudio( slug: slug, chapter: chapter, voice: voice, speed: speed) let playableURL: String if trigger.isAsync { generationProgress = 30 playableURL = try await APIClient.shared.pollAudioStatus( slug: slug, chapter: chapter, voice: voice) } else { guard let url = trigger.url, !url.isEmpty else { throw URLError(.badServerResponse) } playableURL = url } audioURL = playableURL status = .ready generationProgress = 100 await playURL(playableURL) await prefetchNext() } catch is CancellationError { // Cancelled — no-op } catch { status = .error(error.localizedDescription) errorMessage = error.localizedDescription } } // MARK: - Prefetch next chapter private func prefetchNext() async { guard let next = nextChapter, !Task.isCancelled else { return } nextPrefetchStatus = .prefetching nextPrefetchedChapter = next do { if let presigned = try? await APIClient.shared.presignAudio( slug: slug, chapter: next, voice: voice) { nextAudioURL = presigned nextPrefetchStatus = .prefetched return } let trigger = try await APIClient.shared.triggerAudio( slug: slug, chapter: next, voice: voice, speed: speed) let url: String if trigger.isAsync { url = try await APIClient.shared.pollAudioStatus(slug: slug, chapter: next, voice: voice) } else { guard let u = trigger.url, !u.isEmpty else { throw URLError(.badServerResponse) } url = u } nextAudioURL = url nextPrefetchStatus = .prefetched } catch { nextPrefetchStatus = .failed } } // MARK: - AVPlayer management private func playURL(_ urlString: String) async { let resolved: URL? if urlString.hasPrefix("http://") || urlString.hasPrefix("https://") { resolved = URL(string: urlString) } else { resolved = URL(string: urlString, relativeTo: await APIClient.shared.baseURL)?.absoluteURL } guard let url = resolved else { return } teardownPlayer() let item = AVPlayerItem(url: url) playerItem = item player = AVPlayer(playerItem: item) durationObserver = item.publisher(for: \.duration) .receive(on: RunLoop.main) .sink { [weak self] dur in guard let self else { return } let secs = dur.seconds if secs.isFinite && secs > 0 { self.duration = secs; self.updateNowPlaying() } } statusObserver = item.publisher(for: \.status) .receive(on: RunLoop.main) .sink { [weak self] s in guard let self else { return } switch s { case .readyToPlay: self.player?.rate = Float(self.speed) self.isPlaying = true self.updateNowPlaying() case .failed: self.status = .error(item.error?.localizedDescription ?? "Playback failed") self.errorMessage = item.error?.localizedDescription ?? "Playback failed" default: break } } timeObserver = player?.addPeriodicTimeObserver( forInterval: CMTime(seconds: 0.5, preferredTimescale: 600), queue: .main ) { [weak self] time in guard let self else { return } Task { @MainActor in let secs = time.seconds if secs.isFinite && secs >= 0 { self.currentTime = secs } } } finishObserver = NotificationCenter.default .publisher(for: AVPlayerItem.didPlayToEndTimeNotification, object: item) .sink { [weak self] _ in Task { @MainActor in self?.handlePlaybackFinished() } } player?.play() } private func teardownPlayer() { if let obs = timeObserver { player?.removeTimeObserver(obs) } timeObserver = nil; statusObserver = nil; durationObserver = nil; finishObserver = nil player = nil; playerItem = nil } private func handlePlaybackFinished() { isPlaying = false guard let next = nextChapter else { return } // Chapter-based sleep timer if case .chapters(let count) = sleepTimer { let played = chapter - sleepTimerStartChapter + 1 if played >= count { stop(); return } updateChapterTimerLabel(chaptersRemaining: count - played) } NotificationCenter.default.post( name: .audioDidFinishChapter, object: nil, userInfo: ["next": next, "autoNext": autoNext]) guard autoNext else { return } let nextTitle = chapters.first(where: { $0.number == next })?.title ?? "" let nextNextChapter = chapters.first(where: { $0.number > next })?.number if nextPrefetchStatus == .prefetched, !nextAudioURL.isEmpty { let url = nextAudioURL chapter = next chapterTitle = nextTitle nextChapter = nextNextChapter prevChapter = chapter nextPrefetchStatus = .none nextAudioURL = "" nextPrefetchedChapter = nil audioURL = url status = .ready generationProgress = 100 if case .chapters = sleepTimer { sleepTimerStartChapter = next } generationTask = Task { await playURL(url); await prefetchNext() } } else { load(slug: slug, chapter: next, chapterTitle: nextTitle, bookTitle: bookTitle, coverURL: coverURL, voice: voice, speed: speed, chapters: chapters, nextChapter: nextNextChapter, prevChapter: chapter) } } // MARK: - Cover art (URLSession — no Kingfisher) private func prefetchCoverArtwork(from urlString: String) async { guard !urlString.isEmpty, let url = URL(string: urlString) else { return } guard let (data, _) = try? await URLSession.shared.data(from: url), let image = UIImage(data: data) else { return } let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image } cachedCoverArtwork = artwork updateNowPlaying() } // MARK: - Audio session private func configureAudioSession() { try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio) try? AVAudioSession.sharedInstance().setActive(true) } // MARK: - Lock-screen controls private func setupRemoteCommandCenter() { let center = MPRemoteCommandCenter.shared() center.playCommand.addTarget { [weak self] _ in self?.play(); return .success } center.pauseCommand.addTarget { [weak self] _ in self?.pause(); return .success } center.togglePlayPauseCommand.addTarget { [weak self] _ in self?.togglePlayPause(); return .success } center.skipForwardCommand.preferredIntervals = [15] center.skipForwardCommand.addTarget { [weak self] _ in self?.skip(by: 15); return .success } center.skipBackwardCommand.preferredIntervals = [15] center.skipBackwardCommand.addTarget { [weak self] _ in self?.skip(by: -15); return .success } center.changePlaybackPositionCommand.addTarget { [weak self] event in if let e = event as? MPChangePlaybackPositionCommandEvent { self?.seek(to: e.positionTime) } return .success } } private func updateNowPlaying() { var info: [String: Any] = [ MPMediaItemPropertyTitle: chapterTitle.isEmpty ? "Chapter \(chapter)" : chapterTitle, MPMediaItemPropertyArtist: bookTitle, MPNowPlayingInfoPropertyElapsedPlaybackTime: currentTime, MPMediaItemPropertyPlaybackDuration: duration, MPNowPlayingInfoPropertyPlaybackRate: isPlaying ? speed : 0.0 ] if let artwork = cachedCoverArtwork { info[MPMediaItemPropertyArtwork] = artwork } MPNowPlayingInfoCenter.default().nowPlayingInfo = info } } // MARK: - Supporting types enum AudioPlayerStatus: Equatable { case idle case generating case ready case error(String) static func == (lhs: AudioPlayerStatus, rhs: AudioPlayerStatus) -> Bool { switch (lhs, rhs) { case (.idle, .idle), (.generating, .generating), (.ready, .ready): return true case (.error(let a), .error(let b)): return a == b default: return false } } } enum SleepTimerOption: Equatable { case chapters(Int) case minutes(Int) } extension Notification.Name { static let audioDidFinishChapter = Notification.Name("v2.audioDidFinishChapter") static let skipToNextChapter = Notification.Name("v2.skipToNextChapter") static let skipToPrevChapter = Notification.Name("v2.skipToPrevChapter") }