UNPKG

videomail-client

Version:

A wicked npm package to record videos directly in the browser, wohooo!

1,410 lines (1,114 loc) 36.2 kB
import animitter from 'animitter' import Frame from 'canvas-to-buffer' import deepmerge from 'deepmerge' import hidden from 'hidden' import h from 'hyperscript' import stringify from 'safe-json-stringify' import util from 'util' import websocket from 'websocket-stream' import Constants from '../../constants' import Events from '../../events' import Browser from '../../util/browser' import EventEmitter from '../../util/eventEmitter' import Humanize from '../../util/humanize' import pretty from '../../util/pretty' import VideomailError from '../../util/videomailError' import UserMedia from './userMedia' // credits http://1lineart.kulaone.com/#/ const PIPE_SYMBOL = '°º¤ø,¸¸,ø¤º°`°º¤ø,¸,ø¤°º¤ø,¸¸,ø¤º°`°º¤ø,¸ ' const Recorder = function (visuals, replay, defaultOptions = {}) { EventEmitter.call(this, defaultOptions, 'Recorder') const browser = new Browser(defaultOptions) const options = deepmerge(defaultOptions, { image: { // automatically lower quality when on mobile quality: browser.isMobile() ? defaultOptions.image.quality - 0.05 : defaultOptions.image.quality } }) // validate some options this class needs if (!options.video || !options.video.fps) { throw VideomailError.create('FPS must be defined', options) } const self = this const debug = options.debug let loop = null let originalAnimationFrameObject let samplesCount = 0 let framesCount = 0 let facingMode = options.video.facingMode // default is 'user' let recordingStats = {} let confirmedFrameNumber = 0 let confirmedSampleNumber = 0 let recorderElement let userMedia let userMediaTimeout let retryTimeout let bytesSum let frameProgress let sampleProgress let canvas let ctx let userMediaLoaded let userMediaLoading let submitting let unloaded let stopTime let stream let connecting let connected let blocking let built let key let waitingTime let pingInterval let frame let recordingBufferLength let recordingBuffer function writeStream(buffer, opts) { if (stream) { if (stream.destroyed) { // prevents https://github.com/binarykitchen/videomail.io/issues/393 stopPings() self.emit( Events.ERROR, VideomailError.create( 'Already disconnected', 'Sorry, connection to the server has been destroyed. Please reload.', options ) ) } else { const onFlushedCallback = opts && opts.onFlushedCallback try { stream.write(buffer, function () { onFlushedCallback && onFlushedCallback(opts) }) } catch (exc) { self.emit( Events.ERROR, VideomailError.create( 'Failed writing to server', 'stream.write() failed because of ' + pretty(exc), options ) ) } } } } function sendPings() { pingInterval = window.setInterval(function () { debug('Recorder: pinging...') writeStream(Buffer.from('')) }, options.timeouts.pingInterval) } function stopPings() { clearInterval(pingInterval) } function onAudioSample(audioSample) { samplesCount++ const audioBuffer = audioSample.toBuffer() // if (options.verbose) { // debug( // 'Sample #' + samplesCount + ' (' + audioBuffer.length + ' bytes):' // ) // } writeStream(audioBuffer) } function show() { recorderElement && hidden(recorderElement, false) } function onUserMediaReady(params = {}) { try { debug('Recorder: onUserMediaReady()', params) const switchingFacingMode = params.switchingFacingMode userMediaLoading = blocking = unloaded = submitting = false userMediaLoaded = true if (!switchingFacingMode) { loop = createLoop() } show() self.emit(Events.USER_MEDIA_READY, { switchingFacingMode: params.switchingFacingMode, paused: self.isPaused() }) } catch (exc) { self.emit(Events.ERROR, exc) } } function clearRetryTimeout() { debug('Recorder: clearRetryTimeout()') retryTimeout && clearTimeout(retryTimeout) retryTimeout = null } function clearUserMediaTimeout() { if (userMediaTimeout) { debug('Recorder: clearUserMediaTimeout()') userMediaTimeout && clearTimeout(userMediaTimeout) userMediaTimeout = null } } function calculateFrameProgress() { return ((confirmedFrameNumber / (framesCount || 1)) * 100).toFixed(2) + '%' } function calculateSampleProgress() { return ((confirmedSampleNumber / (samplesCount || 1)) * 100).toFixed(2) + '%' } function updateOverallProgress() { // when progresses aren't initialized, // then do a first calculation to avoid `infinite` or `null` displays if (!frameProgress) { frameProgress = calculateFrameProgress() } if (!sampleProgress) { sampleProgress = calculateSampleProgress() } self.emit(Events.PROGRESS, frameProgress, sampleProgress) } function updateFrameProgress(args) { confirmedFrameNumber = args.frame ? args.frame : confirmedFrameNumber frameProgress = calculateFrameProgress() updateOverallProgress() } function updateSampleProgress(args) { confirmedSampleNumber = args.sample ? args.sample : confirmedSampleNumber sampleProgress = calculateSampleProgress() updateOverallProgress() } function preview(args) { confirmedFrameNumber = confirmedSampleNumber = samplesCount = framesCount = 0 sampleProgress = frameProgress = null key = args.key // We are not serving MP4 videos anymore due to licensing but are keeping code // for compatibility and documentation if (args.mp4) { replay.setMp4Source( args.mp4 + Constants.SITE_NAME_LABEL + '/' + options.siteName + '/videomail.mp4', true ) } if (args.webm) { replay.setWebMSource( args.webm + Constants.SITE_NAME_LABEL + '/' + options.siteName + '/videomail.webm', true ) } self.hide() const width = self.getRecorderWidth(true) const height = self.getRecorderHeight(true) self.emit(Events.PREVIEW, key, width, height) // keep it for recording stats waitingTime = Date.now() - stopTime recordingStats.waitingTime = waitingTime if (options.debug) { debug( 'While recording, %s have been transferred and waiting time was %s', Humanize.filesize(bytesSum, 2), Humanize.toTime(waitingTime) ) } } function initSocket(cb) { if (!connected) { connecting = true debug('Recorder: initialising web socket to %s', options.socketUrl) self.emit(Events.CONNECTING) // https://github.com/maxogden/websocket-stream#binary-sockets // we use query parameters here because we cannot set custom headers in web sockets, // see https://github.com/websockets/ws/issues/467 const url2Connect = options.socketUrl + '?' + encodeURIComponent(Constants.SITE_NAME_LABEL) + '=' + encodeURIComponent(options.siteName) try { // websocket options cannot be set on client side, only on server, see // https://github.com/maxogden/websocket-stream/issues/116#issuecomment-296421077 stream = websocket(url2Connect, { perMessageDeflate: false, // see https://github.com/maxogden/websocket-stream/issues/117#issuecomment-298826011 objectMode: true }) } catch (exc) { connecting = connected = false let err if (typeof websocket === 'undefined') { err = VideomailError.create( 'There is no websocket', 'Cause: ' + pretty(exc), options ) } else { err = VideomailError.create( 'Failed to connect to server', 'Please upgrade your browser. Your current version does not seem to support websockets.', options, { browserProblem: true } ) } self.emit(Events.ERROR, err) } if (stream) { // // useful for debugging streams // if (!stream.originalEmit) { // stream.originalEmit = stream.emit // } // stream.emit = function (type) { // if (stream) { // debug(PIPE_SYMBOL + 'Debugging stream event:', type) // var args = Array.prototype.slice.call(arguments, 0) // return stream.originalEmit.apply(stream, args) // } // } stream.on('close', function (err) { debug(PIPE_SYMBOL + 'Stream has closed') connecting = connected = false if (err) { self.emit(Events.ERROR, err || 'Unhandled websocket error') } else { self.emit(Events.DISCONNECTED) // prevents from https://github.com/binarykitchen/videomail.io/issues/297 happening cancelAnimationFrame() } }) stream.on('connect', function () { debug(PIPE_SYMBOL + 'Stream *connect* event emitted') if (!connected) { connected = true connecting = unloaded = false self.emit(Events.CONNECTED) debug('Going to ask for webcam permissons now ...') cb && cb() } }) stream.on('data', function (data) { debug(PIPE_SYMBOL + 'Stream *data* event emitted') let command try { command = JSON.parse(data.toString()) } catch (exc) { debug('Failed to parse command:', exc) self.emit( Events.ERROR, VideomailError.create( 'Invalid server command', // toString() since https://github.com/binarykitchen/videomail.io/issues/288 'Contact us asap. Bad command was ' + data.toString() + '. ', options ) ) } finally { executeCommand.call(self, command) } }) stream.on('error', function (err) { debug(PIPE_SYMBOL + 'Stream *error* event emitted', err) connecting = connected = false let videomailError if (browser.isIOS()) { // setting custom text since that err object isn't really an error // on iphones when locked, and unlocked, this err is actually // an event object with stuff we can't use at all (an external bug) videomailError = VideomailError.create( err, 'iPhones cannot maintain a live connection for too long. Original error message is: ' + err.toString(), options ) // Changed to the above temporarily for better investigations // videomailError = VideomailError.create( // 'Sorry, connection has timed out', // 'iPhones cannot maintain a live connection for too long, // options // ) } else { // or else it could be a poor wifi connection... videomailError = VideomailError.create( 'Data exchange interrupted', 'Please check your network connection and reload.', options ) } self.emit(Events.ERROR, videomailError) }) // just experimental stream.on('drain', function () { debug(PIPE_SYMBOL + 'Stream *drain* event emitted (should not happen!)') }) stream.on('preend', function () { debug(PIPE_SYMBOL + 'Stream *preend* event emitted') }) stream.on('end', function () { debug(PIPE_SYMBOL + 'Stream *end* event emitted') }) stream.on('drain', function () { debug(PIPE_SYMBOL + 'Stream *drain* event emitted') }) stream.on('pipe', function () { debug(PIPE_SYMBOL + 'Stream *pipe* event emitted') }) stream.on('unpipe', function () { debug(PIPE_SYMBOL + 'Stream *unpipe* event emitted') }) stream.on('resume', function () { debug(PIPE_SYMBOL + 'Stream *resume* event emitted') }) stream.on('uncork', function () { debug(PIPE_SYMBOL + 'Stream *uncork* event emitted') }) stream.on('readable', function () { debug(PIPE_SYMBOL + 'Stream *preend* event emitted') }) stream.on('prefinish', function () { debug(PIPE_SYMBOL + 'Stream *preend* event emitted') }) stream.on('finish', function () { debug(PIPE_SYMBOL + 'Stream *preend* event emitted') }) } } } function showUserMedia() { // use connected flag to prevent this from happening // https://github.com/binarykitchen/videomail.io/issues/323 return connected && (isNotifying() || !isHidden() || blocking) } function userMediaErrorCallback(err, extraA, extraB) { userMediaLoading = false clearUserMediaTimeout() debug( 'Recorder: userMediaErrorCallback()', ', name:', err.name, ', message:', err.message, ', Webcam characteristics:', userMedia.getCharacteristics(), // added recently in the hope to investigate weird webcam issues ', extraA arguments:', extraA ? extraA.toString() : undefined, ', extraB arguments:', extraB ? extraB.toString() : undefined ) const errorListeners = self.listeners(Events.ERROR) if (errorListeners && errorListeners.length) { if (err.name !== VideomailError.MEDIA_DEVICE_NOT_SUPPORTED) { self.emit(Events.ERROR, VideomailError.create(err, options)) } else { // do not emit but retry since MEDIA_DEVICE_NOT_SUPPORTED can be a race condition debug('Recorder: ignore user media error', err) } // retry after a while retryTimeout = setTimeout(initSocket, options.timeouts.userMedia) } else { if (unloaded) { // can happen that container is unloaded but some user media related callbacks // are still in process. in that case ignore error. debug('Recorder: already unloaded. Not going to throw error', err) } else { debug('Recorder: no error listeners attached but throwing error', err) // weird situation, throw it instead of emitting since there are no error listeners throw VideomailError.create( err, 'Unable to process this error since there are no error listeners anymore.', options ) } } } function getUserMediaCallback(localStream, params) { debug('Recorder: getUserMediaCallback()', params) if (showUserMedia()) { try { clearUserMediaTimeout() userMedia.init( localStream, function () { onUserMediaReady(params) }, onAudioSample.bind(self), function (err) { self.emit(Events.ERROR, err) }, params ) } catch (exc) { self.emit(Events.ERROR, exc) } } } function loadGenuineUserMedia(params) { if (!navigator) { throw new Error('Navigator is missing!') } debug('Recorder: loadGenuineUserMedia()') self.emit(Events.ASKING_WEBCAM_PERMISSION) // https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) { // prefer the front camera (if one is available) over the rear one const constraints = { video: { facingMode: facingMode, frameRate: { ideal: options.video.fps } }, audio: options.isAudioEnabled() } if (browser.isOkSafari()) { // do not use those width/height constraints yet, // current safari would throw an error // todo in https://github.com/binarykitchen/videomail-client/issues/142 } else { if (options.hasDefinedWidth()) { constraints.video.width = { ideal: options.video.width } } else { // otherwise try to apply the same width as the element is having // but there is no 100% guarantee that this will happen. not // all webcam drivers behave the same way constraints.video.width = { ideal: self.limitWidth() } } if (options.hasDefinedHeight()) { constraints.video.height = { ideal: options.video.height } } } debug('Recorder: navigator.mediaDevices.getUserMedia()', constraints) if (navigator.mediaDevices.getSupportedConstraints) { debug( 'Recorder: navigator.mediaDevices.getSupportedConstraints()', navigator.mediaDevices.getSupportedConstraints() ) } const genuineUserMediaRequest = navigator.mediaDevices.getUserMedia(constraints) if (genuineUserMediaRequest) { genuineUserMediaRequest .then(function (localStream) { getUserMediaCallback(localStream, params) }) .catch(userMediaErrorCallback) } else { // this to trap errors like these // Cannot read property 'then' of undefined // todo retry with navigator.getUserMedia_() maybe? throw VideomailError.create( 'Sorry, your browser is unable to use cameras.', 'Try a different browser with better user media functionalities.', options ) } } else { debug('Recorder: navigator.getUserMedia()') navigator.getUserMedia_( { video: true, audio: options.isAudioEnabled() }, getUserMediaCallback, userMediaErrorCallback ) } } function loadUserMedia() { if (userMediaLoaded) { debug('Recorder: skipping loadUserMedia() because it is already loaded') onUserMediaReady() return false } else if (userMediaLoading) { debug( 'Recorder: skipping loadUserMedia() because it is already asking for permission' ) return false } debug('Recorder: loadUserMedia()') self.emit(Events.LOADING_USER_MEDIA) try { userMediaTimeout = setTimeout(function () { if (!self.isReady()) { self.emit(Events.ERROR, browser.getNoAccessIssue()) } }, options.timeouts.userMedia) userMediaLoading = true loadGenuineUserMedia() } catch (exc) { debug('Recorder: failed to load genuine user media') userMediaLoading = false const errorListeners = self.listeners(Events.ERROR) if (errorListeners.length) { self.emit(Events.ERROR, exc) } else { debug('Recorder: no error listeners attached but throwing exception', exc) throw exc // throw it further } } } function executeCommand(command) { try { debug( 'Server commanded: %s', command.command, command.args ? ', ' + stringify(command.args) : '' ) switch (command.command) { case 'ready': if (!userMediaTimeout) { loadUserMedia() } break case 'preview': preview(command.args) break case 'error': this.emit( Events.ERROR, VideomailError.create( 'Oh no, server error!', command.args.err.toString() || '(No explanation given)', options ) ) break case 'confirmFrame': updateFrameProgress(command.args) break case 'confirmSample': updateSampleProgress(command.args) break case 'beginAudioEncoding': this.emit(Events.BEGIN_AUDIO_ENCODING) break case 'beginVideoEncoding': this.emit(Events.BEGIN_VIDEO_ENCODING) break default: this.emit(Events.ERROR, 'Unknown server command: ' + command.command) break } } catch (exc) { self.emit(Events.ERROR, exc) } } function isNotifying() { return visuals.isNotifying() } function isHidden() { return !recorderElement || hidden(recorderElement) } function writeCommand(command, args, cb) { if (!cb && args && args.constructor === Function) { cb = args args = null } if (!connected) { debug('Reconnecting for the command', command, '…') initSocket(function () { writeCommand(command, args) cb && cb() }) } else if (stream) { debug('$ %s', command, args ? stringify(args) : '') const commandObj = { command: command, args: args } // todo commented out because for some reasons server does // not accept such a long array of many log lines. to examine later. // // add some useful debug info to examine weird stuff like this one // UnprocessableError: Unable to encode a video with FPS near zero. // todo consider removing this later or have it for debug=1 only? // // if (options.logger && options.logger.getLines) { // commandObj.logLines = options.logger.getLines() // } writeStream(Buffer.from(stringify(commandObj))) if (cb) { // keep all callbacks async setTimeout(function () { cb() }, 0) } } } function disconnect() { if (connected) { debug('Recorder: disconnect()') if (userMedia) { // prevents https://github.com/binarykitchen/videomail-client/issues/114 userMedia.unloadRemainingEventListeners() } if (submitting) { // server will disconnect socket automatically after submitting connecting = connected = false } else if (stream) { // force to disconnect socket right now to clean temp files on server // event listeners will do the rest stream.end() stream = undefined } } } function cancelAnimationFrame() { loop && loop.dispose() } function getIntervalSum() { return loop.getElapsedTime() } function getAvgInterval() { return getIntervalSum() / framesCount } function getAvgFps() { return (framesCount / getIntervalSum()) * 1000 } this.getRecordingStats = function () { return recordingStats } this.getAudioSampleRate = function () { return userMedia.getAudioSampleRate() } this.stop = function (params) { debug('stop()', params) const limitReached = params.limitReached this.emit(Events.STOPPING, limitReached) loop.complete() const self = this // needed to give dom enough time to prepare the replay element // to show up upon the STOPPING event so that we can evaluate // the right video type setTimeout(function () { stopTime = Date.now() recordingStats = { // do not use loop.getFPS() as this will only return the fps from the last delta, // not the average. see https://github.com/hapticdata/animitter/issues/3 avgFps: getAvgFps(), wantedFps: options.video.fps, avgInterval: getAvgInterval(), wantedInterval: 1e3 / options.video.fps, intervalSum: getIntervalSum(), framesCount: framesCount, videoType: replay.getVideoType() } if (options.isAudioEnabled()) { recordingStats.samplesCount = samplesCount recordingStats.sampleRate = userMedia.getAudioSampleRate() } writeCommand('stop', recordingStats, function () { self.emit(Events.STOPPED, { recordingStats }) }) // beware, resetting will set framesCount to zero, so leave this here self.reset() }, 60) } this.back = function (cb) { this.emit(Events.GOING_BACK) show() this.reset() writeCommand('back', cb) } function reInitialiseAudio() { debug('Recorder: reInitialiseAudio()') clearUserMediaTimeout() // important to free memory userMedia && userMedia.stop() userMediaLoaded = key = canvas = ctx = null loadUserMedia() } this.unload = function (e) { if (!unloaded) { let cause if (e) { cause = e.name || e.statusText || e.toString() } debug('Recorder: unload()' + (cause ? ', cause: ' + cause : '')) this.reset() clearUserMediaTimeout() disconnect() unloaded = true built = false } } this.reset = function () { // no need to reset when already unloaded if (!unloaded) { debug('Recorder: reset()') this.emit(Events.RESETTING) cancelAnimationFrame() // important to free memory userMedia && userMedia.stop() replay.reset() userMediaLoaded = key = canvas = ctx = waitingTime = null } } this.validate = function () { return connected && framesCount > 0 && canvas === null } this.isReady = function () { return userMedia.isReady() } this.pause = function (params) { const e = params && params.event if (e instanceof window.Event) { params.eventType = e.type } debug(`pause() at frame ${framesCount}`, params) userMedia.pause() loop.stop() this.emit(Events.PAUSED) sendPings() } this.isPaused = function () { return userMedia && userMedia.isPaused() } this.resume = function () { debug(`Recorder: resume() with frame ${framesCount}`) stopPings() this.emit(Events.RESUMING) userMedia.resume() loop.start() } function onFlushed(opts) { const frameNumber = opts && opts.frameNumber if (frameNumber === 1) { self.emit(Events.FIRST_FRAME_SENT) } } function createLoop() { const newLoop = animitter({ fps: options.video.fps }, draw) // remember it first originalAnimationFrameObject = newLoop.getRequestAnimationFrameObject() return newLoop } function draw(deltaTime, elapsedTime) { try { // ctx and stream might become null while unloading if (!self.isPaused() && stream && ctx) { if (framesCount === 0) { self.emit(Events.SENDING_FIRST_FRAME) } framesCount++ ctx.drawImage(userMedia.getRawVisuals(), 0, 0, canvas.width, canvas.height) recordingBuffer = frame.toBuffer() recordingBufferLength = recordingBuffer.length if (recordingBufferLength < 1) { throw VideomailError.create('Failed to extract webcam data.', options) } bytesSum += recordingBufferLength const frameControlBuffer = Buffer.from(stringify({ frameNumber: framesCount })) const frameBuffer = Buffer.concat([recordingBuffer, frameControlBuffer]) writeStream(frameBuffer, { frameNumber: framesCount, onFlushedCallback: onFlushed }) // if (options.verbose) { // debug( // 'Frame #' + framesCount + ' (' + recordingBufferLength + ' bytes):', // ' delta=' + deltaTime + 'ms, ' + // ' elapsed=' + elapsedTime + 'ms' // ) // } visuals.checkTimer({ intervalSum: elapsedTime }) } } catch (exc) { self.emit(Events.ERROR, exc) } } this.record = function () { if (unloaded) { return false } // reconnect when needed if (!connected) { debug('Recorder: reconnecting before recording ...') initSocket(function () { self.once(Events.USER_MEDIA_READY, self.record) }) return false } try { canvas = userMedia.createCanvas() } catch (exc) { self.emit(Events.ERROR, VideomailError.create(exc, options)) return false } ctx = canvas.getContext('2d') if (!canvas.width) { self.emit( Events.ERROR, VideomailError.create('Canvas has an invalid width.', options) ) return false } if (!canvas.height) { self.emit( Events.ERROR, VideomailError.create('Canvas has an invalid height.', options) ) return false } bytesSum = 0 frame = new Frame(canvas, options) debug('Recorder: record()') userMedia.record() self.emit(Events.RECORDING, framesCount) // see https://github.com/hapticdata/animitter/issues/3 loop.on('update', function (deltaTime, elapsedTime) { // x1000 because of milliseconds const avgFPS = (framesCount / elapsedTime) * 1000 debug('Recorder: avgFps =', Math.round(avgFPS)) }) loop.start() } function setAnimationFrameObject(newObj) { // must stop and then start to make it become effective, see // https://github.com/hapticdata/animitter/issues/5#issuecomment-292019168 if (loop) { const isRecording = self.isRecording() loop.stop() loop.setRequestAnimationFrameObject(newObj) if (isRecording) { loop.start() } } } function restoreAnimationFrameObject() { debug('Recorder: restoreAnimationFrameObject()') setAnimationFrameObject(originalAnimationFrameObject) } function loopWithTimeouts() { debug('Recorder: loopWithTimeouts()') const wantedInterval = 1e3 / options.video.fps let processingTime = 0 let start function raf(fn) { return setTimeout( function () { start = Date.now() fn() processingTime = Date.now() - start }, // reducing wanted interval by respecting the time it takes to // compute internally since this is not multi-threaded like // requestAnimationFrame wantedInterval - processingTime ) } function cancel(id) { clearTimeout(id) } setAnimationFrameObject({ requestAnimationFrame: raf, cancelAnimationFrame: cancel }) } function buildElement() { recorderElement = h('video.' + options.selectors.userMediaClass) visuals.appendChild(recorderElement) } function correctDimensions() { if (options.hasDefinedWidth()) { recorderElement.width = self.getRecorderWidth(true) } if (options.hasDefinedHeight()) { recorderElement.height = self.getRecorderHeight(true) } } function switchFacingMode() { if (!browser.isMobile()) { return false } if (facingMode === 'user') { facingMode = 'environment' } else if (facingMode === 'environment') { facingMode = 'user' } else { debug('Recorder: unsupported facing mode', facingMode) } loadGenuineUserMedia({ switchingFacingMode: true }) } function initEvents() { debug('Recorder: initEvents()') self .on(Events.SUBMITTING, function () { submitting = true }) .on(Events.SUBMITTED, function () { submitting = false self.unload() }) .on(Events.BLOCKING, function () { blocking = true clearUserMediaTimeout() }) .on(Events.HIDE, function () { self.hide() }) .on(Events.LOADED_META_DATA, function () { correctDimensions() }) .on(Events.DISABLING_AUDIO, function () { reInitialiseAudio() }) .on(Events.ENABLING_AUDIO, function () { reInitialiseAudio() }) .on(Events.INVISIBLE, function () { loopWithTimeouts() }) .on(Events.VISIBLE, function () { restoreAnimationFrameObject() }) .on(Events.SWITCH_FACING_MODE, function () { switchFacingMode() }) } this.build = function () { let err = browser.checkRecordingCapabilities() if (!err) { err = browser.checkBufferTypes() } if (err) { this.emit(Events.ERROR, err) } else { recorderElement = visuals.querySelector('video.' + options.selectors.userMediaClass) if (!recorderElement) { buildElement() } correctDimensions() // prevent audio feedback, see // https://github.com/binarykitchen/videomail-client/issues/35 recorderElement.muted = true // for iphones, see https://github.com/webrtc/samples/issues/929 recorderElement.setAttribute('playsinline', true) recorderElement.setAttribute('webkit-playsinline', 'webkit-playsinline') // add these here, not in CSS because users can configure custom // class names recorderElement.style.transform = 'rotateY(180deg)' recorderElement.style['-webkit-transform'] = 'rotateY(180deg)' recorderElement.style['-moz-transform'] = 'rotateY(180deg)' if (!userMedia) { userMedia = new UserMedia(this, options) } show() if (!built) { initEvents() if (!connected) { initSocket() } else { loadUserMedia() } } else { loadUserMedia() } built = true } } this.isPaused = function () { return userMedia && userMedia.isPaused() && !loop.isRunning() } this.isRecording = function () { // checking for stream.destroyed needed since // https://github.com/binarykitchen/videomail.io/issues/296 return ( loop && loop.isRunning() && !this.isPaused() && !isNotifying() && stream && !stream.destroyed ) } this.hide = function () { if (!isHidden()) { recorderElement && hidden(recorderElement, true) clearUserMediaTimeout() clearRetryTimeout() } } this.isUnloaded = function () { return unloaded } // these two return the true dimensions of the webcam area. // needed because on mobiles they might be different. this.getRecorderWidth = function (responsive) { if (userMedia && userMedia.hasVideoWidth()) { return userMedia.getRawWidth(responsive) } else if (responsive && options.hasDefinedWidth()) { return this.limitWidth(options.video.width) } } this.getRecorderHeight = function (responsive) { if (userMedia) { return userMedia.getRawHeight(responsive) } else if (responsive && options.hasDefinedHeight()) { return this.calculateHeight(responsive) } } function getRatio() { let ratio if (userMedia) { const userMediaVideoWidth = userMedia.getVideoWidth() // avoid division by zero if (userMediaVideoWidth < 1) { // use as a last resort fallback computation (needed for safari 11) ratio = visuals.getRatio() } else { ratio = userMedia.getVideoHeight() / userMediaVideoWidth } } else { ratio = options.getRatio() } return ratio } this.calculateWidth = function (responsive) { let videoHeight if (userMedia) { videoHeight = userMedia.getVideoHeight() } else if (recorderElement) { videoHeight = recorderElement.videoHeight || recorderElement.height } return visuals.calculateWidth({ responsive: responsive, ratio: getRatio(), videoHeight: videoHeight }) } this.calculateHeight = function (responsive) { let videoWidth if (userMedia) { videoWidth = userMedia.getVideoWidth() } else if (recorderElement) { videoWidth = recorderElement.videoWidth || recorderElement.width } return visuals.calculateHeight({ responsive: responsive, ratio: getRatio(), videoWidth: videoWidth }) } this.getRawVisualUserMedia = function () { return recorderElement } this.isConnected = function () { return connected } this.isConnecting = function () { return connecting } this.limitWidth = function (width) { return visuals.limitWidth(width) } this.limitHeight = function (height) { return visuals.limitHeight(height) } this.isUserMediaLoaded = function () { return userMediaLoaded } } util.inherits(Recorder, EventEmitter) export default Recorder