import {DivImportance, layoutManager} from "./LayoutManager"; import {HtmlUtils} from "./HtmlUtils"; import {discussionManager, SendMessageCallback} from "./DiscussionManager"; import {UserInputManager} from "../Phaser/UserInput/UserInputManager"; import {VIDEO_QUALITY_SELECT} from "../Administration/ConsoleGlobalMessageManager"; declare const navigator:any; // eslint-disable-line @typescript-eslint/no-explicit-any const localValueVideo = localStorage.getItem(VIDEO_QUALITY_SELECT); let valueVideo = 20; if(localValueVideo){ valueVideo = parseInt(localValueVideo); } let videoConstraint: boolean|MediaTrackConstraints = { width: { min: 640, ideal: 1280, max: 1920 }, height: { min: 400, ideal: 720 }, frameRate: {exact: valueVideo, ideal: valueVideo}, facingMode: "user", resizeMode: 'crop-and-scale', aspectRatio: 1.777777778 }; 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; private webrtcOutAudio: 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 focused : boolean = true; private lastUpdateScene : Date = new Date(); private setTimeOutlastUpdateScene? : NodeJS.Timeout; private hasCamera = true; private triggerCloseJistiFrame : Map = new Map(); constructor() { this.myCamVideo = HtmlUtils.getElementByIdOrFail('myCamVideo'); this.webrtcInAudio = HtmlUtils.getElementByIdOrFail('audio-webrtc-in'); this.webrtcOutAudio = HtmlUtils.getElementByIdOrFail('audio-webrtc-out'); this.webrtcInAudio.volume = 0.2; this.webrtcOutAudio.volume = 0.2; this.microphoneBtn = HtmlUtils.getElementByIdOrFail('btn-micro'); this.microphoneClose = HtmlUtils.getElementByIdOrFail('microphone-close'); this.microphoneClose.style.display = "none"; this.microphoneClose.addEventListener('click', (e: MouseEvent) => { e.preventDefault(); this.enableMicrophone(); //update tracking }); this.microphone = HtmlUtils.getElementByIdOrFail('microphone'); this.microphone.addEventListener('click', (e: MouseEvent) => { e.preventDefault(); this.disableMicrophone(); //update tracking }); this.cinemaBtn = HtmlUtils.getElementByIdOrFail('btn-video'); this.cinemaClose = HtmlUtils.getElementByIdOrFail('cinema-close'); this.cinemaClose.style.display = "none"; this.cinemaClose.addEventListener('click', (e: MouseEvent) => { e.preventDefault(); this.enableCamera(); //update tracking }); this.cinema = HtmlUtils.getElementByIdOrFail('cinema'); this.cinema.addEventListener('click', (e: MouseEvent) => { e.preventDefault(); this.disableCamera(); //update tracking }); this.monitorBtn = HtmlUtils.getElementByIdOrFail('btn-monitor'); this.monitorClose = HtmlUtils.getElementByIdOrFail('monitor-close'); this.monitorClose.style.display = "block"; this.monitorClose.addEventListener('click', (e: MouseEvent) => { e.preventDefault(); this.enableScreenSharing(); //update tracking }); this.monitor = HtmlUtils.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)); this.pingCameraStatus(); this.checkActiveUser(); //todo: desactivated in case of bug } public setLastUpdateScene(){ this.lastUpdateScene = new Date(); } public blurCamera() { if(!this.focused){ return; } this.focused = false; this.previousConstraint = JSON.parse(JSON.stringify(this.constraintsMedia)); this.disableCamera(); } public focusCamera() { if(this.focused){ return; } this.focused = true; 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 = HtmlUtils.getElementByIdOrFail('game-overlay'); gameOverlay.classList.add('active'); const buttonCloseFrame = HtmlUtils.getElementByIdOrFail('cowebsite-close'); const functionTrigger = () => { this.triggerCloseJitsiFrameButton(); } buttonCloseFrame.removeEventListener('click', functionTrigger); } public hideGameOverlay(){ const gameOverlay = HtmlUtils.getElementByIdOrFail('game-overlay'); gameOverlay.classList.remove('active'); const buttonCloseFrame = HtmlUtils.getElementByIdOrFail('cowebsite-close'); const functionTrigger = () => { this.triggerCloseJitsiFrameButton(); } buttonCloseFrame.addEventListener('click', functionTrigger); } public updateCameraQuality(value: number) { this.enableCameraStyle(); const newVideoConstraint = JSON.parse(JSON.stringify(videoConstraint)); newVideoConstraint.frameRate = {exact: value, ideal: value}; videoConstraint = newVideoConstraint; this.constraintsMedia.video = videoConstraint; this.getCamera().then((stream: MediaStream) => { this.triggerUpdatedLocalStreamCallbacks(stream); }); } public enableCamera() { this.enableCameraStyle(); this.constraintsMedia.video = videoConstraint; this.getCamera().then((stream: MediaStream) => { this.triggerUpdatedLocalStreamCallbacks(stream); }); } public async disableCamera() { this.disableCameraStyle(); if (this.constraintsMedia.audio !== false) { const stream = await this.getCamera(); this.triggerUpdatedLocalStreamCallbacks(stream); } else { this.triggerUpdatedLocalStreamCallbacks(null); } } public enableMicrophone() { this.enableMicrophoneStyle(); this.constraintsMedia.audio = true; this.getCamera().then((stream) => { this.triggerUpdatedLocalStreamCallbacks(stream); }); } public 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; this.stopCamera(); } 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); }).catch((err) => { //catch error get camera console.error(err); 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.'); } } return this.getLocalStream().catch(() => { console.info('Error get camera, trying with video option at null'); this.disableCameraStyle(); return this.getLocalStream().then((stream : MediaStream) => { this.hasCamera = false; return stream; }).catch((err) => { console.info("error get media ", this.constraintsMedia.video, this.constraintsMedia.audio, err); throw err; }); }); //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*/ } private getLocalStream() : Promise { return navigator.mediaDevices.getUserMedia(this.constraintsMedia).then((stream : MediaStream) => { this.localStream = stream; this.myCamVideo.srcObject = this.localStream; return stream; }).catch((err: Error) => { 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 = HtmlUtils.getElementByIdOrFail(`report-${userId}`); reportBtn.addEventListener('click', (e: MouseEvent) => { e.preventDefault(); this.showReportModal(userId, userName, reportCallBack); }); } this.remoteVideo.set(userId, HtmlUtils.getElementByIdOrFail(userId)); //permit to create participant in discussion part this.addNewParticipant(userId, userName, undefined, reportCallBack); } addScreenSharingActiveVideo(userId: string, divImportance: DivImportance = DivImportance.Important){ userId = `screen-sharing-${userId}`; const html = `
`; layoutManager.add(divImportance, userId, html); this.remoteVideo.set(userId, HtmlUtils.getElementByIdOrFail(userId)); } disabledMicrophoneByUserId(userId: number){ const element = document.getElementById(`microphone-${userId}`); if(!element){ return; } element.classList.add('active') //todo: why does a method 'disable' add a class 'active'? } enabledMicrophoneByUserId(userId: number){ const element = document.getElementById(`microphone-${userId}`); if(!element){ return; } element.classList.remove('active') //todo: why does a method 'enable' remove a class '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): void { 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); //permit to remove user in discussion part this.removeParticipant(userId); } removeActiveScreenSharingVideo(userId: string) { this.removeActiveVideo(`screen-sharing-${userId}`) } playWebrtcOutSound(): void { this.webrtcOutAudio.play(); } 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.info("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; } public showReportModal(userId: string, userName: string, reportCallBack: ReportCallback){ //create report text area const mainContainer = HtmlUtils.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); } public addNewParticipant(userId: number|string, name: string|undefined, img?: string, reportCallBack?: ReportCallback){ discussionManager.addParticipant(userId, name, img, false, reportCallBack); } public removeParticipant(userId: number|string){ discussionManager.removeParticipant(userId); } public addTriggerCloseJitsiFrameButton(id: String, Function: Function){ this.triggerCloseJistiFrame.set(id, Function); } public removeTriggerCloseJitsiFrameButton(id: String){ this.triggerCloseJistiFrame.delete(id); } private triggerCloseJitsiFrameButton(): void { for (const callback of this.triggerCloseJistiFrame.values()) { callback(); } } /** * For some reasons, the microphone muted icon or the stream is not always up to date. * Here, every 30 seconds, we are "reseting" the streams and sending again the constraints to the other peers via the data channel again (see SimplePeer::pushVideoToRemoteUser) **/ private pingCameraStatus(){ /*setInterval(() => { console.log('ping camera status'); this.triggerUpdatedLocalStreamCallbacks(this.localStream); }, 30000);*/ } public addNewMessage(name: string, message: string, isMe: boolean = false){ discussionManager.addMessage(name, message, isMe); //when there are new message, show discussion if(!discussionManager.activatedDiscussion) { discussionManager.showDiscussionPart(); } } public addSendMessageCallback(userId: string|number, callback: SendMessageCallback){ discussionManager.onSendMessageCallback(userId, callback); } get activatedDiscussion(){ return discussionManager.activatedDiscussion; } public setUserInputManager(userInputManager : UserInputManager){ discussionManager.setUserInputManager(userInputManager); } //check if user is active private checkActiveUser(){ if(this.setTimeOutlastUpdateScene){ clearTimeout(this.setTimeOutlastUpdateScene); } this.setTimeOutlastUpdateScene = setTimeout(() => { const now = new Date(); //if last update is more of 10 sec if( (now.getTime() - this.lastUpdateScene.getTime()) > 10000) { this.blurCamera(); }else{ this.focusCamera(); } this.checkActiveUser(); }, this.focused ? 10000 : 1000); } } export const mediaManager = new MediaManager();