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