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) } } @Published var miniVisYOffset: Double { didSet { save("vis_mini_y_offset", miniVisYOffset) } } 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 miniVisYOffset = d.object(forKey: "vis_mini_y_offset") as? Double ?? 0.0 } var effectiveFPS: Double { ProcessInfo.processInfo.isLowPowerModeEnabled ? min(fps, 24) : fps } // MARK: - Persistence private func save(_ key: String, _ value: Any) { pendingSaves[key] = value scheduleFlush() } private func saveConfig(_ key: String, _ config: ViewVisualizerConfig) { if let data = try? JSONEncoder().encode(config) { pendingSaves[key] = data scheduleFlush() } } // Debounce UserDefaults writes to 500ms so rapid slider drags don't // flood the defaults system. The Task is explicitly @MainActor so // pendingSaves is only ever read/written on the main thread — no data race. private func scheduleFlush() { saveTask?.cancel() saveTask = Task { @MainActor [weak self] in try? await Task.sleep(nanoseconds: 500_000_000) guard !Task.isCancelled, let self else { return } for (k, v) in self.pendingSaves { UserDefaults.standard.set(v, forKey: k) } self.pendingSaves.removeAll() } } private var pendingSaves: [String: Any] = [:] private var saveTask: Task? } // MARK: - Wave State Cache (shared between mini player and DI for morph continuity) /// Single source of truth for compact visualizer levels. /// Both MiniPlayerBar and DynamicIslandView seed from here on appear, /// so the wave doesn't reset to idle when matchedGeometryEffect transitions between them. final class WaveStateCache { static let shared = WaveStateCache() var compactLevels: [Float] = [] private init() {} } // MARK: - Level Box /// Per-instance mutable render state. Zero @Published properties → no observation warnings. /// All working buffers pre-allocated here so the 60fps render loop does zero heap allocation. fileprivate final class VisualizerLevelBox: ObservableObject { var displayLevels: [Float] = [] var targetLevels: [Float] = [] // scratch buffer — pre-allocated, mutated in-place each frame var idleLevels: [Float] = [] // flat idle wave — pre-allocated, updated only when count changes var peakFollower: Float = 0.01 var levelHistoryBuf: [[Float]] = [] var historyWriteIdx: Int = 0 var wobblePhaseOffset: Double = 0 var lastTickTime: CFTimeInterval = 0 var resumeTickCount: Int = 0 // debug: counts first N ticks after resume /// Resize all per-frame buffers when point count changes (rare — settings slider). func resizeIfNeeded(count: Int, idleAmplitude: Float) { guard count > 0 else { return } if targetLevels.count != count { targetLevels = [Float](repeating: 0, count: count) displayLevels = [Float](repeating: idleAmplitude, count: count) idleLevels = [Float](repeating: idleAmplitude, count: count) // Pre-fill history ring with idle so depth ghost doesn't start blank levelHistoryBuf = [[Float]]( repeating: [Float](repeating: idleAmplitude, count: count), count: MitsuhaVisualizerView.historySize ) historyWriteIdx = 0 } else if idleLevels.first != idleAmplitude { for i in 0..= 2 else { return } if isRenderingActive { let t = CACurrentMediaTime() let rawLevels = previewLevels ?? AudioPlayer.shared.currentLevels() if box.resumeTickCount < 3 { box.resumeTickCount += 1 DebugLogger.shared.log( "TL tick #\(box.resumeTickCount): " + "rawCount=\(rawLevels.count) " + "rawMax=\(String(format: "%.3f", rawLevels.max() ?? 0)) " + "lastTick=\(String(format: "%.1f", box.lastTickTime))", category: "VisDebug", level: .debug) } updateDisplayLevels(newRawLevels: rawLevels, t: t) updateWobblePhase(t: t) switch config.style { case .wave: drawWave(ctx: context, size: size, levels: box.displayLevels, continuousTime: box.wobblePhaseOffset) case .bar: drawBars(ctx: context, size: size, levels: box.displayLevels) case .line: drawLine(ctx: context, size: size, levels: box.displayLevels) case .siriWave: drawSiriWave(ctx: context, size: size, levels: box.displayLevels, continuousTime: box.wobblePhaseOffset) } } else { // Idle/paused: draw last known state without updating levels drawIdleState(ctx: context, size: size) } } } } } .opacity(isPlaying ? 1.0 : (isSongLoaded ? 0.35 : 1.0)) .animation(isSongLoaded ? .easeInOut(duration: 0.6) : nil, value: isPlaying) .onAppear { box.resizeIfNeeded(count: config.numberOfPoints, idleAmplitude: Float(settings.idleAmplitude)) if compact, !WaveStateCache.shared.compactLevels.isEmpty { let cached = WaveStateCache.shared.compactLevels let count = box.displayLevels.count for i in 0..= 2 else { return } switch config.style { case .wave: drawWave(ctx: ctx, size: size, levels: box.idleLevels, continuousTime: 0) case .bar: drawBars(ctx: ctx, size: size, levels: box.idleLevels) case .line: drawLine(ctx: ctx, size: size, levels: box.idleLevels) case .siriWave: drawSiriWave(ctx: ctx, size: size, levels: box.idleLevels, continuousTime: 0) } } // MARK: - Wobble Phase private func updateWobblePhase(t: Double) { if box.lastTickTime > 0 { let delta = t - box.lastTickTime box.wobblePhaseOffset += min(delta, 0.1) } box.lastTickTime = t } // MARK: - Temporal Smoothing & Log Binning @discardableResult private func updateDisplayLevels(newRawLevels: [Float], t: Double) -> Bool { let count = config.numberOfPoints guard count > 0, !newRawLevels.isEmpty, box.targetLevels.count == count else { DebugLogger.shared.log( "updateDisplayLevels GUARD FAILED: " + "count=\(count) rawEmpty=\(newRawLevels.isEmpty) " + "targetCount=\(box.targetLevels.count)", category: "VisDebug", level: .warning) return false } let dt = Float(box.lastTickTime > 0 ? min(t - box.lastTickTime, 0.1) : 1.0/60.0) let sens = Float(config.sensitivity) let isPreProcessed = AudioPlayer.shared.isUsingOfflineVis // ── Write directly into pre-allocated box.targetLevels ─────────────── if isPreProcessed { if newRawLevels.count == count { // Hot path: identical size — single in-place pass, zero allocation for i in 0.. 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.. 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.. 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..= 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.., range: ClosedRange, 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, range: ClosedRange, 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 settings.miniVisYOffset = 0.0 } } // 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") } if isCompact { sd("Height", value: Binding( get: { settings.miniPlayerHeight }, set: { settings.miniPlayerHeight = $0 } ), range: 20...100, step: 2, format: "%.0f pt") sd("Opacity", value: Binding( get: { settings.miniOpacity }, set: { settings.miniOpacity = $0 } ), range: 0.1...1.0, step: 0.05, format: "%.2f") sd("Vertical position", value: Binding( get: { settings.miniVisYOffset }, set: { settings.miniVisYOffset = $0 } ), range: -20...20, step: 1, format: "%+.0f pt") } } 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, range: ClosedRange, 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 ) } }