interface CanvasElement extends HTMLCanvasElement { captureStream(frameRate?: number): MediaStream; } class Lock { waiters: (() => void)[]; locked: boolean; constructor() { this.waiters = []; this.locked = false; } async lock() { if (this.locked) { const p = new Promise((resolve) => { this.waiters.push(resolve); }); await p; } this.locked = true; } async unlock() { this.locked = false; this.waiters.shift()?.(); } } class UserMedia { canvas: CanvasElement; audioCtx: AudioContext; mediaOut: MediaStream; turnVideoOff: () => void; turnAudioOff: () => void; isVideoOn: boolean; isAudioOn: boolean; destination: MediaStreamAudioDestinationNode; lock: Lock; constructor(width: number, height: number) { this.canvas = document.createElement("canvas"); this.canvas.width = width; this.canvas.height = height; this.mediaOut = new MediaStream(); this.canvas.getContext("2d"); // Initialize canvas in FF. this.canvas .captureStream(16) .getVideoTracks() .forEach((t) => this.mediaOut.addTrack(t)); this.audioCtx = new AudioContext(); this.destination = this.audioCtx.createMediaStreamDestination(); this.destination.stream .getAudioTracks() .forEach((t) => this.mediaOut.addTrack(t)); this.lock = new Lock(); navigator.mediaDevices.ondevicechange = () => this.startAll(); this.isVideoOn = true; this.isAudioOn = true; this.startAll(); } async startAll() { await this.attachWebcam(); await this.attachMicrophone(); } async attachWebcam() { await this.lock.lock(); try { await this.attachWebcam_(); } catch (e) {} await this.lock.unlock(); } async attachMicrophone() { await this.lock.lock(); try { await this.attachMicrophone_(); } catch (e) {} await this.lock.unlock(); } async attachWebcam_() { this.detatchWebcam(); if (!this.isVideoOn) return; const stream = await navigator.mediaDevices.getUserMedia({video: true}); if (stream.getVideoTracks().length === 0) return; const video = document.createElement("video"); video.autoplay = true; video.oncanplay = () => (video.muted = true); const settings = stream.getVideoTracks()[0].getSettings(); const wantRatio = this.canvas.width / this.canvas.height; const gotRatio = settings.width / settings.height; video.srcObject = stream; const repaint = () => { const cnv = this.canvas; const ctx = cnv.getContext("2d"); if (wantRatio > gotRatio) { ctx.drawImage(video, 0, 0, cnv.height * gotRatio, cnv.height); } else { ctx.drawImage(video, 0, 0, cnv.width, cnv.width / gotRatio); } frameId = window.requestAnimationFrame(repaint); }; let frameId = window.requestAnimationFrame(repaint); this.turnVideoOff = () => { stream.getVideoTracks().forEach((t) => t.stop()); cancelAnimationFrame(frameId); }; } detatchWebcam() { this.turnVideoOff?.(); if (!this.isVideoOn) { const cnv = this.canvas; const ctx = cnv.getContext("2d"); setTimeout(() => ctx.clearRect(0, 0, cnv.width, cnv.height), 80); } } async attachMicrophone_() { this.detatchMicrophone(); if (!this.isAudioOn) return; const stream = await navigator.mediaDevices.getUserMedia({audio: true}); if (stream.getAudioTracks().length === 0) return; const src = this.audioCtx.createMediaStreamSource(stream); src.connect(this.destination); this.audioCtx.resume(); this.turnAudioOff = () => { stream.getAudioTracks().forEach((t) => t.stop()); src.disconnect(); }; } detatchMicrophone() { this.turnAudioOff?.(); } async toggleAudio() { this.isAudioOn = !this.isAudioOn; await this.attachMicrophone(); } async toggleVideo() { this.isVideoOn = !this.isVideoOn; await this.attachWebcam(); } }