From 442dd51d3d0d1bcab04f6abd909b910e4692b801 Mon Sep 17 00:00:00 2001 From: Iris <244833241+its-iris@users.noreply.github.com> Date: Thu, 29 Jan 2026 09:24:00 +0100 Subject: [PATCH] refactor(visualizer): Removed restart requirement and refactored impls (#4200) --- src/plugins/visualizer/index.ts | 151 ++++++++++-------- .../visualizer/visualizers/butterchurn.ts | 51 +++--- .../visualizer/visualizers/visualizer.ts | 23 +-- src/plugins/visualizer/visualizers/vudio.ts | 30 ++-- src/plugins/visualizer/visualizers/wave.ts | 30 ++-- 5 files changed, 138 insertions(+), 147 deletions(-) diff --git a/src/plugins/visualizer/index.ts b/src/plugins/visualizer/index.ts index c3c7e43ba..cadb75f06 100644 --- a/src/plugins/visualizer/index.ts +++ b/src/plugins/visualizer/index.ts @@ -18,7 +18,6 @@ export type VisualizerPluginConfig = { type: 'butterchurn' | 'vudio' | 'wave'; butterchurn: { preset: string; - renderingFrequencyInMs: number; blendTimeInSeconds: number; }; vudio: { @@ -57,17 +56,23 @@ export type VisualizerPluginConfig = { }; }; +type RenderProps = { + visualizerInstance: Visualizer | null; + audioContext: AudioContext | null; + audioSource: MediaElementAudioSourceNode | null; + observer: ResizeObserver | null; +}; + export default createPlugin({ name: () => t('plugins.visualizer.name'), description: () => t('plugins.visualizer.description'), - restartNeeded: true, + restartNeeded: false, config: { enabled: false, type: 'butterchurn', // Config per visualizer butterchurn: { preset: 'martin [shadow harlequins shape code] - fata morgana', - renderingFrequencyInMs: 500, blendTimeInSeconds: 2.7, }, vudio: { @@ -148,81 +153,91 @@ export default createPlugin({ }, renderer: { - async onPlayerApiReady(_, { getConfig }) { - const config = await getConfig(); + props: { + visualizerInstance: null, + audioContext: null, + audioSource: null, + observer: null, + } as RenderProps, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let visualizerType: { new (...args: any[]): Visualizer } = vudio; + createVisualizer( + this: { props: RenderProps }, + config: VisualizerPluginConfig, + ) { + this.props.visualizerInstance?.destroy(); + this.props.visualizerInstance = null; + if (!this.props.audioContext || !this.props.audioSource) return; + if (!config.enabled) return; + + const video = document.querySelector< + HTMLVideoElement & { captureStream(): MediaStream } + >('video'); + if (!video) { + return; + } + + const visualizerContainer = + document.querySelector('#player'); + if (!visualizerContainer) { + return; + } + + let canvas = document.querySelector('#visualizer'); + if (!canvas) { + canvas = document.createElement('canvas'); + canvas.id = 'visualizer'; + visualizerContainer?.prepend(canvas); + } + + const gainNode = this.props.audioContext.createGain(); + gainNode.gain.value = 1.25; + this.props.audioSource.connect(gainNode); + + let visualizerType: { + new (...args: ConstructorParameters): Visualizer; + } = vudio; if (config.type === 'wave') { visualizerType = wave; } else if (config.type === 'butterchurn') { visualizerType = butterchurn; } + this.props.visualizerInstance = new visualizerType( + this.props.audioContext, + this.props.audioSource, + canvas, + gainNode, + video.captureStream(), + config, + ); + const resizeVisualizer = () => { + if (canvas && visualizerContainer) { + const { width, height } = + window.getComputedStyle(visualizerContainer); + canvas.width = Math.ceil(parseFloat(width)); + canvas.height = Math.ceil(parseFloat(height)); + } + this.props.visualizerInstance?.resize(canvas.width, canvas.height); + }; + resizeVisualizer(); + + this.props.observer?.disconnect(); + this.props.observer = new ResizeObserver(resizeVisualizer); + this.props.observer.observe(visualizerContainer); + }, + + onConfigChange(newConfig) { + this.createVisualizer(newConfig); + }, + + onPlayerApiReady(_, { getConfig }) { document.addEventListener( 'peard:audio-can-play', - (e) => { - const video = document.querySelector< - HTMLVideoElement & { captureStream(): MediaStream } - >('video'); - if (!video) { - return; - } - - const visualizerContainer = - document.querySelector('#player'); - if (!visualizerContainer) { - return; - } - - let canvas = document.querySelector('#visualizer'); - if (!canvas) { - canvas = document.createElement('canvas'); - canvas.id = 'visualizer'; - visualizerContainer?.prepend(canvas); - } - - const resizeCanvas = () => { - if (canvas) { - canvas.width = visualizerContainer.clientWidth; - canvas.height = visualizerContainer.clientHeight; - } - }; - - resizeCanvas(); - - const gainNode = e.detail.audioContext.createGain(); - gainNode.gain.value = 1.25; - e.detail.audioSource.connect(gainNode); - - const visualizer = new visualizerType( - e.detail.audioContext, - e.detail.audioSource, - visualizerContainer, - canvas, - gainNode, - video.captureStream(), - config, - ); - - const resizeVisualizer = (width: number, height: number) => { - resizeCanvas(); - visualizer.resize(width, height); - }; - - resizeVisualizer(canvas.width, canvas.height); - const visualizerContainerObserver = new ResizeObserver((entries) => { - for (const entry of entries) { - resizeVisualizer( - entry.contentRect.width, - entry.contentRect.height, - ); - } - }); - visualizerContainerObserver.observe(visualizerContainer); - - visualizer.render(); + async (e) => { + this.props.audioContext = e.detail.audioContext; + this.props.audioSource = e.detail.audioSource; + this.createVisualizer(await getConfig()); }, { passive: true }, ); diff --git a/src/plugins/visualizer/visualizers/butterchurn.ts b/src/plugins/visualizer/visualizers/butterchurn.ts index 1a2f88658..1f9d257bc 100644 --- a/src/plugins/visualizer/visualizers/butterchurn.ts +++ b/src/plugins/visualizer/visualizers/butterchurn.ts @@ -5,54 +5,49 @@ import { Visualizer } from './visualizer'; import type { VisualizerPluginConfig } from '../index'; -class ButterchurnVisualizer extends Visualizer { - name = 'butterchurn'; - - visualizer: ReturnType; - private readonly renderingFrequencyInMs: number; +class ButterchurnVisualizer extends Visualizer { + private readonly visualizer: ReturnType; + private destroyed: boolean = false; + private animFrameHandle: number | null; constructor( audioContext: AudioContext, audioSource: MediaElementAudioSourceNode, - visualizerContainer: HTMLElement, canvas: HTMLCanvasElement, audioNode: GainNode, - stream: MediaStream, - options: VisualizerPluginConfig, + _stream: MediaStream, + config: VisualizerPluginConfig, ) { - super( - audioContext, - audioSource, - visualizerContainer, - canvas, - audioNode, - stream, - options, - ); + super(audioSource, audioNode); + + const preset = ButterchurnPresets[config.butterchurn.preset]; + const renderVisualizer = () => { + if (this.destroyed) return; + this.visualizer.render(); + this.animFrameHandle = requestAnimationFrame(renderVisualizer); + }; this.visualizer = Butterchurn.createVisualizer(audioContext, canvas, { width: canvas.width, height: canvas.height, }); - - const preset = ButterchurnPresets[options.butterchurn.preset]; - this.visualizer.loadPreset(preset, options.butterchurn.blendTimeInSeconds); - + this.visualizer.loadPreset(preset, config.butterchurn.blendTimeInSeconds); this.visualizer.connectAudio(audioNode); - this.renderingFrequencyInMs = options.butterchurn.renderingFrequencyInMs; + // Start animation request loop. Do not use setInterval! + this.animFrameHandle = requestAnimationFrame(renderVisualizer); } resize(width: number, height: number) { this.visualizer.setRendererSize(width, height); } - render() { - const renderVisualizer = () => { - requestAnimationFrame(renderVisualizer); - this.visualizer.render(); - }; - setTimeout(renderVisualizer, this.renderingFrequencyInMs); + destroy() { + if (this.animFrameHandle) cancelAnimationFrame(this.animFrameHandle); + this.destroyed = true; + try { + this.audioSource.disconnect(this.audioNode); + } catch {} } } diff --git a/src/plugins/visualizer/visualizers/visualizer.ts b/src/plugins/visualizer/visualizers/visualizer.ts index 12972dcaa..94ee2420b 100644 --- a/src/plugins/visualizer/visualizers/visualizer.ts +++ b/src/plugins/visualizer/visualizers/visualizer.ts @@ -1,22 +1,15 @@ -import type { VisualizerPluginConfig } from '../index'; - -export abstract class Visualizer { - /** - * The name must be the same as the file name. - */ - abstract name: string; - abstract visualizer: T; +export abstract class Visualizer { + protected audioNode: GainNode; + protected audioSource: MediaElementAudioSourceNode; protected constructor( - _audioContext: AudioContext, _audioSource: MediaElementAudioSourceNode, - _visualizerContainer: HTMLElement, - _canvas: HTMLCanvasElement, _audioNode: GainNode, - _stream: MediaStream, - _options: VisualizerPluginConfig, - ) {} + ) { + this.audioNode = _audioNode; + this.audioSource = _audioSource; + } abstract resize(width: number, height: number): void; - abstract render(): void; + abstract destroy(): void; } diff --git a/src/plugins/visualizer/visualizers/vudio.ts b/src/plugins/visualizer/visualizers/vudio.ts index 16f803fb9..2c4e34aee 100644 --- a/src/plugins/visualizer/visualizers/vudio.ts +++ b/src/plugins/visualizer/visualizers/vudio.ts @@ -4,35 +4,24 @@ import { Visualizer } from './visualizer'; import type { VisualizerPluginConfig } from '../index'; -class VudioVisualizer extends Visualizer { - name = 'vudio'; - - visualizer: Vudio; +class VudioVisualizer extends Visualizer { + private readonly visualizer: Vudio; constructor( - audioContext: AudioContext, + _audioContext: AudioContext, audioSource: MediaElementAudioSourceNode, - visualizerContainer: HTMLElement, canvas: HTMLCanvasElement, audioNode: GainNode, stream: MediaStream, - options: VisualizerPluginConfig, + config: VisualizerPluginConfig, ) { - super( - audioContext, - audioSource, - visualizerContainer, - canvas, - audioNode, - stream, - options, - ); + super(audioSource, audioNode); this.visualizer = new Vudio(stream, canvas, { width: canvas.width, height: canvas.height, // Visualizer config - ...options, + ...config, }); this.visualizer.dance(); @@ -45,7 +34,12 @@ class VudioVisualizer extends Visualizer { }); } - render() {} + destroy() { + this.visualizer.pause(); + try { + this.audioSource.disconnect(this.audioNode); + } catch {} + } } export default VudioVisualizer; diff --git a/src/plugins/visualizer/visualizers/wave.ts b/src/plugins/visualizer/visualizers/wave.ts index 3456e90cb..c95f38fe0 100644 --- a/src/plugins/visualizer/visualizers/wave.ts +++ b/src/plugins/visualizer/visualizers/wave.ts @@ -4,35 +4,24 @@ import { Visualizer } from './visualizer'; import type { VisualizerPluginConfig } from '../index'; -class WaveVisualizer extends Visualizer { - name = 'wave'; - - visualizer: Wave; +class WaveVisualizer extends Visualizer { + private readonly visualizer: Wave; constructor( audioContext: AudioContext, audioSource: MediaElementAudioSourceNode, - visualizerContainer: HTMLElement, canvas: HTMLCanvasElement, audioNode: GainNode, - stream: MediaStream, - options: VisualizerPluginConfig, + _stream: MediaStream, + config: VisualizerPluginConfig, ) { - super( - audioContext, - audioSource, - visualizerContainer, - canvas, - audioNode, - stream, - options, - ); + super(audioSource, audioNode); this.visualizer = new Wave( { context: audioContext, source: audioSource }, canvas, ); - for (const animation of options.wave.animations) { + for (const animation of config.wave.animations) { const TargetVisualizer = this.visualizer.animations[ animation.type as keyof typeof this.visualizer.animations @@ -46,7 +35,12 @@ class WaveVisualizer extends Visualizer { resize(_: number, __: number) {} - render() {} + destroy() { + this.visualizer.clearAnimations(); + try { + this.audioSource.disconnect(this.audioNode); + } catch {} + } } export default WaveVisualizer;