Imrpved Nowplaying/Visualizer
This commit is contained in:
parent
5fc531b888
commit
9bf94c90b7
3 changed files with 127 additions and 26 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 0→1
|
||||
|
||||
// Sine-window attenuation — zero at edges, 1 at centre
|
||||
let attenuation = CGFloat(sin(.pi * nx))
|
||||
|
||||
// Sample level at this x with per-layer phase offset applied as a
|
||||
// time warp: the effective sample position shifts along the level array
|
||||
// by (phase / 2π) * count, wrapped with linear interpolation.
|
||||
let shift = layer.phase / (2 * .pi) * Double(count - 1)
|
||||
let rawPos = Double(i) + shift
|
||||
let lo = Int(rawPos) % count
|
||||
let hi = (lo + 1) % count
|
||||
let frac = CGFloat(rawPos - floor(rawPos))
|
||||
let sampledLevel = CGFloat(levels[lo]) * (1 - frac) + CGFloat(levels[hi]) * frac
|
||||
|
||||
// Organic wobble using continuous time + per-layer phase
|
||||
let wobble = CGFloat(sin(continuousTime * 3.0 + Double(i) * 0.8 + layer.phase) * 0.03)
|
||||
let totalAmp = (sampledLevel + wobble) * layer.ampMult * attenuation * baseAmp
|
||||
|
||||
points.append(CGPoint(x: x, y: centerY - totalAmp))
|
||||
}
|
||||
|
||||
let curve = strokeableCurve(points)
|
||||
let style = StrokeStyle(lineWidth: layer.width, lineCap: .round, lineJoin: .round)
|
||||
|
||||
// Draw with plusLighter blend mode for additive glow at intersections
|
||||
ctx.drawLayer { layerCtx in
|
||||
layerCtx.blendMode = .plusLighter
|
||||
if useVibrant {
|
||||
layerCtx.stroke(curve, with: .linearGradient(
|
||||
Gradient(colors: siriColors.map { $0.opacity(layer.opacity * a) }),
|
||||
startPoint: .zero,
|
||||
endPoint: CGPoint(x: w, y: 0)
|
||||
), style: style)
|
||||
} else {
|
||||
let c: Color
|
||||
switch 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.")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue