Bidirectional progress sync, reliable background history, MB-accurate download ring
- Bidirectional progress sync: server's `lastUpdate` now parsed correctly; pull timer (60s) + scenePhase hook reconcile against local state. Server-newer while paused/playing stashes a `pendingServerProgress` and surfaces a prompt on next Play; server-older triggers an immediate push. - History: lockscreen/Control-Center skip & scrub now route through AppState via `onRemoteSkip`/`onRemoteSeek` callbacks (previously bypassed history). `AppState.skip(by:)` itself now records the pre-skip position. - Chapter detection moved to the AVPlayer periodic time observer — fires reliably while the app is backgrounded or the device is locked, where the 5s runloop Timer can be throttled. - Always fetch item detail when online (even for downloaded items) so `item.chapters` is populated and history entries get chapter titles. - DownloadManager: per-track byte-fraction progress, so single-track 1+GB audiobooks' ring grows smoothly instead of staying at 0% until done. - PlayerBar: extracted ScrubberView into its own struct so per-second time updates no longer re-render the parent (fixes iOS history-popup flicker). - App icon: re-embedded sRGB profile in marketing icon; bumped version 2.0 to 2.1. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -23,9 +23,15 @@ final class AppState {
|
||||
/// 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 lastTrackedChapterId: Int?
|
||||
private var lastPushedAt: Date = .distantPast
|
||||
|
||||
init() {
|
||||
let auth = AuthStore()
|
||||
@@ -38,6 +44,25 @@ final class AppState {
|
||||
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 {
|
||||
@@ -60,6 +85,59 @@ final class AppState {
|
||||
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).
|
||||
@@ -95,6 +173,8 @@ final class AppState {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -109,10 +189,11 @@ final class AppState {
|
||||
defer { isPreparingPlayback = false }
|
||||
|
||||
var workItem = item
|
||||
// Always fetch detail for books to get chapters; skip if downloaded offline.
|
||||
// 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 {
|
||||
let alreadyDownloaded = downloads.isDownloaded(downloadKey: item.downloadKey)
|
||||
if !alreadyDownloaded, let detail = try? await client.fetchItemDetail(itemId: item.id) {
|
||||
if let detail = try? await client.fetchItemDetail(itemId: item.id) {
|
||||
workItem = detail
|
||||
}
|
||||
}
|
||||
@@ -140,8 +221,6 @@ final class AppState {
|
||||
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)
|
||||
}
|
||||
@@ -182,8 +261,8 @@ final class AppState {
|
||||
syncTimer = nil
|
||||
player.teardown()
|
||||
currentItem = nil
|
||||
pendingServerProgress = nil
|
||||
lastReportedSecond = -10
|
||||
lastTrackedChapterId = nil
|
||||
}
|
||||
|
||||
func togglePlay() {
|
||||
@@ -193,7 +272,10 @@ final class AppState {
|
||||
}
|
||||
|
||||
func skip(by seconds: Double) {
|
||||
guard currentItem != nil else { return }
|
||||
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)
|
||||
}
|
||||
@@ -238,21 +320,10 @@ final class AppState {
|
||||
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
|
||||
@@ -265,6 +336,7 @@ final class AppState {
|
||||
duration: d,
|
||||
isFinished: finished
|
||||
)
|
||||
lastPushedAt = Date()
|
||||
|
||||
Task {
|
||||
await sync.report(
|
||||
|
||||
Reference in New Issue
Block a user