Proyectos de Subversion Moodle

Rev

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

Rev Autor Línea Nro. Línea
1 efrain 1
// This file is part of Moodle - http://moodle.org/
2
//
3
// Moodle is free software: you can redistribute it and/or modify
4
// it under the terms of the GNU General Public License as published by
5
// the Free Software Foundation, either version 3 of the License, or
6
// (at your option) any later version.
7
//
8
// Moodle is distributed in the hope that it will be useful,
9
// but WITHOUT ANY WARRANTY; without even the implied warranty of
10
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11
// GNU General Public License for more details.
12
//
13
// You should have received a copy of the GNU General Public License
14
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
15
//
16
 
17
/**
18
 * Tiny Record RTC type.
19
 *
20
 * @module      tiny_recordrtc/base_recorder
21
 * @copyright   2022 Stevani Andolo <stevani@hotmail.com.au>
22
 * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23
 */
24
 
25
import {getString, getStrings} from 'core/str';
26
import {component} from './common';
27
import Pending from 'core/pending';
1441 ariadna 28
import {getData, isPausingAllowed} from './options';
1 efrain 29
import uploadFile from 'editor_tiny/uploader';
30
import {add as addToast} from 'core/toast';
31
import * as ModalEvents from 'core/modal_events';
32
import * as Templates from 'core/templates';
33
import {saveCancelPromise} from 'core/notification';
34
import {prefetchStrings, prefetchTemplates} from 'core/prefetch';
35
import AlertModal from 'core/local/modal/alert';
36
 
37
/**
38
 * The RecordRTC base class for audio, video, and any other future types
39
 */
