Skip to content

Source: src/visualizer/VisualizerAmbient.js

import AnalyserVideo from './AnalyserVideo.js';
import DomSmith from '../../lib/dom/DomSmith.js';
import Looper from '../../lib/util/Looper.js';
import { lerp } from '../../lib/util/math.js';
import { extend, isString } from '../../lib/util/object.js';

/**
 * The VisualizerAmbient component renders a real-time ambient visualization based on color data extracted from the video stream in real time.
 * It creates a visual effect similar to "AmbiLight", where the average color of the video is extended to the surrounding canvas.
 * @exports module:src/visualizer/VisualizerAmbient
 * @requires lib/dom/DomSmith
 * @requires lib/util/Looper
 * @requires lib/util/object
 * @requires lib/util/math
 * @augments module:src/visualizer/AnalyserVideo
 * @author   Frank Kudermann - alphanull
 * @version  1.0.0
 * @license  MIT
 */
export default class VisualizerAmbient extends AnalyserVideo {

    /**
     * Configuration options for the VisualizerAmbient component.
     * @type     {Object}
     * @property {number} [selector="body"]  CSS selector resolving to the dom element to attach to.
     * @property {number} [smooth=0.96]      Additional interpolation factor (on top on "lerp") to smooth pixel values over time.
     */
    #config;

    /**
     * Reference to the main player instance.
     * @type {module:src/core/Player}
     */
    #player;

    /**
     * Holds tokens of subscriptions to player events, for later unsubscribe.
     * @type {number[]}
     */
    #subscriptions;

    /**
     * DomSmith instance for the ambient visualizer container.
     * @type {module:lib/dom/DomSmith}
     */
    #dom;

    /**
     * DomSmith instance for the settings menu slider.
     * @type {module:lib/dom/DomSmith|undefined}
     */
    #menu;

    /**
     * Canvas element used to draw the final ambient visualization.
     * @type {HTMLCanvasElement}
     */
    #renderCanvas;

    /**
     * 2D rendering context for the render canvas.
     * @type {CanvasRenderingContext2D}
     */
    #renderCtx;

    /**
     * Canvas element used for the final upscale.
     * @type {HTMLCanvasElement|OffscreenCanvas}
     */
    #scaleCanvas;

    /**
     * 2D rendering context for the scale canvas.
     * @type {CanvasRenderingContext2D}
     */
    #scaleCtx;

    /**
     * Pixel data from the current analysis frame.
     * @type {ImageData}
     */
    #pixelData;

    /**
     * Pixel data from the previous render iteration, used for smoothing.
     * @type {Uint8ClampedArray}
     */
    #prevPixelData;

    /**
     * Flag indicating whether the analyser is enabled.
     * @type {boolean}
     */
    #enabled = true;

    /**
     * Render Loop Instance, used for updating the scrubber.
     * @type {module:lib/util/Looper}
     */
    #renderLoop;

