Restructure project folders
This commit is contained in:
224
ABS Client Mac/Audiobookshelf swift/Services/AppState.swift
Normal file
224
ABS Client Mac/Audiobookshelf swift/Services/AppState.swift
Normal file
@@ -0,0 +1,224 @@
|
||||
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 }
|
||||
}
|
||||
Reference in New Issue
Block a user