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
577 lines
20 KiB
Swift
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")
|
|
}
|