import {DivImportance, layoutManager} from "./LayoutManager"; import {HtmlUtils} from "./HtmlUtils"; declare const navigator:any; // eslint-disable-line @typescript-eslint/no-explicit-any const videoConstraint: boolean|MediaTrackConstraints = { width: { ideal: 1280 }, height: { ideal: 720 }, facingMode: "user" }; export type UpdatedLocalStreamCallback = (media: MediaStream|null) => void; export type StartScreenSharingCallback = (media: MediaStream) => void; export type StopScreenSharingCallback = (media: MediaStream) => void; export type ReportCallback = (message: string) => void; // TODO: Split MediaManager in 2 classes: MediaManagerUI (in charge of HTML) and MediaManager (singleton in charge of the camera only) // TODO: verify that microphone event listeners are not triggered plenty of time NOW (since MediaManager is created many times!!!!) export class MediaManager { localStream: MediaStream|null = null; localScreenCapture: MediaStream|null = null; private remoteVideo: Map = new Map(); myCamVideo: HTMLVideoElement; cinemaClose: HTMLImageElement; cinema: HTMLImageElement; monitorClose: HTMLImageElement; monitor: HTMLImageElement; microphoneClose: HTMLImageElement; microphone: HTMLImageElement; webrtcInAudio: HTMLAudioElement; constraintsMedia : MediaStreamConstraints = { audio: true, video: videoConstraint }; updatedLocalStreamCallBacks : Set = new Set(); startScreenSharingCallBacks : Set = new Set(); stopScreenSharingCallBacks : Set = new Set(); private microphoneBtn: HTMLDivElement; private cinemaBtn: HTMLDivElement; private monitorBtn: HTMLDivElement; private previousConstraint : MediaStreamConstraints; private timeoutBlurWindows?: NodeJS.Timeout; constructor() { this.myCamVideo = this.getElementByIdOrFail('myCamVideo'); this.webrtcInAudio = this.getElementByIdOrFail('audio-webrtc-in'); this.webrtcInAudio.volume = 0.2; this.microphoneBtn = this.getElementByIdOrFail('btn-micro'); this.microphoneClose = this.getElementByIdOrFail('microphone-close'); this.microphoneClose.style.display = "none"; this.microphoneClose.addEventListener('click', (e: MouseEvent) => { e.preventDefault(); this.enableMicrophone(); //update tracking }); this.microphone = this.getElementByIdOrFail('microphone'); this.microphone.addEventListener('click', (e: MouseEvent) => { e.preventDefault(); this.disableMicrophone(); //update tracking }); this.cinemaBtn = this.getElementByIdOrFail('btn-video'); this.cinemaClose = this.getElementByIdOrFail('cinema-close'); this.cinemaClose.style.display = "none"; this.cinemaClose.addEventListener('click', (e: MouseEvent) => { e.preventDefault(); this.enableCamera(); //update tracking }); this.cinema = this.getElementByIdOrFail('cinema'); this.cinema.addEventListener('click', (e: MouseEvent) => { e.preventDefault(); this.disableCamera(); //update tracking }); this.monitorBtn = this.getElementByIdOrFail('btn-monitor'); this.monitorClose = this.getElementByIdOrFail('monitor-close'); this.monitorClose.style.display = "block"; this.monitorClose.addEventListener('click', (e: MouseEvent) => { e.preventDefault(); this.enableScreenSharing(); //update tracking }); this.monitor = this.getElementByIdOrFail('monitor'); this.monitor.style.display = "none"; this.monitor.addEventListener('click', (e: MouseEvent) => { e.preventDefault(); this.disableScreenSharing(); //update tracking }); this.previousConstraint = JSON.parse(JSON.stringify(this.constraintsMedia)); window.addEventListener('blur', () => { if(this.timeoutBlurWindows){ clearTimeout(this.timeoutBlurWindows); } this.timeoutBlurWindows = setTimeout(() => { this.previousConstraint = JSON.parse(JSON.stringify(this.constraintsMedia)); this.disableCamera(); }, 10000); }); window.addEventListener('focus', () => { if(this.timeoutBlurWindows){ clearTimeout(this.timeoutBlurWindows); } this.applyPreviousConfig(); }); } public onUpdateLocalStream(callback: UpdatedLocalStreamCallback): void { this.updatedLocalStreamCallBacks.add(callback); } public onStartScreenSharing(callback: StartScreenSharingCallback): void { this.startScreenSharingCallBacks.add(callback); } public onStopScreenSharing(callback: StopScreenSharingCallback): void { this.stopScreenSharingCallBacks.add(callback); } removeUpdateLocalStreamEventListener(callback: UpdatedLocalStreamCallback): void { this.updatedLocalStreamCallBacks.delete(callback); } private triggerUpdatedLocalStreamCallbacks(stream: MediaStream|null): void { for (const callback of this.updatedLocalStreamCallBacks) { callback(stream); } } private triggerStartedScreenSharingCallbacks(stream: MediaStream): void { for (const callback of this.startScreenSharingCallBacks) { callback(stream); } } private triggerStoppedScreenSharingCallbacks(stream: MediaStream): void { for (const callback of this.stopScreenSharingCallBacks) { callback(stream); } } public showGameOverlay(){ const gameOverlay = this.getElementByIdOrFail('game-overlay'); gameOverlay.classList.add('active'); } public hideGameOverlay(){ const gameOverlay = this.getElementByIdOrFail('game-overlay'); gameOverlay.classList.remove('active'); } private enableCamera() { this.enableCameraStyle(); this.constraintsMedia.video = videoConstraint; this.getCamera().then((stream: MediaStream) => { this.triggerUpdatedLocalStreamCallbacks(stream); }); } private async disableCamera() { this.disableCameraStyle(); this.stopCamera(); if (this.constraintsMedia.audio !== false) { const stream = await this.getCamera(); this.triggerUpdatedLocalStreamCallbacks(stream); } else { this.triggerUpdatedLocalStreamCallbacks(null); } } private enableMicrophone() { this.enableMicrophoneStyle(); this.constraintsMedia.audio = true; this.getCamera().then((stream) => { this.triggerUpdatedLocalStreamCallbacks(stream); }); } private async disableMicrophone() { this.disableMicrophoneStyle(); this.stopMicrophone(); if (this.constraintsMedia.video !== false) { const stream = await this.getCamera(); this.triggerUpdatedLocalStreamCallbacks(stream); } else { this.triggerUpdatedLocalStreamCallbacks(null); } } private applyPreviousConfig() { this.constraintsMedia = this.previousConstraint; if(!this.constraintsMedia.video){ this.disableCameraStyle(); }else{ this.enableCameraStyle(); } if(!this.constraintsMedia.audio){ this.disableMicrophoneStyle() }else{ this.enableMicrophoneStyle() } this.getCamera().then((stream: MediaStream) => { this.triggerUpdatedLocalStreamCallbacks(stream); }); } private enableCameraStyle(){ this.cinemaClose.style.display = "none"; this.cinemaBtn.classList.remove("disabled"); this.cinema.style.display = "block"; } private disableCameraStyle(){ this.cinemaClose.style.display = "block"; this.cinema.style.display = "none"; this.cinemaBtn.classList.add("disabled"); this.constraintsMedia.video = false; this.myCamVideo.srcObject = null; } private enableMicrophoneStyle(){ this.microphoneClose.style.display = "none"; this.microphone.style.display = "block"; this.microphoneBtn.classList.remove("disabled"); } private disableMicrophoneStyle(){ this.microphoneClose.style.display = "block"; this.microphone.style.display = "none"; this.microphoneBtn.classList.add("disabled"); this.constraintsMedia.audio = false; } private enableScreenSharing() { this.monitorClose.style.display = "none"; this.monitor.style.display = "block"; this.monitorBtn.classList.add("enabled"); this.getScreenMedia().then((stream) => { this.triggerStartedScreenSharingCallbacks(stream); }); } private disableScreenSharing() { this.monitorClose.style.display = "block"; this.monitor.style.display = "none"; this.monitorBtn.classList.remove("enabled"); this.removeActiveScreenSharingVideo('me'); this.localScreenCapture?.getTracks().forEach((track: MediaStreamTrack) => { track.stop(); }); if (this.localScreenCapture === null) { console.warn('Weird: trying to remove a screen sharing that is not enabled'); return; } const localScreenCapture = this.localScreenCapture; this.getCamera().then((stream) => { this.triggerStoppedScreenSharingCallbacks(localScreenCapture); }); this.localScreenCapture = null; } //get screen getScreenMedia() : Promise{ try { return this._startScreenCapture() .then((stream: MediaStream) => { this.localScreenCapture = stream; // If stream ends (for instance if user clicks the stop screen sharing button in the browser), let's close the view for (const track of stream.getTracks()) { track.onended = () => { this.disableScreenSharing(); }; } this.addScreenSharingActiveVideo('me', DivImportance.Normal); HtmlUtils.getElementByIdOrFail('screen-sharing-me').srcObject = stream; return stream; }) .catch((err: unknown) => { console.error("Error => getScreenMedia => ", err); throw err; }); }catch (err) { return new Promise((resolve, reject) => { // eslint-disable-line no-unused-vars reject(err); }); } } private _startScreenCapture() { if (navigator.getDisplayMedia) { return navigator.getDisplayMedia({video: true}); } else if (navigator.mediaDevices.getDisplayMedia) { return navigator.mediaDevices.getDisplayMedia({video: true}); } else { return new Promise((resolve, reject) => { // eslint-disable-line no-unused-vars reject("error sharing screen"); }); } } //get camera async getCamera(): Promise { if (navigator.mediaDevices === undefined) { if (window.location.protocol === 'http:') { throw new Error('Unable to access your camera or microphone. You need to use a HTTPS connection.'); } else { throw new Error('Unable to access your camera or microphone. Your browser is too old.'); } } try { const stream = await navigator.mediaDevices.getUserMedia(this.constraintsMedia); this.localStream = stream; this.myCamVideo.srcObject = this.localStream; return stream; //TODO resize remote cam /*console.log(this.localStream.getTracks()); let videoMediaStreamTrack = this.localStream.getTracks().find((media : MediaStreamTrack) => media.kind === "video"); let {width, height} = videoMediaStreamTrack.getSettings(); console.info(`${width}x${height}`); // 6*/ } catch (err) { console.info("error get media ", this.constraintsMedia.video, this.constraintsMedia.audio, err); this.localStream = null; throw err; } } /** * Stops the camera from filming */ public stopCamera(): void { if (this.localStream) { for (const track of this.localStream.getVideoTracks()) { track.stop(); } } } /** * Stops the microphone from listening */ public stopMicrophone(): void { if (this.localStream) { for (const track of this.localStream.getAudioTracks()) { track.stop(); } } } setCamera(id: string): Promise { let video = this.constraintsMedia.video; if (typeof(video) === 'boolean' || video === undefined) { video = {} } video.deviceId = { exact: id }; return this.getCamera(); } setMicrophone(id: string): Promise { let audio = this.constraintsMedia.audio; if (typeof(audio) === 'boolean' || audio === undefined) { audio = {} } audio.deviceId = { exact: id }; return this.getCamera(); } addActiveVideo(userId: string, reportCallBack: ReportCallback|undefined, userName: string = ""){ this.webrtcInAudio.play(); userName = userName.toUpperCase(); const color = this.getColorByString(userName); const html = `
${userName} ` + ((reportCallBack!==undefined)?``:'') + `
`; layoutManager.add(DivImportance.Normal, userId, html); if (reportCallBack) { const reportBtn = this.getElementByIdOrFail(`report-${userId}`); reportBtn.addEventListener('click', (e: MouseEvent) => { e.preventDefault(); this.showReportModal(userId, userName, reportCallBack); }); } this.remoteVideo.set(userId, this.getElementByIdOrFail(userId)); } addScreenSharingActiveVideo(userId: string, divImportance: DivImportance = DivImportance.Important){ userId = `screen-sharing-${userId}`; const html = `
`; layoutManager.add(divImportance, userId, html); this.remoteVideo.set(userId, this.getElementByIdOrFail(userId)); } disabledMicrophoneByUserId(userId: number){ const element = document.getElementById(`microphone-${userId}`); if(!element){ return; } element.classList.add('active') } enabledMicrophoneByUserId(userId: number){ const element = document.getElementById(`microphone-${userId}`); if(!element){ return; } element.classList.remove('active') } disabledVideoByUserId(userId: number) { let element = document.getElementById(`${userId}`); if (element) { element.style.opacity = "0"; } element = document.getElementById(`name-${userId}`); if (element) { element.style.display = "block"; } } enabledVideoByUserId(userId: number){ let element = document.getElementById(`${userId}`); if(element){ element.style.opacity = "1"; } element = document.getElementById(`name-${userId}`); if(element){ element.style.display = "none"; } } addStreamRemoteVideo(userId: string, stream : MediaStream){ const remoteVideo = this.remoteVideo.get(userId); if (remoteVideo === undefined) { throw `Unable to find video for ${userId}`; } remoteVideo.srcObject = stream; } addStreamRemoteScreenSharing(userId: string, stream : MediaStream){ // In the case of screen sharing (going both ways), we may need to create the HTML element if it does not exist yet const remoteVideo = this.remoteVideo.get(`screen-sharing-${userId}`); if (remoteVideo === undefined) { this.addScreenSharingActiveVideo(userId); } this.addStreamRemoteVideo(`screen-sharing-${userId}`, stream); } removeActiveVideo(userId: string){ layoutManager.remove(userId); this.remoteVideo.delete(userId); } removeActiveScreenSharingVideo(userId: string) { this.removeActiveVideo(`screen-sharing-${userId}`) } isConnecting(userId: string): void { const connectingSpinnerDiv = this.getSpinner(userId); if (connectingSpinnerDiv === null) { return; } connectingSpinnerDiv.style.display = 'block'; } isConnected(userId: string): void { const connectingSpinnerDiv = this.getSpinner(userId); if (connectingSpinnerDiv === null) { return; } connectingSpinnerDiv.style.display = 'none'; } isError(userId: string): void { console.log("isError", `div-${userId}`); const element = document.getElementById(`div-${userId}`); if(!element){ return; } const errorDiv = element.getElementsByClassName('rtc-error').item(0) as HTMLDivElement|null; if (errorDiv === null) { return; } errorDiv.style.display = 'block'; } isErrorScreenSharing(userId: string): void { this.isError(`screen-sharing-${userId}`); } private getSpinner(userId: string): HTMLDivElement|null { const element = document.getElementById(`div-${userId}`); if(!element){ return null; } const connnectingSpinnerDiv = element.getElementsByClassName('connecting-spinner').item(0) as HTMLDivElement|null; return connnectingSpinnerDiv; } private getColorByString(str: String) : String|null { let hash = 0; if (str.length === 0) return null; for (let i = 0; i < str.length; i++) { hash = str.charCodeAt(i) + ((hash << 5) - hash); hash = hash & hash; } let color = '#'; for (let i = 0; i < 3; i++) { const value = (hash >> (i * 8)) & 255; color += ('00' + value.toString(16)).substr(-2); } return color; } private getElementByIdOrFail(id: string): T { const elem = document.getElementById(id); if (elem === null) { throw new Error("Cannot find HTML element with id '"+id+"'"); } // FIXME: does not check the type of the returned type return elem as T; } private showReportModal(userId: string, userName: string, reportCallBack: ReportCallback){ //create report text area const mainContainer = this.getElementByIdOrFail('main-container'); const divReport = document.createElement('div'); divReport.classList.add('modal-report-user'); const inputHidden = document.createElement('input'); inputHidden.id = 'input-report-user'; inputHidden.type = 'hidden'; inputHidden.value = userId; divReport.appendChild(inputHidden); const titleMessage = document.createElement('p'); titleMessage.id = 'title-report-user'; titleMessage.innerText = 'Open a report'; divReport.appendChild(titleMessage); const bodyMessage = document.createElement('p'); bodyMessage.id = 'body-report-user'; bodyMessage.innerText = `You are about to open a report regarding an offensive conduct from user ${userName.toUpperCase()}. Please explain to us how you think ${userName.toUpperCase()} breached the code of conduct.`; divReport.appendChild(bodyMessage); const imgReportUser = document.createElement('img'); imgReportUser.id = 'img-report-user'; imgReportUser.src = 'resources/logos/report.svg'; divReport.appendChild(imgReportUser); const textareaUser = document.createElement('textarea'); textareaUser.id = 'textarea-report-user'; textareaUser.placeholder = 'Write ...'; divReport.appendChild(textareaUser); const buttonReport = document.createElement('button'); buttonReport.id = 'button-save-report-user'; buttonReport.innerText = 'Report'; buttonReport.addEventListener('click', () => { if(!textareaUser.value){ textareaUser.style.border = '1px solid red' return; } reportCallBack(textareaUser.value); divReport.remove(); }); divReport.appendChild(buttonReport); const buttonCancel = document.createElement('img'); buttonCancel.id = 'cancel-report-user'; buttonCancel.src = 'resources/logos/close.svg'; buttonCancel.addEventListener('click', () => { divReport.remove(); }); divReport.appendChild(buttonCancel); mainContainer.appendChild(divReport); } } export const mediaManager = new MediaManager();