UNPKG

videomail-client

Version:

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

772 lines (628 loc) 20.2 kB
import Visibility from 'document-visibility' // needed for IE 11 import elementClosest from 'element-closest' import hidden from 'hidden' import insertCss from 'insert-css' import util from 'util' import css from '../../styles/css/main.min.css.js' import Events from '../events' import Resource from '../resource' import EventEmitter from '../util/eventEmitter' import VideomailError from '../util/videomailError' import Buttons from './buttons' import Dimension from './dimension' import Form from './form' import OptionsWrapper from './optionsWrapper' import Visuals from './visuals' elementClosest(window) const Container = function (options) { EventEmitter.call(this, options, 'Container') const self = this const visibility = Visibility() const visuals = new Visuals(this, options) const buttons = new Buttons(this, options) const resource = new Resource(options) const htmlElement = document && document.querySelector && document.querySelector('html') const debug = options.debug let hasError = false let submitted = false let lastValidation = false let containerElement let built let form function prependDefaultCss() { insertCss(css, { prepend: true }) } // since https://github.com/binarykitchen/videomail-client/issues/87 function findParentFormElement() { return containerElement.closest('form') } function getFormElement() { let formElement if (containerElement.tagName === 'FORM') { formElement = containerElement } else if (options.selectors.formId) { formElement = document.getElementById(options.selectors.formId) } else { formElement = findParentFormElement() } return formElement } function buildForm() { const formElement = getFormElement() if (formElement) { debug('Container: buildForm()') form = new Form(self, formElement, options) const submitButton = form.findSubmitButton() submitButton && buttons.setSubmitButton(submitButton) form.build() } } function buildChildren() { debug('Container: buildChildren()') if (!containerElement.classList) { self.emit( Events.ERROR, VideomailError.create('Sorry, your browser is too old!', options) ) } else { containerElement.classList.add('videomail') if (!options.playerOnly) { buttons.build() } visuals.build() } } function processError(err) { hasError = true if (err.stack) { options.logger.error(err.stack) } else { options.logger.error(err) } if (options.displayErrors) { visuals.error(err) } else { visuals.reset() } } function initEvents() { debug('Container: initEvents()') window.addEventListener('beforeunload', (e) => { self.unload(e) }) if (!options.playerOnly) { visibility.onChange(function (visible) { // built? see https://github.com/binarykitchen/videomail.io/issues/326 if (built) { if (visible) { if (options.isAutoPauseEnabled() && self.isCountingDown()) { self.resume() } self.emit(Events.VISIBLE) } else { if ( options.isAutoPauseEnabled() && (self.isCountingDown() || self.isRecording()) ) { self.pause('document invisible') } self.emit(Events.INVISIBLE) } } }) } if (options.enableSpace) { if (!options.playerOnly) { window.addEventListener('keypress', function (e) { const tagName = e.target.tagName const isEditable = e.target.isContentEditable || e.target.contentEditable === 'true' || e.target.contentEditable === true // beware of rich text editors, hence the isEditable check (wordpress plugin issue) if (!isEditable && tagName !== 'INPUT' && tagName !== 'TEXTAREA') { const code = e.keyCode ? e.keyCode : e.which if (code === 32) { e.preventDefault() if (options.enablePause) { visuals.pauseOrResume() } else { visuals.recordOrStop() } } } }) } } // better to keep the one and only error listeners // at one spot, here, because unload() will do a removeAllListeners() self.on(Events.ERROR, function (err) { processError(err) unloadChildren(err) if (err.removeDimensions && err.removeDimensions()) { removeDimensions() } }) if (!options.playerOnly) { self.on(Events.LOADED_META_DATA, function () { correctDimensions() }) } } function validateOptions() { if (options.hasDefinedWidth() && options.video.width % 2 !== 0) { throw VideomailError.create('Width must be divisible by two.', options) } if (options.hasDefinedHeight() && options.video.height % 2 !== 0) { throw VideomailError.create('Height must be divisible by two.', options) } } // this will just set the width but not the height because // it can be a form with more inputs elements function correctDimensions() { const width = visuals.getRecorderWidth(true) if (width < 1) { throw VideomailError.create('Recorder width cannot be less than 1!', options) } else { containerElement.style.width = width + 'px' } } function removeDimensions() { containerElement.style.width = 'auto' } function unloadChildren(e) { visuals.unload(e) buttons.unload() self.endWaiting() } function hideMySelf() { hidden(containerElement, true) } function submitVideomail(formData, method, cb) { const videomailFormData = form.transformFormData(formData) // when method is undefined, treat it as a post if (isPost(method) || !method) { videomailFormData.recordingStats = visuals.getRecordingStats() videomailFormData.width = visuals.getRecorderWidth(true) videomailFormData.height = visuals.getRecorderHeight(true) if (navigator.connection) { videomailFormData.connection = { downlink: navigator.connection.downlink + ' Mbit/s', effectiveType: navigator.connection.effectiveType, rtt: navigator.connection.rtt, type: navigator.connection.type } } resource.post(videomailFormData, cb) } else if (isPut(method)) { resource.put(videomailFormData, cb) } } function submitForm(formData, videomailResponse, url, cb) { // for now, accept POSTs only which have an URL unlike null and // treat all other submissions as direct submissions if (!url || url === '') { // figure out URL automatically then url = document.baseURI } // can be missing when no videomail was recorded and is not required if (videomailResponse) { formData[options.selectors.aliasInputName] = videomailResponse.videomail.alias // this in case if user wants all videomail metadata to be posted // altogether with the remaining form if (options.submitWithVideomail) { formData.videomail = videomailResponse.videomail } } resource.form(formData, url, cb) } function finalizeSubmissions(err, method, videomail, response, formResponse) { self.endWaiting() if (err) { self.emit(Events.ERROR, err) } else { submitted = true // merge two json response bodies to fake as if it were only one request if (response && formResponse && formResponse.body) { Object.keys(formResponse.body).forEach(function (key) { response[key] = formResponse.body[key] }) } self.emit(Events.SUBMITTED, videomail, response || formResponse) if (formResponse && formResponse.type === 'text/html' && formResponse.text) { // server replied with HTML contents - display these document.body.innerHTML = formResponse.text // todo: figure out how to fire dom's onload event again // todo: or how to run all the scripts over again } } } this.addPlayerDimensions = function (videomail, element) { try { videomail.playerHeight = this.calculateHeight( { responsive: true, videoWidth: videomail.width, ratio: videomail.height / videomail.width }, element ) videomail.playerWidth = this.calculateWidth({ responsive: true, videoHeight: videomail.playerHeight, ratio: videomail.height / videomail.width }) return videomail } catch (exc) { self.emit(Events.ERROR, exc) } } this.limitWidth = function (width) { return Dimension.limitWidth(containerElement, width, options) } this.limitHeight = function (height) { return Dimension.limitHeight(height, options) } this.calculateWidth = function (fnOptions) { return Dimension.calculateWidth(OptionsWrapper.merge(options, fnOptions, true)) } this.calculateHeight = function (fnOptions, element) { if (!element) { if (containerElement) { element = containerElement } else { // better than nothing element = document.body } } return Dimension.calculateHeight( element, OptionsWrapper.merge(options, fnOptions, true) ) } this.areVisualsHidden = function () { return visuals.isHidden() } this.hasElement = function () { return !!containerElement } this.build = function () { try { containerElement = document.getElementById(options.selectors.containerId) // only build when a container element hast been found, otherwise // be silent and do nothing if (containerElement) { options.insertCss && prependDefaultCss() !built && initEvents() validateOptions() correctDimensions() if (!options.playerOnly) { buildForm() } buildChildren() if (!hasError) { debug('Container: built.') built = true self.emit(Events.BUILT) } else { debug('Container: building failed due to an error.') } } else { // commented out since it does too much noise on videomail's view page which is fine // debug('Container: no container element with ID ' + options.selectors.containerId + ' found. Do nothing.') } } catch (exc) { if (visuals.isNotifierBuilt()) { self.emit(Events.ERROR, exc) } else { throw exc } } } this.getSubmitButton = function () { return buttons.getSubmitButton() } this.querySelector = function (selector) { return containerElement.querySelector(selector) } this.beginWaiting = function () { htmlElement.classList && htmlElement.classList.add('wait') } this.endWaiting = function () { htmlElement.classList && htmlElement.classList.remove('wait') } this.appendChild = function (child) { containerElement.appendChild(child) } this.insertBefore = function (child, reference) { containerElement.insertBefore(child, reference) } this.unload = function (e) { debug('Container: unload()', e) try { unloadChildren(e) this.removeAllListeners() built = submitted = false } catch (exc) { self.emit(Events.ERROR, exc) } } this.show = function () { if (containerElement) { hidden(containerElement, false) visuals.show() if (!hasError) { const paused = self.isPaused() if (paused) { buttons.adjustButtonsForPause() } // since https://github.com/binarykitchen/videomail-client/issues/60 // we hide areas to make it easier for the user buttons.show() if (self.isReplayShown()) { self.emit(Events.PREVIEW) } else { self.emit(Events.FORM_READY, { paused: paused }) } } } } this.hide = function () { debug('Container: hide()') hasError = false this.isRecording() && this.pause() visuals.hide() if (submitted) { buttons.hide() hideMySelf() } } this.startOver = function (params) { try { self.emit(Events.STARTING_OVER) submitted = false form.show() visuals.back(params, function () { if (params && params.keepHidden) { // just enable form, do nothing else. // see example contact_form.html when you submit without videomail // and go back self.enableForm() } else { self.show(params) } }) } catch (exc) { self.emit(Events.ERROR, exc) } } this.showReplayOnly = function () { hasError = false this.isRecording() && this.pause() visuals.showReplayOnly() submitted && buttons.hide() } this.isNotifying = function () { return visuals.isNotifying() } this.isPaused = function () { return visuals.isPaused() } this.pause = function (params) { visuals.pause(params) } // this code needs a good rewrite :( this.validate = function (force) { let runValidation = true let valid if (!options.enableAutoValidation) { runValidation = false lastValidation = true // needed so that it can be submitted anyway, see submit() } else if (force) { runValidation = force } else if (self.isNotifying()) { runValidation = false } else if (visuals.isConnected()) { runValidation = visuals.isUserMediaLoaded() || visuals.isReplayShown() } else if (visuals.isConnecting()) { runValidation = false } if (runValidation) { this.emit(Events.VALIDATING) const visualsValid = visuals.validate() && buttons.isRecordAgainButtonEnabled() let whyInvalid if (form) { valid = form.validate() if (valid) { if (!this.areVisualsHidden() && !visualsValid) { if ( submitted || this.isReady() || this.isRecording() || this.isPaused() || this.isCountingDown() ) { valid = false } if (!valid) { whyInvalid = 'Video is not recorded' } } } else { const invalidInput = form.getInvalidElement() if (invalidInput) { whyInvalid = 'Form input named ' + invalidInput.name + ' is invalid. It has the value: "' + invalidInput.value + '"' } else { whyInvalid = 'Form input(s) are invalid' } } if (valid) { // If CC and/or BCC exist, validate one more time to ensure at least // one recipient is given const recipients = form.getRecipients() const toIsConfigured = 'to' in recipients const ccIsConfigured = 'cc' in recipients const bccIsConfigured = 'bcc' in recipients const hasTo = recipients.to?.length > 0 const hasCc = recipients.cc?.length > 0 const hasBcc = recipients.bcc?.length > 0 if (toIsConfigured) { if (!hasTo) { if (ccIsConfigured && bccIsConfigured) { if (!hasCc && !hasBcc) { valid = false } } else if (ccIsConfigured) { if (!hasCc) { valid = false } } else if (bccIsConfigured) { if (!hasBcc) { valid = false } } else { whyInvalid = 'Please configure the form to have at least one recipient.' } } } else if (ccIsConfigured) { if (!hasCc) { if (bccIsConfigured) { if (!hasBcc) { valid = false } } } } if (!valid) { whyInvalid = 'Please enter at least one recipient.' } } } else { valid = visualsValid } if (valid) { this.emit(Events.VALID) } else { this.emit(Events.INVALID, whyInvalid) } lastValidation = valid } return valid } this.disableForm = function (buttonsToo) { form && form.disable(buttonsToo) } this.enableForm = function (buttonsToo) { form && form.enable(buttonsToo) } this.hasForm = function () { return !!form } this.isReady = function () { return buttons.isRecordButtonEnabled() } function isPost(method) { return method && method.toUpperCase() === 'POST' } function isPut(method) { return method && method.toUpperCase() === 'PUT' } this.submitAll = function (formData, method, url) { const post = isPost(method) const hasVideomailKey = !!formData[options.selectors.keyInputName] function startSubmission() { self.beginWaiting() self.disableForm(true) self.emit(Events.SUBMITTING) } // a closure so that we can access method const submitVideomailCallback = function (err1, videomail, videomailResponse) { if (err1) { finalizeSubmissions(err1, method, videomail, videomailResponse) } else if (post) { submitForm(formData, videomailResponse, url, function (err2, formResponse) { finalizeSubmissions(err2, method, videomail, videomailResponse, formResponse) }) } else { // it's a direct submission finalizeSubmissions(null, method, videomail, videomailResponse) } } // !hasVideomailKey makes it possible to submit form when videomail itself // is not optional. if (!hasVideomailKey) { if (options.enableAutoSubmission) { startSubmission() submitForm(formData, null, url, function (err2, formResponse) { finalizeSubmissions(err2, method, null, null, formResponse) }) } // ... and when the enableAutoSubmission option is false, // then that can mean, leave it to the framework to process with the form // validation/handling/submission itself. for example the ninja form // will want to highlight which one input are wrong. } else { startSubmission() submitVideomail(formData, method, submitVideomailCallback) } } this.isBuilt = function () { return built } this.isReplayShown = function () { return visuals.isReplayShown() } this.isDirty = function () { let isDirty = false if (form) { if (visuals.isRecorderUnloaded()) { isDirty = false } else if (this.isReplayShown() || this.isPaused()) { isDirty = true } } return isDirty } this.getReplay = function () { return visuals.getReplay() } this.isOutsideElementOf = function (element) { return element.parentNode !== containerElement && element !== containerElement } this.hideForm = function (params) { // form check needed, see https://github.com/binarykitchen/videomail-client/issues/127 form && form.hide() buttons && buttons.hide(params) } this.loadForm = function (videomail) { if (form) { form.loadVideomail(videomail) this.validate() } } this.enableAudio = function () { options.setAudioEnabled(true) this.emit(Events.ENABLING_AUDIO) } this.disableAudio = function () { options.setAudioEnabled(false) this.emit(Events.DISABLING_AUDIO) } this.submit = function () { lastValidation && form && form.doTheSubmit() } this.isCountingDown = visuals.isCountingDown.bind(visuals) this.isRecording = visuals.isRecording.bind(visuals) this.record = visuals.record.bind(visuals) this.resume = visuals.resume.bind(visuals) this.stop = visuals.stop.bind(visuals) this.recordAgain = visuals.recordAgain.bind(visuals) } util.inherits(Container, EventEmitter) export default Container