|
| 1 | +import{ClientMessage,ServerMessage}from"./sfu.ts"; |
| 2 | + |
| 3 | +constMAX_DOWNSTREAMS=9; |
| 4 | + |
| 5 | +typeMID=string; |
| 6 | + |
| 7 | +exportinterfaceClientCoreConfig{ |
| 8 | +sfuUrl:string; |
| 9 | +maxDownstreams:number; |
| 10 | +onStateChanged?:(state:RTCPeerConnectionState)=>void; |
| 11 | +} |
| 12 | + |
| 13 | +exportclassClientCore{ |
| 14 | + #sfuUrl:string; |
| 15 | + #pc:RTCPeerConnection; |
| 16 | + #rpc:RTCDataChannel; |
| 17 | + #videoSender:RTCRtpTransceiver; |
| 18 | + #audioSender:RTCRtpTransceiver; |
| 19 | + #closed:boolean; |
| 20 | + |
| 21 | + #videoSlots:Record<MID,RTCRtpTransceiver>; |
| 22 | + #audioSlots:Record<MID,RTCRtpTransceiver>; |
| 23 | + |
| 24 | +constructor(cfg:ClientCoreConfig){ |
| 25 | +this.#sfuUrl=cfg.sfuUrl; |
| 26 | +constmaxDownstreams=Math.max( |
| 27 | +Math.min(cfg.maxDownstreams,MAX_DOWNSTREAMS), |
| 28 | +0, |
| 29 | +); |
| 30 | +constonStateChanged=cfg.onStateChanged||(()=>{}); |
| 31 | +this.#closed=false; |
| 32 | +this.#videoSlots={}; |
| 33 | +this.#audioSlots={}; |
| 34 | + |
| 35 | +this.#pc=newRTCPeerConnection(); |
| 36 | +this.#pc.onconnectionstatechange=()=>{ |
| 37 | +constconnectionState=this.#pc.connectionState; |
| 38 | +console.debug(`PeerConnection state changed:${connectionState}`); |
| 39 | +if(connectionState==="connected"){ |
| 40 | +onStateChanged(connectionState); |
| 41 | +}elseif( |
| 42 | +connectionState==="failed"||connectionState==="closed"|| |
| 43 | +connectionState==="disconnected" |
| 44 | +){ |
| 45 | +this.#close( |
| 46 | +`PeerConnection state became:${connectionState}`, |
| 47 | +); |
| 48 | +} |
| 49 | +}; |
| 50 | + |
| 51 | +this.#pc.ontrack=(event:RTCTrackEvent)=>{ |
| 52 | +constmid=event.transceiver?.mid; |
| 53 | +consttrack=event.track; |
| 54 | +if(!mid||!track){ |
| 55 | +console.warn("Received track event without MID or track object."); |
| 56 | +return; |
| 57 | +} |
| 58 | + |
| 59 | +// TODO: implement this |
| 60 | +}; |
| 61 | + |
| 62 | +// SFU RPC DataChannel |
| 63 | +this.#rpc=this.#pc.createDataChannel("pulsebeam::rpc"); |
| 64 | +this.#rpc.binaryType="arraybuffer"; |
| 65 | +this.#rpc.onmessage=(event:MessageEvent)=>{ |
| 66 | +try{ |
| 67 | +constserverMessage=ServerMessage.fromBinary( |
| 68 | +newUint8Array(event.dataasArrayBuffer), |
| 69 | +); |
| 70 | +constpayload=serverMessage.payload; |
| 71 | +constpayloadKind=payload.oneofKind; |
| 72 | +if(!payloadKind){ |
| 73 | +console.warn("Received SFU message with undefined payload kind."); |
| 74 | +return; |
| 75 | +} |
| 76 | + |
| 77 | +// TODO: implement this |
| 78 | +}catch(e:any){ |
| 79 | +this.#close(`Error processing SFU RPC message:${e}`); |
| 80 | +} |
| 81 | +}; |
| 82 | +this.#rpc.onclose=()=>{ |
| 83 | +this.#close("Internal RPC closed prematurely"); |
| 84 | +}; |
| 85 | +this.#rpc.onerror=(e)=>{ |
| 86 | +this.#close(`Internal RPC closed prematurely with an error:${e}`); |
| 87 | +}; |
| 88 | + |
| 89 | +// Transceivers |
| 90 | +this.#videoSender=this.#pc.addTransceiver("video",{ |
| 91 | +direction:"sendonly", |
| 92 | +}); |
| 93 | +this.#audioSender=this.#pc.addTransceiver("audio",{ |
| 94 | +direction:"sendonly", |
| 95 | +}); |
| 96 | +for(leti=0;i<maxDownstreams;i++){ |
| 97 | +constvideoTransceiver=this.#pc.addTransceiver("video",{ |
| 98 | +direction:"recvonly", |
| 99 | +}); |
| 100 | +if(!videoTransceiver.mid){ |
| 101 | +this.#close("missing mid from video recvonly"); |
| 102 | +return; |
| 103 | +} |
| 104 | + |
| 105 | +this.#videoSlots[videoTransceiver.mid]=videoTransceiver; |
| 106 | +constaudioTransceiver=this.#pc.addTransceiver("audio",{ |
| 107 | +direction:"recvonly", |
| 108 | +}); |
| 109 | + |
| 110 | +if(!audioTransceiver.mid){ |
| 111 | +this.#close("missing mid from audio recvonly"); |
| 112 | +return; |
| 113 | +} |
| 114 | +this.#audioSlots[audioTransceiver.mid]=audioTransceiver; |
| 115 | +} |
| 116 | +} |
| 117 | + |
| 118 | + #close(error?:string){ |
| 119 | +if(this.#closed)return; |
| 120 | + |
| 121 | +if(error){ |
| 122 | +console.error("exited with an error:",error); |
| 123 | +} |
| 124 | + |
| 125 | +this.#closed=true; |
| 126 | +} |
| 127 | + |
| 128 | +asyncconnect(room:string,participant:string){ |
| 129 | +if(this.#closed){ |
| 130 | +consterrorMessage= |
| 131 | +"This client instance has been terminated and cannot be reused."; |
| 132 | +console.error(errorMessage); |
| 133 | +thrownewError(errorMessage);// More direct feedback to developer |
| 134 | +} |
| 135 | + |
| 136 | +try{ |
| 137 | +constoffer=awaitthis.#pc.createOffer(); |
| 138 | +awaitthis.#pc.setLocalDescription(offer); |
| 139 | +constresponse=awaitfetch( |
| 140 | +`${this.#sfuUrl}?room=${room}&participant=${participant}`, |
| 141 | +{ |
| 142 | +method:"POST", |
| 143 | +body:offer.sdp!, |
| 144 | +headers:{"Content-Type":"application/sdp"}, |
| 145 | +}, |
| 146 | +); |
| 147 | +if(!response.ok){ |
| 148 | +thrownewError( |
| 149 | +`Signaling request failed:${response.status}${awaitresponse |
| 150 | +.text()}`, |
| 151 | +); |
| 152 | +} |
| 153 | +awaitthis.#pc.setRemoteDescription({ |
| 154 | +type:"answer", |
| 155 | +sdp:awaitresponse.text(), |
| 156 | +}); |
| 157 | +// Status transitions to "connected" will be handled by onconnectionstatechange and data channel onopen events. |
| 158 | +}catch(error:any){ |
| 159 | +this.#close( |
| 160 | +error.message||"Signaling process failed unexpectedly.", |
| 161 | +); |
| 162 | +} |
| 163 | +} |
| 164 | + |
| 165 | +disconnect(){ |
| 166 | +this.#pc.close(); |
| 167 | +} |
| 168 | + |
| 169 | +publish(stream:MediaStream){ |
| 170 | +constvideoTracks=stream.getVideoTracks(); |
| 171 | +if(videoTracks.length>1){ |
| 172 | +thrownewError( |
| 173 | +`Unexpected MediaStream composition: Expected at most one video track, but found${videoTracks.length}. This component or function is designed to handle a single video source and/or a single audio source.`, |
| 174 | +); |
| 175 | +} |
| 176 | + |
| 177 | +constaudioTracks=stream.getAudioTracks(); |
| 178 | +if(audioTracks.length>1){ |
| 179 | +thrownewError( |
| 180 | +`Unexpected MediaStream composition: Expected at most one audio track, but found${audioTracks.length}. This component or function is designed to handle a single audio source and/or a single audio source.`, |
| 181 | +); |
| 182 | +} |
| 183 | + |
| 184 | +constnewVideoTrack=videoTracks.at(0)||null; |
| 185 | +this.#videoSender.sender.replaceTrack(newVideoTrack); |
| 186 | + |
| 187 | +constnewAudioTrack=audioTracks.at(0)||null; |
| 188 | +this.#audioSender.sender.replaceTrack(newAudioTrack); |
| 189 | +} |
| 190 | +} |