arcast/apps/main/src/api/webrtc.ts

275 lines
7.7 KiB
TypeScript

import { BroadcastChannel, cast, getBroadcastChannel } from "./arcast"
export class ScreenSharing extends EventTarget {
signaling: BroadcastChannel
handlers: { [messageType: string]: (data: { type: string, data: any }) => void }
constructor() {
super()
this.handlers = {}
this.signaling = getBroadcastChannel("arcast.screen-sharing", this.onSignal.bind(this))
}
onSignal(data: any) {
let message: any
try {
message = JSON.parse(data)
} catch (err) {
console.warn(err)
return
}
if (typeof message.type !== "string") return
const handler = this.handlers[message.type]
if (!handler) return
handler(message.data)
}
send(type: string, data: any) {
this.signaling.send(JSON.stringify({ type, data }));
}
}
export class ScreenSharingClient extends ScreenSharing {
sessionId: string
clientId: string
peerConnection: RTCPeerConnection | undefined
constructor(sessionId: string) {
super()
this.sessionId = sessionId
this.clientId = window.crypto.randomUUID()
this.handlers['server-offer'] = this.onServerOffer.bind(this)
this.handlers['ice-candidate'] = this.onIceCandidate.bind(this)
}
connect() {
this.send("client-request", { clientId: this.clientId, sessionId: this.sessionId })
}
close() {
this.signaling.close()
}
async onServerOffer(message: any) {
if (!this.checkMessage(message)) return
const conn = new RTCPeerConnection()
conn.onicecandidate = this.sendCandidate.bind(this)
conn.ontrack = this.onTrack.bind(this)
conn.onconnectionstatechange = (evt: Event) => {
const state = conn.connectionState
this.dispatchEvent(new StateChangedEvent(state))
}
await conn.setRemoteDescription(new RTCSessionDescription(message.offer))
const answer = await conn.createAnswer()
await conn.setLocalDescription(answer)
this.peerConnection = conn
this.send("client-answer", { clientId: this.clientId, sessionId: this.sessionId, answer })
}
sendCandidate(evt: RTCPeerConnectionIceEvent) {
this.send("ice-candidate", {
clientId: this.clientId,
sessionId: this.sessionId,
candidate: evt.candidate
})
}
onTrack(evt: RTCTrackEvent) {
if (!evt.streams || evt.streams.length < 1) return;
const stream = evt.streams[0]
this.dispatchEvent(new StreamChangedEvent(stream))
}
async onIceCandidate(message: any) {
if (!this.checkMessage(message)) return
if (!this.peerConnection) return
if (!message.candidate) return
await this.peerConnection.addIceCandidate(message.candidate)
}
checkMessage(message: any) {
const sessionId = message.sessionId;
if (!this.sessionId || sessionId !== this.sessionId) return false;
if (this.clientId !== message.clientId) return false;
return true
}
}
export enum ScreenSharingServerState {
Idle = "idle",
Sharing = "sharing"
}
export class ScreenSharingServer extends ScreenSharing {
stream: MediaStream | undefined
sessionId: string | undefined
peerConnections: {
[key: string]: RTCPeerConnection
}
state: ScreenSharingServerState
constructor() {
super()
this.peerConnections = {}
this.state = ScreenSharingServerState.Idle
this.handlers['client-request'] = this.onClientRequest.bind(this)
this.handlers['client-answer'] = this.onClientAnswer.bind(this)
this.handlers['ice-candidate'] = this.onIceCandidate.bind(this)
}
async shareScreen(options?: DisplayMediaStreamOptions) {
try {
this.stream = await navigator.mediaDevices.getDisplayMedia(options)
} catch (err) {
console.warn(err)
return
}
this.sessionId = window.crypto.randomUUID()
const url = `http://127.0.0.1:45555/apps/main/#screen-sharing/sessions/${this.sessionId}`
cast(url)
if (process.env.NODE_ENV === "development") {
window.open(`http://localhost:3000/apps/main/#screen-sharing/sessions/${this.sessionId}`, "_blank")
}
this.state = ScreenSharingServerState.Sharing
this.dispatchEvent(new StateChangedEvent(ScreenSharingServerState.Sharing))
}
onClientRequest(message: any) {
if (!this.checkMessage(message)) return
this.addNewClient(message.clientId)
}
async onIceCandidate(message: any) {
if (!this.checkMessage(message)) return
const conn = this.peerConnections[message.clientId]
if (!conn) return
if (!message.candidate) return
await conn.addIceCandidate(message.candidate)
}
async onClientAnswer(message: any) {
if (!this.checkMessage(message)) return
const conn = this.peerConnections[message.clientId]
if (!conn) return
await conn.setRemoteDescription(new RTCSessionDescription(message.answer));
}
checkMessage(message: any) {
const sessionId = message.sessionId;
if (!this.sessionId || sessionId !== this.sessionId) return false;
const clientId = message.clientId;
if (!clientId) return false;
return true
}
async addNewClient(clientId: string) {
if (!this.stream) return
const conn = new RTCPeerConnection();
const tracks = this.stream?.getVideoTracks()
if (!tracks || tracks.length === 0) {
return
}
conn.addTrack(tracks[0], this.stream)
const offer = await conn.createOffer()
await conn.setLocalDescription(offer);
conn.onicecandidate = this.sendCandidate.bind(this, clientId)
conn.onconnectionstatechange = (evt: Event) => {
const isClosed = conn.connectionState === "disconnected" ||
conn.connectionState === "failed"
if (!isClosed) return;
conn.onicecandidate = null;
conn.onconnectionstatechange = null;
conn.close();
console.log("Removing client", clientId);
delete this.peerConnections[clientId];
}
console.log("Adding client", clientId);
this.peerConnections[clientId] = conn;
this.send("server-offer", { sessionId: this.sessionId, clientId, offer })
}
sendCandidate(clientId: string, evt: RTCPeerConnectionIceEvent) {
this.send("ice-candidate", {
clientId,
sessionId: this.sessionId,
candidate: evt.candidate
})
}
close() {
this.signaling.close()
Object.keys(this.peerConnections).forEach(clientId => {
const conn = this.peerConnections[clientId];
conn.onicecandidate = null;
conn.onconnectionstatechange = null;
conn.close();
delete this.peerConnections[clientId]
})
const tracks = this.stream?.getTracks()
if (!tracks) return
tracks.forEach(track => track.stop())
this.state = ScreenSharingServerState.Idle
this.dispatchEvent(new StateChangedEvent(ScreenSharingServerState.Idle))
}
}
export const StreamChangedEventType = "stream-changed"
export class StreamChangedEvent extends Event {
stream: MediaStream
constructor(stream: MediaStream) {
super(StreamChangedEventType)
this.stream = stream
}
}
export const StateChangedEventType = "state-changed"
export class StateChangedEvent extends Event {
state: string
constructor(state: string) {
super(StateChangedEventType)
this.state = state
}
}