Skip to content

Source: src/selection/File.js

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

/**
 * The File component provides functionality to handle playing local media files.
 * Files can be selected via the standard file selector or by dragging & dropping.
 * It converts the selected files into binary blobs and passes them to the player where they can be played as usual.
 * If more than one file is selected, a corresponding playlist will be automatically generated.
 * @exports module:src/selection/File
 * @requires lib/dom/DomSmith
 * @author   Frank Kudermann - alphanull
 * @version  1.0.0
 * @license  MIT
 */
export default class File {

    /**
     * Contains configuration options for how files are handled.
     * @type     {Object}
     * @property {boolean} [fileDrop=true]            Enables drag & drop file upload.
     * @property {boolean} [fileSelector=true]        Enables the file selection button in the controller.
     * @property {boolean} [fileSelectorAccept=true]  If `true`, the file picker limits selection to supported extensions (e.g. .mp4, .webm); if false, it accepts any file type, enabling formats such as .mov or .mkv.
     */
    #config = {
        fileDrop: true,
        fileSelector: true,
        fileSelectorAccept: true
    };

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

    /**
     * Reference to the DomSmith instance for the file selector button.
     * @type {module:lib/dom/DomSmith}
     */
    #button;

    /**
     * Reference to the DomSmith instance for the drop zone.
     * @type {module:lib/dom/DomSmith}
     */
    #dropZone;

    /**
     * Holds uploades blobs for later revoking.
     * @type {Array}
     */
    #blobs = [];

    /**
     * Creates an instance of the File component.
     * @param {module:src/core/Player}           player            Reference to the VisionPlayer instance.
     * @param {module:src/controller/Controller} parent            The parent container, in this case the controller.
     * @param {Object}                           [options]         Additional options.
     * @param {symbol}                           [options.apiKey]  Token for extended access to the player API.
     */
    constructor(player, parent, { apiKey }) {

        // This component is disabled by default and has to be explicitly enabled via playerConfig
        if (!player.getConfig('file')) return [false];

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

        const hasDragDrop = 'draggable' in player.dom.getElement(apiKey),
              { fileDrop, fileSelector } = this.#config;

        // If neither fileDrop is enabled nor fileSelector with drag & drop is available, do not add this component.
        if (!fileSelector && (!fileDrop || !hasDragDrop)) return [false];

        this.#player = player;

        // get all supported extensions
        const allowedExts = this.#player.constructor.getFormats().reduce((acc, format) => acc.concat(format.extensions), []);

        if (this.#config.fileSelector) {

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

            this.#button = new DomSmith({
                _ref: 'wrapper',
                className: 'vip-file',
                'data-sort': 55,
                _nodes: [{
                    _tag: 'input',
                    id: `vip-file-input-${playerId}`,
                    className: 'vip-file-input is-hidden',
                    type: 'file',
                    multiple: true,
                    ariaLabel: this.#player.locale.t('file.select'),
                    accept: this.#config.fileSelectorAccept ? allowedExts.map(ext => `.${ext}`).join(',') : null,
                    change: this.#fileSelected
                },
                {
                    _ref: 'button',
                    _tag: 'label',
                    for: `vip-file-input-${playerId}`,
                    className: 'icon file',
                    $tooltip: { player, text: this.#player.locale.t('file.select') }
                }]
            }, parent.getElement('right'));

        }

        if (this.#config.fileDrop && hasDragDrop) {

            this.#dropZone = new DomSmith({
                _ref: 'drop',
                className: 'vip-file-dropper',
                ariaHidden: true,
                dragenter: this.#onZoneDragEnter,
                dragleave: this.#onZoneDragLeave,
                drop: this.#onZoneDrop,
                _nodes: [{
                    className: 'vip-file-dropper-icon icon'
                }, {
                    className: 'vip-file-dropper-text-drop',
                    _nodes: [this.#player.locale.t('file.drop')]
                }]
            }, this.#player.dom.getElement(apiKey));

            document.addEventListener('dragover', this.#onWinDragOver);
            document.addEventListener('dragleave', this.#onWinDragLeave);

        }
    }

    /**
     * Handler for file selection via the file input or drag'n'drop event.
     * Creates an array of file objects with properties:
     * - title: File name.
     * - ext: File extension.
     * - src: Blob URL created from the file.
     * - type: MIME type of the file.
     * Then sets the media data for the player by creating a playlist of the selcted files.
     * @param {Event} event  The change event from the file input.
     */
    #fileSelected = event => {

        // remove old uploads first
        this.#blobs.forEach(url => URL.revokeObjectURL(url));
        this.#blobs = [];

        const files = [];

        for (const file of event.target.files) {
            const url = URL.createObjectURL(file);
            this.#blobs.push(url);
            files.push({
                title: file.name,
                src: url,
                mimeType: file.type || (file.name.endsWith('.mkv') ? 'video/x-matroska' : '')
            });
        }

        this.#player.data.setMediaData({
            title: 'My File Upload',
            media: files
        }).catch(error => {
            if (error.name !== 'AbortError' && error.name !== 'DataError') throw error;
        });

    };

    /**
     * Handles the dragenter event on the drop zone. Displays a visual indication that the file can be dropped.
     * @param {DragEvent} event  The dragenter event.
     * @listens module:src/selection/File#dragover
     */
    #onZoneDragEnter = event => {

        if (!this.#isFileDrag(event)) return;

        // prevent safari from firing while over the inner drop zone
        document.removeEventListener('dragleave', this.#onWinDragLeave);

        event.stopPropagation();
        event.preventDefault();
        event.dataTransfer.dropEffect = 'copy';
        this.#dropZone.drop.classList.add('drag-over');

    };

    /**
     * Handles the dragleave event on the drop zone. Removes the visual drag indicator.
     * @param {DragEvent} event  The dragleave event.
     * @listens module:src/selection/File#dragleave
     */
    #onZoneDragLeave = event => {

        if (!this.#isFileDrag(event)) return;

        document.addEventListener('dragleave', this.#onWinDragLeave); // enable again!
        this.#dropZone.drop.classList.remove('drag-over');

    };

    /**
     * Handles the drop, by extracting the associated files from the drop event.
     * @param {DragEvent} event  The originating drag event.
     * @listens module:src/selection/File#drop
     */
    #onZoneDrop = event => {

        if (!this.#isFileDrag(event)) return;

        document.removeEventListener('dragleave', this.#onWinDragLeave);
        this.#dropZone.drop.classList.remove('drag-over');
        this.#dropZone.drop.classList.remove('is-active');
        event.stopPropagation();
        event.preventDefault();

        this.#fileSelected({ target: { files: event.dataTransfer.files } });

    };

    /**
     * Handles the dragenter event on the window. Visually activates the inner drop zone.
     * @param {DragEvent} event  The dragenter event.
     * @listens module:src/selection/File#dragover
     */
    #onWinDragOver = event => {

        if (!this.#isFileDrag(event)) return;

        event.preventDefault();
        this.#dropZone.drop.classList.add('is-active');
        event.dataTransfer.dropEffect = 'copy';

    };

    /**
     * Handles the dragleave event on the window. Visually deactivates the inner drop zone.
     * @param {DragEvent} event  The dragleave event.
     * @listens module:src/selection/File#dragover
     */
    #onWinDragLeave = event => {

        if (!this.#isFileDrag(event)) return;

        this.#dropZone.drop.classList.remove('is-active');

    };

    /**
     * Helper function to determine whether the drag event contains files.
     * @param   {DragEvent} event  The originating drag event.
     * @returns {boolean}          Returns true if the drag event contains files.
     */
    #isFileDrag(event) { // eslint-disable-line class-methods-use-this

        const dt = event.dataTransfer;
        return dt.types && (dt.types.indexOf ? dt.types.indexOf('Files') !== -1 : dt.types.contains('Files'));

    }

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

        window.removeEventListener('dragover', this.#onWinDragOver);
        window.removeEventListener('dragleave', this.#onWinDragLeave);
        this.#blobs.forEach(url => URL.revokeObjectURL(url));
        this.#button?.destroy();
        this.#dropZone?.destroy();
        this.#player = this.#button = this.#dropZone = this.#blobs = null;

    }

}