NavidromeApp/iOS/Views/Visualizer/MitsuhaVisualizerView.swift
Dallas Groot 3385b88270 Audio Tap Infrastructure:
- AudioTapProcessor: shared MTAudioProcessingTap with lock-free PCM ring buffer
- Pre-allocated vDSP FFT (1024-sample, Hann window, log-frequency 30-band output)
- Zero per-frame heap allocation in FFT path
- Shared tap serves both FFT visualizer and Shazam simultaneously

Fixes (blockers for tap to work):
- radioGoLive/radioSeekBack now update self.playerItem (was orphaned)
- Tap reinstalled on every AVPlayerItem swap (seek, live, station change)
- Tap removed on background, reinstalled on foreground
- Tap removed on radio→music transition

Shazam rework:
- Uses shared AudioTapProcessor instead of creating its own tap
- Fixes tap conflict where Shazam overwrote FFT audioMix
- 500ms wait for tapPrepare callback (sourceFormat timing race)
- Fixed pre-existing bug: stopAll() audio session never restored after mic fallback

Debug capture:
- Capture Audio Tap button in Visualizer Settings
- Records 5s of raw tap PCM as playable WAV file
- Uses actual stream sample rate (not hardcoded 44100)
- Share sheet via Notification pattern (survives view dismiss)
- Spinner auto-resets on appear if capture interrupted by background

Also includes from main branch:
- Edit History UI, batch undo, companion API 7-bug fix
- Recently Played tab, Discover section, Play Queue sync, Share links"
2026-04-14 17:15:34 -07:00

