diff --git a/apps/mukke/apps/web/package.json b/apps/mukke/apps/web/package.json index 084ffa140..bb6a2f7f4 100644 --- a/apps/mukke/apps/web/package.json +++ b/apps/mukke/apps/web/package.json @@ -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" diff --git a/apps/mukke/apps/web/src/app.d.ts b/apps/mukke/apps/web/src/app.d.ts index c269fca6f..f2762c8d7 100644 --- a/apps/mukke/apps/web/src/app.d.ts +++ b/apps/mukke/apps/web/src/app.d.ts @@ -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, blendTime?: number): void; + render(opts?: Record): void; + setRendererSize(width: number, height: number, opts?: Record): 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>; + export default getPresets; +} diff --git a/apps/mukke/apps/web/src/lib/components/FullPlayer.svelte b/apps/mukke/apps/web/src/lib/components/FullPlayer.svelte index 35e543fff..96ecfc68a 100644 --- a/apps/mukke/apps/web/src/lib/components/FullPlayer.svelte +++ b/apps/mukke/apps/web/src/lib/components/FullPlayer.svelte @@ -1,6 +1,6 @@ + +
+ + + {#if isInitialized} + +
+ + + + {currentPresetName} + + + +
+ {/if} +
diff --git a/apps/mukke/apps/web/src/lib/visualizer/ParticleViz.svelte b/apps/mukke/apps/web/src/lib/visualizer/ParticleViz.svelte new file mode 100644 index 000000000..f1ffa7284 --- /dev/null +++ b/apps/mukke/apps/web/src/lib/visualizer/ParticleViz.svelte @@ -0,0 +1,222 @@ + + + diff --git a/apps/mukke/apps/web/src/lib/visualizer/VisualizerRenderer.svelte b/apps/mukke/apps/web/src/lib/visualizer/VisualizerRenderer.svelte new file mode 100644 index 000000000..39f90e205 --- /dev/null +++ b/apps/mukke/apps/web/src/lib/visualizer/VisualizerRenderer.svelte @@ -0,0 +1,48 @@ + + +
+ {#if visualizerStore.active === 'bars'} + + {:else if visualizerStore.active === 'butterchurn'} + + {:else if visualizerStore.active === 'particles'} + + {/if} + + {#if showSwitcher} +
+ {#each visualizerStore.all as viz} + + {/each} +
+ {/if} +
diff --git a/apps/mukke/apps/web/src/lib/visualizer/analyzer.ts b/apps/mukke/apps/web/src/lib/visualizer/analyzer.ts index a0360bb5b..e30c234ea 100644 --- a/apps/mukke/apps/web/src/lib/visualizer/analyzer.ts +++ b/apps/mukke/apps/web/src/lib/visualizer/analyzer.ts @@ -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). */ diff --git a/apps/mukke/apps/web/src/lib/visualizer/registry.svelte.ts b/apps/mukke/apps/web/src/lib/visualizer/registry.svelte.ts new file mode 100644 index 000000000..a08131273 --- /dev/null +++ b/apps/mukke/apps/web/src/lib/visualizer/registry.svelte.ts @@ -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('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();