lib/editor/tiny/plugins/media/amd/src/embed/embedpreview.js

// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.

/**
 * Tiny media plugin embed preview and details class.
 *
 * This handles the embed file/url preview before embedding them into tiny editor.
 *
 * @module      tiny_media/embed/embedpreview
 * @copyright   2024 Stevani Andolo <stevani@hotmail.com.au>
 * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */

import Selectors from '../selectors';
import {component} from '../common';
import {getString} from 'core/str';
import {
    sourceTypeChecked,
    getFileName,
    setPropertiesFromData,
    showElements,
    stopMediaLoading,
    hideElements,
} from '../helpers';
import {EmbedHandler} from './embedhandler';
import {MediaBase} from '../mediabase';
import Notification from 'core/notification';
import EmbedModal from '../embedmodal';
import {
    getEmbeddedMediaDetails,
    insertMediaThumbnailTemplateContext,
    fetchPreview,
} from './embedhelpers';
import {notifyFilterContentUpdated} from 'core_filters/events';

export class EmbedPreview extends MediaBase {

    // Selector type for "EMBED".
    selectorType = Selectors.EMBED.type;

    // Fixed aspect ratio used for external media providers.
    linkMediaAspectRatio = 1.78;

    constructor(data) {
        super();
        setPropertiesFromData(this, data); // Creates dynamic properties based on "data" param.
    }

    /**
     * Init the media details preview.
     */
    init = async() => {
        this.currentModal.setTitle(getString('mediadetails', component));
        sourceTypeChecked({
            fetchedTitle: this.fetchedMediaLinkTitle ?? null,
            source: this.originalUrl,
            root: this.root,
            urlSelector: Selectors.EMBED.elements.fromUrl,
            fileNameSelector: Selectors.EMBED.elements.fileNameLabel,
        });
        this.setMediaSourceAndPoster();
        this.registerMediaDetailsEventListeners(this.currentModal);
    };

    /**
     * Sets media source and thumbnail for the video.
     */
    setMediaSourceAndPoster = async() => {
        const box = this.root.querySelector(Selectors.EMBED.elements.previewBox);
        const previewArea = document.querySelector(Selectors.EMBED.elements.mediaPreviewContainer);
        previewArea.setAttribute('data-original-url', this.originalUrl);

        // Previewing existing media could be a link one.
        // Or, new media added using url input and mediaType is neither video or audio.
        if (this.mediaType === 'link' || (this.newMediaLink && !['video', 'audio'].includes(this.mediaType))) {
            previewArea.setAttribute('data-media-type', 'link');
            previewArea.innerHTML = await fetchPreview(this.originalUrl, this.contextId);
            notifyFilterContentUpdated(previewArea);
        } else if (this.mediaType === 'video') {
            const video = document.createElement('video');
            video.src = this.originalUrl;

            // Media url can be played using html video.
            video.addEventListener('loadedmetadata', () => {
                const videoHeight = video.videoHeight;
                const videoWidth = video.videoWidth;
                const widthProportion = (videoWidth - videoHeight);
                const isLandscape = widthProportion > 0;

                // Store dimensions of the raw video.
                this.mediaDimensions = {
                    width: videoWidth,
                    height: videoHeight,
                };

                // Set the media preview based on the media dimensions.
                if (isLandscape) {
                    video.width = box.offsetWidth;
                } else {
                    video.height = box.offsetHeight;
                }

                const height = this.root.querySelector(Selectors.EMBED.elements.height);
                const width = this.root.querySelector(Selectors.EMBED.elements.width);

                if (height.value === '' && width.value === '') {
                    height.value = videoHeight;
                    width.value = videoWidth;
                }

                // Size checking and adjustment.
                if (videoHeight === parseInt(height.value) && videoWidth === parseInt(width.value)) {
                    this.currentWidth = this.mediaDimensions.width;
                    this.currentHeight = this.mediaDimensions.height;
                    this.sizeChecked('original');
                } else {
                    this.currentWidth = parseInt(width.value);
                    this.currentHeight = parseInt(height.value);
                    this.sizeChecked('custom');
                }
            });

            video.controls = true;
            if (this.media.poster) {
                previewArea.setAttribute('data-media-poster', this.media.poster);
                if (!video.classList.contains('w-100')) {
                    video.classList.add('w-100');
                }
                video.poster = this.media.poster;
            }
            video.load();

            previewArea.setAttribute('data-media-type', 'video');
            previewArea.innerHTML = video.outerHTML;
            notifyFilterContentUpdated(previewArea);
        } else if (this.mediaType === 'audio') {
            const audio = document.createElement('audio');
            audio.src = this.originalUrl;
            audio.controls = true;
            audio.load();

            previewArea.setAttribute('data-media-type', 'audio');
            previewArea.innerHTML = audio.outerHTML;
            notifyFilterContentUpdated(previewArea);
        } else {
            // Show warning notification.
            const urlWarningLabelEle = this.root.querySelector(Selectors.EMBED.elements.urlWarning);
            urlWarningLabelEle.innerHTML = await getString('medianotavailabledesc', component, this.originalUrl);
            showElements(Selectors.EMBED.elements.urlWarning, this.root);

            // Stop the spinner.
            stopMediaLoading(this.root, Selectors.EMBED.type);

            // Reset the upload form.
            (new EmbedHandler(this)).resetUploadForm();
            return;
        }

        // Stop the loader and display back the body template when the media is loaded.
        stopMediaLoading(this.root, Selectors.EMBED.type);
        showElements(Selectors.EMBED.elements.mediaDetailsBody, this.root);

        // Set the media name/title.
        this.root.querySelector(Selectors.EMBED.elements.title).value = this.setMediaTitle();
    };