40
export default class {
41
 
42
    stopRequested = false;
1441 ariadna 43
    buttonTimer = null;
44
    pauseTime = null;
45
    startTime = null;
1 efrain 46
 
47
    /**
48
     * Constructor for the RecordRTC class
49
     *
50
     * @param {TinyMCE} editor The Editor to which the content will be inserted
51
     * @param {Modal} modal The Moodle Modal that contains the interface used for recording
52
     */
53
    constructor(editor, modal) {
54
        this.ready = false;
55
 
56
        if (!this.checkAndWarnAboutBrowserCompatibility()) {
57
            return;
58
        }
59
 
60
        this.editor = editor;
61
        this.config = getData(editor).params;
62
        this.modal = modal;
63
        this.modalRoot = modal.getRoot()[0];
64
        this.startStopButton = this.modalRoot.querySelector('button[data-action="startstop"]');
65
        this.uploadButton = this.modalRoot.querySelector('button[data-action="upload"]');
1441 ariadna 66
        this.pauseResumeButton = this.modalRoot.querySelector('button[data-action="pauseresume"]');
1 efrain 67
 
68
        // Disable the record button untilt he stream is acquired.
69
        this.setRecordButtonState(false);
70
 
71
        this.player = this.configurePlayer();
72
        this.registerEventListeners();
73
        this.ready = true;
74
 
75
        this.captureUserMedia();
76
        this.prefetchContent();
77
    }
78
 
79
    /**
80
     * Check whether the browser is compatible.
81
     *
82
     * @returns {boolean}
83
     */
84
    isReady() {
85
        return this.ready;
86
    }
87
 
88
    // Disable eslint's valid-jsdoc rule as the following methods are abstract and mnust be overridden by the child class.
89
 
90
    /* eslint-disable valid-jsdoc, no-unused-vars */
91
 
92
    /**
93
     * Get the Player element for this type.
94
     *
95
     * @returns {HTMLElement} The player element, typically an audio or video tag.
96
     */
97
    configurePlayer() {
98
        throw new Error(`configurePlayer() must be implemented in ${this.constructor.name}`);
99
    }
100
 
101
    /**
102
     * Get the list of supported mimetypes for this recorder.
103
     * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder/isTypeSupported}
104
     *
105
     * @returns {string[]} The list of supported mimetypes.
106
     */
107
    getSupportedTypes() {
108
        throw new Error(`getSupportedTypes() must be implemented in ${this.constructor.name}`);
109
    }
110
 
111
    /**
112
     * Get any recording options passed into the MediaRecorder.
113
     * Please note that the mimeType will be fetched from {@link getSupportedTypes()}.
114
     *
115
     * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder/MediaRecorder#options}
116
     * @returns {Object}
117
     */
118
    getRecordingOptions() {
119
        throw new Error(`getRecordingOptions() must be implemented in ${this.constructor.name}`);
120
    }
121
 
122
    /**
123
     * Get a filename for the generated file.
124
     *
125
     * Typically this function will take a prefix and add a type-specific suffix such as the extension to it.
126
     *
127
     * @param {string} prefix The prefix for the filename generated by the recorder.
128
     * @returns {string}
129
     */
130
    getFileName(prefix) {
131
        throw new Error(`getFileName() must be implemented in ${this.constructor.name}`);
132
    }
133
 
134
    /**
135
     * Get a list of constraints as required by the getUserMedia() function.
136
     * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia#constraints}
137
     *
138
     * @returns {Object}
139
     */
140
    getMediaConstraints() {
141
        throw new Error(`getMediaConstraints() must be implemented in ${this.constructor.name}`);
142
    }
143
 
144
    /**
145
     * Whether to start playing the recording as it is captured.
146
     * @returns {boolean} Whether to start playing the recording as it is captured.
147
     */
148
    playOnCapture() {
149
        return false;
150
    }
151
 
152
    /**
153
     * Get the time limit for this recording type.
154
     *
155
     * @returns {number} The time limit in seconds.
156
     */
157
    getTimeLimit() {
158
        throw new Error(`getTimeLimit() must be implemented in ${this.constructor.name}`);
159
    }
160
 
161
    /**
162
     * Get the name of the template used when embedding the URL in the editor content.
163
     *
164
     * @returns {string}
165
     */
166
    getEmbedTemplateName() {
167
        throw new Error(`getEmbedTemplateName() must be implemented in ${this.constructor.name}`);
168
    }
169
 
170
    /**
171
     * Fetch the Class of the Modal to be displayed.
172
     *
173
     * @returns {Modal}
174
     */
175
    static getModalClass() {
176
        throw new Error(`getModalClass() must be implemented in ${this.constructor.name}`);
177
    }
178
 
179
    /* eslint-enable valid-jsdoc, no-unused-vars */
180
 
181
    /**
182
     * Get the options for the MediaRecorder.
183
     *
184
     * @returns {object} The options for the MediaRecorder instance.
185
     */
186
    getParsedRecordingOptions() {
187
        const requestedTypes = this.getSupportedTypes();
188
        const possibleTypes = requestedTypes.reduce((result, type) => {
189
            result.push(type);
190
            // Safari seems to use codecs: instead of codecs=.
191
            // It is safe to add both, so we do, but we want them to remain in order.
192
            result.push(type.replace('=', ':'));
193
            return result;
194
        }, []);
195
 
196
        const compatTypes = possibleTypes.filter((type) => window.MediaRecorder.isTypeSupported(type));
197
 
198
        const options = this.getRecordingOptions();
199
        if (compatTypes.length !== 0) {
200
            options.mimeType = compatTypes[0];
201
        }
202
        window.console.info(
203
            `Selected codec ${options.mimeType} from ${compatTypes.length} options.`,
204
            compatTypes,
205
        );
206
 
207
        return options;
208
    }
209
 
210
    /**
211
     * Start capturing the User Media and handle success or failure of the capture.
212
     */
213
    async captureUserMedia() {
214
        try {
215
            const stream = await navigator.mediaDevices.getUserMedia(this.getMediaConstraints());
216
            this.handleCaptureSuccess(stream);
217
        } catch (error) {
218
            this.handleCaptureFailure(error);
219
        }
220
    }
221
 
222
    /**
223
     * Prefetch some of the content that will be used in the UI.
224
     *
225
     * Note: not all of the strings used are pre-fetched.
226
     * Some of the strings will be fetched because their template is used.
227
     */
228
    prefetchContent() {
229
        prefetchStrings(component, [
230
            'uploading',
231
            'recordagain_title',
232
            'recordagain_desc',
233
            'discard_title',
234
            'discard_desc',
235
            'confirm_yes',
236
            'recordinguploaded',
237
            'maxfilesizehit',
238
            'maxfilesizehit_title',
239
            'uploadfailed',
1441 ariadna 240
            'pause',
241
            'resume',
1 efrain 242
        ]);
243
 
244
        prefetchTemplates([
245
            this.getEmbedTemplateName(),
246
            'tiny_recordrtc/timeremaining',
247
        ]);
248
    }
249
 
250
    /**
251
     * Display an error message to the user.
252
     *
253
     * @param {Promise<string>} title The error title
254
     * @param {Promise<string>} content The error message
255
     * @returns {Promise<Modal>}
256
     */
257
    async displayAlert(title, content) {
258
        const pendingPromise = new Pending('core/confirm:alert');
259
        const modal = await AlertModal.create({
260
            title: title,
261
            body: content,
262
            removeOnClose: true,
263
        });
264
 
265
        modal.show();
266
        pendingPromise.resolve();
267
 
268
        return modal;
269
    }
270
 
271
    /**
272
     * Handle successful capture of the User Media.
273
     *
274
     * @param {MediaStream} stream The stream as captured by the User Media.
275
     */
276
    handleCaptureSuccess(stream) {
277
        // Set audio player source to microphone stream.
278
        this.player.srcObject = stream;
279
 
280
        if (this.playOnCapture()) {
281
            // Mute audio, distracting while recording.
282
            this.player.muted = true;
283
 
284
            this.player.play();
285
        }
286
 
287
        this.stream = stream;
288
        this.setupPlayerSource();
289
        this.setRecordButtonState(true);
290
    }
291
 
292
    /**
293
     * Setup the player to use the stream as a source.
294
     */
295
    setupPlayerSource() {
296
        if (!this.player.srcObject) {
297
            this.player.srcObject = this.stream;
298
 
299
            // Mute audio, distracting while recording.
300
            this.player.muted = true;
301
 
302
            this.player.play();
303
        }
304
    }
305
 
306
    /**
307
     * Enable the record button.
308
     *
309
     * @param {boolean|null} enabled Set the button state
310
     */
311
    setRecordButtonState(enabled) {
312
        this.startStopButton.disabled = !enabled;
313
    }
314
 
315
    /**
316
     * Configure button visibility for the record button.
317
     *
318
     * @param {boolean} visible Set the visibility of the button.
319
     */
320
    setRecordButtonVisibility(visible) {
321
        const container = this.getButtonContainer('start-stop');
322
        container.classList.toggle('hide', !visible);
323
    }
324
 
325
    /**
1441 ariadna 326
     * Configure button visibility for the pause button.
327
     *
328
     * @param {boolean} visible Set the visibility of the button.
329
     */
330
    setPauseButtonVisibility(visible) {
331
        if (this.pauseResumeButton) {
332
            this.pauseResumeButton.classList.toggle('hidden', !visible);
333
        }
334
    }
335
 
336
    /**
1 efrain 337
     * Enable the upload button.
338
     *
339
     * @param {boolean|null} enabled Set the button state
340
     */
341
    setUploadButtonState(enabled) {
342
        this.uploadButton.disabled = !enabled;
343
    }
344
 
345
    /**
346
     * Configure button visibility for the upload button.
347
     *
348
     * @param {boolean} visible Set the visibility of the button.
349
     */
350
    setUploadButtonVisibility(visible) {
351
        const container = this.getButtonContainer('upload');
352
        container.classList.toggle('hide', !visible);
353
    }
1441 ariadna 354
 
1 efrain 355
    /**
1441 ariadna 356
     * Sets the state of the audio player, including visibility, muting, and controls.
357
     *
358
     * @param {boolean} state A boolean indicating the audio player state.
359
     */
360
    setPlayerState(state) {
361
        // Mute or unmute the audio player and show or hide controls.
362
        this.player.muted = !state;
363
        this.player.controls = state;
364
        // Toggle the 'hide' class on the player button container based on state.
365
        this.getButtonContainer('player')?.classList.toggle('hide', !state);
366
    }
367
 
368
    /**
1 efrain 369
     * Handle failure to capture the User Media.
370
     *
371
     * @param {Error} error
372
     */
373
    handleCaptureFailure(error) {
374
        // Changes 'CertainError' -> 'gumcertain' to match language string names.
375
        var subject = `gum${error.name.replace('Error', '').toLowerCase()}`;
376
        this.displayAlert(
377
            getString(`${subject}_title`, component),
378
            getString(subject, component)
379
        );
380
    }
381
 
382
    /**
383
     * Close the modal and stop recording.
384
     */
385
    close() {
386
        // Closing the modal will destroy it and remove it from the DOM.
387
        // It will also stop the recording via the hidden Modal Event.
388
        this.modal.hide();
389
    }
390
 
391
    /**
392
     * Register event listeners for the modal.
393
     */
394
    registerEventListeners() {
395
        this.modalRoot.addEventListener('click', this.handleModalClick.bind(this));
396
        this.modal.getRoot().on(ModalEvents.outsideClick, this.outsideClickHandler.bind(this));
397
        this.modal.getRoot().on(ModalEvents.hidden, () => {
398
            this.cleanupStream();
399
            this.requestRecordingStop();
400
        });
1441 ariadna 401
        this.player.addEventListener('error', this.handlePlayerError.bind(this));
402
        this.player.addEventListener('loadedmetadata', this.handlePlayerLoadedMetadata.bind(this));
1 efrain 403
    }
404
 
405
    /**
1441 ariadna 406
     * Handle the player `error` event.
407
     *
408
     * This event is called when the player throws an error.
409
     */
410
    handlePlayerError() {
411
        const error = this.player.error;
412
        if (error) {
413
            const message = `An error occurred: ${error.message || 'Unknown error'}. Please try again.`;
414
            addToast(message, {type: error});
415
            // Disable the upload button.
416
            this.setUploadButtonState(false);
417
        }
418
    }
419
 
420
    /**
421
     * Handles the event when the player's metadata has been loaded.
422
     */
423
    handlePlayerLoadedMetadata() {
424
        if (isFinite(this.player.duration)) {
425
            // Note: In Chrome, you need to seek to activate the error listener
426
            // if an issue arises after inserting the recorded audio into the player source.
427
            this.player.currentTime = 0.1;
428
        }
429
    }
430
 
431
    /**
1 efrain 432
     * Prevent the Modal from closing when recording is on process.
433
     *
434
     * @param {MouseEvent} event The click event
435
     */
436
    async outsideClickHandler(event) {
1441 ariadna 437
        if (this.isRecording() || this.isPaused()) {
1 efrain 438
            // The user is recording.
439
            // Do not distract with a confirmation, just prevent closing.
440
            event.preventDefault();
441
        } else if (this.hasData()) {
442
            // If there is a blobsize then there is data that may be lost.
443
            // Ask the user to confirm they want to close the modal.
444
            // We prevent default here, and then close the modal if they confirm.
445
            event.preventDefault();
446
 
447
            try {
448
                await saveCancelPromise(
449
                    await getString("discard_title", component),
450
                    await getString("discard_desc", component),
451
                    await getString("confirm_yes", component),
452
                );
453
                this.modal.hide();
454
            } catch (error) {
455
                // Do nothing, the modal will not close.
456
            }
457
        }
458
    }
459
 
460
    /**
461
     * Handle a click within the Modal.
462
     *
463
     * @param {MouseEvent} event The click event
464
     */
465
    handleModalClick(event) {
466
        const button = event.target.closest('button');
467
        if (button && button.dataset.action) {
468
            const action = button.dataset.action;
469
            if (action === 'startstop') {
470
                this.handleRecordingStartStopRequested();
471
            }
472
 
473
            if (action === 'upload') {
474
                this.uploadRecording();
475
            }
1441 ariadna 476
 
477
            if (action === 'pauseresume') {
478
                this.handleRecordingPauseResumeRequested();
479
            }
1 efrain 480
        }
481
    }
482
 
483
    /**
484
     * Handle the click event for the recording start/stop button.
485
     */
486
    handleRecordingStartStopRequested() {
1441 ariadna 487
        if (this.isRecording() || this.isPaused()) {
1 efrain 488
            this.requestRecordingStop();
489
        } else {
490
            this.startRecording();
491
        }
492
    }
493
 
494
    /**
1441 ariadna 495
     * Handle the click event for the recording pause/resume button.
496
     */
497
    handleRecordingPauseResumeRequested() {
498
        if (this.isRecording()) {
499
            // Pause recording.
500
            this.mediaRecorder.pause();
501
        } else if (this.isPaused()) {
502
            // Resume recording.
503
            this.mediaRecorder.resume();
504
        }
505
    }
506
 
507
    /**
1 efrain 508
     * Handle the media stream after it has finished.
509
     */
510
    async onMediaStopped() {
511
        // Set source of audio player.
512
        this.blob = new Blob(this.data.chunks, {
513
            type: this.mediaRecorder.mimeType
514
        });
515
        this.player.srcObject = null;
516
        this.player.src = URL.createObjectURL(this.blob);
517
 
518
        // Change the label to "Record again".
519
        this.setRecordButtonTextFromString('recordagain');
520
 
521
        // Show upload button.
522
        this.setUploadButtonVisibility(true);
1441 ariadna 523
        this.setPlayerState(true);
1 efrain 524
        this.setUploadButtonState(true);
1441 ariadna 525
 
526
        // Hide the pause button.
527
        this.setPauseButtonVisibility(false);
528
        if (this.mediaRecorder.state === 'inactive') {
529
            this.setPauseButtonTextFromString('pause');
530
        }
1 efrain 531
    }
532
 
533
    /**
534
     * Upload the recording and insert it into the editor content.
535
     */
536
    async uploadRecording() {
537
        // Trigger error if no recording has been made.
538
        if (this.data.chunks.length === 0) {
539
            this.displayAlert('norecordingfound');
540
            return;
541
        }
542
 
543
        const fileName = this.getFileName((Math.random() * 1000).toString().replace('.', ''));
544
 
545
        // Upload recording to server.
546
        try {
547
            // Once uploading starts, do not allow any further changes to the recording.
548
            this.setRecordButtonVisibility(false);
549
 
550
            // Disable the upload button.
551
            this.setUploadButtonState(false);
552
 
553
            // Upload the recording.
554
            const fileURL = await uploadFile(this.editor, 'media', this.blob, fileName, (progress) => {
555
                this.setUploadButtonTextProgress(progress);
556
            });
557
            this.insertMedia(fileURL);
558
            this.close();
559
            addToast(await getString('recordinguploaded', component));
560
        } catch (error) {
561
            // Show a toast and unhide the button.
562
            this.setUploadButtonState(true);
563
 
564
            addToast(await getString('uploadfailed', component, {error}), {
565
                type: 'error',
566
            });
567
 
568
        }
569
    }
570
 
571
    /**
572
     * Helper to get the container that a button is in.
573
     *
574
     * @param {string} purpose The button purpose
575
     * @returns {HTMLElement}
576
     */
577
    getButtonContainer(purpose) {
578
        return this.modalRoot.querySelector(`[data-purpose="${purpose}-container"]`);
579
    }
580
 
581
    /**
582
     * Check whether the browser is compatible with capturing media.
583
     *
584
     * @returns {boolean}
585
     */
586
    static isBrowserCompatible() {
587
        return this.checkSecure() && this.hasUserMedia();
588
    }
589
 
590
    static async display(editor) {
591
        const ModalClass = this.getModalClass();
592
        const modal = await ModalClass.create({
1441 ariadna 593
            templateContext: {
594
                isallowedpausing: isPausingAllowed(editor),
595
            },
1 efrain 596
            large: true,
597
            removeOnClose: true,
598
        });
599
 
600
        // Set up the VideoRecorder.
601
        const recorder = new this(editor, modal);
602
        if (recorder.isReady()) {
603
            modal.show();
604
        }
605
        return modal;
606
    }
607
 
608
    /**
609
     * Check whether the browser is compatible with capturing media, and display a warning if not.
610
     *
611
     * @returns {boolean}
612
     */
613
    checkAndWarnAboutBrowserCompatibility() {
614
        if (!this.constructor.checkSecure()) {
615
            getStrings(['insecurealert_title', 'insecurealert'].map((key) => ({key, component})))
616
                .then(([title, message]) => addToast(message, {title, type: 'error'}))
617
                .catch();
618
            return false;
619
        }
620
 
621
        if (!this.constructor.hasUserMedia) {
622
            getStrings(['nowebrtc_title', 'nowebrtc'].map((key) => ({key, component})))
623
                .then(([title, message]) => addToast(message, {title, type: 'error'}))
624
                .catch();
625
            return false;
626
        }
627
 
628
        return true;
629
    }
630
 
631
    /**
632
     * Check whether the browser supports WebRTC.
633
     *
634
     * @returns {boolean}
635
     */
636
    static hasUserMedia() {
637
        return (navigator.mediaDevices && window.MediaRecorder);
638
    }
639
 
640
    /**
641
     * Check whether the hostname is either hosted over SSL, or from a valid localhost hostname.
642
     *
643
     * The UserMedia API can only be used in secure contexts as noted.
644
     * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia#privacy_and_security}
645
     *
646
     * @returns {boolean} Whether the plugin can be loaded.
647
     */
648
    static checkSecure() {
649
        // Note: We can now use window.isSecureContext.
650
        // https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#feature_detection
651
        // https://developer.mozilla.org/en-US/docs/Web/API/isSecureContext
652
        return window.isSecureContext;
653
    }
654
 
655
    /**
656
     * Update the content of the stop recording button timer.
657
     */
658
    async setStopRecordingButton() {
659
        const {html, js} = await Templates.renderForPromise('tiny_recordrtc/timeremaining', this.getTimeRemaining());
660
        Templates.replaceNodeContents(this.startStopButton, html, js);
1441 ariadna 661
        this.startButtonTimer();
1 efrain 662
    }
663
 
664
    /**
665
     * Update the time on the stop recording button.
666
     */
667
    updateRecordButtonTime() {
668
        const {remaining, minutes, seconds} = this.getTimeRemaining();
669
        if (remaining < 0) {
670
            this.requestRecordingStop();
671
        } else {
672
            this.startStopButton.querySelector('[data-type="minutes"]').textContent = minutes;
673
            this.startStopButton.querySelector('[data-type="seconds"]').textContent = seconds;
674
        }
675
    }
676
 
677
    /**
678
     * Set the text of the record button using a language string.
679
     *
680
     * @param {string} string The string identifier
681
     */
682
    async setRecordButtonTextFromString(string) {
683
        this.startStopButton.textContent = await getString(string, component);
684
    }
685
 
686
    /**
1441 ariadna 687
     * Set the text of the pause button using a language string.
688
     *
689
     * @param {string} string The string identifier
690
     */
691
    async setPauseButtonTextFromString(string) {
692
        if (this.pauseResumeButton) {
693
            this.pauseResumeButton.textContent = await getString(string, component);
694
        }
695
    }
696
 
697
    /**
1 efrain 698
     * Set the upload button text progress.
699
     *
700
     * @param {number} progress The progress
701
     */
702
    async setUploadButtonTextProgress(progress) {
703
        this.uploadButton.textContent = await getString('uploading', component, {
704
            progress: Math.round(progress * 100) / 100,
705
        });
706
    }
707
 
708
    async resetUploadButtonText() {
709
        this.uploadButton.textContent = await getString('upload', component);
710
    }
711
 
712
    /**
713
     * Clear the timer for the stop recording button.
714
     */
715
    clearButtonTimer() {
716
        if (this.buttonTimer) {
717
            clearInterval(this.buttonTimer);
718
        }
719
        this.buttonTimer = null;
1441 ariadna 720
        this.pauseTime = null;
721
        this.startTime = null;
1 efrain 722
    }
723
 
724
    /**
1441 ariadna 725
     * Pause the timer for the stop recording button.
726
     */
727
    pauseButtonTimer() {
728
        // Stop the countdown timer.
729
        this.pauseTime = new Date().getTime(); // Store pause time.
730
        if (this.buttonTimer) {
731
            clearInterval(this.buttonTimer);
732
        }
733
    }
734
 
735
    /**
736
     * Start the timer for the start recording button.
737
     * If the recording was paused, the timer will resume from the pause time.
738
     */
739
    startButtonTimer() {
740
        if (this.pauseTime !== null) {
741
            // Resume from pause.
742
            const pauseDuration = new Date().getTime() - this.pauseTime;
743
            // Adjust start time by pause duration.
744
            this.startTime += pauseDuration;
745
            this.pauseTime = null;
746
        }
747
        this.buttonTimer = setInterval(this.updateRecordButtonTime.bind(this), 500);
748
    }
749
 
750
    /**
1 efrain 751
     * Get the time remaining for the recording.
752
     *
753
     * @returns {Object} The minutes and seconds remaining.
754
     */
755
    getTimeRemaining() {
1441 ariadna 756
        // All times are in milliseconds.
757
        let now = new Date().getTime();
758
        if (this.pauseTime !== null) {
759
            // If paused, use pauseTime instead of current time.
760
            now = this.pauseTime;
761
        }
1 efrain 762
        const remaining = Math.floor(this.getTimeLimit() - ((now - this.startTime) / 1000));
763
 
764
        const formatter = new Intl.NumberFormat(navigator.language, {minimumIntegerDigits: 2});
765
        const seconds = formatter.format(remaining % 60);
766
        const minutes = formatter.format(Math.floor((remaining - seconds) / 60));
767
        return {
768
            remaining,
769
            minutes,
770
            seconds,
771
        };
772
    }
773
 
774
    /**
775
     * Get the maximum file size that can be uploaded.
776
     *
777
     * @returns {number} The max byte size
778
     */
779
    getMaxUploadSize() {
780
        return this.config.maxrecsize;
781
    }
782
 
783
    /**
784
     * Stop the recording.
785
     * Please note that this should only stop the recording.
786
     * Anything related to processing the recording should be handled by the
787
     * mediaRecorder's stopped event handler which is processed after it has stopped.
788
     */
789
    requestRecordingStop() {
790
        if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
791
            this.stopRequested = true;
1441 ariadna 792
            if (this.isPaused()) {
793
                this.stopRecorder();
794
            }
1 efrain 795
        } else {
796
            // There is no recording to stop, but the stream must still be cleaned up.
797
            this.cleanupStream();
798
        }
799
    }
