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