    /**
     * Set media name/title.
     *
     * @returns {string}
     */
    setMediaTitle = () => {
        // Getting and setting up media title/name.
        let fileName = null;
        if (['video', 'audio'].includes(this.mediaType)) {
            fileName = getFileName(this.originalUrl); // Get original filename.
        } else if (this.fetchedMediaLinkTitle) {
            fileName = this.fetchedMediaLinkTitle;
        } else {
            fileName = this.originalUrl;
        }

        if (this.isUpdating) {
            if (!this.newMediaLink) {
                fileName = this.mediaTitle; // Title from the selected media.
            }
        }

        return fileName;
    };

    /**
     * Deletes the media after confirming with the user and loads the insert media page.
     */
    deleteMedia = () => {
        Notification.deleteCancelPromise(
            getString('deletemedia', component),
            getString('deletemediawarning', component),
        ).then(() => {
            // Reset media upload form.
            (new EmbedHandler(this)).resetUploadForm();

            // Delete any selected media mediaData.
            delete this.mediaData;
            return;
        }).catch(error => {
            window.console.log(error);
        });
    };

    /**
     * Delete embedded media thumbnail.
     */
    deleteEmbeddedThumbnail = () => {
        Notification.deleteCancelPromise(
            getString('deleteembeddedthumbnail', component),
            getString('deleteembeddedthumbnailwarning', component),
        ).then(async() => {
            if (this.mediaType === 'video') {
                const video = this.root.querySelector('video');
                if (video) {
                    video.removeAttribute('poster');
                    const preview = this.root.querySelector(Selectors.EMBED.elements.mediaPreviewContainer);
                    preview.removeAttribute('data-media-poster');
                }
            }

            const deleteCustomThumbnail = this.root.querySelector(Selectors.EMBED.actions.deleteCustomThumbnail);
            deleteCustomThumbnail.remove();

            const uploadCustomThumbnail = this.root.querySelector(Selectors.EMBED.actions.uploadCustomThumbnail);
            uploadCustomThumbnail.textContent = await getString('uploadthumbnail', component);
            return;
        }).catch(error => {
            window.console.log(error);
        });
    };

    /**
     * Shows the insert thumbnail dialogue.
     */
    showUploadThumbnail = async() => {
        const uploadThumbnailModal = await EmbedModal.create({
            large: true,
            templateContext: {elementid: this.editor.getElement().id},
        });
        const root = uploadThumbnailModal.getRoot()[0];

        // Get selected media metadata.
        const mediaData = getEmbeddedMediaDetails(this);
        mediaData.isUpdating = this.isUpdating;

        const embedHandler = new EmbedHandler(this);
        embedHandler.loadInsertThumbnailTemplatePromise(
            insertMediaThumbnailTemplateContext(this), // Get template context for creating media thumbnail.
            {root, uploadThumbnailModal}, // Required root elements.
            await embedHandler.getMediaTemplateContext(mediaData) // Get current media data.
        );
    };

