jsPsych/packages/jspsych/src/modules/plugin-api/MediaAPI.ts
2024-07-15 17:08:32 -04:00

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