Skip to content

Instantly share code, notes, and snippets.

@sp00m
Created October 7, 2020 17:56
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save sp00m/928a92e9283e8c3a4ac5daf4481f3a06 to your computer and use it in GitHub Desktop.
Save sp00m/928a92e9283e8c3a4ac5daf4481f3a06 to your computer and use it in GitHub Desktop.
Jitsi TS client, abstraction on top of lib-jitsi-meet, relying on DOM native events to try increasing the conferences steadiness
const inBrowser = true; // or process.browser if you're using SSR frameworks for instance
const OPTIONS = {
JITSI: {},
CONNECTION: ((jitsiDomain) => ({
serviceUrl: `https://${jitsiDomain}/http-bind`,
hosts: {
domain: jitsiDomain,
muc: `conference.${jitsiDomain}`
},
p2p: {
enabled: false
},
useStunTurn: false
}))("your-jitsi-domain.com"),
CONFERENCE: {
openBridgeChannel: true
},
TRACKS: ((supportedConstraints) => ({
devices: ["audio", "video"],
resolution: 640,
minFps: 24,
maxFps: 24,
facingMode: "user",
constraints: {
video: {
facingMode: supportedConstraints.facingMode ? { ideal: "user" } : undefined,
width: supportedConstraints.width ? { ideal: 640 } : undefined, // vga
height: supportedConstraints.height ? { ideal: 480 } : undefined, // vga
frameRate: supportedConstraints.frameRate ? 24 : undefined,
resizeMode: supportedConstraints.resizeMode ? "crop-and-scale" : undefined,
}
}
}))(inBrowser && navigator.mediaDevices && navigator.mediaDevices.getSupportedConstraints() || {})
};
const noop = () => { };
// JitsiMeetJS from https://github.com/jitsi/lib-jitsi-meet
const JitsiMeetJS = inBrowser && (window as any).JitsiMeetJS;
if (JitsiMeetJS) {
JitsiMeetJS.setLogLevel(JitsiMeetJS.logLevels.ERROR);
JitsiMeetJS.init(OPTIONS.JITSI);
}
export enum TrackType {
AUDIO = "audio",
VIDEO = "video"
}
interface Track<E extends HTMLElement> {
isLocal(): boolean
getId(): string
getParticipantId(): string
getType(): TrackType
attach(element: E): any
detach(element: E): any
dispose(): Promise<any> // local track only
addEventListener(event: any, callback: any)
}
class AttachableTrack<E extends HTMLElement> {
private _track: Track<E>
private _element: E
constructor(track: Track<E>, element: E) {
this._track = track;
this._element = element;
}
get track() {
return this._track;
}
get element() {
return this._element;
}
async attach(): Promise<any> {
this._track.attach(this._element);
}
async dispose(): Promise<any> {
this._track.detach(this._element);
if (this._track.isLocal()) {
return await this._track.dispose();
}
}
}
export interface Member {
uuid: string
nickname: string
}
export class Conference {
private _room: string;
private _member: Member;
private _connection: any;
private _conference: any;
private _localVideo: AttachableTrack<HTMLVideoElement>
private _localAudio: AttachableTrack<HTMLAudioElement>
private _audiosByParticipantId: Map<string, AttachableTrack<HTMLAudioElement>> = new Map();
private _videosByParticipantId: Map<string, AttachableTrack<HTMLVideoElement>> = new Map();
private _membersByParticipantId: Map<string, Member> = new Map();
private _onLocalJoined: (audio: HTMLAudioElement, video: HTMLVideoElement, member: Member) => any = noop;
private _onRemoteJoined: (audio: HTMLAudioElement, video: HTMLVideoElement, member: Member) => any = noop;
private _onRemoteLeft: (member: Member) => any = noop;
private _onError: (error: any) => any = noop;
constructor() {
this._quitOnUnload();
}
onLocalJoined(onLocalJoined: (audio: HTMLAudioElement, video: HTMLVideoElement, member: Member) => any) {
this._onLocalJoined = onLocalJoined;
return this;
}
onRemoteJoined(onRemoteJoined: (audio: HTMLAudioElement, video: HTMLVideoElement, member: Member) => any) {
this._onRemoteJoined = onRemoteJoined;
return this;
}
onRemoteLeft(onRemoteLeft: (member: Member) => any) {
this._onRemoteLeft = onRemoteLeft;
return this;
}
onError(onError: (error: string) => any) {
this._onError = onError;
return this;
}
join(room: string, memberUuid: string, memberNickname: string) {
console.log("Conference - Connecting");
this._prepareConnection();
this._room = room.toLowerCase();
this._member = { uuid: memberUuid, nickname: memberNickname };
this._connection.connect();
return this;
}
async quit(error?: string) {
if (error) {
console.error("Conference - An error occurred that triggered quitting the conference: " + error);
} else if (this._connection) {
console.log("Conference - Quitting conference");
} else {
return;
}
const _onError = this._onError;
this._onLocalJoined = noop;
this._onRemoteJoined = noop;
this._onRemoteLeft = noop;
this._onError = noop;
try {
await this._conference && this._conference.leave();
const remoteAudioPromises = Array.from(this._audiosByParticipantId.values()).map((track) => track.dispose());
const remoteVideoPromises = Array.from(this._videosByParticipantId.values()).map((track) => track.dispose());
await Promise.all([
...remoteAudioPromises,
...remoteVideoPromises,
this._localVideo && this._localVideo.dispose(),
this._localAudio && this._localAudio.dispose(),
this._connection && this._connection.disconnect()
]);
} catch (e) {
console.error("Conference - Unable to quit conference gracefully", e)
}
if (error) {
await _onError(error);
}
this._room = undefined;
this._member = undefined;
this._connection = undefined;
this._conference = undefined;
this._localAudio = undefined;
this._localVideo = undefined;
this._audiosByParticipantId.clear();
this._videosByParticipantId.clear();
this._membersByParticipantId.clear();
}
private _quitOnUnload() {
if (inBrowser) {
window.addEventListener("beforeunload", async () => {
await this.quit();
});
}
}
private _prepareConnection() {
if (!JitsiMeetJS) {
throw new Error("JitsiMeetJS is undefined");
}
this._connection = new JitsiMeetJS.JitsiConnection(null, null, OPTIONS.CONNECTION);
this._connection.addEventListener(
JitsiMeetJS.events.connection.CONNECTION_ESTABLISHED,
async () => await this._onConnectionEstablished()
);
this._connection.addEventListener(
JitsiMeetJS.events.connection.CONNECTION_FAILED,
async (...args) => await this._onConnectionFailed(args)
);
}
private async _onConnectionEstablished() {
try {
console.log("Conference - Connection established");
await this._createLocalTracks();
} catch (e) {
console.error("Conference - Unable to create local tracks", e);
await this.quit("local_tracks");
}
}
private async _createLocalTracks() {
const localTracks: [Track<any>] = await JitsiMeetJS.createLocalTracks(OPTIONS.TRACKS);
await Promise.all(localTracks.map((track) => this._onLocalTrack(track)));
}
private async _onLocalTrack(track: Track<any>) {
if (!track.isLocal()) {
console.warn("Conference - Track was supposed to be local");
return;
}
switch (track.getType()) {
case "audio":
await this._onLocalAudioTrack(track);
break;
case "video":
await this._onLocalVideoTrack(track);
break;
default:
console.error("Conference - Unexpected track type: " + track.getType());
break;
}
}
private async _onLocalAudioTrack(track: Track<HTMLAudioElement>) {
if (this._localAudio) {
console.warn("Conference - Local audio already set");
return;
}
console.log("Conference - Creating local audio");
const audio = document.createElement("audio");
audio.autoplay = true;
audio.muted = true;
audio.setAttribute("id", track.getId());
audio.setAttribute("data-participant-id", track.getParticipantId());
audio.setAttribute("data-member-uuid", this._member.uuid);
const attachableTrack = new AttachableTrack<HTMLAudioElement>(track, audio);
audio.onloadeddata = async () => {
audio.onloadeddata = noop;
if (this._localAudio) {
console.error("Conference - Race condition with local audio", track);
} else {
console.log("Conference - Local audio loaded");
this._localAudio = attachableTrack;
await this._notifyLocalJoined();
}
};
await attachableTrack.attach();
}
private async _onLocalVideoTrack(track: Track<HTMLVideoElement>) {
if (this._localVideo) {
console.warn("Conference - Local video already set");
return;
}
console.log("Conference - Creating local video");
const video = document.createElement("video");
video.autoplay = true;
video.muted = true;
video.setAttribute("playsinline", "");
video.setAttribute("id", track.getId());
video.setAttribute("data-participant-id", track.getParticipantId());
video.setAttribute("data-member-uuid", this._member.uuid);
const attachableTrack = new AttachableTrack<HTMLVideoElement>(track, video);
video.onloadeddata = async () => {
video.onloadeddata = noop;
if (this._localVideo) {
console.error("Conference - Race condition with local video", track);
} else {
console.log("Conference - Local video loaded");
this._localVideo = attachableTrack;
await this._notifyLocalJoined();
}
};
await attachableTrack.attach();
}
private async _notifyLocalJoined() {
const shouldNotify = this._localAudio && this._localVideo;
if (shouldNotify) {
console.log("Conference - Local ready, joining conference");
await Promise.all([
this._onLocalJoined(this._localAudio.element, this._localVideo.element, this._member),
this._joinConference()
]);
}
}
private async _joinConference() {
this._conference = this._connection.initJitsiConference(this._room, OPTIONS.CONFERENCE);
this._conference.on(
JitsiMeetJS.events.conference.CONFERENCE_JOINED,
async () => await this._onConferenceJoined()
);
this._conference.on(
JitsiMeetJS.events.conference.CONFERENCE_FAILED,
async (...args) => await this._onConferenceFailed(...args)
);
this._conference.on(
JitsiMeetJS.events.conference.USER_JOINED,
async (participantId, participant) => await this._onParticipantJoined(participantId, participant)
);
this._conference.on(
JitsiMeetJS.events.conference.DISPLAY_NAME_CHANGED,
async (participantId, displayName) => await this._onParticipantDisplayNameChanged(participantId, displayName)
);
this._conference.on(
JitsiMeetJS.events.conference.TRACK_ADDED,
async (track) => await this._onRemoteTrack(track)
);
this._conference.on(
JitsiMeetJS.events.conference.USER_LEFT,
async (participantId) => await this._onParticipantLeft(participantId)
);
this._conference.join();
}
private async _onConferenceJoined() {
console.log("Conference - Conference joined, setting display name and adding local tracks");
this._setDisplayName();
await Promise.all([
this._conference.addTrack(this._localAudio.track),
this._conference.addTrack(this._localVideo.track)
]);
}
private _setDisplayName() {
const displayName = JSON.stringify(this._member);
this._conference.setDisplayName(displayName);
}
private async _onRemoteTrack(track: Track<any>) {
if (track.isLocal()) {
console.warn("Conference - Track was supposed to be remote");
return;
}
switch (track.getType()) {
case "audio":
await this._onRemoteAudioTrack(track);
break;
case "video":
await this._onRemoteVideoTrack(track);
break;
default:
console.error("Conference - Unexpected track type: " + track.getType());
break;
}
}
private async _onRemoteAudioTrack(track: Track<HTMLAudioElement>) {
const participantId = track.getParticipantId();
const audioAlreadySet = this._audiosByParticipantId.has(participantId);
let audio: HTMLAudioElement = undefined;
if (audioAlreadySet) {
console.log("Conference - Replacing remote audio");
const attachedTrack = this._audiosByParticipantId.get(participantId);
audio = attachedTrack.element;
await attachedTrack.dispose();
} else {
console.log("Conference - Creating remote audio");
audio = document.createElement("audio");
audio.autoplay = true;
}
audio.setAttribute("id", track.getId());
audio.setAttribute("data-participant-id", participantId);
if (this._membersByParticipantId.has(participantId)) {
audio.setAttribute("data-member-uuid", this._membersByParticipantId.get(participantId).uuid);
}
const attachableTrack = new AttachableTrack<HTMLAudioElement>(track, audio);
if (audioAlreadySet) {
this._audiosByParticipantId.set(participantId, attachableTrack);
} else {
audio.onloadeddata = async () => {
audio.onloadeddata = noop;
if (this._audiosByParticipantId.has(participantId)) {
console.error("Conference - Race condition with remote audio track", track);
} else {
console.log("Conference - Remote audio loaded");
this._audiosByParticipantId.set(participantId, attachableTrack);
await this._notifyRemoteJoined(participantId);
}
};
}
await attachableTrack.attach();
}
private async _onRemoteVideoTrack(track: Track<HTMLVideoElement>) {
const participantId = track.getParticipantId();
const videoAlreadySet = this._videosByParticipantId.has(participantId);
let video: HTMLVideoElement = undefined;
if (videoAlreadySet) {
console.log("Conference - Replacing remote video");
const attachedTrack = this._videosByParticipantId.get(participantId);
video = attachedTrack.element;
await attachedTrack.dispose();
} else {
console.log("Conference - Creating remote video");
video = document.createElement("video");
video.autoplay = true;
video.setAttribute("playsinline", "");
}
video.setAttribute("id", track.getId());
video.setAttribute("data-participant-id", participantId);
if (this._membersByParticipantId.has(participantId)) {
video.setAttribute("data-member-uuid", this._membersByParticipantId.get(participantId).uuid);
}
const attachableTrack = new AttachableTrack<HTMLVideoElement>(track, video);
if (videoAlreadySet) {
this._videosByParticipantId.set(participantId, attachableTrack);
} else {
video.onloadeddata = async () => {
video.onloadeddata = noop;
if (this._videosByParticipantId.has(participantId)) {
console.error("Conference - Race condition with remote video", track);
} else {
console.log("Conference - Remote video loaded");
this._videosByParticipantId.set(participantId, attachableTrack);
await this._notifyRemoteJoined(participantId);
}
};
}
await attachableTrack.attach();
}
private async _onParticipantJoined(participantId, participant) {
await this._onParticipantDisplayNameChanged(participantId, participant.getDisplayName());
}
private async _onParticipantDisplayNameChanged(participantId, displayName) {
if (!displayName) {
console.log("Conference - No remote display name yet");
return;
}
if (this._membersByParticipantId.has(participantId)) {
console.warn("Conference - Remote display name already set");
return;
}
console.log("Conference - Setting remote display name");
const member = JSON.parse(displayName);
this._membersByParticipantId.set(participantId, member);
if (this._audiosByParticipantId.has(participantId)) {
this._audiosByParticipantId.get(participantId).element.setAttribute("data-member-uuid", member.uuid);
}
if (this._videosByParticipantId.has(participantId)) {
this._videosByParticipantId.get(participantId).element.setAttribute("data-member-uuid", member.uuid);
}
await this._notifyRemoteJoined(participantId);
}
private async _notifyRemoteJoined(participantId: string) {
const shouldNotify = this._audiosByParticipantId.has(participantId)
&& this._videosByParticipantId.has(participantId)
&& this._membersByParticipantId.has(participantId);
if (shouldNotify) {
console.log("Conference - Remote ready");
const audio = this._audiosByParticipantId.get(participantId);
const video = this._videosByParticipantId.get(participantId);
const member = this._membersByParticipantId.get(participantId);
await this._onRemoteJoined(audio.element, video.element, member);
}
}
private async _onParticipantLeft(participantId) {
const promises = [];
if (this._videosByParticipantId.has(participantId)) {
const track = this._videosByParticipantId.get(participantId);
this._videosByParticipantId.delete(participantId);
promises.push(track.dispose());
}
if (this._audiosByParticipantId.has(participantId)) {
const track = this._audiosByParticipantId.get(participantId);
this._audiosByParticipantId.delete(participantId);
promises.push(track.dispose());
}
if (this._membersByParticipantId.has(participantId)) {
const member = this._membersByParticipantId.get(participantId);
this._membersByParticipantId.delete(participantId);
if (2 === promises.length) {
promises.push(this._onRemoteLeft(member));
}
}
if (3 === promises.length) {
console.log("Conference - Remote left");
} else {
console.warn("Conference - Remote left before being ready");
}
return await Promise.all(promises);
}
private async _onConnectionFailed(...args) {
console.error("Conference - Connection failed", args);
await this.quit("connection_failed");
}
private async _onConferenceFailed(...args) {
console.error("Conference - Conference failed", args);
await this.quit("conference_failed");
}
get member() {
return this._member;
}
}
const conference = new Conference()
.onLocalJoined((audio, video, member) => {
// audio = DOM native HTMLAudioElement
// video = DOM native HTMLVideoElement
// member = local member { uuid: "string", nickname: "string" }
})
.onRemoteJoined((audio, video, member) => {
// audio = DOM native HTMLAudioElement
// video = DOM native HTMLVideoElement
// member = remote member { uuid: "string", nickname: "string" }
})
.onRemoteLeft((member) => {
// member = remote member { uuid: "string", nickname: "string" }
})
.onError((error) => {
// something bad happened
})
.join("name of the conference to join", "local member uuid", "local member nickname");
// to quit:
// await conference.quit();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment