NavidromeApp/iOS/Views/NowPlaying/NowPlayingSeekBar.swift
Dallas Groot fc69d8a3cf 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.
2026-04-11 16:44:56 -07:00

191 lines
7.4 KiB
Swift

import SwiftUI
// MARK: - NowPlayingSeekBar
//
// 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.
//
// 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 {
// 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 isLiveRadio: Bool {
radioBuffer != nil && AudioPlayer.shared.isRadioStream
}
var body: some View {
let _ = DebugLogger.shared.trackViewBodies ? ViewBodyTracker.shared.record("SeekBar") : false
// 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
}
}
}
}
// MARK: - Normal seek bar
private var normalBar: some View {
let player = AudioPlayer.shared
let time = player.currentTime
let duration = player.duration
return VStack(spacing: 6) {
SiriSeekBar(
value: Binding(
get: {
guard duration > 0 else { return 0 }
return time / duration
},
set: { _ in }
),
onSeek: { pct in
isDraggingSlider = false
AudioPlayer.shared.seekToPercent(pct)
},
onDragChanged: { pct in
isDraggingSlider = true
dragPosition = pct
}
)
.accessibilityLabel("Seek bar")
.accessibilityValue(formatTime(time) + " of " + formatTime(duration))
.accessibilityAdjustableAction { direction in
let step = duration * 0.05
switch direction {
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 ? duration * dragPosition : time))
.font(.system(size: 11)).foregroundColor(.gray)
Spacer()
Text("-" + formatTime(duration - (isDraggingSlider ? duration * dragPosition : time)))
.font(.system(size: 11)).foregroundColor(.gray)
}
}
}
// MARK: - Radio buffer bar
@ViewBuilder
private func radioBar(_ rb: RadioStreamBuffer) -> some View {
let player = AudioPlayer.shared
let time = player.currentTime
if rb.isHLSStream {
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 player.isPlayingFromBuffer {
return CGFloat(min(time / 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.shared.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 - time)
Text("-" + formatTime(behindLive))
.font(.system(size: 11, weight: .medium))
.foregroundColor(.gray)
}
Spacer()
Text(formatTime(rb.bufferedDuration))
.font(.system(size: 11))
.foregroundColor(.gray)
}
} else {
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)
}
}