NavidromeApp/watchOS/Views/WatchVisualizerView.swift
Dallas Groot d8041c0019 NavidromePlayer: iOS + watchOS Navidrome/Subsonic music player
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
2026-03-28 20:49:47 +00:00

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