Show:

File: app/mixins/video-record.js

import Ember from 'ember';
import { observer } from '@ember/object';
import VideoRecorder from '../services/video-recorder';
import { colorSpecToRgbaArray, isColor } from '../utils/is-color';
import { expFormat } from '../helpers/exp-format';

let {
    $
} = Ember;

/**
 * @module exp-player
 * @submodule mixins
 */

/**
 *
 * Reference for DEVELOPERS of new frames only!
 *
 * A mixin that can be used to add basic support for video recording across frames
 *
 * By default, the recorder will be installed when this frame loads, but recording
 * will not start automatically. To override either of these settings, set
 * the properties `doUseCamera` and/or `startRecordingAutomatically` in the consuming
 * frame.
 *
 * You will also need to set `recorderElement` if the recorder is to be housed other than
 * in an element identified by the ID `recorder`.
 *
 * The properties `recorder`, `videoList`, `stoppedRecording`, `recorderReady`, and
 * `videoId` become available to the consuming frame. The recorder object has fields
 * that give information about its state: `hasWebCam`, 'hasCamAccess`, `recording`,
 * `connected`, and `micChecked` - for details, see services/video-recorder.js. These
 * can be accessed from the consuming frame as e.g. `this.get('recorder').get('hasWebCam')`.
 *
 * If starting recording automatically,  the function `onRecordingStarted` will be called
 * once recording begins. If you want to do other things at this point, like proceeding
 * to a test trial, you can add this hook in your frame.
 *
 * See 'methods' for the functions you can use on a frame that extends VideoRecord.
 *
 * Events recorded in a frame that extends VideoRecord will automatically have additional
 * fields videoId (video filename), pipeId (temporary filename initially assigned by
 * the recording service),
 * and streamTime (when in the video they happened, in s).
 *
 * Setting up the camera is handled in didInsertElement, and making sure recording is
 * stopped is handled in willDestroyElement (Ember hooks that fire during the component
 * lifecycle). It is very important (in general, but especially when using this mixin)
 * that you call `this._super(...arguments);` in any functions where your frame overrides
 * hooks like this, so that the mixin's functions get called too!
 *
 *
 * @class Video-record
 */

/**
 * When recorder detects a change in camera access
 *
 * @event hasCamAccess
 * @param {Boolean} hasCamAccess
 */

/**
 * When recorder detects a change in video stream connection status
 *
 * @event videoStreamConnection
 * @param {String} status status of video stream connection, e.g.
 * 'NetConnection.Connect.Success' if successful
 */

/**
 * When pausing study, immediately before request to pause webcam recording
 *
 * @event pauseVideo
 */

/**
 * When unpausing study, immediately before request to resume webcam recording
 *
 * @event unpauseVideo
 */

/**
 * Just before stopping webcam video capture
 *
 * @event stoppingCapture
 */

