debugging high cpu usages
This commit is contained in:
parent
85c85c2090
commit
3b56626d6d
8 changed files with 403 additions and 56 deletions
|
|
@ -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..<self.internalLevels.count {
|
||||
self.internalLevels[i] = 0
|
||||
}
|
||||
self?.setLevels(self?.internalLevels ?? [])
|
||||
self.setLevels(self.internalLevels)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,11 +49,27 @@ class DebugLogger: ObservableObject {
|
|||
@Published var isEnabled: Bool {
|
||||
didSet { UserDefaults.standard.set(isEnabled, forKey: "debug_console_enabled") }
|
||||
}
|
||||
/// When true, key views record each body evaluation into ViewBodyTracker.
|
||||
/// The tracker reports per-second counts to the "ViewBody" category so you
|
||||
/// can see exactly which views are re-evaluating and how often — without
|
||||
/// needing Instruments. Toggle via the ⚡ Bodies button in the filter bar.
|
||||
@Published var trackViewBodies: Bool = false {
|
||||
didSet {
|
||||
UserDefaults.standard.set(trackViewBodies, forKey: "debug_track_view_bodies")
|
||||
if trackViewBodies {
|
||||
ViewBodyTracker.shared.start()
|
||||
} else {
|
||||
ViewBodyTracker.shared.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let maxEntries = 500
|
||||
|
||||
private init() {
|
||||
isEnabled = UserDefaults.standard.bool(forKey: "debug_console_enabled")
|
||||
isEnabled = UserDefaults.standard.bool(forKey: "debug_console_enabled")
|
||||
trackViewBodies = UserDefaults.standard.bool(forKey: "debug_track_view_bodies")
|
||||
if trackViewBodies { ViewBodyTracker.shared.start() }
|
||||
// Auto-insert app state markers
|
||||
NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification,
|
||||
object: nil, queue: .main) { [weak self] _ in self?.insertMarker("── Background ──") }
|
||||
|
|
@ -94,6 +110,58 @@ class DebugLogger: ObservableObject {
|
|||
func clear() { entries.removeAll() }
|
||||
}
|
||||
|
||||
// MARK: - View Body Tracker
|
||||
//
|
||||
// Counts how many times each key view's body runs per second and logs the
|
||||
// results to the "ViewBody" category in the debug console.
|
||||
// Enable via the ⚡ Bodies toggle in the console filter bar.
|
||||
//
|
||||
// Usage in any view body:
|
||||
// let _ = ViewBodyTracker.shared.record("MyView")
|
||||
//
|
||||
class ViewBodyTracker {
|
||||
static let shared = ViewBodyTracker()
|
||||
private var counts: [String: Int] = [:]
|
||||
private var timer: Timer?
|
||||
private let queue = DispatchQueue(label: "viewbodytracker", qos: .utility)
|
||||
|
||||
private init() {}
|
||||
|
||||
/// Call from within a view body to count that evaluation.
|
||||
/// Returns Void so it can be used with `let _ = ...` inside ViewBuilder.
|
||||
@discardableResult
|
||||
func record(_ name: String) -> 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 {
|
||||
|
|
|
|||
|
|
@ -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<String> = ["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()
|
||||
|
|
|
|||
189
iOS/Views/NowPlaying/NowPlayingSeekBar.swift
Normal file
189
iOS/Views/NowPlaying/NowPlayingSeekBar.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<WKRefreshBackgroundTask>) {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue