189 lines
7.6 KiB
Swift
189 lines
7.6 KiB
Swift
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)
|
|
}
|
|
}
|