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.
1227 lines
58 KiB
Swift
1227 lines
58 KiB
Swift
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 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 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.10–0.20 = heavy ocean swell. 0.4+ = snappy EQ. Base Multiplier: 15–25 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
|
||
)
|
||
}
|
||
}
|