800
 
801
    stopRecorder() {
1441 ariadna 802
        if (this.isPaused()) {
803
            this.pauseTime = null;
804
        }
1 efrain 805
        this.mediaRecorder.stop();
806
 
807
        // Unmute the player so that the audio is heard during playback.
808
        this.player.muted = false;
809
    }
810
 
811
    /**
812
     * Clean up the stream.
813
     *
814
     * This involves stopping any track which is still active.
815
     */
816
    cleanupStream() {
817
        if (this.stream) {
818
            this.stream.getTracks()
819
                .filter((track) => track.readyState !== 'ended')
820
                .forEach((track) => track.stop());
821
        }
822
    }
823
 
824
    /**
825
     * Handle the mediaRecorder `stop` event.
826
     */
827
    handleStopped() {
828
        // Handle the stream data.
829
        this.onMediaStopped();
830
 
831
        // Clear the button timer.
832
        this.clearButtonTimer();
833
    }
834
 
835
    /**
836
     * Handle the mediaRecorder `start` event.
837
     *
838
     * This event is called when the recording starts.
839
     */
840
    handleStarted() {
841
        this.startTime = new Date().getTime();
1441 ariadna 842
        if (isPausingAllowed(this.editor) && !this.isPaused()) {
843
            this.setPauseButtonVisibility(true);
844
        }
1 efrain 845
        this.setStopRecordingButton();
846
    }
