Skip to content

Source: src/ui/Popup.js

import DomSmith from '../../lib/dom/DomSmith.js';
import LibPopup from '../util/PopupWrapper.js';

/**
 * Configuration options for the Popup Component. Those options are set at build time, not runtime by adding them to the "addComponent" function.
 * @typedef {Object} module:src/ui/Popup~PopupConfig
 * @property {string} [buttonClass]          CSS class(es) for the popup button in the controller.
 * @property {string} [label]                Translate Path for the accessible label text for the button.
 * @property {string} [attach]               Where to attach the button in the parent's container (e.g., "right", "top", etc.).
 * @property {string} [viewClass=""]         Additional CSS class to apply to the popup container.
 * @property {string} [hideNoContent=false]  If `true` and no content is present after dynamic deletion hide the popup icon completely, otherwise set it to disabled.
 * @example Player.addComponent("ui.controller.popupControls", Popup, { buttonClass: "icon control", viewClass: "vip-control-popup", label: "components.pictureControls.header", attach: "right" });
 */

/**
 * The Popup component adds a customizable button to the controller that opens a layered popup panel.
 * It is designed to be reused by other components such as AirPlay, Quality, PlaybackRate, and Loop, which inject their UI into the popup dynamically.
 * The component supports flexible layout areas (`top`, `center`, `bottom`) and only reveals itself when actual content is detected.
 * @exports module:src/ui/Popup
 * @requires lib/dom/DomSmith
 * @requires lib/ui/Popup
 * @author   Frank Kudermann - alphanull
 * @version  1.0.0
 * @license  MIT
 */
export default class Popup {

    /**
     * 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;

    /**
     * Secret key only known to the player instance and initialized components.
     * Used to be able to restrict access to API methods in conjunction with secure mode.
     * @type {symbol}
     */
    #apiKey;

    /**
     * A DomSmith instance to create a button in the controller or parent container.
     * This button is initially hidden until we detect content inside the popup.
     * @type {module:lib/dom/DomSmith}
     */
    #icon;

    /**
     * Reference to the popup instance.
     * @type {module:src/util/PopupWrapper}
     */
    #popup;

    /**
     * A DomSmith used as the content container within the popup.
     * Child components (AirPlay, Quality, etc.) attach their UI here.
     * @type {module:lib/dom/DomSmith}
     */
    #popupContent;

    /**
     * MutationObserver that reveals the popup button once content is added to the popup container.
     * Also tracks deletions of content nodes and automatically disables or hides the popup when no content is present.
     * @type {MutationObserver}
     */
    #mutationObserver;

    /**
     * If `true`
     * , popup icon is completely hidden instead of greyed our when popup content is empty.
     * @type {boolean}
     */
    #hideNoContent;

    /**
     * Creates an instance of the Popup component.
     * @param {module:src/core/Player}           player            Reference to the player instance.
     * @param {module:src/controller/Controller} parent            Reference to the parent instance, in this case the controller.
     * @param {module:src/ui/Popup~PopupConfig}  [options]         Additional configuration for the popup (defined at build time).
     * @param {symbol}                           [options.apiKey]  Token for extended access to the player API.
     */
    constructor(player, parent, options = {}) {

        const { buttonClass, label, attach, viewClass = '', sort = 0, hideNoContent = true } = options.config;

        this.#player = player;
        this.#hideNoContent = hideNoContent;

        if (options.apiKey) {
            this.#apiKey = options.apiKey;
            delete options.apiKey;
        }

        this.#icon = new DomSmith({
            _ref: 'button',
            _tag: 'button',
            className: buttonClass,
            'data-sort': sort || 0,
            ariaLabel: this.#player.locale.t(label),
            style: 'display: none;',
            click: this.showPopup,
            $tooltip: label ? { player, text: this.#player.locale.t(label) } : null
        }, parent.getElement(attach));

        this.#popup = new LibPopup(player, player.dom.getElement(this.#apiKey), {
            orientation: ['top', 'bottom'],
            margins: { top: 0, left: 20, right: 20, bottom: 0 },
            viewClass,
            targetHoverClass: 'is-hover',
            resize: false
        }, this.#apiKey);

        this.#popupContent = new DomSmith({
            _ref: 'wrapper',
            className: 'vip-popup-content',
            _nodes: [{
                _tag: 'span',
                class: 'is-invisible',
                id: 'pu-aria-label',
                _nodes: [this.#player.locale.t(label)]
            }, {
                _ref: 'top',
                className: 'vip-popup-content-top'
            }, {
                _ref: 'center',
                className: 'vip-popup-content-center'
            }, {
                _ref: 'bottom',
                className: 'vip-popup-content-bottom'
            }]
        });

        this.#subscriptions = [
            this.#player.subscribe('ui/hide', this.hidePopup),
            this.#player.subscribe('ui/resize', this.refreshPopup),
            this.#player.subscribe('media/ready', this.refreshPopup, { priority: -99 })
        ];

        this.#mutationObserver = new MutationObserver(this.#onMutation);
        this.#mutationObserver.observe(this.#popupContent.wrapper, { childList: true, subtree: true });

    }

    /**
     * Called by the mutation observer when popup content changes.
     * Hides or disables the popup icon if no content is present.
     * @param {Array} mutationList  [description].
     */
    #onMutation = mutationList => {
        let added = false,
            removed = false;

        for (const { type, addedNodes, removedNodes } of mutationList) {
            if (type === 'childList') {
                if (addedNodes.length) added = true;
                if (removedNodes.length) removed = true;
            }
        }

        if (added) {
            this.#icon.button.style.display = 'block';
            this.#icon.button.disabled = false;
        }

        const isContentEmpty = !this.#popupContent.top.hasChildNodes()
          && !this.#popupContent.center.hasChildNodes()
          && !this.#popupContent.bottom.hasChildNodes();

        if (removed && isContentEmpty) {
            this.hidePopup();
            if (this.#hideNoContent) this.#icon.button.style.display = 'none';
            else this.#icon.button.disabled = true;
        }
    };

    /**
     * Shows the popup when the user clicks the associated button.
     * @param {Event} event  The DOM event that triggered this method.
     */
    showPopup = event => {

        this.#popup.show(this.#popupContent.wrapper, event);

    };

    /**
     * Hides the popup, either because the UI hides or the user clicks away from the popup.
     * @listens module:src/ui/UI#ui/hide
     */
    hidePopup = () => {

        this.#popup.hide(null, { focus: false });

    };

    /**
     * Refreshes Popup (recalculates layout) on media/ready if open.
     * May be necessary because child components might alter popup content.
     * This handler is invoked with a very low priority to ensure it comes last.
     * @listens module:src/core/Media#media/ready
     */
    refreshPopup = () => {

        if (this.#popup.state === 'visible') this.#popup.layout();

    };

    /**
     * Provides container elements for child components that want to attach content to this popup.
     * "top", "center", and "bottom" can be used for layout areas, or fallback to the root wrapper.
     * @param   {string}      area  The desired area: "top", "center", "bottom".
     * @returns {HTMLElement}       The container element for that area.
     */
    getElement(area) {

        return this.#popupContent[area] ?? this.#popupContent.wrapper;

    }

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

        this.#mutationObserver.disconnect(this.#popupContent.wrapper);
        this.#popup.remove();
        this.#icon.destroy();
        this.#popupContent.destroy();
        this.#player.unsubscribe(this.#subscriptions);
        this.#player = this.#icon = this.#popup = this.#popupContent = this.#mutationObserver = this.#apiKey = null;
    }

}