jspsych 7.3.4 → 8.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/css/jspsych.css +18 -8
- package/dist/index.browser.js +3080 -4286
- package/dist/index.browser.js.map +1 -1
- package/dist/index.browser.min.js +6 -2
- package/dist/index.browser.min.js.map +1 -1
- package/dist/index.cjs +2314 -4066
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +984 -6
- package/dist/index.js +2313 -4066
- package/dist/index.js.map +1 -1
- package/package.json +6 -5
- package/src/ExtensionManager.spec.ts +123 -0
- package/src/ExtensionManager.ts +81 -0
- package/src/JsPsych.ts +195 -690
- package/src/ProgressBar.spec.ts +60 -0
- package/src/ProgressBar.ts +60 -0
- package/src/index.scss +29 -8
- package/src/index.ts +4 -9
- package/src/modules/data/DataCollection.ts +1 -1
- package/src/modules/data/DataColumn.ts +12 -1
- package/src/modules/data/index.ts +92 -103
- package/src/modules/extensions.ts +4 -0
- package/src/modules/plugin-api/AudioPlayer.ts +101 -0
- package/src/modules/plugin-api/KeyboardListenerAPI.ts +1 -1
- package/src/modules/plugin-api/MediaAPI.ts +48 -106
- package/src/modules/plugin-api/__mocks__/AudioPlayer.ts +38 -0
- package/src/modules/plugin-api/index.ts +11 -14
- package/src/modules/plugins.ts +26 -27
- package/src/modules/randomization.ts +1 -1
- package/src/timeline/Timeline.spec.ts +921 -0
- package/src/timeline/Timeline.ts +342 -0
- package/src/timeline/TimelineNode.ts +174 -0
- package/src/timeline/Trial.spec.ts +897 -0
- package/src/timeline/Trial.ts +419 -0
- package/src/timeline/index.ts +232 -0
- package/src/timeline/util.spec.ts +124 -0
- package/src/timeline/util.ts +146 -0
- package/dist/JsPsych.d.ts +0 -112
- package/dist/TimelineNode.d.ts +0 -34
- package/dist/migration.d.ts +0 -3
- package/dist/modules/data/DataCollection.d.ts +0 -46
- package/dist/modules/data/DataColumn.d.ts +0 -15
- package/dist/modules/data/index.d.ts +0 -25
- package/dist/modules/data/utils.d.ts +0 -3
- package/dist/modules/extensions.d.ts +0 -22
- package/dist/modules/plugin-api/HardwareAPI.d.ts +0 -15
- package/dist/modules/plugin-api/KeyboardListenerAPI.d.ts +0 -34
- package/dist/modules/plugin-api/MediaAPI.d.ts +0 -32
- package/dist/modules/plugin-api/SimulationAPI.d.ts +0 -44
- package/dist/modules/plugin-api/TimeoutAPI.d.ts +0 -17
- package/dist/modules/plugin-api/index.d.ts +0 -8
- package/dist/modules/plugins.d.ts +0 -136
- package/dist/modules/randomization.d.ts +0 -42
- package/dist/modules/turk.d.ts +0 -40
- package/dist/modules/utils.d.ts +0 -13
- package/src/TimelineNode.ts +0 -544
- package/src/modules/plugin-api/HardwareAPI.ts +0 -32
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { ProgressBar } from "./ProgressBar";
|
|
2
|
+
|
|
3
|
+
describe("ProgressBar", () => {
|
|
4
|
+
let containerElement: HTMLDivElement;
|
|
5
|
+
let progressBar: ProgressBar;
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
containerElement = document.createElement("div");
|
|
9
|
+
progressBar = new ProgressBar(containerElement, "My message");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("sets up proper HTML markup when created", () => {
|
|
13
|
+
expect(containerElement.innerHTML).toMatchInlineSnapshot(
|
|
14
|
+
'"<span>My message</span><div id="jspsych-progressbar-outer"><div id="jspsych-progressbar-inner" style="width: 0%;"></div></div>"'
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("progress", () => {
|
|
19
|
+
it("updates the bar width accordingly", () => {
|
|
20
|
+
expect(progressBar.progress).toEqual(0);
|
|
21
|
+
expect(containerElement.innerHTML).toContain('style="width: 0%;"');
|
|
22
|
+
progressBar.progress = 0.5;
|
|
23
|
+
expect(progressBar.progress).toEqual(0.5);
|
|
24
|
+
expect(containerElement.innerHTML).toContain('style="width: 50%;"');
|
|
25
|
+
|
|
26
|
+
progressBar.progress = 1;
|
|
27
|
+
expect(progressBar.progress).toEqual(1);
|
|
28
|
+
expect(containerElement.innerHTML).toContain('style="width: 100%;"');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("errors if an invalid progress value is provided", () => {
|
|
32
|
+
expect(() => {
|
|
33
|
+
// @ts-expect-error
|
|
34
|
+
progressBar.progress = "0";
|
|
35
|
+
}).toThrowErrorMatchingInlineSnapshot(
|
|
36
|
+
'"jsPsych.progressBar.progress must be a number between 0 and 1"'
|
|
37
|
+
);
|
|
38
|
+
expect(() => {
|
|
39
|
+
progressBar.progress = -0.1;
|
|
40
|
+
}).toThrowErrorMatchingInlineSnapshot(
|
|
41
|
+
'"jsPsych.progressBar.progress must be a number between 0 and 1"'
|
|
42
|
+
);
|
|
43
|
+
expect(() => (progressBar.progress = 1.1)).toThrowErrorMatchingInlineSnapshot(
|
|
44
|
+
'"jsPsych.progressBar.progress must be a number between 0 and 1"'
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("should work when message is a function", () => {
|
|
49
|
+
// Override default container element and progress bar
|
|
50
|
+
containerElement = document.createElement("div");
|
|
51
|
+
progressBar = new ProgressBar(containerElement, (progress: number) => String(progress));
|
|
52
|
+
let messageSpan: HTMLSpanElement = containerElement.querySelector("span");
|
|
53
|
+
|
|
54
|
+
expect(messageSpan.innerHTML).toEqual("0");
|
|
55
|
+
|
|
56
|
+
progressBar.progress = 0.5;
|
|
57
|
+
expect(messageSpan.innerHTML).toEqual("0.5");
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maintains a visual progress bar using HTML and CSS
|
|
3
|
+
*/
|
|
4
|
+
export class ProgressBar {
|
|
5
|
+
constructor(
|
|
6
|
+
private readonly containerElement: HTMLDivElement,
|
|
7
|
+
private readonly message: string | ((progress: number) => string)
|
|
8
|
+
) {
|
|
9
|
+
this.setupElements();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
private _progress = 0;
|
|
13
|
+
|
|
14
|
+
private innerDiv: HTMLDivElement;
|
|
15
|
+
private messageSpan: HTMLSpanElement;
|
|
16
|
+
|
|
17
|
+
/** Adds the progress bar HTML code into `this.containerElement` */
|
|
18
|
+
private setupElements() {
|
|
19
|
+
this.messageSpan = document.createElement("span");
|
|
20
|
+
|
|
21
|
+
this.innerDiv = document.createElement("div");
|
|
22
|
+
this.innerDiv.id = "jspsych-progressbar-inner";
|
|
23
|
+
this.update();
|
|
24
|
+
|
|
25
|
+
const outerDiv = document.createElement("div");
|
|
26
|
+
outerDiv.id = "jspsych-progressbar-outer";
|
|
27
|
+
outerDiv.appendChild(this.innerDiv);
|
|
28
|
+
|
|
29
|
+
this.containerElement.appendChild(this.messageSpan);
|
|
30
|
+
this.containerElement.appendChild(outerDiv);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Updates the progress bar according to `this.progress` */
|
|
34
|
+
private update() {
|
|
35
|
+
this.innerDiv.style.width = this._progress * 100 + "%";
|
|
36
|
+
|
|
37
|
+
if (typeof this.message === "function") {
|
|
38
|
+
this.messageSpan.innerHTML = this.message(this._progress);
|
|
39
|
+
} else {
|
|
40
|
+
this.messageSpan.innerHTML = this.message;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The bar's current position as a number in the closed interval [0, 1]. Set this to update the
|
|
46
|
+
* progress bar accordingly.
|
|
47
|
+
*/
|
|
48
|
+
set progress(progress: number) {
|
|
49
|
+
if (typeof progress !== "number" || progress < 0 || progress > 1) {
|
|
50
|
+
throw new Error("jsPsych.progressBar.progress must be a number between 0 and 1");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
this._progress = progress;
|
|
54
|
+
this.update();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
get progress() {
|
|
58
|
+
return this._progress;
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/index.scss
CHANGED
|
@@ -30,7 +30,6 @@
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
.jspsych-content {
|
|
33
|
-
max-width: 95%; /* this is mainly an IE 10-11 fix */
|
|
34
33
|
text-align: center;
|
|
35
34
|
margin: auto; /* this is for overflowing content */
|
|
36
35
|
}
|
|
@@ -58,11 +57,25 @@
|
|
|
58
57
|
font-size: 14px;
|
|
59
58
|
}
|
|
60
59
|
|
|
61
|
-
/*
|
|
60
|
+
/* Buttons and Button Groups */
|
|
61
|
+
|
|
62
|
+
.jspsych-btn-group-flex {
|
|
63
|
+
display: flex;
|
|
64
|
+
justify-content: center;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.jspsych-btn-group-grid {
|
|
68
|
+
display: grid;
|
|
69
|
+
grid-auto-columns: max-content;
|
|
70
|
+
max-width: fit-content;
|
|
71
|
+
margin-right: auto;
|
|
72
|
+
margin-left: auto;
|
|
73
|
+
}
|
|
74
|
+
|
|
62
75
|
.jspsych-btn {
|
|
63
76
|
display: inline-block;
|
|
64
|
-
padding:
|
|
65
|
-
margin:
|
|
77
|
+
padding: 8px 12px;
|
|
78
|
+
margin: 0.75em;
|
|
66
79
|
font-size: 14px;
|
|
67
80
|
font-weight: 400;
|
|
68
81
|
font-family: "Open Sans", "Arial", sans-serif;
|
|
@@ -108,10 +121,11 @@
|
|
|
108
121
|
width: 100%;
|
|
109
122
|
background: transparent;
|
|
110
123
|
}
|
|
124
|
+
|
|
111
125
|
.jspsych-slider:focus {
|
|
112
126
|
outline: none;
|
|
113
127
|
}
|
|
114
|
-
|
|
128
|
+
|
|
115
129
|
.jspsych-slider::-webkit-slider-runnable-track {
|
|
116
130
|
appearance: none;
|
|
117
131
|
-webkit-appearance: none;
|
|
@@ -123,6 +137,7 @@
|
|
|
123
137
|
border-radius: 2px;
|
|
124
138
|
border: 1px solid #aaa;
|
|
125
139
|
}
|
|
140
|
+
|
|
126
141
|
.jspsych-slider::-moz-range-track {
|
|
127
142
|
appearance: none;
|
|
128
143
|
width: 100%;
|
|
@@ -133,6 +148,7 @@
|
|
|
133
148
|
border-radius: 2px;
|
|
134
149
|
border: 1px solid #aaa;
|
|
135
150
|
}
|
|
151
|
+
|
|
136
152
|
.jspsych-slider::-ms-track {
|
|
137
153
|
appearance: none;
|
|
138
154
|
width: 99%;
|
|
@@ -143,7 +159,7 @@
|
|
|
143
159
|
border-radius: 2px;
|
|
144
160
|
border: 1px solid #aaa;
|
|
145
161
|
}
|
|
146
|
-
|
|
162
|
+
|
|
147
163
|
.jspsych-slider::-webkit-slider-thumb {
|
|
148
164
|
border: 1px solid #666;
|
|
149
165
|
height: 24px;
|
|
@@ -154,6 +170,7 @@
|
|
|
154
170
|
-webkit-appearance: none;
|
|
155
171
|
margin-top: -9px;
|
|
156
172
|
}
|
|
173
|
+
|
|
157
174
|
.jspsych-slider::-moz-range-thumb {
|
|
158
175
|
border: 1px solid #666;
|
|
159
176
|
height: 24px;
|
|
@@ -162,6 +179,7 @@
|
|
|
162
179
|
background: #ffffff;
|
|
163
180
|
cursor: pointer;
|
|
164
181
|
}
|
|
182
|
+
|
|
165
183
|
.jspsych-slider::-ms-thumb {
|
|
166
184
|
border: 1px solid #666;
|
|
167
185
|
height: 20px;
|
|
@@ -172,7 +190,7 @@
|
|
|
172
190
|
margin-top: -2px;
|
|
173
191
|
}
|
|
174
192
|
|
|
175
|
-
/*
|
|
193
|
+
/* progress bar */
|
|
176
194
|
|
|
177
195
|
#jspsych-progressbar-container {
|
|
178
196
|
color: #555;
|
|
@@ -184,10 +202,12 @@
|
|
|
184
202
|
width: 100%;
|
|
185
203
|
line-height: 1em;
|
|
186
204
|
}
|
|
205
|
+
|
|
187
206
|
#jspsych-progressbar-container span {
|
|
188
207
|
font-size: 14px;
|
|
189
208
|
padding-right: 14px;
|
|
190
209
|
}
|
|
210
|
+
|
|
191
211
|
#jspsych-progressbar-outer {
|
|
192
212
|
background-color: #eee;
|
|
193
213
|
width: 50%;
|
|
@@ -197,13 +217,14 @@
|
|
|
197
217
|
vertical-align: middle;
|
|
198
218
|
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
|
|
199
219
|
}
|
|
220
|
+
|
|
200
221
|
#jspsych-progressbar-inner {
|
|
201
222
|
background-color: #aaa;
|
|
202
223
|
width: 0%;
|
|
203
224
|
height: 100%;
|
|
204
225
|
}
|
|
205
226
|
|
|
206
|
-
/*
|
|
227
|
+
/* Appearance of jsPsych.data.displayData() */
|
|
207
228
|
#jspsych-data-display {
|
|
208
229
|
text-align: left;
|
|
209
230
|
}
|
package/src/index.ts
CHANGED
|
@@ -62,12 +62,7 @@ export function initJsPsych(options?) {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
export { JsPsych } from "./JsPsych";
|
|
65
|
-
export {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
ParameterType,
|
|
70
|
-
universalPluginParameters,
|
|
71
|
-
UniversalPluginParameters,
|
|
72
|
-
} from "./modules/plugins";
|
|
73
|
-
export { JsPsychExtension, JsPsychExtensionInfo } from "./modules/extensions";
|
|
65
|
+
export type { JsPsychPlugin, PluginInfo, TrialType } from "./modules/plugins";
|
|
66
|
+
export { ParameterType } from "./modules/plugins";
|
|
67
|
+
export type { JsPsychExtension, JsPsychExtensionInfo } from "./modules/extensions";
|
|
68
|
+
export { DataCollection } from "./modules/data/DataCollection";
|
|
@@ -10,7 +10,18 @@ export class DataColumn {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
mean() {
|
|
13
|
-
|
|
13
|
+
let sum = 0;
|
|
14
|
+
let count = 0;
|
|
15
|
+
for (const value of this.values) {
|
|
16
|
+
if (typeof value !== "undefined" && value !== null) {
|
|
17
|
+
sum += value;
|
|
18
|
+
count++;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
if (count === 0) {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
return sum / count;
|
|
14
25
|
}
|
|
15
26
|
|
|
16
27
|
median() {
|
|
@@ -1,106 +1,104 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { TrialResult } from "../../timeline";
|
|
2
|
+
import { Trial } from "../../timeline/Trial";
|
|
2
3
|
import { DataCollection } from "./DataCollection";
|
|
3
4
|
import { getQueryString } from "./utils";
|
|
4
5
|
|
|
6
|
+
export type InteractionEvent = "blur" | "focus" | "fullscreenenter" | "fullscreenexit";
|
|
7
|
+
|
|
8
|
+
export interface InteractionRecord {
|
|
9
|
+
event: InteractionEvent;
|
|
10
|
+
trial: number;
|
|
11
|
+
time: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Functions and options needed by the `JsPsychData` module
|
|
16
|
+
*/
|
|
17
|
+
export interface JsPsychDataDependencies {
|
|
18
|
+
/**
|
|
19
|
+
* Returns progress information for interaction records.
|
|
20
|
+
*/
|
|
21
|
+
getProgress: () => { trial: number; time: number };
|
|
22
|
+
|
|
23
|
+
onInteractionRecordAdded: (record: InteractionRecord) => void;
|
|
24
|
+
|
|
25
|
+
getDisplayElement: () => HTMLElement;
|
|
26
|
+
}
|
|
27
|
+
|
|
5
28
|
export class JsPsychData {
|
|
6
|
-
|
|
7
|
-
private
|
|
29
|
+
private results: DataCollection;
|
|
30
|
+
private resultToTrialMap: WeakMap<TrialResult, Trial>;
|
|
8
31
|
|
|
9
|
-
|
|
10
|
-
private
|
|
32
|
+
/** Browser interaction event data */
|
|
33
|
+
private interactionRecords: DataCollection;
|
|
11
34
|
|
|
12
|
-
|
|
35
|
+
/** Data properties for all trials */
|
|
13
36
|
private dataProperties = {};
|
|
14
37
|
|
|
15
38
|
// cache the query_string
|
|
16
39
|
private query_string;
|
|
17
40
|
|
|
18
|
-
constructor(private
|
|
41
|
+
constructor(private dependencies: JsPsychDataDependencies) {
|
|
19
42
|
this.reset();
|
|
20
43
|
}
|
|
21
44
|
|
|
22
45
|
reset() {
|
|
23
|
-
this.
|
|
24
|
-
this.
|
|
46
|
+
this.results = new DataCollection();
|
|
47
|
+
this.resultToTrialMap = new WeakMap<TrialResult, Trial>();
|
|
48
|
+
this.interactionRecords = new DataCollection();
|
|
25
49
|
}
|
|
26
50
|
|
|
27
51
|
get() {
|
|
28
|
-
return this.
|
|
52
|
+
return this.results;
|
|
29
53
|
}
|
|
30
54
|
|
|
31
55
|
getInteractionData() {
|
|
32
|
-
return this.
|
|
56
|
+
return this.interactionRecords;
|
|
33
57
|
}
|
|
34
58
|
|
|
35
|
-
write(
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const default_data = {
|
|
42
|
-
trial_type: trial.type.info.name,
|
|
43
|
-
trial_index: progress.current_trial_global,
|
|
44
|
-
time_elapsed: this.jsPsych.getTotalTime(),
|
|
45
|
-
internal_node_id: this.jsPsych.getCurrentTimelineNodeID(),
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
this.allData.push({
|
|
49
|
-
...data_object,
|
|
50
|
-
...trial.data,
|
|
51
|
-
...default_data,
|
|
52
|
-
...this.dataProperties,
|
|
53
|
-
});
|
|
59
|
+
write(trial: Trial) {
|
|
60
|
+
const result = trial.getResult();
|
|
61
|
+
Object.assign(result, this.dataProperties);
|
|
62
|
+
this.results.push(result);
|
|
63
|
+
this.resultToTrialMap.set(result, trial);
|
|
54
64
|
}
|
|
55
65
|
|
|
56
66
|
addProperties(properties) {
|
|
57
67
|
// first, add the properties to all data that's already stored
|
|
58
|
-
this.
|
|
68
|
+
this.results.addToAll(properties);
|
|
59
69
|
|
|
60
70
|
// now add to list so that it gets appended to all future data
|
|
61
71
|
this.dataProperties = Object.assign({}, this.dataProperties, properties);
|
|
62
72
|
}
|
|
63
73
|
|
|
64
74
|
addDataToLastTrial(data) {
|
|
65
|
-
this.
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
getDataByTimelineNode(node_id) {
|
|
69
|
-
return this.allData.filterCustom(
|
|
70
|
-
(x) => x.internal_node_id.slice(0, node_id.length) === node_id
|
|
71
|
-
);
|
|
75
|
+
this.results.addToLast(data);
|
|
72
76
|
}
|
|
73
77
|
|
|
74
78
|
getLastTrialData() {
|
|
75
|
-
return this.
|
|
79
|
+
return this.results.top();
|
|
76
80
|
}
|
|
77
81
|
|
|
78
82
|
getLastTimelineData() {
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const parent_node_id = node_id.substr(0, node_id.lastIndexOf("-"));
|
|
85
|
-
const lastnodedata = this.getDataByTimelineNode(parent_node_id);
|
|
86
|
-
return lastnodedata;
|
|
87
|
-
}
|
|
83
|
+
const lastResult = this.getLastTrialData().values()[0];
|
|
84
|
+
|
|
85
|
+
return new DataCollection(
|
|
86
|
+
lastResult ? this.resultToTrialMap.get(lastResult).parent.getResults() : []
|
|
87
|
+
);
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
displayData(format = "json") {
|
|
91
91
|
format = format.toLowerCase();
|
|
92
|
-
if (format
|
|
92
|
+
if (format !== "json" && format !== "csv") {
|
|
93
93
|
console.log("Invalid format declared for displayData function. Using json as default.");
|
|
94
94
|
format = "json";
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
const
|
|
97
|
+
const dataContainer = document.createElement("pre");
|
|
98
|
+
dataContainer.id = "jspsych-data-display";
|
|
99
|
+
dataContainer.textContent = format === "json" ? this.results.json(true) : this.results.csv();
|
|
98
100
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
display_element.innerHTML = '<pre id="jspsych-data-display"></pre>';
|
|
102
|
-
|
|
103
|
-
document.getElementById("jspsych-data-display").textContent = data_string;
|
|
101
|
+
this.dependencies.getDisplayElement().replaceChildren(dataContainer);
|
|
104
102
|
}
|
|
105
103
|
|
|
106
104
|
urlVariables() {
|
|
@@ -114,61 +112,52 @@ export class JsPsychData {
|
|
|
114
112
|
return this.urlVariables()[whichvar];
|
|
115
113
|
}
|
|
116
114
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
this.
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
time: this.jsPsych.getTotalTime(),
|
|
135
|
-
};
|
|
136
|
-
this.interactionData.push(data);
|
|
137
|
-
this.jsPsych.getInitSettings().on_interaction_data_update(data);
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
// fullscreen change capture
|
|
141
|
-
const fullscreenchange = () => {
|
|
142
|
-
const data = {
|
|
143
|
-
event:
|
|
144
|
-
// @ts-expect-error
|
|
145
|
-
document.isFullScreen ||
|
|
115
|
+
private addInteractionRecord(event: InteractionEvent) {
|
|
116
|
+
const record: InteractionRecord = { event, ...this.dependencies.getProgress() };
|
|
117
|
+
this.interactionRecords.push(record);
|
|
118
|
+
this.dependencies.onInteractionRecordAdded(record);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private interactionListeners = {
|
|
122
|
+
blur: () => {
|
|
123
|
+
this.addInteractionRecord("blur");
|
|
124
|
+
},
|
|
125
|
+
focus: () => {
|
|
126
|
+
this.addInteractionRecord("focus");
|
|
127
|
+
},
|
|
128
|
+
fullscreenchange: () => {
|
|
129
|
+
this.addInteractionRecord(
|
|
130
|
+
// @ts-expect-error
|
|
131
|
+
document.isFullScreen ||
|
|
146
132
|
// @ts-expect-error
|
|
147
133
|
document.webkitIsFullScreen ||
|
|
148
134
|
// @ts-expect-error
|
|
149
135
|
document.mozIsFullScreen ||
|
|
150
136
|
document.fullscreenElement
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
document.addEventListener("fullscreenchange", fullscreenchange);
|
|
161
|
-
document.addEventListener("mozfullscreenchange", fullscreenchange);
|
|
162
|
-
document.addEventListener("webkitfullscreenchange", fullscreenchange);
|
|
163
|
-
}
|
|
137
|
+
? "fullscreenenter"
|
|
138
|
+
: "fullscreenexit"
|
|
139
|
+
);
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
createInteractionListeners() {
|
|
144
|
+
window.addEventListener("blur", this.interactionListeners.blur);
|
|
145
|
+
window.addEventListener("focus", this.interactionListeners.focus);
|
|
164
146
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
this.
|
|
147
|
+
document.addEventListener("fullscreenchange", this.interactionListeners.fullscreenchange);
|
|
148
|
+
document.addEventListener("mozfullscreenchange", this.interactionListeners.fullscreenchange);
|
|
149
|
+
document.addEventListener("webkitfullscreenchange", this.interactionListeners.fullscreenchange);
|
|
168
150
|
}
|
|
169
151
|
|
|
170
|
-
|
|
171
|
-
this.
|
|
172
|
-
this.
|
|
152
|
+
removeInteractionListeners() {
|
|
153
|
+
window.removeEventListener("blur", this.interactionListeners.blur);
|
|
154
|
+
window.removeEventListener("focus", this.interactionListeners.focus);
|
|
155
|
+
|
|
156
|
+
document.removeEventListener("fullscreenchange", this.interactionListeners.fullscreenchange);
|
|
157
|
+
document.removeEventListener("mozfullscreenchange", this.interactionListeners.fullscreenchange);
|
|
158
|
+
document.removeEventListener(
|
|
159
|
+
"webkitfullscreenchange",
|
|
160
|
+
this.interactionListeners.fullscreenchange
|
|
161
|
+
);
|
|
173
162
|
}
|
|
174
163
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
export interface AudioPlayerOptions {
|
|
2
|
+
useWebAudio: boolean;
|
|
3
|
+
audioContext?: AudioContext;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface AudioPlayerInterface {
|
|
7
|
+
load(): Promise<void>;
|
|
8
|
+
play(): void;
|
|
9
|
+
stop(): void;
|
|
10
|
+
addEventListener(eventName: string, callback: EventListenerOrEventListenerObject): void;
|
|
11
|
+
removeEventListener(eventName: string, callback: EventListenerOrEventListenerObject): void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class AudioPlayer implements AudioPlayerInterface {
|
|
15
|
+
private audio: HTMLAudioElement | AudioBufferSourceNode;
|
|
16
|
+
private webAudioBuffer: AudioBuffer;
|
|
17
|
+
private audioContext: AudioContext | null;
|
|
18
|
+
private useWebAudio: boolean;
|
|
19
|
+
private src: string;
|
|
20
|
+
|
|
21
|
+
constructor(src: string, options: AudioPlayerOptions = { useWebAudio: false }) {
|
|
22
|
+
this.src = src;
|
|
23
|
+
this.useWebAudio = options.useWebAudio;
|
|
24
|
+
this.audioContext = options.audioContext || null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async load() {
|
|
28
|
+
if (this.useWebAudio) {
|
|
29
|
+
this.webAudioBuffer = await this.preloadWebAudio(this.src);
|
|
30
|
+
} else {
|
|
31
|
+
this.audio = await this.preloadHTMLAudio(this.src);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
play() {
|
|
36
|
+
if (this.audio instanceof HTMLAudioElement) {
|
|
37
|
+
this.audio.play();
|
|
38
|
+
} else {
|
|
39
|
+
// If audio is not HTMLAudioElement, it must be a WebAudio API object, so create a source node.
|
|
40
|
+
if (!this.audio) this.audio = this.getAudioSourceNode(this.webAudioBuffer);
|
|
41
|
+
this.audio.start();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
stop() {
|
|
46
|
+
if (this.audio instanceof HTMLAudioElement) {
|
|
47
|
+
this.audio.pause();
|
|
48
|
+
this.audio.currentTime = 0;
|
|
49
|
+
} else {
|
|
50
|
+
this.audio!.stop();
|
|
51
|
+
// Regenerate source node for audio since the previous one is stopped and unusable.
|
|
52
|
+
this.audio = this.getAudioSourceNode(this.webAudioBuffer);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
addEventListener(eventName: string, callback: EventListenerOrEventListenerObject) {
|
|
57
|
+
// If WebAudio buffer exists but source node doesn't, create it.
|
|
58
|
+
if (!this.audio && this.webAudioBuffer)
|
|
59
|
+
this.audio = this.getAudioSourceNode(this.webAudioBuffer);
|
|
60
|
+
this.audio.addEventListener(eventName, callback);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
removeEventListener(eventName: string, callback: EventListenerOrEventListenerObject) {
|
|
64
|
+
// If WebAudio buffer exists but source node doesn't, create it.
|
|
65
|
+
if (!this.audio && this.webAudioBuffer)
|
|
66
|
+
this.audio = this.getAudioSourceNode(this.webAudioBuffer);
|
|
67
|
+
this.audio.removeEventListener(eventName, callback);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private getAudioSourceNode(audioBuffer: AudioBuffer): AudioBufferSourceNode {
|
|
71
|
+
const source = this.audioContext!.createBufferSource();
|
|
72
|
+
source.buffer = audioBuffer;
|
|
73
|
+
source.connect(this.audioContext!.destination);
|
|
74
|
+
return source;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private async preloadWebAudio(src: string): Promise<AudioBuffer> {
|
|
78
|
+
const buffer = await fetch(src);
|
|
79
|
+
const arrayBuffer = await buffer.arrayBuffer();
|
|
80
|
+
const audioBuffer = await this.audioContext!.decodeAudioData(arrayBuffer);
|
|
81
|
+
const source = this.audioContext!.createBufferSource();
|
|
82
|
+
source.buffer = audioBuffer;
|
|
83
|
+
source.connect(this.audioContext!.destination);
|
|
84
|
+
return audioBuffer;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private async preloadHTMLAudio(src: string): Promise<HTMLAudioElement> {
|
|
88
|
+
return new Promise<HTMLAudioElement>((resolve, reject) => {
|
|
89
|
+
const audio = new Audio(src);
|
|
90
|
+
audio.addEventListener("canplaythrough", () => {
|
|
91
|
+
resolve(audio);
|
|
92
|
+
});
|
|
93
|
+
audio.addEventListener("error", (err) => {
|
|
94
|
+
reject(err);
|
|
95
|
+
});
|
|
96
|
+
audio.addEventListener("abort", (err) => {
|
|
97
|
+
reject(err);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|