lib/editor/tiny/plugins/recordrtc/amd/src/screen_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 - Screen recorder configuration.
 *
 * @module      tiny_recordrtc/screen_recorder
 * @copyright   2024 The Open University
 * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */

import BaseClass from './base_recorder';
import Modal from 'tiny_recordrtc/modal';
import {component} from 'tiny_recordrtc/common';
import {getString} from 'core/str';

export default class Screen extends BaseClass {
    configurePlayer() {
        return this.modalRoot.querySelector('video');
    }

    getSupportedTypes() {
        return [
            // Support webm as a preference.
            // This container supports both vp9, and vp8.
            // It does not support AVC1/h264 at all.
            // It is supported by Chromium, and Firefox browsers, but not Safari.
            'video/webm;codecs=vp9,opus',
            'video/webm;codecs=vp8,opus',

            // Fall back to mp4 if webm is not available.
            // The mp4 container supports v9, and h264 but neither of these are supported for recording on other
            // browsers.
            // In addition to this, we can record in v9, but VideoJS does not support a mp4 container with v9 codec
            // for playback. We leave it as a final option as a just-in-case.
            'video/mp4;codecs=h264,opus',
            'video/mp4;codecs=h264,wav',
            'video/mp4;codecs=v9,opus',
        ];

    }

    getRecordingOptions() {
        return {
            videoBitsPerSecond: parseInt(this.config.screenbitrate),
            videoWidth: parseInt(this.config.videoscreenwidth),
            videoHeight: parseInt(this.config.videoscreenheight),
        };
    }

    getMediaConstraints() {
        return {
            audio: true,
            systemAudio: 'exclude',
            video: {
                displaySurface: 'monitor',
                frameRate: {ideal: 24},
                width: {
                    max: parseInt(this.config.videoscreenwidth),
                },
                height: {
                    max: parseInt(this.config.videoscreenheight),
                },
            },
        };
    }

    playOnCapture() {
        // Play the recording back on capture.
        return true;
    }

    getRecordingType() {
        return 'screen';
    }

    getTimeLimit() {
        return this.config.screentimelimit;
    }

    getEmbedTemplateName() {
        return 'tiny_recordrtc/embed_screen';
    }

    getFileName(prefix) {
        return `${prefix}-video.${this.getFileExtension()}`;
    }

    getFileExtension() {
        if (window.MediaRecorder.isTypeSupported('audio/webm')) {
            return 'webm';
        } else if (window.MediaRecorder.isTypeSupported('audio/mp4')) {
            return 'mp4';
        }

        window.console.warn(`Unknown file type for MediaRecorder API`);
        return '';
    }

    async captureUserMedia() {
        // Screen recording requires both audio and the screen, and we need to get them both together.
        const audioPromise = navigator.mediaDevices.getUserMedia({audio: true});
        const screenPromise = navigator.mediaDevices.getDisplayMedia(this.getMediaConstraints());
        // If the audioPromise is "rejected" (indicating that the user does not want to share their voice),
        // we will proceed to record their screen without audio.
        // Therefore, we will use Promise.allSettled instead of Promise.all.
        await Promise.allSettled([audioPromise, screenPromise]).then(this.combineAudioAndScreenRecording.bind(this));
    }

    /**
     * For starting screen recording, once we have both audio and video, combine them.
     *
     * @param {Object[]} results from the above Promise.allSettled call.
     */
    combineAudioAndScreenRecording(results) {
        const [audioData, screenData] = results;
        if (screenData.status !== 'fulfilled') {
            // If the user does not grant screen permission show warning popup.
            this.handleCaptureFailure(screenData.reason);
            return;
        }

        const screenStream = screenData.value;
        // Prepare to handle if the user clicks the browser's "Stop Sharing Screen" button.
        screenStream.getVideoTracks()[0].addEventListener('ended', this.handleStopScreenSharing.bind(this));

        // Handle microphone.
        if (audioData.status !== 'fulfilled') {
            // We could not get audio. In this case, we just continue without audio.
            this.handleCaptureSuccess(screenStream);
            return;
        }
        const audioStream = audioData.value;
        // Merge the video track from the media stream with the audio track from the microphone stream
        // and stop any unnecessary tracks to ensure that the recorded video has microphone sound.
        const composedStream = new MediaStream();
        screenStream.getTracks().forEach(function(track) {
            if (track.kind === 'video') {
                composedStream.addTrack(track);
            } else {
                track.stop();
            }
        });
        audioStream.getAudioTracks().forEach(function(micTrack) {
            composedStream.addTrack(micTrack);
        });

        this.handleCaptureSuccess(composedStream);
    }

    /**
     * Callback that is called by the user clicking Stop screen sharing on the browser.
     */
    handleStopScreenSharing() {
        if (this.isRecording() || this.isPaused()) {
            this.requestRecordingStop();
            this.cleanupStream();
        } else {
            this.setRecordButtonState(false);
            this.displayAlert(
                getString('screensharingstopped_title', component),
                getString('screensharingstopped', component)
            );
        }
    }

    handleRecordingStartStopRequested() {
        if (this.isRecording() || this.isPaused()) {
            this.requestRecordingStop();
            this.cleanupStream();
        } else {
            this.startRecording();
        }
    }

    static getModalClass() {
        return class extends Modal {
            static TYPE = `${component}/screen_recorder`;
            static TEMPLATE = `${component}/screen_recorder`;
        };
    }
}