NavidromeApp/iOS/Views/Visualizer/MitsuhaVisualizerView.swift
Dallas Groot 2bdac607b4 bug fixes
Songs Tab (SearchView.swift)
Default state now loads all songs alphabetically from the library via
getAlbumList2 → per-album song fetch, cached under "all_songs_sorted"
so subsequent opens are instant. The Download All banner shows song
count + already-downloaded count and queues only non-downloaded songs.
Every row uses .contextMenu (the long-press menu) with Play Now, Play
Next, Add to Queue, Download/Remove, Send to Watch, and Add to
Playlist — same pattern as Favourites. Watch and download badges
appear on each row. Searching ≥2 chars runs the server search and
shows artists/albums/songs in sections, then clears back to the full
list when the field is empty.
Keyboard Done Button
A single keyboardDoneButton() View extension in AsyncCoverArt.swift
calls UIApplication.shared.sendAction(resignFirstResponder:...)
globally — no @FocusState needed. Applied to: LoginView (all 4
fields), CompanionSettingsView (host/port), TrackEditorView
(checkField helper covers all tag fields), BatchAlbumEditorSheet
(editField helper), RadioView (name/URL), PlaylistsView (name fields),
MyMusicView (search), SearchView (via @FocusState + toolbar directly).
ShazamKit MTAudioProcessingTap
Primary path: MTAudioProcessingTap installed on AVPlayerItem.audioMix
— works for HLS, radio, and any AVPlayer stream without touching the
microphone. The prepare callback captures the source format and builds
an AVAudioConverter to 16kHz mono. The C-style shazamTapProcess free
function (required by the API) calls
MTAudioProcessingTapGetSourceAudio then dispatches to a serial
analysisQueue — the render thread is never blocked. convertAndMatch
wraps the raw AudioBufferList in an AVAudioPCMBuffer, converts it, and
feeds SHSession.matchStreamingBuffer. Fallback to microphone
(AVAudioEngine) is kept for the local engine path where no
AVPlayerItem exists. NSMicrophoneUsageDescription is only needed if
the mic fallback is ever hit.
2026-04-10 16:55:09 -07:00

1227 lines
58 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()
}
}
private func scheduleFlush() {
saveTask?.cancel()
saveTask = Task { [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
/// 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
let accentColor: Color
var compact: Bool = false
var isVisible: Bool = true
@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 {
if isRenderingActive {
// Active: TimelineView drives the Canvas at 60fps
TimelineView(.periodic(from: .now, by: 1.0 / 60.0)) { timeline in
let tickDate = timeline.date
Canvas { context, size in
_ = tickDate
box.resizeIfNeeded(count: config.numberOfPoints,
idleAmplitude: Float(settings.idleAmplitude))
let t = CACurrentMediaTime()
let rawLevels = previewLevels ?? AudioPlayer.shared.currentLevels()
updateDisplayLevels(newRawLevels: rawLevels, t: t)
updateWobblePhase(t: t)
guard box.displayLevels.count >= 2 else { return }
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: static Canvas drawn once zero animation cost, zero CPU
Canvas { context, size in
box.resizeIfNeeded(count: config.numberOfPoints,
idleAmplitude: Float(settings.idleAmplitude))
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
box.peakFollower = 0.01
}
}
.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 { 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
let accentColor: Color
let height: CGFloat
var body: some View {
MitsuhaVisualizerView(
isPlaying: isPlaying,
isSongLoaded: isSongLoaded,
accentColor: accentColor,
compact: true
)
.frame(height: height)
}
}
// MARK: - Visualizer Settings View
struct VisualizerSettingsView: View {
@ObservedObject var settings = VisualizerSettings.shared
@Environment(\.dismiss) private var dismiss
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() } }
}
.navigationTitle("Visualizer")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") { dismiss() }.foregroundColor(pink)
}
}
}
}
// 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
)
}
}