    /**
     * Creates an instance of the VisualizerAmbient component.
     * @param {module:src/core/Player}     player            Reference to the media player instance.
     * @param {module:src/util/AudioChain} parent            Reference to the parent instance.
     * @param {Object}                     [options]         Additional options.
     * @param {symbol}                     [options.apiKey]  Token for extended access to the player API.
     */
    constructor(player, parent, { apiKey }) {

        const config = player.getConfig('visualizerAmbient');
        if (!config || !config.selector) return [false];

        const configExt = extend({
            selector: config === true ? 'body' : isString(config) ? config : config.selector,
            gridSize: 4,
            gridScale: 4,
            smooth: 0.96,
            opacity: 0.7,
            analyseTimer: 250
        }, config);

        const attachNode = document.querySelector(configExt.selector);
        if (!attachNode) throw new Error(`[VisualizerAmbient] provided selector: ${config.selector} yielded no match.`);

        super(player, configExt, apiKey); // Call the parent class constructor with the extended configuration.

        this.#config = configExt;
        this.#player = player;
        this.#player.setConfig({ visualizerAmbient: this.#config });

        this.#dom = new DomSmith({
            _ref: 'wrapper',
            className: 'vip-visualizer vip-visualizer-ambient',
            _nodes: [{
                _ref: 'renderCanvas',
                _tag: 'canvas',
                className: 'vip-visualizer-ambient-canvas',
                style: `opacity: ${this.#config.opacity};`
            }]
        }, attachNode);

        // check if we have the settings menu available for additional UI
        const settingsPopup = this.#player.getComponent('ui.controller.popupSettings', apiKey);

        if (settingsPopup) {
            this.#menu = new DomSmith({
                _ref: 'menu',
                className: 'vip-menu',
                _nodes: [{
                    _tag: 'label',
                    _nodes: [
                        {
                            _tag: 'span',
                            className: 'form-label-text',
                            _nodes: [this.#player.locale.t('visualizerAmbient.header')]
                        }, {
                            _tag: 'input',
                            _ref: 'slider',
                            type: 'range',
                            min: 0,
                            max: 1,
                            step: 0.01,
                            value: this.#config.opacity,
                            className: 'has-center-line',
                            ariaLabel: this.#player.locale.t('visualizerAmbient.slider'),
                            'aria-valuetext': `${this.#config.opacity * 100}%`,
                            style: this.#config.opacity === 0 ? 'opacity: 0.5;' : '',
                            change: this.#setOpacity,
                            input: this.#setOpacity
                        }
                    ]
                }]
            }, settingsPopup.getElement('bottom'));
        }

        this.#prevPixelData = new Uint8ClampedArray(this.#config.gridSize * this.#config.gridSize * 4);
        this.#prevPixelData.fill(0);

        this.#renderLoop = new Looper(this.#render);

        this.#renderCanvas = this.#dom.renderCanvas;
        this.#renderCtx = this.#renderCanvas.getContext('2d', { alpha: false });
        this.#renderCtx.filter = 'blur(8px)';

        if (typeof window.OffscreenCanvas === 'undefined') {
            this.#scaleCanvas = document.createElement('canvas');
            this.#scaleCanvas.width = this.#scaleCanvas.height = this.#config.gridSize;
        } else {
            // use offscreencanvas if possible for better performance
            this.#scaleCanvas = new OffscreenCanvas(this.#config.gridSize, this.#config.gridSize);
        }

        this.#scaleCtx = this.#scaleCanvas.getContext('2d', { alpha: false });

        // NOTE: This methods are explicitly bound with .bind(this) to guarantee the correct this context in subclasses,
        // allowing overrides and super.method() calls to work reliably.
        this.#subscriptions = [
            ['data/ready', this.#onDataReady],
            ['media/enterpictureinpicture', this.stopLoop.bind(this)],
            ['media/leavepictureinpicture', this.startLoop.bind(this)],
            ['airplay/start', this.stopLoop.bind(this)],
            ['airplay/stop', this.startLoop.bind(this)],
            ['chromecast/start', this.stopLoop.bind(this)],
            ['chromecast/stop', this.startLoop.bind(this)],
            ['fullscreen/enter', this.disable.bind(this)],
            ['fullscreen/leave', this.enable.bind(this)]
        ].map(([event, handler]) => player.subscribe(event, handler));

    }

    /**
     * Called when media data is ready. If opacity is 0, disables the visualizer.
     * @param {module:src/core/Data~mediaItem} mediaItem  Object containing media info.
     * @listens module:src/core/Data#data/ready
     */
    #onDataReady = ({ mediaType }) => {

        if (!this.#enabled) return;

        if (this.#config.opacity === 0) this.disable();
        this.#dom.wrapper.classList.toggle('is-visible', mediaType !== 'audio');
        if (mediaType === 'audio') this.#menu?.unmount(); else this.#menu?.mount();

    };

    /**
     * Starts the ambient visualizer render loop.
     * @override
     * @listens module:src/casting/ChromeCast#chromecast/stop
     * @listens module:src/casting/AirPlay#airplay/stop
     * @listens module:src/core/Media#media/leavepictureinpicture
     */
    startLoop() {

        if (this.#player.getConfig('visualizerAmbient.opacity') === 0) this.disable();
        if (this.#player.getState('media.paused') || !this.#enabled) return;

        super.startLoop();
        this.#renderLoop.start();

    }

    /**
     * Stops the ambient visualizer render loop.
     * @override
     * @listens module:src/casting/ChromeCast#chromecast/start
     * @listens module:src/casting/AirPlay#airplay/start
     * @listens module:src/core/Media#media/enterpictureinpicture
     */
    stopLoop() {

        super.stopLoop();
        this.#renderLoop.stop();

    }

    /**
     * Performs one iteration of the analysis loop and schedules the next.
     * @override
     */
    analyseLoop() {

        this.#pixelData = super.analyseLoop();

    }

    /**
     * Refreshes the visualization immediately. Used when seeking.
     * @override
     * @listens module:src/core/Media#media/seeked
     * @listens module:src/core/Media#media/canplay
     */
    refresh() {

        if (this.#player.getConfig('visualizerAmbient.opacity') === 0 || !this.#player.getState('media.paused')) return;

        this.#pixelData = super.refresh();
        // start some render iterations to get a smooth transition even when the video is paused
        this.#renderLoop.start(20);

    }

    /**
     * Renders the ambient visualization. Applies linear interpolation to smooth pixel data,
     * draws the processed image onto the scale canvas, and then draws the scaled image onto the render canvas.
     * @param {number} [lr]  Interpolation factor for smoothing.
     */
    #render = (lr = 1 - this.#config.smooth) => {

        if (!this.#pixelData?.width) return;

        const avgData = new ImageData(this.#pixelData.width, this.#pixelData.height);
        for (let i = 0; i < avgData.data.length; i += 1) {
            avgData.data[i] = lerp(this.#prevPixelData[i], this.#pixelData.data[i], lr);
        }

        this.#prevPixelData = avgData.data;
        this.#scaleCtx.putImageData(avgData, 0, 0);
        this.#renderCtx.drawImage(this.#scaleCanvas, 0, 0, this.#renderCanvas.width, this.#renderCanvas.height);

    };

    /**
     * Sets the ambient visualizer opacity.
     * @param {event} event  The event object containing the target element.
     */
    #setOpacity = ({ target }) => {

        const oldOpacity = this.#config.opacity,
              newOpacity = Number(target.value);

        this.#config.opacity = this.#dom.renderCanvas.style.opacity = newOpacity;
        this.#player.setConfig({ visualizerAmbient: { opacity: newOpacity } });
        this.#menu.slider.setAttribute('aria-valuetext', `${newOpacity * 100}%`);

        if (oldOpacity === 0 && newOpacity) {
            this.#menu.slider.style.opacity = 1;
            this.startLoop();
        } else if (newOpacity === 0) {
            this.stopLoop();
            this.#menu.slider.style.opacity = 0.5;
        }
    };

    /**
     * Enables the analyser and restarts the analysis loop if playback is ongoing.
     */
    enable() {

        this.#enabled = true;
        super.enable();

    }

    /**
     * Disables the analyser.
     */
    disable() {

        this.#renderLoop.stop();
        this.#enabled = false;
        super.disable();

    }

    /**
     * Removes all events, subscriptions and DOM nodes created by this component.
     */
    destroy() {

        this.#player.unsubscribe(this.#subscriptions);
        this.#renderLoop.destroy();
        this.#menu?.destroy();
        this.#dom.destroy();
        this.#player = this.#dom = this.#menu = this.#scaleCtx = this.#scaleCanvas = this.#renderCtx = this.#renderCanvas = this.#renderLoop = null;
        super.destroy();

    }
}