mirror of
https://github.com/jspsych/jsPsych.git
synced 2025-05-10 19:20:55 +00:00
300 lines
8.1 KiB
TypeScript
300 lines
8.1 KiB
TypeScript
import { ParameterType } from "../../modules/plugins";
|
|
import { unique } from "../utils";
|
|
import { AudioPlayer } from "./AudioPlayer";
|
|
|
|
const preloadParameterTypes = <const>[
|
|
ParameterType.AUDIO,
|
|
ParameterType.IMAGE,
|
|
ParameterType.VIDEO,
|
|
];
|
|
type PreloadType = typeof preloadParameterTypes[number];
|
|
|
|
export class MediaAPI {
|
|
constructor(public useWebaudio: boolean) {
|
|
if (
|
|
this.useWebaudio &&
|
|
typeof window !== "undefined" &&
|
|
typeof window.AudioContext !== "undefined"
|
|
) {
|
|
this.context = new AudioContext();
|
|
}
|
|
}
|
|
|
|
// video //
|
|
private video_buffers = {};
|
|
getVideoBuffer(videoID: string) {
|
|
if (videoID.startsWith("blob:")) {
|
|
this.video_buffers[videoID] = videoID;
|
|
}
|
|
return this.video_buffers[videoID];
|
|
}
|
|
|
|
// audio //
|
|
private context: AudioContext = null;
|
|
private audio_buffers = [];
|
|
|
|
audioContext(): AudioContext {
|
|
if (this.context && this.context.state !== "running") {
|
|
this.context.resume();
|
|
}
|
|
return this.context;
|
|
}
|
|
|
|
async getAudioPlayer(audioID: string): Promise<AudioPlayer> {
|
|
if (this.audio_buffers[audioID] instanceof AudioPlayer) {
|
|
return this.audio_buffers[audioID];
|
|
} else {
|
|
this.audio_buffers[audioID] = new AudioPlayer(audioID, {
|
|
useWebAudio: this.useWebaudio,
|
|
audioContext: this.context,
|
|
});
|
|
await this.audio_buffers[audioID].load();
|
|
return this.audio_buffers[audioID];
|
|
}
|
|
}
|
|
|
|
// preloading stimuli //
|
|
private preload_requests = [];
|
|
|
|
private img_cache = {};
|
|
|
|
preloadAudio(
|
|
files,
|
|
callback_complete = () => {},
|
|
callback_load = (filepath: string) => {},
|
|
callback_error = (error) => {}
|
|
) {
|
|
files = unique(files.flat());
|
|
|
|
let n_loaded = 0;
|
|
|
|
if (files.length == 0) {
|
|
callback_complete();
|
|
return;
|
|
}
|
|
|
|
for (const file of files) {
|
|
// check if file was already loaded
|
|
if (this.audio_buffers[file] instanceof AudioPlayer) {
|
|
n_loaded++;
|
|
callback_load(file);
|
|
if (n_loaded == files.length) {
|
|
callback_complete();
|
|
}
|
|
} else {
|
|
this.audio_buffers[file] = new AudioPlayer(file, {
|
|
useWebAudio: this.useWebaudio,
|
|
audioContext: this.context,
|
|
});
|
|
this.audio_buffers[file]
|
|
.load()
|
|
.then(() => {
|
|
n_loaded++;
|
|
callback_load(file);
|
|
if (n_loaded == files.length) {
|
|
callback_complete();
|
|
}
|
|
})
|
|
.catch((e) => {
|
|
callback_error(e);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
preloadImages(
|
|
images,
|
|
callback_complete = () => {},
|
|
callback_load = (filepath) => {},
|
|
callback_error = (error_msg) => {}
|
|
) {
|
|
// flatten the images array
|
|
images = unique(images.flat());
|
|
|
|
var n_loaded = 0;
|
|
|
|
if (images.length === 0) {
|
|
callback_complete();
|
|
return;
|
|
}
|
|
|
|
for (let i = 0; i < images.length; i++) {
|
|
const img = new Image();
|
|
const src = images[i];
|
|
img.onload = () => {
|
|
n_loaded++;
|
|
callback_load(src);
|
|
if (n_loaded === images.length) {
|
|
callback_complete();
|
|
}
|
|
};
|
|
|
|
img.onerror = (e) => {
|
|
callback_error({ source: src, error: e });
|
|
};
|
|
|
|
img.src = src;
|
|
|
|
this.img_cache[src] = img;
|
|
this.preload_requests.push(img);
|
|
}
|
|
}
|
|
|
|
preloadVideo(
|
|
videos,
|
|
callback_complete = () => {},
|
|
callback_load = (filepath) => {},
|
|
callback_error = (error_msg) => {}
|
|
) {
|
|
// flatten the video array
|
|
videos = unique(videos.flat());
|
|
|
|
let n_loaded = 0;
|
|
|
|
if (videos.length === 0) {
|
|
callback_complete();
|
|
return;
|
|
}
|
|
|
|
for (const video of videos) {
|
|
const video_buffers = this.video_buffers;
|
|
|
|
//based on option 4 here: http://dinbror.dk/blog/how-to-preload-entire-html5-video-before-play-solved/
|
|
const request = new XMLHttpRequest();
|
|
request.open("GET", video, true);
|
|
request.responseType = "blob";
|
|
request.onload = () => {
|
|
if (request.status === 200 || request.status === 0) {
|
|
const videoBlob = request.response;
|
|
video_buffers[video] = URL.createObjectURL(videoBlob); // IE10+
|
|
n_loaded++;
|
|
callback_load(video);
|
|
if (n_loaded === videos.length) {
|
|
callback_complete();
|
|
}
|
|
}
|
|
};
|
|
request.onerror = (e) => {
|
|
let err: ProgressEvent | string = e;
|
|
if (request.status == 404) {
|
|
err = "404";
|
|
}
|
|
callback_error({ source: video, error: err });
|
|
};
|
|
request.onloadend = (e) => {
|
|
if (request.status == 404) {
|
|
callback_error({ source: video, error: "404" });
|
|
}
|
|
};
|
|
request.send();
|
|
this.preload_requests.push(request);
|
|
}
|
|
}
|
|
|
|
private preloadMap = new Map<string, Record<string, PreloadType>>();
|
|
|
|
getAutoPreloadList(timeline_description: any[]) {
|
|
/** Map each preload parameter type to a set of paths to be preloaded */
|
|
const preloadPaths = Object.fromEntries(
|
|
preloadParameterTypes.map((type) => [type, new Set<string>()])
|
|
);
|
|
|
|
const traverseTimeline = (node, inheritedTrialType?) => {
|
|
const isTimeline = typeof node.timeline !== "undefined";
|
|
|
|
if (isTimeline) {
|
|
for (const childNode of node.timeline) {
|
|
traverseTimeline(childNode, node.type ?? inheritedTrialType);
|
|
}
|
|
} else if ((node.type ?? inheritedTrialType)?.info) {
|
|
// node is a trial with type.info set
|
|
|
|
// Get the plugin name and parameters object from the info object
|
|
const { name: pluginName, parameters } = (node.type ?? inheritedTrialType).info;
|
|
|
|
// Extract parameters to be preloaded and their types from parameter info if this has not
|
|
// yet been done for `pluginName`
|
|
if (!this.preloadMap.has(pluginName)) {
|
|
this.preloadMap.set(
|
|
pluginName,
|
|
Object.fromEntries(
|
|
Object.entries<any>(parameters)
|
|
// Filter out parameter entries with media types and a non-false `preload` option
|
|
.filter(
|
|
([_name, { type, preload }]) =>
|
|
preloadParameterTypes.includes(type) && (preload ?? true)
|
|
)
|
|
// Map each entry's value to its parameter type
|
|
.map(([name, { type }]) => [name, type])
|
|
)
|
|
);
|
|
}
|
|
|
|
// Add preload paths from this trial
|
|
for (const [parameterName, parameterType] of Object.entries(
|
|
this.preloadMap.get(pluginName)
|
|
)) {
|
|
const parameterValue = node[parameterName];
|
|
const elements = preloadPaths[parameterType];
|
|
|
|
if (typeof parameterValue === "string") {
|
|
elements.add(parameterValue);
|
|
} else if (Array.isArray(parameterValue)) {
|
|
for (const element of parameterValue.flat()) {
|
|
if (typeof element === "string") {
|
|
elements.add(element);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
traverseTimeline({ timeline: timeline_description });
|
|
|
|
return {
|
|
images: [...preloadPaths[ParameterType.IMAGE]],
|
|
audio: [...preloadPaths[ParameterType.AUDIO]],
|
|
video: [...preloadPaths[ParameterType.VIDEO]],
|
|
};
|
|
}
|
|
|
|
cancelPreloads() {
|
|
for (const request of this.preload_requests) {
|
|
request.onload = () => {};
|
|
request.onerror = () => {};
|
|
request.oncanplaythrough = () => {};
|
|
request.onabort = () => {};
|
|
}
|
|
this.preload_requests = [];
|
|
}
|
|
|
|
private microphone_recorder: MediaRecorder = null;
|
|
|
|
initializeMicrophoneRecorder(stream: MediaStream) {
|
|
const recorder = new MediaRecorder(stream);
|
|
this.microphone_recorder = recorder;
|
|
}
|
|
|
|
getMicrophoneRecorder(): MediaRecorder {
|
|
return this.microphone_recorder;
|
|
}
|
|
|
|
private camera_stream: MediaStream = null;
|
|
private camera_recorder: MediaRecorder = null;
|
|
|
|
initializeCameraRecorder(stream: MediaStream, opts?: MediaRecorderOptions) {
|
|
this.camera_stream = stream;
|
|
const recorder = new MediaRecorder(stream, opts);
|
|
this.camera_recorder = recorder;
|
|
}
|
|
|
|
getCameraStream(): MediaStream {
|
|
return this.camera_stream;
|
|
}
|
|
|
|
getCameraRecorder(): MediaRecorder {
|
|
return this.camera_recorder;
|
|
}
|
|
}
|