Skip to content

Source: src/controller/Volume.js

import DomSmith from '../../lib/dom/DomSmith.js';

/**
 * The Volume component allows the user to adjust the media volume and toggle mute/unmute.
 * It optionally includes a slider UI for fine-grained control, which can auto-hide based on configuration.
 * The component listens to player state and updates its icon and slider accordingly.
 * On touch devices, it features specific interaction behavior for first tap and slider visibility.
 * @exports module:src/controller/Volume
 * @requires lib/dom/DomSmith
 * @author   Frank Kudermann - alphanull
 * @version  1.0.0
 * @license  MIT
 */
export default class Volume {

    /**
     * Holds the instance configuration for this component.
     * @type     {Object}
     * @property {boolean} [slider=true]          If enabled, a volume slider is shown. If disabled, only muting / unmuting would be possible.
     * @property {boolean} [sliderAutoHide=true]  If enabled, the slider is automatically hidden on `pointerout` after a short delay.
     */
    #config = {
        slider: true,
        sliderAutoHide: true
    };

    /**
     * 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 volume wrapper.
     * @type {module:lib/dom/DomSmith}
     */
    #dom;

    /**
     * Used to restore former volume when muting / unmuting.
     * @type {number}
     */
    #savedVolume = 0;

    /**
     * Helper variable used for touch interaction. Since in this case there is no mouseover,
     * the first tap enables the slider, and a second one mutes the media.
     * @type {boolean}
     */
    #firstTap = false;

    /**
     * Timeout ID for hiding the slider automatically.
     * @type {number|undefined}
     */
    #autoHideId;

    /**
     * Creates an instance of the Volume component.
     * @param {module:src/core/Player}           player  Reference to the VisionPlayer instance.
     * @param {module:src/controller/Controller} parent  Reference to the parent instance, in this case the controller.
     */
    constructor(player, parent) {

        this.#config = player.initConfig('volumeControl', this.#config);

        if (!this.#config) return [false];

        this.#player = player;

        const domConfig = {
            _ref: 'wrapper',
            className: `vip-volume-wrapper${this.#config.sliderAutoHide ? '' : ' is-visible'}`,
            'data-sort': 30,
            _nodes: [{
                _tag: 'button',
                _ref: 'volButton',
                className: 'volume icon',
                ariaLabel: this.#player.locale.t('misc.volume'),
                pointerup: this.#toggleMute,
                $tooltip: { player, text: this.#player.locale.t('misc.volume') }
            }]
        };

        if (this.#config.slider && !this.#player.getClient('iOS')) {

            domConfig._nodes.push({
                className: 'vip-volume-slider',
                _nodes: [{
                    _tag: 'input',
                    _ref: 'volSlider',
                    className: 'vip-volume-slider-input',
                    type: 'range',
                    min: 0,
                    max: 1,
                    step: 0.01,
                    'data-sort': 21,
                    ariaLabel: this.#player.locale.t('misc.volumeSlider'),
                    change: this.#setVolume,
                    input: this.#setVolume
                }]
            });

            if (this.#config.sliderAutoHide) {
                domConfig.pointerover = this.#showSlider;
                domConfig.pointerout = this.#hideSlider;
            }
        }

        this.#dom = new DomSmith(domConfig, parent.getElement('left'));

        this.#subscriptions = [
            ['media/volumechange', this.#onVolumeUpdate],
            ['data/ready', this.#onVolumeUpdate],
            ['data/nomedia', this.#disable],
            ['media/error', this.#disable],
            ['media/canplay', this.#enable]
        ].map(([event, handler]) => this.#player.subscribe(event, handler));

    }

    /**
     * Toggle mute state on click/tap:
     * - On touch devices: First tap -> reveal the volume slider / Second tap -> actually mute/unmute
     * - On desktop: single click -> toggle mute immediately.
     * @param {PointerEvent} [event]  The PointerEvent that triggered this call.
     */
    #toggleMute = ({ pointerType }) => {

        if (pointerType === 'touch' && this.#config.slider && !this.#player.getClient('iOS')) {
            if (this.#firstTap === false) {
                this.#firstTap = true;
                return;
            }
        }

        this.#player.media.mute(!this.#player.getState('media.muted'));
        if (this.#player.getState('media.muted')) this.#player.media.volume(this.#savedVolume || 1);

    };

    /**
     * Sets the volume of the media based on user input in this component.
     */
    #setVolume = () => {

        if (this.#player.getState('media.muted') && this.#dom.volSlider.value > 0) {
            this.#player.media.mute(false);
        } else if (this.#dom.volSlider.value === '0' && !this.#player.getState('media.muted')) {
            this.#player.media.mute(true);
        }

        this.#player.media.volume(this.#dom.volSlider.value);

    };

    /**
     * Updates slider values and also adapts the icon display based on the current volume.
     * @listens module:src/core/Data#data/ready
     * @listens module:src/core/Media#media/volumechange
     */
    #onVolumeUpdate = () => {

        const volume = this.#player.getState('media.volume'),
              muted = this.#player.getState('media.muted'),
              { volButton, volSlider } = this.#dom;

        if (volSlider) {
            volSlider.value = muted ? 0 : volume;
            volSlider.setAttribute('aria-valuetext', `${this.#player.locale.t('misc.volume')}: ${volSlider.value * 100}%`);
        }

        this.#savedVolume = volume;

        volButton.classList.toggle('is-half', volume <= 0.5);
        volButton.classList.toggle('is-muted', volume === 0 || muted);

    };

    /**
     * Shows the volume slider (if autohide is enabled).
     * @param   {PointerEvent}      [event]  The PointerEvent that triggered this call.
     * @returns {boolean|undefined}          Returns false when disabled.
     * @fires   module:src/controller/Volume#volume/slider/show
     */
    #showSlider = ({ pointerType, target } = {}) => {

        if (target?.disabled) return false;

        if (pointerType === 'touch') {
            clearTimeout(this.#autoHideId);
            this.#autoHideId = setTimeout(this.#hideSlider, 3000);
        }

        this.#dom.wrapper.classList.add('is-visible');

    };

    /**
     * Hides the volume slider (if autohide is enabled).
     * @param {PointerEvent} [event]  The PointerEvent that triggered this call.
     * @fires module:src/controller/Volume#volume/slider/hide
     */
    #hideSlider = ({ pointerType } = {}) => {

        if (pointerType === 'touch' || !this.#dom.wrapper.classList.contains('is-visible')) return;

        this.#dom.wrapper.classList.remove('is-visible');
        this.#dom.volButton.blur();
        this.#firstTap = false;

    };

    /**
     * Disables the volume component (e.g., no media, or an error).
     * @listens module:src/core/Data#data/nomedia
     * @listens module:src/core/Media#media/error
     */
    #disable = () => {

        this.#hideSlider();
        this.#dom.volButton.disabled = true;
        if (this.#dom.volSlider) this.#dom.volSlider.disabled = true;

    };

    /**
     * Enables the volume component (e.g., once media can play).
     * @listens module:src/core/Media#media/canplay
     */
    #enable = () => {

        if (!this.#config.sliderAutoHide && this.#config.slider) this.#showSlider();
        this.#dom.volButton.disabled = false;
        if (this.#dom.volSlider) this.#dom.volSlider.disabled = false;

    };

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

        clearTimeout(this.#autoHideId);
        this.#dom.destroy();
        this.#player.unsubscribe(this.#subscriptions);
        this.#player = this.#dom = null;

    }

}