From e50c8a250382e8d041d790e204ec8809879d1031 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Sat, 29 Nov 2025 21:11:38 +0100 Subject: [PATCH] feat(visualizer): add multiple visualizers with mouse tracking and beat response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add BaseVisualizer class for shared visualizer logic - Add Helix visualizer (DNA helix with rungs, beat compression/bounce) - Add Tunnel visualizer (ring tunnel with depth parallax) - Refactor SphereVisualizer to extend BaseVisualizer - Add mouse tracking to all visualizers (canvas-relative, centered) - Add visualizer switching via logo click (persisted to localStorage) - Add beat-responsive bouncing and compression effects 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- assets/js/visualizer/base-visualizer.js | 90 +++++++++++ assets/js/visualizer/helix.js | 201 ++++++++++++++++++++++++ assets/js/visualizer/particles.js | 72 ++++----- assets/js/visualizer/scene.js | 113 ++++++++++++- assets/js/visualizer/tunnel.js | 146 +++++++++++++++++ config/_default/params.toml | 2 +- content/_index.md | 2 +- layouts/_default/baseof.html | 18 +++ layouts/partials/header.html | 20 +-- 9 files changed, 609 insertions(+), 55 deletions(-) create mode 100644 assets/js/visualizer/base-visualizer.js create mode 100644 assets/js/visualizer/helix.js create mode 100644 assets/js/visualizer/tunnel.js diff --git a/assets/js/visualizer/base-visualizer.js b/assets/js/visualizer/base-visualizer.js new file mode 100644 index 0000000..16bbd16 --- /dev/null +++ b/assets/js/visualizer/base-visualizer.js @@ -0,0 +1,90 @@ +/** + * Base Visualizer Class + * Abstract base for all audio visualizers + */ + +import * as THREE from 'three'; + +export class BaseVisualizer { + constructor(scene) { + this.scene = scene; + this.mesh = null; + this.isVisible = false; + } + + /** + * Initialize geometry, materials, and mesh + * Override in subclasses + */ + init() { + throw new Error('init() must be implemented by subclass'); + } + + /** + * Update visualizer with audio data + * @param {Object} bands - { low, mid, high } frequency bands (0-1) + * @param {number} time - Animation time + */ + update(bands, time) { + throw new Error('update() must be implemented by subclass'); + } + + /** + * Add visualizer to scene + */ + show() { + if (this.mesh && !this.isVisible) { + this.scene.add(this.mesh); + this.isVisible = true; + } + } + + /** + * Remove visualizer from scene + */ + hide() { + if (this.mesh && this.isVisible) { + this.scene.remove(this.mesh); + this.isVisible = false; + } + } + + /** + * Cleanup resources + */ + dispose() { + this.hide(); + if (this.mesh) { + this.mesh.geometry?.dispose(); + if (this.mesh.material) { + if (Array.isArray(this.mesh.material)) { + this.mesh.material.forEach(m => m.dispose()); + } else { + this.mesh.material.dispose(); + } + } + } + } + + /** + * Helper: Create shader material with common settings + */ + createShaderMaterial(vertexShader, fragmentShader, uniforms = {}) { + return new THREE.ShaderMaterial({ + vertexShader, + fragmentShader, + uniforms: { + uTime: { value: 0 }, + uLow: { value: 0 }, + uMid: { value: 0 }, + uHigh: { value: 0 }, + ...uniforms + }, + transparent: true, + blending: THREE.AdditiveBlending, + depthWrite: false + }); + } +} + +export default BaseVisualizer; diff --git a/assets/js/visualizer/helix.js b/assets/js/visualizer/helix.js new file mode 100644 index 0000000..f1d8968 --- /dev/null +++ b/assets/js/visualizer/helix.js @@ -0,0 +1,201 @@ +/** + * DNA Helix Visualizer + * Immersive multi-layered double helix that fills the screen + */ + +import * as THREE from 'three'; +import { BaseVisualizer } from './base-visualizer.js'; + +const vertexShader = ` +uniform float uTime; +uniform float uLow; +uniform float uMid; +uniform float uHigh; +uniform float uMouseX; +uniform float uMouseY; + +attribute float aStrand; // 0 or 1 for which strand +attribute float aProgress; // 0-1 position along helix +attribute float aIsRung; // 1 if this is a connecting rung particle +attribute float aLayer; // Which helix layer (0, 1, 2 for nested helixes) + +varying float vAlpha; +varying float vStrand; +varying float vLayer; + +void main() { + vec3 pos = position; + + // Layer-based radius - nested helixes + float baseRadius = 8.0 + aLayer * 12.0; // Inner: 8, Mid: 20, Outer: 32 + float helixRadius = baseRadius + uLow * 6.0; // Bass expands all layers + float helixHeight = 100.0 * (1.0 - uLow * 0.3); // Compress with bass + float twists = 5.0 - aLayer * 1.0; // Inner twists more + + // Rotation speed - layers rotate at different speeds + float layerSpeed = 1.0 - aLayer * 0.2; + float rotationSpeed = (0.6 + uMid * 2.0) * layerSpeed; + float timeOffset = uTime * rotationSpeed; + + // Reverse rotation for alternate layers + if (mod(aLayer, 2.0) > 0.5) { + timeOffset = -timeOffset; + } + + // Calculate helix position + float t = aProgress; + float angle = t * twists * 6.28318 + timeOffset; + + // Offset for second strand + if (aStrand > 0.5 && aIsRung < 0.5) { + angle += 3.14159; + } + + // Base helix position - along Z-axis (viewer inside helix) + float x = cos(angle) * helixRadius; + float y = sin(angle) * helixRadius; + float z = (t - 0.5) * helixHeight; + + // Rungs connect the two strands + if (aIsRung > 0.5) { + float rungAngle = t * twists * 6.28318 + timeOffset; + float rungT = fract(aStrand * 10.0); + float angle1 = rungAngle; + float angle2 = rungAngle + 3.14159; + + x = mix(cos(angle1), cos(angle2), rungT) * helixRadius; + y = mix(sin(angle1), sin(angle2), rungT) * helixRadius; + + // Rungs pulse dramatically with beat + float pulse = 1.0 + uLow * 0.5; + x *= pulse; + y *= pulse; + } + + // High frequencies add jitter/scatter - more on outer layers + float jitterAmount = uHigh * (3.0 + aLayer * 2.0); + x += sin(t * 40.0 + uTime * 8.0 + aLayer) * jitterAmount; + y += cos(t * 40.0 + uTime * 8.0) * jitterAmount * 0.5; + z += sin(t * 40.0 + uTime * 8.0 + 1.57 + aLayer) * jitterAmount; + + // Mouse offset - helix follows mouse position subtly + x -= uMouseX * 1.0; + y -= uMouseY * 0.7; + + // Bounce toward viewer with bass + z += uLow * 10.0; + + pos = vec3(x, y, z); + + vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0); + + // Point size - larger for outer layers + float size = 3.0 + aLayer * 1.0; + if (aIsRung > 0.5) { + size = 2.0 + uLow * 1.5; + } + size *= (1.0 + uLow * 0.3); + gl_PointSize = size * (300.0 / -mvPosition.z); + + // Alpha - outer layers slightly more transparent + vAlpha = 0.8 - aLayer * 0.1 + uLow * 0.2; + if (aIsRung > 0.5) { + vAlpha *= 0.7; + } + + vStrand = aStrand; + vLayer = aLayer; + + gl_Position = projectionMatrix * mvPosition; +} +`; + +const fragmentShader = ` +varying float vAlpha; +varying float vStrand; +varying float vLayer; + +void main() { + float dist = length(gl_PointCoord - vec2(0.5)); + if (dist > 0.5) discard; + + float alpha = 1.0 - smoothstep(0.1, 0.5, dist); + alpha *= vAlpha; + + // Pure white + vec3 color = vec3(1.0); + + gl_FragColor = vec4(color, alpha); +} +`; + +export class HelixVisualizer extends BaseVisualizer { + static name = 'Helix'; + + constructor(scene) { + super(scene); + this.particlesPerStrand = 400; + this.rungsCount = 60; + this.layers = 1; // Single helix + this.init(); + } + + init() { + const rungsPerLayer = this.rungsCount; + const particlesPerRung = 15; + + const totalParticles = this.layers * rungsPerLayer * particlesPerRung; + + const positions = new Float32Array(totalParticles * 3); + const strands = new Float32Array(totalParticles); + const progress = new Float32Array(totalParticles); + const isRung = new Float32Array(totalParticles); + const layers = new Float32Array(totalParticles); + + let idx = 0; + + for (let layer = 0; layer < this.layers; layer++) { + // Rungs only (connecting particles between strands) + for (let r = 0; r < rungsPerLayer; r++) { + const rungProgress = r / rungsPerLayer; + + for (let p = 0; p < particlesPerRung; p++) { + positions[idx * 3] = 0; + positions[idx * 3 + 1] = 0; + positions[idx * 3 + 2] = 0; + strands[idx] = p / (particlesPerRung - 1); + progress[idx] = rungProgress; + isRung[idx] = 1; + layers[idx] = layer; + idx++; + } + } + } + + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + geometry.setAttribute('aStrand', new THREE.BufferAttribute(strands, 1)); + geometry.setAttribute('aProgress', new THREE.BufferAttribute(progress, 1)); + geometry.setAttribute('aIsRung', new THREE.BufferAttribute(isRung, 1)); + geometry.setAttribute('aLayer', new THREE.BufferAttribute(layers, 1)); + + this.material = this.createShaderMaterial(vertexShader, fragmentShader, { + uMouseX: { value: 0 }, + uMouseY: { value: 0 } + }); + this.mesh = new THREE.Points(geometry, this.material); + } + + update(bands, time, mouse = { x: 0, y: 0 }) { + if (!this.material) return; + + this.material.uniforms.uTime.value = time; + this.material.uniforms.uLow.value = bands.low || 0; + this.material.uniforms.uMid.value = bands.mid || 0; + this.material.uniforms.uHigh.value = bands.high || 0; + this.material.uniforms.uMouseX.value = mouse.x; + this.material.uniforms.uMouseY.value = mouse.y; + } +} + +export default HelixVisualizer; diff --git a/assets/js/visualizer/particles.js b/assets/js/visualizer/particles.js index dfb6adb..6d0b69a 100644 --- a/assets/js/visualizer/particles.js +++ b/assets/js/visualizer/particles.js @@ -1,17 +1,19 @@ /** - * Particle System + * Particle System (Sphere) Visualizer * Audio-reactive 3D particle cloud */ import * as THREE from 'three'; +import { BaseVisualizer } from './base-visualizer.js'; -// Vertex Shader const vertexShader = ` uniform float uTime; uniform float uLow; uniform float uMid; uniform float uHigh; uniform float uSize; +uniform float uMouseX; +uniform float uMouseY; attribute float aRandom; attribute float aScale; @@ -42,6 +44,10 @@ void main() { vec3 jitter = normalize(pos) * uHigh * aRandom * 5.0; pos += jitter; + // Mouse offset + pos.x -= uMouseX * 3.0; + pos.y -= uMouseY * 2.0; + // Calculate view position vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0); @@ -57,7 +63,6 @@ void main() { } `; -// Fragment Shader const fragmentShader = ` varying float vAlpha; @@ -74,17 +79,16 @@ void main() { } `; -export class ParticleSystem { - constructor(count = 5000) { +export class SphereVisualizer extends BaseVisualizer { + static name = 'Sphere'; + + constructor(scene, count = 5000) { + super(scene); this.count = count; - this.createGeometry(); - this.createMaterial(); - this.mesh = new THREE.Points(this.geometry, this.material); + this.init(); } - createGeometry() { - this.geometry = new THREE.BufferGeometry(); - + init() { const positions = new Float32Array(this.count * 3); const randoms = new Float32Array(this.count); const scales = new Float32Array(this.count); @@ -105,41 +109,37 @@ export class ParticleSystem { scales[i] = 0.5 + Math.random() * 1.5; } - this.geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); - this.geometry.setAttribute('aRandom', new THREE.BufferAttribute(randoms, 1)); - this.geometry.setAttribute('aScale', new THREE.BufferAttribute(scales, 1)); - } + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + geometry.setAttribute('aRandom', new THREE.BufferAttribute(randoms, 1)); + geometry.setAttribute('aScale', new THREE.BufferAttribute(scales, 1)); - createMaterial() { - this.material = new THREE.ShaderMaterial({ - vertexShader, - fragmentShader, - uniforms: { - uTime: { value: 0 }, - uLow: { value: 0 }, - uMid: { value: 0 }, - uHigh: { value: 0 }, - uSize: { value: 2.0 } - }, - transparent: true, - blending: THREE.AdditiveBlending, - depthWrite: false + this.material = this.createShaderMaterial(vertexShader, fragmentShader, { + uSize: { value: 2.0 }, + uMouseX: { value: 0 }, + uMouseY: { value: 0 } }); + + this.mesh = new THREE.Points(geometry, this.material); } - update(bands, time) { + update(bands, time, mouse = { x: 0, y: 0 }) { if (!this.material) return; this.material.uniforms.uTime.value = time; this.material.uniforms.uLow.value = bands.low || 0; this.material.uniforms.uMid.value = bands.mid || 0; this.material.uniforms.uHigh.value = bands.high || 0; - } - - dispose() { - this.geometry?.dispose(); - this.material?.dispose(); + this.material.uniforms.uMouseX.value = mouse.x; + this.material.uniforms.uMouseY.value = mouse.y; } } -export default ParticleSystem; +// Legacy export for backwards compatibility +export class ParticleSystem extends SphereVisualizer { + constructor(count = 5000) { + super(null, count); + } +} + +export default SphereVisualizer; diff --git a/assets/js/visualizer/scene.js b/assets/js/visualizer/scene.js index deaf287..f2129ab 100644 --- a/assets/js/visualizer/scene.js +++ b/assets/js/visualizer/scene.js @@ -1,10 +1,19 @@ /** * WebGL Visualizer Scene - * Three.js particle system that reacts to audio frequency data + * Three.js scene manager with multiple selectable visualizers */ import * as THREE from 'three'; -import { ParticleSystem } from './particles.js'; +import { SphereVisualizer } from './particles.js'; +import { TunnelVisualizer } from './tunnel.js'; +import { HelixVisualizer } from './helix.js'; + +// Available visualizer classes +const VISUALIZERS = [ + SphereVisualizer, + TunnelVisualizer, + HelixVisualizer +]; export class Visualizer { constructor(canvas, audioManager) { @@ -13,6 +22,14 @@ export class Visualizer { this.running = false; this.time = 0; + // Mouse tracking (normalized -1 to 1) + this.mouse = { x: 0, y: 0 }; + this.mouseActive = false; + + // Visualizer management + this.visualizers = []; + this.currentIndex = 0; + if (!canvas) { console.warn('Visualizer: No canvas provided'); return; @@ -44,17 +61,93 @@ export class Visualizer { this.renderer.setSize(window.innerWidth, window.innerHeight); this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); - // Particles - this.particles = new ParticleSystem(5000); - this.scene.add(this.particles.mesh); + // Initialize all visualizers + this.visualizers = VISUALIZERS.map(VisualizerClass => { + return new VisualizerClass(this.scene); + }); + + // Load saved visualizer preference + const savedIndex = localStorage.getItem('pivoine-visualizer'); + if (savedIndex !== null) { + this.currentIndex = parseInt(savedIndex, 10) % this.visualizers.length; + } + + // Show the current visualizer + this.visualizers[this.currentIndex].show(); // Event listeners window.addEventListener('resize', () => this.resize()); + window.addEventListener('mousemove', (e) => this.onMouseMove(e)); // Start animation this.start(); } + /** + * Get list of visualizer names + */ + getVisualizerNames() { + return VISUALIZERS.map(V => V.name); + } + + /** + * Get current visualizer name + */ + getCurrentName() { + return VISUALIZERS[this.currentIndex].name; + } + + /** + * Get current visualizer index + */ + getCurrentIndex() { + return this.currentIndex; + } + + /** + * Set active visualizer by index + */ + setVisualizer(index) { + if (index < 0 || index >= this.visualizers.length) return; + if (index === this.currentIndex) return; + + // Hide current + this.visualizers[this.currentIndex].hide(); + + // Show new + this.currentIndex = index; + this.visualizers[this.currentIndex].show(); + + // Save preference + localStorage.setItem('pivoine-visualizer', index.toString()); + } + + /** + * Cycle to next visualizer + */ + nextVisualizer() { + const nextIndex = (this.currentIndex + 1) % this.visualizers.length; + this.setVisualizer(nextIndex); + return this.getCurrentName(); + } + + /** + * Cycle to previous visualizer + */ + prevVisualizer() { + const prevIndex = (this.currentIndex - 1 + this.visualizers.length) % this.visualizers.length; + this.setVisualizer(prevIndex); + return this.getCurrentName(); + } + + onMouseMove(e) { + // Normalize to -1 to 1 relative to canvas + const rect = this.canvas.getBoundingClientRect(); + this.mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1; + this.mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1; + this.mouseActive = true; + } + resize() { if (!this.camera || !this.renderer) return; @@ -89,8 +182,12 @@ export class Visualizer { bands = this.audioManager.getFrequencyBands(); } - // Update particles with audio data - this.particles.update(bands, this.time); + // Update current visualizer with audio data and mouse position + const currentViz = this.visualizers[this.currentIndex]; + if (currentViz) { + const mouse = this.mouseActive ? this.mouse : { x: 0, y: 0 }; + currentViz.update(bands, this.time, mouse); + } // Subtle camera movement this.camera.position.x = Math.sin(this.time * 0.1) * 3; @@ -102,7 +199,7 @@ export class Visualizer { destroy() { this.running = false; - this.particles?.dispose(); + this.visualizers.forEach(v => v.dispose()); this.renderer?.dispose(); } } diff --git a/assets/js/visualizer/tunnel.js b/assets/js/visualizer/tunnel.js new file mode 100644 index 0000000..69fc560 --- /dev/null +++ b/assets/js/visualizer/tunnel.js @@ -0,0 +1,146 @@ +/** + * Waveform Tunnel Visualizer + * Infinite tunnel of pulsing rings + */ + +import * as THREE from 'three'; +import { BaseVisualizer } from './base-visualizer.js'; + +const vertexShader = ` +uniform float uTime; +uniform float uLow; +uniform float uMid; +uniform float uHigh; +uniform float uMouseX; +uniform float uMouseY; + +attribute float aRingIndex; +attribute float aAngle; + +varying float vAlpha; +varying float vRingIndex; + +void main() { + vec3 pos = position; + + // Ring depth position (z movement for tunnel effect) + float depth = mod(pos.z + uTime * 12.0, 200.0) - 100.0; + pos.z = depth; + + // Distance from camera affects ring behavior + float depthFactor = 1.0 - (depth + 100.0) / 200.0; + + // Bass expands rings - stronger pulse + float bassExpand = 1.0 + uLow * 0.8 * depthFactor; + pos.x *= bassExpand; + pos.y *= bassExpand; + + // Bounce toward viewer on bass + pos.z += uLow * 15.0; + + // Mid creates wave distortion on ring + float wave = sin(aAngle * 4.0 + uTime * 3.0) * uMid * 2.0 * depthFactor; + float waveRadius = 1.0 + wave * 0.3; + pos.x *= waveRadius; + pos.y *= waveRadius; + + // High adds subtle jitter + pos.x += sin(aAngle * 8.0 + uTime * 5.0) * uHigh * 0.5; + pos.y += cos(aAngle * 8.0 + uTime * 5.0) * uHigh * 0.5; + + // Mouse offset - stronger for closer rings + pos.x -= uMouseX * 5.0 * depthFactor; + pos.y -= uMouseY * 4.0 * depthFactor; + + vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0); + + // Point size based on depth - larger for more coverage + float size = 4.5 * (1.0 + uLow * 0.4); + gl_PointSize = size * (250.0 / -mvPosition.z); + + // Alpha fades with depth + vAlpha = 0.8 * depthFactor + uHigh * 0.2; + vRingIndex = aRingIndex; + + gl_Position = projectionMatrix * mvPosition; +} +`; + +const fragmentShader = ` +varying float vAlpha; +varying float vRingIndex; + +void main() { + float dist = length(gl_PointCoord - vec2(0.5)); + if (dist > 0.5) discard; + + float alpha = 1.0 - smoothstep(0.1, 0.5, dist); + alpha *= vAlpha; + + // Slight color variation based on ring + vec3 color = vec3(1.0, 1.0, 1.0); + + gl_FragColor = vec4(color, alpha); +} +`; + +export class TunnelVisualizer extends BaseVisualizer { + static name = 'Tunnel'; + + constructor(scene) { + super(scene); + this.ringCount = 100; + this.pointsPerRing = 128; + this.init(); + } + + init() { + const totalPoints = this.ringCount * this.pointsPerRing; + const positions = new Float32Array(totalPoints * 3); + const ringIndices = new Float32Array(totalPoints); + const angles = new Float32Array(totalPoints); + + let idx = 0; + for (let ring = 0; ring < this.ringCount; ring++) { + const z = (ring / this.ringCount) * 200 - 100; // -100 to 100 + const radius = 28 + Math.sin(ring * 0.25) * 5; // Larger varying radius + + for (let p = 0; p < this.pointsPerRing; p++) { + const angle = (p / this.pointsPerRing) * Math.PI * 2; + + positions[idx * 3] = Math.cos(angle) * radius; + positions[idx * 3 + 1] = Math.sin(angle) * radius; + positions[idx * 3 + 2] = z; + + ringIndices[idx] = ring / this.ringCount; + angles[idx] = angle; + + idx++; + } + } + + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + geometry.setAttribute('aRingIndex', new THREE.BufferAttribute(ringIndices, 1)); + geometry.setAttribute('aAngle', new THREE.BufferAttribute(angles, 1)); + + this.material = this.createShaderMaterial(vertexShader, fragmentShader, { + uMouseX: { value: 0 }, + uMouseY: { value: 0 } + }); + this.mesh = new THREE.Points(geometry, this.material); + } + + update(bands, time, mouse = { x: 0, y: 0 }) { + if (!this.material) return; + + this.material.uniforms.uTime.value = time; + this.material.uniforms.uLow.value = bands.low || 0; + this.material.uniforms.uMid.value = bands.mid || 0; + this.material.uniforms.uHigh.value = bands.high || 0; + this.material.uniforms.uMouseX.value = mouse.x; + this.material.uniforms.uMouseY.value = mouse.y; + } +} + +export default TunnelVisualizer; diff --git a/config/_default/params.toml b/config/_default/params.toml index 7b281b2..eb74615 100644 --- a/config/_default/params.toml +++ b/config/_default/params.toml @@ -1,4 +1,4 @@ -description = "Valknar's audio" +description = "Valknar's Pivoine.Art" author = "Valknar" email = "valknar@pivoine.art" diff --git a/content/_index.md b/content/_index.md index fe01af1..7c8bab6 100644 --- a/content/_index.md +++ b/content/_index.md @@ -1,4 +1,4 @@ --- title: "Valknar's" -description: "Valknar's audio" +description: "Valknar's Pivoine.Art" --- diff --git a/layouts/_default/baseof.html b/layouts/_default/baseof.html index a9b2a4b..b3b7b41 100755 --- a/layouts/_default/baseof.html +++ b/layouts/_default/baseof.html @@ -64,6 +64,18 @@ // Player UI component Alpine.data('playerUI', () => ({ + visualizerName: 'Sphere', + + init() { + // Get initial visualizer name + this.$nextTick(() => { + const viz = window.__pivoine?.visualizer; + if (viz) { + this.visualizerName = viz.getCurrentName() || 'Sphere'; + } + }); + }, + togglePlay() { window.__pivoine?.audioManager?.toggle(); }, @@ -85,6 +97,12 @@ this.setVolume(this._previousVolume || 0.8); } }, + cycleVisualizer() { + const viz = window.__pivoine?.visualizer; + if (viz) { + this.visualizerName = viz.nextVisualizer(); + } + }, formatTime(seconds) { if (!seconds || isNaN(seconds)) return '0:00'; const mins = Math.floor(seconds / 60); diff --git a/layouts/partials/header.html b/layouts/partials/header.html index 0d3b59c..443af98 100755 --- a/layouts/partials/header.html +++ b/layouts/partials/header.html @@ -1,23 +1,25 @@