@casual-simulation/aux-vm-browser
Version:
A set of utilities required to securely run an AUX in a web browser.
660 lines • 25.9 kB
JavaScript
import { asyncResult, hasValue, ON_ROOM_JOINED, ON_ROOM_LEAVE, ON_ROOM_OPTIONS_CHANGED, ON_ROOM_REMOTE_JOINED, ON_ROOM_REMOTE_LEAVE, ON_ROOM_SPEAKERS_CHANGED, ON_ROOM_STREAMING, ON_ROOM_STREAM_LOST, ON_ROOM_TRACK_SUBSCRIBED, ON_ROOM_TRACK_UNSUBSCRIBED, } from '@casual-simulation/aux-common';
import { Subject } from 'rxjs';
/**
* Defines a class that is able to manage Livekit rooms and make streams available to scripts.
*/
export class LivekitManager {
/**
* Gets an observable that resolves whenever a track needs to be attached to the document.
*/
get onTrackNeedsAttachment() {
return this._onTrackNeedsAttachment;
}
/**
* Gets an observable that resolves whenever a track needs to be detached from the document.
*/
get onTrackNeedsDetachment() {
return this._onTrackNeedsDetachment;
}
get closed() {
return this._closed;
}
constructor(helper) {
this._rooms = [];
this._addressToTrack = new Map();
this._addressToPublication = new Map();
this._addressToParticipant = new Map();
this._addressToVideo = new Map();
this._trackToAddress = new Map();
this._closed = false;
this._onTrackNeedsAttachment = new Subject();
this._onTrackNeedsDetachment = new Subject();
this._helper = helper;
}
unsubscribe() {
if (this._closed) {
return;
}
this._closed = true;
for (let room of this._rooms) {
room.disconnect(true);
}
this._rooms = [];
this._addressToTrack = null;
this._trackToAddress = null;
}
async _requestMediaPermissions() {
// Request audio and video permission (required)
try {
await navigator.mediaDevices.getUserMedia({
video: true,
audio: true,
});
console.log('[LivekitManager] Media permissions granted.');
}
catch (error) {
console.error('[LivekitManager] Media permissions denied:', error);
throw new Error('Media permissions are required to join the room.');
}
}
async joinRoom(join) {
try {
await this._requestMediaPermissions();
this._livekit = await import('livekit-client');
const room = new this._livekit.Room({
adaptiveStream: false,
dynacast: true,
...join.options,
});
room.on(this._livekit.RoomEvent.TrackSubscribed, this._onTrackSubscribed(room))
.on(this._livekit.RoomEvent.TrackUnsubscribed, this._onTrackUnsubscribed(room))
.on(this._livekit.RoomEvent.Disconnected, this._onDisconnected(room))
.on(this._livekit.RoomEvent.Reconnected, this._onReconnected(room))
.on(this._livekit.RoomEvent.LocalTrackPublished, this._onLocalTrackPublished(room))
.on(this._livekit.RoomEvent.LocalTrackUnpublished, this._onLocalTrackUnpublished(room))
.on(this._livekit.RoomEvent.TrackMuted, this._onTrackMuted(room))
.on(this._livekit.RoomEvent.TrackUnmuted, this._onTrackUnmuted(room))
.on(this._livekit.RoomEvent.ActiveSpeakersChanged, this._onActiveSpeakersChanged(room))
.on(this._livekit.RoomEvent.ParticipantConnected, this._onParticipantConnected(room))
.on(this._livekit.RoomEvent.ParticipantDisconnected, this._onParticipantDisconnected(room));
await room.connect(join.url, join.token, {});
try {
await this._setRoomOptions(room, {
video: true,
audio: true,
...join.options,
});
}
catch (err) {
console.warn('[LivekitManager] Unable to set room options:', err);
}
this._rooms.push(room);
const options = this._getRoomOptions(room);
join.resolve(options);
let actions = [
{
eventName: ON_ROOM_JOINED,
bots: null,
arg: { roomName: room.name, options },
},
{
eventName: ON_ROOM_STREAMING,
bots: null,
arg: { roomName: room.name, options },
},
];
// Send initial ON_ROOM_REMOTE_JOINED events
for (let participant of room.remoteParticipants.values()) {
actions.push({
eventName: ON_ROOM_REMOTE_JOINED,
bots: null,
arg: {
roomName: room.name,
remoteId: participant.identity,
},
});
}
this._helper.transaction(...this._helper.actions(actions));
}
catch (err) {
join.reject('server_error', err.toString());
}
}
async leaveRoom(leave) {
try {
const index = this._rooms.findIndex((r) => r.name === leave.roomName);
let room;
let actions = [];
if (index >= 0) {
room = this._rooms[index];
this._rooms.splice(index, 1);
if (room) {
// Send ON_ROOM_REMOTE_LEAVE events
for (let participant of room.remoteParticipants.values()) {
actions.push({
eventName: ON_ROOM_REMOTE_LEAVE,
bots: null,
arg: {
roomName: room.name,
remoteId: participant.identity,
},
});
}
room.disconnect(true);
}
}
leave.resolve();
if (room) {
actions.push({
eventName: ON_ROOM_STREAM_LOST,
bots: null,
arg: { roomName: leave.roomName },
}, {
eventName: ON_ROOM_LEAVE,
bots: null,
arg: { roomName: leave.roomName },
});
this._helper.transaction(...this._helper.actions(actions));
}
}
catch (err) {
leave.reject('error', err.toString());
}
}
async setRoomOptions(setRoomOptions) {
try {
const room = this._rooms.find((r) => r.name === setRoomOptions.roomName);
if (!room || !this._livekit) {
setRoomOptions.reject('room_not_found', 'The specified room was not found.');
return;
}
const changed = await this._setRoomOptions(room, setRoomOptions.options);
const options = this._getRoomOptions(room);
let rejected = false;
for (let key in setRoomOptions.options) {
const targetValue = setRoomOptions.options[key];
const currentValue = options[key];
if (targetValue !== currentValue) {
setRoomOptions.reject('error', `Unable to set "${key}" to ${targetValue}`);
rejected = true;
}
}
if (!rejected) {
setRoomOptions.resolve(options);
}
if (changed) {
this._helper.action(ON_ROOM_OPTIONS_CHANGED, null, {
roomName: room.name,
options,
});
}
}
catch (err) {
setRoomOptions.reject('error', err.toString());
}
}
async _setRoomOptions(room, options) {
let promises = [];
if ('video' in options) {
promises.push(room.localParticipant.setCameraEnabled(!!options.video));
}
if ('audio' in options) {
promises.push(room.localParticipant.setMicrophoneEnabled(!!options.audio));
}
if ('screen' in options) {
promises.push(room.localParticipant.setScreenShareEnabled(!!options.screen));
}
const results = await Promise.allSettled(promises);
return results.some((r) => r.status === 'fulfilled');
}
async getRoomOptions(getRoomOptions) {
try {
const room = this._rooms.find((r) => r.name === getRoomOptions.roomName);
if (!room) {
getRoomOptions.reject('room_not_found', 'The specified room was not found.');
return;
}
const options = this._getRoomOptions(room);
getRoomOptions.resolve(options);
}
catch (err) {
getRoomOptions.reject('error', err.toString());
}
}
_getRoomOptions(room) {
return this._getParticipantOptions(room.localParticipant);
}
_getParticipantOptions(participant) {
let options = {
video: false,
audio: false,
screen: false,
};
if (this._livekit) {
for (let [id, pub] of participant.trackPublications) {
if (!pub.isMuted && pub.isEnabled) {
if (pub.kind === this._livekit.Track.Kind.Audio &&
pub.source === this._livekit.Track.Source.Microphone) {
options.audio = true;
}
else if (pub.kind === this._livekit.Track.Kind.Video &&
pub.source === this._livekit.Track.Source.Camera) {
options.video = true;
}
else if (pub.source === this._livekit.Track.Source.ScreenShare) {
options.screen = true;
}
}
}
}
return options;
}
handleEvents(events) {
for (let event of events) {
if (event.type === 'get_room_track_options') {
this._getRoomTrackOptions(event);
}
else if (event.type === 'set_room_track_options') {
this._setRoomTrackOptions(event);
}
else if (event.type === 'get_room_remote_options') {
this._getRoomRemoteOptions(event);
}
}
}
async _getRoomTrackOptions(event) {
try {
const room = this._rooms.find((r) => r.name === event.roomName);
if (!room || !this._livekit) {
this._helper.transaction(asyncResult(event.taskId, {
success: false,
errorCode: 'room_not_found',
errorMessage: 'The specified room was not found.',
roomName: event.roomName,
}));
return;
}
const pub = this._addressToPublication.get(event.address);
const participant = this._addressToParticipant.get(event.address);
if (!pub || !participant) {
this._helper.transaction(asyncResult(event.taskId, {
success: false,
errorCode: 'track_not_found',
errorMessage: 'The specified track was not found.',
roomName: event.roomName,
address: event.address,
}));
return;
}
const options = this._getTrackOptions(pub, participant);
this._helper.transaction(asyncResult(event.taskId, {
success: true,
roomName: room.name,
address: event.address,
options,
}));
}
catch (err) {
if (hasValue(event.taskId)) {
this._helper.transaction(asyncResult(event.taskId, {
success: false,
errorCode: 'error',
errorMessage: err.toString(),
roomName: event.roomName,
address: event.address,
}));
}
}
}
async _setRoomTrackOptions(event) {
try {
const room = this._rooms.find((r) => r.name === event.roomName);
if (!room || !this._livekit) {
this._helper.transaction(asyncResult(event.taskId, {
success: false,
errorCode: 'room_not_found',
errorMessage: 'The specified room was not found.',
roomName: event.roomName,
}));
return;
}
const pub = this._addressToPublication.get(event.address);
const participant = this._addressToParticipant.get(event.address);
if (!pub || !participant) {
this._helper.transaction(asyncResult(event.taskId, {
success: false,
errorCode: 'track_not_found',
errorMessage: 'The specified track was not found.',
roomName: event.roomName,
address: event.address,
}));
return;
}
let promises = [];
if ('muted' in event.options) {
if (pub instanceof this._livekit.LocalTrackPublication) {
if (event.options.muted) {
promises.push(pub.mute().then(() => { }));
}
else {
promises.push(pub.unmute().then(() => { }));
}
}
else if (pub instanceof this._livekit.RemoteTrackPublication) {
pub.setEnabled(!event.options.muted);
}
}
if ('videoQuality' in event.options) {
if (pub instanceof this._livekit.RemoteTrackPublication) {
let quality = this._livekit.VideoQuality.HIGH;
if (event.options.videoQuality === 'medium') {
quality = this._livekit.VideoQuality.MEDIUM;
}
else if (event.options.videoQuality === 'low') {
quality = this._livekit.VideoQuality.LOW;
}
else if (event.options.videoQuality === 'high') {
quality = this._livekit.VideoQuality.HIGH;
}
else if (event.options.videoQuality === 'off') {
pub.setEnabled(false);
}
pub.setVideoQuality(quality);
}
}
await Promise.allSettled(promises);
const options = this._getTrackOptions(pub, participant);
this._helper.transaction(asyncResult(event.taskId, {
success: true,
roomName: room.name,
address: event.address,
options,
}));
}
catch (err) {
if (hasValue(event.taskId)) {
this._helper.transaction(asyncResult(event.taskId, {
success: false,
errorCode: 'error',
errorMessage: err.toString(),
roomName: event.roomName,
address: event.address,
}));
}
}
}
_findRemoteParticipant(room, identity) {
for (let p of room.remoteParticipants.values()) {
if (p.identity === identity) {
return p;
}
}
return null;
}
async _getRoomRemoteOptions(event) {
try {
const room = this._rooms.find((r) => r.name === event.roomName);
if (!room || !this._livekit) {
this._helper.transaction(asyncResult(event.taskId, {
success: false,
errorCode: 'room_not_found',
errorMessage: 'The specified room was not found.',
roomName: event.roomName,
}));
return;
}
const participant = this._findRemoteParticipant(room, event.remoteId);
if (!participant) {
this._helper.transaction(asyncResult(event.taskId, {
success: false,
errorCode: 'remote_not_found',
errorMessage: 'The specified remote was not found.',
roomName: event.roomName,
remoteId: event.remoteId,
}));
return;
}
const basicOptions = this._getParticipantOptions(participant);
const options = {
...basicOptions,
audioLevel: participant.audioLevel,
connectionQuality: participant.connectionQuality,
};
this._helper.transaction(asyncResult(event.taskId, {
success: true,
roomName: room.name,
remoteId: event.remoteId,
options,
}));
}
catch (err) {
if (hasValue(event.taskId)) {
this._helper.transaction(asyncResult(event.taskId, {
success: false,
errorCode: 'error',
errorMessage: err.toString(),
roomName: event.roomName,
remoteId: event.remoteId,
}));
}
}
}
/**
* Gets the media stream that is referenced by the given address.
* @param address The address that should be used.
*/
getMediaByAddress(address) {
const track = this._addressToTrack.get(address);
if (track) {
return track.mediaStream;
}
return null;
}
/**
* Gets the video that is referenced by the given address.
* @param address The address that should be used.
*/
getVideoByAddress(address) {
const track = this._addressToVideo.get(address);
if (track) {
return track;
}
return null;
}
_onTrackSubscribed(room) {
return (track, pub, participant) => {
console.log('[LivekitManager] Track subscribed!', track);
const address = this._getTrackAddress(pub, participant);
this._saveTrack(address, track, pub, participant);
this._helper.action(ON_ROOM_TRACK_SUBSCRIBED, null, this._trackArg(room.name, pub, participant, address, track));
if (track.kind === this._livekit.Track.Kind.Audio ||
track.kind === this._livekit.Track.Kind.Video) {
this._onTrackNeedsAttachment.next(track);
}
};
}
_onTrackUnsubscribed(room) {
return (track, pub, participant) => {
console.log('[LivekitManager] Track unsubscribed!', track);
const address = this._deleteTrack(track);
if (address) {
this._helper.action(ON_ROOM_TRACK_UNSUBSCRIBED, null, this._trackArg(room.name, pub, participant, address, track));
}
if (track.kind === this._livekit.Track.Kind.Audio ||
track.kind === this._livekit.Track.Kind.Video) {
this._onTrackNeedsDetachment.next(track);
}
};
}
_onLocalTrackPublished(room) {
return (pub, participant) => {
const track = pub.track;
console.log('[LivekitManager] Track subscribed!', track);
const address = this._getTrackAddress(pub, participant);
this._saveTrack(address, track, pub, participant);
this._helper.action(ON_ROOM_TRACK_SUBSCRIBED, null, this._trackArg(room.name, pub, participant, address, track));
if (track.kind === this._livekit.Track.Kind.Video) {
this._onTrackNeedsAttachment.next(track);
}
};
}
_onLocalTrackUnpublished(room) {
return (pub, participant) => {
const track = pub.track;
console.log('[LivekitManager] Track unsubscribed!', track);
const address = this._deleteTrack(track);
if (address) {
this._helper.action(ON_ROOM_TRACK_UNSUBSCRIBED, null, this._trackArg(room.name, pub, participant, address, track));
}
if (track.kind === this._livekit.Track.Kind.Video) {
this._onTrackNeedsDetachment.next(track);
}
};
}
_trackArg(roomName, pub, participant, address, track) {
return {
roomName: roomName,
address: address,
...this._getTrackOptions(pub, participant, track),
};
}
_getTrackOptions(pub, participant, t) {
const track = t ?? pub.track;
const isRemote = pub instanceof this._livekit.RemoteTrackPublication;
const common = {
isRemote: isRemote,
remoteId: participant.identity,
muted: pub.isMuted || !pub.isEnabled,
kind: this._getTrackKind(track),
source: track.source,
};
if (pub.kind === this._livekit.Track.Kind.Video) {
return {
...common,
dimensions: pub.dimensions,
aspectRatio: pub.dimensions.width / pub.dimensions.height,
videoQuality: this._getTrackQuality(pub),
};
}
else {
return {
...common,
};
}
}
_onDisconnected(room) {
return () => {
console.log('[LivekitManager] Disconnected!');
this._helper.action(ON_ROOM_STREAM_LOST, null, {
roomName: room.name,
});
};
}
_onReconnected(room) {
return () => {
console.log('[LivekitManager] Reconnected!');
this._helper.action(ON_ROOM_STREAMING, null, {
roomName: room.name,
});
};
}
_onTrackMuted(room) {
return (pub, participant) => {
console.log('[LivekitManager] Track muted!', pub, participant);
const address = this._trackToAddress.get(pub.track);
this._helper.action(ON_ROOM_TRACK_UNSUBSCRIBED, null, this._trackArg(room.name, pub, participant, address, null));
};
}
_onTrackUnmuted(room) {
return (pub, participant) => {
console.log('[LivekitManager] Track unmuted!', pub, participant);
const address = this._trackToAddress.get(pub.track);
this._helper.action(ON_ROOM_TRACK_SUBSCRIBED, null, this._trackArg(room.name, pub, participant, address, null));
};
}
_onActiveSpeakersChanged(room) {
return (speakers) => {
this._helper.action(ON_ROOM_SPEAKERS_CHANGED, null, {
roomName: room.name,
speakerIds: speakers.map((s) => s.identity),
});
};
}
_onParticipantConnected(room) {
return (participant) => {
this._helper.action(ON_ROOM_REMOTE_JOINED, null, {
roomName: room.name,
remoteId: participant.identity,
});
};
}
_onParticipantDisconnected(room) {
return (participant) => {
this._helper.action(ON_ROOM_REMOTE_LEAVE, null, {
roomName: room.name,
remoteId: participant.identity,
});
};
}
_saveTrack(address, track, pub, participant) {
this._addressToTrack.set(address, track);
this._addressToPublication.set(address, pub);
this._addressToParticipant.set(address, participant);
if (track.kind === this._livekit.Track.Kind.Video) {
this._addressToVideo.set(address, track.attach());
}
this._trackToAddress.set(track, address);
}
_deleteTrack(track) {
const address = this._trackToAddress.get(track);
if (address) {
this._addressToTrack.delete(address);
this._addressToVideo.delete(address);
this._addressToPublication.delete(address);
this._addressToParticipant.delete(address);
this._trackToAddress.delete(track);
return address;
}
return null;
}
_getTrackKind(track) {
return track.kind === this._livekit.Track.Kind.Video
? 'video'
: 'audio';
}
_getTrackQuality(publication) {
if (publication instanceof this._livekit.RemoteTrackPublication) {
if (publication.isMuted || !publication.isEnabled) {
return 'off';
}
const quality = publication.videoQuality;
if (quality === this._livekit.VideoQuality.HIGH) {
return 'high';
}
else if (quality === this._livekit.VideoQuality.MEDIUM) {
return 'medium';
}
else if (quality === this._livekit.VideoQuality.LOW) {
return 'low';
}
else {
return 'high';
}
}
else if (publication.videoTrack) {
return publication.isMuted || !publication.isEnabled
? 'off'
: 'high';
}
return null;
}
_getTrackAddress(publication, participant) {
if (publication.kind === this._livekit.Track.Kind.Video) {
return `casualos://video-element/${participant.identity}-${publication.trackSid}`;
}
else {
return `casualos://audio-element/${participant.identity}-${publication.trackSid}`;
}
}
}
//# sourceMappingURL=LivekitManager.js.map