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.
191 lines
7.4 KiB
Swift
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)
|
|
}
|
|
}
|