UNPKG

videomail-client

Version:

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

534 lines (434 loc) 15.2 kB
import AudioRecorder from './../../util/audioRecorder' import Browser from './../../util/browser' import EventEmitter from './../../util/eventEmitter' import Events from './../../events' import MEDIA_EVENTS from './../../util/mediaEvents' import VideomailError from './../../util/videomailError' import h from 'hyperscript' import pretty from './../../util/pretty' import stringify from 'safe-json-stringify' const EVENT_ASCII = '|—O—|' export default function (recorder, options) { EventEmitter.call(this, options, 'UserMedia') const rawVisualUserMedia = recorder && recorder.getRawVisualUserMedia() const browser = new Browser(options) const self = this let paused = false let record = false let audioRecorder let currentVisualStream function attachMediaStream(stream) { currentVisualStream = stream if (typeof rawVisualUserMedia.srcObject !== 'undefined') { rawVisualUserMedia.srcObject = stream } else if (typeof rawVisualUserMedia.src !== 'undefined') { const URL = window.URL || window.webkitURL rawVisualUserMedia.src = URL.createObjectURL(stream) || stream } else { throw VideomailError.create( 'Error attaching stream to element.', 'Contact the developer about this', options ) } } function setVisualStream(localMediaStream) { if (localMediaStream) { attachMediaStream(localMediaStream) } else { rawVisualUserMedia.removeAttribute('srcObject') rawVisualUserMedia.removeAttribute('src') currentVisualStream = null } } function getVisualStream() { if (rawVisualUserMedia.mozSrcObject) { return rawVisualUserMedia.mozSrcObject } else if (rawVisualUserMedia.srcObject) { return rawVisualUserMedia.srcObject } return currentVisualStream } function hasEnded() { if (rawVisualUserMedia.ended) { return rawVisualUserMedia.ended } const visualStream = getVisualStream() return visualStream && visualStream.ended } function hasInvalidDimensions() { if ( (rawVisualUserMedia.videoWidth && rawVisualUserMedia.videoWidth < 3) || (rawVisualUserMedia.height && rawVisualUserMedia.height < 3) ) { return true } } function getTracks(localMediaStream) { let tracks if (localMediaStream && localMediaStream.getTracks) { tracks = localMediaStream.getTracks() } return tracks } function getVideoTracks(localMediaStream) { let videoTracks if (localMediaStream && localMediaStream.getVideoTracks) { videoTracks = localMediaStream.getVideoTracks() } return videoTracks } function getFirstVideoTrack(localMediaStream) { const videoTracks = getVideoTracks(localMediaStream) let videoTrack if (videoTracks && videoTracks[0]) { videoTrack = videoTracks[0] } return videoTrack } function logEvent(event, params) { options.debug('UserMedia: ...', EVENT_ASCII, 'event', event, stringify(params)) } function isPromise(anything) { return anything && typeof Promise !== 'undefined' && anything instanceof Promise } function outputEvent(e) { logEvent(e.type, { readyState: rawVisualUserMedia.readyState }) // remove myself rawVisualUserMedia.removeEventListener && rawVisualUserMedia.removeEventListener(e.type, outputEvent) } this.unloadRemainingEventListeners = function () { options.debug('UserMedia: unloadRemainingEventListeners()') MEDIA_EVENTS.forEach(function (eventName) { rawVisualUserMedia.removeEventListener(eventName, outputEvent) }) } this.init = function ( localMediaStream, videoCallback, audioCallback, endedEarlyCallback, params = {} ) { this.stop(localMediaStream, { aboutToInitialize: true, switchingFacingMode: params.switchingFacingMode }) let onPlayReached = false let onLoadedMetaDataReached = false let playingPromiseReached = false if (options && options.isAudioEnabled()) { audioRecorder = audioRecorder || new AudioRecorder(this, options) } function audioRecord() { self.removeListener(Events.SENDING_FIRST_FRAME, audioRecord) audioRecorder && audioRecorder.record(audioCallback) } function unloadAllEventListeners() { options.debug('UserMedia: unloadAllEventListeners()') self.removeListener(Events.SENDING_FIRST_FRAME, audioRecord) rawVisualUserMedia.removeEventListener && rawVisualUserMedia.removeEventListener('play', onPlay) rawVisualUserMedia.removeEventListener && rawVisualUserMedia.removeEventListener('loadedmetadata', onLoadedMetaData) self.unloadRemainingEventListeners() } function play() { // Resets the media element and restarts the media resource. Any pending events are discarded. try { rawVisualUserMedia.load() // fixes https://github.com/binarykitchen/videomail.io/issues/401 // see https://github.com/MicrosoftEdge/Demos/blob/master/photocapture/scripts/demo.js#L27 if (rawVisualUserMedia.paused) { options.debug( 'UserMedia: play()', 'media.readyState=' + rawVisualUserMedia.readyState, 'media.paused=' + rawVisualUserMedia.paused, 'media.ended=' + rawVisualUserMedia.ended, 'media.played=' + pretty(rawVisualUserMedia.played) ) let p try { p = rawVisualUserMedia.play() } catch (exc) { // this in the hope to catch InvalidStateError, see // https://github.com/binarykitchen/videomail-client/issues/149 options.logger.warn('Caught raw usermedia play exception:', exc) } // using the promise here just experimental for now // and this to catch any weird errors early if possible if (isPromise(p)) { p.then(function () { if (!playingPromiseReached) { options.debug('UserMedia: play promise successful. Playing now.') playingPromiseReached = true } }).catch(function (reason) { // promise can be interrupted, i.E. when switching tabs // and promise can get resumed when switching back to tab, hence // do not treat this like an error options.logger.warn( 'Caught pending usermedia promise exception: %s', reason.toString() ) }) } } } catch (exc) { unloadAllEventListeners() endedEarlyCallback(exc) } } function fireCallbacks() { const readyState = rawVisualUserMedia.readyState // ready state, see https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/readyState options.debug( 'UserMedia: fireCallbacks(' + 'readyState=' + readyState + ', ' + 'onPlayReached=' + onPlayReached + ', ' + 'onLoadedMetaDataReached=' + onLoadedMetaDataReached + ')' ) if (onPlayReached && onLoadedMetaDataReached) { videoCallback() if (audioRecorder && audioCallback) { try { audioRecorder.init(localMediaStream) self.on(Events.SENDING_FIRST_FRAME, audioRecord) } catch (exc) { unloadAllEventListeners() endedEarlyCallback(exc) } } } } function onPlay() { try { logEvent('play', { readyState: rawVisualUserMedia.readyState, audio: options.isAudioEnabled(), width: rawVisualUserMedia.width, height: rawVisualUserMedia.height, videoWidth: rawVisualUserMedia.videoWidth, videoHeight: rawVisualUserMedia.videoHeight }) rawVisualUserMedia.removeEventListener && rawVisualUserMedia.removeEventListener('play', onPlay) if (hasEnded() || hasInvalidDimensions()) { endedEarlyCallback( VideomailError.create( 'Already busy', 'Probably another browser window is using your webcam?', options ) ) } else { onPlayReached = true fireCallbacks() } } catch (exc) { unloadAllEventListeners() endedEarlyCallback(exc) } } // player modifications to perform that must wait until `loadedmetadata` has been triggered function onLoadedMetaData() { logEvent('loadedmetadata', { readyState: rawVisualUserMedia.readyState, paused: rawVisualUserMedia.paused, width: rawVisualUserMedia.width, height: rawVisualUserMedia.height, videoWidth: rawVisualUserMedia.videoWidth, videoHeight: rawVisualUserMedia.videoHeight }) rawVisualUserMedia.removeEventListener && rawVisualUserMedia.removeEventListener('loadedmetadata', onLoadedMetaData) if (!hasEnded() && !hasInvalidDimensions()) { self.emit(Events.LOADED_META_DATA) // for android devices, we cannot call play() unless meta data has been loaded! // todo consider removing that if it's not the case anymore (for better performance) if (browser.isAndroid()) { play() } onLoadedMetaDataReached = true fireCallbacks() } } try { const videoTrack = getFirstVideoTrack(localMediaStream) if (!videoTrack) { options.debug('UserMedia: detected (but no video tracks exist') } else if (!videoTrack.enabled) { throw VideomailError.create( 'Webcam is disabled', 'The video track seems to be disabled. Enable it in your system.', options ) } else { let description if (videoTrack.label && videoTrack.label.length > 0) { description = videoTrack.label } description += ' with enabled=' + videoTrack.enabled description += ', muted=' + videoTrack.muted description += ', remote=' + videoTrack.remote description += ', readyState=' + videoTrack.readyState description += ', error=' + videoTrack.error options.debug('UserMedia: ' + videoTrack.kind + ' detected.', description || '') } // very useful i think, so leave this and just use options.debug() const heavyDebugging = true if (heavyDebugging) { MEDIA_EVENTS.forEach(function (eventName) { rawVisualUserMedia.addEventListener(eventName, outputEvent, false) }) } rawVisualUserMedia.addEventListener('loadedmetadata', onLoadedMetaData) rawVisualUserMedia.addEventListener('play', onPlay) // experimental, not sure if this is ever needed/called? since 2 apr 2017 // An error occurs while fetching the media data. // Error can be an object with the code MEDIA_ERR_NETWORK or higher. // networkState equals either NETWORK_EMPTY or NETWORK_IDLE, depending on when the download was aborted. rawVisualUserMedia.addEventListener('error', function (err) { options.logger.warn('Caught video element error event: %s', pretty(err)) }) setVisualStream(localMediaStream) play() } catch (exc) { self.emit(Events.ERROR, exc) } } this.isReady = function () { return !!rawVisualUserMedia.src } this.stop = function (visualStream, params = {}) { try { // do not stop "too much" when going to initialize anyway const aboutToInitialize = params.aboutToInitialize const switchingFacingMode = params.switchingFacingMode if (!aboutToInitialize) { if (!visualStream) { visualStream = getVisualStream() } const tracks = getTracks(visualStream) let newStopApiFound = false if (tracks) { tracks.forEach(function (track) { if (track.stop) { newStopApiFound = true track.stop() } }) } // will probably become obsolete in one year (after june 2017) !newStopApiFound && visualStream && visualStream.stop && visualStream.stop() setVisualStream(null) audioRecorder && audioRecorder.stop() audioRecorder = null } // don't have to reset these states when just switching camera // while still recording or pausing if (!switchingFacingMode) { paused = record = false } } catch (exc) { self.emit(Events.ERROR, exc) } } this.createCanvas = function () { return h('canvas', { width: this.getRawWidth(true), height: this.getRawHeight(true) }) } this.getVideoHeight = function () { return rawVisualUserMedia.videoHeight } this.getVideoWidth = function () { return rawVisualUserMedia.videoWidth } this.hasVideoWidth = function () { return this.getVideoWidth() > 0 } this.getRawWidth = function (responsive) { let rawWidth = this.getVideoWidth() const widthDefined = options.hasDefinedWidth() if (widthDefined || options.hasDefinedHeight()) { if (!responsive && widthDefined) { rawWidth = options.video.width } else { rawWidth = recorder.calculateWidth(responsive) } } if (responsive) { rawWidth = recorder.limitWidth(rawWidth) } return rawWidth } this.getRawHeight = function (responsive) { let rawHeight if (options.hasDefinedDimension()) { rawHeight = recorder.calculateHeight(responsive) if (rawHeight < 1) { throw VideomailError.create( 'Bad dimensions', 'Calculated raw height cannot be less than 1!', options ) } } else { rawHeight = this.getVideoHeight() if (rawHeight < 1) { throw VideomailError.create( 'Bad dimensions', 'Raw video height from DOM element cannot be less than 1!', options ) } } if (responsive) { rawHeight = recorder.limitHeight(rawHeight) } return rawHeight } this.getRawVisuals = function () { return rawVisualUserMedia } this.pause = function () { paused = true } this.isPaused = function () { return paused } this.resume = function () { paused = false } this.record = function () { record = true } this.isRecording = function () { return record } this.getAudioSampleRate = function () { if (audioRecorder) { return audioRecorder.getSampleRate() } return -1 } this.getCharacteristics = function () { return { audioSampleRate: this.getAudioSampleRate(), muted: rawVisualUserMedia && rawVisualUserMedia.muted, width: rawVisualUserMedia && rawVisualUserMedia.width, height: rawVisualUserMedia && rawVisualUserMedia.height, videoWidth: rawVisualUserMedia && rawVisualUserMedia.videoWidth, videoHeight: rawVisualUserMedia && rawVisualUserMedia.videoHeight } } }