Files
battle-caster-v1/src/model/Remote/Channels/WebRTCChannel.ts
2024-11-24 18:03:06 +00:00

205 lines
6.3 KiB
TypeScript

import { VirtualFile } from "../../FileSystem/types";
import { RemoteDeviceManagerCtx } from "../RemoteDeviceManager";
import { JsonChannelCaster, JsonChannelSummon, Message, MessageEmitter } from "./type";
export class WebRtcChannelCaster extends MessageEmitter implements JsonChannelCaster {
private peer = new RTCPeerConnection()
private offerSent = false;
private dataChannel = this.peer.createDataChannel('json');
public readonly sink: MediaStreamAudioDestinationNode;
constructor(
private channel: JsonChannelCaster,
{onOpen}:Partial<{onOpen: Function}> = {},
// @ts-ignore
private ctx: RemoteDeviceManagerCtx
) {
super();
this.sink = ctx.audioContext.createMediaStreamDestination();
this.channel.on(this.onJsonMessage);
this.dataChannel.addEventListener('close', () => {
this.emit({ type: 'disconnected' });
});
this.dataChannel.addEventListener('open', () => {
onOpen?.();
this.emit({ type: 'connected' });
});
this.dataChannel.addEventListener('message', this.onMessage);
this.peer.addTrack(this.sink.stream.getAudioTracks()[0], this.sink.stream);
this.peer.onicecandidate = (event) => {
if (event.candidate) {
this.channel.send({
type: 'RtcIce',
candidate: event.candidate
});
}
}
this.peer.onnegotiationneeded = async () => {
const offer = await this.peer.createOffer();
await this.peer.setLocalDescription(offer);
// Send the offer to the remote peer
this.channel.send({
type: 'RtcOffer',
offer
});
console.log('negotiationneeded');
}
}
private onMessage = (event: MessageEvent) => {
const data = JSON.parse(event.data) as Message;
this.emit(data);
}
private onJsonMessage = (data: Message) => {
if (data.type === 'summoned') {
if (!this.offerSent) {
this.peer.createOffer().then(offer => {
this.peer.setLocalDescription(offer);
this.channel.send({
type: 'RtcOffer',
offer
});
});
this.offerSent = true;
}
} else if (data.type === 'RtcAnswer') {
this.peer.setRemoteDescription(new RTCSessionDescription(data.answer));
} else if (data.type === 'RtcIce') {
this.peer.addIceCandidate(new RTCIceCandidate(data.candidate));
}
}
async sendBinary(file: VirtualFile, data: Blob) {
const channelName = `file:${Math.random()}`;
this.dataChannel.send(JSON.stringify({
type: 'fileTransfer',
channel: channelName,
size: data.size,
file,
}));
const channel = this.peer.createDataChannel(channelName);
channel.binaryType = 'arraybuffer';
const buffer = await data.arrayBuffer();
const chunkSize = 16384;
for (let i = 0; i < buffer.byteLength; i += chunkSize) {
const chunk = buffer.slice(i, i + chunkSize);
channel.send(chunk);
}
channel.close();
await new Promise((resolve) => channel.addEventListener('close', resolve, { once: true }));
}
send(data: Message) {
this.dataChannel.send(JSON.stringify(data));
}
connectVideoStream(stream: MediaStream) {
this.peer.addTrack(stream.getVideoTracks()[0], stream);
}
disconnect() {
super.disconnect();
}
}
export class WebRtcChannelSummon extends MessageEmitter implements JsonChannelSummon {
private peer = new RTCPeerConnection()
private dataChannel?: RTCDataChannel;
private transferedFilesMeta = new Map<string, VirtualFile>();
constructor(private channel: JsonChannelCaster) {
super();
this.channel.on(this.onJsonMessage);
this.peer.onicecandidate = (event) => {
if (event.candidate) {
this.channel.send({
type: 'RtcIce',
candidate: event.candidate
});
}
}
this.peer.ontrack = (event) => {
event.streams.forEach((stream) => {
stream.getTracks().forEach((track) => {
if (event.track.kind === 'audio') {
console.log('audio track', track);
const audioElement = document.createElement('audio');
audioElement.srcObject = stream; // Attach the received stream
audioElement.play();
} else if (event.track.kind === 'video') {
console.log('video track', track);
const videoEl = document.getElementById('mainVideo') as HTMLVideoElement;
if (videoEl) {
videoEl.srcObject = stream;
videoEl.play();
}
}
});
});
};
this.peer.ondatachannel = (event) => {
if (event.channel.label === 'video') {
// @ts-ignore
this.videoChannel = event.channel;
} else if (event.channel.label === 'json') {
this.dataChannel = event.channel;
this.dataChannel.addEventListener('message', this.onRtcMessage);
this.emit({ type: 'connected' });
} else {
const channel = event.channel;
const chunks:ArrayBuffer[] = [];
channel.addEventListener('message', (event: MessageEvent<ArrayBuffer>) => {
console.log('>', channel.label, event.data);
chunks.push(event.data);
});
channel.addEventListener('close', () => {
console.log('>', channel.label, 'closed');
const meta = this.transferedFilesMeta.get(channel.label)!;
// TODO: handle data before meta?
const blob = new Blob(chunks, { type: meta?.mimeType ?? 'application/octet-stream' });
console.log(meta, blob);
this.emit({
type: 'fileTransfered',
meta,
blob,
})
});
}
}
}
private onRtcMessage = (event: MessageEvent) => {
const msg = JSON.parse(event.data) as Message;
if (msg.type === 'fileTransfer') {
this.transferedFilesMeta.set(msg.channel, msg.file);
} else {
this.emit(msg);
}
};
private onJsonMessage = (data: Message) => {
if (data.type === 'RtcOffer') {
this.peer.setRemoteDescription(new RTCSessionDescription(data.offer));
this.peer.createAnswer().then(answer => {
this.peer.setLocalDescription(answer);
this.channel.send({
type: 'RtcAnswer',
answer
});
});
} else if (data.type === 'RtcIce') {
this.peer.addIceCandidate(new RTCIceCandidate(data.candidate));
}
}
send(data: Message) {
this.dataChannel?.send(JSON.stringify(data));
}
}