847
 
848
    /**
1441 ariadna 849
     * Handle the mediaRecorder `pause` event.
850
     *
851
     * This event is called when the recording pauses.
852
     */
853
    handlePaused() {
854
        this.pauseButtonTimer();
855
        this.setPauseButtonTextFromString('resume');
856
    }
857
 
858
    /**
859
     * Handle the mediaRecorder `resume` event.
860
     *
861
     * This event is called when the recording resumes.
862
     */
863
    handleResume() {
864
        this.startButtonTimer();
865
        this.setPauseButtonTextFromString('pause');
866
    }
867
 
868
    /**
1 efrain 869
     * Handle the mediaRecorder `dataavailable` event.
870
     *
871
     * @param {Event} event
872
     */
873
    handleDataAvailable(event) {
1441 ariadna 874
        if (this.isRecording() || this.isPaused()) {
1 efrain 875
            const newSize = this.data.blobSize + event.data.size;
876
            // Recording stops when either the maximum upload size is reached, or the time limit expires.
877
            // The time limit is checked in the `updateButtonTime` function.
878
            if (newSize >= this.getMaxUploadSize()) {
879
                this.stopRecorder();
880
                this.displayFileLimitHitMessage();
881
            } else {
882
                // Push recording slice to array.
883
                this.data.chunks.push(event.data);
884
 
885
                // Size of all recorded data so far.
886
                this.data.blobSize = newSize;
887
 
888
                if (this.stopRequested) {
889
                    this.stopRecorder();
890
                }
891
            }
892
        }
893
    }
