{% extends "macros.html" %}
{% include "macros/browser-detect.html" %}

{% block stylesheets %}
    {{ super() }}
    {% include "theme.html" %}

    {% for x in css %}
        <style>
            {{ x }}
        </style>
    {% endfor %}

    {% for x in css_links %}
        <link rel="stylesheet" href={{ x }}>
    {% endfor %}
{% endblock %}

{% block body %}
    {% block header %}
        <div class="header">
            {% if config.show_progress_bar %}
                <div class="progress">
                    <div id="timeline-progress-bar" class="progress-bar" role="progressbar"
                         aria-valuenow="{{ progress_percentage }}" aria-valuemin="0" aria-valuemax="100"
                         style="width:{{ progress_percentage }}%">{{ progress_percentage }}%
                    </div>
                </div>
            {% endif %}
        </div>
    {% endblock %}


    <div id="main-body" class="my-5 container" style="clear: both;">
        {% block main_body %}
            {{ media.media_container() }}
            {{ progress.trial_progress_display(trial_progress_display_config) }}
        {% endblock %}

        <span id="psynet-event-listener"></span>
    </div>

    {% block modals %}
        {% block comment_modal %}
            {% if config.get("leave_comments_on_every_page") %}
            <div id="comment-modal" class="modal">
                <div class="modal-content">
                    <div><span class="close">&times;</span></div>
                    <h5>{{ pgettext("timeline_comment", "Leave a comment") }}</h5>
                    <textarea id="comment-text" class="form-control" placeholder='{{ pgettext("timeline_comment", "Write a comment here") }}'></textarea>
                    <button type="button" class="btn btn-primary mt-2"
                            id="send-comment">{{ pgettext("timeline_comment", "Send comment") }}</button>
                </div>
            </div>
            {% endif %}
        {% endblock %}

        {% block alert_modal %}
            <div id="alert-modal" class="modal">
                <div class="modal-content">
                    <h3 id="alert-message"></h3>
                    <button type="button" style="max-width: 100px" class="btn btn-primary" id="alert-button">
                        {{ pgettext("alert_ok", "OK") }}
                    </button>
                </div>
            </div>
        {% endblock %}

        {% block resume_modal %}
            <div id="resume-modal" class="modal">
                <div class="modal-content">
                    <h3>{{ pgettext("timeline_resume", "Are you ready?") }}</h3>
                    <p>
                        {{ pgettext("timeline_resume", "Click the button when you're ready to continue the experiment.") }}
                    </p>
                    <p>
                        {{ pgettext("timeline_resume", "Note: if you see this message a lot, it might be because your browser has an incompatible extension installed (e.g. adblocker). Please try opening this page in an incognito window instead (you can just copy and paste the URL from the current page), and/or try manually disabling your extensions.") }}
                    </p>
                    <button type="button" style="max-width: 100px" class="btn btn-primary" id="resume-button">
                        {{ pgettext("timeline_resume", "Resume") }}
                    </button>
                </div>
            </div>
        {% endblock %}
    {% endblock %}

    {% block footer %}
        <style>
            #footer {
                background-color: rgb(49, 124, 246);
                color: white;
                margin: 0px;
                padding: 0px;
                line-height: 33px;
                border-style: none;
                {% if config.show_footer == false %} display: none; {% endif %}
            }

            #media-download-progress-bar {
                width: {{ initial_download_progress }}%;
                height: 6px;
                background-color: black;
            }

            .footer-text {
                margin: 0px;
            }

        </style>

        <nav id="footer" class="navbar fixed-bottom">
            <div id="media-download-progress-bar"></div>
                <div class="container py-2">
                    {% if config.show_reward %}
                        <div class="footer-text">
                            {{ pgettext("timeline", "Reward") }}:
                            <strong>
                                <span id="reward-details" style="display: none">
                                    {{ config.currency }}<span id="time-reward">{{ time_reward }}</span> (time) +
                                    {{ config.currency }}<span id="performance-reward">{{ performance_reward }}</span> (performance) =
                                </span>
                                <span class="fw-bold">
                                    {{ config.currency }}<span id="total-reward">{{ total_reward }}</span>
                                </span>
                            </strong>
                        </div>
                    {% endif %}

                    {% if config.get("leave_comments_on_every_page") %}
                        <button type="button" class="btn btn-light"
                                id="comment-button">{{ pgettext("timeline", "Comment") }}</button>
                    {% endif %}

                    {% if show_termination_button or (experiment.with_lucid_recruitment() and show_termination_button is none) %}
                        <button id="terminate-button" class="btn btn-danger">
                            {{ pgettext("timeline_problem", "Terminate the experiment early") }}
                        </button>
                    {% endif %}
                </div>
        </nav>
        <script>
            // Allows the user to switch the language of the experiment if this setting is enabled.
            $('#iso-language').on('change', function (x) {
                var $_GET = [];
                window.location.href.replace(/[?&]+([^=&]+)=([^&]*)/gi, function (a, name, value) {
                    $_GET[name] = value;
                });
                $.get("/set_locale_participant/{{ participant.id }}?locale=" + $(this).val(), function (data) {
                    location.reload();
                });
                $('#iso-language').val()
            })
        </script>
    {% endblock %}
{% endblock %}

