openreplay/frontend/app/player/web/assist/CanvasReceiver.ts
Andrey Babushkin 894d4c84b3
Patch assist canvas (#3277)
* resolved conflict

* removed comments
2025-04-07 15:13:36 +02:00

242 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import logger from '@/logger';
import { VElement } from 'Player/web/managers/DOM/VirtualDOM';
import MessageManager from 'Player/web/MessageManager';
import { Socket } from 'socket.io-client';
import { toast } from 'react-toastify';
export default class CanvasReceiver {
private streams: Map<string, MediaStream> = new Map();
// Store RTCPeerConnection for each remote peer
private connections: Map<string, RTCPeerConnection> = new Map();
private cId: string;
private frameCounter = 0;
private canvasesData = new Map<
string,
{
video: HTMLVideoElement;
canvas: HTMLCanvasElement;
canvasCtx: CanvasRenderingContext2D;
}
>(new Map());
// sendSignal for sending signals (offer/answer/ICE)
constructor(
private readonly peerIdPrefix: string,
private readonly config: RTCIceServer[],
private readonly getNode: MessageManager['getNode'],
private readonly agentInfo: Record<string, any>,
private readonly socket: Socket,
) {
// Form an id like in PeerJS
this.cId = `${this.peerIdPrefix}-${this.agentInfo.id}-canvas`;
this.socket.on(
'webrtc_canvas_offer',
(data: { data: { offer: RTCSessionDescriptionInit; id: string } }) => {
const { offer, id } = data.data;
if (checkId(id, this.cId)) {
this.handleOffer(offer, id);
}
},
);
this.socket.on(
'webrtc_canvas_ice_candidate',
(data: { data: { candidate: RTCIceCandidateInit; id: string } }) => {
const { candidate, id } = data.data;
if (checkId(id, this.cId)) {
this.handleCandidate(candidate, id);
}
},
);
this.socket.on('webrtc_canvas_stop', (data: { id: string }) => {
const { id } = data;
const canvasId = getCanvasId(id);
this.connections.delete(id);
this.streams.delete(id);
this.canvasesData.delete(canvasId);
});
this.socket.on('webrtc_canvas_restart', () => {
this.clear();
});
}
async handleOffer(
offer: RTCSessionDescriptionInit,
id: string,
): Promise<void> {
const pc = new RTCPeerConnection({
iceServers: this.config,
});
// Save the connection
this.connections.set(id, pc);
pc.onicecandidate = (event) => {
if (event.candidate) {
this.socket.emit('webrtc_canvas_ice_candidate', {
candidate: event.candidate,
id,
});
}
};
pc.ontrack = (event) => {
const stream = event.streams[0];
if (stream) {
// Detect canvasId from remote peer id
const canvasId = getCanvasId(id);
this.streams.set(canvasId, stream);
setTimeout(() => {
const node = this.getNode(parseInt(canvasId, 10));
const videoEl = spawnVideo(
stream.clone() as MediaStream,
node as VElement,
);
if (node && videoEl) {
this.canvasesData.set(canvasId, {
video: videoEl,
canvas: node.node as HTMLCanvasElement,
canvasCtx: (node.node as HTMLCanvasElement)?.getContext(
'2d',
) as CanvasRenderingContext2D,
});
this.draw();
} else {
logger.log('NODE', canvasId, 'IS NOT FOUND');
}
}, 250);
}
};
await pc.setRemoteDescription(new RTCSessionDescription(offer));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
this.socket.emit('webrtc_canvas_answer', { answer, id });
}
async handleCandidate(
candidate: RTCIceCandidateInit,
id: string,
): Promise<void> {
const pc = this.connections.get(id);
if (pc) {
try {
await pc.addIceCandidate(new RTCIceCandidate(candidate));
} catch (e) {
console.error('Error adding ICE candidate', e);
}
}
}
clear() {
this.connections.forEach((pc) => {
pc.close();
});
this.connections.clear();
this.streams.clear();
this.canvasesData.clear();
}
draw = () => {
if (this.frameCounter % 4 === 0) {
if (this.canvasesData.size === 0) {
return;
}
this.canvasesData.forEach((canvasData, id) => {
const { video, canvas, canvasCtx } = canvasData;
const node = this.getNode(parseInt(id, 10));
if (node) {
canvasCtx.drawImage(video, 0, 0, canvas.width, canvas.height);
} else {
this.canvasesData.delete(id);
}
});
}
this.frameCounter++;
requestAnimationFrame(() => this.draw());
};
}
function spawnVideo(stream: MediaStream, node: VElement) {
const videoEl = document.createElement('video');
videoEl.srcObject = stream;
videoEl.setAttribute('autoplay', 'true');
videoEl.setAttribute('muted', 'true');
videoEl.setAttribute('playsinline', 'true');
videoEl.setAttribute('crossorigin', 'anonymous');
videoEl
.play()
.then(() => true)
.catch(() => {
toast.error('Click to unpause canvas stream', {
autoClose: false,
toastId: 'canvas-stream',
});
// we allow that if user just reloaded the page
});
const clearListeners = () => {
document.removeEventListener('click', startStream);
videoEl.removeEventListener('playing', clearListeners);
};
videoEl.addEventListener('playing', clearListeners);
const startStream = () => {
videoEl
.play()
.then(() => {
toast.dismiss('canvas-stream');
clearListeners();
})
.then(() => console.log('unpaused'))
.catch(() => {
// we allow that if user just reloaded the page
});
document.removeEventListener('click', startStream);
};
document.addEventListener('click', startStream);
return videoEl;
}
function checkId(id: string, cId: string): boolean {
return id.includes(cId);
}
function getCanvasId(id: string): string {
return id.split('-')[4];
}
/** simple peer example
* // @ts-ignore
* const peer = new SLPeer({ initiator: false })
* socket.on('c_signal', ({ data }) => {
* console.log('got signal', data)
* peer.signal(data.data);
* peer.canvasId = data.id;
* });
*
* peer.on('signal', (data: any) => {
* socket.emit('c_signal', data);
* });
* peer.on('stream', (stream: MediaStream) => {
* console.log('stream ready', stream, peer.canvasId);
* this.streams.set(peer.canvasId, stream)
* setTimeout(() => {
* const node = this.getNode(peer.canvasId)
* console.log(peer.canvasId, this.streams, node)
* spawnVideo(this.streams.get(peer.canvasId)?.clone(), node, this.screen)
* }, 500)
* })
* peer.on('error', console.error)
*
* */