Show:

File: app/services/video-recorder.js

import Ember from 'ember';

/* OMIT FROM YUIDOC *
 * @module exp-player
 * @submodule services
 */

let {
    $,
    RSVP
} = Ember;

var LOOKIT_PREFERRED_DEVICES = {
    'cam': null,
    'mic': null
};

// Deal with Firefox issue where, after selecting camera/mic to share and saying to
// 'remember' settings, the default cam/mic are used each time getUserMedia is called.
// This does NOT fix persisting selections across multiple Lookit sessions, but will
// persist it through the session (one page load). (To forcibly fix selection, can
// revoke & refresh page).
// Override getUserMedia function to insert our preference on camera and mic, and to set
// that preference the first time getUserMedia is called successfully. We do this rather than
// editing https://cdn.addpipe.com/2.0/pipe.js and hosting our own copy so we don't have to
// maintain across changes to Pipe.
// Only override newer navigator.mediaDevices.getUserMedia rather than also
// navigator.getUserMedia, as the latter will only be used by Pipe if the newer one is not
// available, in which case probably bigger problems than this one.
navigator.mediaDevices.getUserMedia = (function(origGetUserMedia) {
    return function() {
        // Add preferred mic and camera, if already stored, to any other constraints being
        // passed to getUserMedia
        var constraints = arguments[0];
        if (constraints.hasOwnProperty('audio') && LOOKIT_PREFERRED_DEVICES.mic) {
            constraints.audio.deviceId = LOOKIT_PREFERRED_DEVICES.mic;
        }
        if (constraints.hasOwnProperty('video') && LOOKIT_PREFERRED_DEVICES.cam) {
            constraints.video.deviceId = LOOKIT_PREFERRED_DEVICES.cam;
        }
        return origGetUserMedia.apply(this, arguments).then(function(stream) {
            // Set the preferred cam/mic IDs the first time we get a stream
            try {
                var audioTracks = stream.getAudioTracks();
                var videoTracks = stream.getVideoTracks();
                if (!LOOKIT_PREFERRED_DEVICES.mic && audioTracks) {
                    var thisAudioLabel = audioTracks[0].label;
                    navigator.mediaDevices.enumerateDevices()
                        .then(function(devices) {
                            devices.forEach(function(device) {
                                if (device.kind == 'audioinput' && device.label == thisAudioLabel) {
                                    LOOKIT_PREFERRED_DEVICES.mic = device.deviceId;
                                }
                            });
                        });
                }
                if (!LOOKIT_PREFERRED_DEVICES.cam && videoTracks) {
                    var thisVideoLabel = videoTracks[0].label;
                    navigator.mediaDevices.enumerateDevices()
                        .then(function(devices) {
                            devices.forEach(function(device) {
                                if (device.kind == 'videoinput' && device.label == thisVideoLabel) {
                                    LOOKIT_PREFERRED_DEVICES.cam = device.deviceId;
                                }
                            });
                        });
                }
            } catch (error) {
                console.error('Error setting preferred mic/camera: ' + error);
            }
            return stream;
        });
    };
})(navigator.mediaDevices.getUserMedia);

/**
 * An instance of a video recorder tied to or used by one specific page. A given experiment may use more than one
 *   video recorder depending on the number of video capture frames.
 * @class video-recorder
 */