{% block head %}
    {{ super() }}

    <script src="/static/scripts/jquery-3.7.1.min.js"></script>
    <script src="/static/scripts/JSZip/jszip.min.js"></script>

    {% for x in js_links %}
        <script src="{{ x }}"></script>
    {% endfor %}

    <script>
        {{ init_js_vars }}
        var psynet = (function () {
                /**
                 * @namespace
                 * @alias psynet
                 */

                var psynet = {
                    media: {},
                    page: {
                        prompt: {},
                        control: {}
                    },
                    utils: {},
                    comments: [],
                    var: {{ js_vars | tojson }}
                };

                psynet.utils.shallowCopy = function (x) {
                    return Object.assign({}, x);
                }

                psynet.utils.deepCopy = function (x) {
                    return JSON.parse(JSON.stringify(x));
                }

                // check if it is a function
                psynet.utils.isFunction = function (functionToCheck) {
                    return functionToCheck !== null &&
                        functionToCheck &&
                        {}.toString.call(functionToCheck) === '[object Function]';
                };

                // Checks which value in the haystack is closest to the needle
                psynet.utils.closest = function (needle, haystack) {
                    var bestValue;
                    var bestDist;
                    var bestI;
                    for (var i = 0; i < haystack.length; i++) {
                        var proposal = haystack[i];
                        var dist = Math.abs(proposal - needle);
                        if (bestDist == undefined || dist < bestDist) {
                            bestValue = proposal;
                            bestDist = dist;
                            bestI = i;
                        }
                    }
                    return {
                        value: bestValue,
                        dist: bestDist,
                        index: bestI
                    }
                };

                // check if it is a dictionary
                psynet.utils.isDict = function (dictToCheck) {
                    return (
                        typeof dictToCheck === 'object' &&
                        dictToCheck !== null &&
                        !(dictToCheck instanceof Array) &&
                        !(dictToCheck instanceof Date)
                    );
                };

                // check if a key exists in an array
                psynet.utils.keyExistsInArray = function (key, arr) {
                    return (arr.indexOf(key) > -1);
                };

                // compute a mean; this is not build-in into Javascript XD
                psynet.utils.mean = function (numbers) {
                    var total = 0, i;
                    for (i = 0; i < numbers.length; i += 1) {
                        total += numbers[i];
                    }
                    return total / numbers.length;
                };

                psynet.removeBeforeUnloadEventListener = function () {
                    window.removeEventListener("beforeunload", beforeunloadFunction);
                };

                psynet.waitForEventListener = async function (target, type) {
                    await new Promise((resolve) => {
                        target.addEventListener(type, () => resolve(), {once: true});
                    });
                };

                class PsyNetError extends Error {
                    constructor(message) {
                        super(message);
                        this.name = "PsyNetError"; // (2)
                    }
                }

                psynet.log = {};

                psynet.log.max = 100;
                psynet.log.counter = 0;

                psynet.log.generic = function (msg, level) {
                    if (level !== "error" && level !== "warning" && level !== "info" && level !== "debug") {
                        throw new Error("Invalid log level: " + level);
                    }

                    let time = new Date();
                    console.log("LOG (" + level.toUpperCase() + ") at " + time.toLocaleTimeString() + ": " + msg);

                    if (level != "debug" && psynet.log.counter < psynet.log.max) {
                        psynet.log.counter += 1;
                        route = '/log/' + level + "/{{ unique_id }}";

                        dallinger.post(route, {message: msg});
                    }
                };

                psynet.log.error = function (msg) {
                    var msgWithBrowserInfo = msg + "\n    " + "Platform: " + platform.toString();
                    psynet.log.generic(msgWithBrowserInfo, "error");
                };

                psynet.log.warning = function (msg) {
                    psynet.log.generic(msg, "warning")
                };

                psynet.log.warn = psynet.log.warning

                psynet.log.info = function (msg) {
                    psynet.log.generic(msg, "info")
                };

                psynet.log.debug = function (msg) {
                    psynet.log.generic(msg, "debug")
                }

                let Trial = function () {
                    let trial = {
                        state: null,
                        events: {},
                        timers: [],
                        eventLog: [],
                        inProgress: false,
                        stopping: false
                    };

                    trial.reset = function () {
                        trial.state = null;
                        trial.inProgress = false;
                        trial.startTime = null;
                        Object.values(trial.events).forEach((e) => e.reset());
                    };

                    let Event = function (id, spec) {
                        let event = {
                            id: id,
                            toBeTriggered: [],
                            triggerCondition: spec.trigger_condition,
                            delay: spec.delay,
                            once: spec.once,
                            handlers: [],
                            happened: false,
                            message: spec.message,
                            messageColor: spec.message_color,
                            js: spec.js
                        };
                        event.isTriggeredBy = spec.is_triggered_by.map((t) => {
                            let trigger = {
                                delay: t.delay,
                                triggeringEvent: t.triggering_event,
                                fired: false,
                                triggeredEvent: event
                            };
                            trigger.fire = function (info) {
                                trigger.fired = true;
                                trigger.triggeredEvent.checkTriggers(info);
                            };
                            return trigger
                        });

                        event.checkTriggers = function (info) {
                            let allTriggersFired = event.isTriggeredBy.every((trigger) => trigger.fired);
                            if ((event.triggerCondition == "any" || allTriggersFired) && (!(event.once && event.happened))) {
                                trial.setTimer(() => trial.registerEvent(id, {info: info}), event.delay * 1000);
                            }
                        };

                        event.propagateTriggers = function () {
                            event.isTriggeredBy.forEach((trigger) => {
                                let triggeringEvent = trial.events[trigger.triggeringEvent];
                                if (!triggeringEvent) {
                                    triggeringEvent = genericEvent(trigger.triggeringEvent);
                                    trial.events[trigger.triggeringEvent] = triggeringEvent;
                                }

                                triggeringEvent.toBeTriggered.push(trigger);
                            });
                        };

                        event.resetTriggers = function () {
                            event.isTriggeredBy.forEach((trigger) => {
                                trigger.fired = false;
                            });
                        };

                        event.reset = function () {
                            event.happened = false;
                            event.resetTriggers();
                        };

                        event.showMessage = function () {
                            if (event.message !== null) {
                                psynet.trialProgress.setText(event.message, event.messageColor);
                            }
                        };

                        event.runJS = function (info) {
                            if (event.js !== null) {
                                Function("info", unescape(event.js))(info);
                            }
                        };

                        event.runHandlers = async function (info) {
                            let handlers = event.handlers;

                            // Sort in order of decreasing priority
                            handlers.sort((a, b) => -(a.priority - b.priority));

                            for (const handler of handlers) {
                                trial.pendingEventHandlers.add(id);
                                await handler.func(info);
                                trial.pendingEventHandlers.remove(id);
                            }
                        };

                        event.hitTriggers = function (info) {
                            for (const target of event.toBeTriggered) {
                                trial.setTimer(() => target.fire(info), target.delay * 1000);
                            }
                        };

                        return event;
                    };

                    let genericEvent = function (id) {
                        // We use snake_case here to mirror the specification format
                        // that comes from the Python back-end
                        return Event(id, {
                            is_triggered_by: [],
                            trigger_condition: "all",
                            delay: 0.0,
                            once: false
                        });
                    };

                    trial.initEvents = function () {
                        let rawEvents = {{ events | tojson }};
                        Object.entries(rawEvents).forEach(([id, spec]) => trial.events[id] = Event(id, spec));
                        Object.values(trial.events).forEach((event) => event.propagateTriggers());
                    };

                    trial.initEvents()

                    trial.setTimer = function (handler, timeout) {
                        trial.timers.push(setTimeout(handler, timeout));
                    };

                    trial.clearTimers = function () {
                        trial.timers.forEach(timer => clearTimeout(timer));
                    };

                    trial.pendingEventHandlers = (() => {
                        let data = {};
                        return {
                            data: data,

                            add: function (id) {
                                let time = new Date();
                                data[id] = {
                                    timeStarted: time
                                }
                            },

                            remove: function (id) {
                                delete data[id];
                            },

                            waitFor: async function (providedOptions) {
                                let options = {
                                    pollInterval: 0.005,
                                    timeOut: 10,
                                    ignore: []
                                };

                                Object.assign(options, providedOptions);

                                let timer = 0;

                                function queueIsEmpty() {
                                    let pendingHandlers = Object.keys(data);
                                    let filteredHandlers = pendingHandlers.filter((id) => !options.ignore.includes(id));
                                    return filteredHandlers.length == 0;
                                }

                                function checkQueue() {
                                    return new Promise((resolve, reject) => {
                                        let poller = setInterval(() => {
                                            if (queueIsEmpty()) {
                                                clearInterval(poller);
                                                resolve();
                                            } else {
                                                timer += options.pollInterval;
                                                if (timer >= options.timeOut) {
                                                    reject();
                                                    clearInterval(poller);
                                                    throw new Error("Timed out when waiting for event handlers to complete.");
                                                }
                                            }
                                        }, options.pollInterval);
                                    });
                                }

                                await checkQueue();
                            }
                        };
                    })();

                    trial.logEvent = function (id, info) {
                        let time = new Date();
                        trial.eventLog.push({
                            eventType: id,
                            localTime: time,
                            info: info
                        });
                        psynet.log.debug(id);
                    }

                    trial.registerEvent = async function (id, providedOptions) {
                        let options = {
                            info: null,
                            once: false
                        };
                        Object.assign(options, providedOptions);

                        let event = trial.events[id];
                        if (event !== undefined && event.happened && options.once) {
                            return;
                        }

                        trial.state = id;
                        trial.logEvent(id, options.info);

                        if (event !== undefined) {
                            event.happened = true;
                            event.showMessage();
                            event.runJS(options.info);
                            await event.runHandlers(options.info);
                            event.hitTriggers(options.info);
                        }
                    };

                    trial.onEvent = function (id, handler, providedOptions) {
                        if (typeof handler !== "function") {
                            throw new Error("Handler must be a function in trial.onEvent");
                        }
                        // Higher priority values are executed first
                        let options = {
                            priority: 0.0
                        };
                        Object.assign(options, providedOptions);

                        let event = trial.events[id]
                        if (event === undefined) {
                            event = genericEvent(id);
                            trial.events[id] = event;
                        }
                        event.handlers.push({
                            func: handler,
                            priority: options.priority
                        });
                    };

                    trial.getGlobalTime = function () {
                        return psynet.media.audioContext.currentTime;
                    };

                    trial.listEvents = function () {
                        return Object.keys(trial.events);
                    };

                    trial.init = async function () {
                        // Should only be called once, on page load
                        await trial.registerEvent("trialConstruct");
                        $("#buttonStart").attr("disabled", false);
                    };

                    trial.stop = async function () {
                        /**
                         * Can be called manually to stop the trial.
                         * Is idempotent (you can call it multiple times
                         * with no bad side effects).
                         */
                        if (trial.inProgress && !trial.stopping) {
                            trial.stopping = true;
                            trial.clearTimers();
                            trial.inProgress = false;
                            await this.pendingEventHandlers.waitFor();
                            await trial.registerEvent("trialStop");
                            trial.reset();
                            trial.stopping = false;
                        }
                    };

                    trial.restart = async function (providedOptions) {
                        // Can be called manually at an arbitrary point to restart the trial
                        let options = {
                            from: "trialPrepare"
                        };
                        Object.assign(options, providedOptions);

                        await trial.stop();
                        trial.reset();
                        trial.inProgress = true;
                        await trial.registerEvent(options.from);
                    }

                    return trial;
                }

                psynet.trial = Trial();

                psynet.trial.onEvent("trialConstruct", async function () {
                    await psynet.media.init();
                    $(".wait-for-media-load").removeAttr("disabled");
                });

                psynet.trial.onEvent("trialPrepare", function () {
                    psynet.trial.inProgress = true;
                });

                psynet.trial.onEvent("trialFinished", function () {
                    psynet.trial.inProgress = false;
                });

                psynet.trial.onEvent("trialStopped", function () {
                    psynet.trial.inProgress = false;
                });

                psynet.submit = {
                    // .sd-navigation__complete-btn is the complete button in SurveyJS
                    enable: () => $(".submit, .sd-navigation__complete-btn").removeAttr("disabled"),
                    disable: () => $(".submit, .sd-navigation__complete-btn").attr("disabled", "disabled")
                };

                psynet.response = {
                    staged: {
                        rawAnswer: null,
                        metadata: {},
                        blobs: {}
                    },
                    enable: () => $(".response").removeAttr("disabled"),
                    disable: () => $(".response").attr("disabled", "disabled")
                };

                psynet.trial.onEvent("responseEnable", psynet.response.enable);
                psynet.trial.onEvent("submitEnable", psynet.submit.enable);

                psynet.media.types = ["audio", "image", "html", "video"];
                psynet.media.data = {};

                psynet.media.sounds = [];

                psynet.media.loaded = false;

                psynet.media.downloadProgress = {
                    byFile: {}
                };

                psynet.media.types.forEach(function (mediaType) {
                    psynet.media.downloadProgress.byFile[mediaType] = {};
                });

                psynet.media.downloadProgress.set = function (mediaType, fileId, value) {
                    psynet.media.downloadProgress.byFile[mediaType][fileId] = value;
                    psynet.media.downloadProgress.updateDisplay();
                };

                psynet.media.downloadProgress.getTotal = function () {
                    let res = [];
                    Object
                        .values(psynet.media.downloadProgress.byFile)
                        .forEach(function (processes) {
                            Object.values(processes).forEach(function (i) {
                                res.push(i)
                            });
                        });
                    return psynet.utils.mean(res);
                };

                psynet.media.downloadProgress.bar = function () {
                    return document.getElementById("media-download-progress-bar")
                };

                psynet.media.downloadProgress.updateDisplay = function () {
                    let bar = psynet.media.downloadProgress.bar();
                    if (bar !== null) {
                        var progress = psynet.media.downloadProgress.getTotal();
                        bar.style.width = Math.round(progress) + "%";
                        if (progress === 100) {
                            bar.classList.remove("colorfadeanim");
                            // You could do something here once loading is complete,
                            // e.g. delete progress bar text
                        }
                    }
                };

                // The last thing we expect of the user is that the resources, that need to be loaded, are dumped as a json:
                // For example here, we request a batch file that contains three files and we request a single file
                // As we can see each file has a ID and a url where the file is stored
                {{ define_media_requests }}

                // psynet.media.requests = {
                //     "audio": {
                //         "batch": {
                //             "url": "file_concatenated.mp3",
                //             "ids": ["funk_game_loop", "honey_bee", "there_it_is"],
                //             "type": "batch"
                //         },
                //         "bier": "bier.wav"
                //     }
                // };

                psynet.media.stopAllAudio = function () {
                    if (typeof stop_all_tonejs_audio === "function") {
                        stop_all_tonejs_audio();
                    }
                    if (psynet.audio) {
                        Object.values(psynet.audio).forEach(function (x) {
                            x.stop();
                        });
                    }
                }

                psynet.media.getMicrophoneMetadataFromAudioStream = function (stream) {
                    var audioTracks = stream.getAudioTracks();
                    if (audioTracks.length === 0) {
                        psynet.log.error('No tracks found')
                        return {}
                    } else if (audioTracks.length > 1) {
                        psynet.log.warn(
                            'Expected exactly one audio track, but got ' + audioTracks.length + ' tracks. ' +
                            'Recording the first track.'
                        )
                    }

                    var audioTrack = audioTracks[0];
                    audioTrackSettings = audioTrack.getSettings();
                    return {
                        "microphone": {
                            label: audioTrack.label,
                            enabled: audioTrack.enabled,
                            muted: audioTrack.muted,
                            channelCount: audioTrackSettings.channelCount,
                            autoGainControl: audioTrackSettings.autoGainControl,
                            echoCancellation: audioTrackSettings.echoCancellation,
                            noiseSuppression: audioTrackSettings.noiseSuppression,
                            latency: audioTrackSettings.latency,
                            sampleRate: audioTrackSettings.sampleRate,
                            sampleSize: audioTrackSettings.sampleSize,
                        }
                    }
                }

                psynet.media.initAudioContext = function () {
                    let context = null;
                    if ("webkitAudioContext" in window) context = new webkitAudioContext();
                    if ("AudioContext" in window) context = new AudioContext();
                    if (!context) {
                        throw Error("ERROR: No AudioContext available. Try Chrome, Safari or Firefox Nightly.");
                    }
                    if (context.state == "suspended") {
                        context.resume();
                    }
                    psynet.media.audioContext = context;
                };

                let askUserToResume = function () {
                    return new Promise((resolve) => {
                        let resumeModal = $("#resume-modal");
                        let resumeButton = $("#resume-button");
                        resumeModal.css("display", "block");
                        resumeButton.click(async function () {
                            resumeModal.css("display", "none");
                            $("#js-psych").trigger("focus");
                            await psynet.media.init();
                            resolve();
                        });
                    });
                };

                psynet.media.init = async function () {
                    psynet.media.initAudioContext();

                    if (psynet.media.audioContext.state == "suspended") {
                        psynet.log.info(
                            "Audio context is suspended, and can only be resumed " +
                            "after a user interaction. Waiting for the participant to click 'Resume'."
                        );
                        await askUserToResume();
                        psynet.media.initAudioContext();
                    }
                    let requests = psynet.media.requests;
                    let mediaTypes = Object.keys(requests);
                    let bar = psynet.media.downloadProgress.bar();
                    bar.classList.add("colorfadeanim");
                    await Promise.all(
                        mediaTypes.map((x) => processRequests(x, requests[x]))
                    );
                    bar.classList.remove("colorfadeanim");
                };

                let initMediaType = function (mediaType) {
                    let x = {};
                    psynet.media.data[mediaType] = x;
                    psynet[mediaType] = x;
                };

                let initStimulus = function (id, mediaType) {
                    psynet.media.data[mediaType][id] = {
                        loaded: false
                    };
                };

                let processRequests = function (mediaType, requests) {
                    initMediaType(mediaType);
                    checkRequests(requests);

                    return Promise.all(
                        Object.keys(requests).map(function (id) {
                            let value = requests[id];
                            if (isBatch(value)) {
                                return preloadBatch(id, value, mediaType);
                            } else {
                                let url = value;
                                return preloadStimulus(id, url, mediaType);
                            }
                        })
                    );
                };

                let isBatch = function (x) {
                    return psynet.utils.isDict(x)
                };

                async function unzipBatch(url) {
                    console.log("Zip file: " + url);
                    let batchdata = (
                        fetch(url)
                        .then(response => response.arrayBuffer())
                        .then(data => JSZip.loadAsync(data))
                        .then(zip => { return zip.file("stim.batch").async("uint8array") })
                        .then(data => {
                           return data;
                        })
                    )
                    return batchdata
                }

                let preloadBatch = async function (batchId, batch, mediaType) {
                    let url = batch.url;

                    let args = {
                        batchId: batchId,
                        stimulusIds: batch.ids,
                        mediaType: mediaType,
                        fileId: batchId
                    };
                    args.stimulusIds.forEach(function (id) {
                        initStimulus(id, mediaType);
                    });

                    let unzip = batch.unzip;
                    if (unzip) {
                        let data = await unzipBatch(batch.url);
                        return processMediaBatch[mediaType](data.buffer, args);
                    }
                    else {
                        return createRequest(url, mediaType, processMediaBatch[mediaType], args);
                    }

                };

                let preloadStimulus = function (stimulusId, url, mediaType) {
                    let args = {
                        stimulusId: stimulusId,
                        mediaType: mediaType,
                        fileId: stimulusId
                    };
                    initStimulus(stimulusId, mediaType);
                    return createRequest(url, mediaType, createMediaFromBuffer[mediaType], args);
                };

                function reportRequestError(url, status) {
                    let msg;
                    if (status === 404) {
                        msg = "Failed to load media asset at " + url + " (404, file not found).";
                    } else {
                        msg = "Failed to load media asset at " + url + " (error code = " + status + ")."
                    }
                    throw Error(msg);
                }

                async function createRequest(url, mediaType, callbackFunction, args) {
                    let request = new XMLHttpRequest();
                    request.open("GET", url, true);

                    if (mediaType == "audio" || mediaType == "html") {
                        request.responseType = "arraybuffer";
                    } else if (mediaType == "image" || mediaType == "video") {
                        request.responseType = "blob";
                    } else {
                        throw Error("Unsupported media type: " + mediaType);
                    }

                    // Make sure all batches are processed as arraybuffers
                    if (args["batchId"]) {
                        request.responseType = "arraybuffer";
                    }

                    request.onprogress = function (e) {
                        psynet.media.downloadProgress.set(args["mediaType"], args["fileId"], e.loaded * 100 / e.total);
                    };

                    return new Promise((resolve) => {
                        request.onload = async function () {
                            if (request.status === 200) {
                                await callbackFunction(request.response, args);
                                resolve();
                            } else {
                                reportRequestError(url, request.status);
                            }
                        };

                        request.send();
                    });
                }

                checkRequests = function (requests) {
                    // - Checks for missing URLs in batches.
                    // - Checks for duplicated IDs
                    let ids = [];

                    let log = function (id) {
                        var value = requests[id];
                        if (psynet.utils.isDict(value)) {
                            logBatch(value);
                        } else {
                            logStimulus(id);
                        }
                    };

                    let logStimulus = function (id) {
                        if (psynet.utils.keyExistsInArray(id, ids)) {
                            throw Error("The ID you specified `" + id + "` already exists! IDs must be unique!");
                        }
                        ids.push(id);
                    };

                    let logBatch = function (batch) {
                        if (!("url" in batch)) {
                            throw Error('Each batch object must contain a "url" attribute.');
                        }
                        if (!("ids" in batch)) {
                            throw Error('Each batch object must contain an "ids" attribute specifying its constituent stimuli.');
                        }
                        let stimulusIds = batch["ids"];
                        stimulusIds.forEach(logStimulus);
                    };

                    Object.keys(requests).forEach(log);
                };

                let createAudioStimulus = function (data, stimulusId, handler) {
                    psynet.log.debug("Decoding sound " + stimulusId + "...");

                    return new Promise((resolve) => {
                        psynet.media.audioContext.decodeAudioData(data, function (buffer) {
                            psynet.log.debug("Sound " + stimulusId + " decoded.");

                            let out = psynet.media.data.audio[stimulusId];
                            out.buffer = buffer;

                            out.play = function (providedOptions) {
                                let options = {
                                    fadeIn: 0.0,
                                    fadeOut: 0.0,
                                    startDelay: 0.01,
                                    gain: 1,
                                    loop: false,
                                    start: null,
                                    end: null
                                };

                                Object.assign(options, providedOptions);

                                const start = (options.start === null) ? 0.0 : options.start;
                                const duration = (options.end === null) ? undefined : options.end - options.start;

                                let sound = {
                                    source: psynet.media.audioContext.createBufferSource(),
                                    gainNode: psynet.media.audioContext.createGain(),
                                    startTime: psynet.media.audioContext.currentTime + options.startDelay,
                                    options: options,
                                    manuallyStopped: false,
                                    duration: (duration === undefined) ? buffer.duration : duration,
                                    stimulusId: stimulusId,
                                    onEnd: null
                                };

                                sound.source.buffer = buffer;

                                sound.source.connect(sound.gainNode);
                                sound.gainNode.connect(psynet.media.audioContext.destination);

                                sound.gainNode.gain.setValueAtTime(
                                    0.001,
                                    sound.startTime
                                );

                                sound.source.start(sound.startTime, start, duration);

                                if (options.gain > 1e-10) {
                                    sound.gainNode.gain.exponentialRampToValueAtTime(
                                        options.gain,
                                        psynet.media.audioContext.currentTime + options.startDelay + options.fadeIn
                                    );
                                }

                                let stopTimer;

                                if (sound.options.fadeOut > 0.0) {
                                    stopTimer = setTimeout(
                                        () => sound.stop({fadeOut: options.fadeOut, manual: false}),
                                        1000 * (options.startDelay + sound.duration - options.fadeOut)
                                    );
                                }

                                sound.stop = function (providedOptions) {
                                    let options = {
                                        fadeOut: sound.options.fadeOut,
                                        manual: true
                                    };

                                    Object.assign(options, providedOptions);

                                    clearTimeout(stopTimer);

                                    psynet.log.debug("Stopping audio " + sound.stimulusId + ".");

                                    sound.manuallyStopped = options.manual;

                                    let gainNow = sound.gainNode.gain.value;
                                    let timeNow = psynet.media.audioContext.currentTime;

                                    if (sound.gainNode.gain.value > 0.001) {
                                        sound.gainNode.gain.setValueAtTime(gainNow, timeNow);
                                        sound.gainNode.gain.exponentialRampToValueAtTime(0.001, timeNow + options.fadeOut);
                                    }

                                    return new Promise((resolve) => {
                                        setTimeout(() => {
                                            sound.source.stop();
                                            resolve();
                                        }, options.fadeOut * 1000);
                                    });
                                }

                                sound.source.addEventListener("ended", function () {
                                    clearTimeout(stopTimer);
                                    psynet.log.debug("Finished sound with ID = " + sound.stimulusId);
                                    psynet.media.sounds = psynet.media.sounds.filter(s => s !== sound);
                                    psynet.trial.registerEvent("audioFinished: " + sound.stimulusId);
                                    if (sound.options.loop && !sound.manuallyStopped) {
                                        psynet.log.debug("Looping sound with ID = " + out.stimulusId);
                                        out.play(sound.options);
                                    }
                                });

                                psynet.media.sounds.push(sound);
                                return sound;
                            };

                            out.stop = function (options) {
                                psynet.media.sounds.forEach(function (s) {
                                    if (s.stimulusId == stimulusId) {
                                        s.stop(options);
                                    }
                                });
                                return this
                            };

                            out.loaded = true;

                            resolve();
                        })
                    });
                };

                psynet.media.blobToArrayBuffer = async function (blob) {
                    let buffer = await new Response(blob).arrayBuffer();
                    return buffer;
                }

                // Used to add extra stimuli that aren't loaded as part of the initial media load
                // (needs refactoring in a general cross-media way)
                psynet.media.addExtraAudioStimulus = async function (buffer, stimulusId) {
                    initStimulus(stimulusId, "audio");
                    await createAudioStimulus(buffer, stimulusId, () => Promise.resolve());
                }

                let createMediaFromBuffer = {};
                createMediaFromBuffer.audio = function (data, args) {
                    psynet.media.downloadProgress.set(args.mediaType, args.fileId, 100);
                    return createAudioStimulus(data, args.stimulusId);
                };

                let getImageMetadataFromURL = function (url) {
                    return new Promise((resolve, reject) => {
                        let img = new Image();
                        img.onload = () => resolve({
                            width: img.width,
                            height: img.height
                        });
                        img.onerror = () => reject();
                        img.src = url;
                    });
                };

                function getMimeTypeFromArrayBuffer(arrayBuffer) {
                    const uint8arr = new Uint8Array(arrayBuffer)
                    var dec = new TextDecoder()
                    var text = dec.decode(uint8arr).toLowerCase().replace(' ', '')

                    if (text.includes('<svg')) {
                        return 'image/svg+xml'
                    } else {
                        return null;
                    }
                }

                createMediaFromBuffer.html = async function (data, args) {
                    let stimulusId = args["stimulusId"];
                    let mediaType = args["mediaType"];
                    let fileId = args["fileId"];

                    let player = document.getElementById(stimulusId);
                    if (player != null) {
                        psynet.log.debug(
                            "Associating " + mediaType + " '" + stimulusId
                            + "' with the player of the same name.'"
                        );
                        const uint8arr = new Uint8Array(data)
                        var dec = new TextDecoder()
                        player.innerHTML = dec.decode(uint8arr);
                     }
                    let out = psynet.media.data[mediaType][stimulusId];
                    out.player = player;
                    out.innerHTML = player.innerHTML;
                    // let metadata = await getSvgMetadataFromData(player.innerHTML);
                    // {#out.url = url;#}
                    // {#out.width = metadata.width;#}
                    // {#out.height = metadata.height;#}
                    // {#out.aspectRatio = out.width / out.height;#}
                    out.loaded = true;

                    psynet.media.downloadProgress.set(mediaType, fileId, 100);
                    return true;
                }

                createMediaFromBuffer.image = async function (data, args) {
                    let stimulusId = args["stimulusId"];
                    let mediaType = args["mediaType"];
                    let fileId = args["fileId"];

                    let player = document.getElementById(stimulusId);

                    if (player) {
                        psynet.log.debug(
                            "Associating " + mediaType + " '" + stimulusId
                            + "' with the player of the same name.'"
                        );
                    } else {
                        player = document.createElement("div");
                    }

                    if (!data.type) {
                        let mimeType = getMimeTypeFromArrayBuffer(data);
                        data = new Blob([data], { type: mimeType })
                    }

                    player.src = URL.createObjectURL(data);

                    let out = psynet.media.data[mediaType][stimulusId];
                    out.player = player;

                    let url = player.src;
                    let metadata = await getImageMetadataFromURL(url);
                    out.url = url;
                    out.width = metadata.width;
                    out.height = metadata.height;
                    out.aspectRatio = out.width / out.height;
                    out.loaded = true;

                    psynet.media.downloadProgress.set(mediaType, fileId, 100);
                    return true;
                }

                createMediaFromBuffer.video = async function (data, args) {
                    let stimulusId = args["stimulusId"];
                    let mediaType = args["mediaType"];
                    let fileId = args["fileId"];

                    let player = document.getElementById(stimulusId);
                    if (player != null) {
                        psynet.log.debug(
                            "Associating " + mediaType + " '" + stimulusId
                            + "' with the player of the same name.'"
                        );
                        data = new Blob([data])  // convert to blob
                        player.src = URL.createObjectURL(data);
                        player.classList.remove("loader");
                        player.load();
                        await psynet.waitForEventListener(player, "canplaythrough");
                    }

                    let out = psynet.media.data[mediaType][stimulusId];
                    out.player = player;
                    out.loaded = true;

                    psynet.media.downloadProgress.set(mediaType, fileId, 100);
                    return true;
                };

                let processMediaBatch = {};

                processMediaBatch.extract_stimuli = function (data, args) {
                    let stimulusIds = args["stimulusIds"];
                    let mediaType = args["mediaType"];
                    let fileId = args["fileId"];

                    function extractBuffer(src, start, length) {
                        // This function is used to find the start and end of each file
                        let dstU8 = new Uint8Array(length);
                        let srcU8 = new Uint8Array(src, start, length);
                        dstU8.set(srcU8);
                        return dstU8;
                    }

                    let numFiles = 0;
                    psynet.log.debug('Unpacking the ' + mediaType + ' batch "' + fileId + '".');
                    let bb = new DataView(data);
                    let offset = 0;
                    let promises = [];

                    while (offset < bb.byteLength) {
                        let stimulusId = stimulusIds[numFiles];
                        let length = bb.getUint32(offset, true);
                        offset += 4;
                        let media = extractBuffer(data, offset, length);
                        offset += length;
                        numFiles++;

                        if (numFiles > stimulusIds.length) {
                            throw Error(
                                "Too many stimuli found in batch file (" +
                                "expected " + stimulusIds.length +
                                ", got at least" + numFiles + ")."
                            );
                        }

                        args = {
                            stimulusId: stimulusId,
                            mediaType: mediaType,
                            fileId: fileId
                        };

                        // In contrast to audio, video, and image are not loaded into the DOM, so we need to create a player
                        if (mediaType === 'html') {
                            $('#media-container').prepend('<div style="display: none" class="html" id="' + stimulusId + '"></div>')
                        }

                        if (mediaType === 'image') {
                            $('#media-container').prepend('<img style="display: none" class="image" id="' + stimulusId + '"></img>')
                        }

                        else if (mediaType === 'video') {
                            $('#media-container').prepend('<video style="display: none" class="video" id="' + stimulusId + '"></video>')
                        }

                        promises.push(createMediaFromBuffer[mediaType](media.buffer, args));
                    }
                    if (numFiles < stimulusIds.length) {
                        throw Error(
                            "Too few stimuli found in batch file (" +
                            "expected " + stimulusIds.length +
                            ", got " + numFiles + ")."
                        );
                    }
                    return Promise.all(promises);
                };

                const SUPPORTED_MODALITIES = ['audio', 'image', 'html', 'video'];
                SUPPORTED_MODALITIES.forEach(function (modality){
                    processMediaBatch[modality] = function (data, args) { //, ids,) {
                        return processMediaBatch.extract_stimuli(data, args);
                    };
                })

                let initModal = function (modalId, buttonId, closeClass="close") {
                    let modal = document.getElementById(modalId);
                    let commentBtn = document.getElementById(buttonId);
                    let span = document.getElementsByClassName(closeClass)[0];

                    if (modal !== null && commentBtn !== null) {
                        commentBtn.onclick = function () {
                            modal.style.display = "block";
                        };

                        span.onclick = function () {
                            modal.style.display = "none";
                        };

                        window.onclick = function (event) {
                            if (event.target === modal) {
                                modal.style.display = "none";
                            }
                        }
                    }

                    $('#send-comment').click(function () {
                        var text = $("#comment-text");
                        var textValue = text.val();

                        if (textValue !== "") {
                            psynet.comments.push(textValue);
                            text.val("");
                            psynet.alert('{{ pgettext("timeline_problem", "Comment stored.") }}');
                        }
                    });
                };

                psynet.estimateDownloadSpeed = function () {
                    // Returns estimated download speed in megabits/second
                    // (at the time of writing, this is capped at 10).
                    let connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
                    if (connection) {
                        return connection.downlink
                    } else {
                        return null;
                    }

                }

                psynet.pageLoaded = false;

                let waitForPageLoad = function () {
                    return new Promise(resolve => {
                        window.addEventListener("load", () => resolve());
                    });
                };

                let waitForBrowserCheck = async function () {
                    return await browser.validate();
                };

                psynet.initPage = async function () {
                    console.log("Initialising page...");

                    psynet.registerErrorHandler();
                    initModal(modalId="comment-modal", buttonId="comment-button");

                    psynet.response.disable();
                    psynet.submit.disable();
                    $(".wait-for-media-load").attr("disabled", "disabled");

                    await waitForPageLoad();
                    let correctBrowser = await waitForBrowserCheck();

                    psynet.pageLoadTime = new Date();
                    psynet.pageLoaded = true;
                    psynet.assignmentId = "{{ participant.assignment_id }}";
                    psynet.participantId = {{ participant.id }};
                    psynet.uniqueId = "{{ participant.unique_id }}";

                    updateProgressAndReward();

                    if (correctBrowser) {
                        setTimeout(() => psynet.trial.init(), 25);
                    }
                };

                psynet.registerErrorHandler = function () {
                    window.onerror = function (msg, url, line, col, error) {
                        if (error) {
                            psynet.log.error(error.stack);
                        }
                    }
                };

                psynet.nextPagePending = false;

                psynet.submitResponse = async function (onRejection) {
                    let response;

                    try {
                        response = await psynet.compileResponse();
                    } catch (error) {
                        onRejection();
                        throw error;
                    }

                    await psynet.nextPage(
                        response.rawAnswer,
                        response.metadata,
                        response.blobs,
                        onRejection,
                    );
                }

                psynet.compileResponse = async function () {
                    let response = {};

                    if (typeof retrieveResponse == "undefined") {
                        if (psynet.stageResponse) {
                            await psynet.stageResponse();
                        }
                        response = psynet.response.staged;
                    } else {
                        response = retrieveResponse();
                    }

                    return response;
                }

                psynet.nextPage = async function (rawAnswer, metadata, blobs, onRejection) {
                    if (!psynet.pageLoaded) {
                        psynet.alert(
                            "{{ pgettext("timeline_problem", "Please wait for the page to load.") }} {{ pgettext("timeline_problem", "Try again in a couple of seconds.") }}"
                        )
                        return false;
                    }
                    if (psynet.nextPagePending) {
                        psynet.log.debug("Skipping nextPage request as nextPage is already pending.");
                        return false;
                    }
                    psynet.nextPagePending = true;
                    // rawAnswer, metadata, and blobs default to psynet.response.staged if they
                    // are not provided explicitly.
                    //
                    // Returns true if the answer passes validation checks.
                    if (rawAnswer === undefined) {
                        rawAnswer = psynet.response.staged.rawAnswer;
                    }
                    if (metadata === undefined) {
                        metadata = psynet.response.staged.metadata;
                    }
                    metadata.comments = psynet.comments
                    if (blobs === undefined) {
                        blobs = psynet.response.staged.blobs;
                    }
                    {% if experiment.with_lucid_recruitment() %}
                        psynet.removeBeforeUnloadEventListener();
                    {% endif %}
                    let passedValidation = await submitGenericResponse(rawAnswer, metadata, blobs, onSuccessResponse, onErrorResponse, onRejection);
                    if (!passedValidation) {
                        psynet.nextPagePending = false;
                    }
                    return passedValidation
                };

                psynet.alert = function (text) {
                    return new Promise((resolve) => {
                        let alertModal = $("#alert-modal");
                        let alertButton = $("#alert-button");
                        let alertText = $("#alert-message");
                        alertText.text(text);
                        alertModal.css("display", "block");
                        alertButton.click(async function () {
                            alertModal.css("display", "none");
                            resolve();
                        });
                    });
                };

                let onSuccessResponse = function (request, onRejection) {
                    let response = JSON.parse(request.response);
                    let passedValidation;
                    if (response.submission === "approved") {
                        psynet.log.debug("Response received successfully.");
                        let shouldRefreshPage = !(
                            response.page.attributes && psynet.page.attributes &&
                            response.page.attributes.session_id == psynet.page.attributes.session_id
                        );
                        if (shouldRefreshPage) {
                            window.location = "/timeline?unique_id=" + psynet.uniqueId;
                        } else {
                            psynet.page = response.page;
                            psynet.trial.registerEvent("pageUpdated");
                            psynet.nextPagePending = false;
                        }
                        passedValidation = true;
                    } else if (response.submission === "rejected") {
                        psynet.log.debug("Response rejected.");
                        psynet.alert(response.message);
                        psynet.response.enable();
                        psynet.submit.enable();
                        if (onRejection) {
                            onRejection(response);
                        }
                        passedValidation = false;
                    } else {
                        throw Error("Received a malformed response.");
                    }
                    return passedValidation
                };

                onPageUpdated = function (event) {
                    console.log("Dispatched 'onPageUpdated' event. Sending data to Unity:\nattributes:" + psynet.page.attributes + "\ncontents:" + psynet.page.contents);
                    unityInstance.SendMessage(
                        "PsynetObj",
                        "GetData",
                        JSON.stringify({
                            attributes: psynet.page.attributes,
                            contents: psynet.page.contents,
                        })
                    );
                };

                let onErrorResponse = function (request) {
                    dallinger.error({
                        "data": {
                            "participant_id": {{ participant.id }}
                        }
                    });
                };

                let addBlobs = function (formData, blobs) {
                    for (let [key, value] of Object.entries(blobs)) {
                        if (key === "json") {
                            throw Error("Blobs may not be named 'json'.");
                        }
                        formData.append(key, value);
                    }
                };

                let prepareJsonSubmission = function (rawAnswer, metadata) {
                    var currentTime = new Date();

                    var allMetadata = {
                        "time_taken": (currentTime - psynet.pageLoadTime) / 1000,
                        "platform": platform.toString(),
                        "download_speed_megabits_per_sec": psynet.estimateDownloadSpeed(),
                        "event_log": psynet.trial.eventLog
                    };

                    if (metadata !== undefined) {
                        for (var x in metadata) {
                            allMetadata[x] = metadata[x];
                        }
                    }

                    return JSON.stringify({
                        "participant_id": psynet.participantId,
                        "page_uuid": pageUuid,
                        "assignment_id": psynet.assignmentId,
                        "unique_id": psynet.uniqueId,
                        "raw_answer": rawAnswer,
                        "metadata": allMetadata
                    });
                };

                let submitGenericResponse = function (rawAnswer, metadata, blobs, onSuccessResponse, onErrorResponse, onRejection) {
                    // rawAnswer - an arbitrary Javascript object (not necessarily an Object) to be sent to JSON
                    // blobs - optional Object, each attribute should be a blob to upload.
                    //       - Note that 'json' is not a permitted name for an attribute
                    //
                    // Returns true if the answer passed validation checks, false otherwise.
                    $(" .response, .submit ").prop("disabled", true);

                    json = prepareJsonSubmission(rawAnswer, metadata);

                    var formData = new FormData();
                    formData.append("json", json);

                    if (blobs !== undefined) {
                        addBlobs(formData, blobs);
                    }

                    return new Promise((resolve) => {
                        let request = new XMLHttpRequest();
                        request.onreadystatechange = function () {
                            if (request.readyState === 4) {
                                let passedValidation;
                                if (request.status === 200) {
                                    psynet.log.debug("Response was successfully received.");
                                    passedValidation = onSuccessResponse(request, onRejection);
                                } else {
                                    psynet.log.debug("Something went wrong.");
                                    onErrorResponse(request);
                                    passedValidation = false;
                                }
                                resolve(passedValidation);
                            }
                        };
                        request.open("POST", "/response");
                        request.send(formData);
                    });
                };

                psynet.trialProgress = (() => {
                    let config = {{ trial_progress_display_config | tojson }};
                    let opacities = {light: 0.25, medium: 0.6, dark: 1.0};

                    let init = function () {
                        config.stages.forEach((stage) => stage.html = {});
                        config.stages.forEach((stage) => {
                            stage.html.past = addBarSegment(stage.color, opacities.dark);
                        });
                        config.stages.forEach((stage) => {
                            stage.html.future = addBarSegment(stage.color, opacities.light);
                        });
                        config.stages.forEach((stage) => {
                            stage.update = function (elapsed) {
                                let proportionOfStageComplete = bound((elapsed - stage.time[0]) / stage.duration, 0, 1);
                                let proportionOfBarComplete = (proportionOfStageComplete * stage.duration) / config.duration;
                                stage.html.past.setWidth(100 * proportionOfBarComplete);
                                let proportionOfStageIncomplete = 1 - proportionOfStageComplete;
                                let proportionOfBarIncomplete = (proportionOfStageIncomplete * stage.duration) / config.duration
                                stage.html.future.setWidth(100 * proportionOfBarIncomplete);
                                if (proportionOfStageComplete > 0) {
                                    if (stage.persistent || proportionOfStageComplete < 1) {
                                        activeStage = stage;
                                    } else {
                                        activeStage = null;
                                    }
                                }
                            };
                        });
                        setInterval(update, 5);
                    };

                    let bound = function (x, min, max) {
                        return Math.max(min, Math.min(x, max));
                    };

                    let addBarSegment = function (color, opacity) {
                        let bar = $("#trial-progress-bar");
                        let segment = $("<div/>", {
                            "class": "progress-bar trial-progress-bar-segment",
                            role: "progressbar",
                            style: "width: 0%; opacity: " + opacity + "; background-color: " + color + ";",
                            "aria-valuenow": "0",
                            "aria-valuemin": "0",
                            "aria-valuemax": "100"
                        });
                        bar.append(segment);
                        segment.setWidth = function (percent) {
                            segment.css("width", percent + "%");
                            segment.attr("aria-valuenow", percent);
                        };
                        return segment;
                    };

                    let activeStage = null;
                    let startTime = null;

                    let start = function () {
                        startTime = psynet.trial.getGlobalTime();
                    };

                    let stop = function () {
                        startTime = null;
                        activeStage = null;
                    };

                    let setText = function (content, color) {
                        if (content == "") {
                            resetText();
                        } else {
                            let text = $("#trial-progress-caption-contents");
                            text.text(content);
                            if (color) {
                                text.css("color", color);
                            }
                        }
                    };

                    let resetText = function () {
                        let text = $("#trial-progress-caption-contents");
                        text.html("&nbsp;");
                    };

                    let update = function () {
                        let elapsed = startTime !== null ? psynet.trial.getGlobalTime() - startTime : 0.0
                        config.stages.forEach((s) => s.update(elapsed));
                        if (config.stages.length > 0) {
                            if (activeStage) {
                                setText(activeStage.caption, activeStage.color);
                            } else {
                                resetText();
                            }
                        }
                    };

                    psynet.trial.onEvent("trialConstruct", init, {priority: -1000});
                    psynet.trial.onEvent(config.start, start);
                    psynet.trial.onEvent("trialStopped", stop);
                    psynet.trial.onEvent("pageUpdated", onPageUpdated);

                    return {
                        stages: config.stages,
                        setText: setText,
                        start: start,
                        stop: stop
                    };
                })();

                psynet.audio = psynet.media.data.audio;
                psynet.image = psynet.media.data.image;
                psynet.html = psynet.media.data.html;
                psynet.video = psynet.media.data.video;

                return psynet
            }()
        )

        let updateProgressAndReward = function () {
            if (psynet.participantId !== undefined) {
                $.get('/timeline/progress_and_reward', {'participantId': psynet.participantId})
                    .done(function (data) {
                        let progressPercentage = data['progressPercentage'];
                        let progressPercentageStr = progressPercentage + '%';
                        $('#timeline-progress-bar').text(progressPercentageStr);
                        $('#timeline-progress-bar').css("width", progressPercentageStr);
                        $('#timeline-progress-bar').attr("aria-valuenow", progressPercentage);

                        if (data['reward'] !== undefined) {
                            if (data['reward']['performance'].toFixed(2) > 0) {
                                $('#time-reward').text(data['reward']['time'].toFixed(2));
                                $('#performance-reward').text(data['reward']['performance'].toFixed(2));
                                $("#reward-details").show();
                            }
                            $('#total-reward').text(data['reward']['total'].toFixed(2));
                        }
                    });
            }
        }

        if (dynamicallyUpdateProgressBarAndReward) {
            setInterval(updateProgressAndReward, 1000);
        }

        function alertParticipantOpenedDevtools() {
            console.warn(
                'You have opened the developer tools. ' +
                'The experimenter is informed about this possible misconduct. ' +
                'You might be excluded from the experiment.'
            );
        }

        function writeToDbParticipantOpenedDevtools() {
            dallinger.post(
                "/participant_opened_devtools/{{ unique_id }}"
            );
        }

        function logParticipantOpenedDevtools() {
            psynet.log.warning('Participant ' + psynet.participantId + " opened the browser's developer tools.")
        }

        function isConsoleOpen() {
            let consoleOpen = false;
            let f = function () {
            }
            f.toString = function () {
                consoleOpen = true;
            }
            console.profile(f);
            console.profileEnd(f);
            return consoleOpen;
        }

        {% if config.check_participant_opened_devtools %}
            let warnedDevtools = false;
            setInterval(() => {
                if (!warnedDevtools && isConsoleOpen()) {
                    logParticipantOpenedDevtools();
                    writeToDbParticipantOpenedDevtools();
                    alertParticipantOpenedDevtools();
                    warnedDevtools = true;
                }
            }, 1000);
        {% endif %}
    </script>

