Skip to content

Source: src/controller/PictureInPicture.js

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

const testEle = document.createElement('video'),
      hasPiP = 'pictureInPictureEnabled' in document,
      needsPiPWebkit = !hasPiP && testEle.webkitSupportsPresentationMode && typeof testEle.webkitSetPresentationMode === 'function';

/**
 * The PictureInPicture component enables support for native Picture-in-Picture (PiP) mode on platforms that support the standardized or WebKit-specific API.
 * It provides a control button in the settings menu and backdrop UI.
 * @exports module:src/controller/PictureInPicture
 * @requires lib/dom/DomSmith
 * @author   Frank Kudermann - alphanull
 * @version  1.0.0
 * @license  MIT
 */
export default class PictureInPicture {

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

    /**
     * Reference to the parent instance.
     * @type {module:src/controller/Controller}
     */
    #parent;

    /**
     * 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 for the PiP toggle button placed in the top container.
     * @type {module:lib/dom/DomSmith}
     */
    #control;

    /**
     * A DomSmith instance for the PiP backdrop, used if the video is displayed as PiP and
     * the user wants to cancel PiP or see a placeholder.
     * @type {module:lib/dom/DomSmith}
     */
    #backdrop;

    /**
     * Type of the media ('video' or 'audio').
     * @type {string}
     */
    #mediaType = '';

    /**
     * Creates an instance of the PictureInPicture 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 component.
     * @param {Object}                           [options]         Additional options.
     * @param {symbol}                           [options.apiKey]  Token for extended access to the player API.
     */
    constructor(player, parent, { apiKey }) {

        if (player.getClient('iPhone') || !hasPiP && !needsPiPWebkit || !player.initConfig('pictureInPicture', true)) return [false];

        this.#player = player;
        this.#parent = parent;
        this.#apiKey = apiKey;

        const id = this.#player.getConfig('player.id');

        this.#control = new DomSmith({
            _tag: 'label',
            for: `pip-control-${id}`,
            _nodes: [{
                _tag: 'span',
                className: 'form-label-text',
                _nodes: this.#player.locale.t('pip.title')
            }, {
                _ref: 'input',
                _tag: 'input',
                id: `pip-control-${id}`,
                name: `pip-control-${id}`,
                type: 'checkbox',
                className: 'is-toggle',
                change: this.#togglePip
            }]
        });

        this.#backdrop = new DomSmith({
            _ref: 'wrapper',
            className: 'vip-pip',
            'data-sort': 70,
            ariaHidden: true,
            _nodes: [{
                className: 'vip-pip-bg',
                _nodes: [{
                    _tag: 'p',
                    _nodes: [this.#player.locale.t('pip.placeholder')]
                }]
            }, {
                _tag: 'button',
                _ref: 'button',
                tabIndex: -1,
                click: this.#togglePip,
                _nodes: [this.#player.locale.t('pip.cancel')]
            }]
        }, this.#player.dom.getElement(this.#apiKey));

        this.#subscriptions = [
            ['data/ready', this.#onDataReady],
            ['data/nomedia', this.#disable],
            ['chromecast/start', this.#disable],
            ['chromecast/stop', this.#enable],
            ['airplay/start', this.#disable],
            ['airplay/stop', this.#enable],
            ['media/enterpictureinpicture', this.#onPipEnter],
            ['media/leavepictureinpicture', this.#onPipExit],
            ['media/error', this.#disable],
            ['media/canplay', this.#enable]
        ].map(([event, handler]) => this.#player.subscribe(event, handler));

    }

    /**
     * Called when media data is ready. Enables or disables PiP if the media is video or not.
     * @param {module:src/core/Data~mediaItem} mediaItem            Object containing media type info.
     * @param {string}                         mediaItem.mediaType  Type of the media ('video' or 'audio').
     * @listens module:src/core/Data#data/ready
     */
    #onDataReady = ({ mediaType }) => {

        this.#mediaType = mediaType;
        if (mediaType === 'audio') this.#disable(); else this.#enable();

    };

    /**
     * Toggles PiP status. If PiP is active, exit; otherwise enter PiP mode.
     */
    #togglePip = () => {

        if (!needsPiPWebkit && document.pictureInPictureElement
          || needsPiPWebkit && this.#player.media.getElement(this.#apiKey).webkitPresentationMode === 'picture-in-picture') {
            this.#pipExit();
        } else {
            this.#pipEnter();
        }

    };

    /**
     * Enters Picture-in-Picture mode via the appropriate API (standard or webkit).
     * @returns {Promise<void>}
     */
    async #pipEnter() {

        try {
            if (needsPiPWebkit) {
                this.#player.media.getElement(this.#apiKey).webkitSetPresentationMode('picture-in-picture');
            } else {
                await this.#player.media.getElement(this.#apiKey).requestPictureInPicture();
            }
        } catch {}

    }

    /**
     * Exits Picture-in-Picture mode via the appropriate API (standard or webkit).
     * @returns {Promise<void>}
     */
    async #pipExit() {

        try {
            if (needsPiPWebkit) {
                this.#player.media.getElement(this.#apiKey).webkitSetPresentationMode('inline');
            } else {
                await document.exitPictureInPicture();
            }
        } catch {}

    }

    /**
     * Handler for the 'media/enterpictureinpicture' event. Adjusts UI/state accordingly by displaying the PiP backdrop.
     * @listens module:src/core/Media#media/enterpictureinpicture
     */
    #onPipEnter = () => {

        this.#player.dom.getElement(this.#apiKey).classList.add('is-pip');
        // this.#control.button.classList.add('is-active');
        // this.#control.button.setAttribute('aria-pressed', 'true');
        this.#control.input.checked = true;
        this.#backdrop.button.removeAttribute('tabindex');
        this.#backdrop.wrapper.removeAttribute('aria-hidden');

    };

    /**
     * Handler for the 'media/leavepictureinpicture' event. Adjusts UI/state accordingly.
     * @listens module:src/core/Media#media/leavepictureinpicture
     */
    #onPipExit = () => {

        this.#player.dom.getElement(this.#apiKey).classList.remove('is-pip');
        // this.#control.button.classList.remove('is-active');
        // this.#control.button.setAttribute('aria-pressed', 'false');
        this.#control.input.checked = false;
        this.#backdrop.button.setAttribute('tabindex', '-1');
        this.#backdrop.wrapper.setAttribute('aria-hidden', 'true');

    };

    /**
     * Enables the PiP button if conditions allow it.
     * @listens module:src/casting/ChromeCast#chromecast/stop
     * @listens module:src/casting/AirPlay#airplay/stop
     * @listens module:src/core/Media#media/canplay
     */
    #enable = () => {

        if (this.#mediaType !== 'audio') this.#control.mount(this.#parent.getElement('top'));

    };

    /**
     * Disables the PiP button, and exits PiP if currently active.
     * @listens module:src/casting/ChromeCast#chromecast/start
     * @listens module:src/casting/AirPlay#airplay/start
     * @listens module:src/core/Data#data/nomedia
     * @listens module:src/core/Media#media/error
     */
    #disable = () => {

        this.#control.unmount();
        if (this.#player.dom.getElement(this.#apiKey).classList.contains('is-pip')) this.#pipExit();

    };

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

        this.#backdrop.destroy();
        this.#control.destroy();
        this.#player.unsubscribe(this.#subscriptions);
        this.#player = this.#control = this.#player = this.#backdrop = this.#apiKey = null;

    }
}