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
// on the main queue safe for SwiftUI observation. NowPlayingView binds directly,
// eliminating the Timer.publish poller that caused runloop starvation.
@Published var currentTime: TimeInterval = 0
@Published var duration: TimeInterval = 0
// currentTime and duration are intentionally NOT @Published.
// @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 repeatMode: RepeatMode = .off
@Published var shuffleEnabled = false
@ -425,8 +432,9 @@ class AudioPlayer: NSObject, ObservableObject {
isUsingCrossfade = false
#endif
// Check for offline version first
if let localURL = OfflineManager.shared.localURL(for: song.id) {
// Check for offline version first use Song overload which falls back to
// title/artist/duration matching if the ID changed after a Companion restructure
if let localURL = OfflineManager.shared.localURL(for: song) {
#if os(iOS)
if VisualizerSettings.shared.realAudioAnalysis {
// Use AVPlayer for reliable playback + offline vis for the visualizer

View file

@ -218,6 +218,47 @@ class OfflineManager: ObservableObject {
let url = downloadDirectory.appendingPathComponent(filename)
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? {
let path = downloadDirectory.appendingPathComponent("\(coverArtId).jpg")

View file

@ -578,7 +578,6 @@ struct MiniPlayerBar: View {
// when audioPlayer.currentTime changes. The rest of MiniPlayerBar body
// only re-evaluates on song change, play/pause, or color change.
MiniProgressBar(
audioPlayer: audioPlayer,
colorExtractor: colorExtractor,
isScrubbing: $isScrubbing,
scrubPosition: $scrubPosition,
@ -796,64 +795,73 @@ struct DynamicIslandView: View {
// color change not on every currentTime tick.
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
@Binding var isScrubbing: Bool
@Binding var scrubPosition: Double
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 {
colorExtractor.isLoaded ? colorExtractor.primaryColor : accentPink
}
var body: some View {
let _ = DebugLogger.shared.trackViewBodies ? ViewBodyTracker.shared.record("MiniProgressBar") : false
GeometryReader { geo in
ZStack(alignment: .leading) {
Rectangle()
.fill(Color.white.opacity(0.1))
.frame(height: isScrubbing ? 8 : 3)
// TimelineView provides a fresh timestamp every 0.1s the progress bar
// reads AudioPlayer.shared.currentTime directly on each tick.
// No @Published subscription means NowPlayingView/MiniPlayerBar are
// completely unaffected by playback position changes.
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()
.fill(barColor)
.frame(
width: geo.size.width * displayProgress,
height: isScrubbing ? 8 : 3
)
ZStack(alignment: .leading) {
Rectangle()
.fill(Color.white.opacity(0.1))
.frame(height: isScrubbing ? 8 : 3)
if isScrubbing {
Circle()
Rectangle()
.fill(barColor)
.frame(width: 16, height: 16)
.offset(x: geo.size.width * displayProgress - 8, y: -1)
.frame(
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)
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
isScrubbing = true
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
.frame(maxHeight: .infinity)
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
isScrubbing = true
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.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
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 dlState = offlineManager.downloads[song.id]
let available = isDownloaded || libraryCache.isServerAvailable

View file

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

View file

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