Features: - Dual-AVPlayer Smart DJ crossfade with LUFS normalization - Mitsuha-style FFT visualizer (real-time + offline pre-computed) - Companion API integration (Smart DJ, tag editing, vis frames) - Offline-first SyncEngine with delta sync and album detail pre-caching - Audio pre-fetcher for gapless queue playback - Optimistic action queue (star/unstar with background retry) - ShazamKit recognition with MusicKit preview playback - Radio streaming with HLS/PLS/M3U support and buffer seek - Watch app with Crown Sequencer and Ultra speaker support - Batch metadata editing with album_artist fix for split albums - Cache-first UI pattern across all views - NWPathMonitor offline detection with reactive song greying
60 lines
2.4 KiB
Swift
60 lines
2.4 KiB
Swift
import SwiftUI
|
|
|
|
/// A custom seek bar with independent drag state to prevent timer/thumb fighting during scrubs.
|
|
struct SiriSeekBar: View {
|
|
@Binding var value: Double // 0.0 to 1.0, bound to playback progress
|
|
var onSeek: (Double) -> Void // Called when drag ends with final position
|
|
var onDragChanged: ((Double) -> Void)? = nil // Called continuously during drag
|
|
|
|
@State private var isDragging: Bool = false
|
|
@State private var dragValue: Double = 0.0
|
|
|
|
var body: some View {
|
|
GeometryReader { geo in
|
|
let width = geo.size.width
|
|
let currentVal = isDragging ? dragValue : value
|
|
let fillWidth = max(0, min(width, CGFloat(currentVal) * width))
|
|
|
|
ZStack(alignment: .leading) {
|
|
// 1. Background track
|
|
Capsule()
|
|
.fill(Color.white.opacity(0.2))
|
|
.frame(height: isDragging ? 8 : 4)
|
|
|
|
// 2. Fill track
|
|
Capsule()
|
|
.fill(Color.white)
|
|
.frame(width: fillWidth, height: isDragging ? 8 : 4)
|
|
|
|
// 3. Scrubber thumb (grows when dragging)
|
|
Circle()
|
|
.fill(Color.white)
|
|
.frame(width: isDragging ? 18 : 8, height: isDragging ? 18 : 8)
|
|
.offset(x: fillWidth - (isDragging ? 9 : 4))
|
|
.shadow(color: .black.opacity(0.4), radius: 3, x: 0, y: 2)
|
|
}
|
|
.frame(height: 30)
|
|
.contentShape(Rectangle())
|
|
.gesture(
|
|
DragGesture(minimumDistance: 0)
|
|
.onChanged { gesture in
|
|
let pct = max(0, min(1, Double(gesture.location.x / width)))
|
|
if !isDragging {
|
|
isDragging = true
|
|
dragValue = value
|
|
}
|
|
dragValue = pct
|
|
onDragChanged?(pct)
|
|
}
|
|
.onEnded { gesture in
|
|
let pct = max(0, min(1, Double(gesture.location.x / width)))
|
|
dragValue = pct
|
|
isDragging = false
|
|
onSeek(pct)
|
|
}
|
|
)
|
|
.animation(.easeInOut(duration: 0.15), value: isDragging)
|
|
}
|
|
.frame(height: 30)
|
|
}
|
|
}
|