1360 lines
65 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import SwiftUI
// MARK: - Per-View Independent Configuration
/// Holds the full visual configuration for one visualizer context (Now Playing or Mini Player).
/// Each style stores its own point count and sensitivity so switching styles
/// never bleeds parameters from a different style.
struct ViewVisualizerConfig: Codable {
// MARK: Style & Color
var style: VisualizerSettings.Style = .wave
var colorMode: VisualizerSettings.ColorMode = .dynamic
var alpha: Double = 0.6
// Custom color stored as components Color is not Codable
var customColorR: Double = 1.0
var customColorG: Double = 0.176
var customColorB: Double = 0.333
// MARK: Per-style point counts (independent switching styles uses its own last value)
var wavePoints: Int = 9
var barPoints: Int = 12
var linePoints: Int = 10
var siriPoints: Int = 16
// MARK: Per-style sensitivity
var waveSensitivity: Double = 0.9
var barSensitivity: Double = 1.0
var lineSensitivity: Double = 0.9
var siriSensitivity: Double = 1.0
// MARK: Computed from active style
var numberOfPoints: Int {
get {
switch style {
case .wave: return wavePoints
case .bar: return barPoints
case .line: return linePoints
case .siriWave: return siriPoints
}
}
set {
switch style {
case .wave: wavePoints = newValue
case .bar: barPoints = newValue
case .line: linePoints = newValue
case .siriWave: siriPoints = newValue
}
}
}
var sensitivity: Double {
get {
switch style {
case .wave: return waveSensitivity
case .bar: return barSensitivity
case .line: return lineSensitivity
case .siriWave: return siriSensitivity
}
}
set {
switch style {
case .wave: waveSensitivity = newValue
case .bar: barSensitivity = newValue
case .line: lineSensitivity = newValue
case .siriWave: siriSensitivity = newValue
}
}
}
var customColor: Color {
Color(red: customColorR, green: customColorG, blue: customColorB)
}
// MARK: Defaults
static var nowPlayingDefault: ViewVisualizerConfig {
var c = ViewVisualizerConfig()
c.wavePoints = 9; c.barPoints = 12; c.linePoints = 10; c.siriPoints = 16
return c
}
static var miniPlayerDefault: ViewVisualizerConfig {
var c = ViewVisualizerConfig()
c.style = .wave; c.alpha = 0.5
c.wavePoints = 8; c.barPoints = 10; c.linePoints = 8; c.siriPoints = 12
return c
}
}
// MARK: - Visualizer Settings (global + per-view configs)
class VisualizerSettings: ObservableObject {
static let shared = VisualizerSettings()
// MARK: Global toggles
@Published var enabled: Bool { didSet { save("vis_enabled", enabled) } }
@Published var nowPlayingEnabled: Bool { didSet { save("vis_nowplaying", nowPlayingEnabled) } }
@Published var miniPlayerEnabled: Bool { didSet { save("vis_miniplayer", miniPlayerEnabled) } }
// MARK: Per-view independent configs
@Published var nowPlaying: ViewVisualizerConfig { didSet { saveConfig("vis_np_config", nowPlaying) } }
@Published var miniPlayer: ViewVisualizerConfig { didSet { saveConfig("vis_mini_config", miniPlayer) } }
// MARK: Global physics (shared between views)
@Published var fps: Double { didSet { save("vis_fps", fps) } }
@Published var realAudioAnalysis:Bool { didSet { save("vis_real_fft", realAudioAnalysis) } }
@Published var dynamicGainEnabled:Bool { didSet { save("vis_dynamic_gain", dynamicGainEnabled) } }
@Published var viscosity: Double { didSet { save("vis_viscosity", viscosity) } }
@Published var frequencyCutoff: Int { didSet { save("vis_freq_cutoff", frequencyCutoff) } }
@Published var baseMultiplier: Double { didSet { save("vis_base_mult", baseMultiplier) } }
// MARK: Wave-specific global
@Published var waveStrokeThickness: Double { didSet { save("vis_wave_stroke", waveStrokeThickness) } }
// MARK: Bar-specific global
@Published var barSpacing: Double { didSet { save("vis_bar_spacing", barSpacing) } }
@Published var barCornerRadius: Double { didSet { save("vis_bar_radius", barCornerRadius) } }
// MARK: Line-specific global
@Published var lineThickness: Double { didSet { save("vis_line_thick", lineThickness) } }
// MARK: Now Playing layout / depth
@Published var nowPlayingHeightPct: Double { didSet { save("vis_np_height", nowPlayingHeightPct) } }
@Published var waveOffsetTop: Double { didSet { save("vis_wave_offset", waveOffsetTop) } }
@Published var npAmplitude: Double { didSet { save("vis_np_amplitude", npAmplitude) } }
@Published var npBaseLift: Double { didSet { save("vis_np_baselift", npBaseLift) } }
@Published var depthOffset: Double { didSet { save("vis_depth_offset", depthOffset) } }
@Published var depthOpacity: Double { didSet { save("vis_depth_opacity",depthOpacity) } }
@Published var idleAmplitude: Double { didSet { save("vis_idle_amp", idleAmplitude) } }
// MARK: Mini Player layout / depth
@Published var miniPlayerHeight: Double { didSet { save("vis_mini_height", miniPlayerHeight) } }
@Published var miniOpacity: Double { didSet { save("vis_mini_opacity", miniOpacity) } }
@Published var miniAmplitude: Double { didSet { save("vis_mini_amplitude", miniAmplitude) } }
@Published var miniIdleAmplitude: Double { didSet { save("vis_mini_idle", miniIdleAmplitude) } }
@Published var miniDepthOffset: Double { didSet { save("vis_mini_depth", miniDepthOffset) } }
@Published var miniDepthOpacity: Double { didSet { save("vis_mini_depth_opacity", miniDepthOpacity) } }
enum Style: String, CaseIterable, Codable {
case wave = "Wave"
case bar = "Bar"
case line = "Line"
case siriWave = "Siri"
}
enum ColorMode: String, CaseIterable, Codable {
case dynamic = "Dynamic"
case albumArt = "Album Art"
case vibrant = "Vibrant"
case custom = "Custom"
}
private init() {
let d = UserDefaults.standard
enabled = d.object(forKey: "vis_enabled") as? Bool ?? true
nowPlayingEnabled = d.object(forKey: "vis_nowplaying") as? Bool ?? true
miniPlayerEnabled = d.object(forKey: "vis_miniplayer") as? Bool ?? true
// Per-view configs load from JSON or use defaults
let dec = JSONDecoder()
if let data = d.data(forKey: "vis_np_config"),
let cfg = try? dec.decode(ViewVisualizerConfig.self, from: data) {
nowPlaying = cfg
} else {
nowPlaying = .nowPlayingDefault
}
if let data = d.data(forKey: "vis_mini_config"),
let cfg = try? dec.decode(ViewVisualizerConfig.self, from: data) {
miniPlayer = cfg
} else {
miniPlayer = .miniPlayerDefault
}
// Global physics
fps = { let v = d.double(forKey: "vis_fps"); return v > 0 ? v : 60.0 }()
realAudioAnalysis = d.object(forKey: "vis_real_fft") as? Bool ?? true
dynamicGainEnabled = d.object(forKey: "vis_dynamic_gain") as? Bool ?? true
viscosity = { let v = d.double(forKey: "vis_viscosity"); return v > 0 ? v : 0.25 }()
frequencyCutoff = { let v = d.integer(forKey: "vis_freq_cutoff"); return v > 0 ? v : 80 }()
baseMultiplier = { let v = d.double(forKey: "vis_base_mult"); return v > 0 ? v : 25.0 }()
// Style-specific globals
waveStrokeThickness = { let v = d.double(forKey: "vis_wave_stroke"); return v > 0 ? v : 1.5 }()
barSpacing = { let v = d.double(forKey: "vis_bar_spacing"); return v > 0 ? v : 5.0 }()
barCornerRadius = d.object(forKey: "vis_bar_radius") as? Double ?? 0.0
lineThickness = { let v = d.double(forKey: "vis_line_thick"); return v > 0 ? v : 5.0 }()
// Now Playing layout
nowPlayingHeightPct = { let v = d.double(forKey: "vis_np_height"); return v > 0 ? v : 0.50 }()
waveOffsetTop = d.object(forKey: "vis_wave_offset") as? Double ?? 0.0
npAmplitude = { let v = d.double(forKey: "vis_np_amplitude"); return v > 0 ? v : 0.45 }()
npBaseLift = d.object(forKey: "vis_np_baselift") as? Double ?? 130.0
// Fix: use object(forKey:) ?? default so setting value to 0 is persisted correctly
depthOffset = d.object(forKey: "vis_depth_offset") as? Double ?? 15.0
depthOpacity = d.object(forKey: "vis_depth_opacity") as? Double ?? 0.2
idleAmplitude = { let v = d.double(forKey: "vis_idle_amp"); return v > 0 ? v : 0.03 }()
// Mini Player layout
miniPlayerHeight = { let v = d.double(forKey: "vis_mini_height"); return v > 0 ? v : 48.0 }()
miniOpacity = d.object(forKey: "vis_mini_opacity") as? Double ?? 0.5
miniAmplitude = { let v = d.double(forKey: "vis_mini_amplitude"); return v > 0 ? v : 0.7 }()
miniIdleAmplitude = { let v = d.double(forKey: "vis_mini_idle"); return v > 0 ? v : 0.03 }()
miniDepthOffset = d.object(forKey: "vis_mini_depth") as? Double ?? 8.0
miniDepthOpacity = d.object(forKey: "vis_mini_depth_opacity") as? Double ?? 0.2
}
var effectiveFPS: Double {
ProcessInfo.processInfo.isLowPowerModeEnabled ? min(fps, 24) : fps
}
// MARK: - Persistence
private func save(_ key: String, _ value: Any) {
pendingSaves[key] = value
scheduleFlush()
}
private func saveConfig(_ key: String, _ config: ViewVisualizerConfig) {
if let data = try? JSONEncoder().encode(config) {
pendingSaves[key] = data
scheduleFlush()
}
}
// Debounce UserDefaults writes to 500ms so rapid slider drags don't
// flood the defaults system. The Task is explicitly @MainActor so
// pendingSaves is only ever read/written on the main thread no data race.
private func scheduleFlush() {
saveTask?.cancel()
saveTask = Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: 500_000_000)
guard !Task.isCancelled, let self else { return }
for (k, v) in self.pendingSaves {
UserDefaults.standard.set(v, forKey: k)
}
self.pendingSaves.removeAll()
}
}
private var pendingSaves: [String: Any] = [:]
private var saveTask: Task<Void, Never>?
}
// MARK: - Wave State Cache (shared between mini player and DI for morph continuity)
/// Single source of truth for compact visualizer levels.
/// Both MiniPlayerBar and DynamicIslandView seed from here on appear,
/// so the wave doesn't reset to idle when matchedGeometryEffect transitions between them.
final class WaveStateCache {
static let shared = WaveStateCache()
var compactLevels: [Float] = []
private init() {}
}
// MARK: - Level Box
/// Per-instance mutable render state. Zero @Published properties no observation warnings.
/// All working buffers pre-allocated here so the 60fps render loop does zero heap allocation.
fileprivate final class VisualizerLevelBox: ObservableObject {
var displayLevels: [Float] = []
var targetLevels: [Float] = [] // scratch buffer pre-allocated, mutated in-place each frame
var idleLevels: [Float] = [] // flat idle wave pre-allocated, updated only when count changes
var peakFollower: Float = 0.01
var levelHistoryBuf: [[Float]] = []
var historyWriteIdx: Int = 0
var wobblePhaseOffset: Double = 0
var lastTickTime: CFTimeInterval = 0
var resumeTickCount: Int = 0 // debug: counts first N ticks after resume
/// Resize all per-frame buffers when point count changes (rare settings slider).
func resizeIfNeeded(count: Int, idleAmplitude: Float) {
guard count > 0 else { return }
if targetLevels.count != count {
targetLevels = [Float](repeating: 0, count: count)
displayLevels = [Float](repeating: idleAmplitude, count: count)
idleLevels = [Float](repeating: idleAmplitude, count: count)
// Pre-fill history ring with idle so depth ghost doesn't start blank
levelHistoryBuf = [[Float]](
repeating: [Float](repeating: idleAmplitude, count: count),
count: MitsuhaVisualizerView.historySize
)
historyWriteIdx = 0
} else if idleLevels.first != idleAmplitude {
for i in 0..<count { idleLevels[i] = idleAmplitude }
}
}
}
// MARK: - Main Visualizer View
struct MitsuhaVisualizerView: View {
var previewLevels: [Float]? = nil
let isPlaying: Bool
var isSongLoaded: Bool = true
var songId: String? = nil // used to reset peakFollower on song change
let accentColor: Color
var compact: Bool = false
var isVisible: Bool = true
// Observe settings only for the enabled gate and config values.
// The Canvas itself does NOT observe settings we pass the values it
// needs as local constants captured at body evaluation time. This means
// a slider drag in VisualizerSettingsView does NOT invalidate the Canvas
// on every change only the outer view re-evaluates, and the Canvas
// only re-evaluates when isPlaying / isVisible / isAppActive changes.
@ObservedObject var settings = VisualizerSettings.shared
@ObservedObject var albumColors = AlbumColorExtractor.shared
@StateObject private var box = VisualizerLevelBox()
@State private var isAppActive = true
static let historySize = 16
/// Active view config mini player uses its own independent config
private var config: ViewVisualizerConfig {
compact ? settings.miniPlayer : settings.nowPlaying
}
private var isRenderingActive: Bool {
settings.enabled && isPlaying && isAppActive && isVisible
}
var body: some View {
Group {
if settings.enabled {
// Adaptive FPS: full rate when rendering, 2fps when idle/paused.
// This prevents the 60fps GPU wakeup that drains battery during pause
// and stops the render loop from fighting Liquid Glass gesture tracking.
let targetFPS = isRenderingActive ? settings.effectiveFPS : 2.0
let tickInterval = 1.0 / max(targetFPS, 1.0)
// Use a stable start date so the periodic schedule origin never
// resets. Previously, .now was passed every time targetFPS changed
// (e.g. on a radio buffer hiccup), causing the next tick to be
// deferred by a full interval the visible "stall" in the wave.
TimelineView(.periodic(from: .distantPast, by: tickInterval)) { timeline in
Canvas { context, size in
// Read timeline.date INSIDE Canvas this is the critical dependency
// that tells SwiftUI to re-evaluate Canvas on every timeline tick.
// Reading it outside the Canvas closure (as a let) doesn't establish
// the Canvas redraw dependency, causing the occasional stall.
let _ = timeline.date
box.resizeIfNeeded(count: config.numberOfPoints,
idleAmplitude: Float(settings.idleAmplitude))
guard box.displayLevels.count >= 2 else { return }
if isRenderingActive {
let t = CACurrentMediaTime()
let rawLevels = previewLevels ?? AudioPlayer.shared.currentLevels()
if box.resumeTickCount < 3 {
box.resumeTickCount += 1
DebugLogger.shared.log(
"TL tick #\(box.resumeTickCount): " +
"rawCount=\(rawLevels.count) " +
"rawMax=\(String(format: "%.3f", rawLevels.max() ?? 0)) " +
"lastTick=\(String(format: "%.1f", box.lastTickTime))",
category: "VisDebug", level: .debug)
}
updateDisplayLevels(newRawLevels: rawLevels, t: t)
updateWobblePhase(t: t)
switch config.style {
case .wave: drawWave(ctx: context, size: size, levels: box.displayLevels, continuousTime: box.wobblePhaseOffset)
case .bar: drawBars(ctx: context, size: size, levels: box.displayLevels)
case .line: drawLine(ctx: context, size: size, levels: box.displayLevels)
case .siriWave: drawSiriWave(ctx: context, size: size, levels: box.displayLevels, continuousTime: box.wobblePhaseOffset)
}
} else {
// Idle/paused: draw last known state without updating levels
drawIdleState(ctx: context, size: size)
}
}
}
}
}
.opacity(isPlaying ? 1.0 : (isSongLoaded ? 0.35 : 1.0))
.animation(isSongLoaded ? .easeInOut(duration: 0.6) : nil, value: isPlaying)
.onAppear {
box.resizeIfNeeded(count: config.numberOfPoints,
idleAmplitude: Float(settings.idleAmplitude))
if compact, !WaveStateCache.shared.compactLevels.isEmpty {
let cached = WaveStateCache.shared.compactLevels
let count = box.displayLevels.count
for i in 0..<min(cached.count, count) {
box.displayLevels[i] = cached[i]
}
}
}
.onChange(of: isPlaying) { _, playing in
if !playing {
box.levelHistoryBuf.removeAll(keepingCapacity: true)
box.historyWriteIdx = 0
// Do NOT reset peakFollower here. Resetting to 0.01 means the
// first resume frame gets normFactor=100x (1/0.01), spiking
// normal levels (~0.5) to 1.0 the "raise up" on resume.
box.lastTickTime = 0
DebugLogger.shared.log(
"PAUSE → levelHistoryBuf cleared, lastTickTime reset " +
"[targets:\(box.targetLevels.count) display:\(box.displayLevels.count)]",
category: "VisDebug", level: .info)
} else {
box.resumeTickCount = 0
// Warm-start peakFollower so normFactor begins around 3x not 100x
box.peakFollower = 0.3
box.lastTickTime = 0
DebugLogger.shared.log(
"RESUME → isRenderingActive=\(isRenderingActive) " +
"isAppActive=\(isAppActive) isVisible=\(isVisible) " +
"[targets:\(box.targetLevels.count) display:\(box.displayLevels.count) " +
"history:\(box.levelHistoryBuf.count)]",
category: "VisDebug", level: .info)
}
}
// Reset peakFollower on song change so a spike from the outgoing
// simulation doesn't "raise" the wave at the start of the next song.
.onChange(of: songId) { _, _ in
box.peakFollower = 0.01
box.levelHistoryBuf.removeAll(keepingCapacity: true)
box.historyWriteIdx = 0
box.lastTickTime = 0
}
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
isAppActive = true
box.lastTickTime = 0
}
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification)) { _ in
isAppActive = false
box.levelHistoryBuf.removeAll(keepingCapacity: true)
box.historyWriteIdx = 0
}
}
// MARK: - Idle State
private func drawIdleState(ctx: GraphicsContext, size: CGSize) {
guard box.idleLevels.count >= 2 else { return }
switch config.style {
case .wave: drawWave(ctx: ctx, size: size, levels: box.idleLevels, continuousTime: 0)
case .bar: drawBars(ctx: ctx, size: size, levels: box.idleLevels)
case .line: drawLine(ctx: ctx, size: size, levels: box.idleLevels)
case .siriWave: drawSiriWave(ctx: ctx, size: size, levels: box.idleLevels, continuousTime: 0)
}
}
// MARK: - Wobble Phase
private func updateWobblePhase(t: Double) {
if box.lastTickTime > 0 {
let delta = t - box.lastTickTime
box.wobblePhaseOffset += min(delta, 0.1)
}
box.lastTickTime = t
}
// MARK: - Temporal Smoothing & Log Binning
@discardableResult
private func updateDisplayLevels(newRawLevels: [Float], t: Double) -> Bool {
let count = config.numberOfPoints
guard count > 0, !newRawLevels.isEmpty,
box.targetLevels.count == count else {
DebugLogger.shared.log(
"updateDisplayLevels GUARD FAILED: " +
"count=\(count) rawEmpty=\(newRawLevels.isEmpty) " +
"targetCount=\(box.targetLevels.count)",
category: "VisDebug", level: .warning)
return false
}
let dt = Float(box.lastTickTime > 0 ? min(t - box.lastTickTime, 0.1) : 1.0/60.0)
let sens = Float(config.sensitivity)
let isPreProcessed = AudioPlayer.shared.isUsingOfflineVis
// Write directly into pre-allocated box.targetLevels
if isPreProcessed {
if newRawLevels.count == count {
// Hot path: identical size single in-place pass, zero allocation
for i in 0..<count {
box.targetLevels[i] = min(1.0, newRawLevels[i] * sens)
}
} else {
// Resample via linear interpolation directly into box.targetLevels
let srcCount = Float(newRawLevels.count - 1)
let dstCount = Float(max(count - 1, 1))
for i in 0..<count {
let srcPos = Float(i) / dstCount * srcCount
let lo = Int(srcPos)
let hi = min(lo + 1, newRawLevels.count - 1)
let frac = srcPos - Float(lo)
box.targetLevels[i] = min(1.0,
(newRawLevels[lo] * (1.0 - frac) + newRawLevels[hi] * frac) * sens)
}
}
} else {
// Raw FFT log binning directly into box.targetLevels
let maxUsefulBin = min(newRawLevels.count - 1, settings.frequencyCutoff)
let mult = Float(settings.baseMultiplier)
for i in 0..<count {
let ni = Float(i + 1) / Float(count)
let centerBin = log10(ni * 9.0 + 1.0) * Float(maxUsefulBin)
let binWidth = max(1.0, Float(maxUsefulBin) / Float(count))
let startBin = max(1, Int(centerBin - binWidth * 0.5))
let endBin = min(maxUsefulBin, Int(centerBin + binWidth * 0.5))
var sum: Float = 0
var n = 0
for j in startBin...endBin where j < newRawLevels.count {
sum += newRawLevels[j]; n += 1
}
box.targetLevels[i] = min(1.0, (n > 0 ? sum / Float(n) : 0) * mult * sens)
}
}
// Dynamic Gain / Peak Follower (in-place on box.targetLevels)
let smoothFactor = min(Float(settings.viscosity) * 60.0 * dt, 1.0)
let frameMax = box.targetLevels.max() ?? 0.0
if settings.dynamicGainEnabled {
if frameMax > box.peakFollower {
box.peakFollower += (frameMax - box.peakFollower) * 0.3
} else {
box.peakFollower = max(box.peakFollower * pow(0.5, dt), 0.01)
}
let normFactor = 1.0 / max(box.peakFollower, 0.01)
for i in 0..<count {
box.targetLevels[i] = min(box.targetLevels[i] * normFactor, 1.0)
}
} else {
box.peakFollower = max(box.peakFollower, frameMax)
}
// Exponential smooth into displayLevels (in-place)
for i in 0..<count {
box.displayLevels[i] += (box.targetLevels[i] - box.displayLevels[i]) * smoothFactor
}
// Ring buffer: copy in-place into pre-allocated history slot
// Pre-allocated slots mean no COW trigger on assignment.
if box.levelHistoryBuf.count < MitsuhaVisualizerView.historySize {
box.levelHistoryBuf.append(box.displayLevels)
} else {
let idx = box.historyWriteIdx
if box.levelHistoryBuf[idx].count == count {
for i in 0..<count {
box.levelHistoryBuf[idx][i] = box.displayLevels[i]
}
} else {
box.levelHistoryBuf[idx] = box.displayLevels
}
box.historyWriteIdx = (box.historyWriteIdx + 1) % MitsuhaVisualizerView.historySize
}
if compact { WaveStateCache.shared.compactLevels = box.displayLevels }
return true
}
// MARK: - Colors
private var fillColors: [Color] {
let a = config.alpha
switch config.colorMode {
case .dynamic: return [accentColor.opacity(a), accentColor.opacity(a * 0.6)]
case .albumArt: return [albumColors.primaryColor.opacity(a), albumColors.secondaryColor.opacity(a * 0.7)]
case .vibrant: return [.pink.opacity(a), .green.opacity(a), .cyan.opacity(a), .purple.opacity(a), .white.opacity(a * 0.5), .pink.opacity(a)]
case .custom: return [config.customColor.opacity(a), config.customColor.opacity(a * 0.6)]
}
}
private var strokeColor: Color {
let a = min(1, config.alpha + 0.3)
switch config.colorMode {
case .dynamic: return accentColor.opacity(a)
case .albumArt: return albumColors.primaryColor.opacity(a)
case .vibrant: return Color.cyan.opacity(a)
case .custom: return config.customColor.opacity(a)
}
}
// MARK: - Catmull-Rom Spline (tension 0.3 = heavy liquid surface tension)
/// Tension: 0.3 gives rounded, rolling peaks. Lower = tighter. Higher = bouncier.
private let curveTension: CGFloat = 0.3
/// Curve path without initial moveTo used for fill shapes
private func smoothCurve(_ points: [CGPoint]) -> Path {
var p = Path()
guard points.count >= 2 else { return p }
for i in 0..<(points.count - 1) {
let p0 = i > 0 ? points[i - 1] : points[0]
let p1 = points[i]
let p2 = points[i + 1]
let p3 = i < points.count - 2 ? points[i + 2] : p2
let cp1 = CGPoint(
x: p1.x + (p2.x - p0.x) * curveTension,
y: p1.y + (p2.y - p0.y) * curveTension
)
let cp2 = CGPoint(
x: p2.x - (p3.x - p1.x) * curveTension,
y: p2.y - (p3.y - p1.y) * curveTension
)
p.addCurve(to: p2, control1: cp1, control2: cp2)
}
return p
}
/// Full curve path with moveTo used for strokes
private func strokeableCurve(_ points: [CGPoint]) -> Path {
var p = Path()
guard points.count >= 2 else { return p }
p.move(to: points[0])
for i in 0..<(points.count - 1) {
let p0 = i > 0 ? points[i - 1] : points[0]
let p1 = points[i]
let p2 = points[i + 1]
let p3 = i < points.count - 2 ? points[i + 2] : p2
let cp1 = CGPoint(
x: p1.x + (p2.x - p0.x) * curveTension,
y: p1.y + (p2.y - p0.y) * curveTension
)
let cp2 = CGPoint(
x: p2.x - (p3.x - p1.x) * curveTension,
y: p2.y - (p3.y - p1.y) * curveTension
)
p.addCurve(to: p2, control1: cp1, control2: cp2)
}
return p
}
// MARK: - Wave
private func drawWave(ctx: GraphicsContext, size: CGSize, levels: [Float], continuousTime: Double) {
let w = size.width
let h = size.height
let idleVal = CGFloat(compact ? settings.miniIdleAmplitude : settings.idleAmplitude)
guard levels.count >= 2 else { return }
let count = levels.count
let spacing = w / CGFloat(count - 1)
let now = continuousTime
let baseLift: CGFloat = compact ? 0 : CGFloat(settings.npBaseLift)
let offset = compact ? 0 : CGFloat(settings.waveOffsetTop)
let baseline = h - baseLift - offset
let ampScale: CGFloat = compact ? CGFloat(settings.miniAmplitude) : CGFloat(settings.npAmplitude)
var points = levels.enumerated().map { i, lev -> CGPoint in
let x = CGFloat(i) * spacing
let baseAmp = CGFloat(lev)
let organicWobble = CGFloat(sin(now * 3.0 + (Double(i) * 0.8)) * 0.03)
let totalAmp = max(idleVal, baseAmp + organicWobble)
return CGPoint(x: x, y: baseline - (totalAmp * h * ampScale))
}
// Endpoint Anchoring (improvement #13)
// Pin first and last points exactly to baseline so the wave cleanly
// rises from and returns to the horizon matching the original tweak.
points[0] = CGPoint(x: 0, y: baseline)
points[count - 1] = CGPoint(x: w, y: baseline)
// Layer 1: Temporal depth ghost (improvement #11)
// Use the oldest ring-buffer frame (~250ms ago) for the shadow wave,
// matching the original's 0.25s dispatch_after delay exactly.
let ctxDepthOffset = compact ? CGFloat(settings.miniDepthOffset) : CGFloat(settings.depthOffset)
let ctxDepthOpacity = compact ? settings.miniDepthOpacity : settings.depthOpacity
let ghostLevels: [Float]
let histFull = box.levelHistoryBuf.count >= MitsuhaVisualizerView.historySize
if histFull {
ghostLevels = box.levelHistoryBuf[box.historyWriteIdx]
} else {
ghostLevels = box.levelHistoryBuf.first ?? Array(repeating: 0, count: count)
}
let ghostCount = max(ghostLevels.count, 2)
var depthPts = (0..<count).map { i -> CGPoint in
// Sample the ghost frame at the same normalised position
let srcF = Float(i) / Float(count - 1) * Float(ghostCount - 1)
let lo = min(Int(srcF), ghostCount - 1)
let hi = min(lo + 1, ghostCount - 1)
let frac = CGFloat(srcF - Float(lo))
let ghostLev = CGFloat(ghostLevels[lo]) * (1 - frac) + CGFloat(ghostLevels[hi]) * frac
let ghostAmp = max(idleVal, ghostLev)
let x = CGFloat(i) * spacing
return CGPoint(x: x, y: baseline - (ghostAmp * h * ampScale) + ctxDepthOffset + 5)
}
// Anchor ghost endpoints too
depthPts[0] = CGPoint(x: 0, y: baseline + ctxDepthOffset)
depthPts[count - 1] = CGPoint(x: w, y: baseline + ctxDepthOffset)
let depthCurve = smoothCurve(depthPts)
var depthFill = Path()
depthFill.move(to: CGPoint(x: 0, y: h))
depthFill.addLine(to: depthPts[0])
depthFill.addPath(depthCurve)
depthFill.addLine(to: CGPoint(x: w, y: h))
depthFill.closeSubpath()
ctx.fill(depthFill, with: .color(strokeColor.opacity(ctxDepthOpacity)))
// Layer 2: Main fill gradient from wave crest to true bottom
let curve = smoothCurve(points)
var fill = Path()
fill.move(to: CGPoint(x: 0, y: h))
fill.addLine(to: points[0])
fill.addPath(curve)
fill.addLine(to: CGPoint(x: w, y: h))
fill.closeSubpath()
let isSiri = config.colorMode == .vibrant
if isSiri {
ctx.fill(fill, with: .linearGradient(
Gradient(colors: fillColors),
startPoint: CGPoint(x: 0, y: 0),
endPoint: CGPoint(x: w, y: 0)
))
} else {
ctx.fill(fill, with: .linearGradient(
Gradient(colors: fillColors),
startPoint: CGPoint(x: w / 2, y: baseline - h * 0.3),
endPoint: CGPoint(x: w / 2, y: h)
))
}
// Layer 3: Stroke line
ctx.stroke(strokeableCurve(points), with: .color(strokeColor), lineWidth: CGFloat(settings.waveStrokeThickness))
}
// MARK: - Bars
private func drawBars(ctx: GraphicsContext, size: CGSize, levels: [Float]) {
let w = size.width
let h = size.height
let count = levels.count
guard count > 0 else { return }
let baseLift: CGFloat = compact ? 0 : CGFloat(settings.npBaseLift)
let offset = compact ? 0 : CGFloat(settings.waveOffsetTop)
let baseline = h - baseLift - offset
let ampScale: CGFloat = compact ? CGFloat(settings.miniAmplitude) : CGFloat(settings.npAmplitude)
let gap = CGFloat(settings.barSpacing)
let cr = CGFloat(settings.barCornerRadius)
let totalGapSpace = gap * CGFloat(count - 1)
let barW = max(1, (w - totalGapSpace) / CGFloat(count))
let isSiri = config.colorMode == .vibrant
// Hoist fillColors outside loop was allocating [Color] count times per frame
let colors = fillColors
for i in 0..<count {
let amp = CGFloat(levels[i])
let barH = max(compact ? 2 : 4, amp * h * ampScale)
let x = CGFloat(i) * (barW + gap)
let barTop = baseline - barH
let rect = CGRect(x: x, y: barTop, width: barW, height: h - barTop)
let path = Path(roundedRect: rect, cornerRadius: cr)
let startPoint = isSiri ? CGPoint(x: 0, y: 0) : CGPoint(x: x, y: barTop)
let endPoint = isSiri ? CGPoint(x: w, y: 0) : CGPoint(x: x, y: h)
ctx.fill(path, with: .linearGradient(
Gradient(colors: colors),
startPoint: startPoint,
endPoint: endPoint
))
}
}
// MARK: - Line
private func drawLine(ctx: GraphicsContext, size: CGSize, levels: [Float]) {
let w = size.width
let h = size.height
guard levels.count >= 2 else { return }
let spacing = w / CGFloat(levels.count - 1)
let thick = CGFloat(settings.lineThickness)
let isSiri = config.colorMode == .vibrant
let baseLift: CGFloat = compact ? 0 : CGFloat(settings.npBaseLift)
let offset = compact ? 0 : CGFloat(settings.waveOffsetTop)
let centerY = compact ? h * 0.5 : h - baseLift - offset
let ampMult: CGFloat = compact ? CGFloat(settings.miniAmplitude) * 0.5 : CGFloat(settings.npAmplitude) * 0.55
let points = levels.enumerated().map { i, lev -> CGPoint in
let dir: CGFloat = i % 2 == 0 ? -1 : 1
let amp = CGFloat(lev) * (h * ampMult)
return CGPoint(x: CGFloat(i) * spacing, y: centerY + (dir * amp))
}
let curve = strokeableCurve(points)
let strokeStyle = StrokeStyle(lineWidth: thick, lineCap: .round, lineJoin: .round)
// Glow
ctx.stroke(curve, with: .color(strokeColor.opacity(0.3)), style: StrokeStyle(lineWidth: thick + 6, lineCap: .round, lineJoin: .round))
// Core line
if isSiri {
ctx.stroke(curve, with: .linearGradient(
Gradient(colors: fillColors),
startPoint: CGPoint(x: 0, y: centerY),
endPoint: CGPoint(x: w, y: centerY)
), style: strokeStyle)
} else {
ctx.stroke(curve, with: .color(strokeColor), style: strokeStyle)
}
}
// MARK: - Siri Wave
/// Authentic multi-layer Siri waveform.
///
/// Five stroked paths are drawn over the same level data. Each layer has:
/// - A distinct phase offset so the peaks weave past each other organically.
/// - A distinct amplitude multiplier (some negative = inverted) creating the
/// crossing / figure-eight structure visible in the original Mitsuha Infinity.
/// - A sine-window (bell-curve) attenuation across the width: the wave is forced
/// to zero at both edges and reaches full amplitude only in the centre.
/// Mathematically: attenuation(x) = sin(π * x/w) which is 0 at x=0, peaks at 1
/// at x=w/2, and returns to 0 at x=w.
/// - `plusLighter` blend mode so where two bright lines overlap they add their
/// luminance, creating glowing intersection nodes like the original.
private func drawSiriWave(ctx: GraphicsContext, size: CGSize, levels: [Float], continuousTime: Double) {
let w = size.width
let h = size.height
guard levels.count >= 2 else { return }
let baseLift: CGFloat = compact ? 0 : CGFloat(settings.npBaseLift)
let waveOffset = compact ? 0 : CGFloat(settings.waveOffsetTop)
let centerY = compact ? h * 0.5 : h - baseLift - waveOffset
let baseAmp: CGFloat = compact
? CGFloat(settings.miniAmplitude) * h * 0.4
: CGFloat(settings.npAmplitude) * h * 0.38
// Five layers: (phase offset in radians, amplitude multiplier, opacity, stroke width)
let layers: [(phase: Double, ampMult: CGFloat, opacity: Double, width: CGFloat)] = [
(phase: 0.0, ampMult: 1.00, opacity: 0.90, width: 2.5), // core
(phase: .pi * 0.35, ampMult: 0.60, opacity: 0.65, width: 1.8), // support A
(phase: .pi * 0.70, ampMult: 0.30, opacity: 0.50, width: 1.4), // support B
(phase: .pi * 1.10, ampMult: -0.40, opacity: 0.45, width: 1.2), // inverted A
(phase: .pi * 1.55, ampMult: -0.20, opacity: 0.30, width: 1.0), // inverted B
]
let count = levels.count
let spacing = w / CGFloat(count - 1)
// Build siri colour gradient once
let siriColors: [Color] = [.pink, .purple, .cyan, .green, .pink]
let useVibrant = config.colorMode == .vibrant
let a = config.alpha
for layer in layers {
var points: [CGPoint] = []
points.reserveCapacity(count)
for i in 0..<count {
let x = CGFloat(i) * spacing
let nx = x / w // normalised 01
// Sine-window attenuation zero at edges, 1 at centre
let attenuation = CGFloat(sin(.pi * nx))
// Sample level at this x with per-layer phase offset applied as a
// time warp: the effective sample position shifts along the level array
// by (phase / 2π) * count, wrapped with linear interpolation.
let shift = layer.phase / (2 * .pi) * Double(count - 1)
let rawPos = Double(i) + shift
let lo = Int(rawPos) % count
let hi = (lo + 1) % count
let frac = CGFloat(rawPos - floor(rawPos))
let sampledLevel = CGFloat(levels[lo]) * (1 - frac) + CGFloat(levels[hi]) * frac
// Organic wobble using continuous time + per-layer phase
let wobble = CGFloat(sin(continuousTime * 3.0 + Double(i) * 0.8 + layer.phase) * 0.03)
let totalAmp = (sampledLevel + wobble) * layer.ampMult * attenuation * baseAmp
points.append(CGPoint(x: x, y: centerY - totalAmp))
}
let curve = strokeableCurve(points)
let style = StrokeStyle(lineWidth: layer.width, lineCap: .round, lineJoin: .round)
// Draw with plusLighter blend mode for additive glow at intersections
ctx.drawLayer { layerCtx in
layerCtx.blendMode = .plusLighter
if useVibrant {
layerCtx.stroke(curve, with: .linearGradient(
Gradient(colors: siriColors.map { $0.opacity(layer.opacity * a) }),
startPoint: .zero,
endPoint: CGPoint(x: w, y: 0)
), style: style)
} else {
let c: Color
switch config.colorMode {
case .albumArt: c = AlbumColorExtractor.shared.primaryColor
case .custom: c = config.customColor
default: c = accentColor
}
layerCtx.stroke(curve, with: .color(c.opacity(layer.opacity * a)), style: style)
}
}
}
}
}
// MARK: - Compact Visualizer (for mini player)
struct CompactVisualizerView: View {
let isPlaying: Bool
var isSongLoaded: Bool = true
var songId: String? = nil
let accentColor: Color
let height: CGFloat
var body: some View {
MitsuhaVisualizerView(
isPlaying: isPlaying,
isSongLoaded: isSongLoaded,
songId: songId,
accentColor: accentColor,
compact: true
)
.frame(height: height)
}
}
// MARK: - Visualizer Settings View
struct VisualizerSettingsView: View {
@ObservedObject var settings = VisualizerSettings.shared
@Environment(\.dismiss) private var dismiss
@State private var isCapturingTap = false
@State private var showShareSheet = false
@State private var capturedFileURL: URL?
private let pink = Color(red: 1.0, green: 0.176, blue: 0.333)
var body: some View {
NavigationStack {
Form {
// Master toggles
Section {
Toggle("Enabled", isOn: $settings.enabled).tint(pink)
Toggle("Now Playing Screen", isOn: $settings.nowPlayingEnabled).tint(pink).disabled(!settings.enabled)
Toggle("Mini Player", isOn: $settings.miniPlayerEnabled).tint(pink).disabled(!settings.enabled)
} header: { Text("VISUALIZER") } footer: {
Text("Master toggle disables all visualizers. Individual toggles control each location.")
}
// Per-view independent configs
Section {
NavigationLink {
ViewConfigSettingsView(
title: "Now Playing",
config: $settings.nowPlaying,
amplitude: $settings.npAmplitude,
baseLift: $settings.npBaseLift,
waveOffset: $settings.waveOffsetTop,
depthOffset: $settings.depthOffset,
depthOpacity: $settings.depthOpacity,
idleAmplitude: $settings.idleAmplitude,
heightPct: $settings.nowPlayingHeightPct,
isCompact: false
)
} label: {
HStack {
Image(systemName: "play.rectangle.fill").foregroundColor(pink).frame(width: 28)
Text("Now Playing")
Spacer()
Text(settings.nowPlaying.style.rawValue)
.font(.caption).foregroundColor(.gray)
}
}
NavigationLink {
ViewConfigSettingsView(
title: "Mini Player",
config: $settings.miniPlayer,
amplitude: $settings.miniAmplitude,
baseLift: .constant(0),
waveOffset: .constant(0),
depthOffset: $settings.miniDepthOffset,
depthOpacity: $settings.miniDepthOpacity,
idleAmplitude: $settings.miniIdleAmplitude,
heightPct: .constant(0),
isCompact: true
)
} label: {
HStack {
Image(systemName: "rectangle.bottomhalf.filled").foregroundColor(pink).frame(width: 28)
Text("Mini Player")
Spacer()
Text(settings.miniPlayer.style.rawValue)
.font(.caption).foregroundColor(.gray)
}
}
} header: { Text("PER-VIEW SETTINGS") } footer: {
Text("Each view has its own style, color, point count, sensitivity, amplitude, and depth. Changes in one view never affect the other.")
}
// Style-specific global params
Section {
sliderRowDouble("Wave stroke", value: $settings.waveStrokeThickness, range: 0.5...5.0, step: 0.5, format: "%.1f")
sliderRowDouble("Bar spacing", value: $settings.barSpacing, range: 0...20, step: 1, format: "%.0f")
sliderRowDouble("Bar radius", value: $settings.barCornerRadius, range: 0...15, step: 1, format: "%.0f")
sliderRowDouble("Line thickness",value: $settings.lineThickness, range: 1...12, step: 0.5, format: "%.1f")
} header: { Text("STYLE GLOBALS") } footer: {
Text("These shape parameters apply globally to their respective styles across both views.")
}
// Shared physics
Section {
sliderRowDouble("Viscosity", value: $settings.viscosity, range: 0.05...1.0, step: 0.01, format: "%.2f")
sliderRow( "Frequency cutoff",value: $settings.frequencyCutoff, range: 40...150, step: 5)
sliderRowDouble("Base multiplier", value: $settings.baseMultiplier, range: 5.0...50.0, step: 1.0, format: "%.0f")
sliderRowDouble("FPS", value: $settings.fps, range: 15...60, step: 1, format: "%.0f")
Toggle("Real Audio Analysis", isOn: $settings.realAudioAnalysis).tint(pink)
Toggle("Dynamic Gain", isOn: $settings.dynamicGainEnabled).tint(pink)
} header: { Text("SHARED PHYSICS") } footer: {
Text("Viscosity: 0.100.20 = heavy ocean swell. 0.4+ = snappy EQ. Base Multiplier: 1525 for most music. Dynamic Gain normalises amplitude across loud and quiet tracks.")
}
// Presets
Section {
Button(action: applyDeepOcean) {
HStack {
Image(systemName: "water.waves").foregroundColor(.cyan)
VStack(alignment: .leading) {
Text("Deep Ocean Swell").foregroundColor(.white)
Text("Slow, massive rolling waves").font(.caption2).foregroundColor(.gray)
}
}
}
Button(action: applyReactiveEQ) {
HStack {
Image(systemName: "waveform").foregroundColor(.green)
VStack(alignment: .leading) {
Text("Reactive EQ").foregroundColor(.white)
Text("Detailed, fast response").font(.caption2).foregroundColor(.gray)
}
}
}
Button(action: applySubtleAmbient) {
HStack {
Image(systemName: "moonphase.waning.crescent").foregroundColor(.purple)
VStack(alignment: .leading) {
Text("Subtle Ambient").foregroundColor(.white)
Text("Gentle, transparent shimmer").font(.caption2).foregroundColor(.gray)
}
}
}
Button(action: applyHeavyMercury) {
HStack {
Image(systemName: "drop.fill").foregroundColor(.gray)
VStack(alignment: .leading) {
Text("Heavy Liquid Mercury").foregroundColor(.white)
Text("Dense, weighty metallic flow").font(.caption2).foregroundColor(.gray)
}
}
}
} header: { Text("PRESETS") } footer: {
Text("Presets apply to the Now Playing view. Fine-tune in Per-View Settings afterwards.")
}
Section { Button("Reset to Defaults", role: .destructive) { resetDefaults() } }
// Debug: Audio Tap Capture
Section {
if isCapturingTap {
HStack {
ProgressView().tint(pink)
Text("Capturing 5s of audio tap…")
.font(.system(size: 13))
.foregroundColor(.gray)
.padding(.leading, 8)
}
} else {
Button(action: startTapCapture) {
HStack {
Image(systemName: "waveform.badge.mic")
.foregroundColor(.orange)
VStack(alignment: .leading) {
Text("Capture Audio Tap").foregroundColor(.white)
Text("Records 5s of raw tap output as WAV")
.font(.caption2).foregroundColor(.gray)
}
}
}
.disabled(!AudioPlayer.shared.isRadioStream)
}
if let fmt = AudioTapProcessor.shared.sourceFormat {
HStack {
Text("Tap format")
.foregroundColor(.gray)
Spacer()
Text("\(Int(fmt.sampleRate))Hz \(fmt.channelCount)ch")
.font(.system(size: 12))
.foregroundColor(.white)
}
.font(.system(size: 13))
}
} header: { Text("DEBUG: AUDIO TAP") } footer: {
Text("Captures raw PCM from MTAudioProcessingTap as a playable WAV file. Only active during radio playback. Use to verify the tap is receiving real audio data.")
}
}
.navigationTitle("Visualizer")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") { dismiss() }.foregroundColor(pink)
}
}
.sheet(isPresented: $showShareSheet) {
if let url = capturedFileURL {
ShareSheet(items: [url])
}
}
.onAppear {
// Bug 12 fix: reset stuck spinner if capture was interrupted by background
if isCapturingTap && !AudioTapProcessor.shared.debugDumpEnabled {
isCapturingTap = false
}
}
.onReceive(NotificationCenter.default.publisher(for: AudioTapProcessor.captureCompleteNotification)) { note in
// Bug 11 fix: notification-based completion works even if view was dismissed and re-opened
isCapturingTap = false
if let url = note.userInfo?["url"] as? URL {
capturedFileURL = url
showShareSheet = true
}
}
}
}
// MARK: - Tap Capture
private func startTapCapture() {
isCapturingTap = true
AudioTapProcessor.shared.startDebugDump()
}
// MARK: - Presets
private func applyDeepOcean() {
settings.nowPlaying.wavePoints = 6
settings.viscosity = 0.12; settings.baseMultiplier = 22; settings.frequencyCutoff = 50
settings.npAmplitude = 0.75; settings.depthOffset = 30
settings.nowPlaying.waveSensitivity = 0.8
}
private func applyReactiveEQ() {
settings.nowPlaying.barPoints = 18; settings.nowPlaying.style = .bar
settings.viscosity = 0.6; settings.baseMultiplier = 20; settings.frequencyCutoff = 120
settings.npAmplitude = 0.5; settings.depthOffset = 5
settings.nowPlaying.barSensitivity = 1.4
}
private func applySubtleAmbient() {
settings.nowPlaying.wavePoints = 8; settings.nowPlaying.style = .wave
settings.viscosity = 0.18; settings.baseMultiplier = 15; settings.idleAmplitude = 0.04
settings.npAmplitude = 0.25; settings.nowPlaying.alpha = 0.3
settings.nowPlaying.waveSensitivity = 0.7
}
private func applyHeavyMercury() {
settings.nowPlaying.wavePoints = 10; settings.nowPlaying.style = .wave
settings.viscosity = 0.12; settings.baseMultiplier = 60
settings.npAmplitude = 0.6; settings.depthOffset = 20
settings.nowPlaying.waveSensitivity = 2.5
}
// MARK: - Slider helpers
func sliderRow(_ label: String, value: Binding<Int>, range: ClosedRange<Int>, step: Int) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(label).font(.body)
HStack {
Slider(value: Binding(get: { Double(value.wrappedValue) }, set: { value.wrappedValue = Int($0) }),
in: Double(range.lowerBound)...Double(range.upperBound), step: Double(step)).tint(pink)
Text("\(value.wrappedValue)").foregroundColor(.gray).frame(width: 44, alignment: .trailing)
}
}
}
func sliderRowDouble(_ label: String, value: Binding<Double>, range: ClosedRange<Double>, step: Double, format: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(label).font(.body)
HStack {
Slider(value: value, in: range, step: step).tint(pink)
Text(String(format: format, value.wrappedValue)).foregroundColor(.gray).frame(width: 44, alignment: .trailing)
}
}
}
private func resetDefaults() {
settings.nowPlaying = .nowPlayingDefault
settings.miniPlayer = .miniPlayerDefault
settings.enabled = true; settings.nowPlayingEnabled = true; settings.miniPlayerEnabled = true
settings.fps = 60; settings.realAudioAnalysis = true; settings.dynamicGainEnabled = true
settings.waveStrokeThickness = 1.5; settings.barSpacing = 5; settings.barCornerRadius = 0; settings.lineThickness = 5
settings.viscosity = 0.17; settings.frequencyCutoff = 75; settings.baseMultiplier = 18.0
settings.waveOffsetTop = 0; settings.nowPlayingHeightPct = 0.50
settings.npAmplitude = 0.50; settings.npBaseLift = 130.0
settings.depthOffset = 14.0; settings.depthOpacity = 0.18; settings.idleAmplitude = 0.03
settings.miniPlayerHeight = 48.0; settings.miniOpacity = 0.5
settings.miniAmplitude = 0.7; settings.miniIdleAmplitude = 0.03
settings.miniDepthOffset = 8.0; settings.miniDepthOpacity = 0.2
}
}
// MARK: - Per-View Config Settings (Now Playing & Mini Player share this)
struct ViewConfigSettingsView: View {
let title: String
@Binding var config: ViewVisualizerConfig
@Binding var amplitude: Double
@Binding var baseLift: Double
@Binding var waveOffset: Double
@Binding var depthOffset: Double
@Binding var depthOpacity: Double
@Binding var idleAmplitude:Double
@Binding var heightPct: Double
var isCompact: Bool
@ObservedObject private var settings = VisualizerSettings.shared
private let pink = Color(red: 1.0, green: 0.176, blue: 0.333)
var body: some View {
Form {
// Style
Section {
Picker("Style", selection: $config.style) {
ForEach(VisualizerSettings.Style.allCases, id: \.self) { Text($0.rawValue).tag($0) }
}.pickerStyle(.segmented)
// Per-style point count independent, not shared with other style
sd("Points (\(config.style.rawValue))",
value: Binding(
get: { Double(config.numberOfPoints) },
set: { config.numberOfPoints = Int($0) }
),
range: 4...24, step: 1, format: "%.0f")
// Per-style sensitivity
sd("Sensitivity (\(config.style.rawValue))",
value: Binding(
get: { config.sensitivity },
set: { config.sensitivity = $0 }
),
range: 0.1...2.0, step: 0.1, format: "%.1f")
} header: { Text("STYLE") } footer: {
Text("Each style remembers its own point count and sensitivity independently. Switching styles never overwrites another style's values.")
}
// Color
Section {
Picker("Color", selection: $config.colorMode) {
ForEach(VisualizerSettings.ColorMode.allCases, id: \.self) { Text($0.rawValue).tag($0) }
}.pickerStyle(.segmented)
sd("Alpha", value: $config.alpha, range: 0.1...1.0, step: 0.05, format: "%.2f")
if config.colorMode == .custom {
ColorPicker("Wave Color", selection: Binding(
get: { config.customColor },
set: { c in
if let components = UIColor(c).cgColor.components, components.count >= 3 {
config.customColorR = Double(components[0])
config.customColorG = Double(components[1])
config.customColorB = Double(components[2])
}
}
))
}
} header: { Text("COLOR") }
// Amplitude & Layout
Section {
if !isCompact {
VStack(alignment: .leading, spacing: 4) {
Text("Screen height %").font(.body)
HStack {
Slider(value: $heightPct, in: 0.2...0.8, step: 0.05).tint(pink)
Text("\(Int(heightPct * 100))%").foregroundColor(.gray).frame(width: 52, alignment: .trailing)
}
}
}
sd("Amplitude", value: $amplitude, range: 0.1...1.0, step: 0.05, format: "%.2f")
if !isCompact {
sd("Base lift (from bottom)", value: $baseLift, range: 0...300, step: 5, format: "%.0f pt")
sd("Wave offset (top)", value: $waveOffset, range: -100...300, step: 5, format: "%.0f")
}
} header: { Text("LAYOUT & AMPLITUDE") }
// Depth & Idle
Section {
sd("Depth offset", value: $depthOffset, range: 0...50, step: 2, format: "%.0f")
sd("Depth opacity", value: $depthOpacity, range: 0.0...0.5, step: 0.05, format: "%.2f")
sd("Idle amplitude",value: $idleAmplitude, range: 0.0...0.15, step: 0.005, format: "%.3f")
} header: { Text("DEPTH & IDLE") }
// Preview
Section {
ZStack {
Color(white: isCompact ? 0.12 : 0.0)
MitsuhaVisualizerView(previewLevels: previewLevels, isPlaying: true,
accentColor: pink, compact: isCompact)
.frame(height: isCompact ? settings.miniPlayerHeight : 180)
.opacity(isCompact ? settings.miniOpacity : 1.0)
if isCompact {
HStack(spacing: 12) {
RoundedRectangle(cornerRadius: 4).fill(Color.gray.opacity(0.3)).frame(width: 40, height: 40)
VStack(alignment: .leading, spacing: 2) {
Text("Song Title").font(.system(size: 14, weight: .medium)).foregroundColor(.white)
Text("Artist").font(.system(size: 12)).foregroundColor(.gray)
}
Spacer()
Image(systemName: "pause.fill").foregroundColor(.white)
Image(systemName: "forward.fill").foregroundColor(.white)
}.padding(.horizontal, 12)
}
}
.frame(height: isCompact ? 64 : 180)
.listRowInsets(EdgeInsets())
.listRowBackground(Color(white: isCompact ? 0.12 : 0.0))
} header: { Text("PREVIEW") }
}
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
}
private var previewLevels: [Float] {
(0..<30).map { Float(0.2 + 0.5 * sin(Float($0) * 0.4)) }
}
private func sd(_ label: String, value: Binding<Double>, range: ClosedRange<Double>, step: Double, format: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(label).font(.body)
HStack {
Slider(value: value, in: range, step: step).tint(pink)
Text(String(format: format, value.wrappedValue)).foregroundColor(.gray).frame(width: 52, alignment: .trailing)
}
}
}
}
// MARK: - Legacy sub-settings views (kept for backward compat, now redirect to unified view)
struct NowPlayingVisSettingsView: View {
@ObservedObject var settings: VisualizerSettings
var body: some View {
ViewConfigSettingsView(
title: "Now Playing",
config: $settings.nowPlaying,
amplitude: $settings.npAmplitude,
baseLift: $settings.npBaseLift,
waveOffset: $settings.waveOffsetTop,
depthOffset: $settings.depthOffset,
depthOpacity: $settings.depthOpacity,
idleAmplitude: $settings.idleAmplitude,
heightPct: $settings.nowPlayingHeightPct,
isCompact: false
)
}
}
struct MiniPlayerVisSettingsView: View {
@ObservedObject var settings: VisualizerSettings
var body: some View {
ViewConfigSettingsView(
title: "Mini Player",
config: $settings.miniPlayer,
amplitude: $settings.miniAmplitude,
baseLift: .constant(0),
waveOffset: .constant(0),
depthOffset: $settings.miniDepthOffset,
depthOpacity: $settings.miniDepthOpacity,
idleAmplitude: $settings.miniIdleAmplitude,
heightPct: .constant(0),
isCompact: true
)
}
}