-
-
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
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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