Files
ABS-Client/ABS Client/Audiobookshelf swift/Services/AppState.swift
2026-05-17 21:06:59 +02:00

225 lines
7.3 KiB
Swift

import Foundation
import Observation
@Observable
@MainActor
final class AppState {
let auth: AuthStore
let client: ABSClient
let network: NetworkMonitor
let downloads: DownloadManager
let sync: ProgressSyncManager
let player: PlayerEngine
var currentItem: LibraryItem?
var isPreparingPlayback: Bool = false
/// Map: PlaybackProgress.syncKey -> PlaybackProgress (server-known progress).
/// Used to show progress bars on covers in the library views.
var progressCache: [String: PlaybackProgress] = [:]
private var syncTimer: Timer?
private var lastReportedSecond: Double = -10
init() {
let auth = AuthStore()
let client = ABSClient(auth: auth)
self.auth = auth
self.client = client
self.network = NetworkMonitor()
self.downloads = DownloadManager(client: client)
self.sync = ProgressSyncManager(client: client)
self.player = PlayerEngine()
}
func bootstrap() async {
auth.restoreSession()
network.start { [weak self] online in
guard let self else { return }
if online {
Task { [weak self] in
await self?.sync.drain()
await self?.refreshProgressCache()
}
}
}
if auth.isLoggedIn {
let ok = await client.validateToken()
if !ok {
auth.logout()
} else {
await sync.drain()
await refreshProgressCache()
}
}
}
/// Pulls the entire progress map from the server (via /api/me).
func refreshProgressCache() async {
guard network.isOnline, auth.isLoggedIn else { return }
do {
let all = try await client.fetchAllProgress()
progressCache = Dictionary(all.map { ($0.syncKey, $0) }, uniquingKeysWith: { _, new in new })
} catch {
// non-fatal
}
}
/// Local update for the cache while we're actively playing.
func cacheProgress(itemId: String, episodeId: String?, currentTime: Double, duration: Double, isFinished: Bool) {
let p = PlaybackProgress(
itemId: itemId, episodeId: episodeId,
currentTime: currentTime, duration: duration,
isFinished: isFinished, updatedAt: Date()
)
progressCache[p.syncKey] = p
}
func progress(for item: LibraryItem) -> PlaybackProgress? {
progressCache[item.syncKey]
}
func progressFraction(itemId: String, episodeId: String? = nil) -> Double {
let key = episodeId.map { "\(itemId)|\($0)" } ?? itemId
guard let p = progressCache[key], p.duration > 0 else { return 0 }
if p.isFinished { return 1.0 }
return min(1, max(0, p.currentTime / p.duration))
}
func play(item: LibraryItem) async {
if currentItem?.id == item.id, currentItem?.episodeId == item.episodeId, player.isReady {
player.play()
return
}
stopPlayback(reportFinal: true)
isPreparingPlayback = true
defer { isPreparingPlayback = false }
var workItem = item
// Only fetch detail for books with empty audioFiles (podcast episodes
// arrive with their single audioFile already populated by the caller).
if !workItem.isPodcast && workItem.audioFiles.isEmpty && network.isOnline {
let alreadyDownloaded = downloads.isDownloaded(downloadKey: item.downloadKey)
if !alreadyDownloaded, let detail = try? await client.fetchItemDetail(itemId: item.id) {
workItem = detail
}
}
var startAt: Double = 0
if network.isOnline {
if let p = try? await client.fetchProgress(itemId: item.id, episodeId: workItem.episodeId) {
// Replaying a finished item (or one with progress essentially at the end)
// should start from the beginning, not drop the user at the last few seconds.
let nearEnd = p.duration > 0 && p.currentTime >= p.duration - 10
if !p.isFinished && !nearEnd {
startAt = p.currentTime
}
}
}
currentItem = workItem
player.load(item: workItem, client: client, downloads: downloads, startAt: startAt)
if player.errorMessage == nil {
player.play()
startSyncTimer()
}
}
/// Convenience for podcast episodes.
func play(podcast: LibraryItem, episode: PodcastEpisode) async {
var synthetic = LibraryItem(
id: podcast.id,
title: episode.title,
author: podcast.title,
durationSeconds: episode.durationSeconds > 0 ? episode.durationSeconds : episode.audioFile.durationSeconds,
audioFiles: [episode.audioFile]
)
synthetic.mediaType = "podcast"
synthetic.episodeId = episode.id
await play(item: synthetic)
}
func stopPlayback(reportFinal: Bool = true) {
if reportFinal { reportProgress(force: true) }
syncTimer?.invalidate()
syncTimer = nil
player.teardown()
currentItem = nil
lastReportedSecond = -10
}
func togglePlay() {
guard currentItem != nil else { return }
player.togglePlay()
if !player.isPlaying { reportProgress(force: true) }
}
func skip(by seconds: Double) {
guard currentItem != nil else { return }
player.skip(by: seconds)
reportProgress(force: true)
}
func seekAbsolute(_ target: Double) {
guard currentItem != nil else { return }
player.seekAbsolute(target)
reportProgress(force: true)
}
func setRate(_ newRate: Float) {
player.setRate(newRate)
}
private func startSyncTimer() {
syncTimer?.invalidate()
let timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { _ in
Task { @MainActor [weak self] in
self?.reportProgress(force: false)
}
}
RunLoop.main.add(timer, forMode: .common)
syncTimer = timer
}
private func reportProgress(force: Bool) {
guard let item = currentItem else { return }
let t = player.absoluteCurrentTime
let d = player.totalDuration
guard d > 0 else { return }
if !force && abs(t - lastReportedSecond) < 3 { return }
lastReportedSecond = t
let finished = (d - t) < 30
cacheProgress(
itemId: item.id,
episodeId: item.episodeId,
currentTime: t,
duration: d,
isFinished: finished
)
Task {
await sync.report(
itemId: item.id,
episodeId: item.episodeId,
currentTime: t,
duration: d,
isFinished: finished,
isOnline: network.isOnline
)
}
}
}
extension LibraryItem {
/// Matches PlaybackProgress.syncKey for cache lookups.
var syncKey: String {
if let episodeId { return "\(id)|\(episodeId)" }
return id
}
/// The DownloadManager keys downloads by this composite identifier,
/// allowing the same podcast item to host multiple per-episode downloads.
var downloadKey: String { syncKey }
}