debugging high cpu usages

This commit is contained in:
Dallas Groot 2026-04-11 16:15:27 -07:00
parent 85c85c2090
commit 3b56626d6d
8 changed files with 403 additions and 56 deletions

View file

@ -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
}

View file

@ -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 {

View file

@ -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()

View 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)
}
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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