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:
@@ -33,6 +33,17 @@ final class PlayerEngine {
|
||||
/// Pausiert mit der Wiedergabe; bei `.endOfBook`/`.endOfChapter` rate-skaliert aus der Restspielzeit.
|
||||
var sleepRemainingSeconds: Double = 0
|
||||
var chapters: [Chapter] = []
|
||||
/// Set by AppState. Fired when a remote/system control (lockscreen, Control
|
||||
/// Center, headphones) triggers a skip, so AppState can record history and
|
||||
/// run its own seek path instead of bypassing it.
|
||||
var onRemoteSkip: ((Double) -> Void)?
|
||||
/// Set by AppState. Fired when a remote control changes playback position.
|
||||
var onRemoteSeek: ((Double) -> Void)?
|
||||
/// Set by AppState. Fired by the AVPlayer periodic time observer whenever
|
||||
/// playback crosses into a new chapter — works reliably in background and
|
||||
/// with the device locked, unlike a runloop-scheduled Timer.
|
||||
var onChapterChanged: ((Chapter) -> Void)?
|
||||
private var lastObservedChapterId: Int?
|
||||
|
||||
var currentChapter: Chapter? {
|
||||
chapters.last { $0.start <= absoluteCurrentTime }
|
||||
@@ -125,6 +136,9 @@ final class PlayerEngine {
|
||||
}
|
||||
|
||||
self.chapters = item.chapters
|
||||
// Reset chapter tracking — first observation after load is "initial",
|
||||
// not a transition.
|
||||
lastObservedChapterId = nil
|
||||
currentTitle = item.title
|
||||
currentAuthor = item.author
|
||||
currentCoverURL = client.coverURL(itemId: item.id)
|
||||
@@ -251,6 +265,19 @@ final class PlayerEngine {
|
||||
if wasPlaying != isPlaying { updateNowPlayingInfo() }
|
||||
updateEndOfBookSleep()
|
||||
updateEndOfChapterSleep()
|
||||
detectChapterTransition()
|
||||
}
|
||||
|
||||
private func detectChapterTransition() {
|
||||
let currentId = currentChapter?.id
|
||||
guard currentId != lastObservedChapterId else { return }
|
||||
let priorId = lastObservedChapterId
|
||||
lastObservedChapterId = currentId
|
||||
// Skip the very first observation after load (priorId == nil) — that's
|
||||
// the initial position, not a real transition. AppState records that
|
||||
// separately in `play(item:)`.
|
||||
guard priorId != nil, let chapter = currentChapter else { return }
|
||||
onChapterChanged?(chapter)
|
||||
}
|
||||
|
||||
func teardown() {
|
||||
@@ -275,6 +302,7 @@ final class PlayerEngine {
|
||||
errorMessage = nil
|
||||
isSeeking = false
|
||||
chapters = []
|
||||
lastObservedChapterId = nil
|
||||
currentTitle = ""
|
||||
currentAuthor = ""
|
||||
currentCoverURL = nil
|
||||
@@ -374,12 +402,24 @@ final class PlayerEngine {
|
||||
applyRemoteSkipInterval(seconds: Self.currentSkipSeconds())
|
||||
center.skipForwardCommand.addTarget { [weak self] _ in
|
||||
let s = Double(Self.currentSkipSeconds())
|
||||
Task { @MainActor in self?.skip(by: s) }
|
||||
Task { @MainActor in
|
||||
if let handler = self?.onRemoteSkip {
|
||||
handler(s)
|
||||
} else {
|
||||
self?.skip(by: s)
|
||||
}
|
||||
}
|
||||
return .success
|
||||
}
|
||||
center.skipBackwardCommand.addTarget { [weak self] _ in
|
||||
let s = Double(Self.currentSkipSeconds())
|
||||
Task { @MainActor in self?.skip(by: -s) }
|
||||
Task { @MainActor in
|
||||
if let handler = self?.onRemoteSkip {
|
||||
handler(-s)
|
||||
} else {
|
||||
self?.skip(by: -s)
|
||||
}
|
||||
}
|
||||
return .success
|
||||
}
|
||||
NotificationCenter.default.addObserver(
|
||||
@@ -394,7 +434,13 @@ final class PlayerEngine {
|
||||
return .commandFailed
|
||||
}
|
||||
let target = posEvent.positionTime
|
||||
Task { @MainActor in self?.seekAbsolute(target) }
|
||||
Task { @MainActor in
|
||||
if let handler = self?.onRemoteSeek {
|
||||
handler(target)
|
||||
} else {
|
||||
self?.seekAbsolute(target)
|
||||
}
|
||||
}
|
||||
return .success
|
||||
}
|
||||
center.changePlaybackRateCommand.supportedPlaybackRates = [0.75, 1.0, 1.25, 1.5, 1.75, 2.0]
|
||||
|
||||
Reference in New Issue
Block a user