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
493 lines
18 KiB
Swift
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")
|
|
}
|