diff --git a/Shared/Audio/AudioPlayer.swift b/Shared/Audio/AudioPlayer.swift index 5cbca4e..8010fda 100644 --- a/Shared/Audio/AudioPlayer.swift +++ b/Shared/Audio/AudioPlayer.swift @@ -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 diff --git a/Shared/Storage/OfflineManager.swift b/Shared/Storage/OfflineManager.swift index 7806a87..cfe82a0 100644 --- a/Shared/Storage/OfflineManager.swift +++ b/Shared/Storage/OfflineManager.swift @@ -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") diff --git a/iOS/Views/Common/MainTabView.swift b/iOS/Views/Common/MainTabView.swift index efa005f..e106c4e 100644 --- a/iOS/Views/Common/MainTabView.swift +++ b/iOS/Views/Common/MainTabView.swift @@ -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) } } diff --git a/iOS/Views/Library/MyMusicView.swift b/iOS/Views/Library/MyMusicView.swift index a0d7fa9..37baeaf 100644 --- a/iOS/Views/Library/MyMusicView.swift +++ b/iOS/Views/Library/MyMusicView.swift @@ -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 diff --git a/iOS/Views/NowPlaying/NowPlayingSeekBar.swift b/iOS/Views/NowPlaying/NowPlayingSeekBar.swift index 41e0167..15e5f41 100644 --- a/iOS/Views/NowPlaying/NowPlayingSeekBar.swift +++ b/iOS/Views/NowPlaying/NowPlayingSeekBar.swift @@ -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) diff --git a/iOS/Views/NowPlaying/NowPlayingView.swift b/iOS/Views/NowPlaying/NowPlayingView.swift index cdca188..bc8bf4b 100644 --- a/iOS/Views/NowPlaying/NowPlayingView.swift +++ b/iOS/Views/NowPlaying/NowPlayingView.swift @@ -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