import Ember from 'ember';
import layout from './template';
import ExpFrameBaseComponent from '../exp-frame-base/component';
import FullScreen from '../../mixins/full-screen';
import VideoRecord from '../../mixins/video-record';
import ExpandAssets from '../../mixins/expand-assets';
import { audioAssetOptions, videoAssetOptions } from '../../mixins/expand-assets';
import { observer } from '@ember/object';
let {
$
} = Ember;
/**
* @module exp-player
* @submodule frames-deprecated
*/
/**
* This frame is a bespoke frame for a beta tester. It is deprecated and will not be
* included in release 2.x. For new studies, use {{#crossLink "Exp-lookit-change-detection"}}{{/crossLink}} instead.
*
* Frame to implement specific test trial structure for geometry alternation
* replication study. Includes announcement, calibration, and alternation (test)
* phases. During "alternation," two streams of "open triangles" are shown, in
* rectangles on the left and right of the screen: one one side both size and
* shape change, on the other only size changes. Frame is displayed fullscreen
* and video recording is conducted during calibration/test.
*
* This frame is displayed fullscreen; if the frame before it is not, that frame
* needs to include a manual "next" button so that there's a user interaction
* event to trigger fullscreen mode. (Browsers don't allow us to switch to FS
* without a user event.)
*
* Specifying media locations:
* For any parameters that expect a list of audio/video sources, you can EITHER provide
* a list of src/type pairs with full paths like this:
```json
[
{
'src': 'http://.../video1.mp4',
'type': 'video/mp4'
},
{
'src': 'http://.../video1.webm',
'type': 'video/webm'
}
]
```
* OR you can provide a single string 'stub', which will be expanded
* based on the parameter baseDir and the media types expected - either audioTypes or
* videoTypes as appropriate. For example, if you provide the audio source `intro`
* and baseDir is https://mystimuli.org/mystudy/, with audioTypes ['mp3', 'ogg'], then this
* will be expanded to:
```json
[
{
src: 'https://mystimuli.org/mystudy/mp3/intro.mp3',
type: 'audio/mp3'
},
{
src: 'https://mystimuli.org/mystudy/ogg/intro.ogg',
type: 'audio/ogg'
}
]
```
* This allows you to simplify your JSON document a bit and also easily switch to a
* new version of your stimuli without changing every URL. You can mix source objects with
* full URLs and those using stubs within the same directory. However, any stimuli
* specified using stubs MUST be
* organized as expected under baseDir/MEDIATYPE/filename.MEDIATYPE.
*
* Example usage:
```json
"frames": {
"alt-trial": {
"kind": "exp-lookit-geometry-alternation-open",
"triangleLineWidth": 8,
"baseDir": "https://s3.amazonaws.com/lookitcontents/geometry/",
"videoTypes": ["mp4", "webm"],
"audioTypes": ["mp3", "ogg"],
"calibrationVideoSources": "attention",
"trialLength": 60,
"attnLength": 10,
"calibrationLength": 3000,
"fsAudio": "fullscreen",
"triangleColor": "#056090",
"unpauseAudio": "return_after_pause",
"pauseAudio": "pause",
"videoSources": "attentiongrabber",
"musicSources": "happy-stroll",
"calibrationAudioSources": "chimes",
"altOnLeft": true,
"context": true,
"audioSources": "video_01",
"endAudioSources": "all_done"
}
}
* ```
* @class Exp-lookit-geometry-alternation-open
* @extends Exp-frame-base
* @uses Full-screen
* @uses Video-record
* @uses Expand-assets
* @deprecated
*/
export default ExpFrameBaseComponent.extend(FullScreen, VideoRecord, ExpandAssets, {
type: 'exp-lookit-geometry-alternation',
layout: layout,
displayFullscreen: true, // force fullscreen for all uses of this component
fullScreenElementId: 'experiment-player',
fsButtonID: 'fsButton',
// Track state of experiment
completedAudio: false,
completedAttn: false,
currentSegment: 'intro', // 'calibration', 'test' (mutually exclusive)
alreadyStartedCalibration: false,
// Override setting in VideoRecord mixin - only use camera if doing recording
doUseCamera: Ember.computed.alias('doRecording'),
startRecordingAutomatically: Ember.computed.alias('doRecording'),
recordingStarted: false,
assetsToExpand: {
'audio': [
'audioSources',
'musicSources',
'calibrationAudioSources',
'endAudioSources',
'pauseAudio',
'unpauseAudio',
'fsAudio'
],
'video': [
'calibrationVideoSources',
'videoSources'
],
'image': [
]
},
readyToStartCalibration: Ember.computed('recordingStarted', 'completedAudio', 'completedAttn',
function() {
var recordingStarted = false;
if (this.get('session').get('recorder')) {
recordingStarted = this.get('session').get('recorder').get('recording');
} else {
recordingStarted = this.get('recordingStarted');
}
return (recordingStarted && this.get('completedAudio') && this.get('completedAttn'));
}),
// used only by template
doingCalibration: Ember.computed('currentSegment', function() {
return (this.get('currentSegment') === 'calibration');
}),
doingIntro: Ember.computed('currentSegment', function() {
return (this.get('currentSegment') === 'intro');
}),
isPaused: false,
hasBeenPaused: false,
// Timers for intro & stimuli
introTimer: null, // minimum length of intro segment
stimTimer: null,
// Store data about triangles to show, display lengths, etc. in frame
settings: null,
triangleBases: null,
frameSchemaProperties: {
/**
* Whether to do webcam recording on this frame
*
* @property {Boolean} doRecording
*/
doRecording: {
type: 'boolean',
description: 'Whether to do webcam recording',
default: true
},
/**
* True to use big uneven triangle as context figure, or false to use small even triangle as context.
*
* @property {Boolean} context
* @default true
*/
context: {
type: 'boolean',
description: 'True to use big uneven triangle as context figure, or false to use small even triangle as context.',
default: true
},
/**
* Whether to put the shape+size alternating stream on the left (other stream alternates only in size)
*
* @property {Boolean} altOnLeft
* @default true
*/
altOnLeft: {
type: 'boolean',
description: 'Whether to put the shape+size alternating stream on the left.',
default: true
},
/**
* color of triangle outline (3 or 6 char hex, starting with #)
*
* @property {String} triangleColor
* @default '#056090'
*/
triangleColor: {
type: 'string',
description: 'color of triangle outline (3 or 6 char hex, starting with #)',
default: '#056090'
},
/**
* triangle line width in pixels
*
* @property {Integer} triangleLineWidth
* @default 5
*/
triangleLineWidth: {
type: 'integer',
description: 'triangle line width in pixels',
default: 5
},
/**
* minimum amount of time to show attention-getter in seconds
*
* @property {Number} attnLength
* @default 5
*/
attnLength: {
type: 'number',
description: 'minimum amount of time to show attention-getter in seconds',
default: 5
},
/**
* length of alternation trial in seconds
*
* @property {Number} trialLength
* @default 6
*/
trialLength: {
type: 'number',
description: 'length of alternation trial in seconds',
default: 6
},
/**
* length of single calibration segment in ms
*
* @property {Number} calibrationLength
* @default 3000
*/
calibrationLength: {
type: 'number',
description: 'length of single calibration segment in ms',
default: 3000
},
/**
* Sources Array of {src: 'url', type: 'MIMEtype'} objects for
* instructions during attention-getter video
*
* @property {Object[]} audioSources
*/
audioSources: {
oneOf: audioAssetOptions,
description: 'List of objects specifying audio src and type for instructions during attention-getter video',
default: []
},
/**
* Sources Array of {src: 'url', type: 'MIMEtype'} objects for
* music during trial
*
* @property {Object[]} musicSources
*/
musicSources: {
oneOf: audioAssetOptions,
description: 'List of objects specifying audio src and type for music during trial',
default: []
},
/**
* Sources Array of {src: 'url', type: 'MIMEtype'} objects for
* audio after completion of trial (optional; used for last
* trial "okay to open your eyes now" announcement)
*
* @property {Object[]} endAudioSources
*/
endAudioSources: {
oneOf: audioAssetOptions,
description: 'Supply this to play audio at the end of the trial; list of objects specifying audio src and type',
default: []
},
/**
* Sources Array of {src: 'url', type: 'MIMEtype'} objects for
* calibration audio (played 4 times during calibration)
*
* @property {Object[]} calibrationAudioSources
*/
calibrationAudioSources: {
oneOf: audioAssetOptions,
description: 'list of objects specifying audio src and type for calibration audio',
default: []
},
/**
* Sources Array of {src: 'url', type: 'MIMEtype'} objects for
* calibration video (played from start 4 times during
* calibration)
*
* @property {Object[]} calibrationVideoSources
*/
calibrationVideoSources: {
oneOf: videoAssetOptions,
description: 'list of objects specifying video src and type for calibration audio',
default: []
},
/**
* Sources Array of {src: 'url', type: 'MIMEtype'} objects for
* attention-getter video (should be loopable)
*
* @property {Object[]} videoSources
*/
videoSources: {
oneOf: videoAssetOptions,
description: 'List of objects specifying video src and type for attention-getter video',
default: []
},
/**
* Sources Array of {src: 'url', type: 'MIMEtype'} objects for
* audio played upon pausing study
*
* @property {Object[]} pauseAudio
*/
pauseAudio: {
oneOf: audioAssetOptions,
description: 'List of objects specifying audio src and type for audio played when pausing study',
default: []
},
/**
* Sources Array of {src: 'url', type: 'MIMEtype'} objects for
* audio played upon unpausing study
*
* @property {Object[]} unpauseAudio
*/
unpauseAudio: {
oneOf: audioAssetOptions,
description: 'List of objects specifying audio src and type for audio played when pausing study',
default: []
},
/**
* Sources Array of {src: 'url', type: 'MIMEtype'} objects for
* audio played when study is paused due to not being fullscreen
*
* @property {Object[]} fsAudio
*/
fsAudio: {
oneOf: audioAssetOptions,
description: 'List of objects specifying audio src and type for audio played when pausing study if study is not fullscreen',
default: []
}
},
meta: {
data: {
type: 'object',
properties: {
/**
* True to use big fat triangle as context figure, or false to use small skinny triangle as context. [same as passed to this frame]
* @attribute context
*/
context: {
type: 'boolean'
},
/**
* Whether to put the shape+size alternating stream on the left (other stream alternates only in size) [same as passed to this frame]
* @attribute altOnLeft
*/
altOnLeft: {
type: 'boolean'
},
videoId: {
type: 'string'
},
/**
* whether this trial was paused
* @attribute hasBeenPaused
*/
hasBeenPaused: {
type: 'boolean'
}
}
}
},
calObserver: observer('readyToStartCalibration', function(frame) {
if (frame.get('readyToStartCalibration') && frame.get('currentSegment') === 'intro') {
if (!frame.checkFullscreen()) {
frame.pauseStudy();
} else {
frame.set('currentSegment', 'calibration');
}
}
}),
segmentObserver: observer('currentSegment', function(frame) {
// Don't trigger starting intro; that'll be done manually.
if (frame.get('currentSegment') === 'calibration') {
frame.notifyPropertyChange('doingCalibration');
frame.set('alreadyStartedCalibration', false);
frame.rerender(); // Defer starting calibration until re-render completes, to
// wait for video to be available. Forcing rerender due to idiosyncratic
// calibration display problem
} else if (frame.get('currentSegment') === 'test') {
frame.startTrial();
}
}),
didRender() {
this._super(...arguments);
if (this.get('doingCalibration') && !this.get('alreadyStartedCalibration')) {
this.set('alreadyStartedCalibration', true);
this.startCalibration();
}
},
actions: {
// When intro audio is complete
endAudio() {
this.set('completedAudio', true);
this.notifyPropertyChange('readyToStartCalibration');
},
finish() {
// Call this something separate from next because stopRecorder promise needs
// to call next AFTER recording is stopped and we don't want this to have
// already been destroyed at that point.
/**
* Just before stopping webcam video capture
*
* @event stoppingCapture
*/
var _this = this;
this.stopRecorder().then(() => {
_this.set('stoppedRecording', true);
_this.send('next');
return;
}, () => {
_this.send('next');
return;
});
this._super(...arguments);
}
},
startIntro() {
// Allow pausing during intro
var _this = this;
$(document).off('keyup.pauser');
$(document).on('keyup.pauser', function(e) {_this.handleSpace(e, _this);});
// Start placeholder video right away
/**
* Immediately before starting intro/announcement segment
*
* @event startIntro
*/
this.send('setTimeEvent', 'startIntro');
$('#player-video')[0].play();
// Set a timer for the minimum length for the intro/break
$('#player-audio')[0].play();
this.set('introTimer', window.setTimeout(function() {
_this.set('completedAttn', true);
_this.notifyPropertyChange('readyToStartCalibration');
}, _this.get('attnLength') * 1000));
},
startCalibration() {
var _this = this;
// Don't allow pausing during calibration/test.
$(document).off('keyup.pauser');
// Attempt to fix calibration display similar to exit-fullscreen-and-return fix
$('#allstimuli').css('background-color', 'white');
var calAudio = $('#player-calibration-audio')[0];
var calVideo = $('#player-calibration-video')[0];
$('#player-calibration-video').show();
// Show the calibration segment at center, left, right, center, each
// time recording an event and playing the calibration audio.
var doCalibrationSegments = function(calList, lastLoc) {
if (calList.length === 0) {
$('#player-calibration-video').hide();
_this.set('currentSegment', 'test');
} else {
var thisLoc = calList.shift();
/**
* Start of EACH calibration segment
*
* @event startCalibration
* @param {String} location location of calibration ball, relative to child: 'left', 'right', or 'center'
*/
_this.send('setTimeEvent', 'startCalibration',
{location: thisLoc});
calAudio.pause();
calAudio.currentTime = 0;
calAudio.play();
calVideo.pause();
calVideo.currentTime = 0;
calVideo.play();
$('#player-calibration-video').removeClass(lastLoc);
$('#player-calibration-video').addClass(thisLoc);
window.setTimeout(function() {
doCalibrationSegments(calList, thisLoc);
}, _this.settings.calLength);
}
};
doCalibrationSegments(['center', 'left', 'right', 'center'], '');
},
startTrial() {
var _this = this;
/**
* Immediately before starting test trial segment
*
* @event startTestTrial
*/
_this.send('setTimeEvent', 'startTestTrial');
// Begin playing music; fade in and set to fade out at end of trial
var $musicPlayer = $('#player-music');
$musicPlayer.prop('volume', 0.1);
$musicPlayer[0].play();
$musicPlayer.animate({volume: 1}, _this.settings.musicFadeLength);
window.setTimeout(function() {
$musicPlayer.animate({volume: 0}, _this.settings.musicFadeLength);
}, _this.settings.trialLength * 1000 - _this.settings.musicFadeLength);
// Start presenting triangles and set to stop after trial length
_this.presentTriangles(_this.settings.LshapesStart,
_this.settings.RshapesStart,
_this.settings.LsizeBaseStart,
_this.settings.RsizeBaseStart);
window.setTimeout(function() {
window.clearTimeout(_this.get('stimTimer'));
_this.clearTriangles();
_this.endTrial();
}, _this.settings.trialLength * 1000);
},
// When triangles have been shown for time indicated: play end-audio if
// present, or just move on.
endTrial() {
this.stopRecorder();
if (this.get('endAudioSources').length) {
$('#player-endaudio')[0].play();
} else {
this.send('finish');
}
},
getRandomElement(arr) {
return arr[Math.floor(Math.random() * arr.length)];
},
getRandom(min, max) {
return Math.random() * (max - min) + min;
},
drawTriangles(Lshape, LX, LY, LRot, LFlip, LSize, Rshape, RX, RY, RRot, RFlip, RSize) {
/**
* records EACH triangle presentation during test trial
*
* @event videoStreamConnection
* @param {String} Lshape shape of left triangle: 'skinny' or 'fat'
* @param {String} Rshape shape of right triangle: 'skinny' or 'fat'
* @param {Number} LX Horizontal offset of left triangle from rectangle center, in units where rectangle width = 70; positive = to right
* @param {Number} LY Vertical offset of left triangle from rectangle center, in units where rectangle height = 100.8; positive = down
* @param {Number} RX Horizontal offset of right triangle from rectangle center, in units where screen width = 200 and rectangle width = 70; positive = to right
* @param {Number} RY Vertical offset of right triangle from rectangle center, in units where rectangle height = 100.8; positive = down
* @param {Number} LRot rotation of left triangle in degrees. 0 degrees has long side horizontal and 15 degree angle (skinny triangle) or 60 degree angle (fat triangle) on left.
* @param {Number} RRot rotation of right triangle in degrees. 0 degrees has long side horizontal and 15 degree angle (skinny triangle) or 60 degree angle (fat triangle) on left.
* @param {Number} LFlip whether left triangle is flipped (1 = no, -1 = yes)
* @param {Number} RFlip whether right triangle is flipped (1 = no, -1 = yes)
* @param {Number} LSize size of left triangle, relative to standard ('standard' sizes are set so that areas of skinny & fat triangles are equal), in terms of side length (e.g. for a rectangle, 2 would mean take a 1x3 rectangle and make it a 2x6 rectangle, quadrupling the area)
* @param {Number} RSize size of right triangle, relative to standard ('standard' sizes are set so that areas of skinny & fat triangles are equal), in terms of side length (e.g. for a rectangle, 2 would mean take a 1x3 rectangle and make it a 2x6 rectangle, quadrupling the area)
*/
this.send('setTimeEvent', 'presentTriangles', {
Lshape: Lshape,
LX: LX,
LY: LY,
LRot: LRot,
LFlip: LFlip,
LSize: LSize,
Rshape: Rshape,
RX: RX,
RY: RY,
RRot: RRot,
RFlip: RFlip,
RSize: RSize
});
var leftTriangle = `${this.triangleBases[Lshape]}
transform=" translate(${LX}, ${LY})
translate(37.5, 56)
rotate(${LRot})
scale(${LSize})
scale(${LFlip}, 1)" />`;
var rightTriangle = `${this.triangleBases[Rshape]}
transform=" translate(${RX}, ${RY})
translate(162.5, 56)
rotate(${RRot})
scale(${RSize})
scale(${RFlip}, 1)" />`;
$('#stimuli').html(leftTriangle + rightTriangle);
},
clearTriangles() {
/**
* Records each time triangles are cleared from display
*
* @event clearTriangles
*/
this.send('setTimeEvent', 'clearTriangles');
$('#stimuli').html('');
},
presentTriangles(Lshapes, Rshapes, LsizeBase, RsizeBase) {
// select X and Y positions for each shape
var LX = this.getRandom(this.settings.XRange[0],
this.settings.XRange[1]);
var RX = this.getRandom(this.settings.XRange[0],
this.settings.XRange[1]);
var LY = this.getRandom(this.settings.YRange[0],
this.settings.YRange[1]);
var RY = this.getRandom(this.settings.YRange[0],
this.settings.YRange[1]);
// select rotation, flip, size per shape
var LRot = this.getRandom(this.settings.rotRange[0],
this.settings.rotRange[1]);
var RRot = this.getRandom(this.settings.rotRange[0],
this.settings.rotRange[1]);
var LFlip = this.getRandomElement(this.settings.flipVals);
var RFlip = this.getRandomElement(this.settings.flipVals);
var LSize = this.getRandom(this.settings.sizeRange[0],
this.settings.sizeRange[1]) * LsizeBase[0];
var RSize = this.getRandom(this.settings.sizeRange[0],
this.settings.sizeRange[1]) * RsizeBase[0];
var _this = this;
_this.clearTriangles();
_this.set('stimTimer', window.setTimeout(function() {
_this.drawTriangles(Lshapes[0], LX, LY, LRot, LFlip, LSize,
Rshapes[0], RX, RY, RRot, RFlip, RSize);
_this.set('stimTimer', window.setTimeout(function() {
_this.presentTriangles(Lshapes.reverse(), Rshapes.reverse(),
LsizeBase.reverse(), RsizeBase.reverse());
}, _this.settings.msTriangles));
}, _this.settings.msBlank));
},
handleSpace(event, frame) {
if (frame.checkFullscreen() || !frame.isPaused) {
if (event.which === 32) { // space
frame.pauseStudy();
}
}
},
// Pause/unpause study; only called if doing intro.
pauseStudy() {
$('#player-audio')[0].pause();
$('#player-audio')[0].currentTime = 0;
$('#player-pause-audio')[0].pause();
$('#player-pause-audio')[0].currentTime = 0;
$('#player-pause-audio-leftfs')[0].pause();
$('#player-pause-audio-leftfs')[0].currentTime = 0;
this.set('completedAudio', false);
this.set('completedAttn', false);
Ember.run.once(this, () => {
this.set('hasBeenPaused', true);
var wasPaused = this.get('isPaused');
this.set('currentSegment', 'intro');
// Currently paused: RESUME
if (wasPaused) {
this.startIntro();
this.set('isPaused', false);
} else { // Not currently paused: PAUSE
window.clearTimeout(this.get('introTimer'));
if (this.checkFullscreen()) {
$('#player-pause-audio')[0].play();
} else {
$('#player-pause-audio-leftfs')[0].play();
}
this.set('isPaused', true);
}
});
},
didInsertElement() {
this._super(...arguments);
// Define basic properties for two triangle shapes used. It would be
// more natural to define these in the template, and then use the
// <use xlink:href="#name" .../> syntax to transform them as
// appropriate, but although this worked fine on experimenter I couldn't
// get the links working on lookit. The code was correctly generated,
// but while a direct use of polygon showed up, nothing that used
// xlink:href showed up at all (even when hard-coded into the template).
// Possibly related to issues like
// https://github.com/emberjs/ember.js/issues/14752.
// --kim
this.set('triangleBases', {
'even': `<polyline stroke="${this.get('triangleColor')}"
stroke-width="${this.get('triangleLineWidth')}"
fill="none"
points="-5.75451015291 , -5.14699035165
-5.75451015291 , 10.2939807033
11.5090203058 , -5.14699035165"
vector-effect="non-scaling-stroke"
stroke-linejoin="round"`,
'uneven': `<polyline stroke="${this.get('triangleColor')}"
stroke-width="${this.get('triangleLineWidth')}"
fill="none"
points="-7.19313769114 , 0.0
-7.19313769114 , 9.65060690934
14.3862753823 , -9.65060690934"
vector-effect="non-scaling-stroke"
stroke-linejoin="round"`
});
// COUNTERBALANCING (2x2):
// context: whether to use even or uneven triangle as context. If 'even',
// contrasts are small even/big even and small even/big uneven. If 'uneven',
// contrasts are big uneven/small even and big uneven/small uneven.
// altOnLeft: whether to put size-and-shape alteration on left
var diffShapes;
var sameShapes;
var shapeSizes;
if (this.get('context')) {
sameShapes = ['uneven']; // context: big fat triangle
shapeSizes = [1.6, 1]; // big fat vs. small fat/small skinny
diffShapes = ['uneven', 'even']; // start with context
} else {
sameShapes = ['even']; // context: small skinny triangle
shapeSizes = [1, 1.6]; // small skinny vs. big skinny/big fat
diffShapes = ['even', 'uneven']; // start with context
}
var Lshapes;
var Rshapes;
if (this.get('altOnLeft')) {
Lshapes = diffShapes;
Rshapes = sameShapes;
} else {
Lshapes = sameShapes;
Rshapes = diffShapes;
}
this.set('settings', {
msBlank: 300,
msTriangles: 500,
LsizeBaseStart: shapeSizes,
RsizeBaseStart: shapeSizes.slice(),
XRange: [-3.125, 3.125],
YRange: [-3.125, 3.125],
rotRange: [0, 360],
flipVals: [-1, 1],
sizeRange: [0.921954, 1.072381], // 15% by AREA: sqrt(0.85), sqrt(1.15)
trialLength: this.get('trialLength'),
LshapesStart: Lshapes,
RshapesStart: Rshapes,
musicFadeLength: 2000,
calLength: this.get('calibrationLength')});
this.notifyPropertyChange('readyToStartCalibration');
this.startIntro();
},
willDestroyElement() { // remove event handler
$(document).off('keyup.pauser');
window.clearInterval(this.get('introTimer'));
window.clearInterval(this.get('stimTimer'));
this._super(...arguments);
},
// Override to do a bit extra when starting recording
onRecordingStarted() {
this.set('recordingStarted', true);
},
// Override to do a bit extra when starting session recorder
onSessionRecordingStarted() {
this.set('recordingStarted', true);
}
});