diff --git a/BookPlayer/Library/ItemList/Views/ItemProgressView.swift b/BookPlayer/Library/ItemList/Views/ItemProgressView.swift index 5b64bd137..079eab7bc 100644 --- a/BookPlayer/Library/ItemList/Views/ItemProgressView.swift +++ b/BookPlayer/Library/ItemList/Views/ItemProgressView.swift @@ -10,6 +10,13 @@ import BookPlayerKit import SwiftUI struct ItemProgressView: View { + /// Throttle window applied to per-row progress publishers. Each visible row in a long + /// library list subscribes to three high-frequency progress streams; without throttling + /// SwiftUI re-diffs the circular indicators on every audio tick, which lags VoiceOver + /// and burns CPU. One second matches the existing `.folderProgressUpdated` cadence and + /// keeps the list feeling live. + private static let progressUpdateThrottleSeconds = 1 + let item: SimpleLibraryItem let isHighlighted: Bool @@ -51,12 +58,14 @@ struct ItemProgressView: View { .onReceive( playerManager.currentProgressPublisher() .filter { $0.0 == item.relativePath } + .throttle(for: .seconds(Self.progressUpdateThrottleSeconds), scheduler: DispatchQueue.main, latest: true) ) { (_, progress) in self.progress = progress } .onReceive( libraryService.immediateProgressUpdatePublisher .filter { item.relativePath == $0["relativePath"] as? String } + .throttle(for: .seconds(Self.progressUpdateThrottleSeconds), scheduler: DispatchQueue.main, latest: true) ) { params in if let percentCompleted = params["percentCompleted"] as? Double { self.progress = percentCompleted / 100 @@ -67,7 +76,7 @@ struct ItemProgressView: View { } .onReceive( NotificationCenter.default.publisher(for: .folderProgressUpdated) - .throttle(for: .seconds(1), scheduler: DispatchQueue.main, latest: true) + .throttle(for: .seconds(Self.progressUpdateThrottleSeconds), scheduler: DispatchQueue.main, latest: true) .filter { notification in guard item.type != .book, diff --git a/BookPlayer/Player/ViewModels/PlayerViewModel.swift b/BookPlayer/Player/ViewModels/PlayerViewModel.swift index 67db179b3..4845364c4 100644 --- a/BookPlayer/Player/ViewModels/PlayerViewModel.swift +++ b/BookPlayer/Player/ViewModels/PlayerViewModel.swift @@ -21,6 +21,13 @@ enum PlayerSheetStyle: String, Identifiable { @MainActor final class PlayerViewModel: ObservableObject { + /// Throttle window applied to high-frequency playback-progress notifications + /// (`.bookPlaying`, `.listeningProgressChanged`) before they trigger a recompute of the + /// progress UI. Tuned to a roughly 2 Hz refresh — fast enough for the time/progress + /// readout to feel live, slow enough to keep the main thread out of the audio engine's + /// per-tick firehose. + private static let progressNotificationThrottleMs = 500 + @Published var progressData = ProgressData() @Published var isPlaying = false @Published var playbackSpeed: Float = 1.0 @@ -223,7 +230,7 @@ final class PlayerViewModel: ObservableObject { self.playingProgressSubscriber?.cancel() self.playingProgressSubscriber = NotificationCenter.default.publisher(for: .bookPlaying) - .receive(on: DispatchQueue.main) + .throttle(for: .milliseconds(Self.progressNotificationThrottleMs), scheduler: DispatchQueue.main, latest: true) .sink { [weak self] _ in guard let self = self else { return } self.recalculateProgress() @@ -231,7 +238,7 @@ final class PlayerViewModel: ObservableObject { self.listeningProgressSubscriber?.cancel() self.listeningProgressSubscriber = NotificationCenter.default.publisher(for: .listeningProgressChanged) - .receive(on: DispatchQueue.main) + .throttle(for: .milliseconds(Self.progressNotificationThrottleMs), scheduler: DispatchQueue.main, latest: true) .sink { [weak self] _ in guard let self = self else { return } self.recalculateProgress()