export default Ember.Mixin.create({

    /**
     * The recorder object, accessible to the consuming frame. Includes properties
     * recorder.nWebcams, recorder.hasCamAccess, recorder.micChecked, recorder.connected.
     * @property {VideoRecorder} recorder
     * @private
     */
    recorder: null,

    /**
     * A list of all video IDs used in this mixin (a new one is created for each recording).
     * Accessible to consuming frame.
     * @property {List} videoList
     * @private
     */
    videoList: null,

    /**
     * Whether recording is stopped already, meaning it doesn't need to be re-stopped when
     * destroying frame. This should be set to true by the consuming frame when video is
     * stopped.
     * @property {Boolean} stoppedRecording
     * @private
     */
    stoppedRecording: false,

    /**
     * JQuery string to identify the recorder element.
     * @property {String} recorderElement
     * @default '#recorder'
     * @private
     */
    recorderElement: '#recorder',

    /**
     * Whether recorder has been set up yet. Automatically set when doing setup.
     * Accessible to consuming frame.
     * @property {Boolean} recorderReady
     * @private
     */
    recorderReady: false,

    /**
     * Maximum recording length in seconds. Can be overridden by consuming frame.
     * @property {Number} maxRecordingLength
     * @default 7200
     */
    maxRecordingLength: 7200,

    /**
     * Maximum time allowed for video upload before proceeding, in seconds.
     * Can be overridden by researcher, based on tradeoff between making families wait and
     * losing data.
     * @property {Number} maxUploadSeconds
     * @default 5
     */
    maxUploadSeconds: 5,

    /**
     * Whether to autosave recordings. Can be overridden by consuming frame.
     * TODO: eventually use this to set up non-recording option for previewing
     * @property {Number} autosave
     * @default 1
     * @private
     */
    autosave: 1,

    /**
     * Whether to do audio-only (vs also video) recording. Can be overridden by consuming frame.
     * @property {Number} audioOnly
     * @default 0
     */
    audioOnly: 0,

    /**
     * Whether to use the camera in this frame. Consuming frame should set this property
     * to override if needed.
     * @property {Boolean} doUseCamera
     * @default true
     */
    doUseCamera: true,

    /**
     * Whether to start recording ASAP (only applies if doUseCamera). Consuming frame
     * should set to override if needed.
     * @property {Boolean} startRecordingAutomatically
     * @default false
     */
    startRecordingAutomatically: false,

    /**
     * A video ID to use for the current recording. Format is
     * `videoStream_<experimentId>_<frameId>_<sessionId>_timestampMS_RRR`
     * where RRR are random numeric digits.
     *
     * @property {String} videoId
     * @private
     */
    videoId: '',

    /**
     * Whether to initially show a message saying to wait until recording starts, covering the entire frame.
     * This prevents participants from seeing any stimuli before recording begins. Only used if recording is being
     * started immediately.
     * @property {Boolean} showWaitForRecordingMessage
     * @default true
     */
    showWaitForRecordingMessage: true,

    /**
     * [Only used if showWaitForRecordingMessage is true] Text to display while waiting for recording to begin.
     * @property {Boolean} waitForRecordingMessage
     * @default 'Please wait... <br><br> starting webcam recording'
     */
    waitForRecordingMessage: 'Please wait... <br><br> starting webcam recording',

    /**
     * [Only used if showWaitForRecordingMessage is true] Background color of screen while waiting for recording to
     * begin. See https://developer.mozilla.org/en-US/docs/Web/CSS/color_value
     * for acceptable syntax: can use either color names ('blue', 'red', 'green', etc.), or
     * rgb hex values (e.g. '#800080' - include the '#'). The text on top of this will be either black or white
     * depending on which will have higher contrast.
     * @property {Boolean} waitForRecordingMessageColor
     * @default 'white'
     */
    waitForRecordingMessageColor: 'white',

    /**
     * Whether to stop media and hide stimuli with a message saying to wait for video upload when stopping recording.
     * Do NOT set this to true if end of recording does not correspond to end of the frame (e.g. during consent or
     * observation frames) since it will hide everything upon stopping the recording!
     * @property {Boolean} showWaitForUploadMessage
     * @default true
     */
    showWaitForUploadMessage: false,

    /**
     * [Only used if showWaitForUploadMessage is true] Text to display while waiting for recording to begin.
     * @property {Boolean} waitForUploadMessage
     * @default 'Please wait... <br><br> uploading video'
     */
    waitForUploadMessage: 'Please wait... <br><br> uploading video',

    /**
     * [Only used if showWaitForUploadMessage is true] Background color of screen while waiting for recording to
     * upload. See https://developer.mozilla.org/en-US/docs/Web/CSS/color_value
     * for acceptable syntax: can use either color names ('blue', 'red', 'green', etc.), or
     * rgb hex values (e.g. '#800080' - include the '#'). The text on top of this will be either black or white
     * depending on which will have higher contrast.
     * @property {String} waitForUploadMessageColor
     * @default 'white'
     */
    waitForUploadMessageColor: 'white',

    /**
     * [Only used if showWaitForUploadMessage and/or showWaitForRecordingMessage are true] Image to display along with
     * any wait-for-recording or wait-for-upload message. Either waitForWebcamImage or waitForWebcamVideo can be
     * specified. This can be either a full URL ('https://...') or just a filename, which will be assumed to be
     * inside ``baseDir/img/`` if this frame otherwise supports use of ``baseDir``.
     * @property {String} waitForWebcamImage
     * @default ''
     */
    waitForWebcamImage: '',

    /**
     * [Only used if showWaitForUploadMessage and/or showWaitForRecordingMessage are true] Video to display along with
     * any wait-for-recording or wait-for-upload message (looping). Either waitForWebcamImage or waitForWebcamVideo can be
     * specified. This can be either an array of ``{'src': 'https://...', 'type': '...'}`` objects (e.g. providing both
     * webm and mp4 versions at specified URLS) or a single string relative to ``baseDir/<EXT>/`` if this frame otherwise
     * supports use of ``baseDir``.
     * @property {String} waitForWebcamVideo
     * @default ''
     */
    waitForWebcamVideo: '',


    _generateVideoId() {
        return [
            'videoStream',
            this.get('experiment.id'),
            this.get('id'), // parser enforces that id is composed of a-z, A-Z, -, ., [space]
            this.get('session.id'),
            +Date.now(), // Timestamp in ms
            Math.floor(Math.random() * 1000)
        ].join('_');
    },

    /**
     * Extend any base time event capture with information about the recorded video
     * @method makeTimeEvent
     * @param eventName
     * @param extra
     * @return {Object} Event data object
     */
    makeTimeEvent(eventName, extra) {
        // All frames using this mixin will add streamTime to every server event
        let base = this._super(eventName, extra);
        Ember.assign(base, {
            streamTime: this.get('recorder') ? this.get('recorder').getTime() : null
        });
        return base;
    },

    /**
     * Set up a video recorder instance
     * @method setupRecorder
     * @param {Node} element A DOM node representing where to mount the recorder
     * @return {Promise} A promise representing the result of installing the recorder
     */
    setupRecorder(element) {
        const videoId = this._generateVideoId();
        this.set('videoId', videoId);
        const recorder = new VideoRecorder({element: element});
        const pipeLoc = Ember.getOwner(this).resolveRegistration('config:environment').pipeLoc;
        const pipeEnv = Ember.getOwner(this).resolveRegistration('config:environment').pipeEnv;
        const installPromise = recorder.install(this.get('videoId'), pipeLoc, pipeEnv,
            this.get('maxRecordingLength'), this.get('autosave'), this.get('audioOnly'));

        // Track specific events for all frames that use  VideoRecorder
        var _this = this;
        recorder.on('onCamAccess', (recId, hasAccess) => {   // eslint-disable-line no-unused-vars
            if (!(_this.get('isDestroyed') || _this.get('isDestroying'))) {
                _this.send('setTimeEvent', 'recorder.hasCamAccess', {
                    hasCamAccess: hasAccess
                });
            }
        });
        recorder.on('onConnectionStatus', (recId, status) => {   // eslint-disable-line no-unused-vars
            if (!(_this.get('isDestroyed') || _this.get('isDestroying'))) {
                _this.send('setTimeEvent', 'videoStreamConnection', {
                    status: status
                });
            }
        });
        this.set('recorder', recorder);
        this.send('setTimeEvent', 'setupVideoRecorder', {
            videoId: videoId
        });
        return installPromise;
    },

    /**
     * Start recording
     * @method startRecorder
     * @return Promise Resolves when recording has started
     */
    startRecorder() {
        const recorder = this.get('recorder');
        if (recorder) {
            return recorder.record().then(() => {
                this.send('setTimeEvent', 'startRecording', {
                    pipeId: recorder.get('pipeVideoName')
                });
                if (this.get('videoList') == null) {
                    this.set('videoList', [this.get('videoId')]);
                } else {
                    this.set('videoList', this.get('videoList').concat([this.get('videoId')]));
                }
            });
        } else {
            return Ember.RSVP.resolve();
        }
    },

    /**
     * Stop the recording
     * @method stopRecorder
     * @return Promise A promise that resolves when upload is complete
     */
    stopRecorder() {
        const recorder = this.get('recorder');
        if (recorder && recorder.get('recording')) {
            this.send('setTimeEvent', 'stoppingCapture');
            if (this.get('showWaitForUploadMessage')) {
                // TODO: consider adding progress bar
                $( "video audio" ).each(function() {
                    this.pause();
                });

                let colorSpec = this.get('waitForUploadMessageColor');
                if (!isColor(colorSpec)) {
                    console.warn(`Invalid background color waitForRecordingUploadColor (${colorSpec}) provided; using default instead.`);
                    colorSpec = 'white';
                }
                let colorSpecRGBA = colorSpecToRgbaArray(colorSpec);
                $('.video-record-mixin-wait-for-video').css('background-color', colorSpec);
                $('.video-record-mixin-wait-for-video-text').css('color', (colorSpecRGBA[0] + colorSpecRGBA[1] + colorSpecRGBA[2] > 128 * 3) ? 'black' : 'white');
                $('.video-record-mixin-wait-for-video-text').html(`${expFormat(this.get('waitForUploadMessage'))}`);
                $('.video-record-mixin-wait-for-video').show();

            }
            return recorder.stop(this.get('maxUploadSeconds') * 1000);
        } else {
            return Ember.RSVP.reject(1);
        }
    },

    /**
     * Destroy recorder and stop accessing webcam
     * @method destroyRecorder
     */
    destroyRecorder() {
        const recorder = this.get('recorder');
        if (recorder) {
            if (!(this.get('isDestroyed') || this.get('isDestroying'))) {
                this.send('setTimeEvent', 'destroyingRecorder');
            }
            recorder.destroy();
        }
    },

    willDestroyElement() {
        var _this = this;
        if (_this.get('recorder')) {
            window.clearTimeout(_this.get('recorder').get('uploadTimeout'));
            if (_this.get('stoppedRecording', true)) {
                _this.destroyRecorder();
            } else {
                _this.stopRecorder().then(() => {
                    _this.set('stoppedRecording', true);
                    _this.destroyRecorder();
                }, () => {
                    _this.destroyRecorder();
                });
            }
        }
        _this._super(...arguments);
    },

    didReceiveAttrs() {
        let assets = this.get('assetsToExpand');
        if (assets) {
            if (assets.image) {
                assets.image.push('waitForUploadImage');
            } else {
                assets.image = ['waitForUploadImage'];
            }
            if (assets.video) {
                assets.video.push('waitForUploadVideo');
            } else {
                assets.video = ['waitForUploadVideo'];
            }
        } else {
            this.set('assetsToExpand', {'image': ['waitForUploadImage'], 'video': ['waitForUploadVideo']})
        }
        console.log(this.get('assetsToExpand'));
        this._super(...arguments);
    },

    didInsertElement() {
        // Give any active session recorder precedence over individual-frame recording
        if (this.get('sessionRecorder') && this.get('session').get('recordingInProgress')) {
            console.warn('Recording on this frame was specified, but session recording is already active. Not making frame recording.');
            this.set('doUseCamera', false);
        }

        if (this.get('doUseCamera')) {

            // If showing a wait-for-recording or wait-for-upload message, set it up now.
            if ((this.get('showWaitForRecordingMessage') && this.get('startRecordingAutomatically')) || this.get('showWaitForUploadMessage')) {
                let $waitForVideoCover = $('<div></div>');
                $waitForVideoCover.addClass('video-record-mixin-wait-for-video'); // for easily referencing later to show/hide

                // Set the background color of the cover
                let colorSpec = this.get('waitForRecordingMessageColor');
                if (!isColor(colorSpec)) {
                    console.warn(`Invalid background color waitForRecordingMessageColor (${colorSpec}) provided; using default instead.`);
                    colorSpec = 'white';
                }
                let colorSpecRGBA = colorSpecToRgbaArray(colorSpec);
                $waitForVideoCover.css('background-color', colorSpec);

                // Add the image, if any
                if (this.get('waitForUploadImage')) {
                    let imageSource = this.get('waitForUploadImage_parsed') ? this.get('waitForUploadImage_parsed') : this.get('waitForUploadImage');
                    $waitForVideoCover.append($(`<img src='${imageSource}' class='video-record-mixin-image'>`));
                }

                // Add the video, if any
                if (this.get('waitForUploadVideo')) {
                    let $videoElement = $('<video loop autoplay="autoplay" class="video-record-mixin-image"></video>');
                    let videoSources = this.get('waitForUploadVideo_parsed') ? this.get('waitForUploadVideo_parsed') : this.get('waitForUploadVideo');
                    console.log(videoSources);
                    console.log(this);
                    $.each(videoSources, function (idx, source) {
                        $videoElement.append(`<source src=${source.src} type=${source.type}>`);
                    });
                    $waitForVideoCover.append($videoElement);
                }

                // Add the text and set its color so it'll be visible against the background
                let $waitForVideoText = $(`<div>${expFormat(this.get('waitForRecordingMessage'))}</div>`);
                $waitForVideoText.addClass('video-record-mixin-wait-for-video-text');
                $waitForVideoText.css('color', (colorSpecRGBA[0] + colorSpecRGBA[1] + colorSpecRGBA[2] > 128 * 3) ? 'black' : 'white');
                $waitForVideoCover.append($waitForVideoText);

                $('div.lookit-frame').append($waitForVideoCover);

                if (this.get('showWaitForRecordingMessage') && this.get('startRecordingAutomatically')) {
                    $waitForVideoCover.css('display', 'block');
                }
            }

            var _this = this;
            this.setupRecorder(this.$(this.get('recorderElement'))).then(() => {
                /**
                 * When video recorder has been installed
                 *
                 * @event recorderReady
                 */
                _this.send('setTimeEvent', 'recorderReady');
                _this.set('recorderReady', true);
                _this.whenPossibleToRecordObserver(); // make sure this fires
            });
        }
        this._super(...arguments);
    },

    /**
     * Function called when frame recording is started automatically. Override to do
     * frame-specific actions at this point (e.g., beginning a test trial).
     *
     * @method onRecordingStarted
     */
    onRecordingStarted() {
    },

    /**
     * Observer that starts recording once recorder is ready.
     * @method whenPossibleToRecordObserver
     */
    whenPossibleToRecordObserver: observer('recorder.hasCamAccess', 'recorderReady', function() {
        if (this.get('doUseCamera') && this.get('startRecordingAutomatically')) {
            var _this = this;
            if (this.get('recorder.hasCamAccess') && this.get('recorderReady')) {
                this.startRecorder().then(() => {
                    _this.set('recorderReady', false);
                    $('.video-record-mixin-wait-for-video').hide();
                    _this.onRecordingStarted();
                });
            }
        }
    }),

    /**
     * Hide the recorder from display. Useful if you would like to keep recording without extra UI elements to
     *   distract the user.
     * @method hideRecorder
     */
    hideRecorder() {
        $(this.get('recorderElement')).parent().addClass('video-recorder-hidden');
    },

    /**
     * Show the recorder to the user. Useful if you want to temporarily show a hidden recorder- eg to let the user fix
     *   a problem with video capture settings
     * @method showRecorder
     */
    showRecorder() {
        $(this.get('recorderElement')).parent().removeClass('video-recorder-hidden');
    },

    init() {
        this._super(...arguments);
        this.set('videoList', []);
    }

});