Show:

File: app/components/exp-lookit-images-audio/component.js

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);
    },


});