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();
}
}