847 lines
43 KiB
Swift
847 lines
43 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) } }
|
|||
|
|
|
|||
|
|
// 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"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
enum ColorMode: String, CaseIterable {
|
|||
|
|
case dynamic = "Dynamic"
|
|||
|
|
case albumArt = "Album Art"
|
|||
|
|
case siri = "Siri"
|
|||
|
|
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
|
|||
|
|
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 : 40.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: - Main Visualizer View
|
|||
|
|
struct MitsuhaVisualizerView: View {
|
|||
|
|
/// Optional override levels for previews. When nil, pulls from AudioPlayer.
|
|||
|
|
var previewLevels: [Float]? = nil
|
|||
|
|
let isPlaying: Bool
|
|||
|
|
let accentColor: Color
|
|||
|
|
var compact: Bool = false
|
|||
|
|
|
|||
|
|
@ObservedObject var settings = VisualizerSettings.shared
|
|||
|
|
@ObservedObject var albumColors = AlbumColorExtractor.shared
|
|||
|
|
@State private var touchRipples: [TouchRipple] = []
|
|||
|
|
@State private var displayLevels: [Float] = []
|
|||
|
|
|
|||
|
|
struct TouchRipple: Identifiable {
|
|||
|
|
let id = UUID()
|
|||
|
|
let x: CGFloat
|
|||
|
|
let createdAt: Date
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var body: some View {
|
|||
|
|
if settings.enabled {
|
|||
|
|
GeometryReader { geo in
|
|||
|
|
TimelineView(.animation(minimumInterval: 1.0 / settings.effectiveFPS)) { timeline in
|
|||
|
|
// PULL: Grab latest levels right before rendering — no @Published thrashing
|
|||
|
|
let rawLevels = previewLevels ?? AudioPlayer.shared.currentLevels()
|
|||
|
|
let _ = updateDisplayLevelsIfNeeded(newRawLevels: rawLevels)
|
|||
|
|
|
|||
|
|
Canvas(opaque: false) { context, size in
|
|||
|
|
let pts = isPlaying ? displayLevels : Array(repeating: Float(settings.idleAmplitude), count: settings.numberOfPoints)
|
|||
|
|
guard pts.count >= 2 else { return }
|
|||
|
|
switch settings.style {
|
|||
|
|
case .wave: drawWave(ctx: context, size: size, levels: pts, date: timeline.date)
|
|||
|
|
case .bar: drawBars(ctx: context, size: size, levels: pts)
|
|||
|
|
case .line: drawLine(ctx: context, size: size, levels: pts)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
.gesture(
|
|||
|
|
DragGesture(minimumDistance: 0)
|
|||
|
|
.onChanged { value in
|
|||
|
|
addRipple(at: value.location.x / geo.size.width)
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
.opacity(isPlaying ? 1 : 0)
|
|||
|
|
.animation(.easeInOut(duration: 0.6), value: isPlaying)
|
|||
|
|
.onAppear {
|
|||
|
|
displayLevels = Array(repeating: Float(settings.idleAmplitude), count: settings.numberOfPoints)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Called every frame inside TimelineView — updates displayLevels in-place
|
|||
|
|
/// Returns Void; the `let _ =` assignment is just to run code inside the ViewBuilder
|
|||
|
|
private func updateDisplayLevelsIfNeeded(newRawLevels: [Float]) -> Bool {
|
|||
|
|
// Must dispatch to avoid mutating @State during view render
|
|||
|
|
DispatchQueue.main.async {
|
|||
|
|
self.updateDisplayLevels(newRawLevels: newRawLevels)
|
|||
|
|
}
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MARK: - Touch Ripples
|
|||
|
|
|
|||
|
|
private func addRipple(at x: CGFloat) {
|
|||
|
|
let clamped = min(max(x, 0), 1)
|
|||
|
|
if let last = touchRipples.last, Date().timeIntervalSince(last.createdAt) < 0.04 { return }
|
|||
|
|
touchRipples.append(TouchRipple(x: clamped, createdAt: Date()))
|
|||
|
|
let now = Date()
|
|||
|
|
touchRipples.removeAll { now.timeIntervalSince($0.createdAt) > 1.0 }
|
|||
|
|
if touchRipples.count > 25 { touchRipples.removeFirst() }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MARK: - Temporal Smoothing & Log Binning
|
|||
|
|
|
|||
|
|
private func updateDisplayLevels(newRawLevels: [Float]) {
|
|||
|
|
let count = settings.numberOfPoints
|
|||
|
|
guard count > 0, !newRawLevels.isEmpty else { return }
|
|||
|
|
|
|||
|
|
let sens = Float(settings.sensitivity)
|
|||
|
|
let isPreProcessed = AudioPlayer.shared.isUsingOfflineVis
|
|||
|
|
|
|||
|
|
var targetLevels: [Float]
|
|||
|
|
|
|||
|
|
if isPreProcessed {
|
|||
|
|
// Offline vis frames are already normalized (peak ≈ 0.8) and log-binned.
|
|||
|
|
// Only apply sensitivity for user control — no baseMultiplier needed.
|
|||
|
|
if newRawLevels.count == count {
|
|||
|
|
targetLevels = newRawLevels.map { min(1.0, $0 * sens) }
|
|||
|
|
} else {
|
|||
|
|
// Resample to match display point count via linear interpolation
|
|||
|
|
targetLevels = (0..<count).map { i in
|
|||
|
|
let srcPos = Float(i) / Float(max(count - 1, 1)) * Float(newRawLevels.count - 1)
|
|||
|
|
let lo = Int(srcPos)
|
|||
|
|
let hi = min(lo + 1, newRawLevels.count - 1)
|
|||
|
|
let frac = srcPos - Float(lo)
|
|||
|
|
let val = newRawLevels[lo] * (1.0 - frac) + newRawLevels[hi] * frac
|
|||
|
|
return min(1.0, val * sens)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
// Raw FFT bins or simulated levels — full log binning + EQ boost
|
|||
|
|
targetLevels = [Float](repeating: 0, count: count)
|
|||
|
|
let maxUsefulBin = min(newRawLevels.count - 1, settings.frequencyCutoff)
|
|||
|
|
|
|||
|
|
for i in 0..<count {
|
|||
|
|
let normalizedIndex = Float(i + 1) / Float(count)
|
|||
|
|
let logIndex = log10(normalizedIndex * 9.0 + 1.0)
|
|||
|
|
|
|||
|
|
let centerBin = logIndex * Float(maxUsefulBin)
|
|||
|
|
let binWidth = max(1.0, Float(maxUsefulBin) / Float(count) * logIndex)
|
|||
|
|
|
|||
|
|
let startBin = max(1, Int(centerBin - binWidth / 2))
|
|||
|
|
let endBin = min(maxUsefulBin, Int(centerBin + binWidth / 2))
|
|||
|
|
|
|||
|
|
var sum: Float = 0
|
|||
|
|
var countInBand = 0
|
|||
|
|
for j in startBin...endBin where j < newRawLevels.count {
|
|||
|
|
sum += newRawLevels[j]
|
|||
|
|
countInBand += 1
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let averageInBand = countInBand > 0 ? (sum / Float(countInBand)) : 0
|
|||
|
|
let eqBoost: Float = 1.0 + (Float(i) / Float(count)) * 3.5
|
|||
|
|
|
|||
|
|
targetLevels[i] = min(1.0, averageInBand * Float(settings.baseMultiplier) * sens * eqBoost)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Temporal Smoothing (Viscosity)
|
|||
|
|
if displayLevels.count != count {
|
|||
|
|
displayLevels = targetLevels
|
|||
|
|
} else {
|
|||
|
|
let smoothFactor = Float(settings.viscosity)
|
|||
|
|
for i in 0..<count {
|
|||
|
|
displayLevels[i] = displayLevels[i] + (targetLevels[i] - displayLevels[i]) * smoothFactor
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 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 .siri: 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 .siri: 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], date: Date) {
|
|||
|
|
let w = size.width
|
|||
|
|
let h = size.height
|
|||
|
|
let idleVal = CGFloat(compact ? settings.miniIdleAmplitude : settings.idleAmplitude)
|
|||
|
|
guard levels.count >= 2 else { return }
|
|||
|
|
let spacing = w / CGFloat(levels.count - 1)
|
|||
|
|
let now = date.timeIntervalSinceReferenceDate
|
|||
|
|
|
|||
|
|
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 points = levels.enumerated().map { i, lev -> CGPoint in
|
|||
|
|
let x = CGFloat(i) * spacing
|
|||
|
|
let nx = x / w
|
|||
|
|
let baseAmp = CGFloat(lev)
|
|||
|
|
|
|||
|
|
var ripple: CGFloat = 0
|
|||
|
|
for rp in touchRipples {
|
|||
|
|
let age = CGFloat(now - rp.createdAt.timeIntervalSinceReferenceDate)
|
|||
|
|
let decay = max(0, 1.0 - age * 1.4)
|
|||
|
|
let dist = abs(nx - rp.x)
|
|||
|
|
let radius: CGFloat = 0.08 + age * 1.2
|
|||
|
|
if dist < radius {
|
|||
|
|
let prox = 1.0 - (dist / radius)
|
|||
|
|
ripple += sin(dist * 30 - age * 12) * prox * decay * 0.35
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Organic wobble: gentle rolling sine based on time and position
|
|||
|
|
let organicWobble = CGFloat(sin(now * 3.0 + (Double(i) * 0.8)) * 0.03)
|
|||
|
|
|
|||
|
|
let totalAmp = max(idleVal, baseAmp + ripple + organicWobble)
|
|||
|
|
return CGPoint(x: x, y: baseline - (totalAmp * h * ampScale))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Layer 1: True index phase-shifted depth — liquid parallax
|
|||
|
|
let ctxDepthOffset = compact ? CGFloat(settings.miniDepthOffset) : CGFloat(settings.depthOffset)
|
|||
|
|
let ctxDepthOpacity = compact ? settings.miniDepthOpacity : settings.depthOpacity
|
|||
|
|
let depthPts = points.enumerated().map { i, pt -> CGPoint in
|
|||
|
|
let shiftedIndex = (i + 2) % points.count
|
|||
|
|
let shiftedPt = points[shiftedIndex]
|
|||
|
|
return CGPoint(x: pt.x, y: shiftedPt.y + ctxDepthOffset + 5)
|
|||
|
|
}
|
|||
|
|
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 == .siri
|
|||
|
|
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 == .siri
|
|||
|
|
|
|||
|
|
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
|
|||
|
|
|
|||
|
|
// Bar extends from top to true bottom of screen
|
|||
|
|
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: fillColors),
|
|||
|
|
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 == .siri
|
|||
|
|
|
|||
|
|
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: - Compact Visualizer (for mini player)
|
|||
|
|
struct CompactVisualizerView: View {
|
|||
|
|
let isPlaying: Bool
|
|||
|
|
let accentColor: Color
|
|||
|
|
let height: CGFloat
|
|||
|
|
|
|||
|
|
var body: some View {
|
|||
|
|
MitsuhaVisualizerView(
|
|||
|
|
isPlaying: isPlaying,
|
|||
|
|
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 .siri: Text("Rainbow gradient (pink → green → cyan → purple → white) applied horizontally across the wave.")
|
|||
|
|
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.05, format: "%.2f")
|
|||
|
|
sliderRow("Frequency cutoff", value: $settings.frequencyCutoff, range: 40...200, step: 10)
|
|||
|
|
sliderRowDouble("Base multiplier", value: $settings.baseMultiplier, range: 10.0...100.0, step: 5.0, format: "%.0f")
|
|||
|
|
sliderRowDouble("Sensitivity", value: $settings.sensitivity, range: 0.1...4.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)
|
|||
|
|
} header: { Text("SHARED / ADVANCED") } footer: {
|
|||
|
|
Text("Viscosity is the most important slider — it controls how quickly the wave reacts. 0.15–0.25 = heavy, slow liquid (cinematic). 0.5 = responsive. 0.8+ = snappy EQ meter.\n\nSensitivity multiplies the incoming audio. Push up if the wave looks flat.\n\nBase multiplier is a second gain stage for FFT data. Higher values reveal quiet passages.\n\nFrequency cutoff limits how many FFT bins are used. At 80 (default), mostly bass/mids. At 150+, treble detail like hi-hats shows up.\n\nReal Audio Analysis uses FFT on downloaded songs. Streams always use simulated animation.\n\nFPS drops to 24 automatically in Low Power Mode.")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 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.15; settings.sensitivity = 2.0
|
|||
|
|
settings.npAmplitude = 0.8; settings.depthOffset = 30; settings.frequencyCutoff = 50
|
|||
|
|
}
|
|||
|
|
private func applyReactiveEQ() {
|
|||
|
|
settings.numberOfPoints = 20; settings.viscosity = 0.7; settings.sensitivity = 1.5
|
|||
|
|
settings.npAmplitude = 0.5; settings.depthOffset = 5; settings.frequencyCutoff = 150
|
|||
|
|
}
|
|||
|
|
private func applySubtleAmbient() {
|
|||
|
|
settings.numberOfPoints = 8; settings.viscosity = 0.20; settings.sensitivity = 1.0
|
|||
|
|
settings.npAmplitude = 0.25; settings.alpha = 0.3; settings.idleAmplitude = 0.05
|
|||
|
|
}
|
|||
|
|
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 = 10; settings.sensitivity = 1.5
|
|||
|
|
settings.fps = 60; settings.realAudioAnalysis = true; settings.waveOffsetTop = 0
|
|||
|
|
settings.barSpacing = 5; settings.barCornerRadius = 0; settings.lineThickness = 5
|
|||
|
|
settings.colorMode = .dynamic; settings.alpha = 0.6; settings.viscosity = 0.25
|
|||
|
|
settings.frequencyCutoff = 80; settings.baseMultiplier = 40.0
|
|||
|
|
settings.depthOffset = 15.0; settings.depthOpacity = 0.2; 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.45; 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.5, 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: 1, 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.15, 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)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|