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, imageAssetOptions } from '../../mixins/expand-assets';
import isColor from '../../utils/is-color';
let {
$
} = Ember;
/**
* @module exp-player
* @submodule frames
*/
/**
* Frame to display image(s) and play audio, with optional video recording. Options allow
* customization for looking time, storybook, forced choice, and reaction time type trials,
* including training versions where children (or parents) get feedback about their responses.
*
* This can be used in a variety of ways - for example:
*
* - Display an image for a set amount of time and measure looking time
*
* - Display two images for a set amount of time and play audio for a
* looking-while-listening paradigm
*
* - Show a "storybook page" where you show images and play audio, having the parent/child
* press 'Next' to proceed. If desired,
* images can appear and be highlighted at specific times
* relative to audio. E.g., the audio might say "This [image of Remy appears] is a boy
* named Remy. Remy has a little sister [image of Zenna appears] named Zenna.
* [Remy highlighted] Remy's favorite food is brussel sprouts, but [Zenna highlighted]
* Zenna's favorite food is ice cream. [Remy and Zenna both highlighted] Remy and Zenna
* both love tacos!"
*
* - Play audio asking the child to choose between two images by pointing or answering
* verbally. Show text for the parent about how to help and when to press Next.
*
* - Play audio asking the child to choose between two images, and require one of those
* images to be clicked to proceed (see "choiceRequired" option).
*
* - Measure reaction time as the child is asked to choose a particular option on each trial
* (e.g., a central cue image is shown first, then two options at a short delay; the child
* clicks on the one that matches the cue in some way)
*
* - Provide audio and/or text feedback on the child's (or parent's) choice before proceeding,
* either just to make the study a bit more interactive ("Great job, you chose the color BLUE!")
* or for initial training/familiarization to make sure they understand the task. Some
* images can be marked as the "correct" answer and a correct answer required to proceed.
* If you'd like to include some initial training questions before your test questions,
* this is a great way to do it.
*
* In general, the images are displayed in a designated region of the screen with aspect
* ratio 7:4 (1.75 times as wide as it is tall) to standardize display as much as possible
* across different monitors. If you want to display things truly fullscreen, you can
* use `autoProceed` and not provide `parentText` so there's nothing at the bottom, and then
* set `maximizeDisplay` to true.
*
* Webcam recording may be turned on or off; if on, stimuli are not displayed and audio is
* not started until recording begins. (Using the frame-specific `isRecording` property
* is good if you have a smallish number of test trials and prefer to have separate video
* clips for each. For reaction time trials or many short trials, you will likely want
* to use session recording instead - i.e. start the session recording before the first trial
* and end on the last trial - to avoid the short delays related to starting/stopping the video.)
*
* This frame is displayed fullscreen, but is not paused or otherwise disabled if the
* user leaves fullscreen. A button appears prompting the user to return to
* fullscreen mode.
*
* Any number of images may be placed on the screen, and their position
* specified. (Aspect ratio will be the same as the original image.)
*
* The examples below show a variety of usages, corresponding to those shown in the video.
*
* image-1: Single image displayed full-screen, maximizing area on monitor, for 8 seconds.
*
* image-2: Single image displayed at specified position, with 'next' button to move on
*
* image-3: Image plus audio, auto-proceeding after audio completes and 4 seconds go by
*
* image-4: Image plus audio, with 'next' button to move on
*
* image-5: Two images plus audio question asking child to point to one of the images,
* demonstrating different timing of image display & highlighting of images during audio
*
* image-6: Three images with audio prompt, family has to click one of two to continue
*
* image-7: Three images with audio prompt, family has to click correct one to continue -
* audio feedback on incorrect answer
*
* image-8: Three images with audio prompt, family has to click correct one to continue -
* text feedback on incorrect answer
*
*
```json
"frames": {
"image-1": {
"kind": "exp-lookit-images-audio",
"images": [
{
"id": "cats",
"src": "two_cats.png",
"position": "fill"
}
],
"baseDir": "https://www.mit.edu/~kimscott/placeholderstimuli/",
"autoProceed": true,
"doRecording": true,
"durationSeconds": 8,
"maximizeDisplay": true
},
"image-2": {
"kind": "exp-lookit-images-audio",
"images": [
{
"id": "cats",
"src": "three_cats.JPG",
"top": 10,
"left": 30,
"width": 40
}
],
"baseDir": "https://www.mit.edu/~kimscott/placeholderstimuli/",
"autoProceed": false,
"doRecording": true,
"parentTextBlock": {
"text": "Some explanatory text for parents",
"title": "For parents"
}
},
"image-3": {
"kind": "exp-lookit-images-audio",
"audio": "wheresremy",
"images": [
{
"id": "remy",
"src": "wheres_remy.jpg",
"position": "fill"
}
],
"baseDir": "https://www.mit.edu/~kimscott/placeholderstimuli/",
"audioTypes": [
"mp3",
"ogg"
],
"autoProceed": true,
"doRecording": false,
"durationSeconds": 4,
"parentTextBlock": {
"text": "Some explanatory text for parents",
"title": "For parents"
},
"showProgressBar": true
},
"image-4": {
"kind": "exp-lookit-images-audio",
"audio": "peekaboo",
"images": [
{
"id": "remy",
"src": "peekaboo_remy.jpg",
"position": "fill"
}
],
"baseDir": "https://www.mit.edu/~kimscott/placeholderstimuli/",
"audioTypes": [
"mp3",
"ogg"
],
"autoProceed": false,
"doRecording": false,
"parentTextBlock": {
"text": "Some explanatory text for parents",
"title": "For parents"
}
},
"image-5": {
"kind": "exp-lookit-images-audio",
"audio": "remyzennaintro",
"images": [
{
"id": "remy",
"src": "scared_remy.jpg",
"position": "left"
},
{
"id": "zenna",
"src": "love_zenna.jpg",
"position": "right",
"displayDelayMs": 1500
}
],
"baseDir": "https://www.mit.edu/~kimscott/placeholderstimuli/",
"highlights": [
{
"range": [
0,
1.5
],
"imageId": "remy"
},
{
"range": [
1.5,
3
],
"imageId": "zenna"
}
],
"autoProceed": false,
"doRecording": true,
"parentTextBlock": {
"text": "Some explanatory text for parents",
"title": "For parents"
}
},
"image-6": {
"kind": "exp-lookit-images-audio",
"audio": "matchremy",
"images": [
{
"id": "cue",
"src": "happy_remy.jpg",
"position": "center",
"nonChoiceOption": true
},
{
"id": "option1",
"src": "happy_zenna.jpg",
"position": "left",
"displayDelayMs": 2000
},
{
"id": "option2",
"src": "annoyed_zenna.jpg",
"position": "right",
"displayDelayMs": 2000
}
],
"baseDir": "https://www.mit.edu/~kimscott/placeholderstimuli/",
"autoProceed": false,
"doRecording": true,
"choiceRequired": true,
"parentTextBlock": {
"text": "Some explanatory text for parents",
"title": "For parents"
},
"canMakeChoiceBeforeAudioFinished": true
},
"image-7": {
"kind": "exp-lookit-images-audio",
"audio": "matchzenna",
"images": [
{
"id": "cue",
"src": "sad_zenna.jpg",
"position": "center",
"nonChoiceOption": true
},
{
"id": "option1",
"src": "surprised_remy.jpg",
"position": "left",
"feedbackAudio": "negativefeedback",
"displayDelayMs": 3500
},
{
"id": "option2",
"src": "sad_remy.jpg",
"correct": true,
"position": "right",
"displayDelayMs": 3500
}
],
"baseDir": "https://www.mit.edu/~kimscott/placeholderstimuli/",
"autoProceed": false,
"doRecording": true,
"choiceRequired": true,
"parentTextBlock": {
"text": "Some explanatory text for parents",
"title": "For parents"
},
"correctChoiceRequired": true,
"canMakeChoiceBeforeAudioFinished": false
},
"image-8": {
"kind": "exp-lookit-images-audio",
"audio": "matchzenna",
"images": [
{
"id": "cue",
"src": "sad_zenna.jpg",
"position": "center",
"nonChoiceOption": true
},
{
"id": "option1",
"src": "surprised_remy.jpg",
"position": "left",
"feedbackText": "Try again! Remy looks surprised in that picture. Can you find the picture where he looks sad, like Zenna?",
"displayDelayMs": 3500
},
{
"id": "option2",
"src": "sad_remy.jpg",
"correct": true,
"position": "right",
"feedbackText": "Great job! Remy is sad in that picture, just like Zenna is sad.",
"displayDelayMs": 3500
}
],
"baseDir": "https://www.mit.edu/~kimscott/placeholderstimuli/",
"autoProceed": false,
"doRecording": true,
"choiceRequired": true,
"parentTextBlock": {
"text": "Some explanatory text for parents",
"title": "For parents"
},
"correctChoiceRequired": true,
"canMakeChoiceBeforeAudioFinished": false
}
}
* ```
* @class Exp-lookit-images-audio
* @extends Exp-frame-base
* @uses Full-screen
* @uses Video-record
* @uses Expand-assets
*/
export default ExpFrameBaseComponent.extend(FullScreen, VideoRecord, ExpandAssets, {
type: 'exp-lookit-images-audio',
layout: layout,
displayFullscreen: true, // force fullscreen for all uses of this component
fullScreenElementId: 'experiment-player', // which element to send fullscreen
fsButtonID: 'fsButton', // ID of button to go to fullscreen
startedTrial: false, // whether we've started playing audio yet
_finishing: false, // whether we're currently trying to move to next trial (to prevent overlapping calls)
// Override setting in VideoRecord mixin - only use camera if doing recording
doUseCamera: Ember.computed.alias('doRecording'),
startRecordingAutomatically: Ember.computed.alias('doRecording'),
pageTimer: null,
progressTimer: null,
nextButtonDisableTimer: null,
showChoiceTimer: null,
imageDisplayTimers: null,
autoProceed: false,
finishedAllAudio: false,
minDurationAchieved: false,
choiceRequired: false,
correctChoiceRequired: false,
correctImageSelected: false,
canMakeChoice: true,
showingFeedbackDialog: false,
selectedImage: null,
audioPlayed: null,
noParentText: false,
assetsToExpand: {
'audio': ['audio', 'images/feedbackAudio'],
'video': [],
'image': ['images/src']
},
frameSchemaProperties: {
/**
* Whether to do webcam recording (will wait for webcam
* connection before starting audio or showing images if so)
*
* @property {Boolean} doRecording
*/
doRecording: {
type: 'boolean',
description: 'Whether to do webcam recording (will wait for webcam connection before starting audio if so'
},
/**
* Whether to proceed automatically when all conditions are met, vs. enabling
* next button at that point. If true: the next, previous, and replay buttons are
* hidden, and the frame auto-advances after ALL of the following happen
* (a) the audio segment (if any) completes
* (b) the durationSeconds (if any) is achieved
* (c) a choice is made (if required)
* (d) that choice is correct (if required)
* (e) the choice audio (if any) completes
* (f) the choice text (if any) is dismissed
* If false: the next, previous, and replay buttons (as applicable) are displayed.
* It becomes possible to press 'next' only once the conditions above are met.
*
* @property {Boolean} autoProceed
* @default false
*/
autoProceed: {
type: 'boolean',
description: 'Whether to proceed automatically after audio (and hide replay/next buttons)',
default: false
},
/**
* Minimum duration of frame in seconds. If set, then it will only
* be possible to proceed to the next frame after both the audio completes AND
* this duration is acheived.
*
* @property {Number} durationSeconds
* @default 0
*/
durationSeconds: {
type: 'number',
description: 'Minimum duration of frame in seconds',
minimum: 0,
default: 0
},
/**
* [Only used if durationSeconds set] Whether to
* show a progress bar based on durationSeconds in the parent text area.
*
* @property {Boolean} showProgressBar
* @default false
*/
showProgressBar: {
type: 'boolean',
description: 'Whether to show a progress bar based on durationSeconds',
default: false
},
/**
* [Only used if not autoProceed] Whether to
* show a previous button to allow the participant to go to the previous frame
*
* @property {Boolean} showPreviousButton
* @default true
*/
showPreviousButton: {
type: 'boolean',
default: true,
description: 'Whether to show a previous button (used only if showing Next button)'
},
/**
* [Only used if not autoProceed AND if there is audio] Whether to
* show a replay button to allow the participant to replay the audio
*
* @property {Boolean} showReplayButton
* @default false
*/
showReplayButton: {
type: 'boolean',
default: true,
description: 'Whether to show a replay button (used only if showing Next button)'
},
/**
* Whether to have the image display area take up the whole screen if possible.
* This will only apply if (a) there is no parent text and (b) there are no
* control buttons (next, previous, replay) because the frame auto-proceeds.
*
* @property {Boolean} maximizeDisplay
* @default false
*/
maximizeDisplay: {
type: 'boolean',
default: false,
description: 'Whether to have the image display area take up the whole screen if possible'
},
/**
* Audio file to play at the start of this frame.
* This can either be an array of {src: 'url', type: 'MIMEtype'} objects, e.g.
* listing equivalent .mp3 and .ogg files, or can be a single string `filename`
* which will be expanded based on `baseDir` and `audioTypes` values (see `audioTypes`).
*
* @property {Object[]} audio
* @default []
*
*/
audio: {
anyOf: audioAssetOptions,
description: 'Audio to play as this frame begins',
default: []
},
/**
* Text block to display to parent. (Each field is optional)
*
* @property {Object} parentTextBlock
* @param {String} title title to display
* @param {String} text paragraph of text
* @param {Object} css object specifying any css properties
* to apply to this section, and their values - e.g.
* {'color': 'gray', 'font-size': 'large'}
*/
parentTextBlock: {
type: 'object',
properties: {
title: {
type: 'string'
},
text: {
type: 'string'
},
css: {
type: 'object',
default: {}
}
},
default: {}
},
/**
* Array of images to display and information about their placement. For each
* image, you need to specify `src` (image name/URL) and placement (either by
* providing left/width/top values, or by using a `position` preset).
*
* Everything else is optional! This is where you would say that an image should
* be shown at a delay
*
* @property {Object[]} images
* @param {String} id unique ID for this image
* @param {String} src URL of image source. This can be a full
* URL, or relative to baseDir (see baseDir).
* @param {String} alt alt-text for image in case it doesn't load and for
* screen readers
* @param {Number} left left margin, as percentage of story area width. If not provided,
* the image is centered horizontally.
* @param {Number} width image width, as percentage of story area width. Note:
* in general only provide one of width and height; the other will be adjusted to
* preserve the image aspect ratio.
* @param {Number} top top margin, as percentage of story area height. If not provided,
* the image is centered vertically.
* @param {Number} height image height, as percentage of story area height. Note:
* in general only provide one of width and height; the other will be adjusted to
* preserve the image aspect ratio.
* @param {String} position one of 'left', 'center', 'right', 'fill' to use presets
* that place the image in approximately the left, center, or right third of
* the screen or to fill the screen as much as possible.
* This overrides left/width/top values if given.
* @param {Boolean} nonChoiceOption [Only used if `choiceRequired` is true]
* whether this should be treated as a non-clickable option (e.g., this is
* a picture of a girl, and the child needs to choose whether the girl has a
* DOG or a CAT)
* @param {Number} displayDelayMs Delay at which to show the image after trial
* start (timing will be relative to any audio or to start of trial if no
* audio). Optional; default is to show images immediately.
* @param {Object[]} feedbackAudio [Only used if `choiceRequired` is true]
* Audio to play upon clicking this image. This can either be an array of
* {src: 'url', type: 'MIMEtype'} objects, e.g. listing equivalent .mp3 and
* .ogg files, or can be a single string `filename` which will be expanded
* based on `baseDir` and `audioTypes` values (see `audioTypes`).
* @param {String} feedbackText [Only used if `choiceRequired` is true] Text
* to display in a dialogue window upon clicking the image.
*/
images: {
type: 'array',
items: {
type: 'object',
properties: {
'id': {
type: 'string'
},
'src': {
anyOf: imageAssetOptions
},
'alt': {
type: 'string'
},
'left': {
type: 'number'
},
'width': {
type: 'number'
},
'top': {
type: 'number'
},
'height': {
type: 'number'
},
'position': {
type: 'string',
enum: ['left', 'center', 'right', 'fill']
},
'nonChoiceOption': {
type: 'boolean'
},
'displayDelayMs': {
type: 'number',
minimum: 0
},
'correct': {
type: 'boolean'
},
'feedbackAudio': {
anyOf: audioAssetOptions
},
'feedbackText': {
type: 'string'
}
}
}
},
/**
* Color of background. See https://developer.mozilla.org/en-US/docs/Web/CSS/color_value
* for acceptable syntax: can use color names ('blue', 'red', 'green', etc.), or
* rgb hex values (e.g. '#800080' - include the '#')
*
* @property {String} backgroundColor
* @default 'black'
*/
backgroundColor: {
type: 'string',
description: 'Color of background',
default: 'black'
},
/**
* Color of area where images are shown, if different from overall background.
* Defaults to backgroundColor if one is provided. See
* https://developer.mozilla.org/en-US/docs/Web/CSS/color_value
* for acceptable syntax: can use color names ('blue', 'red', 'green', etc.), or
* rgb hex values (e.g. '#800080' - include the '#')
*
* @property {String} pageColor
* @default 'white'
*/
pageColor: {
type: 'string',
description: 'Color of image area',
default: 'white'
},
/**
* Whether this is a frame where the user needs to click to select one of the
* images before proceeding.
*
* @property {Boolean} choiceRequired
* @default false
*/
choiceRequired: {
type: 'boolean',
description: 'Whether this is a frame where the user needs to click to select one of the images before proceeding',
default: false
},
/**
* [Only used if `choiceRequired` is true] Whether the participant has to select
* one of the *correct* images before proceeding.
*
* @property {Boolean} correctChoiceRequired
* @default false
*/
correctChoiceRequired: {
type: 'boolean',
description: 'Whether this is a frame where the user needs to click a correct image before proceeding',
default: false
},
/**
* Whether the participant can make a choice before audio finishes. (Only relevant
* if `choiceRequired` is true.)
*
* @property {Boolean} canMakeChoiceBeforeAudioFinished
* @default false
*/
canMakeChoiceBeforeAudioFinished: {
type: 'boolean',
description: 'Whether the participant can select an option before audio finishes',
default: false
},
/**
* Array representing times when particular images should be highlighted. Each
* element of the array should be of the form {'range': [3.64, 7.83], 'imageId': 'myImageId'}.
* The two `range` values are the start and end times of the highlight in seconds,
* relative to the audio played. The `imageId` corresponds to the `id` of an
* element of `images`.
*
* Highlights can overlap in time. Any that go longer than the audio will just
* be ignored/cut off.
*
* One strategy for generating a bunch of highlights for a longer story is to
* annotate using Audacity and export the labels to get the range values.
*
* @property {Object[]} highlights
* @param {Array} range [startTimeInSeconds, endTimeInSeconds], e.g. [3.64, 7.83]
* @param {String} imageId ID of the image to highlight, corresponding to the `id` field of the element of `images` to highlight
*/
highlights: {
type: 'array',
items: {
type: 'object',
properties: {
'range': {
type: 'array',
items: {
type: 'number',
minimum: 0
}
},
'imageId': {
'type': 'string'
}
}
},
default: []
}
},
meta: {
data: {
type: 'object',
properties: {
videoId: {
type: 'string'
},
videoList: {
type: 'list'
},
/**
* Array of images used in this frame [same as passed to this frame, but
* may reflect random assignment for this particular participant]
* @attribute {Array} images
*/
images: {
type: 'array'
},
/**
* ID of image selected at time of proceeding
* @attribute {String} selectedImage
*/
selectedImage: {
type: 'string'
},
/**
* Whether image selected at time of proceeding is marked as correct
* @attribute {Boolean} correctImageSelected
*/
correctImageSelected: {
type: 'Boolean'
},
/**
* Source URL of audio played, if any. If multiple sources provided (e.g.
* mp4 and ogg versions) just the first is stored.
* @attribute {String} audioPlayed
*/
audioPlayed: {
type: 'string'
}
},
}
},
// Override to do a bit extra when starting recording
onRecordingStarted() {
this.startTrial();
},
// Override to do a bit extra when starting session recorder
onSessionRecordingStarted() {
this.startTrial();
$('#waitForVideo').hide();
},
updateCharacterHighlighting() {
var highlights = this.get('highlights');
if (highlights.length) {
var t = $('#player-audio')[0].currentTime;
$('.story-image-container img.story-image').removeClass('narration-highlight');
// var _this = this;
highlights.forEach(function (h) {
if (t > h.range[0] && t < h.range[1]) {
var $element = $('#' + h.imageId + ' img.story-image')
$element.addClass('narration-highlight');
// _this.wiggle($element);
}
});
}
},
// Move an image up and down until the isSpeaking class is removed.
// Yes, this could much more naturally be done by using a CSS animation property
// on isSpeaking, but despite animations getting applied properly to the element,
// I haven't been able to get that working - because of the possibility of ember-
// specific problems here, I'm going with something that *works* even if it's less
// elegent.
// wiggle($element) {
// var _this = this;
// var $parent = $element.parent();
// if ($element.hasClass('narration-highlight')) {
// $parent.animate({'margin-bottom': '.1%', 'margin-top': '-.1%'}, 150, function() {
// if ($element.hasClass('narration-highlight')) {
// $parent.animate({'margin-bottom': '0%', 'margin-top': '0%'}, 150, function() {
// _this.wiggle($element);
// });
// }
// });
// }
// },
replay() {
// pause any current audio, and set times to 0
$('audio').each(function() {
this.pause();
this.currentTime = 0;
});
/**
* When main audio segment is replayed
*
* @event replayAudio
*/
this.send('setTimeEvent', 'replayAudio');
// restart audio
$(`.story-image-container`).hide();
this.showImages();
this.playAudio();
},
finish() {
if (!this.get('_finishing')) {
this.set('_finishing', true);
var _this = this;
/**
* Trial is complete and attempting to move to next frame; may wait for recording
* to catch up before proceeding.
*
* @event trialComplete
*/
this.send('setTimeEvent', 'trialComplete');
if (this.get('doRecording')) {
$('#nextbutton').text('Sending recording...');
$('#nextbutton').prop('disabled', true);
this.set('nextButtonDisableTimer', window.setTimeout(function () {
$('#nextbutton').prop('disabled', false);
}, 5000));
this.stopRecorder().then(() => {
_this.set('stoppedRecording', true);
_this.send('next');
}, () => {
_this.send('next');
});
} else {
_this.send('next');
}
}
},
finishedAudio() {
/**
* When main audio segment finishes playing
*
* @event finishAudio
*/
this.send('setTimeEvent', 'finishAudio');
this.set('finishedAllAudio', true);
this.set('canMakeChoice', true);
this.checkAndEnableProceed();
},
checkAndEnableProceed() {
let ready = this.get('minDurationAchieved') &&
this.get('finishedAllAudio') &&
!this.get('showingFeedbackDialog') &&
(!this.get('choiceRequired') || (this.get('selectedImage') && (this.get('correctImageSelected') || !this.get('correctChoiceRequired'))));
if (ready) {
this.readyToFinish();
}
return ready;
},
readyToFinish() {
if (this.get('autoProceed')) {
this.send('finish');
} else {
$('#nextbutton').prop('disabled', false);
}
},
startTrial() {
this.set('startedTrial', true);
if (this.get('durationSeconds') && this.get('durationSeconds') > 0) {
let _this = this;
/**
* Timer for set-duration trial begins
*
* @event startTimer
*/
this.send('setTimeEvent', 'startTimer');
this.set('pageTimer', window.setTimeout(function() {
/**
* Timer for set-duration trial ends
*
* @event endTimer
*/
_this.send('setTimeEvent', 'endTimer');
_this.set('minDurationAchieved', true);
_this.checkAndEnableProceed();
}, _this.get('durationSeconds') * 1000));
if (this.get('showProgressBar')) {
let timerStart = new Date().getTime();
let durationSeconds = _this.get('durationSeconds');
this.set('progressTimer', window.setInterval(function() {
let now = new Date().getTime();
var prctDone = (now - timerStart) / (durationSeconds * 10);
$('.progress-bar').css('width', prctDone + '%');
}, 100));
}
} else {
this.set('minDurationAchieved', true);
}
this.playAudio();
this.showImages();
},
playAudio() {
// Start audio if there is any
var _this = this;
if ($('#player-audio source').length) {
$('#player-audio')[0].play().then(() => {
/**
* When main audio segment starts playing
*
* @event startAudio
*/
_this.send('setTimeEvent', 'startAudio');
}, () => {
/**
* When main audio cannot be started. In this case we treat it as if
* the audio was completed (for purposes of allowing participant to
* proceed)
*
* @event failedToStartAudio
*/
_this.send('setTimeEvent', 'failedToStartAudio');
_this.finishedAudio();
});
} else { // Otherwise treat as if completed
this.finishedAudio();
}
},
showImages() {
/**
* When images are displayed to participant (for images without any delay added)
*
* @event displayAllImages
*/
this.send('setTimeEvent', 'displayImages');
var _this = this;
$.each(this.get('images_parsed'), function(idx, image) {
if (image.hasOwnProperty('displayDelayMs')) {
var thisTimeout = window.setTimeout(function() {
/**
* When a specific image is shown at a delay.
*
* @event displayImage
* @param {String} imageId
*/
_this.send('setTimeEvent', 'displayImage', {
imageId: image.id
});
$(`.story-image-container#${image.id}`).show();
}, image.displayDelayMs);
if (_this.get('imageDisplayTimers')) {
_this.get('imageDisplayTimers').push(thisTimeout);
} else {
_this.set('imageDisplayTimers', [thisTimeout]);
}
} else {
$(`.story-image-container#${image.id}`).show();
}
});
},
clickImage(imageId, nonChoiceOption, correct, feedbackText) {
// If this is a choice frame and a valid choice and we're allowed to make a choice yet...
if (this.get('choiceRequired') && !nonChoiceOption && this.get('canMakeChoice') && !this.get('showingFeedbackDialog')) {
this.set('finishedAllAudio', true); // Treat as if audio is finished in case making choice before audio finishes - otherwise we never satisfy that criterion
/**
* When one of the image options is clicked during a choice frame
*
* @event clickImage
* @param {String} imageId ID of the image selected
* @param {Boolean} correct whether this image is marked as correct
*/
this.send('setTimeEvent', 'clickImage', {
imageId: imageId,
correct: correct
});
// Highlight the selected image and store it
$('.story-image-container img').removeClass('highlight');
$('#' + imageId + ' img').addClass('highlight');
this.set('selectedImage', imageId);
this.set('correctImageSelected', correct);
if (this.get('correctChoiceRequired') && !correct) {
$('#nextbutton').prop('disabled', true);
}
var noFeedback = true; // Track whether we're giving some form of feedback
// vs. allowing immediate proceeding
var _this = this;
// Play any feedback audio if available
if ($(`#${imageId}.story-image-container audio source`).length) {
noFeedback = false;
// If there's audio associated with this choice,
$('audio').each(function() { // pause any other audio
this.pause();
this.currentTime = 0;
});
$(`#${imageId}.story-image-container audio`)[0].play().then(() => {
/**
* When image/feedback audio is started
*
* @event startImageAudio
* @param {String} imageId
*/
_this.send('setTimeEvent', 'startImageAudio', {
imageId: imageId
});
}, () => {
/**
* When image/feedback audio cannot be started. In this case we treat it as if
* the audio was completed (for purposes of allowing participant to
* proceed)
*
* @event failedToStartImageAudio
* @param {String} imageId
*/
_this.send('setTimeEvent', 'failedToStartImageAudio', {
imageId: imageId
});
_this.endFeedbackAudio(imageId, correct);
});
}
// Also display any feedback text if available
if (feedbackText) {
noFeedback = false;
this.set('showingFeedbackDialog', true);
$(`.${imageId}.modal`).show();
}
// If we're giving feedback (audio or text), it will be possible to proceed
// once any audio finishes and any text is dismissed. Otherwise, just ensure
// that if we're moving on, the answer gets highlighted long enough for
// the participant to see it!
if (noFeedback) {
this.set('showChoiceTimer', window.setTimeout(function() {
window.clearInterval(_this.get('showChoiceTimer'));
_this.checkAndEnableProceed();
}, 150));
}
}
},
endFeedbackAudio(imageId, correct) { // eslint-disable-line no-unused-vars
this.checkAndEnableProceed(); // if correct, move on
},
actions: {
// During playing audio
updateCharacterHighlighting() {
this.updateCharacterHighlighting();
},
replay() {
this.replay();
},
finish() {
this.finish();
},
finishedAudio() {
this.finishedAudio();
},
clickImage(imageId, nonChoiceOption, correct, feedbackText) {
this.clickImage(imageId, nonChoiceOption, correct, feedbackText);
},
endFeedbackAudio(imageId, correct) {
this.endFeedbackAudio(imageId, correct);
},
hideFeedbackDialog(imageId) {
$(`.${imageId}.modal`).hide();
this.set('showingFeedbackDialog', false);
/**
* When the participant dismisses a feedback dialogue
*
* @event dismissFeedback
* @param {String} imageId
*/
this.send('setTimeEvent', 'dismissFeedback', {
imageId: imageId
});
this.checkAndEnableProceed();
}
},
// Supply image IDs if they're missing.
didReceiveAttrs() {
this._super(...arguments);
var N = 1;
var allImageIds = this.get('images') ? this.get('images').map(im => im.hasOwnProperty('id') ? im.id : null) : [];
$.each(this.get('images'), function(idx, image) {
if (!image.hasOwnProperty('id')) {
while (allImageIds.includes(`image_${N}`)) {
N++;
}
image.id = `image_${N}`;
}
N++;
});
this.set('canMakeChoice', !!this.get('canMakeChoiceBeforeAudioFinished'));
this.set('minDurationAchieved', !(this.get('durationSeconds') > 0));
this.set('showProgressBar', this.get('showProgressBar') && this.get('durationSeconds') > 0);
this.set('showReplayButton', this.get('showReplayButton') && this.get('audio').length);
// Store audio source
let audioSources = this.get('audio_parsed');
if (audioSources && audioSources.length) {
this.set('audioPlayed', audioSources[0].src);
}
},
didInsertElement() {
this._super(...arguments);
// Apply user-provided CSS to parent text block
if (Object.keys(this.get('parentTextBlock')).length) {
var parentTextBlock = this.get('parentTextBlock') || {};
var css = parentTextBlock.css || {};
$('#parenttext').css(css);
} else {
this.set('noParentText', true);
if (this.get('autoProceed')) {
this.set('noStoryControls', true);
}
}
// Apply user-provided CSS to images
$.each(this.get('images_parsed'), function(idx, image) {
if (!image.position) {
$('#' + image.id).css({'left': `${image.left}%`, 'width': `${image.width}%`, 'top': `${image.top}%`, 'height': `${image.height}%`});
}
});
// Apply background colors
if (isColor(this.get('backgroundColor'))) {
$('div.exp-lookit-image-audio').css('background-color', this.get('backgroundColor'));
} else {
console.warn('Invalid background color provided; not applying.');
}
if (isColor(this.get('pageColor'))) {
$('div.exp-lookit-image-audio div#image-area').css('background-color', this.get('pageColor'));
} else {
console.warn('Invalid page color provided; not applying.');
}
$('#nextbutton').prop('disabled', true);
// Begin trial!
this.checkAndEnableProceed();
},
// Once rendered, hide images and (if not recording) begin trial
didRender() {
if (!this.get('startedTrial')) { // don't re-hide/re-start upon e.g. rerender
$('.story-image-container').hide();
// If recording, trial will be started upon recording start. Otherwise...
if (!this.get('doRecording') && !this.get('startSessionRecording')) {
this.startTrial();
}
}
},
willDestroyElement() {
// Clear any timers that might be active
window.clearInterval(this.get('pageTimer'));
window.clearInterval(this.get('progressTimer'));
window.clearInterval(this.get('nextButtonDisableTimer'));
window.clearInterval(this.get('showChoiceTimer'));
$.each(this.get('imageDisplayTimers'), function(idx, timeout) {
window.clearInterval(timeout);
});
this._super(...arguments);
},
});