Simplify video-button-response plugin DOM structure and make button_html a function parameter

This commit is contained in:
bjoluc 2023-09-05 17:04:49 +02:00
parent 8468fa9a58
commit 3d3299fd23
2 changed files with 97 additions and 131 deletions

View File

@ -5,7 +5,7 @@
<script src="../packages/plugin-video-button-response/dist/index.browser.js"></script> <script src="../packages/plugin-video-button-response/dist/index.browser.js"></script>
<script src="../packages/plugin-html-button-response/dist/index.browser.js"></script> <script src="../packages/plugin-html-button-response/dist/index.browser.js"></script>
<script src="../packages/plugin-preload/dist/index.browser.js"></script> <script src="../packages/plugin-preload/dist/index.browser.js"></script>
<link rel="stylesheet" href="../packages/jspsych/css/jspsych.css"> <link rel="stylesheet" href="../packages/jspsych/css/jspsych.css" />
</head> </head>
<body></body> <body></body>
<script> <script>
@ -17,7 +17,7 @@
}); });
// preloading videos only works when the file is running on a server // preloading videos only works when the file is running on a server
// if you run this experiment by opening the file directly in the browser, // if you run this experiment by opening the file directly in the browser,
// then video preloading will be disabled to prevent CORS errors // then video preloading will be disabled to prevent CORS errors
var preload = { var preload = {
type: jsPsychPreload, type: jsPsychPreload,
@ -52,7 +52,9 @@
type: jsPsychVideoButtonResponse, type: jsPsychVideoButtonResponse,
stimulus: ['video/sample_video.mp4'], stimulus: ['video/sample_video.mp4'],
choices: ['😄','😁','🥱','😣','🤯'], choices: ['😄','😁','🥱','😣','🤯'],
button_html: '<div style="font-size:40px;">%choice%</div>', button_html: function (choice) {
return '<span style="font-size:40px;">' + choice + '</span>';
},
margin_vertical: '10px', margin_vertical: '10px',
margin_horizontal: '8px', margin_horizontal: '8px',
prompt: '<p>Click the emoji that best represents your reaction to the video</p><p>When the video stops, click a button to end the trial.</p><p>Response buttons are disabled while the video is playing.</p>', prompt: '<p>Click the emoji that best represents your reaction to the video</p><p>When the video stops, click a button to end the trial.</p><p>Response buttons are disabled while the video is playing.</p>',
@ -63,6 +65,5 @@
}; };
jsPsych.run([preload, pre_trial, trial_1, trial_2]); jsPsych.run([preload, pre_trial, trial_1, trial_2]);
</script> </script>
</html> </html>

View File

