CPU: Remove @Published from AudioPlayer time properties

Replace @Published var currentTime/duration with plain vars and drive
progress bars via TimelineView(.periodic) instead of SwiftUI
observation. This stops objectWillChange from firing 20x/second on
AudioPlayer, eliminating continuous body re-evaluation on
NowPlayingView, MiniPlayerBar, and MyMusicView regardless of
visualizer state.
This commit is contained in:
Dallas Groot 2026-04-11 16:44:56 -07:00
parent 758d7a5ebd
commit fc69d8a3cf
6 changed files with 153 additions and 91 deletions

View file

@ -21,8 +21,15 @@ class AudioPlayer: NSObject, ObservableObject {
// currentTime and duration are @Published but set only from addPeriodicTimeObserver // currentTime and duration are @Published but set only from addPeriodicTimeObserver
// on the main queue safe for SwiftUI observation. NowPlayingView binds directly, // on the main queue safe for SwiftUI observation. NowPlayingView binds directly,
// eliminating the Timer.publish poller that caused runloop starvation. // eliminating the Timer.publish poller that caused runloop starvation.
@Published var currentTime: TimeInterval = 0 // currentTime and duration are intentionally NOT @Published.
@Published var duration: TimeInterval = 0 // @Published fires objectWillChange on AudioPlayer 10-20x/second, causing
// every @EnvironmentObject/ObservedObject subscriber (NowPlayingView,
// MiniPlayerBar, MyMusicView etc.) to re-evaluate their entire bodies at
// that rate. Progress bars use TimelineView(.periodic) instead, which reads
// these values directly on each tick without triggering objectWillChange.
// Both are written and read exclusively on the main queue no race risk.
var currentTime: TimeInterval = 0
var duration: TimeInterval = 0
@Published var volume: Float = 0.8 @Published var volume: Float = 0.8
@Published var repeatMode: RepeatMode = .off @Published var repeatMode: RepeatMode = .off
@Published var shuffleEnabled = false @Published var shuffleEnabled = false
@ -425,8 +432,9 @@ class AudioPlayer: NSObject, ObservableObject {
isUsingCrossfade = false isUsingCrossfade = false
#endif #endif
// Check for offline version first // Check for offline version first use Song overload which falls back to
if let localURL = OfflineManager.shared.localURL(for: song.id) { // title/artist/duration matching if the ID changed after a Companion restructure
if let localURL = OfflineManager.shared.localURL(for: song) {
#if os(iOS) #if os(iOS)
if VisualizerSettings.shared.realAudioAnalysis { if VisualizerSettings.shared.realAudioAnalysis {
// Use AVPlayer for reliable playback + offline vis for the visualizer // Use AVPlayer for reliable playback + offline vis for the visualizer

View file

@ -218,6 +218,47 @@ class OfflineManager: ObservableObject {
let url = downloadDirectory.appendingPathComponent(filename) let url = downloadDirectory.appendingPathComponent(filename)
return fileManager.fileExists(atPath: url.path) ? url : nil return fileManager.fileExists(atPath: url.path) ? url : nil
} }
/// Full-song overload with title/artist/duration fallback.
///
/// When a Companion API tag edit triggers a Navidrome restructure, the file
/// moves to a new path and Navidrome assigns a new ID. The catalog still has
/// the old ID so a bare ID lookup returns nil. This method falls back to
/// matching by title + artist + duration (±2s) so previously downloaded
/// songs remain playable offline even after their IDs change.
func localURL(for song: Song) -> URL? {
// 1. Exact ID match fast path, covers the common case
if let url = localURL(for: song.id) { return url }
// 2. Fuzzy fallback title + artist + duration (±2s)
// Handles ID changes caused by Companion API restructuring.
let targetTitle = song.title.lowercased().trimmingCharacters(in: .whitespaces)
let targetArtist = (song.artist ?? "").lowercased().trimmingCharacters(in: .whitespaces)
let targetDur = Double(song.duration ?? 0)
for ds in downloadedSongs {
let dsTitle = ds.song.title.lowercased().trimmingCharacters(in: .whitespaces)
let dsArtist = (ds.song.artist ?? "").lowercased().trimmingCharacters(in: .whitespaces)
let dsDur = Double(ds.song.duration ?? 0)
guard dsTitle == targetTitle, dsArtist == targetArtist else { continue }
guard targetDur == 0 || abs(dsDur - targetDur) <= 2 else { continue }
let filename = (ds.localPath as NSString).lastPathComponent
let url = downloadDirectory.appendingPathComponent(filename)
if fileManager.fileExists(atPath: url.path) {
print("[OfflineManager] ID changed: \(ds.id)\(song.id) (\(song.title))", flush: true)
return url
}
}
return nil
}
/// Returns true if this song has a matching offline file, checking both
/// the stored ID and the title/artist/duration fallback.
func isSongAvailableOffline(_ song: Song) -> Bool {
localURL(for: song) != nil
}
func coverArtLocalURL(for coverArtId: String) -> URL? { func coverArtLocalURL(for coverArtId: String) -> URL? {
let path = downloadDirectory.appendingPathComponent("\(coverArtId).jpg") let path = downloadDirectory.appendingPathComponent("\(coverArtId).jpg")

View file

@ -578,7 +578,6 @@ struct MiniPlayerBar: View {
// when audioPlayer.currentTime changes. The rest of MiniPlayerBar body // when audioPlayer.currentTime changes. The rest of MiniPlayerBar body
// only re-evaluates on song change, play/pause, or color change. // only re-evaluates on song change, play/pause, or color change.
MiniProgressBar( MiniProgressBar(
audioPlayer: audioPlayer,
colorExtractor: colorExtractor, colorExtractor: colorExtractor,
isScrubbing: $isScrubbing, isScrubbing: $isScrubbing,
scrubPosition: $scrubPosition, scrubPosition: $scrubPosition,
@ -796,64 +795,73 @@ struct DynamicIslandView: View {
// color change not on every currentTime tick. // color change not on every currentTime tick.
private struct MiniProgressBar: View { private struct MiniProgressBar: View {
@ObservedObject var audioPlayer: AudioPlayer // No @ObservedObject on AudioPlayer currentTime is no longer @Published.
// TimelineView drives re-evaluation at 10Hz independently so this view never
// triggers objectWillChange on AudioPlayer or propagates updates to siblings.
@ObservedObject var colorExtractor: AlbumColorExtractor @ObservedObject var colorExtractor: AlbumColorExtractor
@Binding var isScrubbing: Bool @Binding var isScrubbing: Bool
@Binding var scrubPosition: Double @Binding var scrubPosition: Double
let accentPink: Color let accentPink: Color
private var displayProgress: Double {
if isScrubbing { return scrubPosition }
guard audioPlayer.duration > 0 else { return 0 }
return min(audioPlayer.currentTime / audioPlayer.duration, 1.0)
}
private var barColor: Color { private var barColor: Color {
colorExtractor.isLoaded ? colorExtractor.primaryColor : accentPink colorExtractor.isLoaded ? colorExtractor.primaryColor : accentPink
} }
var body: some View { var body: some View {
let _ = DebugLogger.shared.trackViewBodies ? ViewBodyTracker.shared.record("MiniProgressBar") : false let _ = DebugLogger.shared.trackViewBodies ? ViewBodyTracker.shared.record("MiniProgressBar") : false
GeometryReader { geo in // TimelineView provides a fresh timestamp every 0.1s the progress bar
ZStack(alignment: .leading) { // reads AudioPlayer.shared.currentTime directly on each tick.
Rectangle() // No @Published subscription means NowPlayingView/MiniPlayerBar are
.fill(Color.white.opacity(0.1)) // completely unaffected by playback position changes.
.frame(height: isScrubbing ? 8 : 3) TimelineView(.periodic(from: .now, by: 0.1)) { _ in
GeometryReader { geo in
let player = AudioPlayer.shared
let progress: Double = {
if isScrubbing { return scrubPosition }
guard player.duration > 0 else { return 0 }
return min(player.currentTime / player.duration, 1.0)
}()
Rectangle() ZStack(alignment: .leading) {
.fill(barColor) Rectangle()
.frame( .fill(Color.white.opacity(0.1))
width: geo.size.width * displayProgress, .frame(height: isScrubbing ? 8 : 3)
height: isScrubbing ? 8 : 3
)
if isScrubbing { Rectangle()
Circle()
.fill(barColor) .fill(barColor)
.frame(width: 16, height: 16) .frame(
.offset(x: geo.size.width * displayProgress - 8, y: -1) width: geo.size.width * progress,
height: isScrubbing ? 8 : 3
)
if isScrubbing {
Circle()
.fill(barColor)
.frame(width: 16, height: 16)
.offset(x: geo.size.width * progress - 8, y: -1)
}
} }
} .frame(maxHeight: .infinity)
.frame(maxHeight: .infinity) .contentShape(Rectangle())
.contentShape(Rectangle()) .gesture(
.gesture( DragGesture(minimumDistance: 0)
DragGesture(minimumDistance: 0) .onChanged { value in
.onChanged { value in isScrubbing = true
isScrubbing = true scrubPosition = min(max(value.location.x / geo.size.width, 0), 1)
scrubPosition = min(max(value.location.x / geo.size.width, 0), 1)
}
.onEnded { value in
let pct = min(max(value.location.x / geo.size.width, 0), 1)
audioPlayer.seekToPercent(pct)
scrubPosition = pct
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
isScrubbing = false
} }
} .onEnded { value in
) let pct = min(max(value.location.x / geo.size.width, 0), 1)
AudioPlayer.shared.seekToPercent(pct)
scrubPosition = pct
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
isScrubbing = false
}
}
)
}
.frame(height: isScrubbing ? 20 : 14)
.animation(.easeInOut(duration: 0.15), value: isScrubbing)
} }
.frame(height: isScrubbing ? 20 : 14)
.animation(.easeInOut(duration: 0.15), value: isScrubbing)
} }
} }

View file

@ -858,7 +858,10 @@ struct MyMusicView: View {
@ViewBuilder @ViewBuilder
private func songRow(song: Song, index: Int, allSongs: [Song]) -> some View { private func songRow(song: Song, index: Int, allSongs: [Song]) -> some View {
let isDownloaded = offlineManager.isSongDownloaded(song.id) // isSongAvailableOffline checks exact ID first, then title/artist/duration
// fallback handles songs whose Navidrome ID changed after a Companion
// API tag edit triggered a file restructure (AUDIT: offline song fix)
let isDownloaded = offlineManager.isSongAvailableOffline(song)
let isOnWatch = WatchConnectivityManager.shared.isSongOnWatch(song.id) let isOnWatch = WatchConnectivityManager.shared.isSongOnWatch(song.id)
let dlState = offlineManager.downloads[song.id] let dlState = offlineManager.downloads[song.id]
let available = isDownloaded || libraryCache.isServerAvailable let available = isDownloaded || libraryCache.isServerAvailable

View file

@ -2,39 +2,38 @@ import SwiftUI
// MARK: - NowPlayingSeekBar // MARK: - NowPlayingSeekBar
// //
// Isolated seek bar extracted from NowPlayingView to fix sustained ~128% CPU. // Progress bar for NowPlayingView uses TimelineView(.periodic) instead of
// @ObservedObject so currentTime changes never trigger objectWillChange on
// AudioPlayer. Without this, NowPlayingView.body (always mounted, just
// offset off-screen) re-evaluated 20x/second even when invisible.
// //
// Root cause: NowPlayingView is always mounted in the ZStack (offset off-screen // currentTime and duration are intentionally NOT @Published on AudioPlayer.
// when hidden rather than removed). It had @EnvironmentObject var audioPlayer, // TimelineView re-evaluates this view's content at 10Hz and reads the values
// so its entire body album art, lyrics, queue, transport controls re-evaluated // directly no subscription, no objectWillChange propagation to parent views.
// 10x/second from audioPlayer.currentTime changes, even when the user was in a
// completely different tab.
//
// Fix: this struct declares its OWN @ObservedObject on audioPlayer, scoping the
// 10Hz SwiftUI invalidation to just the seek bar + time labels. NowPlayingView's
// body now only re-evaluates on song change, play/pause, or star/shuffle changes.
struct NowPlayingSeekBar: View { struct NowPlayingSeekBar: View {
@ObservedObject var audioPlayer: AudioPlayer // Plain reference NOT @ObservedObject. We don't want objectWillChange.
var radioBuffer: RadioStreamBuffer? = nil var radioBuffer: RadioStreamBuffer? = nil
var accentColor: Color = Color(red: 1.0, green: 0.176, blue: 0.333) var accentColor: Color = Color(red: 1.0, green: 0.176, blue: 0.333)
@State private var isDraggingSlider = false @State private var isDraggingSlider = false
@State private var dragPosition: Double = 0 @State private var dragPosition: Double = 0
private var playbackTime: TimeInterval { audioPlayer.currentTime } private var isLiveRadio: Bool {
private var playbackDuration: TimeInterval { audioPlayer.duration } radioBuffer != nil && AudioPlayer.shared.isRadioStream
}
private var isLiveRadio: Bool { radioBuffer != nil && audioPlayer.isRadioStream }
private var isRecordedRadio: Bool { radioBuffer != nil && !audioPlayer.isRadioStream }
var body: some View { var body: some View {
let _ = DebugLogger.shared.trackViewBodies ? ViewBodyTracker.shared.record("SeekBar") : false let _ = DebugLogger.shared.trackViewBodies ? ViewBodyTracker.shared.record("SeekBar") : false
VStack(spacing: 6) { // TimelineView drives re-evaluation at 10Hz. currentTime/duration are
if let rb = radioBuffer, isLiveRadio { // read directly from AudioPlayer.shared inside the closure on each tick.
radioBar(rb) TimelineView(.periodic(from: .now, by: 0.1)) { _ in
} else { VStack(spacing: 6) {
normalBar if let rb = radioBuffer, isLiveRadio {
radioBar(rb)
} else {
normalBar
}
} }
} }
} }
@ -42,18 +41,22 @@ struct NowPlayingSeekBar: View {
// MARK: - Normal seek bar // MARK: - Normal seek bar
private var normalBar: some View { private var normalBar: some View {
VStack(spacing: 6) { let player = AudioPlayer.shared
let time = player.currentTime
let duration = player.duration
return VStack(spacing: 6) {
SiriSeekBar( SiriSeekBar(
value: Binding( value: Binding(
get: { get: {
guard playbackDuration > 0 else { return 0 } guard duration > 0 else { return 0 }
return playbackTime / playbackDuration return time / duration
}, },
set: { _ in } set: { _ in }
), ),
onSeek: { pct in onSeek: { pct in
isDraggingSlider = false isDraggingSlider = false
audioPlayer.seekToPercent(pct) AudioPlayer.shared.seekToPercent(pct)
}, },
onDragChanged: { pct in onDragChanged: { pct in
isDraggingSlider = true isDraggingSlider = true
@ -61,23 +64,21 @@ struct NowPlayingSeekBar: View {
} }
) )
.accessibilityLabel("Seek bar") .accessibilityLabel("Seek bar")
.accessibilityValue( .accessibilityValue(formatTime(time) + " of " + formatTime(duration))
formatTime(playbackTime) + " of " + formatTime(playbackDuration)
)
.accessibilityAdjustableAction { direction in .accessibilityAdjustableAction { direction in
let step = playbackDuration * 0.05 let step = duration * 0.05
switch direction { switch direction {
case .increment: audioPlayer.seek(to: min(playbackDuration, playbackTime + step)) case .increment: AudioPlayer.shared.seek(to: min(duration, time + step))
case .decrement: audioPlayer.seek(to: max(0, playbackTime - step)) case .decrement: AudioPlayer.shared.seek(to: max(0, time - step))
@unknown default: break @unknown default: break
} }
} }
HStack { HStack {
Text(formatTime(isDraggingSlider ? playbackDuration * dragPosition : playbackTime)) Text(formatTime(isDraggingSlider ? duration * dragPosition : time))
.font(.system(size: 11)).foregroundColor(.gray) .font(.system(size: 11)).foregroundColor(.gray)
Spacer() Spacer()
Text("-" + formatTime(playbackDuration - (isDraggingSlider ? playbackDuration * dragPosition : playbackTime))) Text("-" + formatTime(duration - (isDraggingSlider ? duration * dragPosition : time)))
.font(.system(size: 11)).foregroundColor(.gray) .font(.system(size: 11)).foregroundColor(.gray)
} }
} }
@ -87,8 +88,10 @@ struct NowPlayingSeekBar: View {
@ViewBuilder @ViewBuilder
private func radioBar(_ rb: RadioStreamBuffer) -> some View { private func radioBar(_ rb: RadioStreamBuffer) -> some View {
let player = AudioPlayer.shared
let time = player.currentTime
if rb.isHLSStream { if rb.isHLSStream {
// HLS: fully greyed, no scrubber, no time labels
Capsule() Capsule()
.fill(Color.white.opacity(0.08)) .fill(Color.white.opacity(0.08))
.frame(height: 4) .frame(height: 4)
@ -109,8 +112,8 @@ struct NowPlayingSeekBar: View {
let playheadPct: CGFloat = { let playheadPct: CGFloat = {
if isDraggingSlider { return dragPosition } if isDraggingSlider { return dragPosition }
if audioPlayer.isPlayingFromBuffer { if player.isPlayingFromBuffer {
return CGFloat(min(playbackTime / maxSec, fillPct)) return CGFloat(min(time / maxSec, fillPct))
} }
return fillPct return fillPct
}() }()
@ -137,8 +140,8 @@ struct NowPlayingSeekBar: View {
} }
.onEnded { v in .onEnded { v in
isDraggingSlider = false isDraggingSlider = false
let pct = min(max(v.location.x / geo.size.width, 0), fillPct) let pct = min(max(v.location.x / geo.size.width, 0), fillPct)
audioPlayer.radioSeekBack(to: pct * maxSec) AudioPlayer.shared.radioSeekBack(to: pct * maxSec)
} }
) )
} }
@ -146,8 +149,8 @@ struct NowPlayingSeekBar: View {
HStack { HStack {
if isDraggingSlider { if isDraggingSlider {
let dragPosSec = dragPosition * rb.maxBufferDuration let dragPosSec = dragPosition * rb.maxBufferDuration
let behindLive = max(0, rb.estimatedBufferSeconds - dragPosSec) let behindLive = max(0, rb.estimatedBufferSeconds - dragPosSec)
Text(behindLive < 1 ? "LIVE" : "-" + formatTime(behindLive)) Text(behindLive < 1 ? "LIVE" : "-" + formatTime(behindLive))
.font(.system(size: 11, weight: .medium)) .font(.system(size: 11, weight: .medium))
.foregroundColor(behindLive < 1 ? accentColor : .gray) .foregroundColor(behindLive < 1 ? accentColor : .gray)
@ -156,7 +159,7 @@ struct NowPlayingSeekBar: View {
.font(.system(size: 11, weight: .semibold)) .font(.system(size: 11, weight: .semibold))
.foregroundColor(accentColor) .foregroundColor(accentColor)
} else { } else {
let behindLive = max(0, rb.estimatedBufferSeconds - playbackTime) let behindLive = max(0, rb.estimatedBufferSeconds - time)
Text("-" + formatTime(behindLive)) Text("-" + formatTime(behindLive))
.font(.system(size: 11, weight: .medium)) .font(.system(size: 11, weight: .medium))
.foregroundColor(.gray) .foregroundColor(.gray)
@ -167,7 +170,6 @@ struct NowPlayingSeekBar: View {
.foregroundColor(.gray) .foregroundColor(.gray)
} }
} else { } else {
// Buffer not yet ready
Capsule() Capsule()
.fill(Color.white.opacity(0.1)) .fill(Color.white.opacity(0.1))
.frame(height: 4) .frame(height: 4)

View file

@ -814,11 +814,11 @@ struct NowPlayingView: View {
private var normalProgressBar: some View { private var normalProgressBar: some View {
// NowPlayingSeekBar owns its own @ObservedObject audioPlayer reference. // NowPlayingSeekBar owns its own @ObservedObject audioPlayer reference.
// Only this child re-evaluates at 10Hz NowPlayingView.body stays quiet. // Only this child re-evaluates at 10Hz NowPlayingView.body stays quiet.
NowPlayingSeekBar(audioPlayer: audioPlayer) NowPlayingSeekBar()
} }
private var radioProgressBar: some View { private var radioProgressBar: some View {
NowPlayingSeekBar(audioPlayer: audioPlayer, radioBuffer: radioBuffer, accentColor: accentPink) NowPlayingSeekBar(radioBuffer: radioBuffer, accentColor: accentPink)
} }
// MARK: - Transport Controls // MARK: - Transport Controls