894
 
895
    async displayFileLimitHitMessage() {
896
        addToast(await getString('maxfilesizehit', component), {
897
            title: await getString('maxfilesizehit_title', component),
898
            type: 'error',
899
        });
900
    }
901
 
902
    /**
903
     * Check whether the recording is in progress.
904
     *
905
     * @returns {boolean}
906
     */
907
    isRecording() {
908
        return this.mediaRecorder?.state === 'recording';
909
    }
910
 
911
    /**
1441 ariadna 912
     * Check whether the recording is paused.
913
     *
914
     * @returns {boolean}
915
     */
916
    isPaused() {
917
        return this.mediaRecorder?.state === 'paused';
918
    }
919
 
920
    /**
1 efrain 921
     * Whether any data has been recorded.
922
     *
923
     * @returns {boolean}
924
     */
925
    hasData() {
926
        return !!this.data?.blobSize;
927
    }
928
 
929
    /**
930
     * Start the recording
931
     */
932
    async startRecording() {
933
        if (this.mediaRecorder) {
934
            // Stop the existing recorder if it exists.
1441 ariadna 935
            if (this.isRecording() || this.isPaused()) {
1 efrain 936
                this.mediaRecorder.stop();
937
            }
938
 
939
            if (this.hasData()) {
940
                const resetRecording = await this.recordAgainConfirmation();
941
                if (!resetRecording) {
942
                    // User cancelled at the confirmation to reset the data, so exit early.
943
                    return;
944
                }
945
                this.setUploadButtonVisibility(false);
1441 ariadna 946
                this.setPlayerState(false);
947
                if (!this.stream.active) {
948
                    await this.captureUserMedia();
949
                }
1 efrain 950
            }
951
 
952
            this.mediaRecorder = null;
953
        }
954
 
955
        // The options for the recording codecs and bitrates.
956
        this.mediaRecorder = new MediaRecorder(this.stream, this.getParsedRecordingOptions());
957
 
958
        this.mediaRecorder.addEventListener('dataavailable', this.handleDataAvailable.bind(this));
959
        this.mediaRecorder.addEventListener('stop', this.handleStopped.bind(this));
960
        this.mediaRecorder.addEventListener('start', this.handleStarted.bind(this));
1441 ariadna 961
        this.mediaRecorder.addEventListener('pause', this.handlePaused.bind(this));
962
        this.mediaRecorder.addEventListener('resume', this.handleResume.bind(this));
1 efrain 963
 
964
        this.data = {
965
            chunks: [],
966
            blobSize: 0
967
        };
968
        this.setupPlayerSource();
969
        this.stopRequested = false;
970
 
971
        // Capture in 50ms chunks.
972
        this.mediaRecorder.start(50);
973
    }
