import Ember from 'ember';
import layout from './template';
import ExpFrameBaseUnsafeComponent from '../../components/exp-frame-base-unsafe/component';
import FullScreen from '../../mixins/full-screen';
import MediaReload from '../../mixins/media-reload';
import VideoRecord from '../../mixins/video-record';
/**
* @module exp-player
* @submodule frames
*/
/**
Test trial for the 'Your baby the physicist' study: audio instructions, intro video, and test video, with webcam recording.
@class ExpVideoPhysics
@extends ExpFrameBaseUnsafe
@uses FullScreen
@uses MediaReload
@uses VideoRecord
*/
let {
$
} = Ember;
export default ExpFrameBaseUnsafeComponent.extend(FullScreen, MediaReload, VideoRecord, {
// In the Lookit use case, the frame BEFORE the one that goes fullscreen must use "unsafe" saves (in order for
// the fullscreen event to register as being user-initiated and not from a promise handler) #LEI-369
layout: layout,
displayFullscreen: true, // force fullscreen for all uses of this component
fullScreenElementId: 'experiment-player',
fsButtonID: 'fsButton',
videoRecorder: Ember.inject.service(),
recorder: null,
recordingIsReady: false,
warning: null,
hasCamAccess: Ember.computed.alias('recorder.hasCamAccess'),
videoUploadConnected: Ember.computed.alias('recorder.connected'),
doingIntro: Ember.computed('videoSources', function() {
return (this.get('currentTask') === 'intro');
}),
playAnnouncementNow: true,
doingTest: Ember.computed('videoSources', function() {
return (this.get('currentTask') === 'test');
}),
testTimer: null,
testTime: 0,
skip: false,
hasBeenPaused: false,
useAlternate: false,
currentTask: 'announce', // announce, intro, or test.
isPaused: false,
showVideoWarning: false,
meta: {
name: 'Video player',
description: 'Component that plays a video',
parameters: {
type: 'object',
properties: {
/**
Whether to automatically advance to the next frame when video is complete. Generally leave this true, since controls will be hidden for fullscreen videos.
@property {Boolean} autoforwardOnEnd
@default true
*/
autoforwardOnEnd: {
type: 'boolean',
description: 'Whether to automatically advance to the next frame when the video is complete',
default: true
},
/**
Whether to automatically start the trial on load.
@property {Boolean} autoplay
@default true
*/
autoplay: {
type: 'boolean',
description: 'Whether to autoplay the video on load',
default: true
},
/**
Source URL for an image to show until the video starts playing.
@property {String} poster
@default ''
*/
poster: {
type: 'string',
description: 'A still image to show until the video starts playing',
default: ''
},
/**
Array of objects specifying video src and type for test video (these should be the same video, but multiple sources--e.g. mp4 and webm--are generally needed for cross-browser support). Example value:
```[{'src': 'http://.../video1.mp4', 'type': 'video/mp4'}, {'src': 'http://.../video1.webm', 'type': 'video/webm'}]```
@property {Array} sources
@param {String} src
@param {String} type
@default []
*/
sources: {
type: 'string',
description: 'List of objects specifying video src and type for test videos',
default: []
},
/**
Array of objects specifying video src and type for alternate test video, as for sources.
@property {Array} altSources
@param {String} src
@param {String} type
@default []
*/
altSources: {
type: 'string',
description: 'List of objects specifying video src and type for alternate test videos',
default: []
},
/**
Array of objects specifying intro video src and type, as for sources.
@property {Array} introSources
@param {String} src
@param {String} type
@default []
*/
introSources: {
type: 'string',
description: 'List of objects specifying intro video src and type',
default: []
},
/**
Array of objects specifying attention-grabber video src and type, as for sources.
@property {Array} attnSources
@param {String} src
@param {String} type
@default []
*/
attnSources: {
type: 'string',
description: 'List of objects specifying attention-grabber video src and type',
default: []
},
/**
List of objects specifying intro announcement src and type.
Example: `[{'src': 'http://.../audio1.mp3', 'type': 'audio/mp3'}, {'src': 'http://.../audio1.ogg', 'type': 'audio/ogg'}]`
@property {Array} audioSources
@param {String} src
@param {String} type
@default []
*/
audioSources: {
type: 'string',
description: 'List of objects specifying intro announcement audio src and type',
default: []
},
/**
List of objects specifying music audio src and type, as for audioSources.
@param musicSources
@property {Array} audioSources
@param {String} src
@param {String} type
@default []
*/
musicSources: {
type: 'string',
description: 'List of objects specifying music audio src and type',
default: []
},
/**
Length to loop test videos, in seconds
@property {Number} testLength
@default 20
*/
testLength: {
type: 'number',
description: 'Length of test videos in seconds',
default: 20
},
/**
Whether this is the last exp-physics-video frame in the group, before moving to a different frame type. (If so, play only the intro audio, no actual tests.)
@property {Boolean} isLast
@default false
*/
isLast: {
type: 'boolean',
description: 'Whether this is the last exp-physics-video frame in the group',
default: false
}
}
},
data: {
// Capture
type: 'object',
/**
* Parameters captured and sent to the server
*
* @method serializeContent
* @param {Array} videosShown Sources of videos (potentially) shown during this trial: [source of test video, source of alternate test video].
* @param {Object} eventTimings
* @param {String} videoID The ID of any webcam video recorded during this frame
* @return {Object} The payload sent to the server
*/
properties: {
videosShown: {
type: 'string',
default: []
},
videoId: {
type: 'string'
}
},
// No fields are required
}
},
videoSources: Ember.computed('isPaused', 'currentTask', 'useAlternate', function() {
if (this.get('isPaused')) {
return this.get('attnSources');
} else {
switch (this.get('currentTask')) {
case 'announce':
return this.get('attnSources');
case 'intro':
return this.get('introSources');
case 'test':
if (this.get('useAlternate')) {
return this.get('altSources');
} else {
return this.get('sources');
}
}
}
return [];
}),
shouldLoop: Ember.computed('videoSources', function() {
return (this.get('isPaused') || (this.get('currentTask') === 'announce' || this.get('currentTask') === 'test'));
}),
onFullscreen() {
if (this.get('isDestroyed')) {
return;
}
this._super(...arguments);
if (!this.checkFullscreen()) {
this.sendTimeEvent('leftFullscreen');
if (!this.get('isPaused')) {
this.pauseStudy();
}
} else {
this.sendTimeEvent('enteredFullscreen');
}
},
sendTimeEvent(name, opts = {}) {
var streamTime = this.get('recorder') ? this.get('recorder').getTime() : null;
Ember.merge(opts, {
streamTime: streamTime,
videoId: this.get('videoId')
});
this.send('setTimeEvent', `exp-physics:${name}`, opts);
},
actions: {
showWarning() {
if (!this.get('showVideoWarning')) {
this.set('showVideoWarning', true);
this.sendTimeEvent('webcamNotConfigured');
// If webcam error, save the partial frame payload immediately, so that we don't lose timing events if
// the user is unable to move on.
// TODO: Assumption: this assumes the user isn't resuming this experiment later, so partial data is ok.
this.send('save');
var recorder = this.get('recorder');
recorder.show();
recorder.on('onCamAccessConfirm', () => {
this.send('removeWarning');
this.get('recorder').record();
});
}
},
removeWarning() {
this.set('showVideoWarning', false);
this.get('recorder').hide();
this.send('showFullscreen');
this.pauseStudy();
},
stopVideo() {
var currentTask = this.get('currentTask');
if (this.get('testTime') >= this.get('testLength')) {
this.send('_afterTest');
} else if (this.get('shouldLoop')) {
this.set('_lastTime', 0);
this.$('#player-video')[0].play();
} else {
this.sendTimeEvent('videoStopped', {
currentTask
});
if (this.get('autoforwardOnEnd')) {
this.send('playNext');
}
}
},
playNext() {
if (this.get('currentTask') === 'intro') {
this.set('currentTask', 'test');
} else {
this.send('next'); // moving to intro video
}
},
_afterTest() {
window.clearInterval(this.get('testTimer'));
this.set('testTime', 0);
$('audio#exp-music')[0].pause();
this.send('playNext');
},
setTestTimer() {
window.clearInterval(this.get('testTimer'));
this.set('testTime', 0);
this.set('_lastTime', 0);
var testLength = this.get('testLength');
this.set('testTimer', window.setInterval(() => {
var videoTime = this.$('#player-video')[0].currentTime;
var lastTime = this.get('_lastTime');
var diff = videoTime - lastTime;
this.set('_lastTime', videoTime);
var testTime = this.get('testTime');
if ((testTime + diff) >= (testLength - 0.02)) {
this.send('_afterTest');
} else {
this.set('testTime', testTime + diff);
}
}, 100));
},
startVideo() {
if (this.get('doingTest')) {
if (!this.get('hasCamAccess')) {
this.pauseStudy(true);
this.send('exitFullscreen');
this.send('showWarning');
$('#videoWarningAudio')[0].play();
}
}
if (this.get('currentTask') === 'test' && !this.get('isPaused')) {
if (this.get('testTime') === 0) {
this.send('setTestTimer');
}
$('audio#exp-music')[0].play();
if (this.get('useAlternate')) {
this.sendTimeEvent('startAlternateVideo');
} else {
this.sendTimeEvent('startTestVideo');
}
}
},
startIntro() {
if (this.get('skip')) {
this.send('next');
return;
}
this.set('currentTask', 'intro');
this.set('playAnnouncementNow', false);
if (!this.get('isPaused')) {
if (this.isLast) {
this.send('next');
} else {
this.sendTimeEvent('startIntro');
this.set('videosShown', [this.get('sources')[0].src, this.get('altSources')[0].src]);
}
}
},
next() {
window.clearInterval(this.get('testTimer'));
this.set('testTime', 0);
this.sendTimeEvent('stoppingCapture');
if (this.get('recorder')) {
this.get('recorder').stop();
}
this._super(...arguments);
}
},
pauseStudy(pause) { // only called in FS mode
if (this.get('showVideoWarning')) {
return;
}
// make sure recording is set already; otherwise, pausing recording leads to an error and all following calls fail silently. Now that this is taken
// care of in videoRecorder.pause(), skip the check.
Ember.run.once(this, () => {
if (!this.get('isLast')) {
try {
this.set('hasBeenPaused', true);
} catch (_) {
return;
}
var wasPaused = this.get('isPaused');
var currentState = this.get('currentTask');
// Currently paused: restart
if (!pause && wasPaused) {
this.set('doingAttn', false);
this.set('isPaused', false);
if (currentState === 'test') {
if (this.get('useAlternate')) {
this.set('skip', true);
}
this.set('useAlternate', true);
this.set('currentTask', 'announce');
this.set('playAnnouncementNow', true);
} else {
this.set('currentTask', 'announce');
this.set('playAnnouncementNow', true);
}
try {
this.get('recorder').resume();
} catch (_) {
return;
}
} else if (pause || !wasPaused) { // Not currently paused: pause
window.clearInterval(this.get('testTimer'));
this.set('testTime', 0);
this.sendTimeEvent('pauseVideo', {
currentTask: this.get('currentTask')
});
if (this.get('recorder')) {
this.get('recorder').pause(true);
}
this.set('playAnnouncementNow', false);
this.set('isPaused', true);
}
}
});
},
didInsertElement() {
this._super(...arguments);
$(document).on('keyup', (e) => {
if (this.checkFullscreen()) {
if (e.which === 32) { // space
this.pauseStudy();
} else if (e.which === 112) { // F1: exit the study early
// FIXME: This binding does not seem to fire, likely because it is removed in willDestroy, called when exp-player advances to a new frame
if (this.get('recorder')) {
this.get('recorder').stop();
}
}
}
});
if (this.get('experiment') && this.get('id') && this.get('session') && !this.get('isLast')) {
let recorder = this.get('videoRecorder').start(this.get('videoId'), this.$('#videoRecorder'), {
hidden: true
});
recorder.install({
record: true
}).then(() => {
this.sendTimeEvent('recorderReady');
this.set('recordingIsReady', true);
});
recorder.on('onCamAccess', (hasAccess) => {
this.sendTimeEvent('hasCamAccess', {
hasCamAccess: hasAccess
});
});
recorder.on('onConnectionStatus', (status) => {
this.sendTimeEvent('videoStreamConnection', {
status: status
});
});
this.set('recorder', recorder);
}
this.send('showFullscreen');
},
willDestroyElement() { // remove event handler
// Whenever the component is destroyed, make sure that event handlers are removed and video recorder is stopped
if (this.get('recorder')) {
this.get('recorder').hide(); // Hide the webcam config screen
this.get('recorder').stop();
}
this.sendTimeEvent('destroyingElement');
this._super(...arguments);
// Todo: make removal of event listener more specific (in case a frame comes between the video and the exit survey)
$(document).off('keyup');
}
});