livekit-client
Version:
JavaScript/TypeScript client SDK for LiveKit
228 lines (210 loc) • 8.1 kB
text/typescript
import DeviceManager from '../DeviceManager';
import { audioDefaults, videoDefaults } from '../defaults';
import { DeviceUnsupportedError, TrackInvalidError } from '../errors';
import { mediaTrackToLocalTrack } from '../participant/publishUtils';
import type { LoggerOptions } from '../types';
import { isAudioTrack, isSafari17Based, isVideoTrack, unwrapConstraint } from '../utils';
import LocalAudioTrack from './LocalAudioTrack';
import type LocalTrack from './LocalTrack';
import LocalVideoTrack from './LocalVideoTrack';
import { Track } from './Track';
import type {
AudioCaptureOptions,
CreateLocalTracksOptions,
ScreenShareCaptureOptions,
VideoCaptureOptions,
} from './options';
import { ScreenSharePresets } from './options';
import {
constraintsForOptions,
extractProcessorsFromOptions,
mergeDefaultOptions,
screenCaptureToDisplayMediaStreamOptions,
} from './utils';
/**
* Creates a local video and audio track at the same time. When acquiring both
* audio and video tracks together, it'll display a single permission prompt to
* the user instead of two separate ones.
* @param options
*/
export async function createLocalTracks(
options?: CreateLocalTracksOptions,
loggerOptions?: LoggerOptions,
): Promise<Array<LocalTrack>> {
options ??= {};
let attemptExactMatch = false;
const {
audioProcessor,
videoProcessor,
optionsWithoutProcessor: internalOptions,
} = extractProcessorsFromOptions(options);
let retryAudioOptions: AudioCaptureOptions | undefined | boolean = internalOptions.audio;
let retryVideoOptions: VideoCaptureOptions | undefined | boolean = internalOptions.video;
if (audioProcessor && typeof internalOptions.audio === 'object') {
internalOptions.audio.processor = audioProcessor;
}
if (videoProcessor && typeof internalOptions.video === 'object') {
internalOptions.video.processor = videoProcessor;
}
// if the user passes a device id as a string, we default to exact match
if (
options.audio &&
typeof internalOptions.audio === 'object' &&
typeof internalOptions.audio.deviceId === 'string'
) {
const deviceId: string = internalOptions.audio.deviceId;
internalOptions.audio.deviceId = { exact: deviceId };
attemptExactMatch = true;
retryAudioOptions = {
...internalOptions.audio,
deviceId: { ideal: deviceId },
};
}
if (
internalOptions.video &&
typeof internalOptions.video === 'object' &&
typeof internalOptions.video.deviceId === 'string'
) {
const deviceId: string = internalOptions.video.deviceId;
internalOptions.video.deviceId = { exact: deviceId };
attemptExactMatch = true;
retryVideoOptions = {
...internalOptions.video,
deviceId: { ideal: deviceId },
};
}
if (internalOptions.audio === true) {
internalOptions.audio = { deviceId: 'default' };
} else if (typeof internalOptions.audio === 'object' && internalOptions.audio !== null) {
internalOptions.audio = {
...internalOptions.audio,
deviceId: internalOptions.audio.deviceId || 'default',
};
}
if (internalOptions.video === true) {
internalOptions.video = { deviceId: 'default' };
} else if (typeof internalOptions.video === 'object' && !internalOptions.video.deviceId) {
internalOptions.video.deviceId = 'default';
}
const opts = mergeDefaultOptions(internalOptions, audioDefaults, videoDefaults);
const constraints = constraintsForOptions(opts);
// Keep a reference to the promise on DeviceManager and await it in getLocalDevices()
// works around iOS Safari Bug https://bugs.webkit.org/show_bug.cgi?id=179363
const mediaPromise = navigator.mediaDevices.getUserMedia(constraints);
if (internalOptions.audio) {
DeviceManager.userMediaPromiseMap.set('audioinput', mediaPromise);
mediaPromise.catch(() => DeviceManager.userMediaPromiseMap.delete('audioinput'));
}
if (internalOptions.video) {
DeviceManager.userMediaPromiseMap.set('videoinput', mediaPromise);
mediaPromise.catch(() => DeviceManager.userMediaPromiseMap.delete('videoinput'));
}
try {
const stream = await mediaPromise;
return await Promise.all(
stream.getTracks().map(async (mediaStreamTrack) => {
const isAudio = mediaStreamTrack.kind === 'audio';
let trackOptions = isAudio ? opts!.audio : opts!.video;
if (typeof trackOptions === 'boolean' || !trackOptions) {
trackOptions = {};
}
let trackConstraints: MediaTrackConstraints | undefined;
const conOrBool = isAudio ? constraints.audio : constraints.video;
if (typeof conOrBool !== 'boolean') {
trackConstraints = conOrBool;
}
// update the constraints with the device id the user gave permissions to in the permission prompt
// otherwise each track restart (e.g. mute - unmute) will try to initialize the device again -> causing additional permission prompts
const newDeviceId = mediaStreamTrack.getSettings().deviceId;
if (
trackConstraints?.deviceId &&
unwrapConstraint(trackConstraints.deviceId) !== newDeviceId
) {
trackConstraints.deviceId = newDeviceId;
} else if (!trackConstraints) {
trackConstraints = { deviceId: newDeviceId };
}
const track = mediaTrackToLocalTrack(mediaStreamTrack, trackConstraints, loggerOptions);
if (track.kind === Track.Kind.Video) {
track.source = Track.Source.Camera;
} else if (track.kind === Track.Kind.Audio) {
track.source = Track.Source.Microphone;
}
track.mediaStream = stream;
if (isAudioTrack(track) && audioProcessor) {
await track.setProcessor(audioProcessor);
} else if (isVideoTrack(track) && videoProcessor) {
await track.setProcessor(videoProcessor);
}
return track;
}),
);
} catch (e) {
if (!attemptExactMatch) {
throw e;
}
return createLocalTracks(
{
...options,
audio: retryAudioOptions,
video: retryVideoOptions,
},
loggerOptions,
);
}
}
/**
* Creates a [[LocalVideoTrack]] with getUserMedia()
* @param options
*/
export async function createLocalVideoTrack(
options?: VideoCaptureOptions,
): Promise<LocalVideoTrack> {
const tracks = await createLocalTracks({
audio: false,
video: options ?? true,
});
return <LocalVideoTrack>tracks[0];
}
export async function createLocalAudioTrack(
options?: AudioCaptureOptions,
): Promise<LocalAudioTrack> {
const tracks = await createLocalTracks({
audio: options ?? true,
video: false,
});
return <LocalAudioTrack>tracks[0];
}
/**
* Creates a screen capture tracks with getDisplayMedia().
* A LocalVideoTrack is always created and returned.
* If { audio: true }, and the browser supports audio capture, a LocalAudioTrack is also created.
*/
export async function createLocalScreenTracks(
options?: ScreenShareCaptureOptions,
): Promise<Array<LocalTrack>> {
if (options === undefined) {
options = {};
}
if (options.resolution === undefined && !isSafari17Based()) {
options.resolution = ScreenSharePresets.h1080fps30.resolution;
}
if (navigator.mediaDevices.getDisplayMedia === undefined) {
throw new DeviceUnsupportedError('getDisplayMedia not supported');
}
const constraints = screenCaptureToDisplayMediaStreamOptions(options);
const stream: MediaStream = await navigator.mediaDevices.getDisplayMedia(constraints);
const tracks = stream.getVideoTracks();
if (tracks.length === 0) {
throw new TrackInvalidError('no video track found');
}
const screenVideo = new LocalVideoTrack(tracks[0], undefined, false);
screenVideo.source = Track.Source.ScreenShare;
const localTracks: Array<LocalTrack> = [screenVideo];
if (stream.getAudioTracks().length > 0) {
const screenAudio = new LocalAudioTrack(stream.getAudioTracks()[0], undefined, false);
screenAudio.source = Track.Source.ScreenShareAudio;
localTracks.push(screenAudio);
}
return localTracks;
}