From 8870fcc2b1b5fdab1b73b0d011e33556819ac3f1 Mon Sep 17 00:00:00 2001 From: Matthew Alvernaz Date: Sun, 12 Apr 2026 14:08:49 -0700 Subject: [PATCH 1/2] Throttle playback progress publishers to reduce UI redraws The .bookPlaying and .listeningProgressChanged notifications fire every second from the AVPlayer periodic time observer, causing recalculateProgress() to update multiple @Published properties on every tick. Similarly, currentProgressPublisher and immediateProgressUpdatePublisher in ItemProgressView trigger per-second redraws for every visible library row. On lower-powered devices (e.g. iPhone SE 2), this causes noticeable UI lag during playback. This adds 500ms throttling on the player progress publishers (keeping slider updates smooth) and 1s throttling on the library progress views (matching the existing folderProgressUpdated throttle). Co-Authored-By: Claude Opus 4.6 (1M context) --- BookPlayer/Library/ItemList/Views/ItemProgressView.swift | 2 ++ BookPlayer/Player/ViewModels/PlayerViewModel.swift | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/BookPlayer/Library/ItemList/Views/ItemProgressView.swift b/BookPlayer/Library/ItemList/Views/ItemProgressView.swift index 5b64bd137..b2919e3b0 100644 --- a/BookPlayer/Library/ItemList/Views/ItemProgressView.swift +++ b/BookPlayer/Library/ItemList/Views/ItemProgressView.swift @@ -51,12 +51,14 @@ struct ItemProgressView: View { .onReceive( playerManager.currentProgressPublisher() .filter { $0.0 == item.relativePath } + .throttle(for: .seconds(1), scheduler: DispatchQueue.main, latest: true) ) { (_, progress) in self.progress = progress } .onReceive( libraryService.immediateProgressUpdatePublisher .filter { item.relativePath == $0["relativePath"] as? String } + .throttle(for: .seconds(1), scheduler: DispatchQueue.main, latest: true) ) { params in if let percentCompleted = params["percentCompleted"] as? Double { self.progress = percentCompleted / 100 diff --git a/BookPlayer/Player/ViewModels/PlayerViewModel.swift b/BookPlayer/Player/ViewModels/PlayerViewModel.swift index 67db179b3..0f931d531 100644 --- a/BookPlayer/Player/ViewModels/PlayerViewModel.swift +++ b/BookPlayer/Player/ViewModels/PlayerViewModel.swift @@ -223,7 +223,7 @@ final class PlayerViewModel: ObservableObject { self.playingProgressSubscriber?.cancel() self.playingProgressSubscriber = NotificationCenter.default.publisher(for: .bookPlaying) - .receive(on: DispatchQueue.main) + .throttle(for: .milliseconds(500), scheduler: DispatchQueue.main, latest: true) .sink { [weak self] _ in guard let self = self else { return } self.recalculateProgress() @@ -231,7 +231,7 @@ final class PlayerViewModel: ObservableObject { self.listeningProgressSubscriber?.cancel() self.listeningProgressSubscriber = NotificationCenter.default.publisher(for: .listeningProgressChanged) - .receive(on: DispatchQueue.main) + .throttle(for: .milliseconds(500), scheduler: DispatchQueue.main, latest: true) .sink { [weak self] _ in guard let self = self else { return } self.recalculateProgress() From 24a926a91aeec0d33ff378dc2cc7f8aac4055ef4 Mon Sep 17 00:00:00 2001 From: matalvernaz Date: Mon, 27 Apr 2026 21:36:36 -0700 Subject: [PATCH 2/2] refactor: extract progress-throttle magic numbers into named constants PlayerViewModel and ItemProgressView were both using literal throttle windows (500ms / 1s) in multiple subscriber pipelines. Promoted each to a documented private static constant on the owning type so the cadence is discoverable and the rationale (audio-tick suppression vs SwiftUI redraw cost) lives next to the value. --- .../Library/ItemList/Views/ItemProgressView.swift | 13 ++++++++++--- BookPlayer/Player/ViewModels/PlayerViewModel.swift | 11 +++++++++-- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/BookPlayer/Library/ItemList/Views/ItemProgressView.swift b/BookPlayer/Library/ItemList/Views/ItemProgressView.swift index b2919e3b0..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,14 +58,14 @@ struct ItemProgressView: View { .onReceive( playerManager.currentProgressPublisher() .filter { $0.0 == item.relativePath } - .throttle(for: .seconds(1), scheduler: DispatchQueue.main, latest: true) + .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(1), scheduler: DispatchQueue.main, latest: true) + .throttle(for: .seconds(Self.progressUpdateThrottleSeconds), scheduler: DispatchQueue.main, latest: true) ) { params in if let percentCompleted = params["percentCompleted"] as? Double { self.progress = percentCompleted / 100 @@ -69,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 0f931d531..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) - .throttle(for: .milliseconds(500), scheduler: DispatchQueue.main, latest: true) + .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) - .throttle(for: .milliseconds(500), scheduler: DispatchQueue.main, latest: true) + .throttle(for: .milliseconds(Self.progressNotificationThrottleMs), scheduler: DispatchQueue.main, latest: true) .sink { [weak self] _ in guard let self = self else { return } self.recalculateProgress()