Imrpved Nowplaying/Visualizer

This commit is contained in:
Dallas Groot 2026-04-09 23:11:40 -07:00
parent 5fc531b888
commit 9bf94c90b7
3 changed files with 127 additions and 26 deletions

View file

@ -18,13 +18,11 @@ class AudioPlayer: NSObject, ObservableObject {
@Published var queue: [Song] = []
@Published var queueIndex: Int = 0
@Published var isPlaying = false
// currentTime and duration are NOT @Published updating them 10x/sec
// would cause every view with @EnvironmentObject audioPlayer to re-render,
// killing gesture recognizers across the entire app.
// Views that need these (NowPlayingView, MiniPlayerBar) poll them directly
// via their own Timer/TimelineView.
var currentTime: TimeInterval = 0
var duration: TimeInterval = 0
// currentTime and duration are @Published but set only from addPeriodicTimeObserver
// on the main queue safe for SwiftUI observation. NowPlayingView binds directly,
// eliminating the Timer.publish poller that caused runloop starvation.
@Published var currentTime: TimeInterval = 0
@Published var duration: TimeInterval = 0
@Published var volume: Float = 0.8
@Published var repeatMode: RepeatMode = .off
@Published var shuffleEnabled = false

View file

@ -213,10 +213,10 @@ struct NowPlayingView: View {
@State private var dragOffset: CGFloat = 0
// Local time polling avoids @Published currentTime triggering global redraws
@State private var playbackTime: TimeInterval = 0
@State private var playbackDuration: TimeInterval = 0
private let timePoller = Timer.publish(every: 0.1, on: .main, in: .common).autoconnect()
// Bound directly to @Published currentTime/duration on AudioPlayer.
// No Timer.publish poller addPeriodicTimeObserver pushes updates on main queue.
private var playbackTime: TimeInterval { audioPlayer.currentTime }
private var playbackDuration: TimeInterval { audioPlayer.duration }
@Environment(\.horizontalSizeClass) private var hSizeClass
@Environment(\.verticalSizeClass) private var vSizeClass
@ -319,10 +319,6 @@ struct NowPlayingView: View {
.onChange(of: audioPlayer.currentSong?.starred) { _, newVal in
isStarred = newVal != nil
}
.onReceive(timePoller) { _ in
playbackTime = audioPlayer.currentTime
playbackDuration = audioPlayer.duration
}
}
// MARK: - Portrait Layout
@ -811,7 +807,7 @@ struct NowPlayingView: View {
let playheadPct: CGFloat = {
if isDraggingSlider { return dragPosition }
if audioPlayer.isPlayingFromBuffer {
// playbackTime is updated from audioPlayer.currentTime by timePoller
// playbackTime tracks audioPlayer.currentTime via @Published binding
return CGFloat(min(playbackTime / maxSec, fillPct))
}
return fillPct // live edge

View file

@ -58,12 +58,13 @@ class VisualizerSettings: ObservableObject {
case wave = "Wave"
case bar = "Bar"
case line = "Line"
case siriWave = "Siri"
}
enum ColorMode: String, CaseIterable {
case dynamic = "Dynamic"
case albumArt = "Album Art"
case siri = "Siri"
case vibrant = "Vibrant"
case custom = "Custom"
}
@ -189,9 +190,10 @@ struct MitsuhaVisualizerView: View {
: 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)
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)
case .siriWave: drawSiriWave(ctx: context, size: size, levels: pts, continuousTime: box.wobblePhaseOffset)
}
}
.opacity(isPlaying ? 1.0 : (isSongLoaded ? 0.35 : 1.0))
@ -202,6 +204,16 @@ struct MitsuhaVisualizerView: View {
? Array(repeating: Float(settings.idleAmplitude), count: settings.numberOfPoints)
: cached
}
.onChange(of: isPlaying) { _, playing in
if !playing {
// Purge history so the depth ghost doesn't show stale frames
// after resume. Forces the ring buffer to rebuild from scratch
// on the next play tick no "ghost" of the pre-pause wave.
box.levelHistoryBuf.removeAll()
box.historyWriteIdx = 0
box.peakFollower = 0.01
}
}
}
}
}
@ -321,7 +333,7 @@ struct MitsuhaVisualizerView: View {
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 .vibrant: return [.pink.opacity(a), .green.opacity(a), .cyan.opacity(a), .purple.opacity(a), .white.opacity(a * 0.5), .pink.opacity(a)]
case .custom: return [settings.customColor.opacity(a), settings.customColor.opacity(a * 0.6)]
}
}
@ -330,7 +342,7 @@ struct MitsuhaVisualizerView: View {
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 .vibrant: return Color.cyan.opacity(min(1, settings.alpha + 0.3))
case .custom: return settings.customColor.opacity(min(1, settings.alpha + 0.3))
}
}
@ -466,7 +478,7 @@ struct MitsuhaVisualizerView: View {
fill.addLine(to: CGPoint(x: w, y: h))
fill.closeSubpath()
let isSiri = settings.colorMode == .siri
let isSiri = settings.colorMode == .vibrant
if isSiri {
ctx.fill(fill, with: .linearGradient(
Gradient(colors: fillColors),
@ -502,7 +514,7 @@ struct MitsuhaVisualizerView: View {
let cr = CGFloat(settings.barCornerRadius)
let totalGapSpace = gap * CGFloat(count - 1)
let barW = max(1, (w - totalGapSpace) / CGFloat(count))
let isSiri = settings.colorMode == .siri
let isSiri = settings.colorMode == .vibrant
for i in 0..<count {
let amp = CGFloat(levels[i])
@ -533,7 +545,7 @@ struct MitsuhaVisualizerView: View {
guard levels.count >= 2 else { return }
let spacing = w / CGFloat(levels.count - 1)
let thick = CGFloat(settings.lineThickness)
let isSiri = settings.colorMode == .siri
let isSiri = settings.colorMode == .vibrant
let baseLift: CGFloat = compact ? 0 : CGFloat(settings.npBaseLift)
let offset = compact ? 0 : CGFloat(settings.waveOffsetTop)
@ -563,6 +575,101 @@ struct MitsuhaVisualizerView: View {
ctx.stroke(curve, with: .color(strokeColor), style: strokeStyle)
}
}
// MARK: - Siri Wave
/// Authentic multi-layer Siri waveform.
///
/// Five stroked paths are drawn over the same level data. Each layer has:
/// - A distinct phase offset so the peaks weave past each other organically.
/// - A distinct amplitude multiplier (some negative = inverted) creating the
/// crossing / figure-eight structure visible in the original Mitsuha Infinity.
/// - A sine-window (bell-curve) attenuation across the width: the wave is forced
/// to zero at both edges and reaches full amplitude only in the centre.
/// Mathematically: attenuation(x) = sin(π * x/w) which is 0 at x=0, peaks at 1
/// at x=w/2, and returns to 0 at x=w.
/// - `plusLighter` blend mode so where two bright lines overlap they add their
/// luminance, creating glowing intersection nodes like the original.
private func drawSiriWave(ctx: GraphicsContext, size: CGSize, levels: [Float], continuousTime: Double) {
let w = size.width
let h = size.height
guard levels.count >= 2 else { return }
let baseLift: CGFloat = compact ? 0 : CGFloat(settings.npBaseLift)
let waveOffset = compact ? 0 : CGFloat(settings.waveOffsetTop)
let centerY = compact ? h * 0.5 : h - baseLift - waveOffset
let baseAmp: CGFloat = compact
? CGFloat(settings.miniAmplitude) * h * 0.4
: CGFloat(settings.npAmplitude) * h * 0.38
// Five layers: (phase offset in radians, amplitude multiplier, opacity, stroke width)
let layers: [(phase: Double, ampMult: CGFloat, opacity: Double, width: CGFloat)] = [
(phase: 0.0, ampMult: 1.00, opacity: 0.90, width: 2.5), // core
(phase: .pi * 0.35, ampMult: 0.60, opacity: 0.65, width: 1.8), // support A
(phase: .pi * 0.70, ampMult: 0.30, opacity: 0.50, width: 1.4), // support B
(phase: .pi * 1.10, ampMult: -0.40, opacity: 0.45, width: 1.2), // inverted A
(phase: .pi * 1.55, ampMult: -0.20, opacity: 0.30, width: 1.0), // inverted B
]
let count = levels.count
let spacing = w / CGFloat(count - 1)
// Build siri colour gradient once
let siriColors: [Color] = [.pink, .purple, .cyan, .green, .pink]
let useVibrant = settings.colorMode == .vibrant
let a = settings.alpha
for layer in layers {
var points: [CGPoint] = []
points.reserveCapacity(count)
for i in 0..<count {
let x = CGFloat(i) * spacing
let nx = x / w // normalised 01
// Sine-window attenuation zero at edges, 1 at centre
let attenuation = CGFloat(sin(.pi * nx))
// Sample level at this x with per-layer phase offset applied as a
// time warp: the effective sample position shifts along the level array
// by (phase / 2π) * count, wrapped with linear interpolation.
let shift = layer.phase / (2 * .pi) * Double(count - 1)
let rawPos = Double(i) + shift
let lo = Int(rawPos) % count
let hi = (lo + 1) % count
let frac = CGFloat(rawPos - floor(rawPos))
let sampledLevel = CGFloat(levels[lo]) * (1 - frac) + CGFloat(levels[hi]) * frac
// Organic wobble using continuous time + per-layer phase
let wobble = CGFloat(sin(continuousTime * 3.0 + Double(i) * 0.8 + layer.phase) * 0.03)
let totalAmp = (sampledLevel + wobble) * layer.ampMult * attenuation * baseAmp
points.append(CGPoint(x: x, y: centerY - totalAmp))
}
let curve = strokeableCurve(points)
let style = StrokeStyle(lineWidth: layer.width, lineCap: .round, lineJoin: .round)
// Draw with plusLighter blend mode for additive glow at intersections
ctx.drawLayer { layerCtx in
layerCtx.blendMode = .plusLighter
if useVibrant {
layerCtx.stroke(curve, with: .linearGradient(
Gradient(colors: siriColors.map { $0.opacity(layer.opacity * a) }),
startPoint: .zero,
endPoint: CGPoint(x: w, y: 0)
), style: style)
} else {
let c: Color
switch settings.colorMode {
case .albumArt: c = AlbumColorExtractor.shared.primaryColor
case .custom: c = settings.customColor
default: c = accentColor
}
layerCtx.stroke(curve, with: .color(c.opacity(layer.opacity * a)), style: style)
}
}
}
}
}
// MARK: - Compact Visualizer (for mini player)
@ -643,7 +750,7 @@ struct VisualizerSettingsView: View {
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 .vibrant: Text("Rainbow gradient (pink → purple → cyan → green) applied horizontally. Also used for the Siri wave style's multi-layer colours.")
case .custom: Text("Pick any color with the color picker above.")
}
}