@ -17,12 +17,16 @@ const info = <const>{
default: undefined, default: undefined,
array: true, array: true,
}, },
/** The HTML for creating button. Can create own style. Use the "%choice%" string to indicate where the label from the choices parameter should be inserted. */ /**
* A function that, given a choice and its index, returns the HTML string of that choice's
* button.
*/
button_html: { button_html: {
type: ParameterType.HTML_STRING, type: ParameterType.FUNCTION,
pretty_name: "Button HTML", pretty_name: "Button HTML",
default: '<button class="jspsych-btn">%choice%</button>', default: function (choice: string, choice_index: number) {
array: true, return `<button class="jspsych-btn">${choice}</button>`;
},
}, },
/** Any content here will be displayed below the buttons. */ /** Any content here will be displayed below the buttons. */
prompt: { prompt: {
@ -127,109 +131,88 @@ class VideoButtonResponsePlugin implements JsPsychPlugin<Info> {
constructor(private jsPsych: JsPsych) {} constructor(private jsPsych: JsPsych) {}
trial(display_element: HTMLElement, trial: TrialType<Info>) { trial(display_element: HTMLElement, trial: TrialType<Info>) {
if (!Array.isArray(trial.stimulus)) { display_element.innerHTML = "";
throw new Error(`
The stimulus property for the video-button-response plugin must be an array
of files. See https://www.jspsych.org/latest/plugins/video-button-response/#parameters
`);
}
// setup stimulus // Setup stimulus
var video_html = "<div>"; const stimulusWrapper = document.createElement("div");
video_html += '<video id="jspsych-video-button-response-stimulus"'; display_element.appendChild(stimulusWrapper);
const videoElement = document.createElement("video");
stimulusWrapper.appendChild(videoElement);
videoElement.id = "jspsych-video-button-response-stimulus";
if (trial.width) { if (trial.width) {
video_html += ' width="' + trial.width + '"'; videoElement.width = trial.width;
} }
if (trial.height) { if (trial.height) {
video_html += ' height="' + trial.height + '"'; videoElement.height = trial.height;
}
if (trial.autoplay && trial.start == null) {
// if autoplay is true and the start time is specified, then the video will start automatically
// via the play() method, rather than the autoplay attribute, to prevent showing the first frame
video_html += " autoplay ";
}
if (trial.controls) {
video_html += " controls ";
} }
videoElement.controls = trial.controls;
// if autoplay is true and the start time is specified, then the video will start automatically
// via the play() method, rather than the autoplay attribute, to prevent showing the first frame
videoElement.autoplay = trial.autoplay && trial.start == null;
if (trial.start !== null) { if (trial.start !== null) {
// hide video element when page loads if the start time is specified, // hide video element when page loads if the start time is specified,
// to prevent the video element from showing the first frame // to prevent the video element from showing the first frame
video_html += ' style="visibility: hidden;"'; videoElement.style.visibility = "hidden";
} }
video_html += ">";
var video_preload_blob = this.jsPsych.pluginAPI.getVideoBuffer(trial.stimulus[0]); const videoPreloadBlob = this.jsPsych.pluginAPI.getVideoBuffer(trial.stimulus[0]);
if (!video_preload_blob) { if (!videoPreloadBlob) {
for (var i = 0; i < trial.stimulus.length; i++) { for (let filename of trial.stimulus) {
var file_name = trial.stimulus[i]; if (filename.indexOf("?") > -1) {
if (file_name.indexOf("?") > -1) { filename = filename.substring(0, filename.indexOf("?"));
file_name = file_name.substring(0, file_name.indexOf("?"));
} }
var type = file_name.substr(file_name.lastIndexOf(".") + 1); const type = filename.substring(filename.lastIndexOf(".") + 1).toLowerCase();
type = type.toLowerCase(); if (type === "mov") {
if (type == "mov") {
console.warn( console.warn(
"Warning: video-button-response plugin does not reliably support .mov files." "Warning: video-button-response plugin does not reliably support .mov files."
); );
} }
video_html += '<source src="' + file_name + '" type="video/' + type + '">';
}
}
video_html += "</video>";
video_html += "</div>";
//display buttons const sourceElement = document.createElement("source");
var buttons = []; sourceElement.src = filename;
if (Array.isArray(trial.button_html)) { sourceElement.type = "video/" + type;
if (trial.button_html.length == trial.choices.length) { videoElement.appendChild(sourceElement);
buttons = trial.button_html;
} else {
console.error(
"Error in video-button-response plugin. The length of the button_html array does not equal the length of the choices array"
);
}
} else {
for (var i = 0; i < trial.choices.length; i++) {
buttons.push(trial.button_html);
} }
} }
video_html += '<div id="jspsych-video-button-response-btngroup">';
for (var i = 0; i < trial.choices.length; i++) {
var str = buttons[i].replace(/%choice%/g, trial.choices[i]);
video_html +=
'<div class="jspsych-video-button-response-button" style="cursor: pointer; display: inline-block; margin:' +
trial.margin_vertical +
" " +
trial.margin_horizontal +
'" id="jspsych-video-button-response-button-' +
i +
'" data-choice="' +
i +
'">' +
str +
"</div>";
}
video_html += "</div>";
// add prompt if there is one // Display buttons
const buttonGroupElement = document.createElement("div");
buttonGroupElement.id = "jspsych-video-button-response-btngroup";
buttonGroupElement.style.cssText = `
display: flex;
justify-content: center;
gap: ${trial.margin_vertical} ${trial.margin_horizontal};
padding: ${trial.margin_vertical} ${trial.margin_horizontal};
`;
for (const [choiceIndex, choice] of trial.choices.entries()) {
buttonGroupElement.insertAdjacentHTML("beforeend", trial.button_html(choice, choiceIndex));
const buttonElement = buttonGroupElement.lastChild as HTMLElement;
buttonElement.dataset.choice = choiceIndex.toString();
buttonElement.addEventListener("click", () => {
after_response(choiceIndex);
});
}
display_element.appendChild(buttonGroupElement);
// Show prompt if there is one
if (trial.prompt !== null) { if (trial.prompt !== null) {
video_html += trial.prompt; display_element.insertAdjacentHTML("beforeend", trial.prompt);
} }
display_element.innerHTML = video_html;
var start_time = performance.now(); var start_time = performance.now();
var video_element = display_element.querySelector<HTMLVideoElement>( if (videoPreloadBlob) {
"#jspsych-video-button-response-stimulus" videoElement.src = videoPreloadBlob;
);
if (video_preload_blob) {
video_element.src = video_preload_blob;
} }
video_element.onended = () => { videoElement.onended = () => {
if (trial.trial_ends_after_video) { if (trial.trial_ends_after_video) {
end_trial(); end_trial();
} else if (!trial.response_allowed_while_playing) { } else if (!trial.response_allowed_while_playing) {
@ -237,41 +220,40 @@ class VideoButtonResponsePlugin implements JsPsychPlugin<Info> {
} }
}; };
video_element.playbackRate = trial.rate; videoElement.playbackRate = trial.rate;
// if video start time is specified, hide the video and set the starting time // if video start time is specified, hide the video and set the starting time
// before showing and playing, so that the video doesn't automatically show the first frame // before showing and playing, so that the video doesn't automatically show the first frame
if (trial.start !== null) { if (trial.start !== null) {
video_element.pause(); videoElement.pause();
video_element.onseeked = () => { videoElement.onseeked = () => {
video_element.style.visibility = "visible"; videoElement.style.visibility = "visible";
video_element.muted = false; videoElement.muted = false;
if (trial.autoplay) { if (trial.autoplay) {
video_element.play(); videoElement.play();
} else { } else {
video_element.pause(); videoElement.pause();
} }
video_element.onseeked = () => {}; videoElement.onseeked = () => {};
}; };
video_element.onplaying = () => { videoElement.onplaying = () => {
video_element.currentTime = trial.start; videoElement.currentTime = trial.start;
video_element.onplaying = () => {}; videoElement.onplaying = () => {};
}; };
// fix for iOS/MacOS browsers: videos aren't seekable until they start playing, so need to hide/mute, play, // fix for iOS/MacOS browsers: videos aren't seekable until they start playing, so need to hide/mute, play,
// change current time, then show/unmute // change current time, then show/unmute
video_element.muted = true; videoElement.muted = true;
video_element.play(); videoElement.play();
} }
let stopped = false; let stopped = false;
if (trial.stop !== null) { if (trial.stop !== null) {
video_element.addEventListener("timeupdate", (e) => { videoElement.addEventListener("timeupdate", (e) => {
var currenttime = video_element.currentTime; if (videoElement.currentTime >= trial.stop) {
if (currenttime >= trial.stop) {
if (!trial.response_allowed_while_playing) { if (!trial.response_allowed_while_playing) {
enable_buttons(); enable_buttons();
} }
video_element.pause(); videoElement.pause();
if (trial.trial_ends_after_video && !stopped) { if (trial.trial_ends_after_video && !stopped) {
// this is to prevent end_trial from being called twice, because the timeupdate event // this is to prevent end_trial from being called twice, because the timeupdate event
// can fire in quick succession // can fire in quick succession
@ -301,15 +283,11 @@ class VideoButtonResponsePlugin implements JsPsychPlugin<Info> {
// stop the video file if it is playing // stop the video file if it is playing
// remove any remaining end event handlers // remove any remaining end event handlers
display_element videoElement.pause();
.querySelector<HTMLVideoElement>("#jspsych-video-button-response-stimulus") videoElement.onended = () => {};
.pause();
display_element.querySelector<HTMLVideoElement>(
"#jspsych-video-button-response-stimulus"
).onended = () => {};
// gather the data to store for the trial // gather the data to store for the trial
var trial_data = { const trial_data = {
rt: response.rt, rt: response.rt,
stimulus: trial.stimulus, stimulus: trial.stimulus,
response: response.button, response: response.button,
@ -323,16 +301,16 @@ class VideoButtonResponsePlugin implements JsPsychPlugin<Info> {
}; };
// function to handle responses by the subject // function to handle responses by the subject
function after_response(choice: string) { function after_response(choice: number) {
// measure rt // measure rt
var end_time = performance.now(); var end_time = performance.now();
var rt = Math.round(end_time - start_time); var rt = Math.round(end_time - start_time);
response.button = parseInt(choice); response.button = choice;
response.rt = rt; response.rt = rt;
// after a valid response, the stimulus will have the CSS class 'responded' // after a valid response, the stimulus will have the CSS class 'responded'
// which can be used to provide visual feedback that a response was recorded // which can be used to provide visual feedback that a response was recorded
video_element.className += " responded"; videoElement.classList.add("responded");
// disable all the buttons after a response // disable all the buttons after a response
disable_buttons(); disable_buttons();
@ -342,30 +320,15 @@ class VideoButtonResponsePlugin implements JsPsychPlugin<Info> {
} }
} }
function button_response(e) {
var choice = e.currentTarget.getAttribute("data-choice"); // don't use dataset for jsdom compatibility
after_response(choice);
}
function disable_buttons() { function disable_buttons() {
var btns = document.querySelectorAll(".jspsych-video-button-response-button"); for (const button of buttonGroupElement.children) {
for (var i = 0; i < btns.length; i++) { button.setAttribute("disabled", "disabled");
var btn_el = btns[i].querySelector("button");
if (btn_el) {
btn_el.disabled = true;
}
btns[i].removeEventListener("click", button_response);
} }
} }
function enable_buttons() { function enable_buttons() {
var btns = document.querySelectorAll(".jspsych-video-button-response-button"); for (const button of buttonGroupElement.children) {
for (var i = 0; i < btns.length; i++) { button.removeAttribute("disabled");
var btn_el = btns[i].querySelector("button");
if (btn_el) {
btn_el.disabled = false;
}
btns[i].addEventListener("click", button_response);
} }
} }
@ -425,7 +388,9 @@ class VideoButtonResponsePlugin implements JsPsychPlugin<Info> {
const respond = () => { const respond = () => {
if (data.rt !== null) { if (data.rt !== null) {
this.jsPsych.pluginAPI.clickTarget( this.jsPsych.pluginAPI.clickTarget(
display_element.querySelector(`div[data-choice="${data.response}"] button`), display_element.querySelector(
`#jspsych-video-button-response-btngroup [data-choice="${data.response}"]`
),
data.rt data.rt
); );
} }