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:
Scarriffle
2026-05-27 20:44:38 +02:00
parent fa47cae664
commit 9497c6e315
8 changed files with 271 additions and 69 deletions

View File

@@ -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(