Files
libnovel/ios/LibNovel/LibNovel/Services/AudioPlayerService.swift
Admin 57591766f2
Some checks failed
CI / UI / Build (pull_request) Failing after 6s
CI / Scraper / Test (pull_request) Failing after 6s
CI / Scraper / Lint (pull_request) Failing after 9s
CI / Scraper / Build (pull_request) Has been skipped
iOS CI / Build (push) Has been cancelled
iOS CI / Test (push) Has been cancelled
iOS CI / Build (pull_request) Failing after 2s
iOS CI / Test (pull_request) Has been skipped
iOS: update tab icons, skip interval, cover radius, and mini player progress bar fix
2026-03-08 19:23:14 +05:00

577 lines
20 KiB
Swift

import Foundation
import AVFoundation
import MediaPlayer
import Combine
// MARK: - PlaybackProgress
// Isolated ObservableObject for high-frequency playback state (currentTime,
// duration, isPlaying). Keeping these separate from AudioPlayerService means
// the 0.5-second time-observer ticks only invalidate views that explicitly
// observe PlaybackProgress menus and other stable UI are unaffected.
@MainActor
final class PlaybackProgress: ObservableObject {
@Published var currentTime: Double = 0
@Published var duration: Double = 0
@Published var isPlaying: Bool = false
}
// MARK: - AudioPlayerService
// Central singleton that owns AVPlayer, drives audio state, handles lock-screen
// controls (NowPlayingInfoCenter + MPRemoteCommandCenter), and pre-fetches the
// next chapter audio.
@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: [ChapterIndexBrief] = []
@Published var status: AudioPlayerStatus = .idle
@Published var audioURL: String = ""
@Published var errorMessage: String = ""
@Published var generationProgress: Double = 0
/// High-frequency playback state (currentTime / duration / isPlaying).
/// Views that only need the seek bar or play-pause button should observe
/// this directly so they don't trigger re-renders of menu-bearing parents.
let progress = PlaybackProgress()
// Convenience forwarders so non-view call sites keep compiling unchanged.
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 nextPrefetchStatus: NextPrefetchStatus = .none
@Published var nextAudioURL: String = ""
@Published var nextPrefetchedChapter: Int? = nil
var isActive: Bool {
switch status {
case .idle: return false
default: return true
}
}
/// Absolute previous chapter number (current - 1), or nil if at first chapter
var absolutePrevChapter: Int? {
guard chapter > 1 else { return nil }
return chapter - 1
}
/// Absolute next chapter number (current + 1), or nil if at last chapter
var absoluteNextChapter: Int? {
guard !chapters.isEmpty else { return nil }
let maxChapter = chapters.map(\.number).max() ?? chapter
guard chapter < maxChapter else { return nil }
return chapter + 1
}
// 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<Void, Never>?
private var prefetchTask: Task<Void, Never>?
// 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?
private var cachedCoverURL: String = ""
// Sleep timer tracking
private var sleepTimerTask: Task<Void, Never>?
private var sleepTimerStartChapter: Int = 0
// MARK: - Init
init() {
configureAudioSession()
setupRemoteCommandCenter()
}
// MARK: - Public API
/// Load audio for a specific chapter. Triggers TTS generation if not cached.
func load(slug: String, chapter: Int, chapterTitle: String,
bookTitle: String, coverURL: String, voice: String, speed: Double,
chapters: [ChapterIndexBrief], 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
// Reset sleep timer start chapter if it's a chapter-based timer
if case .chapters = sleepTimer {
sleepTimerStartChapter = chapter
}
status = .generating
generationProgress = 0
// Invalidate cover cache if the book changed.
if coverURL != cachedCoverURL {
cachedCoverArtwork = nil
cachedCoverURL = coverURL
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 // optimistic UI update
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?) {
// 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()
teardownPlayer()
isPlaying = false
currentTime = 0
duration = 0
audioURL = ""
status = .idle
// Cancel sleep timer
sleepTimerTask?.cancel()
sleepTimerTask = nil
sleepTimer = nil
}
// MARK: - Audio generation
private func generateAudio() async {
guard !slug.isEmpty, chapter > 0 else { return }
do {
// Fast path: audio already in MinIO get a presigned URL and play immediately.
if let presignedURL = try? await APIClient.shared.presignAudio(slug: slug, chapter: chapter, voice: voice) {
audioURL = presignedURL
status = .ready
generationProgress = 100
await playURL(presignedURL)
await prefetchNext()
return
}
// Slow path: trigger TTS generation (async returns 202 immediately).
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 {
// 202 Accepted: poll until done.
generationProgress = 30
playableURL = try await APIClient.shared.pollAudioStatus(slug: slug, chapter: chapter, voice: voice)
} else {
// 200: already cached URL returned inline.
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
// Always prefetch regardless of autoNext faster playback when the user
// manually navigates forward. autoNext only controls whether we auto-navigate.
private func prefetchNext() async {
guard let next = nextChapter, !Task.isCancelled else { return }
nextPrefetchStatus = .prefetching
nextPrefetchedChapter = next
do {
// Fast path: already in MinIO.
if let presignedURL = try? await APIClient.shared.presignAudio(slug: slug, chapter: next, voice: voice) {
nextAudioURL = presignedURL
nextPrefetchStatus = .prefetched
return
}
// Slow path: trigger generation; poll until done (background won't block playback).
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 {
// Resolve relative paths (e.g. "/api/audio/...") to absolute URLs.
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)
// KVO: update duration as soon as asset metadata is loaded.
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()
}
}
// KVO: set playback rate once the item is ready.
// Do NOT call player?.play() unconditionally let readyToPlay trigger it
// so we don't race between AVPlayer's internal buffering and our call.
statusObserver = item.publisher(for: \.status)
.receive(on: RunLoop.main)
.sink { [weak self] itemStatus in
guard let self else { return }
if itemStatus == .readyToPlay {
self.player?.rate = Float(self.speed)
self.isPlaying = true
self.updateNowPlaying()
} else if itemStatus == .failed {
self.status = .error(item.error?.localizedDescription ?? "Playback failed")
self.errorMessage = item.error?.localizedDescription ?? "Playback failed"
}
}
// Periodic time observer for seek bar position.
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
}
}
}
// Observe when playback ends.
finishObserver = NotificationCenter.default
.publisher(for: AVPlayerItem.didPlayToEndTimeNotification, object: item)
.sink { [weak self] _ in
Task { @MainActor in
self?.handlePlaybackFinished()
}
}
// Kick off buffering actual playback starts via statusObserver above.
player?.play()
}
private func teardownPlayer() {
if let observer = timeObserver { player?.removeTimeObserver(observer) }
timeObserver = nil
statusObserver = nil
durationObserver = nil
finishObserver = nil
player = nil
playerItem = nil
}
private func handlePlaybackFinished() {
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(
name: .audioDidFinishChapter,
object: nil,
userInfo: ["next": next, "autoNext": autoNext]
)
// 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 }
let nextTitle = chapters.first(where: { $0.number == next })?.title ?? ""
let nextNextChapter = chapters.first(where: { $0.number > next })?.number
let nextPrevChapter: Int? = chapter // Current chapter becomes previous for the next one
// If we already prefetched a URL for the next chapter, skip straight to
// playback and kick off generation in the background for the one after.
if nextPrefetchStatus == .prefetched, !nextAudioURL.isEmpty {
let url = nextAudioURL
// Advance state before tearing down the current player.
chapter = next
chapterTitle = nextTitle
nextChapter = nextNextChapter
prevChapter = nextPrevChapter
nextPrefetchStatus = .none
nextAudioURL = ""
nextPrefetchedChapter = nil
audioURL = url
status = .ready
generationProgress = 100
// Update sleep timer start chapter if using chapter-based timer
if case .chapters = sleepTimer {
sleepTimerStartChapter = next
}
generationTask = Task {
await playURL(url)
await prefetchNext()
}
} else {
// No prefetch available do a full load.
load(
slug: slug,
chapter: next,
chapterTitle: nextTitle,
bookTitle: bookTitle,
coverURL: coverURL,
voice: voice,
speed: speed,
chapters: chapters,
nextChapter: nextNextChapter,
prevChapter: nextPrevChapter
)
}
}
// MARK: - Cover art prefetch
private func prefetchCoverArtwork(from urlString: String) {
guard !urlString.isEmpty, let url = URL(string: urlString) else { return }
URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
guard let self, let data, let image = UIImage(data: data) else { return }
let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image }
Task { @MainActor in
self.cachedCoverArtwork = artwork
self.updateNowPlaying()
}
}.resume()
}
// MARK: - Audio Session
private func configureAudioSession() {
do {
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio)
try AVAudioSession.sharedInstance().setActive(true)
} catch {
// Non-fatal
}
}
// MARK: - Lock Screen / Control Center
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
]
// Use cached artwork downloaded once in prefetchCoverArtwork().
if let artwork = cachedCoverArtwork {
info[MPMediaItemPropertyArtwork] = artwork
}
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
}
}
// MARK: - Supporting types
enum AudioPlayerStatus: Equatable {
case idle
case generating // covers both "loading" and "generating TTS" phases
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) // 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")
static let skipToPrevChapter = Notification.Name("skipToPrevChapter")
}