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:
@@ -76,6 +76,11 @@ final class DownloadManager: @unchecked Sendable {
|
||||
/// Bytes received for the currently in-flight track per downloadKey.
|
||||
/// Reset between tracks; cleared when the download finishes/cancels.
|
||||
private(set) var inFlightBytes: [String: Int64] = [:]
|
||||
/// Current track index (0-based) per active download. Set at the start of
|
||||
/// each track iteration, cleared via defer when the download exits.
|
||||
private var currentTrackIndex: [String: Int] = [:]
|
||||
/// Total number of tracks per active download.
|
||||
private var totalTrackCount: [String: Int] = [:]
|
||||
|
||||
private var indexFile: URL { AppPaths.supportDirectory.appendingPathComponent("downloads-index.json") }
|
||||
private var activeTasks: [String: Task<Void, Never>] = [:]
|
||||
@@ -118,6 +123,21 @@ final class DownloadManager: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Called from the URL session delegate with the fraction (0…1) of the
|
||||
/// currently downloading track. Combines with the completed-track count to
|
||||
/// drive a smooth overall progress ring, even for single-track downloads.
|
||||
nonisolated func _reportTrackByteFraction(_ fraction: Double, for downloadKey: String) {
|
||||
Task { @MainActor [self] in
|
||||
guard let idx = self.currentTrackIndex[downloadKey],
|
||||
let total = self.totalTrackCount[downloadKey],
|
||||
total > 0 else { return }
|
||||
let overall = (Double(idx) + max(0, min(1, fraction))) / Double(total)
|
||||
// Clamp below 1.0 so `performDownload` is the only place that
|
||||
// transitions to `.downloaded` after the final track is persisted.
|
||||
self.states[downloadKey] = .downloading(progress: min(0.999, overall))
|
||||
}
|
||||
}
|
||||
|
||||
private func fileSize(at url: URL) -> Int64 {
|
||||
guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path) else { return 0 }
|
||||
if let n = attrs[.size] as? NSNumber { return n.int64Value }
|
||||
@@ -218,6 +238,8 @@ final class DownloadManager: @unchecked Sendable {
|
||||
defer {
|
||||
pendingItems.removeValue(forKey: downloadKey)
|
||||
inFlightBytes.removeValue(forKey: downloadKey)
|
||||
currentTrackIndex.removeValue(forKey: downloadKey)
|
||||
totalTrackCount.removeValue(forKey: downloadKey)
|
||||
}
|
||||
let itemDir = directoryURL(itemId: workItem.id, episodeId: workItem.episodeId)
|
||||
do {
|
||||
@@ -229,6 +251,7 @@ final class DownloadManager: @unchecked Sendable {
|
||||
|
||||
var tracks: [DownloadedTrack] = []
|
||||
let total = max(workItem.audioFiles.count, 1)
|
||||
totalTrackCount[downloadKey] = total
|
||||
|
||||
for (idx, file) in workItem.audioFiles.enumerated() {
|
||||
if Task.isCancelled {
|
||||
@@ -238,6 +261,7 @@ final class DownloadManager: @unchecked Sendable {
|
||||
guard let url = client.audioFileURL(itemId: workItem.id, ino: file.ino) else { continue }
|
||||
var request = URLRequest(url: url)
|
||||
for (k, v) in client.bearerHeader { request.setValue(v, forHTTPHeaderField: k) }
|
||||
currentTrackIndex[downloadKey] = idx
|
||||
|
||||
let tempURL: URL
|
||||
do {
|
||||
@@ -418,6 +442,10 @@ private final class DownloadProgressDelegate: NSObject, URLSessionDownloadDelega
|
||||
totalBytesExpectedToWrite: Int64
|
||||
) {
|
||||
manager._updateInFlightBytes(totalBytesWritten, for: downloadKey)
|
||||
if totalBytesExpectedToWrite > 0 {
|
||||
let fraction = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
|
||||
manager._reportTrackByteFraction(fraction, for: downloadKey)
|
||||
}
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
|
||||
Reference in New Issue
Block a user