Files
libnovel/ios/LibNovelV2/Services/AudioPlayerService.swift
Admin 7413313100
All checks were successful
CI / Scraper / Lint (push) Successful in 10s
CI / Scraper / Test (push) Successful in 14s
Release / Scraper / Test (push) Successful in 18s
CI / Scraper / Lint (pull_request) Successful in 18s
Release / UI / Build (push) Successful in 23s
CI / Scraper / Test (pull_request) Successful in 15s
CI / UI / Build (pull_request) Successful in 32s
Release / Scraper / Docker (push) Successful in 55s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
CI / Scraper / Docker Push (push) Successful in 1m5s
Release / UI / Docker (push) Successful in 1m12s
iOS CI / Build (push) Successful in 4m18s
iOS CI / Build (pull_request) Successful in 4m25s
iOS CI / Test (push) Successful in 8m11s
iOS CI / Test (pull_request) Successful in 8m21s
fix: update integration_test.go to match server.New signature (version, commit args)
2026-03-14 14:25:46 +05:00

493 lines
18 KiB
Swift

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<Void, Never>?
private var prefetchTask: Task<Void, Never>?
private var cachedCoverArtwork: MPMediaItemArtwork?
private var cachedCoverURL: String = ""
private var sleepTimerTask: Task<Void, Never>?
private var sleepTimerStartChapter: Int = 0
private var sleepTimerDeadline: Date? = nil
private var sleepTimerCountdownTask: Task<Void, Never>? = 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")
}