NavidromeApp/iOS/Views/NowPlaying/NowPlayingSeekBar.swift
2026-04-11 16:15:27 -07:00

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