| import {Builder, ByteBuffer} from 'flatbuffers'; |
| import {Payload, SdpType, WebSocketIce, WebSocketMessage, WebSocketSdp} from 'org_frc971/aos/network/web_proxy_generated'; |
| |
| // Port 9 is used to indicate an active (outgoing) TCP connection. The server |
| // would send a corresponding candidate with the actual TCP port it is |
| // listening on. Ignore NaN since it doesn't tell us anything about whether the |
| // port selected will have issues through the FMS firewall. |
| function validPort(port: number): boolean { |
| return Number.isNaN(port) || port == 9 || (port >= 1180 && port <= 1190) || |
| (port >= 5800 && port <= 5810); |
| } |
| |
| // Some browsers don't support the port property so provide our own function |
| // to get it. |
| function getIcePort(candidate: RTCIceCandidate): number { |
| if (candidate.port === undefined) { |
| return Number(candidate.candidate.split(' ')[5]); |
| } else { |
| return candidate.port; |
| } |
| } |
| |
| function isMdnsAddress(address: string): boolean { |
| return address.includes('.local'); |
| } |
| |
| function getIceAddress(candidate: RTCIceCandidate): string { |
| if (candidate.address === undefined) { |
| return candidate.candidate.split(' ')[4]; |
| } else { |
| return candidate.address; |
| } |
| } |
| |
| export class Connection { |
| private webSocketConnection: WebSocket|null = null; |
| private rtcPeerConnection: RTCPeerConnection|null = null; |
| private html5VideoElement: HTMLMediaElement|null = null; |
| private webSocketUrl: string; |
| private statsInterval: number; |
| |
| private candidateNominatedId: string; |
| private lastRtpTimestamp: number = 0; |
| private lastBytesReceived: number = 0; |
| private lastFramesDecoded: number = 0; |
| |
| |
| constructor() { |
| const server = location.host; |
| this.webSocketUrl = `ws://${server}/ws`; |
| |
| for (let elem of document.getElementsByClassName('page_hostname')) { |
| (elem as HTMLElement).innerText = location.hostname; |
| } |
| } |
| |
| connect(): void { |
| this.html5VideoElement = |
| (document.getElementById('stream') as HTMLMediaElement); |
| |
| this.webSocketConnection = new WebSocket(this.webSocketUrl); |
| this.webSocketConnection.binaryType = 'arraybuffer'; |
| this.webSocketConnection.addEventListener( |
| 'message', (e) => this.onWebSocketMessage(e)); |
| } |
| |
| |
| checkRemoteCandidate(candidate: RTCIceCandidate) { |
| const port = getIcePort(candidate); |
| if (!validPort(port)) { |
| document.getElementById('bad_remote_port_port').innerText = |
| port.toString(); |
| document.getElementById('bad_remote_port_error').style['display'] = |
| 'inherit'; |
| } |
| const address = getIceAddress(candidate); |
| if (isMdnsAddress(address)) { |
| document.getElementById('bad_remote_address_address').innerText = address; |
| document.getElementById('bad_remote_address_error').style['display'] = |
| 'inherit'; |
| } |
| } |
| |
| checkLocalCandidate(candidate: RTCIceCandidate) { |
| const port = getIcePort(candidate); |
| if (!validPort(port)) { |
| document.getElementById('bad_local_port_port').innerText = |
| port.toString(); |
| document.getElementById('bad_local_port_error').style['display'] = |
| 'inherit'; |
| } |
| const address = getIceAddress(candidate); |
| if (isMdnsAddress(address)) { |
| document.getElementById('bad_local_address_address').innerText = address; |
| document.getElementById('bad_local_address_error').style['display'] = |
| 'inherit'; |
| } |
| } |
| |
| onLocalDescription(desc: RTCSessionDescriptionInit): void { |
| console.log('Local description: ' + JSON.stringify(desc)); |
| this.rtcPeerConnection.setLocalDescription(desc).then(() => { |
| const builder = new Builder(512); |
| const sdpFb = WebSocketSdp.createWebSocketSdp( |
| builder, SdpType.ANSWER, builder.createString(desc.sdp)); |
| const message = WebSocketMessage.createWebSocketMessage( |
| builder, Payload.WebSocketSdp, sdpFb); |
| builder.finish(message); |
| const array = builder.asUint8Array(); |
| |
| this.webSocketConnection.send(array.buffer.slice(array.byteOffset)); |
| }); |
| } |
| |
| onIncomingSDP(sdp: RTCSessionDescriptionInit): void { |
| console.log('Incoming SDP: ' + JSON.stringify(sdp)); |
| this.rtcPeerConnection.setRemoteDescription(sdp); |
| this.rtcPeerConnection.createAnswer().then( |
| (e) => this.onLocalDescription(e)); |
| } |
| |
| onIncomingICE(ice: RTCIceCandidateInit): void { |
| let candidate = new RTCIceCandidate(ice); |
| console.log('Incoming ICE: ' + JSON.stringify(ice)); |
| this.rtcPeerConnection.addIceCandidate(candidate); |
| |
| // If end of candidates, won't have a port. |
| if (candidate.candidate !== '') { |
| this.checkRemoteCandidate(candidate); |
| } |
| } |
| |
| onRequestStats(track: MediaStreamTrack): void { |
| this.rtcPeerConnection.getStats(track).then((stats) => { |
| // getStats returns a list of stats of various types in an implementation |
| // defined order. We would like to get the protocol in use. This is found |
| // in remote-candidate. However, (again, implementation defined), some |
| // browsers return only remote-candidate's in use, while others return all |
| // of them that attempted negotiation. To figure this out, look at the |
| // currently nominated candidate-pair, then match up it's remote with a |
| // remote-candidate we see later. Since the order isn't defined, store the |
| // id in this in case the remote-candidate comes before candidate-pair. |
| |
| for (let dict of stats.values()) { |
| if (dict.type === 'candidate-pair' && dict.nominated) { |
| this.candidateNominatedId = dict.remoteCandidateId; |
| } |
| if (dict.type === 'remote-candidate' && |
| dict.id === this.candidateNominatedId) { |
| document.getElementById('stats_protocol').innerText = dict.protocol; |
| } |
| if (dict.type === 'inbound-rtp') { |
| const timestamp = dict.timestamp; |
| const bytes_now = dict.bytesReceived; |
| const frames_decoded = dict.framesDecoded; |
| |
| document.getElementById('stats_bps').innerText = |
| Math.round( |
| (bytes_now - this.lastBytesReceived) * 8 /* bits */ / |
| 1024 /* kbits */ / (timestamp - this.lastRtpTimestamp) * |
| 1000 /* ms */) |
| .toString(); |
| |
| document.getElementById('stats_fps').innerText = |
| (Math.round( |
| (frames_decoded - this.lastFramesDecoded) / |
| (timestamp - this.lastRtpTimestamp) * 1000 /* ms */ * 10) / |
| 10).toString(); |
| |
| |
| this.lastRtpTimestamp = timestamp; |
| this.lastBytesReceived = bytes_now; |
| this.lastFramesDecoded = frames_decoded; |
| } |
| } |
| }); |
| } |
| |
| onAddRemoteStream(event: RTCTrackEvent): void { |
| const stream = event.streams[0]; |
| this.html5VideoElement.srcObject = stream; |
| |
| const track = stream.getTracks()[0]; |
| this.statsInterval = |
| window.setInterval(() => this.onRequestStats(track), 1000); |
| } |
| |
| onIceCandidate(event: RTCPeerConnectionIceEvent): void { |
| if (event.candidate == null) { |
| return; |
| } |
| |
| console.log( |
| 'Sending ICE candidate out: ' + JSON.stringify(event.candidate)); |
| |
| const builder = new Builder(512); |
| const iceFb = WebSocketIce.createWebSocketIce( |
| builder, builder.createString(event.candidate.candidate), null, |
| event.candidate.sdpMLineIndex); |
| const message = WebSocketMessage.createWebSocketMessage( |
| builder, Payload.WebSocketIce, iceFb); |
| builder.finish(message); |
| const array = builder.asUint8Array(); |
| |
| this.webSocketConnection.send(array.buffer.slice(array.byteOffset)); |
| |
| // If end of candidates, won't have a port. |
| if (event.candidate.candidate !== '') { |
| this.checkLocalCandidate(event.candidate); |
| } |
| } |
| |
| // When we receive a websocket message, we need to determine what type it is |
| // and handle appropriately. Either by setting the remote description or |
| // adding the remote ice candidate. |
| onWebSocketMessage(e: MessageEvent): void { |
| const buffer = new Uint8Array(e.data) |
| const fbBuffer = new ByteBuffer(buffer); |
| const message = WebSocketMessage.getRootAsWebSocketMessage(fbBuffer); |
| |
| if (!this.rtcPeerConnection) { |
| this.rtcPeerConnection = new RTCPeerConnection(); |
| this.rtcPeerConnection.ontrack = (e) => this.onAddRemoteStream(e); |
| this.rtcPeerConnection.onicecandidate = (e) => this.onIceCandidate(e); |
| } |
| |
| switch (message.payloadType()) { |
| case Payload.WebSocketSdp: |
| const sdpFb = message.payload(new WebSocketSdp()); |
| const sdp: |
| RTCSessionDescriptionInit = {type: 'offer', sdp: sdpFb.payload()}; |
| |
| this.onIncomingSDP(sdp); |
| break; |
| case Payload.WebSocketIce: |
| const iceFb = message.payload(new WebSocketIce()); |
| const candidate = {} as RTCIceCandidateInit; |
| candidate.candidate = iceFb.candidate(); |
| candidate.sdpMLineIndex = iceFb.sdpMLineIndex(); |
| candidate.sdpMid = iceFb.sdpMid(); |
| this.onIncomingICE(candidate); |
| break; |
| default: |
| console.log('got an unknown message'); |
| break; |
| } |
| } |
| } |