{% endblock %}

{% block libs %}
    {{ super() }}
    <script src="{{ url_for('static', filename='scripts/platform.min.js') }}"></script>
    <script src="{{ url_for('static', filename='scripts/survey-jquery/survey-jquery.js') }}"></script>
{% endblock %}

{% block scripts %}
    {{ super() }}

    <script>
        psynet.initPage();
    </script>

    {% for x in scripts %}
        <script>
            {{ x }}
        </script>
    {% endfor %}

    {% if experiment.with_lucid_recruitment() %}
        <script>
            {
                // Terminate on 'page reload', 'window/tab close', and 'no focus' with timeouts and
                // polling frequency given in milliseconds.
                let NO_FOCUS_TIMEOUT = toMilliseconds({{ experiment.recruiter.no_focus_timeout_in_s }});
                let NO_FOCUS_TIMEOUT_REASON = "no-focus-timeout-";
                {% if aggressive_termination_on_no_focus %}
                    NO_FOCUS_TIMEOUT = toMilliseconds({{ experiment.recruiter.aggressive_no_focus_timeout_in_s }});
                    NO_FOCUS_TIMEOUT_REASON = "aggressive-" + NO_FOCUS_TIMEOUT_REASON;
                {% endif %}
                const OVERALL_TIMEOUT = {{ experiment.recruiter.termination_time_in_s }};
                const POLLING_INTERVAL = 1000;
                let checkTriedToLeaveIntervalID = setInterval(checkTriedToLeave, POLLING_INTERVAL);
                let clockIntervalID = setInterval(updateClocks, POLLING_INTERVAL);
                let triedToLeave = false;
                let noFocusSince = 0;
                let noActivitySince = 0;
                let secondsLeft = {{ experiment.recruiter.time_until_termination_in_s(participant.assignment_id) }};
                let assignmentId = "{{ worker_id }}";

                window.addEventListener("beforeunload", beforeunloadFunction);

                // Proceed to next page
                $(".btn, .sd-btn").click(function () {
                    psynet.removeBeforeUnloadEventListener();
                });

                $("#terminate-button").click(function () {
                    terminateParticipant('terminate-button');
                });

                function toMilliseconds(seconds) {
                    return seconds * 1000;
                }

                function beforeunloadFunction(event) {
                    {% if not page.is_unity_page and not page.skip_beforeunload %}
                        event.returnValue = `Are you sure you want to leave?`;
                        triedToLeave = true;
                    {% endif %}
                }

                function terminateParticipant(reason) {
                    psynet.removeBeforeUnloadEventListener();
                    clearInterval(checkTriedToLeaveIntervalID);
                    clearInterval(clockIntervalID);
                    return window.location.replace("/terminate_participant?participant_id=" + {{ participant.id }} + "&reason=" + reason)
                }

                function checkTriedToLeave() {
                    if (triedToLeave) {
                        terminateParticipant("user-tried-to-leave");
                    }
                }


                // Terminate due to inactivity
                function updateClocks() {
                    let msg = ''

                    // No focus
                    if (document.hasFocus()) {
                        noFocusSince = 0;
                    } else {
                        noFocusSince += POLLING_INTERVAL;
                        if (noFocusSince > NO_FOCUS_TIMEOUT) {
                            terminateParticipant(NO_FOCUS_TIMEOUT_REASON + NO_FOCUS_TIMEOUT / 1000 + "s");
                        } else {
                            msg = "No focus: " + noFocusSince / 1000 + "/" + NO_FOCUS_TIMEOUT / 1000 + "s";
                        }
                    }

                    // No activity
                    noActivitySince += POLLING_INTERVAL;
                    noActivityTimeout = {{ experiment.recruiter.inactivity_timeout_in_s }} * 1000
                    if (noActivitySince > noActivityTimeout) {
                        terminateParticipant("inactivity-timeout-" + {{ experiment.recruiter.inactivity_timeout_in_s }} + "s");
                    }
                    msg += " No activity: " + noActivitySince / 1000 + "/" + noActivityTimeout / 1000 + "s";

                    // Overall timeout
                    if (secondsLeft <= 0) {
                        psynet.removeBeforeUnloadEventListener();
                        terminateParticipant("overall-timeout-" + OVERALL_TIMEOUT + "s");
                    }
                    secondsLeft -= POLLING_INTERVAL / 1000;
                    msg += " Overall timeout: " + secondsLeft + "/" + OVERALL_TIMEOUT + "s";
                }

                // Events that reset the inactivity time
                const events = ["click", "keypress", "load", "mousedown", "mousemove", "touchstart"];
                for (let i = 0; i < events.length; i++) {
                    window.addEventListener(events[i], function (){
                        noActivitySince = 0;
                    });
                }
            }
        </script>
    {% endif %}
{% endblock %}
