mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 23:41:08 +02:00
feat(mukke): add pluggable visualizer system with Butterchurn and Particle modes
Replace static FrequencyBars with VisualizerRenderer supporting multiple visualizer backends via registry. Add Butterchurn (Milkdrop) and Particle (pixi.js) visualizers with runtime switching. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7cad4073d4
commit
42b32cb07d
8 changed files with 577 additions and 3 deletions
|
|
@ -40,6 +40,7 @@
|
|||
"@manacore/shared-auth": "workspace:*",
|
||||
"@manacore/shared-auth-ui": "workspace:*",
|
||||
"@manacore/shared-branding": "workspace:*",
|
||||
"@manacore/shared-error-tracking": "workspace:*",
|
||||
"@manacore/shared-feedback-service": "workspace:^",
|
||||
"@manacore/shared-feedback-ui": "workspace:^",
|
||||
"@manacore/shared-i18n": "workspace:*",
|
||||
|
|
@ -52,6 +53,9 @@
|
|||
"@manacore/shared-ui": "workspace:*",
|
||||
"@manacore/shared-utils": "workspace:*",
|
||||
"@mukke/shared": "workspace:*",
|
||||
"butterchurn": "^2.6.7",
|
||||
"butterchurn-presets": "^2.4.7",
|
||||
"pixi.js": "^8.17.1",
|
||||
"wavesurfer.js": "^7.8.0"
|
||||
},
|
||||
"type": "module"
|
||||
|
|
|
|||
39
apps/mukke/apps/web/src/app.d.ts
vendored
39
apps/mukke/apps/web/src/app.d.ts
vendored
|
|
@ -1,2 +1,41 @@
|
|||
declare const __BUILD_HASH__: string;
|
||||
declare const __BUILD_TIME__: string;
|
||||
|
||||
declare module 'butterchurn' {
|
||||
interface ButterchurnVisualizer {
|
||||
connectAudio(source: AudioNode): void;
|
||||
disconnectAudio(source: AudioNode): void;
|
||||
loadPreset(preset: Record<string, unknown>, blendTime?: number): void;
|
||||
render(opts?: Record<string, unknown>): void;
|
||||
setRendererSize(width: number, height: number, opts?: Record<string, unknown>): void;
|
||||
setInternalMeshSize(width: number, height: number): void;
|
||||
setOutputAA(useAA: boolean): void;
|
||||
setCanvas(canvas: HTMLCanvasElement): void;
|
||||
launchSongTitleAnim(text: string): void;
|
||||
toDataURL(): string;
|
||||
loseGLContext(): void;
|
||||
}
|
||||
|
||||
interface ButterchurnOptions {
|
||||
width: number;
|
||||
height: number;
|
||||
pixelRatio?: number;
|
||||
textureRatio?: number;
|
||||
}
|
||||
|
||||
interface Butterchurn {
|
||||
createVisualizer(
|
||||
audioContext: AudioContext,
|
||||
canvas: HTMLCanvasElement,
|
||||
options: ButterchurnOptions
|
||||
): ButterchurnVisualizer;
|
||||
}
|
||||
|
||||
const butterchurn: Butterchurn;
|
||||
export default butterchurn;
|
||||
}
|
||||
|
||||
declare module 'butterchurn-presets' {
|
||||
function getPresets(): Record<string, Record<string, unknown>>;
|
||||
export default getPresets;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { playerStore } from '$lib/stores/player.svelte';
|
||||
import FrequencyBars from '$lib/visualizer/FrequencyBars.svelte';
|
||||
import VisualizerRenderer from '$lib/visualizer/VisualizerRenderer.svelte';
|
||||
|
||||
let progress = $derived(
|
||||
playerStore.duration > 0 ? (playerStore.currentTime / playerStore.duration) * 100 : 0
|
||||
|
|
@ -63,9 +63,9 @@
|
|||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Frequency visualizer -->
|
||||
<!-- Visualizer -->
|
||||
<div class="w-full max-w-md">
|
||||
<FrequencyBars barCount={48} height={80} mirror={true} barGap={2} barRadius={2} />
|
||||
<VisualizerRenderer height={200} showSwitcher={true} />
|
||||
</div>
|
||||
|
||||
<!-- Song info -->
|
||||
|
|
|
|||
182
apps/mukke/apps/web/src/lib/visualizer/ButterchurnViz.svelte
Normal file
182
apps/mukke/apps/web/src/lib/visualizer/ButterchurnViz.svelte
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
<script lang="ts">
|
||||
import { playerStore } from '$lib/stores/player.svelte';
|
||||
import { connectAnalyzer, getAudioContext, getSourceNode, resumeAudioContext } from './analyzer';
|
||||
|
||||
interface Props {
|
||||
height?: number;
|
||||
/** Blend duration when switching presets (seconds) */
|
||||
blendTime?: number;
|
||||
}
|
||||
|
||||
let { height = 300, blendTime = 2.7 }: Props = $props();
|
||||
|
||||
let canvas: HTMLCanvasElement | undefined = $state();
|
||||
let visualizer: any = null;
|
||||
let animationId: number | null = null;
|
||||
let isInitialized = $state(false);
|
||||
let presetNames: string[] = $state([]);
|
||||
let currentPresetIndex = $state(0);
|
||||
let currentPresetName = $state('');
|
||||
|
||||
// Lazy-loaded modules
|
||||
let butterchurnModule: any = null;
|
||||
let presets: Record<string, any> = {};
|
||||
|
||||
async function loadButterchurn() {
|
||||
if (butterchurnModule) return;
|
||||
const [bc, bcPresets] = await Promise.all([
|
||||
import('butterchurn'),
|
||||
import('butterchurn-presets'),
|
||||
]);
|
||||
butterchurnModule = bc.default || bc;
|
||||
const getPresets = bcPresets.default || bcPresets;
|
||||
presets = typeof getPresets === 'function' ? getPresets() : getPresets;
|
||||
presetNames = Object.keys(presets).sort();
|
||||
}
|
||||
|
||||
async function initVisualizer() {
|
||||
if (!canvas || isInitialized) return;
|
||||
|
||||
const audio = playerStore.getAudioElement();
|
||||
if (!audio) return;
|
||||
|
||||
try {
|
||||
connectAnalyzer(audio);
|
||||
await resumeAudioContext();
|
||||
await loadButterchurn();
|
||||
|
||||
const audioContext = getAudioContext();
|
||||
const sourceNode = getSourceNode();
|
||||
if (!audioContext || !sourceNode) return;
|
||||
|
||||
const width = canvas.clientWidth;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
visualizer = butterchurnModule.createVisualizer(audioContext, canvas, {
|
||||
width: Math.floor(width * dpr),
|
||||
height: Math.floor(height * dpr),
|
||||
pixelRatio: dpr,
|
||||
textureRatio: 1,
|
||||
});
|
||||
|
||||
visualizer.connectAudio(sourceNode);
|
||||
|
||||
// Load a random preset to start
|
||||
if (presetNames.length > 0) {
|
||||
currentPresetIndex = Math.floor(Math.random() * presetNames.length);
|
||||
loadPresetByIndex(currentPresetIndex, 0);
|
||||
}
|
||||
|
||||
isInitialized = true;
|
||||
} catch (e) {
|
||||
console.warn('[Butterchurn] Failed to initialize:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function loadPresetByIndex(index: number, blend: number) {
|
||||
if (!visualizer || presetNames.length === 0) return;
|
||||
const name = presetNames[index];
|
||||
currentPresetName = name;
|
||||
currentPresetIndex = index;
|
||||
visualizer.loadPreset(presets[name], blend);
|
||||
}
|
||||
|
||||
export function nextPreset() {
|
||||
const next = (currentPresetIndex + 1) % presetNames.length;
|
||||
loadPresetByIndex(next, blendTime);
|
||||
}
|
||||
|
||||
export function previousPreset() {
|
||||
const prev = (currentPresetIndex - 1 + presetNames.length) % presetNames.length;
|
||||
loadPresetByIndex(prev, blendTime);
|
||||
}
|
||||
|
||||
export function randomPreset() {
|
||||
const rand = Math.floor(Math.random() * presetNames.length);
|
||||
loadPresetByIndex(rand, blendTime);
|
||||
}
|
||||
|
||||
// Initialize when playing starts
|
||||
$effect(() => {
|
||||
if (playerStore.isPlaying && canvas && !isInitialized) {
|
||||
initVisualizer();
|
||||
}
|
||||
});
|
||||
|
||||
// Animation loop
|
||||
$effect(() => {
|
||||
if (playerStore.isPlaying && isInitialized) {
|
||||
startAnimation();
|
||||
} else {
|
||||
stopAnimation();
|
||||
}
|
||||
return () => stopAnimation();
|
||||
});
|
||||
|
||||
// Resize handling
|
||||
$effect(() => {
|
||||
if (visualizer && canvas) {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const width = canvas.clientWidth;
|
||||
visualizer.setRendererSize(Math.floor(width * dpr), Math.floor(height * dpr));
|
||||
}
|
||||
});
|
||||
|
||||
function startAnimation() {
|
||||
if (animationId !== null) return;
|
||||
function loop() {
|
||||
if (visualizer) {
|
||||
visualizer.render();
|
||||
}
|
||||
animationId = requestAnimationFrame(loop);
|
||||
}
|
||||
animationId = requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
function stopAnimation() {
|
||||
if (animationId !== null) {
|
||||
cancelAnimationFrame(animationId);
|
||||
animationId = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative w-full" style="height: {height}px;">
|
||||
<canvas
|
||||
bind:this={canvas}
|
||||
class="w-full h-full rounded-lg"
|
||||
style="height: {height}px;"
|
||||
aria-hidden="true"
|
||||
></canvas>
|
||||
|
||||
{#if isInitialized}
|
||||
<!-- Preset controls overlay -->
|
||||
<div
|
||||
class="absolute bottom-2 left-2 right-2 flex items-center justify-between opacity-0 hover:opacity-100 transition-opacity duration-300"
|
||||
>
|
||||
<button
|
||||
onclick={previousPreset}
|
||||
class="p-1.5 rounded-full bg-black/50 text-white hover:bg-black/70 transition-colors"
|
||||
aria-label="Previous preset"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<span class="text-xs text-white/80 bg-black/50 px-2 py-1 rounded truncate max-w-[60%]">
|
||||
{currentPresetName}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onclick={nextPreset}
|
||||
class="p-1.5 rounded-full bg-black/50 text-white hover:bg-black/70 transition-colors"
|
||||
aria-label="Next preset"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8.59 16.59L10 18l6-6-6-6-1.41 1.41L13.17 12z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
222
apps/mukke/apps/web/src/lib/visualizer/ParticleViz.svelte
Normal file
222
apps/mukke/apps/web/src/lib/visualizer/ParticleViz.svelte
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
<script lang="ts">
|
||||
import { playerStore } from '$lib/stores/player.svelte';
|
||||
import { connectAnalyzer, getFrequencyData, resumeAudioContext } from './analyzer';
|
||||
|
||||
interface Props {
|
||||
height?: number;
|
||||
/** Number of particles */
|
||||
particleCount?: number;
|
||||
/** Color mode */
|
||||
colorMode?: 'spectrum' | 'pulse' | 'white';
|
||||
}
|
||||
|
||||
let { height = 300, particleCount = 200, colorMode = 'spectrum' }: Props = $props();
|
||||
|
||||
let container: HTMLDivElement | undefined = $state();
|
||||
let app: any = null;
|
||||
let particles: Particle[] = [];
|
||||
let isInitialized = $state(false);
|
||||
|
||||
interface Particle {
|
||||
graphics: any;
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
baseRadius: number;
|
||||
frequencyBin: number;
|
||||
hue: number;
|
||||
}
|
||||
|
||||
async function initPixi() {
|
||||
if (!container || isInitialized) return;
|
||||
|
||||
const audio = playerStore.getAudioElement();
|
||||
if (!audio) return;
|
||||
|
||||
try {
|
||||
connectAnalyzer(audio, { fftSize: 256 });
|
||||
await resumeAudioContext();
|
||||
|
||||
const PIXI = await import('pixi.js');
|
||||
|
||||
app = new PIXI.Application();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const width = container.clientWidth;
|
||||
|
||||
await app.init({
|
||||
width: Math.floor(width * dpr),
|
||||
height: Math.floor(height * dpr),
|
||||
backgroundAlpha: 0,
|
||||
antialias: true,
|
||||
resolution: 1,
|
||||
autoDensity: false,
|
||||
});
|
||||
|
||||
app.canvas.style.width = '100%';
|
||||
app.canvas.style.height = `${height}px`;
|
||||
container.appendChild(app.canvas);
|
||||
|
||||
createParticles(PIXI, width * dpr, height * dpr);
|
||||
isInitialized = true;
|
||||
} catch (e) {
|
||||
console.warn('[ParticleViz] Failed to initialize:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function createParticles(PIXI: typeof import('pixi.js'), width: number, height: number) {
|
||||
particles = [];
|
||||
|
||||
for (let i = 0; i < particleCount; i++) {
|
||||
const graphics = new PIXI.Graphics();
|
||||
const baseRadius = 1.5 + Math.random() * 3;
|
||||
const hue = (i / particleCount) * 360;
|
||||
|
||||
graphics.circle(0, 0, baseRadius);
|
||||
graphics.fill({ color: hslToHex(hue, 80, 60), alpha: 0.7 });
|
||||
|
||||
graphics.x = Math.random() * width;
|
||||
graphics.y = Math.random() * height;
|
||||
|
||||
app.stage.addChild(graphics);
|
||||
|
||||
particles.push({
|
||||
graphics,
|
||||
x: graphics.x,
|
||||
y: graphics.y,
|
||||
vx: (Math.random() - 0.5) * 0.5,
|
||||
vy: (Math.random() - 0.5) * 0.5,
|
||||
baseRadius,
|
||||
frequencyBin: Math.floor((i / particleCount) * 128),
|
||||
hue,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function hslToHex(h: number, s: number, l: number): number {
|
||||
s /= 100;
|
||||
l /= 100;
|
||||
const a = s * Math.min(l, 1 - l);
|
||||
const f = (n: number) => {
|
||||
const k = (n + h / 30) % 12;
|
||||
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
|
||||
return Math.round(255 * color);
|
||||
};
|
||||
return (f(0) << 16) | (f(8) << 8) | f(4);
|
||||
}
|
||||
|
||||
function updateParticles() {
|
||||
const frequencyData = getFrequencyData();
|
||||
if (!frequencyData || !app) return;
|
||||
|
||||
const w = app.canvas.width;
|
||||
const h = app.canvas.height;
|
||||
|
||||
// Calculate energy bands
|
||||
let bassEnergy = 0;
|
||||
const bassEnd = Math.floor(frequencyData.length * 0.15);
|
||||
for (let i = 0; i < bassEnd; i++) bassEnergy += frequencyData[i];
|
||||
bassEnergy = bassEnergy / bassEnd / 255;
|
||||
|
||||
for (const p of particles) {
|
||||
const freq = frequencyData[p.frequencyBin] / 255;
|
||||
|
||||
// Bass makes particles expand and move faster
|
||||
const energy = freq * 0.7 + bassEnergy * 0.3;
|
||||
const speed = 0.3 + energy * 3;
|
||||
|
||||
p.vx += (Math.random() - 0.5) * speed * 0.3;
|
||||
p.vy += (Math.random() - 0.5) * speed * 0.3;
|
||||
|
||||
// Damping
|
||||
p.vx *= 0.96;
|
||||
p.vy *= 0.96;
|
||||
|
||||
// Gravity toward center on bass hits
|
||||
if (bassEnergy > 0.6) {
|
||||
const cx = w / 2;
|
||||
const cy = h / 2;
|
||||
const dx = cx - p.x;
|
||||
const dy = cy - p.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy) + 1;
|
||||
p.vx += (dx / dist) * bassEnergy * 0.5;
|
||||
p.vy += (dy / dist) * bassEnergy * 0.5;
|
||||
}
|
||||
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
|
||||
// Wrap around edges
|
||||
if (p.x < 0) p.x = w;
|
||||
if (p.x > w) p.x = 0;
|
||||
if (p.y < 0) p.y = h;
|
||||
if (p.y > h) p.y = 0;
|
||||
|
||||
p.graphics.x = p.x;
|
||||
p.graphics.y = p.y;
|
||||
|
||||
// Scale based on frequency energy
|
||||
const scale = 1 + energy * 3;
|
||||
p.graphics.scale.set(scale);
|
||||
|
||||
// Alpha based on energy
|
||||
p.graphics.alpha = 0.3 + energy * 0.7;
|
||||
|
||||
// Redraw with updated color
|
||||
if (colorMode === 'spectrum') {
|
||||
const hue = (p.hue + energy * 60) % 360;
|
||||
const lightness = 50 + energy * 30;
|
||||
p.graphics.clear();
|
||||
p.graphics.circle(0, 0, p.baseRadius);
|
||||
p.graphics.fill({ color: hslToHex(hue, 85, lightness), alpha: 0.3 + energy * 0.7 });
|
||||
} else if (colorMode === 'pulse') {
|
||||
const hue = 200 + bassEnergy * 120;
|
||||
p.graphics.clear();
|
||||
p.graphics.circle(0, 0, p.baseRadius);
|
||||
p.graphics.fill({ color: hslToHex(hue, 90, 55 + energy * 25), alpha: 0.3 + energy * 0.7 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize when playing starts
|
||||
$effect(() => {
|
||||
if (playerStore.isPlaying && container && !isInitialized) {
|
||||
initPixi();
|
||||
}
|
||||
});
|
||||
|
||||
// Ticker-based render loop
|
||||
$effect(() => {
|
||||
if (isInitialized && app) {
|
||||
if (playerStore.isPlaying) {
|
||||
app.ticker.add(updateParticles);
|
||||
} else {
|
||||
app.ticker.remove(updateParticles);
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
if (app) {
|
||||
app.ticker.remove(updateParticles);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Cleanup
|
||||
$effect(() => {
|
||||
return () => {
|
||||
if (app) {
|
||||
app.destroy(true);
|
||||
app = null;
|
||||
isInitialized = false;
|
||||
particles = [];
|
||||
}
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={container}
|
||||
class="w-full rounded-lg overflow-hidden"
|
||||
style="height: {height}px;"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
<script lang="ts">
|
||||
import { visualizerStore } from './registry.svelte';
|
||||
import FrequencyBars from './FrequencyBars.svelte';
|
||||
import ButterchurnViz from './ButterchurnViz.svelte';
|
||||
import ParticleViz from './ParticleViz.svelte';
|
||||
|
||||
interface Props {
|
||||
height?: number;
|
||||
/** Show the visualizer type switcher */
|
||||
showSwitcher?: boolean;
|
||||
/** Compact mode for MiniPlayer */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
let { height = 200, showSwitcher = true, compact = false }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="relative w-full">
|
||||
{#if visualizerStore.active === 'bars'}
|
||||
<FrequencyBars
|
||||
barCount={compact ? 64 : 48}
|
||||
height={compact ? height : height}
|
||||
mirror={!compact}
|
||||
barGap={compact ? 1 : 2}
|
||||
barRadius={compact ? 1 : 2}
|
||||
/>
|
||||
{:else if visualizerStore.active === 'butterchurn'}
|
||||
<ButterchurnViz {height} />
|
||||
{:else if visualizerStore.active === 'particles'}
|
||||
<ParticleViz {height} particleCount={compact ? 100 : 200} />
|
||||
{/if}
|
||||
|
||||
{#if showSwitcher}
|
||||
<div class="absolute top-2 right-2 flex gap-1">
|
||||
{#each visualizerStore.all as viz}
|
||||
<button
|
||||
onclick={() => visualizerStore.setActive(viz.id)}
|
||||
class="px-2 py-1 text-xs rounded-md transition-colors {visualizerStore.active === viz.id
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-black/40 text-white/70 hover:bg-black/60 hover:text-white'}"
|
||||
title={viz.description}
|
||||
>
|
||||
{viz.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -68,6 +68,22 @@ export function getAnalyzer(): AnalyserNode | null {
|
|||
return analyserNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the AudioContext (null if not yet created).
|
||||
* Needed by Butterchurn for its own audio processing.
|
||||
*/
|
||||
export function getAudioContext(): AudioContext | null {
|
||||
return audioContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the MediaElementAudioSourceNode (null if not yet connected).
|
||||
* Needed by Butterchurn to connect its own audio analysis.
|
||||
*/
|
||||
export function getSourceNode(): MediaElementAudioSourceNode | null {
|
||||
return sourceNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume the AudioContext (required after user gesture on some browsers).
|
||||
*/
|
||||
|
|
|
|||
63
apps/mukke/apps/web/src/lib/visualizer/registry.svelte.ts
Normal file
63
apps/mukke/apps/web/src/lib/visualizer/registry.svelte.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
export type VisualizerType = 'bars' | 'butterchurn' | 'particles';
|
||||
|
||||
export interface VisualizerInfo {
|
||||
id: VisualizerType;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export const VISUALIZERS: VisualizerInfo[] = [
|
||||
{
|
||||
id: 'bars',
|
||||
name: 'Frequency Bars',
|
||||
description: 'Classic equalizer bars with frequency spectrum',
|
||||
icon: '▊▊▊',
|
||||
},
|
||||
{
|
||||
id: 'butterchurn',
|
||||
name: 'Milkdrop',
|
||||
description: 'Classic Winamp/Milkdrop presets (500+ visuals)',
|
||||
icon: '✦',
|
||||
},
|
||||
{
|
||||
id: 'particles',
|
||||
name: 'Particles',
|
||||
description: 'GPU-accelerated particle flow reacting to audio',
|
||||
icon: '·:·',
|
||||
},
|
||||
];
|
||||
|
||||
function createVisualizerStore() {
|
||||
let activeId = $state<VisualizerType>('bars');
|
||||
|
||||
return {
|
||||
get active() {
|
||||
return activeId;
|
||||
},
|
||||
get activeInfo() {
|
||||
return VISUALIZERS.find((v) => v.id === activeId)!;
|
||||
},
|
||||
get all() {
|
||||
return VISUALIZERS;
|
||||
},
|
||||
|
||||
setActive(id: VisualizerType) {
|
||||
activeId = id;
|
||||
},
|
||||
|
||||
next() {
|
||||
const idx = VISUALIZERS.findIndex((v) => v.id === activeId);
|
||||
const nextIdx = (idx + 1) % VISUALIZERS.length;
|
||||
activeId = VISUALIZERS[nextIdx].id;
|
||||
},
|
||||
|
||||
previous() {
|
||||
const idx = VISUALIZERS.findIndex((v) => v.id === activeId);
|
||||
const prevIdx = (idx - 1 + VISUALIZERS.length) % VISUALIZERS.length;
|
||||
activeId = VISUALIZERS[prevIdx].id;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const visualizerStore = createVisualizerStore();
|
||||
Loading…
Add table
Add a link
Reference in a new issue