@jellyfish-dev/react-client-sdk
Version:
React client library for Jellyfish.
439 lines (438 loc) • 21.1 kB
JavaScript
import { NOT_FOUND_ERROR, OVERCONSTRAINED_ERROR, parseError, PERMISSION_DENIED, UNHANDLED_ERROR, } from "./types";
import { loadObject, saveObject } from "./localStorage";
import { getExactDeviceConstraint, prepareConstraints, prepareMediaTrackConstraints, toMediaTrackConstraints, } from "./constraints";
import EventEmitter from "events";
const removeExact = (trackConstraints) => {
if (typeof trackConstraints === "object") {
const copy = { ...trackConstraints };
delete copy["deviceId"];
return copy;
}
return trackConstraints;
};
const REQUESTING = "Requesting";
const NOT_REQUESTED = "Not requested";
const isVideo = (device) => device.kind === "videoinput";
const isAudio = (device) => device.kind === "audioinput";
const getDeviceInfo = (trackDeviceId, devices) => (trackDeviceId && devices.find(({ deviceId }) => trackDeviceId === deviceId)) || null;
const getCurrentDevicesSettings = (requestedDevices, mediaDeviceInfos) => {
const currentDevices = { videoinput: null, audioinput: null };
for (const track of requestedDevices.getTracks()) {
const settings = track.getSettings();
if (settings.deviceId) {
const currentDevice = mediaDeviceInfos.find((device) => device.deviceId == settings.deviceId);
const kind = currentDevice?.kind ?? null;
if ((currentDevice && kind === "videoinput") || kind === "audioinput") {
currentDevices[kind] = currentDevice ?? null;
}
}
}
return currentDevices;
};
const isDeviceDifferentFromLastSession = (lastDevice, currentDevice) => lastDevice && (currentDevice?.deviceId !== lastDevice.deviceId || currentDevice?.label !== lastDevice?.label);
const isAnyDeviceDifferentFromLastSession = (lastVideoDevice, lastAudioDevice, currentDevices) => !!((currentDevices?.videoinput &&
isDeviceDifferentFromLastSession(lastVideoDevice, currentDevices?.videoinput || null)) ||
(currentDevices?.audioinput &&
isDeviceDifferentFromLastSession(lastAudioDevice, currentDevices?.audioinput || null)));
const stopTracks = (requestedDevices) => {
for (const track of requestedDevices.getTracks()) {
track.stop();
}
};
const getMedia = async (constraints, previousErrors) => {
try {
const mediaStream = await navigator.mediaDevices.getUserMedia(constraints);
return { stream: mediaStream, type: "OK", constraints, previousErrors };
}
catch (error) {
const parsedError = parseError(error);
return { error: parsedError, type: "Error", constraints };
}
};
const handleNotFoundError = async (constraints) => {
const withoutVideo = await getMedia({ video: false, audio: constraints.audio }, {
video: NOT_FOUND_ERROR,
});
if (withoutVideo.type === "OK") {
return withoutVideo;
}
const withoutAudio = await getMedia({ video: constraints.video, audio: false }, { audio: NOT_FOUND_ERROR });
if (withoutAudio.type === "OK") {
return withoutAudio;
}
return await getMedia({ video: false, audio: false }, { audio: NOT_FOUND_ERROR, video: NOT_FOUND_ERROR });
};
const handleOverconstrainedError = async (constraints) => {
const notExactVideo = await getMedia({
video: removeExact(constraints.video),
audio: constraints.audio,
}, { video: OVERCONSTRAINED_ERROR });
if (notExactVideo.type === "OK" || notExactVideo.error?.name === "NotAllowedError") {
return notExactVideo;
}
const notExactAudio = await getMedia({
video: constraints.video,
audio: removeExact(constraints.audio),
}, { audio: OVERCONSTRAINED_ERROR });
if (notExactAudio.type === "OK" || notExactAudio.error?.name === "NotAllowedError") {
return notExactAudio;
}
return await getMedia({ video: removeExact(constraints.video), audio: removeExact(constraints.audio) }, {
video: OVERCONSTRAINED_ERROR,
audio: OVERCONSTRAINED_ERROR,
});
};
const handleNotAllowedError = async (constraints) => {
const withoutVideo = await getMedia({ video: false, audio: constraints.audio }, { video: PERMISSION_DENIED });
if (withoutVideo.type === "OK") {
return withoutVideo;
}
const withoutAudio = await getMedia({ video: constraints.video, audio: false }, { audio: PERMISSION_DENIED });
if (withoutAudio.type === "OK") {
return withoutAudio;
}
return await getMedia({ video: false, audio: false }, { video: PERMISSION_DENIED, audio: PERMISSION_DENIED });
};
const getError = (result, type) => {
if (result.type === "OK") {
return result.previousErrors[type] || null;
}
console.warn({ name: "Unhandled DeviceManager error", result });
return UNHANDLED_ERROR;
};
const prepareStatus = (requested, track, deviceError) => {
if (!requested)
return ["Not requested", null];
if (track)
return ["OK", null];
if (deviceError)
return ["Error", deviceError];
return ["Error", null];
};
const prepareDeviceState = (stream, track, devices, error, shouldAsk) => {
const deviceInfo = getDeviceInfo(track?.getSettings()?.deviceId || null, devices);
const [devicesStatus, newError] = prepareStatus(shouldAsk, track, error);
return {
devices,
devicesStatus,
media: {
stream: track ? stream : null,
track: track,
deviceInfo,
enabled: !!track,
},
mediaStatus: devicesStatus,
error: error ?? newError,
};
};
const LOCAL_STORAGE_VIDEO_DEVICE_KEY = "last-selected-video-device";
const LOCAL_STORAGE_AUDIO_DEVICE_KEY = "last-selected-audio-device";
const DISABLE_STORAGE_CONFIG = {
getLastVideoDevice: null,
getLastAudioDevice: null,
saveLastAudioDevice: () => { },
saveLastVideoDevice: () => { },
};
const LOCAL_STORAGE_CONFIG = {
getLastAudioDevice: () => loadObject(LOCAL_STORAGE_AUDIO_DEVICE_KEY, null),
saveLastAudioDevice: (info) => saveObject(LOCAL_STORAGE_AUDIO_DEVICE_KEY, info),
getLastVideoDevice: () => loadObject(LOCAL_STORAGE_VIDEO_DEVICE_KEY, null),
saveLastVideoDevice: (info) => saveObject(LOCAL_STORAGE_VIDEO_DEVICE_KEY, info),
};
export class DeviceManager extends EventEmitter {
defaultAudioConstraints;
defaultVideoConstraints;
defaultStorageConfig;
audioConstraints;
videoConstraints;
storageConfig;
status = "uninitialized";
video = {
media: null,
mediaStatus: "Not requested",
devices: null,
devicesStatus: "Not requested",
error: null,
};
audio = {
media: null,
mediaStatus: "Not requested",
devices: null,
devicesStatus: "Not requested",
error: null,
};
constructor(defaultConfig) {
super();
this.defaultStorageConfig = this.createStorageConfig(defaultConfig?.storage);
this.defaultAudioConstraints = defaultConfig?.audioTrackConstraints
? toMediaTrackConstraints(defaultConfig.audioTrackConstraints)
: undefined;
this.defaultVideoConstraints = defaultConfig?.videoTrackConstraints
? toMediaTrackConstraints(defaultConfig.videoTrackConstraints)
: undefined;
}
createStorageConfig(storage) {
if (storage === false)
return DISABLE_STORAGE_CONFIG;
if (storage === true || storage === undefined)
return LOCAL_STORAGE_CONFIG;
return storage;
}
getVideoConstraints(currentConstraints) {
if (currentConstraints === false)
return undefined;
if (currentConstraints === undefined || currentConstraints === true)
return this.videoConstraints ?? this?.defaultVideoConstraints;
return currentConstraints ?? this.videoConstraints ?? this?.defaultVideoConstraints;
}
getAudioConstraints(currentConstraints) {
if (currentConstraints === false)
return undefined;
if (currentConstraints === undefined || currentConstraints === true)
return this.audioConstraints ?? this?.defaultAudioConstraints;
return currentConstraints ?? this.audioConstraints ?? this?.defaultAudioConstraints;
}
getStatus() {
return this.status;
}
async init(config) {
if (this.status !== "uninitialized") {
return Promise.reject("Device manager already initialized");
}
this.status = "initializing";
if (!navigator?.mediaDevices) {
console.error("Cannot initialize DeviceManager. Navigator is available only in secure contexts");
return Promise.resolve("error");
}
const videoTrackConstraints = this.getVideoConstraints(config?.videoTrackConstraints);
const audioTrackConstraints = this.getAudioConstraints(config?.audioTrackConstraints);
const previousVideoDevice = this.getLastVideoDevice() ?? null;
const previousAudioDevice = this.getLastAudioDevice() ?? null;
const shouldAskForVideo = !!videoTrackConstraints;
const shouldAskForAudio = !!audioTrackConstraints;
this.video.devicesStatus =
shouldAskForVideo && videoTrackConstraints ? REQUESTING : this.video.devicesStatus ?? NOT_REQUESTED;
this.audio.devicesStatus =
shouldAskForAudio && audioTrackConstraints ? REQUESTING : this.audio.devicesStatus ?? NOT_REQUESTED;
this.video.mediaStatus = this.video.devicesStatus;
this.audio.mediaStatus = this.audio.devicesStatus;
this.emit("managerStarted", { trackType: "audiovideo", videoConstraints: videoTrackConstraints, audioConstraints: audioTrackConstraints }, {
audio: this.audio,
video: this.video,
});
let requestedDevices = null;
const constraints = {
video: shouldAskForVideo && getExactDeviceConstraint(videoTrackConstraints, previousVideoDevice?.deviceId),
audio: shouldAskForAudio && getExactDeviceConstraint(audioTrackConstraints, previousAudioDevice?.deviceId),
};
let result = await getMedia(constraints, {});
if (result.type === "Error" && result.error?.name === "NotFoundError") {
result = await handleNotFoundError(constraints);
}
if (result.type === "Error" && result.error?.name === "OverconstrainedError") {
result = await handleOverconstrainedError(result.constraints);
}
if (result.type === "Error" && result.error?.name === "NotAllowedError") {
result = await handleNotAllowedError(result.constraints);
}
const mediaDeviceInfos = await navigator.mediaDevices.enumerateDevices();
if (result.type === "OK") {
requestedDevices = result.stream;
// Safari changes deviceId between sessions, therefore we cannot rely on deviceId for identification purposes.
// We can switch a random device that comes from safari to one that has the same label as the one used in the previous session.
const currentDevices = getCurrentDevicesSettings(requestedDevices, mediaDeviceInfos);
const shouldCorrectDevices = isAnyDeviceDifferentFromLastSession(previousVideoDevice, previousAudioDevice, currentDevices);
if (shouldCorrectDevices) {
const videoIdToStart = mediaDeviceInfos.find((info) => info.label === previousVideoDevice?.label)?.deviceId;
const audioIdToStart = mediaDeviceInfos.find((info) => info.label === previousAudioDevice?.label)?.deviceId;
if (videoIdToStart || audioIdToStart) {
stopTracks(requestedDevices);
const exactConstraints = {
video: !!result.constraints.video && prepareConstraints(videoIdToStart, videoTrackConstraints),
audio: !!result.constraints.video && prepareConstraints(audioIdToStart, audioTrackConstraints),
};
const correctedResult = await getMedia(exactConstraints, result.previousErrors);
if (correctedResult.type === "OK") {
requestedDevices = correctedResult.stream;
}
else {
console.error("Device Manager unexpected error");
}
}
}
}
const video = prepareDeviceState(requestedDevices, requestedDevices?.getVideoTracks()[0] || null, mediaDeviceInfos.filter(isVideo), getError(result, "video"), shouldAskForVideo);
const audio = prepareDeviceState(requestedDevices, requestedDevices?.getAudioTracks()[0] || null, mediaDeviceInfos.filter(isAudio), getError(result, "audio"), shouldAskForAudio);
this.video = video;
this.audio = audio;
if (video.media?.deviceInfo) {
this.saveLastVideoDevice(video.media?.deviceInfo);
}
if (audio.media?.deviceInfo) {
this.saveLastAudioDevice(audio.media?.deviceInfo);
}
this.setupOnEndedCallback();
this.emit("managerInitialized", { audio, video }, { audio: this.audio, video: this.video });
return Promise.resolve("initialized");
}
setupOnEndedCallback() {
if (this.video?.media?.track) {
this.video.media.track.addEventListener("ended", async (event) => await this.onTrackEnded("video", event.target.id));
}
if (this.audio?.media?.track) {
this.audio.media.track.addEventListener("ended", async (event) => await this.onTrackEnded("audio", event.target.id));
}
}
onTrackEnded = async (type, trackId) => {
if (trackId === this?.[type].media?.track?.id) {
await this.stop(type);
}
};
getLastVideoDevice() {
return this.storageConfig?.getLastVideoDevice?.() ?? this.defaultStorageConfig?.getLastVideoDevice?.() ?? null;
}
saveLastVideoDevice(info) {
if (this.storageConfig?.saveLastVideoDevice) {
this.storageConfig.saveLastVideoDevice(info);
}
else {
this.defaultStorageConfig.saveLastVideoDevice?.(info);
}
}
saveLastAudioDevice(info) {
if (this.storageConfig?.saveLastAudioDevice) {
this.storageConfig.saveLastAudioDevice(info);
}
else {
this.defaultStorageConfig.saveLastAudioDevice?.(info);
}
}
getLastAudioDevice() {
return this.storageConfig?.getLastAudioDevice?.() ?? this.defaultStorageConfig?.getLastAudioDevice?.() ?? null;
}
// todo `audioDeviceId / videoDeviceId === true` means use last device
async start({ audioDeviceId, videoDeviceId }) {
const shouldRestartVideo = !!videoDeviceId && videoDeviceId !== this.video.media?.deviceInfo?.deviceId;
const shouldRestartAudio = !!audioDeviceId && audioDeviceId !== this.audio.media?.deviceInfo?.deviceId;
const newVideoDevice = videoDeviceId === true ? this.getLastVideoDevice?.()?.deviceId || true : videoDeviceId;
const newAudioDevice = audioDeviceId === true ? this.getLastAudioDevice?.()?.deviceId || true : audioDeviceId;
const videoTrackConstraints = this.getVideoConstraints(true);
const audioTrackConstraints = this.getAudioConstraints(true);
const exactConstraints = {
video: shouldRestartVideo && prepareMediaTrackConstraints(newVideoDevice, videoTrackConstraints),
audio: shouldRestartAudio && prepareMediaTrackConstraints(newAudioDevice, audioTrackConstraints),
};
if (!exactConstraints.video && !exactConstraints.audio)
return;
if (exactConstraints.audio) {
this.audio.mediaStatus = "Requesting";
}
if (exactConstraints.video) {
this.video.mediaStatus = "Requesting";
}
this.emit("devicesStarted", {
audio: { ...this.audio, restarting: shouldRestartAudio, constraints: audioDeviceId },
video: { ...this.video, restarting: shouldRestartVideo, constraints: videoDeviceId },
}, { audio: this.audio, video: this.video });
const result = await getMedia(exactConstraints, {});
if (result.type === "OK") {
const stream = result.stream;
const currentVideoDeviceId = result.stream.getVideoTracks()?.[0]?.getSettings()?.deviceId;
const videoInfo = currentVideoDeviceId ? getDeviceInfo(currentVideoDeviceId, this.video.devices ?? []) : null;
if (videoInfo) {
this.saveLastVideoDevice?.(videoInfo);
}
const currentAudioDeviceId = result.stream.getAudioTracks()?.[0]?.getSettings()?.deviceId;
const audioInfo = currentAudioDeviceId ? getDeviceInfo(currentAudioDeviceId, this.audio.devices ?? []) : null;
if (audioInfo) {
this.saveLastAudioDevice(audioInfo);
}
// The device manager assumes that there is only one audio and video track.
// All previous tracks are deactivated even if the browser is able to handle multiple active sessions. (Chrome, Firefox)
//
// Safari always deactivates the track and emits the `ended` event.
// Its handling is asynchronous and can be executed even before returning a value from the re-execution of `getUserMedia`.
// In such a case, the tracks are already deactivated at this point (logic in `onTrackEnded` method).
// The track is null, so the stop method will not execute.
//
// However, if Safari has not yet handled this event, the tracks are manually stopped at this point.
// Manually stopping tracks on its own does not generate the `ended` event.
// The ended event in Safari has already been emitted and will be handled in the future.
// Therefore, in the `onTrackEnded` method, events for already stopped tracks are filtered out to prevent the state from being damaged.
if (shouldRestartVideo) {
this.video?.media?.track?.stop();
}
if (shouldRestartAudio) {
this.audio?.media?.track?.stop();
}
const videoMedia = shouldRestartVideo
? {
stream: stream,
track: stream.getVideoTracks()[0] || null,
deviceInfo: videoInfo,
enabled: true,
}
: this.video.media;
const audioMedia = shouldRestartAudio
? {
stream: stream,
track: stream.getAudioTracks()[0] || null,
deviceInfo: audioInfo,
enabled: true,
}
: this.audio.media;
this.video.media = videoMedia;
this.audio.media = audioMedia;
this.setupOnEndedCallback();
if (exactConstraints.audio) {
this.audio.mediaStatus = "OK";
}
if (exactConstraints.video) {
this.video.mediaStatus = "OK";
}
this.emit("devicesReady", {
video: { ...this.video, restarted: shouldRestartVideo },
audio: { ...this.audio, restarted: shouldRestartAudio },
}, { audio: this.audio, video: this.video });
}
else {
const parsedError = result.error;
const event = {
parsedError,
constraints: exactConstraints,
};
const videoError = exactConstraints.video ? parsedError : this.video.error;
const audioError = exactConstraints.audio ? parsedError : this.audio.error;
this.video.error = videoError;
this.audio.error = audioError;
this.emit("error", event, { audio: this.audio, video: this.video });
}
}
async stop(type) {
this[type].media?.track?.stop();
this[type].media = null;
this.emit("deviceStopped", { trackType: type }, { audio: this.audio, video: this.video });
}
setEnable(type, value) {
if (!this[type].media || !this[type].media?.track) {
return;
}
this[type].media.track.enabled = value;
this[type].media.enabled = value;
if (value) {
this.emit("deviceEnabled", { trackType: type }, { audio: this.audio, video: this.video });
}
else {
this.emit("deviceDisabled", { trackType: type }, { audio: this.audio, video: this.video });
}
}
setConfig(config) {
this.storageConfig = this.createStorageConfig(config?.storage);
this.audioConstraints = config?.audioTrackConstraints
? toMediaTrackConstraints(config.audioTrackConstraints)
: undefined;
this.videoConstraints = config?.videoTrackConstraints
? toMediaTrackConstraints(config.videoTrackConstraints)
: undefined;
}
}