/* * (C) Copyright 2017-2022 OpenVidu (https://openvidu.io) * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ import { Stream } from './Stream'; import { LocalRecorderState } from '../OpenViduInternal/Enums/LocalRecorderState'; import { OpenViduLogger } from '../OpenViduInternal/Logger/OpenViduLogger'; import { PlatformUtils } from '../OpenViduInternal/Utils/Platform'; import Mime = require('mime/lite'); /** * @hidden */ const logger: OpenViduLogger = OpenViduLogger.getInstance(); /** * @hidden */ let platform: PlatformUtils; /** * Easy recording of [[Stream]] objects straightaway from the browser. Initialized with [[OpenVidu.initLocalRecorder]] method */ export class LocalRecorder { state: LocalRecorderState; private connectionId: string; private mediaRecorder: MediaRecorder; private chunks: any[] = []; private blob?: Blob; private id: string; private videoPreviewSrc: string; private videoPreview: HTMLVideoElement; /** * @hidden */ constructor(private stream: Stream) { platform = PlatformUtils.getInstance(); this.connectionId = !!this.stream.connection ? this.stream.connection.connectionId : 'default-connection'; this.id = this.stream.streamId + '_' + this.connectionId + '_localrecord'; this.state = LocalRecorderState.READY; } /** * Starts the recording of the Stream. [[state]] property must be `READY`. After method succeeds is set to `RECORDING` * * @param options The [MediaRecorder.options](https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder/MediaRecorder#parameters) to be used to record this Stream. * For example: * * ```javascript * var OV = new OpenVidu(); * var publisher = await OV.initPublisherAsync(); * var localRecorder = OV.initLocalRecorder(publisher.stream); * var options = { * mimeType: 'video/webm;codecs=vp8', * audioBitsPerSecond:128000, * videoBitsPerSecond:2500000 * }; * localRecorder.record(options); * ``` * * If not specified, the default options preferred by the platform will be used. * * @returns A Promise (to which you can optionally subscribe to) that is resolved if the recording successfully started and rejected with an Error object if not */ record(options?: any): Promise { return new Promise((resolve, reject) => { try { if (typeof options === 'string' || options instanceof String) { return reject( `When calling LocalRecorder.record(options) parameter 'options' cannot be a string. Must be an object like { mimeType: "${options}" }` ); } if (typeof MediaRecorder === 'undefined') { logger.error( 'MediaRecorder not supported on your device. See compatibility in https://caniuse.com/#search=MediaRecorder' ); throw Error( 'MediaRecorder not supported on your device. See compatibility in https://caniuse.com/#search=MediaRecorder' ); } if (this.state !== LocalRecorderState.READY) { throw Error( "'LocalRecord.record()' needs 'LocalRecord.state' to be 'READY' (current value: '" + this.state + "'). Call 'LocalRecorder.clean()' or init a new LocalRecorder before" ); } logger.log("Starting local recording of stream '" + this.stream.streamId + "' of connection '" + this.connectionId + "'"); if (!options) { options = { mimeType: 'video/webm' }; } else if (!options.mimeType) { options.mimeType = 'video/webm'; } this.mediaRecorder = new MediaRecorder(this.stream.getMediaStream(), options); this.mediaRecorder.start(); } catch (err) { return reject(err); } this.mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0) { this.chunks.push(e.data); } }; this.mediaRecorder.onerror = (e) => { logger.error('MediaRecorder error: ', e); }; this.mediaRecorder.onstart = () => { logger.log('MediaRecorder started (state=' + this.mediaRecorder.state + ')'); }; this.mediaRecorder.onstop = () => { this.onStopDefault(); }; this.mediaRecorder.onpause = () => { logger.log('MediaRecorder paused (state=' + this.mediaRecorder.state + ')'); }; this.mediaRecorder.onresume = () => { logger.log('MediaRecorder resumed (state=' + this.mediaRecorder.state + ')'); }; this.state = LocalRecorderState.RECORDING; return resolve(); }); } /** * Ends the recording of the Stream. [[state]] property must be `RECORDING` or `PAUSED`. After method succeeds is set to `FINISHED` * @returns A Promise (to which you can optionally subscribe to) that is resolved if the recording successfully stopped and rejected with an Error object if not */ stop(): Promise { return new Promise((resolve, reject) => { try { if (this.state === LocalRecorderState.READY || this.state === LocalRecorderState.FINISHED) { throw Error( "'LocalRecord.stop()' needs 'LocalRecord.state' to be 'RECORDING' or 'PAUSED' (current value: '" + this.state + "'). Call 'LocalRecorder.start()' before" ); } this.mediaRecorder.onstop = () => { this.onStopDefault(); return resolve(); }; this.mediaRecorder.stop(); } catch (e) { return reject(e); } }); } /** * Pauses the recording of the Stream. [[state]] property must be `RECORDING`. After method succeeds is set to `PAUSED` * @returns A Promise (to which you can optionally subscribe to) that is resolved if the recording was successfully paused and rejected with an Error object if not */ pause(): Promise { return new Promise((resolve, reject) => { try { if (this.state !== LocalRecorderState.RECORDING) { return reject( Error( "'LocalRecord.pause()' needs 'LocalRecord.state' to be 'RECORDING' (current value: '" + this.state + "'). Call 'LocalRecorder.start()' or 'LocalRecorder.resume()' before" ) ); } this.mediaRecorder.pause(); this.state = LocalRecorderState.PAUSED; return resolve(); } catch (error) { return reject(error); } }); } /** * Resumes the recording of the Stream. [[state]] property must be `PAUSED`. After method succeeds is set to `RECORDING` * @returns A Promise (to which you can optionally subscribe to) that is resolved if the recording was successfully resumed and rejected with an Error object if not */ resume(): Promise { return new Promise((resolve, reject) => { try { if (this.state !== LocalRecorderState.PAUSED) { throw Error( "'LocalRecord.resume()' needs 'LocalRecord.state' to be 'PAUSED' (current value: '" + this.state + "'). Call 'LocalRecorder.pause()' before" ); } this.mediaRecorder.resume(); this.state = LocalRecorderState.RECORDING; return resolve(); } catch (error) { return reject(error); } }); } /** * Previews the recording, appending a new HTMLVideoElement to element with id `parentId`. [[state]] property must be `FINISHED` */ preview(parentElement): HTMLVideoElement { if (this.state !== LocalRecorderState.FINISHED) { throw Error( "'LocalRecord.preview()' needs 'LocalRecord.state' to be 'FINISHED' (current value: '" + this.state + "'). Call 'LocalRecorder.stop()' before" ); } this.videoPreview = document.createElement('video'); this.videoPreview.id = this.id; this.videoPreview.autoplay = true; if (platform.isSafariBrowser()) { this.videoPreview.playsInline = true; } if (typeof parentElement === 'string') { const parentElementDom = document.getElementById(parentElement); if (parentElementDom) { this.videoPreview = parentElementDom.appendChild(this.videoPreview); } } else { this.videoPreview = parentElement.appendChild(this.videoPreview); } this.videoPreview.src = this.videoPreviewSrc; return this.videoPreview; } /** * Gracefully stops and cleans the current recording (WARNING: it is completely dismissed). Sets [[state]] to `READY` so the recording can start again */ clean(): void { const f = () => { delete this.blob; this.chunks = []; this.state = LocalRecorderState.READY; }; if (this.state === LocalRecorderState.RECORDING || this.state === LocalRecorderState.PAUSED) { this.stop() .then(() => f()) .catch(() => f()); } else { f(); } } /** * Downloads the recorded video through the browser. [[state]] property must be `FINISHED` */ download(): void { if (this.state !== LocalRecorderState.FINISHED) { throw Error( "'LocalRecord.download()' needs 'LocalRecord.state' to be 'FINISHED' (current value: '" + this.state + "'). Call 'LocalRecorder.stop()' before" ); } else { const a: HTMLAnchorElement = document.createElement('a'); a.style.display = 'none'; document.body.appendChild(a); const url = globalThis.URL.createObjectURL(this.blob); a.href = url; a.download = this.id + '.' + Mime.getExtension(this.blob!.type); a.click(); globalThis.URL.revokeObjectURL(url); document.body.removeChild(a); } } /** * Gets the raw Blob file. Methods preview, download, uploadAsBinary and uploadAsMultipartfile use this same file to perform their specific actions. [[state]] property must be `FINISHED` */ getBlob(): Blob { if (this.state !== LocalRecorderState.FINISHED) { throw Error("Call 'LocalRecord.stop()' before getting Blob file"); } else { return this.blob!; } } /** * Uploads the recorded video as a binary file performing an HTTP/POST operation to URL `endpoint`. [[state]] property must be `FINISHED`. Optional HTTP headers can be passed as second parameter. For example: * ``` * var headers = { * "Cookie": "$Version=1; Skin=new;", * "Authorization":"Basic QWxhZGpbjpuIHNlctZQ==" * } * ``` * @returns A Promise (to which you can optionally subscribe to) that is resolved with the `http.responseText` from server if the operation was successful and rejected with the failed `http.status` if not */ uploadAsBinary(endpoint: string, headers?: any): Promise { return new Promise((resolve, reject) => { if (this.state !== LocalRecorderState.FINISHED) { return reject( Error( "'LocalRecord.uploadAsBinary()' needs 'LocalRecord.state' to be 'FINISHED' (current value: '" + this.state + "'). Call 'LocalRecorder.stop()' before" ) ); } else { const http = new XMLHttpRequest(); http.open('POST', endpoint, true); if (typeof headers === 'object') { for (const key of Object.keys(headers)) { http.setRequestHeader(key, headers[key]); } } http.onreadystatechange = () => { if (http.readyState === 4) { if (http.status.toString().charAt(0) === '2') { // Success response from server (HTTP status standard: 2XX is success) return resolve(http.responseText); } else { return reject(http.status); } } }; http.send(this.blob); } }); } /** * Uploads the recorded video as a multipart file performing an HTTP/POST operation to URL `endpoint`. [[state]] property must be `FINISHED`. Optional HTTP headers can be passed as second parameter. For example: * ``` * var headers = { * "Cookie": "$Version=1; Skin=new;", * "Authorization":"Basic QWxhZGpbjpuIHNlctZQ==" * } * ``` * @returns A Promise (to which you can optionally subscribe to) that is resolved with the `http.responseText` from server if the operation was successful and rejected with the failed `http.status` if not: */ uploadAsMultipartfile(endpoint: string, headers?: any): Promise { return new Promise((resolve, reject) => { if (this.state !== LocalRecorderState.FINISHED) { return reject( Error( "'LocalRecord.uploadAsMultipartfile()' needs 'LocalRecord.state' to be 'FINISHED' (current value: '" + this.state + "'). Call 'LocalRecorder.stop()' before" ) ); } else { const http = new XMLHttpRequest(); http.open('POST', endpoint, true); if (typeof headers === 'object') { for (const key of Object.keys(headers)) { http.setRequestHeader(key, headers[key]); } } const sendable = new FormData(); sendable.append('file', this.blob!, this.id + '.' + Mime.getExtension(this.blob!.type)); http.onreadystatechange = () => { if (http.readyState === 4) { if (http.status.toString().charAt(0) === '2') { // Success response from server (HTTP status standard: 2XX is success) return resolve(http.responseText); } else { return reject(http.status); } } }; http.send(sendable); } }); } /* Private methods */ private onStopDefault(): void { logger.log('MediaRecorder stopped (state=' + this.mediaRecorder.state + ')'); this.blob = new Blob(this.chunks, { type: this.mediaRecorder.mimeType }); this.chunks = []; this.videoPreviewSrc = globalThis.URL.createObjectURL(this.blob); this.state = LocalRecorderState.FINISHED; } }