974
 
975
    /**
976
     * Confirm whether the user wants to reset the existing recoring.
977
     *
978
     * @returns {Promise<boolean>} Whether the user confirmed the reset.
979
     */
980
    async recordAgainConfirmation() {
981
        try {
982
            await saveCancelPromise(
983
                await getString("recordagain_title", component),
984
                await getString("recordagain_desc", component),
985
                await getString("confirm_yes", component)
986
            );
987
            return true;
988
        } catch {
989
            return false;
990
        }
991
    }
992
 
993
    /**
994
     * Insert the HTML to embed the recording into the editor content.
995
     *
996
     * @param {string} source The URL to view the media.
997
     */
998
    async insertMedia(source) {
999
        const {html} = await Templates.renderForPromise(
1000
            this.getEmbedTemplateName(),
1001
            this.getEmbedTemplateContext({
1002
                source,
1003
            })
1004
        );
1005
        this.editor.insertContent(html);
1006
    }
1007
 
1008
    /**
1009
     * Add or modify the template parameters for the specified type.
1010
     *
1011
     * @param {Object} templateContext The Tempalte context to use
1012
     * @returns {Object} The finalised template context
1013
     */
1014
    getEmbedTemplateContext(templateContext) {
1015
        return templateContext;
1016
    }
1017
}