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:
parent
758d7a5ebd
commit
fc69d8a3cf
6 changed files with 153 additions and 91 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue