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] = [:] private var syncTimer: Timer? private var lastReportedSecond: Double = -10 private var lastTrackedChapterId: Int? 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() } 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, overrideStartAt: Double? = nil) async { 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 for books to get chapters; skip if downloaded offline. if !workItem.isPodcast && 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 = 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() let startingChapter = workItem.chapters.last { $0.start <= startAt } lastTrackedChapterId = startingChapter?.id 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 lastReportedSecond = -10 lastTrackedChapterId = nil } 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 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 detectChapterChange(item: LibraryItem) { let currentId = player.currentChapter?.id guard currentId != lastTrackedChapterId else { return } defer { lastTrackedChapterId = currentId } guard lastTrackedChapterId != nil, let chapter = player.currentChapter, UserDefaults.standard.bool(forKey: "historyEnabled") else { return } history.record(item: item, position: chapter.start, chapters: item.chapters) } private func reportProgress(force: Bool) { guard let item = currentItem else { return } let t = player.absoluteCurrentTime let d = player.totalDuration detectChapterChange(item: item) 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 } }