Phase 1 — VisualizerStorageManager.swift VisFrameBuffer is a flat ContiguousArray<Float> with frameCount and pointsPerFrame. All frame data for a track lives in one contiguous allocation rather than a [[Float]] array-of-arrays. loadCache now uses Data(contentsOf:options:.alwaysMapped) — the OS maps the file into virtual memory and faults pages in on demand rather than reading the whole file into heap. copyFrame(at:into:) copies a frame slice directly into _audioLevels using initialize(from:) — no intermediate [Float] created. Phase 2 — MitsuhaVisualizerView.swift + AudioPlayer.swift VisualizerLevelBox now pre-allocates targetLevels, displayLevels, idleLevels, and the full 16-slot history ring on first use via resizeIfNeeded. This only triggers when numberOfPoints changes (rare — settings slider). updateDisplayLevels writes directly into box.targetLevels throughout — no var targetLevels = [Float](), no map, no append. The history ring buffer now copies in-place into pre-allocated slots, eliminating the COW trigger on every frame. drawIdleState uses box.idleLevels — no Array(repeating:). The Canvas body no longer falls back to Array(repeating:) since displayLevels is always pre-allocated. The timer in AudioPlayer now calls buf.copyFrame(at:into:&_audioLevels) directly — no intermediate [Float] copy. Phase 3 — View invalidation + drawBars fix fillColors hoisted out of the drawBars per-bar loop — it was allocating a new [Color] array count times per frame (8–24 allocations per draw call at 60fps). Now computed once before the loop. @ObservedObject var settings and @StateObject private var box are correct — box has zero @Published properties so it never triggers parent redraws. The Canvas closure only captures tickDate which changes every tick, ensuring per-tick re-execution without touching SwiftUI’s diffing engine.
1074 lines
55 KiB
Swift
1074 lines
55 KiB
Swift
import SwiftUI
|
||
|
||
// MARK: - Visualizer Settings (Mitsuha Infinity parity + advanced physics)
|
||
class VisualizerSettings: ObservableObject {
|
||
static let shared = VisualizerSettings()
|
||
|
||
// General
|
||
@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) } }
|
||
@Published var style: Style { didSet { save("vis_style", style.rawValue) } }
|
||
@Published var numberOfPoints: Int { didSet { save("vis_points", numberOfPoints) } }
|
||
@Published var sensitivity: Double { didSet { save("vis_sensitivity", sensitivity) } }
|
||
@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) } }
|
||
|
||
// Wave
|
||
@Published var waveOffsetTop: Double { didSet { save("vis_wave_offset", waveOffsetTop) } }
|
||
|
||
// Bar
|
||
@Published var barSpacing: Double { didSet { save("vis_bar_spacing", barSpacing) } }
|
||
@Published var barCornerRadius: Double { didSet { save("vis_bar_radius", barCornerRadius) } }
|
||
|
||
// Line
|
||
@Published var lineThickness: Double { didSet { save("vis_line_thick", lineThickness) } }
|
||
|
||
// Color
|
||
@Published var colorMode: ColorMode { didSet { save("vis_color", colorMode.rawValue) } }
|
||
@Published var alpha: Double { didSet { save("vis_alpha", alpha) } }
|
||
@Published var customColor: Color = .pink
|
||
|
||
// Advanced Physics
|
||
@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) } }
|
||
@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) } }
|
||
@Published var waveStrokeThickness: Double { didSet { save("vis_wave_stroke", waveStrokeThickness) } }
|
||
|
||
// Layout
|
||
@Published var nowPlayingHeightPct: Double { didSet { save("vis_np_height", nowPlayingHeightPct) } }
|
||
@Published var miniPlayerHeight: Double { didSet { save("vis_mini_height", miniPlayerHeight) } }
|
||
|
||
// Mini Player overrides
|
||
@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) } }
|
||
|
||
// Now Playing overrides
|
||
@Published var npAmplitude: Double { didSet { save("vis_np_amplitude", npAmplitude) } }
|
||
@Published var npBaseLift: Double { didSet { save("vis_np_baselift", npBaseLift) } }
|
||
|
||
enum Style: String, CaseIterable {
|
||
case wave = "Wave"
|
||
case bar = "Bar"
|
||
case line = "Line"
|
||
case siriWave = "Siri"
|
||
}
|
||
|
||
enum ColorMode: String, CaseIterable {
|
||
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
|
||
style = Style(rawValue: d.string(forKey: "vis_style") ?? "") ?? .wave
|
||
numberOfPoints = { let v = d.integer(forKey: "vis_points"); return v > 0 ? v : 10 }()
|
||
sensitivity = { let v = d.double(forKey: "vis_sensitivity"); return v > 0 ? v : 1.5 }()
|
||
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
|
||
waveOffsetTop = d.double(forKey: "vis_wave_offset")
|
||
barSpacing = { let v = d.double(forKey: "vis_bar_spacing"); return v > 0 ? v : 5.0 }()
|
||
barCornerRadius = d.double(forKey: "vis_bar_radius")
|
||
lineThickness = { let v = d.double(forKey: "vis_line_thick"); return v > 0 ? v : 5.0 }()
|
||
colorMode = ColorMode(rawValue: d.string(forKey: "vis_color") ?? "") ?? .dynamic
|
||
alpha = { let v = d.double(forKey: "vis_alpha"); return v > 0 ? v : 0.6 }()
|
||
// Advanced
|
||
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 }()
|
||
depthOffset = { let v = d.double(forKey: "vis_depth_offset"); return v > 0 ? v : 15.0 }()
|
||
depthOpacity = { let v = d.double(forKey: "vis_depth_opacity"); return v > 0 ? v : 0.2 }()
|
||
idleAmplitude = { let v = d.double(forKey: "vis_idle_amp"); return v > 0 ? v : 0.03 }()
|
||
waveStrokeThickness = { let v = d.double(forKey: "vis_wave_stroke"); return v > 0 ? v : 1.5 }()
|
||
// Layout
|
||
nowPlayingHeightPct = { let v = d.double(forKey: "vis_np_height"); return v > 0 ? v : 0.50 }()
|
||
miniPlayerHeight = { let v = d.double(forKey: "vis_mini_height"); return v > 0 ? v : 48.0 }()
|
||
// Mini Player
|
||
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
|
||
// Now Playing
|
||
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
|
||
}
|
||
|
||
var effectiveFPS: Double {
|
||
ProcessInfo.processInfo.isLowPowerModeEnabled ? min(fps, 24) : fps
|
||
}
|
||
|
||
private func save(_ key: String, _ value: Any) {
|
||
// Debounce: update local var instantly for UI, delay disk write
|
||
pendingSaves[key] = value
|
||
saveTask?.cancel()
|
||
saveTask = Task { [weak self] in
|
||
try? await Task.sleep(nanoseconds: 500_000_000)
|
||
guard !Task.isCancelled else { return }
|
||
if let pending = self?.pendingSaves {
|
||
for (k, v) in pending {
|
||
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
|
||
/// Set to false when this view is covered by another screen (e.g. NowPlaying is dismissed).
|
||
/// Stops FFT work and Canvas execution entirely, cutting CPU/GPU load to near zero.
|
||
var isVisible: Bool = true
|
||
|
||
@ObservedObject var settings = VisualizerSettings.shared
|
||
@ObservedObject var albumColors = AlbumColorExtractor.shared
|
||
|
||
@StateObject private var box = VisualizerLevelBox()
|
||
|
||
/// Whether the app is currently in the foreground.
|
||
@State private var isAppActive = true
|
||
|
||
static let historySize = 16
|
||
|
||
/// True only when it's worth doing FFT + Canvas work.
|
||
/// Any one of these being false → draw idle state instead.
|
||
private var isRenderingActive: Bool {
|
||
settings.enabled && isPlaying && isAppActive && isVisible
|
||
}
|
||
|
||
var body: some View {
|
||
// .periodic fires on a fixed wall-clock interval independent of SwiftUI animation state.
|
||
// Interval is static — changing it would tear down @StateObject box and reset wave state.
|
||
// CACurrentMediaTime() is used for actual dt computation (single consistent clock).
|
||
TimelineView(.periodic(from: .now, by: 1.0 / 60.0)) { timeline in
|
||
if settings.enabled {
|
||
let tickDate = timeline.date // captured by Canvas → forces re-execution
|
||
Canvas { context, size in
|
||
_ = tickDate
|
||
|
||
// Resize scratch buffers if point count changed (rare — settings slider).
|
||
// This is the only allocation allowed in this path, and only when count changes.
|
||
box.resizeIfNeeded(count: settings.numberOfPoints,
|
||
idleAmplitude: Float(settings.idleAmplitude))
|
||
|
||
guard isRenderingActive else {
|
||
drawIdleState(ctx: context, size: size)
|
||
return
|
||
}
|
||
|
||
let t = CACurrentMediaTime()
|
||
let rawLevels: [Float] = previewLevels ?? AudioPlayer.shared.currentLevels()
|
||
updateDisplayLevels(newRawLevels: rawLevels, t: t)
|
||
updateWobblePhase(t: t)
|
||
|
||
guard box.displayLevels.count >= 2 else { return }
|
||
switch settings.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)
|
||
}
|
||
}
|
||
.opacity(isPlaying ? 1.0 : (isSongLoaded ? 0.35 : 1.0))
|
||
.animation(isSongLoaded ? .easeInOut(duration: 0.6) : nil, value: isPlaying)
|
||
.onAppear {
|
||
box.resizeIfNeeded(count: settings.numberOfPoints,
|
||
idleAmplitude: Float(settings.idleAmplitude))
|
||
// Seed compact vis from shared cache for morph continuity
|
||
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
|
||
}
|
||
}
|
||
// App foregrounded — reset lastTickTime so the first dt isn't huge
|
||
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
|
||
isAppActive = true
|
||
box.lastTickTime = 0
|
||
}
|
||
// App backgrounded — stop rendering and purge history
|
||
.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 settings.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 = settings.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(settings.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 = settings.alpha
|
||
switch settings.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 [settings.customColor.opacity(a), settings.customColor.opacity(a * 0.6)]
|
||
}
|
||
}
|
||
|
||
private var strokeColor: Color {
|
||
switch settings.colorMode {
|
||
case .dynamic: return accentColor.opacity(min(1, settings.alpha + 0.3))
|
||
case .albumArt: return albumColors.primaryColor.opacity(min(1, settings.alpha + 0.3))
|
||
case .vibrant: return Color.cyan.opacity(min(1, settings.alpha + 0.3))
|
||
case .custom: return settings.customColor.opacity(min(1, settings.alpha + 0.3))
|
||
}
|
||
}
|
||
|
||
// 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 = settings.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 = settings.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 = settings.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 = settings.colorMode == .vibrant
|
||
let a = settings.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 0→1
|
||
|
||
// 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 settings.colorMode {
|
||
case .albumArt: c = AlbumColorExtractor.shared.primaryColor
|
||
case .custom: c = settings.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 {
|
||
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.")
|
||
}
|
||
|
||
Section {
|
||
Picker("Style", selection: $settings.style) {
|
||
ForEach(VisualizerSettings.Style.allCases, id: \.self) { Text($0.rawValue).tag($0) }
|
||
}.pickerStyle(.segmented)
|
||
sliderRow("Number of points", value: $settings.numberOfPoints, range: 4...24, step: 1)
|
||
} header: { Text("STYLE") } footer: {
|
||
Text("Points control how many data points the wave is built from. Fewer points = bigger, rounder ocean swells. More points = detailed, ripply surface. 8–12 is a good starting point.")
|
||
}
|
||
|
||
if settings.style == .wave {
|
||
Section {
|
||
sliderRowDouble("Stroke thickness", value: $settings.waveStrokeThickness, range: 0.5...5.0, step: 0.5, format: "%.1f")
|
||
} header: { Text("WAVE") } footer: {
|
||
Text("The white line on top of the wave fill. At 0.5, barely visible. At 3+, a bold outline.")
|
||
}
|
||
}
|
||
if settings.style == .bar {
|
||
Section {
|
||
sliderRowDouble("Bar spacing", value: $settings.barSpacing, range: 0...20, step: 1, format: "%.0f")
|
||
sliderRowDouble("Corner radius", value: $settings.barCornerRadius, range: 0...15, step: 1, format: "%.0f")
|
||
} header: { Text("BAR") } footer: {
|
||
Text("Spacing controls the gap between bars. Corner radius rounds the bar tops — at 0 they're sharp rectangles, at 15 they're rounded pills.")
|
||
}
|
||
}
|
||
if settings.style == .line {
|
||
Section {
|
||
sliderRowDouble("Line thickness", value: $settings.lineThickness, range: 1...12, step: 0.5, format: "%.1f")
|
||
} header: { Text("LINE") } footer: {
|
||
Text("How thick the oscillating line is. Includes an automatic glow effect that scales with thickness.")
|
||
}
|
||
}
|
||
|
||
Section {
|
||
Picker("Color", selection: $settings.colorMode) {
|
||
ForEach(VisualizerSettings.ColorMode.allCases, id: \.self) { Text($0.rawValue).tag($0) }
|
||
}.pickerStyle(.segmented)
|
||
sliderRowDouble("Color alpha", value: $settings.alpha, range: 0.1...1.0, step: 0.05, format: "%.2f")
|
||
if settings.colorMode == .custom { ColorPicker("Wave Color", selection: $settings.customColor) }
|
||
} header: { Text("COLOR") } footer: {
|
||
switch settings.colorMode {
|
||
case .dynamic: Text("Uses the app's accent color.")
|
||
case .albumArt: Text("Extracts dominant and secondary colors from the current album cover. The wave changes color with every song.")
|
||
case .vibrant: Text("Rainbow gradient (pink → purple → cyan → green) applied horizontally. Also used for the Siri wave style's multi-layer colours.")
|
||
case .custom: Text("Pick any color with the color picker above.")
|
||
}
|
||
}
|
||
|
||
Section {
|
||
NavigationLink {
|
||
NowPlayingVisSettingsView(settings: settings)
|
||
} label: {
|
||
HStack {
|
||
Image(systemName: "play.rectangle.fill").foregroundColor(pink).frame(width: 28)
|
||
Text("Now Playing")
|
||
Spacer()
|
||
Text("\(Int(settings.npAmplitude * 100))% amp").font(.caption).foregroundColor(.gray)
|
||
}
|
||
}
|
||
NavigationLink {
|
||
MiniPlayerVisSettingsView(settings: settings)
|
||
} label: {
|
||
HStack {
|
||
Image(systemName: "rectangle.bottomhalf.filled").foregroundColor(pink).frame(width: 28)
|
||
Text("Mini Player")
|
||
Spacer()
|
||
Text("\(Int(settings.miniAmplitude * 100))% amp").font(.caption).foregroundColor(.gray)
|
||
}
|
||
}
|
||
} header: { Text("PER-VIEW SETTINGS") } footer: {
|
||
Text("Each view has its own amplitude, depth, idle, and layout controls. Tap to configure individually.")
|
||
}
|
||
|
||
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("Sensitivity", value: $settings.sensitivity, range: 0.1...2.0, step: 0.1, format: "%.1f")
|
||
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 / ADVANCED") } footer: {
|
||
Text("Viscosity: 0.10–0.20 = heavy, slow liquid (Mitsuha). 0.4+ = snappy EQ. This is the most important slider.\n\nSensitivity: multiplies incoming audio. 0.8–1.0 is the sweet spot. Above 1.5 causes the wave to clip at the top.\n\nBase Multiplier: overall gain for FFT data. 15–25 works well. Higher values are needed for quieter recordings.\n\nFrequency Cutoff: limits how many FFT bins are used. 60–80 gives mostly bass and mids. 100+ adds treble detail.\n\nDynamic Gain: recommended ON — 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("Apply a preset to set multiple sliders at once. You can fine-tune 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.numberOfPoints = 6; settings.viscosity = 0.12; settings.sensitivity = 0.8
|
||
settings.npAmplitude = 0.75; settings.depthOffset = 30; settings.frequencyCutoff = 50; settings.baseMultiplier = 22
|
||
}
|
||
private func applyReactiveEQ() {
|
||
settings.numberOfPoints = 18; settings.viscosity = 0.6; settings.sensitivity = 1.4
|
||
settings.npAmplitude = 0.5; settings.depthOffset = 5; settings.frequencyCutoff = 120; settings.baseMultiplier = 20
|
||
}
|
||
private func applySubtleAmbient() {
|
||
settings.numberOfPoints = 8; settings.viscosity = 0.18; settings.sensitivity = 0.7
|
||
settings.npAmplitude = 0.25; settings.alpha = 0.3; settings.idleAmplitude = 0.04; settings.baseMultiplier = 15
|
||
}
|
||
private func applyHeavyMercury() {
|
||
settings.numberOfPoints = 10; settings.viscosity = 0.12; settings.sensitivity = 2.5
|
||
settings.baseMultiplier = 60; settings.npAmplitude = 0.6; settings.depthOffset = 20
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|
||
}
|
||
|
||
var previewLevels: [Float] { (0..<30).map { Float(0.2 + 0.5 * sin(Float($0) * 0.4)) } }
|
||
|
||
private func resetDefaults() {
|
||
settings.enabled = true; settings.nowPlayingEnabled = true; settings.miniPlayerEnabled = true
|
||
settings.style = .wave; settings.numberOfPoints = 9
|
||
settings.fps = 60; settings.realAudioAnalysis = true; settings.dynamicGainEnabled = true
|
||
settings.waveOffsetTop = 0
|
||
settings.barSpacing = 5; settings.barCornerRadius = 0; settings.lineThickness = 5
|
||
settings.colorMode = .dynamic; settings.alpha = 0.6
|
||
// Tuned for Mitsuha feel: slow liquid viscosity, low sensitivity, moderate gain
|
||
settings.viscosity = 0.17; settings.sensitivity = 0.9
|
||
settings.frequencyCutoff = 75; settings.baseMultiplier = 18.0
|
||
settings.depthOffset = 14.0; settings.depthOpacity = 0.18; settings.idleAmplitude = 0.03
|
||
settings.waveStrokeThickness = 1.5; settings.nowPlayingHeightPct = 0.50; settings.miniPlayerHeight = 48.0
|
||
settings.miniOpacity = 0.5; settings.miniAmplitude = 0.7; settings.miniIdleAmplitude = 0.03
|
||
settings.miniDepthOffset = 8.0; settings.miniDepthOpacity = 0.2
|
||
settings.npAmplitude = 0.50; settings.npBaseLift = 130.0
|
||
}
|
||
}
|
||
|
||
// MARK: - Now Playing Sub-Settings
|
||
struct NowPlayingVisSettingsView: View {
|
||
@ObservedObject var settings: VisualizerSettings
|
||
private let pink = Color(red: 1.0, green: 0.176, blue: 0.333)
|
||
|
||
var body: some View {
|
||
Form {
|
||
Section {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text("Screen height %").font(.body)
|
||
HStack {
|
||
Slider(value: $settings.nowPlayingHeightPct, in: 0.2...0.8, step: 0.05).tint(pink)
|
||
Text("\(Int(settings.nowPlayingHeightPct * 100))%").foregroundColor(.gray).frame(width: 52, alignment: .trailing)
|
||
}
|
||
}
|
||
sd("Amplitude", value: $settings.npAmplitude, range: 0.1...1.0, step: 0.05, format: "%.2f")
|
||
sd("Base lift (from bottom)", value: $settings.npBaseLift, range: 0...300, step: 5, format: "%.0f pt")
|
||
sd("Wave offset (top)", value: $settings.waveOffsetTop, range: -100...300, step: 5, format: "%.0f")
|
||
} header: { Text("LAYOUT & AMPLITUDE") } footer: {
|
||
Text("Screen height controls how much of the Now Playing screen the visualizer fills. At 50%, it covers the bottom half. At 70%, it reaches behind the album art.\n\nAmplitude controls how tall wave peaks are. At 0.45, peaks reach about halfway. Push to 0.8+ for dramatic waves. Drop to 0.2 for a subtle accent.\n\nBase lift moves the wave baseline up from the very bottom of the screen. At 130 (default), the wave sits above the transport controls. Set to 0 for the absolute bottom edge.\n\nWave offset adds additional vertical shift on top of base lift. Use negative values to push down, positive to push up.")
|
||
}
|
||
Section {
|
||
sd("Depth offset", value: $settings.depthOffset, range: 0...50, step: 2, format: "%.0f")
|
||
sd("Depth opacity", value: $settings.depthOpacity, range: 0.0...0.5, step: 0.05, format: "%.2f")
|
||
sd("Idle amplitude", value: $settings.idleAmplitude, range: 0.0...0.10, step: 0.005, format: "%.3f")
|
||
} header: { Text("DEPTH & IDLE") } footer: {
|
||
Text("Depth offset controls how far below the main wave the shadow layer is drawn. At 15, there's visible parallax. At 0, they overlap. At 40+, the shadow looks like a distant reflection.\n\nDepth opacity sets how visible the shadow wave is. At 0.2, it's subtle. At 0, invisible.\n\nIdle amplitude is the minimum wave height during quiet moments. Prevents the wave from going completely flat. At 0.03, there's always gentle surface tension.")
|
||
}
|
||
Section {
|
||
ZStack {
|
||
Color.black
|
||
MitsuhaVisualizerView(previewLevels: previewLevels, isPlaying: true, accentColor: pink)
|
||
}
|
||
.frame(height: 180).listRowInsets(EdgeInsets()).listRowBackground(Color.black)
|
||
} header: { Text("PREVIEW") }
|
||
}
|
||
.navigationTitle("Now Playing").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: - Mini Player Sub-Settings
|
||
struct MiniPlayerVisSettingsView: View {
|
||
@ObservedObject var settings: VisualizerSettings
|
||
private let pink = Color(red: 1.0, green: 0.176, blue: 0.333)
|
||
|
||
var body: some View {
|
||
Form {
|
||
Section {
|
||
sd("Height (points)", value: $settings.miniPlayerHeight, range: 24...80, step: 4, format: "%.0f pt")
|
||
sd("Amplitude", value: $settings.miniAmplitude, range: 0.1...2.0, step: 0.05, format: "%.2f")
|
||
sd("Opacity", value: $settings.miniOpacity, range: 0.1...1.0, step: 0.05, format: "%.2f")
|
||
} header: { Text("LAYOUT & AMPLITUDE") } footer: {
|
||
Text("Height is the actual size of the mini player visualizer frame in points. Larger values give the wave more room.\n\nAmplitude is higher than Now Playing by default (0.7) because the mini player is much smaller. Push to 1.5+ if you want the wave to fill the entire height.\n\nOpacity controls transparency behind the mini player controls. At 0.5, the wave is visible but song title and buttons are readable. At 1.0, fully opaque. At 0.2, a subtle shimmer.")
|
||
}
|
||
Section {
|
||
sd("Depth offset", value: $settings.miniDepthOffset, range: 0...30, step: 1, format: "%.0f")
|
||
sd("Depth opacity", value: $settings.miniDepthOpacity, range: 0.0...0.5, step: 0.05, format: "%.2f")
|
||
sd("Idle amplitude", value: $settings.miniIdleAmplitude, range: 0.0...0.15, step: 0.005, format: "%.3f")
|
||
} header: { Text("DEPTH & IDLE") } footer: {
|
||
Text("Depth offset controls the shadow wave distance. Keep lower than Now Playing since the mini player is smaller — 8 is a good default.\n\nIdle amplitude prevents the wave from flatlining during quiet moments.")
|
||
}
|
||
Section {
|
||
ZStack {
|
||
Color(white: 0.12)
|
||
MitsuhaVisualizerView(previewLevels: previewLevels, isPlaying: true, accentColor: pink, compact: true)
|
||
.frame(height: settings.miniPlayerHeight)
|
||
.opacity(settings.miniOpacity)
|
||
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: 64).listRowInsets(EdgeInsets()).listRowBackground(Color(white: 0.12))
|
||
} header: { Text("PREVIEW") }
|
||
}
|
||
.navigationTitle("Mini Player").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)
|
||
}
|
||
}
|
||
}
|
||
}
|