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 } }