Proyectos de Subversion Moodle

Rev

Rev 1 | Autoría | Comparar con el anterior | Ultima modificación | Ver Log |

{"version":3,"file":"base_recorder.min.js","sources":["../src/base_recorder.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.\n//\n\n/**\n * Tiny Record RTC type.\n *\n * @module      tiny_recordrtc/base_recorder\n * @copyright   2022 Stevani Andolo <stevani@hotmail.com.au>\n * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport {getString, getStrings} from 'core/str';\nimport {component} from './common';\nimport Pending from 'core/pending';\nimport {getData, isPausingAllowed} from './options';\nimport uploadFile from 'editor_tiny/uploader';\nimport {add as addToast} from 'core/toast';\nimport * as ModalEvents from 'core/modal_events';\nimport * as Templates from 'core/templates';\nimport {saveCancelPromise} from 'core/notification';\nimport {prefetchStrings, prefetchTemplates} from 'core/prefetch';\nimport AlertModal from 'core/local/modal/alert';\n\n/**\n * The RecordRTC base class for audio, video, and any other future types\n */\nexport default class {\n\n    stopRequested = false;\n    buttonTimer = null;\n    pauseTime = null;\n    startTime = null;\n\n    /**\n     * Constructor for the RecordRTC class\n     *\n     * @param {TinyMCE} editor The Editor to which the content will be inserted\n     * @param {Modal} modal The Moodle Modal that contains the interface used for recording\n     */\n    constructor(editor, modal) {\n        this.ready = false;\n\n        if (!this.checkAndWarnAboutBrowserCompatibility()) {\n            return;\n        }\n\n        this.editor = editor;\n        this.config = getData(editor).params;\n        this.modal = modal;\n        this.modalRoot = modal.getRoot()[0];\n        this.startStopButton = this.modalRoot.querySelector('button[data-action=\"startstop\"]');\n        this.uploadButton = this.modalRoot.querySelector('button[data-action=\"upload\"]');\n        this.pauseResumeButton = this.modalRoot.querySelector('button[data-action=\"pauseresume\"]');\n\n        // Disable the record button untilt he stream is acquired.\n        this.setRecordButtonState(false);\n\n        this.player = this.configurePlayer();\n        this.registerEventListeners();\n        this.ready = true;\n\n        this.captureUserMedia();\n        this.prefetchContent();\n    }\n\n    /**\n     * Check whether the browser is compatible.\n     *\n     * @returns {boolean}\n     */\n    isReady() {\n        return this.ready;\n    }\n\n    // Disable eslint's valid-jsdoc rule as the following methods are abstract and mnust be overridden by the child class.\n\n    /* eslint-disable valid-jsdoc, no-unused-vars */\n\n    /**\n     * Get the Player element for this type.\n     *\n     * @returns {HTMLElement} The player element, typically an audio or video tag.\n     */\n    configurePlayer() {\n        throw new Error(`configurePlayer() must be implemented in ${this.constructor.name}`);\n    }\n\n    /**\n     * Get the list of supported mimetypes for this recorder.\n     * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder/isTypeSupported}\n     *\n     * @returns {string[]} The list of supported mimetypes.\n     */\n    getSupportedTypes() {\n        throw new Error(`getSupportedTypes() must be implemented in ${this.constructor.name}`);\n    }\n\n    /**\n     * Get any recording options passed into the MediaRecorder.\n     * Please note that the mimeType will be fetched from {@link getSupportedTypes()}.\n     *\n     * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder/MediaRecorder#options}\n     * @returns {Object}\n     */\n    getRecordingOptions() {\n        throw new Error(`getRecordingOptions() must be implemented in ${this.constructor.name}`);\n    }\n\n    /**\n     * Get a filename for the generated file.\n     *\n     * Typically this function will take a prefix and add a type-specific suffix such as the extension to it.\n     *\n     * @param {string} prefix The prefix for the filename generated by the recorder.\n     * @returns {string}\n     */\n    getFileName(prefix) {\n        throw new Error(`getFileName() must be implemented in ${this.constructor.name}`);\n    }\n\n    /**\n     * Get a list of constraints as required by the getUserMedia() function.\n     * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia#constraints}\n     *\n     * @returns {Object}\n     */\n    getMediaConstraints() {\n        throw new Error(`getMediaConstraints() must be implemented in ${this.constructor.name}`);\n    }\n\n    /**\n     * Whether to start playing the recording as it is captured.\n     * @returns {boolean} Whether to start playing the recording as it is captured.\n     */\n    playOnCapture() {\n        return false;\n    }\n\n    /**\n     * Get the time limit for this recording type.\n     *\n     * @returns {number} The time limit in seconds.\n     */\n    getTimeLimit() {\n        throw new Error(`getTimeLimit() must be implemented in ${this.constructor.name}`);\n    }\n\n    /**\n     * Get the name of the template used when embedding the URL in the editor content.\n     *\n     * @returns {string}\n     */\n    getEmbedTemplateName() {\n        throw new Error(`getEmbedTemplateName() must be implemented in ${this.constructor.name}`);\n    }\n\n    /**\n     * Fetch the Class of the Modal to be displayed.\n     *\n     * @returns {Modal}\n     */\n    static getModalClass() {\n        throw new Error(`getModalClass() must be implemented in ${this.constructor.name}`);\n    }\n\n    /* eslint-enable valid-jsdoc, no-unused-vars */\n\n    /**\n     * Get the options for the MediaRecorder.\n     *\n     * @returns {object} The options for the MediaRecorder instance.\n     */\n    getParsedRecordingOptions() {\n        const requestedTypes = this.getSupportedTypes();\n        const possibleTypes = requestedTypes.reduce((result, type) => {\n            result.push(type);\n            // Safari seems to use codecs: instead of codecs=.\n            // It is safe to add both, so we do, but we want them to remain in order.\n            result.push(type.replace('=', ':'));\n            return result;\n        }, []);\n\n        const compatTypes = possibleTypes.filter((type) => window.MediaRecorder.isTypeSupported(type));\n\n        const options = this.getRecordingOptions();\n        if (compatTypes.length !== 0) {\n            options.mimeType = compatTypes[0];\n        }\n        window.console.info(\n            `Selected codec ${options.mimeType} from ${compatTypes.length} options.`,\n            compatTypes,\n        );\n\n        return options;\n    }\n\n    /**\n     * Start capturing the User Media and handle success or failure of the capture.\n     */\n    async captureUserMedia() {\n        try {\n            const stream = await navigator.mediaDevices.getUserMedia(this.getMediaConstraints());\n            this.handleCaptureSuccess(stream);\n        } catch (error) {\n            this.handleCaptureFailure(error);\n        }\n    }\n\n    /**\n     * Prefetch some of the content that will be used in the UI.\n     *\n     * Note: not all of the strings used are pre-fetched.\n     * Some of the strings will be fetched because their template is used.\n     */\n    prefetchContent() {\n        prefetchStrings(component, [\n            'uploading',\n            'recordagain_title',\n            'recordagain_desc',\n            'discard_title',\n            'discard_desc',\n            'confirm_yes',\n            'recordinguploaded',\n            'maxfilesizehit',\n            'maxfilesizehit_title',\n            'uploadfailed',\n            'pause',\n            'resume',\n        ]);\n\n        prefetchTemplates([\n            this.getEmbedTemplateName(),\n            'tiny_recordrtc/timeremaining',\n        ]);\n    }\n\n    /**\n     * Display an error message to the user.\n     *\n     * @param {Promise<string>} title The error title\n     * @param {Promise<string>} content The error message\n     * @returns {Promise<Modal>}\n     */\n    async displayAlert(title, content) {\n        const pendingPromise = new Pending('core/confirm:alert');\n        const modal = await AlertModal.create({\n            title: title,\n            body: content,\n            removeOnClose: true,\n        });\n\n        modal.show();\n        pendingPromise.resolve();\n\n        return modal;\n    }\n\n    /**\n     * Handle successful capture of the User Media.\n     *\n     * @param {MediaStream} stream The stream as captured by the User Media.\n     */\n    handleCaptureSuccess(stream) {\n        // Set audio player source to microphone stream.\n        this.player.srcObject = stream;\n\n        if (this.playOnCapture()) {\n            // Mute audio, distracting while recording.\n            this.player.muted = true;\n\n            this.player.play();\n        }\n\n        this.stream = stream;\n        this.setupPlayerSource();\n        this.setRecordButtonState(true);\n    }\n\n    /**\n     * Setup the player to use the stream as a source.\n     */\n    setupPlayerSource() {\n        if (!this.player.srcObject) {\n            this.player.srcObject = this.stream;\n\n            // Mute audio, distracting while recording.\n            this.player.muted = true;\n\n            this.player.play();\n        }\n    }\n\n    /**\n     * Enable the record button.\n     *\n     * @param {boolean|null} enabled Set the button state\n     */\n    setRecordButtonState(enabled) {\n        this.startStopButton.disabled = !enabled;\n    }\n\n    /**\n     * Configure button visibility for the record button.\n     *\n     * @param {boolean} visible Set the visibility of the button.\n     */\n    setRecordButtonVisibility(visible) {\n        const container = this.getButtonContainer('start-stop');\n        container.classList.toggle('hide', !visible);\n    }\n\n    /**\n     * Configure button visibility for the pause button.\n     *\n     * @param {boolean} visible Set the visibility of the button.\n     */\n    setPauseButtonVisibility(visible) {\n        if (this.pauseResumeButton) {\n            this.pauseResumeButton.classList.toggle('hidden', !visible);\n        }\n    }\n\n    /**\n     * Enable the upload button.\n     *\n     * @param {boolean|null} enabled Set the button state\n     */\n    setUploadButtonState(enabled) {\n        this.uploadButton.disabled = !enabled;\n    }\n\n    /**\n     * Configure button visibility for the upload button.\n     *\n     * @param {boolean} visible Set the visibility of the button.\n     */\n    setUploadButtonVisibility(visible) {\n        const container = this.getButtonContainer('upload');\n        container.classList.toggle('hide', !visible);\n    }\n\n    /**\n     * Sets the state of the audio player, including visibility, muting, and controls.\n     *\n     * @param {boolean} state A boolean indicating the audio player state.\n     */\n    setPlayerState(state) {\n        // Mute or unmute the audio player and show or hide controls.\n        this.player.muted = !state;\n        this.player.controls = state;\n        // Toggle the 'hide' class on the player button container based on state.\n        this.getButtonContainer('player')?.classList.toggle('hide', !state);\n    }\n\n    /**\n     * Handle failure to capture the User Media.\n     *\n     * @param {Error} error\n     */\n    handleCaptureFailure(error) {\n        // Changes 'CertainError' -> 'gumcertain' to match language string names.\n        var subject = `gum${error.name.replace('Error', '').toLowerCase()}`;\n        this.displayAlert(\n            getString(`${subject}_title`, component),\n            getString(subject, component)\n        );\n    }\n\n    /**\n     * Close the modal and stop recording.\n     */\n    close() {\n        // Closing the modal will destroy it and remove it from the DOM.\n        // It will also stop the recording via the hidden Modal Event.\n        this.modal.hide();\n    }\n\n    /**\n     * Register event listeners for the modal.\n     */\n    registerEventListeners() {\n        this.modalRoot.addEventListener('click', this.handleModalClick.bind(this));\n        this.modal.getRoot().on(ModalEvents.outsideClick, this.outsideClickHandler.bind(this));\n        this.modal.getRoot().on(ModalEvents.hidden, () => {\n            this.cleanupStream();\n            this.requestRecordingStop();\n        });\n        this.player.addEventListener('error', this.handlePlayerError.bind(this));\n        this.player.addEventListener('loadedmetadata', this.handlePlayerLoadedMetadata.bind(this));\n    }\n\n    /**\n     * Handle the player `error` event.\n     *\n     * This event is called when the player throws an error.\n     */\n    handlePlayerError() {\n        const error = this.player.error;\n        if (error) {\n            const message = `An error occurred: ${error.message || 'Unknown error'}. Please try again.`;\n            addToast(message, {type: error});\n            // Disable the upload button.\n            this.setUploadButtonState(false);\n        }\n    }\n\n    /**\n     * Handles the event when the player's metadata has been loaded.\n     */\n    handlePlayerLoadedMetadata() {\n        if (isFinite(this.player.duration)) {\n            // Note: In Chrome, you need to seek to activate the error listener\n            // if an issue arises after inserting the recorded audio into the player source.\n            this.player.currentTime = 0.1;\n        }\n    }\n\n    /**\n     * Prevent the Modal from closing when recording is on process.\n     *\n     * @param {MouseEvent} event The click event\n     */\n    async outsideClickHandler(event) {\n        if (this.isRecording() || this.isPaused()) {\n            // The user is recording.\n            // Do not distract with a confirmation, just prevent closing.\n            event.preventDefault();\n        } else if (this.hasData()) {\n            // If there is a blobsize then there is data that may be lost.\n            // Ask the user to confirm they want to close the modal.\n            // We prevent default here, and then close the modal if they confirm.\n            event.preventDefault();\n\n            try {\n                await saveCancelPromise(\n                    await getString(\"discard_title\", component),\n                    await getString(\"discard_desc\", component),\n                    await getString(\"confirm_yes\", component),\n                );\n                this.modal.hide();\n            } catch (error) {\n                // Do nothing, the modal will not close.\n            }\n        }\n    }\n\n    /**\n     * Handle a click within the Modal.\n     *\n     * @param {MouseEvent} event The click event\n     */\n    handleModalClick(event) {\n        const button = event.target.closest('button');\n        if (button && button.dataset.action) {\n            const action = button.dataset.action;\n            if (action === 'startstop') {\n                this.handleRecordingStartStopRequested();\n            }\n\n            if (action === 'upload') {\n                this.uploadRecording();\n            }\n\n            if (action === 'pauseresume') {\n                this.handleRecordingPauseResumeRequested();\n            }\n        }\n    }\n\n    /**\n     * Handle the click event for the recording start/stop button.\n     */\n    handleRecordingStartStopRequested() {\n        if (this.isRecording() || this.isPaused()) {\n            this.requestRecordingStop();\n        } else {\n            this.startRecording();\n        }\n    }\n\n    /**\n     * Handle the click event for the recording pause/resume button.\n     */\n    handleRecordingPauseResumeRequested() {\n        if (this.isRecording()) {\n            // Pause recording.\n            this.mediaRecorder.pause();\n        } else if (this.isPaused()) {\n            // Resume recording.\n            this.mediaRecorder.resume();\n        }\n    }\n\n    /**\n     * Handle the media stream after it has finished.\n     */\n    async onMediaStopped() {\n        // Set source of audio player.\n        this.blob = new Blob(this.data.chunks, {\n            type: this.mediaRecorder.mimeType\n        });\n        this.player.srcObject = null;\n        this.player.src = URL.createObjectURL(this.blob);\n\n        // Change the label to \"Record again\".\n        this.setRecordButtonTextFromString('recordagain');\n\n        // Show upload button.\n        this.setUploadButtonVisibility(true);\n        this.setPlayerState(true);\n        this.setUploadButtonState(true);\n\n        // Hide the pause button.\n        this.setPauseButtonVisibility(false);\n        if (this.mediaRecorder.state === 'inactive') {\n            this.setPauseButtonTextFromString('pause');\n        }\n    }\n\n    /**\n     * Upload the recording and insert it into the editor content.\n     */\n    async uploadRecording() {\n        // Trigger error if no recording has been made.\n        if (this.data.chunks.length === 0) {\n            this.displayAlert('norecordingfound');\n            return;\n        }\n\n        const fileName = this.getFileName((Math.random() * 1000).toString().replace('.', ''));\n\n        // Upload recording to server.\n        try {\n            // Once uploading starts, do not allow any further changes to the recording.\n            this.setRecordButtonVisibility(false);\n\n            // Disable the upload button.\n            this.setUploadButtonState(false);\n\n            // Upload the recording.\n            const fileURL = await uploadFile(this.editor, 'media', this.blob, fileName, (progress) => {\n                this.setUploadButtonTextProgress(progress);\n            });\n            this.insertMedia(fileURL);\n            this.close();\n            addToast(await getString('recordinguploaded', component));\n        } catch (error) {\n            // Show a toast and unhide the button.\n            this.setUploadButtonState(true);\n\n            addToast(await getString('uploadfailed', component, {error}), {\n                type: 'error',\n            });\n\n        }\n    }\n\n    /**\n     * Helper to get the container that a button is in.\n     *\n     * @param {string} purpose The button purpose\n     * @returns {HTMLElement}\n     */\n    getButtonContainer(purpose) {\n        return this.modalRoot.querySelector(`[data-purpose=\"${purpose}-container\"]`);\n    }\n\n    /**\n     * Check whether the browser is compatible with capturing media.\n     *\n     * @returns {boolean}\n     */\n    static isBrowserCompatible() {\n        return this.checkSecure() && this.hasUserMedia();\n    }\n\n    static async display(editor) {\n        const ModalClass = this.getModalClass();\n        const modal = await ModalClass.create({\n            templateContext: {\n                isallowedpausing: isPausingAllowed(editor),\n            },\n            large: true,\n            removeOnClose: true,\n        });\n\n        // Set up the VideoRecorder.\n        const recorder = new this(editor, modal);\n        if (recorder.isReady()) {\n            modal.show();\n        }\n        return modal;\n    }\n\n    /**\n     * Check whether the browser is compatible with capturing media, and display a warning if not.\n     *\n     * @returns {boolean}\n     */\n    checkAndWarnAboutBrowserCompatibility() {\n        if (!this.constructor.checkSecure()) {\n            getStrings(['insecurealert_title', 'insecurealert'].map((key) => ({key, component})))\n                .then(([title, message]) => addToast(message, {title, type: 'error'}))\n                .catch();\n            return false;\n        }\n\n        if (!this.constructor.hasUserMedia) {\n            getStrings(['nowebrtc_title', 'nowebrtc'].map((key) => ({key, component})))\n                .then(([title, message]) => addToast(message, {title, type: 'error'}))\n                .catch();\n            return false;\n        }\n\n        return true;\n    }\n\n    /**\n     * Check whether the browser supports WebRTC.\n     *\n     * @returns {boolean}\n     */\n    static hasUserMedia() {\n        return (navigator.mediaDevices && window.MediaRecorder);\n    }\n\n    /**\n     * Check whether the hostname is either hosted over SSL, or from a valid localhost hostname.\n     *\n     * The UserMedia API can only be used in secure contexts as noted.\n     * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia#privacy_and_security}\n     *\n     * @returns {boolean} Whether the plugin can be loaded.\n     */\n    static checkSecure() {\n        // Note: We can now use window.isSecureContext.\n        // https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#feature_detection\n        // https://developer.mozilla.org/en-US/docs/Web/API/isSecureContext\n        return window.isSecureContext;\n    }\n\n    /**\n     * Update the content of the stop recording button timer.\n     */\n    async setStopRecordingButton() {\n        const {html, js} = await Templates.renderForPromise('tiny_recordrtc/timeremaining', this.getTimeRemaining());\n        Templates.replaceNodeContents(this.startStopButton, html, js);\n        this.startButtonTimer();\n    }\n\n    /**\n     * Update the time on the stop recording button.\n     */\n    updateRecordButtonTime() {\n        const {remaining, minutes, seconds} = this.getTimeRemaining();\n        if (remaining < 0) {\n            this.requestRecordingStop();\n        } else {\n            this.startStopButton.querySelector('[data-type=\"minutes\"]').textContent = minutes;\n            this.startStopButton.querySelector('[data-type=\"seconds\"]').textContent = seconds;\n        }\n    }\n\n    /**\n     * Set the text of the record button using a language string.\n     *\n     * @param {string} string The string identifier\n     */\n    async setRecordButtonTextFromString(string) {\n        this.startStopButton.textContent = await getString(string, component);\n    }\n\n    /**\n     * Set the text of the pause button using a language string.\n     *\n     * @param {string} string The string identifier\n     */\n    async setPauseButtonTextFromString(string) {\n        if (this.pauseResumeButton) {\n            this.pauseResumeButton.textContent = await getString(string, component);\n        }\n    }\n\n    /**\n     * Set the upload button text progress.\n     *\n     * @param {number} progress The progress\n     */\n    async setUploadButtonTextProgress(progress) {\n        this.uploadButton.textContent = await getString('uploading', component, {\n            progress: Math.round(progress * 100) / 100,\n        });\n    }\n\n    async resetUploadButtonText() {\n        this.uploadButton.textContent = await getString('upload', component);\n    }\n\n    /**\n     * Clear the timer for the stop recording button.\n     */\n    clearButtonTimer() {\n        if (this.buttonTimer) {\n            clearInterval(this.buttonTimer);\n        }\n        this.buttonTimer = null;\n        this.pauseTime = null;\n        this.startTime = null;\n    }\n\n    /**\n     * Pause the timer for the stop recording button.\n     */\n    pauseButtonTimer() {\n        // Stop the countdown timer.\n        this.pauseTime = new Date().getTime(); // Store pause time.\n        if (this.buttonTimer) {\n            clearInterval(this.buttonTimer);\n        }\n    }\n\n    /**\n     * Start the timer for the start recording button.\n     * If the recording was paused, the timer will resume from the pause time.\n     */\n    startButtonTimer() {\n        if (this.pauseTime !== null) {\n            // Resume from pause.\n            const pauseDuration = new Date().getTime() - this.pauseTime;\n            // Adjust start time by pause duration.\n            this.startTime += pauseDuration;\n            this.pauseTime = null;\n        }\n        this.buttonTimer = setInterval(this.updateRecordButtonTime.bind(this), 500);\n    }\n\n    /**\n     * Get the time remaining for the recording.\n     *\n     * @returns {Object} The minutes and seconds remaining.\n     */\n    getTimeRemaining() {\n        // All times are in milliseconds.\n        let now = new Date().getTime();\n        if (this.pauseTime !== null) {\n            // If paused, use pauseTime instead of current time.\n            now = this.pauseTime;\n        }\n        const remaining = Math.floor(this.getTimeLimit() - ((now - this.startTime) / 1000));\n\n        const formatter = new Intl.NumberFormat(navigator.language, {minimumIntegerDigits: 2});\n        const seconds = formatter.format(remaining % 60);\n        const minutes = formatter.format(Math.floor((remaining - seconds) / 60));\n        return {\n            remaining,\n            minutes,\n            seconds,\n        };\n    }\n\n    /**\n     * Get the maximum file size that can be uploaded.\n     *\n     * @returns {number} The max byte size\n     */\n    getMaxUploadSize() {\n        return this.config.maxrecsize;\n    }\n\n    /**\n     * Stop the recording.\n     * Please note that this should only stop the recording.\n     * Anything related to processing the recording should be handled by the\n     * mediaRecorder's stopped event handler which is processed after it has stopped.\n     */\n    requestRecordingStop() {\n        if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {\n            this.stopRequested = true;\n            if (this.isPaused()) {\n                this.stopRecorder();\n            }\n        } else {\n            // There is no recording to stop, but the stream must still be cleaned up.\n            this.cleanupStream();\n        }\n    }\n\n    stopRecorder() {\n        if (this.isPaused()) {\n            this.pauseTime = null;\n        }\n        this.mediaRecorder.stop();\n\n        // Unmute the player so that the audio is heard during playback.\n        this.player.muted = false;\n    }\n\n    /**\n     * Clean up the stream.\n     *\n     * This involves stopping any track which is still active.\n     */\n    cleanupStream() {\n        if (this.stream) {\n            this.stream.getTracks()\n                .filter((track) => track.readyState !== 'ended')\n                .forEach((track) => track.stop());\n        }\n    }\n\n    /**\n     * Handle the mediaRecorder `stop` event.\n     */\n    handleStopped() {\n        // Handle the stream data.\n        this.onMediaStopped();\n\n        // Clear the button timer.\n        this.clearButtonTimer();\n    }\n\n    /**\n     * Handle the mediaRecorder `start` event.\n     *\n     * This event is called when the recording starts.\n     */\n    handleStarted() {\n        this.startTime = new Date().getTime();\n        if (isPausingAllowed(this.editor) && !this.isPaused()) {\n            this.setPauseButtonVisibility(true);\n        }\n        this.setStopRecordingButton();\n    }\n\n    /**\n     * Handle the mediaRecorder `pause` event.\n     *\n     * This event is called when the recording pauses.\n     */\n    handlePaused() {\n        this.pauseButtonTimer();\n        this.setPauseButtonTextFromString('resume');\n    }\n\n    /**\n     * Handle the mediaRecorder `resume` event.\n     *\n     * This event is called when the recording resumes.\n     */\n    handleResume() {\n        this.startButtonTimer();\n        this.setPauseButtonTextFromString('pause');\n    }\n\n    /**\n     * Handle the mediaRecorder `dataavailable` event.\n     *\n     * @param {Event} event\n     */\n    handleDataAvailable(event) {\n        if (this.isRecording() || this.isPaused()) {\n            const newSize = this.data.blobSize + event.data.size;\n            // Recording stops when either the maximum upload size is reached, or the time limit expires.\n            // The time limit is checked in the `updateButtonTime` function.\n            if (newSize >= this.getMaxUploadSize()) {\n                this.stopRecorder();\n                this.displayFileLimitHitMessage();\n            } else {\n                // Push recording slice to array.\n                this.data.chunks.push(event.data);\n\n                // Size of all recorded data so far.\n                this.data.blobSize = newSize;\n\n                if (this.stopRequested) {\n                    this.stopRecorder();\n                }\n            }\n        }\n    }\n\n    async displayFileLimitHitMessage() {\n        addToast(await getString('maxfilesizehit', component), {\n            title: await getString('maxfilesizehit_title', component),\n            type: 'error',\n        });\n    }\n\n    /**\n     * Check whether the recording is in progress.\n     *\n     * @returns {boolean}\n     */\n    isRecording() {\n        return this.mediaRecorder?.state === 'recording';\n    }\n\n    /**\n     * Check whether the recording is paused.\n     *\n     * @returns {boolean}\n     */\n    isPaused() {\n        return this.mediaRecorder?.state === 'paused';\n    }\n\n    /**\n     * Whether any data has been recorded.\n     *\n     * @returns {boolean}\n     */\n    hasData() {\n        return !!this.data?.blobSize;\n    }\n\n    /**\n     * Start the recording\n     */\n    async startRecording() {\n        if (this.mediaRecorder) {\n            // Stop the existing recorder if it exists.\n            if (this.isRecording() || this.isPaused()) {\n                this.mediaRecorder.stop();\n            }\n\n            if (this.hasData()) {\n                const resetRecording = await this.recordAgainConfirmation();\n                if (!resetRecording) {\n                    // User cancelled at the confirmation to reset the data, so exit early.\n                    return;\n                }\n                this.setUploadButtonVisibility(false);\n                this.setPlayerState(false);\n                if (!this.stream.active) {\n                    await this.captureUserMedia();\n                }\n            }\n\n            this.mediaRecorder = null;\n        }\n\n        // The options for the recording codecs and bitrates.\n        this.mediaRecorder = new MediaRecorder(this.stream, this.getParsedRecordingOptions());\n\n        this.mediaRecorder.addEventListener('dataavailable', this.handleDataAvailable.bind(this));\n        this.mediaRecorder.addEventListener('stop', this.handleStopped.bind(this));\n        this.mediaRecorder.addEventListener('start', this.handleStarted.bind(this));\n        this.mediaRecorder.addEventListener('pause', this.handlePaused.bind(this));\n        this.mediaRecorder.addEventListener('resume', this.handleResume.bind(this));\n\n        this.data = {\n            chunks: [],\n            blobSize: 0\n        };\n        this.setupPlayerSource();\n        this.stopRequested = false;\n\n        // Capture in 50ms chunks.\n        this.mediaRecorder.start(50);\n    }\n\n    /**\n     * Confirm whether the user wants to reset the existing recoring.\n     *\n     * @returns {Promise<boolean>} Whether the user confirmed the reset.\n     */\n    async recordAgainConfirmation() {\n        try {\n            await saveCancelPromise(\n                await getString(\"recordagain_title\", component),\n                await getString(\"recordagain_desc\", component),\n                await getString(\"confirm_yes\", component)\n            );\n            return true;\n        } catch {\n            return false;\n        }\n    }\n\n    /**\n     * Insert the HTML to embed the recording into the editor content.\n     *\n     * @param {string} source The URL to view the media.\n     */\n    async insertMedia(source) {\n        const {html} = await Templates.renderForPromise(\n            this.getEmbedTemplateName(),\n            this.getEmbedTemplateContext({\n                source,\n            })\n        );\n        this.editor.insertContent(html);\n    }\n\n    /**\n     * Add or modify the template parameters for the specified type.\n     *\n     * @param {Object} templateContext The Tempalte context to use\n     * @returns {Object} The finalised template context\n     */\n    getEmbedTemplateContext(templateContext) {\n        return templateContext;\n    }\n}\n"],"names":["constructor","editor","modal","ready","this","checkAndWarnAboutBrowserCompatibility","config","params","modalRoot","getRoot","startStopButton","querySelector","uploadButton","pauseResumeButton","setRecordButtonState","player","configurePlayer","registerEventListeners","captureUserMedia","prefetchContent","isReady","Error","name","getSupportedTypes","getRecordingOptions","getFileName","prefix","getMediaConstraints","playOnCapture","getTimeLimit","getEmbedTemplateName","getParsedRecordingOptions","compatTypes","reduce","result","type","push","replace","filter","window","MediaRecorder","isTypeSupported","options","length","mimeType","console","info","stream","navigator","mediaDevices","getUserMedia","handleCaptureSuccess","error","handleCaptureFailure","component","title","content","pendingPromise","Pending","AlertModal","create","body","removeOnClose","show","resolve","srcObject","muted","play","setupPlayerSource","enabled","disabled","setRecordButtonVisibility","visible","getButtonContainer","classList","toggle","setPauseButtonVisibility","setUploadButtonState","setUploadButtonVisibility","setPlayerState","state","controls","subject","toLowerCase","displayAlert","close","hide","addEventListener","handleModalClick","bind","on","ModalEvents","outsideClick","outsideClickHandler","hidden","cleanupStream","requestRecordingStop","handlePlayerError","handlePlayerLoadedMetadata","message","isFinite","duration","currentTime","event","isRecording","isPaused","preventDefault","hasData","button","target","closest","dataset","action","handleRecordingStartStopRequested","uploadRecording","handleRecordingPauseResumeRequested","startRecording","mediaRecorder","pause","resume","blob","Blob","data","chunks","src","URL","createObjectURL","setRecordButtonTextFromString","setPauseButtonTextFromString","fileName","Math","random","toString","fileURL","progress","setUploadButtonTextProgress","insertMedia","purpose","checkSecure","hasUserMedia","ModalClass","getModalClass","templateContext","isallowedpausing","large","map","key","then","_ref2","catch","_ref","isSecureContext","html","js","Templates","renderForPromise","getTimeRemaining","replaceNodeContents","startButtonTimer","updateRecordButtonTime","remaining","minutes","seconds","textContent","string","round","clearButtonTimer","buttonTimer","clearInterval","pauseTime","startTime","pauseButtonTimer","Date","getTime","pauseDuration","setInterval","now","floor","formatter","Intl","NumberFormat","language","minimumIntegerDigits","format","getMaxUploadSize","maxrecsize","stopRequested","stopRecorder","stop","getTracks","track","readyState","forEach","handleStopped","onMediaStopped","handleStarted","setStopRecordingButton","handlePaused","handleResume","handleDataAvailable","newSize","blobSize","size","displayFileLimitHitMessage","_this$data","recordAgainConfirmation","active","start","source","getEmbedTemplateContext","insertContent"],"mappings":"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"}