mirror of
https://github.com/th-ch/youtube-music.git
synced 2026-01-29 19:03:44 +00:00
refactor(visualizer): Removed restart requirement and refactored impls (#4200)
This commit is contained in:
@ -18,7 +18,6 @@ export type VisualizerPluginConfig = {
|
|||||||
type: 'butterchurn' | 'vudio' | 'wave';
|
type: 'butterchurn' | 'vudio' | 'wave';
|
||||||
butterchurn: {
|
butterchurn: {
|
||||||
preset: string;
|
preset: string;
|
||||||
renderingFrequencyInMs: number;
|
|
||||||
blendTimeInSeconds: number;
|
blendTimeInSeconds: number;
|
||||||
};
|
};
|
||||||
vudio: {
|
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({
|
export default createPlugin({
|
||||||
name: () => t('plugins.visualizer.name'),
|
name: () => t('plugins.visualizer.name'),
|
||||||
description: () => t('plugins.visualizer.description'),
|
description: () => t('plugins.visualizer.description'),
|
||||||
restartNeeded: true,
|
restartNeeded: false,
|
||||||
config: {
|
config: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
type: 'butterchurn',
|
type: 'butterchurn',
|
||||||
// Config per visualizer
|
// Config per visualizer
|
||||||
butterchurn: {
|
butterchurn: {
|
||||||
preset: 'martin [shadow harlequins shape code] - fata morgana',
|
preset: 'martin [shadow harlequins shape code] - fata morgana',
|
||||||
renderingFrequencyInMs: 500,
|
|
||||||
blendTimeInSeconds: 2.7,
|
blendTimeInSeconds: 2.7,
|
||||||
},
|
},
|
||||||
vudio: {
|
vudio: {
|
||||||
@ -148,81 +153,91 @@ export default createPlugin({
|
|||||||
},
|
},
|
||||||
|
|
||||||
renderer: {
|
renderer: {
|
||||||
async onPlayerApiReady(_, { getConfig }) {
|
props: {
|
||||||
const config = await getConfig();
|
visualizerInstance: null,
|
||||||
|
audioContext: null,
|
||||||
|
audioSource: null,
|
||||||
|
observer: null,
|
||||||
|
} as RenderProps,
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
createVisualizer(
|
||||||
let visualizerType: { new (...args: any[]): Visualizer<unknown> } = vudio;
|
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<HTMLElement>('#player');
|
||||||
|
if (!visualizerContainer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let canvas = document.querySelector<HTMLCanvasElement>('#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<typeof vudio>): Visualizer;
|
||||||
|
} = vudio;
|
||||||
if (config.type === 'wave') {
|
if (config.type === 'wave') {
|
||||||
visualizerType = wave;
|
visualizerType = wave;
|
||||||
} else if (config.type === 'butterchurn') {
|
} else if (config.type === 'butterchurn') {
|
||||||
visualizerType = 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(
|
document.addEventListener(
|
||||||
'peard:audio-can-play',
|
'peard:audio-can-play',
|
||||||
(e) => {
|
async (e) => {
|
||||||
const video = document.querySelector<
|
this.props.audioContext = e.detail.audioContext;
|
||||||
HTMLVideoElement & { captureStream(): MediaStream }
|
this.props.audioSource = e.detail.audioSource;
|
||||||
>('video');
|
this.createVisualizer(await getConfig());
|
||||||
if (!video) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const visualizerContainer =
|
|
||||||
document.querySelector<HTMLElement>('#player');
|
|
||||||
if (!visualizerContainer) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let canvas = document.querySelector<HTMLCanvasElement>('#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();
|
|
||||||
},
|
},
|
||||||
{ passive: true },
|
{ passive: true },
|
||||||
);
|
);
|
||||||
|
|||||||
@ -5,54 +5,49 @@ import { Visualizer } from './visualizer';
|
|||||||
|
|
||||||
import type { VisualizerPluginConfig } from '../index';
|
import type { VisualizerPluginConfig } from '../index';
|
||||||
|
|
||||||
class ButterchurnVisualizer extends Visualizer<Butterchurn> {
|
class ButterchurnVisualizer extends Visualizer {
|
||||||
name = 'butterchurn';
|
private readonly visualizer: ReturnType<typeof Butterchurn.createVisualizer>;
|
||||||
|
private destroyed: boolean = false;
|
||||||
visualizer: ReturnType<typeof Butterchurn.createVisualizer>;
|
private animFrameHandle: number | null;
|
||||||
private readonly renderingFrequencyInMs: number;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
audioContext: AudioContext,
|
audioContext: AudioContext,
|
||||||
audioSource: MediaElementAudioSourceNode,
|
audioSource: MediaElementAudioSourceNode,
|
||||||
visualizerContainer: HTMLElement,
|
|
||||||
canvas: HTMLCanvasElement,
|
canvas: HTMLCanvasElement,
|
||||||
audioNode: GainNode,
|
audioNode: GainNode,
|
||||||
stream: MediaStream,
|
_stream: MediaStream,
|
||||||
options: VisualizerPluginConfig,
|
config: VisualizerPluginConfig,
|
||||||
) {
|
) {
|
||||||
super(
|
super(audioSource, audioNode);
|
||||||
audioContext,
|
|
||||||
audioSource,
|
const preset = ButterchurnPresets[config.butterchurn.preset];
|
||||||
visualizerContainer,
|
const renderVisualizer = () => {
|
||||||
canvas,
|
if (this.destroyed) return;
|
||||||
audioNode,
|
this.visualizer.render();
|
||||||
stream,
|
this.animFrameHandle = requestAnimationFrame(renderVisualizer);
|
||||||
options,
|
};
|
||||||
);
|
|
||||||
|
|
||||||
this.visualizer = Butterchurn.createVisualizer(audioContext, canvas, {
|
this.visualizer = Butterchurn.createVisualizer(audioContext, canvas, {
|
||||||
width: canvas.width,
|
width: canvas.width,
|
||||||
height: canvas.height,
|
height: canvas.height,
|
||||||
});
|
});
|
||||||
|
this.visualizer.loadPreset(preset, config.butterchurn.blendTimeInSeconds);
|
||||||
const preset = ButterchurnPresets[options.butterchurn.preset];
|
|
||||||
this.visualizer.loadPreset(preset, options.butterchurn.blendTimeInSeconds);
|
|
||||||
|
|
||||||
this.visualizer.connectAudio(audioNode);
|
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) {
|
resize(width: number, height: number) {
|
||||||
this.visualizer.setRendererSize(width, height);
|
this.visualizer.setRendererSize(width, height);
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
destroy() {
|
||||||
const renderVisualizer = () => {
|
if (this.animFrameHandle) cancelAnimationFrame(this.animFrameHandle);
|
||||||
requestAnimationFrame(renderVisualizer);
|
this.destroyed = true;
|
||||||
this.visualizer.render();
|
try {
|
||||||
};
|
this.audioSource.disconnect(this.audioNode);
|
||||||
setTimeout(renderVisualizer, this.renderingFrequencyInMs);
|
} catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,22 +1,15 @@
|
|||||||
import type { VisualizerPluginConfig } from '../index';
|
export abstract class Visualizer {
|
||||||
|
protected audioNode: GainNode;
|
||||||
export abstract class Visualizer<T> {
|
protected audioSource: MediaElementAudioSourceNode;
|
||||||
/**
|
|
||||||
* The name must be the same as the file name.
|
|
||||||
*/
|
|
||||||
abstract name: string;
|
|
||||||
abstract visualizer: T;
|
|
||||||
|
|
||||||
protected constructor(
|
protected constructor(
|
||||||
_audioContext: AudioContext,
|
|
||||||
_audioSource: MediaElementAudioSourceNode,
|
_audioSource: MediaElementAudioSourceNode,
|
||||||
_visualizerContainer: HTMLElement,
|
|
||||||
_canvas: HTMLCanvasElement,
|
|
||||||
_audioNode: GainNode,
|
_audioNode: GainNode,
|
||||||
_stream: MediaStream,
|
) {
|
||||||
_options: VisualizerPluginConfig,
|
this.audioNode = _audioNode;
|
||||||
) {}
|
this.audioSource = _audioSource;
|
||||||
|
}
|
||||||
|
|
||||||
abstract resize(width: number, height: number): void;
|
abstract resize(width: number, height: number): void;
|
||||||
abstract render(): void;
|
abstract destroy(): void;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,35 +4,24 @@ import { Visualizer } from './visualizer';
|
|||||||
|
|
||||||
import type { VisualizerPluginConfig } from '../index';
|
import type { VisualizerPluginConfig } from '../index';
|
||||||
|
|
||||||
class VudioVisualizer extends Visualizer<Vudio> {
|
class VudioVisualizer extends Visualizer {
|
||||||
name = 'vudio';
|
private readonly visualizer: Vudio;
|
||||||
|
|
||||||
visualizer: Vudio;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
audioContext: AudioContext,
|
_audioContext: AudioContext,
|
||||||
audioSource: MediaElementAudioSourceNode,
|
audioSource: MediaElementAudioSourceNode,
|
||||||
visualizerContainer: HTMLElement,
|
|
||||||
canvas: HTMLCanvasElement,
|
canvas: HTMLCanvasElement,
|
||||||
audioNode: GainNode,
|
audioNode: GainNode,
|
||||||
stream: MediaStream,
|
stream: MediaStream,
|
||||||
options: VisualizerPluginConfig,
|
config: VisualizerPluginConfig,
|
||||||
) {
|
) {
|
||||||
super(
|
super(audioSource, audioNode);
|
||||||
audioContext,
|
|
||||||
audioSource,
|
|
||||||
visualizerContainer,
|
|
||||||
canvas,
|
|
||||||
audioNode,
|
|
||||||
stream,
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
|
|
||||||
this.visualizer = new Vudio(stream, canvas, {
|
this.visualizer = new Vudio(stream, canvas, {
|
||||||
width: canvas.width,
|
width: canvas.width,
|
||||||
height: canvas.height,
|
height: canvas.height,
|
||||||
// Visualizer config
|
// Visualizer config
|
||||||
...options,
|
...config,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.visualizer.dance();
|
this.visualizer.dance();
|
||||||
@ -45,7 +34,12 @@ class VudioVisualizer extends Visualizer<Vudio> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {}
|
destroy() {
|
||||||
|
this.visualizer.pause();
|
||||||
|
try {
|
||||||
|
this.audioSource.disconnect(this.audioNode);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default VudioVisualizer;
|
export default VudioVisualizer;
|
||||||
|
|||||||
@ -4,35 +4,24 @@ import { Visualizer } from './visualizer';
|
|||||||
|
|
||||||
import type { VisualizerPluginConfig } from '../index';
|
import type { VisualizerPluginConfig } from '../index';
|
||||||
|
|
||||||
class WaveVisualizer extends Visualizer<Wave> {
|
class WaveVisualizer extends Visualizer {
|
||||||
name = 'wave';
|
private readonly visualizer: Wave;
|
||||||
|
|
||||||
visualizer: Wave;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
audioContext: AudioContext,
|
audioContext: AudioContext,
|
||||||
audioSource: MediaElementAudioSourceNode,
|
audioSource: MediaElementAudioSourceNode,
|
||||||
visualizerContainer: HTMLElement,
|
|
||||||
canvas: HTMLCanvasElement,
|
canvas: HTMLCanvasElement,
|
||||||
audioNode: GainNode,
|
audioNode: GainNode,
|
||||||
stream: MediaStream,
|
_stream: MediaStream,
|
||||||
options: VisualizerPluginConfig,
|
config: VisualizerPluginConfig,
|
||||||
) {
|
) {
|
||||||
super(
|
super(audioSource, audioNode);
|
||||||
audioContext,
|
|
||||||
audioSource,
|
|
||||||
visualizerContainer,
|
|
||||||
canvas,
|
|
||||||
audioNode,
|
|
||||||
stream,
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
|
|
||||||
this.visualizer = new Wave(
|
this.visualizer = new Wave(
|
||||||
{ context: audioContext, source: audioSource },
|
{ context: audioContext, source: audioSource },
|
||||||
canvas,
|
canvas,
|
||||||
);
|
);
|
||||||
for (const animation of options.wave.animations) {
|
for (const animation of config.wave.animations) {
|
||||||
const TargetVisualizer =
|
const TargetVisualizer =
|
||||||
this.visualizer.animations[
|
this.visualizer.animations[
|
||||||
animation.type as keyof typeof this.visualizer.animations
|
animation.type as keyof typeof this.visualizer.animations
|
||||||
@ -46,7 +35,12 @@ class WaveVisualizer extends Visualizer<Wave> {
|
|||||||
|
|
||||||
resize(_: number, __: number) {}
|
resize(_: number, __: number) {}
|
||||||
|
|
||||||
render() {}
|
destroy() {
|
||||||
|
this.visualizer.clearAnimations();
|
||||||
|
try {
|
||||||
|
this.audioSource.disconnect(this.audioNode);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default WaveVisualizer;
|
export default WaveVisualizer;
|
||||||
|
|||||||
Reference in New Issue
Block a user