const VideoRecorder = Ember.Object.extend({

    element: null,

    divId: 'lookit-video-recorder',
    recorderId: (new Date().getTime() + ''),
    pipeVideoName: '',

    started: Ember.computed.alias('_started').readOnly(),
    hasCamAccess: false,
    nWebcams: Ember.computed.alias('_nWebcams').readOnly(), // number of webcams available for recording
    nMics: Ember.computed.alias('_nMics').readOnly(), // number of microphones available for recording
    recording: Ember.computed.alias('_recording').readOnly(),
    hasCreatedRecording: Ember.computed.alias('_hasCreatedRecording').readOnly(),
    connected: false,
    uploadTimeout: null, // timer counting from attempt to stop until we should just
    //resolve the stopPromise
    maxUploadTimeMs: 5000,

    _started: false,
    _camAccess: false,
    _recording: false,
    _recorderReady: false,
    _hasCreatedRecording: false,
    _nWebcams: 0,
    _nMics: 0,

    _recordPromise: null,
    _stopPromise: null,
    _isuploaded: false,

    recorder: null, // The actual recorder object, also stored in PipeSDK.recorders obj

    // List of webcam hooks that should be added to recorder
    // See https://addpipe.com/docs#javascript-events-api
    hooks: ['onRecordingStarted',
        'onCamAccess',
        'onReadyToRecord',
        'onUploadDone',
        'userHasCamMic',
        'onConnectionStatus',
        'onMicActivityLevel',
        'btPlayPressed',
        'btRecordPressed',
        'btStopRecordingPressed',
        'btPausePressed',
        'onPlaybackComplete',
        'onConnectionClosed',
        'onSaveOk'
    ],

    minVolume: 1, // Volume required to pass mic check
    micChecked: false, // Has the microphone ever exceeded minVolume?

    /**
     * Install a recorder onto the page and optionally begin recording immediately.
     *
     * @method install
     * @param videoFilename desired filename for video (will be set after saving with Pipe name) ['']
     * @param pipeKey Pipe account hash ['']
     * @param pipeEnv which Pipe environment [1]
     * @param maxRecordingTime recording length limit in s [100000000]
     * @param autosave whether to autosave - 1 or 0 [1]
     * @param audioOnly whether to do audio only recording - 1 or 0 [0]
     * @return {Promise} Resolves when widget successfully installed and started
     */

    install(videoFilename = '', pipeKey = '', pipeEnv = 1, maxRecordingTime = 100000000, autosave = 1, audioOnly = 0) {

        let origDivId = this.get('divId');

        this.set('divId', `${this.get('divId')}-${this.get('recorderId')}`);

        var $element = $(this.get('element'));

        let divId = this.get('divId');

        var $container = $('<div>', {
            id: `${divId}-container`,
            css: {
                height: '100%'
            }
        });
        this.set('$container', $container);
        $container.append($('<div>', {id: divId, class: origDivId}));
        $element.append($container);

        return new RSVP.Promise((resolve, reject) => { // eslint-disable-line no-unused-vars

            var pipeConfig = {
                qualityurl: 'https://d3l7d0ho3mojk5.cloudfront.net/pipe/720p.xml',
                showMenu: 0, // hide recording button menu
                sis: 1, // skip initial screen
                asv: autosave, // autosave recordings
                st: 0, // don't show timer
                mv: 0, // don't mirror video for display
                dpv: 1, // disable pre-recorded video on mobile
                ao: audioOnly, // not audio-only
                dup: 0, // don't allow file uploads
                payload: videoFilename, // data used by webhook to rename video
                accountHash:  pipeKey,
                eid:  pipeEnv, // environment ID for pipe account
                mrt:  maxRecordingTime,
                size:  { // just display size when showing to user. We override css.
                    width: 320,
                    height: 240
                }
            };

            this.set('_started', true);
            var _this = this;
            PipeSDK.insert(divId, pipeConfig, function(myRecorderObject) {
                _this.set('recorder', PipeSDK.getRecorderById(divId));
                _this.get('hooks').forEach(hookName => {
                    // At the time the hook is actually called, look up the appropriate
                    // functions both from here and that might be added later.
                    myRecorderObject[hookName] = function(...args) {
                        if (_this.get('_' + hookName)) { // 'Native' hook defined here
                            _this['_' + hookName].apply(_this, args);
                        }
                        if (_this.hasOwnProperty(hookName)) { // Some hook added later via 'on'
                            _this[hookName].apply(_this, args);
                        }
                    };
                });
            });

            return resolve();

        });
    },

    /**
     * Start recording a video, and allow the state of the recording to be accessed for later usage
     *
     * @method record
     * @return {Promise}
     */
    record() {
        if (!this.get('started')) {
            throw new Error('Must call start before record');
        }
        let count = 0;
        var _this = this;
        this.set('_isuploaded', false);
        let id = window.setInterval(() => {
            if (++count > 50) { // stop trying - failure (5s)
                if (_this.get('onCamAccess')) {
                    _this.get('onCamAccess').call(_this, false);
                }
                return window.clearInterval(id), _this.get('_recordPromise').reject();
            }
            if (!_this.get('recorder') || !(_this.get('recorder').record)) {
                return null;
            }
            _this.get('recorder').record();
            window.clearInterval(id); // stop trying - success
            return null;
        }, 100); // try every 100ms

        return new Ember.RSVP.Promise((resolve, reject) => {
            if (_this.get('recording')) {
                resolve(this);
            } else {
                _this.set('_recordPromise', {
                    resolve,
                    reject
                });
            }
        });
    },

    /**
     * Get a timestamp based on the current recording position. Useful to ensure that tracked timing events
     *  line up with the video.
     * @method getTime
     * @return {Date|null}
     */
    getTime() {
        let recorder = this.get('recorder');
        if (recorder && recorder.getStreamTime) {
            return parseFloat(recorder.getStreamTime());
        }
        return null;
    },

    /**
     * Stop recording and save the video to the server
     * @method stop
     */
    stop(maxUploadTimeMs = 5000) {
        var recorder = this.get('recorder');
        if (recorder) {
            try {
                recorder.stopVideo();
            } catch (e) {
                console.log('error stopping video');
            }
        }
        this.set('_recording', false);

        var _this = this;
        var _stopPromise = new Ember.RSVP.Promise((resolve, reject) => {
            // If we don't end up uploading within 5 seconds, call reject
            _this.set('uploadTimeout', window.setTimeout(function() {
                console.warn('waiting for upload timed out');
                window.clearTimeout(_this.get('uploadTimeout'));
                reject();
            }, maxUploadTimeMs));
            if (_this.get('_isuploaded')) {
                window.clearTimeout(_this.get('uploadTimeout'));
                resolve(_this);
            } else {
                _this.set('_stopPromise', {
                    resolve: resolve,
                    reject: reject
                });
            }
        });
        return _stopPromise;
    },

    /**
     * Destroy video recorder
     *
     * @method destroy
     */
    destroy() {
        console.log(`Destroying the videoRecorder: ${this.get('divId')}`);
        $(`#${this.get('divId')}-container`).remove();
        if (this.get('recorder') && this.get('recorder').remove) {
            this.get('recorder').remove();
        }
        this.set('_recording', false);
    },

    on(hookName, func) {
        if (this.get('hooks').indexOf(hookName) === -1) {
            throw `Invalid event ${hookName}`;
        }
        this.set(hookName, func);
    },

    // Begin webcam hooks
    _onRecordingStarted(recorderId) { // eslint-disable-line no-unused-vars
        this.set('_recording', true);
        this.set('_hasCreatedRecording', true);
        this.set('pipeVideoName', this.get('recorder').getStreamName());
        if (this.get('_recordPromise')) {
            this.get('_recordPromise').resolve(this);
        }
    },

    // Once recording finishes uploading, resolve call to stop
    _onUploadDone(recorderId, streamName, streamDuration, audioCodec, videoCodec, fileType, audioOnly, location) { // eslint-disable-line no-unused-vars
        window.clearTimeout(this.get('uploadTimeout'));
        this.set('_isuploaded', true);
        if (this.get('_stopPromise')) {
            console.log('Upload completed for file: ' + streamName);
            this.get('_stopPromise').resolve(this);
        }
    },

    _onCamAccess(recorderId, allowed) { // eslint-disable-line no-unused-vars
        console.log('onCamAccess: ' + recorderId);
        this.set('hasCamAccess', allowed);
    },

    _onReadyToRecord(recorderId, recorderType) { // eslint-disable-line no-unused-vars
        this.set('_recorderReady', true);
    },

    _userHasCamMic(recorderId, camNumber, micNumber) { // eslint-disable-line no-unused-vars
        this.set('_nWebcams', camNumber);
        this.set('_nMics', micNumber);
    },

    _onConnectionStatus(recorderId, status) { // eslint-disable-line no-unused-vars
        this.set('connected', status === 'connected');
    },

    _onMicActivityLevel(recorderId, currentActivityLevel) { // eslint-disable-line no-unused-vars
        if (currentActivityLevel > this.get('minVolume')) {
            this.set('micChecked', true);
            // Remove the handler so we're not running this every single mic sample from now on
            this.set('_onMicActivityLevel', null);
            // This would remove the handler from the actual recorder, but we might have
            // something added by a consuming frame via the 'on' fn
            //this.get('recorder').onMicActivityLevel = function (recorderId, currentActivityLevel) {};
        }
    }

    // Additional hooks available:
    //  btRecordPressed = function (recorderId) {};
    //  btPlayPressed(recorderId)
    //  btStopRecordingPressed = function (recorderId) {};
    //  btPausePressed = function (recorderId) {};
    //  onPlaybackComplete = function (recorderId) {};
    //  onConnectionClosed = function (recorderId) {};
    //  onSaveOk = function (recorderId, streamName, streamDuration, cameraName, micName, audioCodec, videoCodec, filetype, videoId, audioOnly, location) {};

    // End webcam hooks
});

export default VideoRecorder;

export { LOOKIT_PREFERRED_DEVICES };