diff --git a/Shared/Audio/AudioPlayer.swift b/Shared/Audio/AudioPlayer.swift index 8dff9c7..5cbca4e 100644 --- a/Shared/Audio/AudioPlayer.swift +++ b/Shared/Audio/AudioPlayer.swift @@ -273,7 +273,7 @@ class AudioPlayer: NSObject, ObservableObject { timeObserver = player?.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in guard let self = self else { return } self.currentTime = time.seconds - if let dur = self.playerItem?.duration.seconds, !dur.isNaN { + if let dur = self.playerItem?.duration.seconds, !dur.isNaN, dur != self.duration { self.duration = dur } } @@ -728,7 +728,10 @@ class AudioPlayer: NSObject, ObservableObject { timeObserver = player?.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in guard let self = self else { return } self.currentTime = time.seconds - if let dur = self.playerItem?.duration.seconds, !dur.isNaN { + // Guard duration write: @Published fires objectWillChange on every + // assignment even if the value is identical. For a 4-min song this + // would fire an extra objectWillChange 10x/sec for the entire track. + if let dur = self.playerItem?.duration.seconds, !dur.isNaN, dur != self.duration { self.duration = dur } // Throttle state saves to once per 5 seconds — saves are cheap but @@ -1571,12 +1574,18 @@ class AudioPlayer: NSObject, ObservableObject { stopLevelTimer() lastTargetPhase = -1 levelTimer = Timer.scheduledTimer(withTimeInterval: 1.0/60.0, repeats: true) { [weak self] _ in - guard let self = self, self.isPlaying else { - if self?.internalLevels.contains(where: { $0 > 0.01 }) == true { - for i in 0..<(self?.internalLevels.count ?? 0) { - self?.internalLevels[i] = 0 + guard let self = self else { return } + // Skip all work when visualizer is disabled — avoids 60fps float math + // and setLevels() calls that serve no purpose with no Canvas reading them. + #if os(iOS) + guard VisualizerSettings.shared.enabled else { return } + #endif + guard self.isPlaying else { + if self.internalLevels.contains(where: { $0 > 0.01 }) { + for i in 0.. Bool { + queue.async { self.counts[name, default: 0] += 1 } + return true + } + + func start() { + guard timer == nil else { return } + timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + guard let self = self else { return } + self.queue.async { + let snapshot = self.counts + self.counts = [:] + guard !snapshot.isEmpty else { return } + // Sort descending by count so the hottest views appear first + let parts = snapshot + .sorted { $0.value > $1.value } + .map { "\($0.key):\($0.value)" } + .joined(separator: " ") + DispatchQueue.main.async { + DebugLogger.shared.log(parts, category: "ViewBody", level: .debug) + } + } + } + } + + func stop() { + timer?.invalidate() + timer = nil + queue.async { self.counts = [:] } + } +} + // MARK: - Debug Console View struct DebugConsoleView: View { @ObservedObject private var logger = DebugLogger.shared @@ -191,6 +259,8 @@ struct DebugConsoleView: View { levelToggle(.warning, isOn: $showWarning) levelToggle(.info, isOn: $showInfo) levelToggle(.debug, isOn: $showDebug) + Divider().frame(height: 16).background(Color.white.opacity(0.2)) + viewBodyToggle() } .padding(.horizontal, 12) } @@ -330,6 +400,25 @@ struct DebugConsoleView: View { } } + /// Toggle button for the View Body tracker. + /// When active, key views call Self._printChanges() and route the output + /// into the console under the "ViewBody" category. + @ViewBuilder + private func viewBodyToggle() -> some View { + Button(action: { logger.trackViewBodies.toggle() }) { + HStack(spacing: 4) { + Image(systemName: logger.trackViewBodies ? "bolt.fill" : "bolt.slash") + .font(.system(size: 10)) + Text("Bodies") + .font(.system(size: 10, weight: .medium)) + } + .foregroundColor(logger.trackViewBodies ? .orange : .gray.opacity(0.4)) + .padding(.horizontal, 8).padding(.vertical, 4) + .background(logger.trackViewBodies ? Color.orange.opacity(0.2) : Color.white.opacity(0.05)) + .cornerRadius(10) + } + } + private func logRow(_ entry: DebugLogger.LogEntry, prev: DebugLogger.LogEntry?) -> some View { // Marker (background/foreground separator) if entry.isMarker { diff --git a/iOS/Views/Common/MainTabView.swift b/iOS/Views/Common/MainTabView.swift index 3fe109a..efa005f 100644 --- a/iOS/Views/Common/MainTabView.swift +++ b/iOS/Views/Common/MainTabView.swift @@ -257,6 +257,8 @@ struct MainTabView: View { dbgGroupToggle("Watch", icon: "applewatch", isOn: $dbgShowWatch) dbgGroupToggle("Companion", icon: "server.rack", isOn: $dbgShowCompanion) dbgGroupToggle("Audio", icon: "waveform", isOn: $dbgShowAudio) + Divider().frame(height: 14).background(Color.white.opacity(0.15)) + dbgBodiesToggle() } .padding(.horizontal, 14) } @@ -310,6 +312,8 @@ struct MainTabView: View { let audioCats: Set = ["Audio", "FFT", "Radio", "Prefetch"] return DebugLogger.shared.entries.filter { entry in if entry.isMarker { return true } + // ViewBody entries only shown when tracker is active + if entry.category == "ViewBody" { return DebugLogger.shared.trackViewBodies } let isWatch = watchCats.contains(entry.category) let isCompanion = companionCats.contains(entry.category) let isAudio = audioCats.contains(entry.category) @@ -331,6 +335,24 @@ struct MainTabView: View { .cornerRadius(10) } } + + /// Compact Bodies toggle for both PiP and docked panel. + /// Mirrors the ⚡ toggle in DebugConsoleView but accessible inline. + @ViewBuilder + private func dbgBodiesToggle() -> some View { + Button(action: { DebugLogger.shared.trackViewBodies.toggle() }) { + HStack(spacing: 4) { + Image(systemName: DebugLogger.shared.trackViewBodies ? "bolt.fill" : "bolt.slash") + .font(.system(size: 10)) + Text("Bodies") + .font(.system(size: 10, weight: .medium)) + } + .foregroundColor(DebugLogger.shared.trackViewBodies ? .orange : .gray.opacity(0.4)) + .padding(.horizontal, 8).padding(.vertical, 4) + .background(DebugLogger.shared.trackViewBodies ? Color.orange.opacity(0.2) : Color.white.opacity(0.05)) + .cornerRadius(10) + } + } private func debugLogRow(_ entry: DebugLogger.LogEntry) -> some View { if entry.isMarker { @@ -392,7 +414,7 @@ struct MainTabView: View { .font(.system(size: 11, weight: .semibold, design: .monospaced)) .foregroundColor(.white) - Text("\(DebugLogger.shared.entries.count)") + Text("\(filteredDebugEntries.count)/\(DebugLogger.shared.entries.count)") .font(.system(size: 9, weight: .medium, design: .monospaced)) .foregroundColor(.gray) @@ -456,12 +478,27 @@ struct MainTabView: View { // Log content — hidden when collapsed if !pipCollapsed { + // ── Group toggles — same state as docked panel ─────────────── + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + dbgGroupToggle("iPhone", icon: "iphone", isOn: $dbgShowIPhone) + dbgGroupToggle("Watch", icon: "applewatch", isOn: $dbgShowWatch) + dbgGroupToggle("Companion", icon: "server.rack", isOn: $dbgShowCompanion) + dbgGroupToggle("Audio", icon: "waveform", isOn: $dbgShowAudio) + Divider().frame(height: 14).background(Color.white.opacity(0.15)) + dbgBodiesToggle() + } + .padding(.horizontal, 10) + } + .padding(.vertical, 5) + .background(Color(white: 0.10)) + Divider().background(Color.white.opacity(0.1)) ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 1) { - ForEach(DebugLogger.shared.entries) { entry in + ForEach(filteredDebugEntries) { entry in debugLogRow(entry) .id(entry.id) } @@ -535,6 +572,7 @@ struct MiniPlayerBar: View { private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) var body: some View { + let _ = DebugLogger.shared.trackViewBodies ? ViewBodyTracker.shared.record("MiniPlayerBar") : false VStack(spacing: 0) { // Progress bar extracted into its own view — only it re-evaluates at 10Hz // when audioPlayer.currentTime changes. The rest of MiniPlayerBar body @@ -775,6 +813,7 @@ private struct MiniProgressBar: View { } var body: some View { + let _ = DebugLogger.shared.trackViewBodies ? ViewBodyTracker.shared.record("MiniProgressBar") : false GeometryReader { geo in ZStack(alignment: .leading) { Rectangle() diff --git a/iOS/Views/NowPlaying/NowPlayingSeekBar.swift b/iOS/Views/NowPlaying/NowPlayingSeekBar.swift new file mode 100644 index 0000000..41e0167 --- /dev/null +++ b/iOS/Views/NowPlaying/NowPlayingSeekBar.swift @@ -0,0 +1,189 @@ +import SwiftUI + +// MARK: - NowPlayingSeekBar +// +// Isolated seek bar extracted from NowPlayingView to fix sustained ~128% CPU. +// +// 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. + +struct NowPlayingSeekBar: View { + @ObservedObject var audioPlayer: AudioPlayer + 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 } + + 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 + } + } + } + + // MARK: - Normal seek bar + + private var normalBar: some View { + VStack(spacing: 6) { + SiriSeekBar( + value: Binding( + get: { + guard playbackDuration > 0 else { return 0 } + return playbackTime / playbackDuration + }, + set: { _ in } + ), + onSeek: { pct in + isDraggingSlider = false + audioPlayer.seekToPercent(pct) + }, + onDragChanged: { pct in + isDraggingSlider = true + dragPosition = pct + } + ) + .accessibilityLabel("Seek bar") + .accessibilityValue( + formatTime(playbackTime) + " of " + formatTime(playbackDuration) + ) + .accessibilityAdjustableAction { direction in + let step = playbackDuration * 0.05 + switch direction { + case .increment: audioPlayer.seek(to: min(playbackDuration, playbackTime + step)) + case .decrement: audioPlayer.seek(to: max(0, playbackTime - step)) + @unknown default: break + } + } + + HStack { + Text(formatTime(isDraggingSlider ? playbackDuration * dragPosition : playbackTime)) + .font(.system(size: 11)).foregroundColor(.gray) + Spacer() + Text("-" + formatTime(playbackDuration - (isDraggingSlider ? playbackDuration * dragPosition : playbackTime))) + .font(.system(size: 11)).foregroundColor(.gray) + } + } + } + + // MARK: - Radio buffer bar + + @ViewBuilder + private func radioBar(_ rb: RadioStreamBuffer) -> some View { + if rb.isHLSStream { + // HLS: fully greyed, no scrubber, no time labels + Capsule() + .fill(Color.white.opacity(0.08)) + .frame(height: 4) + HStack { + Text("--:--") + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.gray.opacity(0.35)) + Spacer() + Text("--:--") + .font(.system(size: 11)) + .foregroundColor(.gray.opacity(0.35)) + } + } else if rb.isBuffering { + GeometryReader { geo in + let bufferSec = rb.estimatedBufferSeconds + let maxSec = rb.maxBufferDuration + let fillPct = min(max(bufferSec / maxSec, 0), 1.0) + + let playheadPct: CGFloat = { + if isDraggingSlider { return dragPosition } + if audioPlayer.isPlayingFromBuffer { + return CGFloat(min(playbackTime / maxSec, fillPct)) + } + return fillPct + }() + + ZStack(alignment: .leading) { + Capsule().fill(Color.white.opacity(0.1)).frame(height: 4) + Capsule().fill(accentColor.opacity(0.3)) + .frame(width: geo.size.width * fillPct, height: 4) + Circle() + .fill(rb.isLive ? accentColor : Color.white) + .frame( + width: isDraggingSlider ? 16 : 12, + height: isDraggingSlider ? 16 : 12 + ) + .offset(x: geo.size.width * playheadPct - (isDraggingSlider ? 8 : 6)) + .animation(.interactiveSpring(), value: isDraggingSlider) + } + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 8) + .onChanged { v in + isDraggingSlider = true + dragPosition = min(max(v.location.x / geo.size.width, 0), fillPct) + } + .onEnded { v in + isDraggingSlider = false + let pct = min(max(v.location.x / geo.size.width, 0), fillPct) + audioPlayer.radioSeekBack(to: pct * maxSec) + } + ) + } + .frame(height: 24) + + HStack { + if isDraggingSlider { + 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) + } else if rb.isLive { + Text("LIVE") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(accentColor) + } else { + let behindLive = max(0, rb.estimatedBufferSeconds - playbackTime) + Text("-" + formatTime(behindLive)) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.gray) + } + Spacer() + Text(formatTime(rb.bufferedDuration)) + .font(.system(size: 11)) + .foregroundColor(.gray) + } + } else { + // Buffer not yet ready + Capsule() + .fill(Color.white.opacity(0.1)) + .frame(height: 4) + HStack { + Text("Buffering...") + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.gray.opacity(0.5)) + Spacer() + } + } + } + + // MARK: - Helpers + + private func formatTime(_ seconds: TimeInterval) -> String { + let secs = Int(max(0, seconds)) + return String(format: "%d:%02d", secs / 60, secs % 60) + } +} diff --git a/iOS/Views/NowPlaying/NowPlayingView.swift b/iOS/Views/NowPlaying/NowPlayingView.swift index 4e73976..cc29de7 100644 --- a/iOS/Views/NowPlaying/NowPlayingView.swift +++ b/iOS/Views/NowPlaying/NowPlayingView.swift @@ -214,8 +214,8 @@ struct NowPlayingView: View { @EnvironmentObject var offlineManager: OfflineManager @Binding var isPresented: Bool - @State private var isDraggingSlider = false - @State private var dragPosition: Double = 0 + // isDraggingSlider and dragPosition moved into NowPlayingSeekBar — + // they only affect the seek bar child, not NowPlayingView.body @State private var showQueue = false @State private var showVisualizerSettings = false @State private var showPlaylistPicker = false @@ -235,10 +235,9 @@ struct NowPlayingView: View { @State private var dragOffset: CGFloat = 0 - // Bound directly to @Published currentTime/duration on AudioPlayer. - // No Timer.publish poller — addPeriodicTimeObserver pushes updates on main queue. - private var playbackTime: TimeInterval { audioPlayer.currentTime } - private var playbackDuration: TimeInterval { audioPlayer.duration } + // playbackTime/playbackDuration moved into NowPlayingSeekBar. + // NowPlayingView.body must not read currentTime — it causes the entire view + // to re-evaluate 10x/second even when hidden off screen (offset 1500pt). @Environment(\.horizontalSizeClass) private var hSizeClass @Environment(\.verticalSizeClass) private var vSizeClass @@ -267,6 +266,7 @@ struct NowPlayingView: View { } var body: some View { + let _ = DebugLogger.shared.trackViewBodies ? ViewBodyTracker.shared.record("NowPlayingView") : false GeometryReader { screenGeo in ZStack { visualizerLayer(geo: screenGeo) @@ -812,46 +812,18 @@ struct NowPlayingView: View { private var isLiveRadio: Bool { isRadio && audioPlayer.isRadioStream } private var normalProgressBar: some View { - VStack(spacing: 6) { - SiriSeekBar( - value: Binding( - get: { - guard playbackDuration > 0 else { return 0 } - return playbackTime / playbackDuration - }, - set: { _ in } - ), - onSeek: { pct in - isDraggingSlider = false - audioPlayer.seekToPercent(pct) - }, - onDragChanged: { pct in - isDraggingSlider = true - dragPosition = pct - } - ) - .accessibilityLabel("Seek bar") - .accessibilityValue(formatTime(playbackTime) + " of " + formatTime(playbackDuration)) - .accessibilityAdjustableAction { direction in - let step = playbackDuration * 0.05 // 5% per swipe - switch direction { - case .increment: audioPlayer.seek(to: min(playbackDuration, playbackTime + step)) - case .decrement: audioPlayer.seek(to: max(0, playbackTime - step)) - @unknown default: break - } - } - - HStack { - Text(formatTime(isDraggingSlider ? playbackDuration * dragPosition : playbackTime)) - .font(.system(size: 11)).foregroundColor(.gray) - Spacer() - Text("-" + formatTime(playbackDuration - (isDraggingSlider ? playbackDuration * dragPosition : playbackTime))) - .font(.system(size: 11)).foregroundColor(.gray) - } - } + // NowPlayingSeekBar owns its own @ObservedObject audioPlayer reference. + // Only this child re-evaluates at 10Hz — NowPlayingView.body stays quiet. + NowPlayingSeekBar(audioPlayer: audioPlayer) } private var radioProgressBar: some View { + NowPlayingSeekBar(audioPlayer: audioPlayer, radioBuffer: radioBuffer, accentColor: accentPink) + } + + // Radio progress bar implementation moved into NowPlayingSeekBar to keep + // currentTime reads scoped to the child view. This stub preserved for reference. + private var _radioProgressBarImpl: some View { VStack(spacing: 6) { if radioBuffer.isHLSStream { // HLS: fully greyed, no scrubber thumb, no time labels diff --git a/watchOS/App/NavidromeWatchApp.swift b/watchOS/App/NavidromeWatchApp.swift index 03604eb..4c33f84 100644 --- a/watchOS/App/NavidromeWatchApp.swift +++ b/watchOS/App/NavidromeWatchApp.swift @@ -1,9 +1,12 @@ import SwiftUI import WatchConnectivity +import WatchKit @main struct NavidromeWatchApp: App { + @WKApplicationDelegateAdaptor(NavidromeWatchDelegate.self) var appDelegate + @StateObject private var watchManager = WatchSessionManager.shared @StateObject private var audioPlayer = WatchAudioPlayer.shared @StateObject private var offlineStore = WatchOfflineStore.shared @@ -18,6 +21,45 @@ struct NavidromeWatchApp: App { } } +// MARK: - Watch App Delegate +// Required to handle WKURLSessionRefreshBackgroundTask — without this, the +// background URLSession in WatchOfflineStore never fires its completion delegate +// when downloads finish while the app is suspended. sessionSendsLaunchEvents=true +// in WatchOfflineStore has no effect without this handler (AUDIT-053). +class NavidromeWatchDelegate: NSObject, WKApplicationDelegate { + + func handle(_ backgroundTasks: Set) { + for task in backgroundTasks { + switch task { + case let urlSessionTask as WKURLSessionRefreshBackgroundTask: + // Re-connect the background URLSession by identifier so the system + // can hand back the completed download events to WatchOfflineStore's + // URLSessionDownloadDelegate. Merely accessing the backgroundSession + // lazy property is enough — it registers the delegate and the system + // delivers pending delegate calls immediately after. + let identifier = urlSessionTask.sessionIdentifier + if identifier == "com.navidromeplayer.watch.download" { + _ = WatchOfflineStore.shared.backgroundSession + print("[Watch] WKURLSessionRefreshBackgroundTask handled: \(identifier)") + } + urlSessionTask.setTaskCompletedWithSnapshot(false) + + case let refreshTask as WKApplicationRefreshBackgroundTask: + // Opportunity for a lightweight library metadata refresh. + Task { + if WatchSessionManager.shared.activeServer != nil { + try? await WatchSessionManager.shared.syncLibrary() + } + refreshTask.setTaskCompletedWithSnapshot(false) + } + + default: + task.setTaskCompletedWithSnapshot(false) + } + } + } +} + struct WatchRootView: View { @EnvironmentObject var watchManager: WatchSessionManager @EnvironmentObject var offlineStore: WatchOfflineStore diff --git a/watchOS/App/WatchOfflineStore.swift b/watchOS/App/WatchOfflineStore.swift index a2f8795..ac7cf74 100644 --- a/watchOS/App/WatchOfflineStore.swift +++ b/watchOS/App/WatchOfflineStore.swift @@ -31,7 +31,9 @@ class WatchOfflineStore: NSObject, ObservableObject, URLSessionDownloadDelegate private var pendingSongs: [String: Song] = [:] // access via storeQueue only private var taskToSongId: [Int: String] = [:] // access via storeQueue only - private lazy var backgroundSession: URLSession = { + // Internal so NavidromeWatchDelegate can touch it to re-attach the delegate + // after a background wake (WKURLSessionRefreshBackgroundTask handler). + lazy var backgroundSession: URLSession = { let config = URLSessionConfiguration.background(withIdentifier: "com.navidromeplayer.watch.download") config.isDiscretionary = false config.sessionSendsLaunchEvents = true diff --git a/watchOS/Audio/WatchAudioPlayer.swift b/watchOS/Audio/WatchAudioPlayer.swift index 0444f57..eed4d87 100644 --- a/watchOS/Audio/WatchAudioPlayer.swift +++ b/watchOS/Audio/WatchAudioPlayer.swift @@ -31,7 +31,10 @@ class WatchAudioPlayer: NSObject, ObservableObject { @Published var volume: Float = 1.0 @Published var repeatMode: RepeatMode = .off @Published var shuffleEnabled = false - @Published var audioLevels: [Float] = Array(repeating: 0, count: 8) + // audioLevels is NOT @Published — avoids 30fps objectWillChange on WatchAudioPlayer + // which forces every observing view to re-evaluate at 30fps, draining the battery. + // Views that need levels should use a dedicated TimelineView polling approach instead. + var audioLevels: [Float] = Array(repeating: 0, count: 8) // Audio route state @Published var isSessionActive = false @@ -336,7 +339,9 @@ class WatchAudioPlayer: NSObject, ObservableObject { guard let self = self else { return } self.currentTime = time.seconds if let dur = self.playerItem?.duration.seconds, !dur.isNaN { self.duration = dur } - self.updateNowPlayingInfo() + // MPNowPlayingInfoCenter update removed from here — writing the full + // dictionary via IPC every second is excessive (AUDIT-046). It is now + // called only when actual state changes (play/pause/seek/song change). } levelTimer = Timer.scheduledTimer(withTimeInterval: 1.0/30.0, repeats: true) { [weak self] _ in