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
126 lines
3.9 KiB
Swift
126 lines
3.9 KiB
Swift
import SwiftUI
|
|
|
|
/// Compact Siri-style wave visualizer for watchOS
|
|
struct WatchVisualizerView: View {
|
|
let levels: [Float]
|
|
let isPlaying: Bool
|
|
|
|
private let greenColor = Color(red: 0.3, green: 0.85, blue: 0.45)
|
|
private let blueColor = Color(red: 0.2, green: 0.55, blue: 1.0)
|
|
private let purpleColor = Color(red: 0.85, green: 0.25, blue: 0.85)
|
|
|
|
var body: some View {
|
|
TimelineView(.animation(minimumInterval: 1.0 / 60.0)) { timeline in
|
|
Canvas { context, size in
|
|
drawVisualizerContent(context: context, size: size, date: timeline.date)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Main draw (broken out for type-checker)
|
|
|
|
private func drawVisualizerContent(context: GraphicsContext, size: CGSize, date: Date) {
|
|
let w = size.width
|
|
let h = size.height
|
|
let count = levels.count
|
|
guard count >= 2 else { return }
|
|
|
|
let time = date.timeIntervalSinceReferenceDate
|
|
let spacing = w / CGFloat(count - 1)
|
|
|
|
let layerData: [(Color, Double)] = [
|
|
(purpleColor, 0.3),
|
|
(blueColor, 0.15),
|
|
(greenColor, 0.0),
|
|
]
|
|
|
|
for (color, offset) in layerData {
|
|
drawWaveLayer(
|
|
context: context,
|
|
w: w, h: h,
|
|
count: count,
|
|
spacing: spacing,
|
|
time: time,
|
|
offset: offset,
|
|
color: color
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Single wave layer
|
|
|
|
private func drawWaveLayer(
|
|
context: GraphicsContext,
|
|
w: CGFloat, h: CGFloat,
|
|
count: Int,
|
|
spacing: CGFloat,
|
|
time: TimeInterval,
|
|
offset: Double,
|
|
color: Color
|
|
) {
|
|
let points = buildPoints(
|
|
count: count, spacing: spacing,
|
|
h: h, time: time, offset: offset
|
|
)
|
|
|
|
let curvePath = buildCurve(points: points)
|
|
|
|
var fillPath = Path()
|
|
fillPath.move(to: CGPoint(x: 0, y: h))
|
|
fillPath.addLine(to: points[0])
|
|
fillPath.addPath(curvePath)
|
|
fillPath.addLine(to: CGPoint(x: w, y: h))
|
|
fillPath.closeSubpath()
|
|
|
|
context.fill(fillPath, with: .color(color.opacity(0.55)))
|
|
var strokePath = Path()
|
|
strokePath.move(to: points[0])
|
|
strokePath.addPath(curvePath)
|
|
context.stroke(strokePath, with: .color(color.opacity(0.6)), lineWidth: 1.2)
|
|
}
|
|
|
|
// MARK: - Build points array
|
|
|
|
private func buildPoints(
|
|
count: Int, spacing: CGFloat,
|
|
h: CGFloat, time: TimeInterval, offset: Double
|
|
) -> [CGPoint] {
|
|
var points: [CGPoint] = []
|
|
for i in 0..<count {
|
|
let x = CGFloat(i) * spacing
|
|
let baseAmp: CGFloat = isPlaying ? CGFloat(levels[i]) : 0.03
|
|
let sinArg = time * 1.8 + offset * 10 + Double(i) * 0.4
|
|
let breath = CGFloat(sin(sinArg)) * 0.05
|
|
let amp = max(0.02, baseAmp + breath)
|
|
let y = h - (h * amp * 0.85)
|
|
points.append(CGPoint(x: x, y: y))
|
|
}
|
|
return points
|
|
}
|
|
|
|
// MARK: - Catmull-Rom spline
|
|
|
|
private func buildCurve(points: [CGPoint]) -> Path {
|
|
var path = Path()
|
|
guard points.count >= 2 else { return path }
|
|
|
|
for i in 0..<(points.count - 1) {
|
|
let p0 = i > 0 ? points[i - 1] : points[i]
|
|
let p1 = points[i]
|
|
let p2 = points[i + 1]
|
|
let p3 = (i + 2 < points.count) ? points[i + 2] : p2
|
|
|
|
let cp1x = p1.x + (p2.x - p0.x) / 6.0
|
|
let cp1y = p1.y + (p2.y - p0.y) / 6.0
|
|
let cp2x = p2.x - (p3.x - p1.x) / 6.0
|
|
let cp2y = p2.y - (p3.y - p1.y) / 6.0
|
|
|
|
path.addCurve(
|
|
to: p2,
|
|
control1: CGPoint(x: cp1x, y: cp1y),
|
|
control2: CGPoint(x: cp2x, y: cp2y)
|
|
)
|
|
}
|
|
return path
|
|
}
|
|
}
|