    /**
     * Only registers event listeners for new loaded elements in embed preview modal.
     */
    registerMediaDetailsEventListeners = async() => {
        // Handle the original size when selected.
        const sizeOriginalEle = this.root.querySelector(Selectors.EMBED.elements.sizeOriginal);
        if (sizeOriginalEle) {
            sizeOriginalEle.addEventListener('change', () => {
                this.sizeChecked('original');
            });
        }

        // Handle the custom size when selected.
        const sizeCustomEle = this.root.querySelector(Selectors.EMBED.elements.sizeCustom);
        if (sizeCustomEle) {
            sizeCustomEle.addEventListener('change', () => {
                this.sizeChecked('custom');
            });
        }

        const widthEle = this.root.querySelector(Selectors.EMBED.elements.width);
        const heightEle = this.root.querySelector(Selectors.EMBED.elements.height);

        // Handle the custom with size when inputted.
        if (widthEle) {
            widthEle.addEventListener('input', () => {
                if (this.mediaType === 'link') {
                    // Let's apply the 16:9 aspect ratio if it's a link media type.
                    heightEle.value = Math.round(widthEle.value / this.linkMediaAspectRatio);
                } else {
                    // Avoid empty value.
                    widthEle.value = widthEle.value === "" ? 0 : Number(widthEle.value);
                    this.autoAdjustSize();
                }
            });
        }

        // Handle the custom height size when inputted.
        if (heightEle) {
            heightEle.addEventListener('input', () => {
                if (this.mediaType === 'link') {
                    // Let's apply the 16:9 aspect ratio if it's a link media type.
                    widthEle.value = Math.round(heightEle.value * this.linkMediaAspectRatio);
                } else {
                    // Avoid empty value.
                    heightEle.value = heightEle.value === "" ? 0 : Number(heightEle.value);
                    this.autoAdjustSize(true);
                }
            });
        }

        // Handle media preview delete.
        const deleteMedia = this.root.querySelector(Selectors.EMBED.actions.deleteMedia);
        if (deleteMedia) {
            deleteMedia.addEventListener('click', (e) => {
                e.preventDefault();
                this.deleteMedia();
            });
        }

        // Show subtitles and captions settings.
        const showSubtitleCaption = this.root.querySelector(Selectors.EMBED.actions.showSubtitleCaption);
        if (showSubtitleCaption) {
            showSubtitleCaption.addEventListener('click', (e) => {
                e.preventDefault();
                hideElements([
                    Selectors.EMBED.actions.showSubtitleCaption,
                    Selectors.EMBED.actions.cancelMediaDetails,
                    Selectors.EMBED.elements.mediaDetailsBody,
                ], this.root);
                showElements([
                    Selectors.EMBED.actions.backToMediaDetails,
                    Selectors.EMBED.elements.mediaSubtitleCaptionBody,
                ], this.root);
            });
        }

        // Back to media preview.
        const backToMediaDetails = this.root.querySelector(Selectors.EMBED.actions.backToMediaDetails);
        if (backToMediaDetails) {
            backToMediaDetails.addEventListener('click', () => {
                hideElements([
                    Selectors.EMBED.actions.backToMediaDetails,
                    Selectors.EMBED.elements.mediaSubtitleCaptionBody,
                ], this.root);
                showElements([
                    Selectors.EMBED.actions.showSubtitleCaption,
                    Selectors.EMBED.actions.cancelMediaDetails,
                    Selectors.EMBED.elements.mediaDetailsBody,
                ], this.root);
            });
        }

        // Handles upload media thumbnail.
        const uploadCustomThumbnail = this.root.querySelector(Selectors.EMBED.actions.uploadCustomThumbnail);
        if (uploadCustomThumbnail) {
            uploadCustomThumbnail.addEventListener('click', () => {
                this.showUploadThumbnail();
            });
        }

        // Handles delete media thumbnail.
        const deleteCustomThumbnail = this.root.querySelector(Selectors.EMBED.actions.deleteCustomThumbnail);
        if (deleteCustomThumbnail) {
            deleteCustomThumbnail.addEventListener('click', () => {
                this.deleteEmbeddedThumbnail();
            });
        }

        // Handles language track selection.
        const langTracks = this.root.querySelectorAll(Selectors.EMBED.elements.trackLang);
        if (langTracks) {
            langTracks.forEach((dropdown) => {
                const defaultVal = dropdown.getAttribute('data-value');
                if (defaultVal) {
                    dropdown.value = defaultVal;
                }
            });
        }
    };
}