2026-03-28 13:49:47 -07:00
import SwiftUI
// MARK: - V i s u a l i z e r S e t t i n g s ( M i t s u h a I n f i n i t y p a r i t y + a d v a n c e d p h y s i c s )
class VisualizerSettings : ObservableObject {
static let shared = VisualizerSettings ( )
// G e n e r a l
@ 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 ) } }
2026-04-04 23:17:47 -07:00
@ Published var dynamicGainEnabled : Bool { didSet { save ( " vis_dynamic_gain " , dynamicGainEnabled ) } }
2026-03-28 13:49:47 -07:00
// W a v e
@ Published var waveOffsetTop : Double { didSet { save ( " vis_wave_offset " , waveOffsetTop ) } }
// B a r
@ Published var barSpacing : Double { didSet { save ( " vis_bar_spacing " , barSpacing ) } }
@ Published var barCornerRadius : Double { didSet { save ( " vis_bar_radius " , barCornerRadius ) } }
// L i n e
@ Published var lineThickness : Double { didSet { save ( " vis_line_thick " , lineThickness ) } }
// C o l o r
@ Published var colorMode : ColorMode { didSet { save ( " vis_color " , colorMode . rawValue ) } }
@ Published var alpha : Double { didSet { save ( " vis_alpha " , alpha ) } }
@ Published var customColor : Color = . pink
// A d v a n c e d P h y s i c s
@ 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 ) } }
// L a y o u t
@ Published var nowPlayingHeightPct : Double { didSet { save ( " vis_np_height " , nowPlayingHeightPct ) } }
@ Published var miniPlayerHeight : Double { didSet { save ( " vis_mini_height " , miniPlayerHeight ) } }
// M i n i P l a y e r o v e r r i d e s
@ 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 ) } }
// N o w P l a y i n g o v e r r i d e s
@ 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
2026-04-04 23:17:47 -07:00
dynamicGainEnabled = d . object ( forKey : " vis_dynamic_gain " ) as ? Bool ? ? true
2026-03-28 13:49:47 -07:00
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 } ( )
// A d v a n c e d
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 } ( )
// L a y o u t
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 } ( )
// M i n i P l a y e r
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
// N o w P l a y i n g
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 ) {
// D e b o u n c e : u p d a t e l o c a l v a r i n s t a n t l y f o r U I , d e l a y d i s k w r i t e
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 > ?
}
2026-04-04 06:58:58 -07:00
// MARK: - W a v e S t a t e C a c h e ( s h a r e d b e t w e e n m i n i p l a y e r a n d D I f o r m o r p h c o n t i n u i t y )
// / S i n g l e s o u r c e o f t r u t h f o r c o m p a c t v i s u a l i z e r l e v e l s .
// / B o t h M i n i P l a y e r B a r a n d D y n a m i c I s l a n d V i e w s e e d f r o m h e r e o n a p p e a r ,
// / s o t h e w a v e d o e s n ' t r e s e t t o i d l e w h e n m a t c h e d G e o m e t r y E f f e c t t r a n s i t i o n s b e t w e e n t h e m .
final class WaveStateCache {
static let shared = WaveStateCache ( )
var compactLevels : [ Float ] = [ ]
private init ( ) { }
}
2026-04-04 23:17:47 -07:00
// MARK: - L e v e l B o x
2026-04-05 08:51:26 -07:00
// / P e r - i n s t a n c e m u t a b l e r e n d e r s t a t e . Z e r o @ P u b l i s h e d p r o p e r t i e s → n o o b s e r v a t i o n w a r n i n g s .
2026-04-04 23:17:47 -07:00
fileprivate final class VisualizerLevelBox : ObservableObject {
var displayLevels : [ Float ] = [ ]
var peakFollower : Float = 0.01
var levelHistoryBuf : [ [ Float ] ] = [ ]
var historyWriteIdx : Int = 0
var wobblePhaseOffset : Double = 0
2026-04-05 08:51:26 -07:00
var lastTickTime : CFTimeInterval = 0
2026-04-04 23:17:47 -07:00
}
2026-03-28 13:49:47 -07:00
// MARK: - M a i n V i s u a l i z e r V i e w
struct MitsuhaVisualizerView : View {
var previewLevels : [ Float ] ? = nil
let isPlaying : Bool
2026-04-05 08:33:00 -07:00
var isSongLoaded : Bool = true
2026-03-28 13:49:47 -07:00
let accentColor : Color
var compact : Bool = false
2026-04-04 23:17:47 -07:00
2026-03-28 13:49:47 -07:00
@ ObservedObject var settings = VisualizerSettings . shared
@ ObservedObject var albumColors = AlbumColorExtractor . shared
2026-04-04 23:17:47 -07:00
2026-04-05 12:05:20 -07:00
@ StateObject private var box = VisualizerLevelBox ( )
2026-04-04 23:17:47 -07:00
2026-04-05 08:51:26 -07:00
private static let historySize = 16
2026-04-04 23:17:47 -07:00
2026-03-28 13:49:47 -07:00
var body : some View {
2026-04-05 22:47:57 -07:00
// T i m e l i n e V i e w ( . a n i m a t i o n ) f i r e s o n e v e r y a n i m a t i o n f r a m e a s l o n g a s t h e
// v i e w i s o n s c r e e n . I t i s N O T g a t e d o n i s P l a y i n g — i t r u n s d u r i n g p a u s e ,
// d u r i n g f a d e a n i m a t i o n s , d u r i n g s h e e t p r e s e n t a t i o n s , a l w a y s .
// L e v e l s a r e p o l l e d d i r e c t l y i n s i d e t h e C a n v a s v i a A u d i o P l a y e r . s h a r e d . c u r r e n t L e v e l s ( )
// w h i c h i s n o n i s o l a t e d ( u n s a f e ) — s a f e t o c a l l f r o m a n y t h r e a d w i t h n o a c t o r h o p p i n g .
// T h i s e l i m i n a t e s e v e r y @ O b s e r v e d O b j e c t d e p e n d e n c y c h a i n f a i l u r e m o d e .
TimelineView ( . animation ) { timeline in
2026-04-04 07:05:25 -07:00
if settings . enabled {
2026-04-05 08:51:26 -07:00
Canvas { context , size in
2026-04-05 22:47:57 -07:00
// t i m e l i n e . d a t e c h a n g e s e v e r y f r a m e — S w i f t U I m u s t r e - e x e c u t e t h i s c l o s u r e .
let t = timeline . date . timeIntervalSinceReferenceDate
2026-04-05 08:51:26 -07:00
let rawLevels : [ Float ] = isPlaying
2026-04-05 22:47:57 -07:00
? ( previewLevels ? ? AudioPlayer . shared . currentLevels ( ) )
2026-04-05 08:51:26 -07:00
: Array ( repeating : Float ( 0 ) , count : max ( settings . numberOfPoints , 1 ) )
updateDisplayLevels ( newRawLevels : rawLevels )
2026-04-05 22:47:57 -07:00
updateWobblePhase ( t : t )
2026-04-04 23:17:47 -07:00
2026-04-05 08:51:26 -07:00
let pts = box . displayLevels . isEmpty
? Array ( repeating : Float ( settings . idleAmplitude ) , count : settings . numberOfPoints )
: box . displayLevels
guard pts . count >= 2 else { return }
switch settings . style {
case . wave : drawWave ( ctx : context , size : size , levels : pts , continuousTime : box . wobblePhaseOffset )
case . bar : drawBars ( ctx : context , size : size , levels : pts )
case . line : drawLine ( ctx : context , size : size , levels : pts )
2026-03-28 13:49:47 -07:00
}
2026-04-04 07:05:25 -07:00
}
2026-04-05 08:33:00 -07:00
. opacity ( isPlaying ? 1.0 : ( isSongLoaded ? 0.35 : 1.0 ) )
. animation ( isSongLoaded ? . easeInOut ( duration : 0.6 ) : nil , value : isPlaying )
2026-04-04 07:05:25 -07:00
. onAppear {
let cached = compact ? WaveStateCache . shared . compactLevels : [ ]
2026-04-04 23:17:47 -07:00
box . displayLevels = cached . isEmpty
2026-04-04 07:05:25 -07:00
? Array ( repeating : Float ( settings . idleAmplitude ) , count : settings . numberOfPoints )
: cached
2026-03-28 13:49:47 -07:00
}
}
}
}
2026-04-05 08:51:26 -07:00
// MARK: - W o b b l e P h a s e
2026-04-04 23:17:47 -07:00
2026-04-05 22:47:57 -07:00
private func updateWobblePhase ( t : Double ) {
2026-04-05 08:51:26 -07:00
if box . lastTickTime > 0 {
2026-04-05 22:47:57 -07:00
let delta = t - box . lastTickTime
2026-04-04 23:17:47 -07:00
box . wobblePhaseOffset += min ( delta , 0.1 )
2026-03-28 13:49:47 -07:00
}
2026-04-05 22:47:57 -07:00
box . lastTickTime = t
2026-03-28 13:49:47 -07:00
}
// MARK: - T e m p o r a l S m o o t h i n g & L o g B i n n i n g
2026-04-05 08:33:00 -07:00
@ discardableResult
private func updateDisplayLevels ( newRawLevels : [ Float ] ) -> Bool {
2026-03-28 13:49:47 -07:00
let count = settings . numberOfPoints
2026-04-05 08:33:00 -07:00
guard count > 0 , ! newRawLevels . isEmpty else { return false }
2026-03-28 13:49:47 -07:00
let sens = Float ( settings . sensitivity )
let isPreProcessed = AudioPlayer . shared . isUsingOfflineVis
var targetLevels : [ Float ]
if isPreProcessed {
// O f f l i n e v i s f r a m e s a r e a l r e a d y n o r m a l i z e d ( p e a k ≈ 0 . 8 ) a n d l o g - b i n n e d .
// O n l y a p p l y s e n s i t i v i t y f o r u s e r c o n t r o l — n o b a s e M u l t i p l i e r n e e d e d .
if newRawLevels . count = = count {
targetLevels = newRawLevels . map { min ( 1.0 , $0 * sens ) }
} else {
// R e s a m p l e t o m a t c h d i s p l a y p o i n t c o u n t v i a l i n e a r i n t e r p o l a t i o n
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 {
// R a w F F T b i n s o r s i m u l a t e d l e v e l s — f u l l l o g b i n n i n g + E Q b o o s t
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 )
}
}
2026-04-05 22:47:57 -07:00
// T e m p o r a l S m o o t h i n g — d e l t a - t i m e b a s e d s o i t ' s f r a m e - r a t e i n d e p e n d e n t .
// U s e e l a p s e d t i m e s i n c e l a s t t i c k t o c o m p u t e s m o o t h F a c t o r r a t h e r t h a n
// a s s u m i n g a f i x e d f p s — t h i s p r e v e n t s l a g w h e n r u n n i n g a t 6 0 / 1 2 0 f p s .
let dt = Float ( max ( box . lastTickTime > 0 ? min ( CACurrentMediaTime ( ) - box . lastTickTime , 0.1 ) : 1.0 / 60.0 , 0.001 ) )
// v i s c o s i t y 0 . 0 5 = v e r y s l o w , 1 . 0 = i n s t a n t . S c a l e b y 6 0 * d t s o b e h a v i o u r
// a t 6 0 f p s m a t c h e s t h e o r i g i n a l f p s S c a l e = 1 v i s c o s i t y s e t t i n g s .
let smoothFactor = min ( Float ( settings . viscosity ) * 60.0 * dt , 1.0 )
2026-04-04 23:17:47 -07:00
// ─ ─ D y n a m i c G a i n / P e a k F o l l o w e r ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
let frameMax = targetLevels . max ( ) ? ? 0.0
if frameMax > box . peakFollower {
box . peakFollower = box . peakFollower + ( frameMax - box . peakFollower ) * 0.3
} else {
box . peakFollower = max ( box . peakFollower * 0.997 , 0.005 )
}
if settings . dynamicGainEnabled {
let normFactor = Float ( 1.0 ) / max ( box . peakFollower , 0.005 )
targetLevels = targetLevels . map { min ( $0 * normFactor , 1.0 ) }
}
if box . displayLevels . count != count {
box . displayLevels = targetLevels
2026-03-28 13:49:47 -07:00
} else {
for i in 0. . < count {
2026-04-04 23:17:47 -07:00
box . displayLevels [ i ] = box . displayLevels [ i ] + ( targetLevels [ i ] - box . displayLevels [ i ] ) * smoothFactor
2026-03-28 13:49:47 -07:00
}
}
2026-04-04 23:17:47 -07:00
// ─ ─ R i n g B u f f e r f o r t e m p o r a l d e p t h g h o s t ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
if box . levelHistoryBuf . count < MitsuhaVisualizerView . historySize {
box . levelHistoryBuf . append ( box . displayLevels )
} else {
box . levelHistoryBuf [ box . historyWriteIdx ] = box . displayLevels
box . historyWriteIdx = ( box . historyWriteIdx + 1 ) % MitsuhaVisualizerView . historySize
}
2026-04-04 06:58:58 -07:00
// C a c h e c o m p a c t l e v e l s s o D I m o r p h p i c k s u p c u r r e n t w a v e s h a p e
2026-04-04 23:17:47 -07:00
if compact { WaveStateCache . shared . compactLevels = box . displayLevels }
2026-04-05 08:33:00 -07:00
return true
2026-03-28 13:49:47 -07:00
}
// MARK: - C o l o r s
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: - C a t m u l l - R o m S p l i n e ( t e n s i o n 0 . 3 = h e a v y l i q u i d s u r f a c e t e n s i o n )
// / T e n s i o n : 0 . 3 g i v e s r o u n d e d , r o l l i n g p e a k s . L o w e r = t i g h t e r . H i g h e r = b o u n c i e r .
private let curveTension : CGFloat = 0.3
// / C u r v e p a t h w i t h o u t i n i t i a l m o v e T o — u s e d f o r f i l l s h a p e s
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
}
// / F u l l c u r v e p a t h w i t h m o v e T o — u s e d f o r s t r o k e s
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: - W a v e
2026-04-04 23:17:47 -07:00
private func drawWave ( ctx : GraphicsContext , size : CGSize , levels : [ Float ] , continuousTime : Double ) {
2026-03-28 13:49:47 -07:00
let w = size . width
let h = size . height
let idleVal = CGFloat ( compact ? settings . miniIdleAmplitude : settings . idleAmplitude )
guard levels . count >= 2 else { return }
2026-04-04 23:17:47 -07:00
let count = levels . count
let spacing = w / CGFloat ( count - 1 )
let now = continuousTime
2026-03-28 13:49:47 -07:00
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 )
2026-04-04 23:17:47 -07:00
var points = levels . enumerated ( ) . map { i , lev -> CGPoint in
2026-03-28 13:49:47 -07:00
let x = CGFloat ( i ) * spacing
let baseAmp = CGFloat ( lev )
let organicWobble = CGFloat ( sin ( now * 3.0 + ( Double ( i ) * 0.8 ) ) * 0.03 )
2026-04-04 23:17:47 -07:00
let totalAmp = max ( idleVal , baseAmp + organicWobble )
2026-03-28 13:49:47 -07:00
return CGPoint ( x : x , y : baseline - ( totalAmp * h * ampScale ) )
}
2026-04-04 23:17:47 -07:00
// ─ ─ E n d p o i n t A n c h o r i n g ( i m p r o v e m e n t # 1 3 ) ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
// P i n f i r s t a n d l a s t p o i n t s e x a c t l y t o b a s e l i n e s o t h e w a v e c l e a n l y
// r i s e s f r o m a n d r e t u r n s t o t h e h o r i z o n — m a t c h i n g t h e o r i g i n a l t w e a k .
points [ 0 ] = CGPoint ( x : 0 , y : baseline )
points [ count - 1 ] = CGPoint ( x : w , y : baseline )
// ─ ─ L a y e r 1 : T e m p o r a l d e p t h g h o s t ( i m p r o v e m e n t # 1 1 ) ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
// U s e t h e o l d e s t r i n g - b u f f e r f r a m e ( ~ 2 5 0 m s a g o ) f o r t h e s h a d o w w a v e ,
// m a t c h i n g t h e o r i g i n a l ' s 0 . 2 5 s d i s p a t c h _ a f t e r d e l a y e x a c t l y .
2026-03-28 13:49:47 -07:00
let ctxDepthOffset = compact ? CGFloat ( settings . miniDepthOffset ) : CGFloat ( settings . depthOffset )
let ctxDepthOpacity = compact ? settings . miniDepthOpacity : settings . depthOpacity
2026-04-04 23:17:47 -07:00
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
// S a m p l e t h e g h o s t f r a m e a t t h e s a m e n o r m a l i s e d p o s i t i o n
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 )
2026-03-28 13:49:47 -07:00
}
2026-04-04 23:17:47 -07:00
// A n c h o r g h o s t e n d p o i n t s t o o
depthPts [ 0 ] = CGPoint ( x : 0 , y : baseline + ctxDepthOffset )
depthPts [ count - 1 ] = CGPoint ( x : w , y : baseline + ctxDepthOffset )
2026-03-28 13:49:47 -07:00
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 ) ) )
// L a y e r 2 : M a i n f i l l — g r a d i e n t f r o m w a v e c r e s t t o t r u e b o t t o m
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 )
) )
}
// L a y e r 3 : S t r o k e l i n e
ctx . stroke ( strokeableCurve ( points ) , with : . color ( strokeColor ) , lineWidth : CGFloat ( settings . waveStrokeThickness ) )
}
// MARK: - B a r s
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
// B a r e x t e n d s f r o m t o p t o t r u e b o t t o m o f s c r e e n
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: - L i n e
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 )
// G l o w
ctx . stroke ( curve , with : . color ( strokeColor . opacity ( 0.3 ) ) , style : StrokeStyle ( lineWidth : thick + 6 , lineCap : . round , lineJoin : . round ) )
// C o r e l i n e
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: - C o m p a c t V i s u a l i z e r ( f o r m i n i p l a y e r )
struct CompactVisualizerView : View {
let isPlaying : Bool
2026-04-05 08:33:00 -07:00
var isSongLoaded : Bool = true
2026-03-28 13:49:47 -07:00
let accentColor : Color
let height : CGFloat
2026-04-05 08:33:00 -07:00
2026-03-28 13:49:47 -07:00
var body : some View {
MitsuhaVisualizerView (
isPlaying : isPlaying ,
2026-04-05 08:33:00 -07:00
isSongLoaded : isSongLoaded ,
2026-03-28 13:49:47 -07:00
accentColor : accentColor ,
compact : true
)
. frame ( height : height )
}
}
// MARK: - V i s u a l i z e r S e t t i n g s V i e w
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 )
2026-04-04 23:17:47 -07:00
Toggle ( " Dynamic Gain " , isOn : $ settings . dynamicGainEnabled ) . tint ( pink )
2026-03-28 13:49:47 -07:00
} header : { Text ( " SHARED / ADVANCED " ) } footer : {
2026-04-04 23:17:47 -07:00
Text ( " Viscosity controls how quickly the wave reacts. 0.15– 0.25 = heavy liquid. 0.5 = responsive. 0.8+ = snappy EQ. \n \n Sensitivity multiplies incoming audio. Base multiplier is a second gain stage for FFT. \n \n Dynamic Gain automatically normalises the wave amplitude across loud and quiet tracks — keeps the wave full on soft passages without clipping on loud ones. \n \n FPS drops to 24 in Low Power Mode. " )
2026-03-28 13:49:47 -07:00
}
// P r e s e t s
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: - P r e s e t s
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: - S l i d e r H e l p e r s
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
2026-04-04 23:17:47 -07:00
settings . fps = 60 ; settings . realAudioAnalysis = true ; settings . dynamicGainEnabled = true
settings . waveOffsetTop = 0
2026-03-28 13:49:47 -07:00
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: - N o w P l a y i n g S u b - S e t t i n g s
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 \n Amplitude 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 \n Base 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 \n Wave 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 \n Depth opacity sets how visible the shadow wave is. At 0.2, it's subtle. At 0, invisible. \n \n Idle 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: - M i n i P l a y e r S u b - S e t t i n g s
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 \n Amplitude 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 \n Opacity 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 \n Idle 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 )
}
}
}
}