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 let history: HistoryManager let bookmarks: BookmarkManager var currentItem: LibraryItem? var isPreparingPlayback: Bool = false var language: String = UserDefaults.standard.string(forKey: "appLanguage") ?? "de" { didSet { UserDefaults.standard.set(language, forKey: "appLanguage") } } /// Map: PlaybackProgress.syncKey -> PlaybackProgress (server-known progress). /// Used to show progress bars on covers in the library views. var progressCache: [String: PlaybackProgress] = [:] /// Server-progress that's newer than local but we haven't applied yet because /// playback is active/paused. Offered via alert on next play; cleared on /// item change. var pendingServerProgress: PlaybackProgress? private var syncTimer: Timer? private var pullTimer: Timer? private var lastReportedSecond: Double = -10 private var lastPushedAt: Date = .distantPast 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() self.history = HistoryManager() self.bookmarks = BookmarkManager() // Route lockscreen/Control-Center seeks through AppState so history is // recorded — otherwise remote skips bypass history entirely. self.player.onRemoteSkip = { [weak self] seconds in self?.skip(by: seconds) } self.player.onRemoteSeek = { [weak self] target in self?.seekAbsolute(target) } // PlayerEngine reports chapter transitions from the AVPlayer time // observer — fires reliably in background/locked, unlike the 5s Timer. self.player.onChapterChanged = { [weak self] chapter in self?.recordChapterEntry(chapter) } } private func recordChapterEntry(_ chapter: Chapter) { guard let item = currentItem, UserDefaults.standard.bool(forKey: "historyEnabled") else { return } history.record(item: item, position: chapter.start, chapters: item.chapters) } 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() } } startPullTimer() } /// Called by ContentView on scenePhase == .active. Immediate pull so we /// notice updates from other devices the moment the app comes forward. func onScenePhaseActive() { Task { await pullAndReconcile() } } private func startPullTimer() { pullTimer?.invalidate() let timer = Timer.scheduledTimer(withTimeInterval: 60.0, repeats: true) { _ in Task { @MainActor [weak self] in await self?.pullAndReconcile() } } RunLoop.main.add(timer, forMode: .common) pullTimer = timer } /// Pulls server progress and reconciles against local state: /// - currentItem == nil: just refresh the cache (library covers). /// - server is newer than local: stash for the resume-prompt. /// - server is older than local: push our state immediately. func pullAndReconcile() async { guard network.isOnline, auth.isLoggedIn else { return } await refreshProgressCache() guard let current = currentItem else { return } guard let server = progressCache[current.syncKey] else { return } let local = player.absoluteCurrentTime let positionDelta = abs(server.currentTime - local) // Treat <8 s delta as identical to absorb own-update echoes, clock skew, // and reporting granularity. guard positionDelta > 8 else { return } let serverIsNewer = server.updatedAt > lastPushedAt.addingTimeInterval(5) if serverIsNewer { pendingServerProgress = server } else { reportProgress(force: true) } } func acceptPendingServerProgress() { guard let p = pendingServerProgress else { return } pendingServerProgress = nil player.seekAbsolute(p.currentTime) player.play() } func dismissPendingServerProgress() { pendingServerProgress = nil player.play() } /// 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, overrideStartAt: Double? = nil) async { // Clear any stash from a previous item — only carry stashes per-item. pendingServerProgress = nil if currentItem?.id == item.id, currentItem?.episodeId == item.episodeId, player.isReady { if let pos = overrideStartAt { seekAbsolute(pos) } else { player.play() } return } // Record position before switching to a new item if let current = currentItem, player.absoluteCurrentTime > 5, UserDefaults.standard.bool(forKey: "historyEnabled") { history.record(item: current, position: player.absoluteCurrentTime, chapters: current.chapters) } stopPlayback(reportFinal: true) isPreparingPlayback = true defer { isPreparingPlayback = false } var workItem = item // Always fetch detail when online so chapters are loaded — also for // already-downloaded items (the persisted DownloadedItem doesn't store // chapter metadata, so streaming the detail is the only source). if !workItem.isPodcast && network.isOnline { if let detail = try? await client.fetchItemDetail(itemId: item.id) { workItem = detail } } var startAt: Double = overrideStartAt ?? 0 if overrideStartAt == nil && 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 } } } if network.isOnline { // Load bookmarks from server for this item if let serverBMs = try? await client.fetchBookmarks(itemId: workItem.id, episodeId: workItem.episodeId) { bookmarks.mergeFromServer(serverBMs, for: workItem) } } currentItem = workItem player.load(item: workItem, client: client, downloads: downloads, startAt: startAt) if player.errorMessage == nil { player.play() startSyncTimer() if UserDefaults.standard.bool(forKey: "historyEnabled") { history.record(item: workItem, position: startAt, chapters: workItem.chapters) } } } func playFromHistory(_ entry: HistoryEntry) async { if let current = currentItem, current.id == entry.itemId, current.episodeId == entry.episodeId { seekAbsolute(entry.position) return } guard network.isOnline, let detail = try? await client.fetchItemDetail(itemId: entry.itemId) else { return } var item = detail item.episodeId = entry.episodeId await play(item: item, overrideStartAt: entry.position) } /// 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 pendingServerProgress = nil lastReportedSecond = -10 } func togglePlay() { guard currentItem != nil else { return } player.togglePlay() if !player.isPlaying { reportProgress(force: true) } } func skip(by seconds: Double) { guard let item = currentItem else { return } if UserDefaults.standard.bool(forKey: "historyEnabled") { history.record(item: item, position: player.absoluteCurrentTime, chapters: item.chapters) } player.skip(by: seconds) reportProgress(force: true) } func seekAbsolute(_ target: Double) { guard let item = currentItem else { return } if UserDefaults.standard.bool(forKey: "historyEnabled") { history.record(item: item, position: player.absoluteCurrentTime, chapters: item.chapters) } player.seekAbsolute(target) reportProgress(force: true) } func setRate(_ newRate: Float) { player.setRate(newRate) } func addBookmark(title: String) { guard let item = currentItem else { return } let t = player.absoluteCurrentTime bookmarks.add(item: item, time: t, title: title, chapters: item.chapters) if network.isOnline { Task { try? await client.createBookmark(itemId: item.id, time: t, title: title) } } } func deleteBookmark(_ bookmark: Bookmark) { bookmarks.delete(bookmark) if network.isOnline { Task { try? await client.deleteBookmark(itemId: bookmark.itemId, time: bookmark.time) } } } 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 ) lastPushedAt = Date() 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 } }