From 44c2a22ff1fdb42cfb38ce5484b6fde9a523a1da Mon Sep 17 00:00:00 2001 From: James Ketrenos Date: Mon, 7 Mar 2022 19:31:08 -0800 Subject: [PATCH] Working on audio plumbing Signed-off-by: James Ketrenos --- client/package.json | 3 +- client/src/MediaControl.css | 21 +++ client/src/MediaControl.js | 351 ++++++++++++++++++++++++++++++++++++ client/src/Table.css | 18 +- client/src/Table.js | 22 ++- client/src/setupProxy.js | 8 +- server/app.js | 16 +- server/routes/games.js | 133 +++++++++++--- 8 files changed, 528 insertions(+), 44 deletions(-) create mode 100644 client/src/MediaControl.css create mode 100644 client/src/MediaControl.js diff --git a/client/package.json b/client/package.json index c945364..03e7c88 100644 --- a/client/package.json +++ b/client/package.json @@ -22,10 +22,11 @@ "react-moment": "^1.1.1", "react-router-dom": "^6.2.1", "react-scripts": "5.0.0", + "socket.io-client": "^4.4.1", "web-vitals": "^2.1.2" }, "scripts": { - "start": "react-scripts start", + "start": "HTTPS=true react-scripts start", "build": "export $(cat ../.env | xargs) && react-scripts build", "test": "export $(cat ../.env | xargs) && react-scripts test", "eject": "export $(cat ../.env | xargs) && react-scripts eject" diff --git a/client/src/MediaControl.css b/client/src/MediaControl.css new file mode 100644 index 0000000..5b37014 --- /dev/null +++ b/client/src/MediaControl.css @@ -0,0 +1,21 @@ +.MediaAgent { + display: none; + position: absolute; + display: flex; + top: 0; + left: 0; + z-index: 50000; +} + +.MediaControl { + display: flex; + position: relative; + flex-direction: row; + flex-grow: 1; + justify-content: flex-end; + align-items: center; +} + +.MediaControl > div { + display: flex; +} diff --git a/client/src/MediaControl.js b/client/src/MediaControl.js new file mode 100644 index 0000000..035222c --- /dev/null +++ b/client/src/MediaControl.js @@ -0,0 +1,351 @@ +import React, { useState, useEffect } from "react"; +import "./MediaControl.css"; + +import VolumeOff from '@mui/icons-material/VolumeOff'; +import VolumeUp from '@mui/icons-material/VolumeUp'; +import MicOff from '@mui/icons-material/MicOff'; +import Mic from '@mui/icons-material/Mic'; + +const USE_AUDIO = true; +const DEFAULT_CHANNEL = 'some-global-channel-name'; +const MUTE_AUDIO_BY_DEFAULT = false; +let local_media_stream = undefined; + +const ICE_SERVERS = [ + {urls:"stun:stun.l.google.com:19302"} +]; + +const MediaAgent = ({ table }) => { + const [peers, setPeers] = useState({}); + + if (!table.ws) { + return <>; + } + + const ws = table.ws; + + const addPeer = (config) => { + console.log('Signaling server said to add peer:', config); + const peer_id = config.peer_id; + if (peer_id in peers) { + /* This could happen if the user joins multiple channels where the other peer is also in. */ + console.log("Already connected to peer ", peer_id); + return; + } + const peer_connection = new RTCPeerConnection( + { iceServers: ICE_SERVERS }, + /* this will no longer be needed by chrome + * eventually (supposedly), but is necessary + * for now to get firefox to talk to chrome */ + { optional: [{DtlsSrtpKeyAgreement: true}]} + ); + peers[peer_id] = peer_connection; + setPeers(Object.assign({}, peers)); + + peer_connection.onicecandidate = (event) => { + if (event.candidate) { + ws.send(JSON.stringify({ + type: 'relayICECandidate', + config: { + peer_id: peer_id, + ice_candidate: { + sdpMLineIndex: event.candidate.sdpMLineIndex, + candidate: event.candidate.candidate + } + } + })); + } + }; + + peer_connection.ontrack = (event) => { + console.log("ontrack", event); + peer_connection.attributes = { + autoPlay: 'autoplay', + muted: true, + controls: true + }; + if (window.URL) { + peer_connection.extra = { + srcObject: event.streams[0] + }; + } else { + peer_connection.extra = { + src: event.streams[0] + }; + } + }; + + /* Add our local stream */ + peer_connection.addStream(local_media_stream); + + /* Only one side of the peer connection should create the + * offer, the signaling server picks one to be the offerer. + * The other user will get a 'sessionDescription' event and will + * create an offer, then send back an answer 'sessionDescription' to us + */ + if (config.should_create_offer) { + console.log("Creating RTC offer to ", peer_id); + peer_connection.createOffer((local_description) => { + console.log("Local offer description is: ", local_description); + peer_connection.setLocalDescription(local_description, () => { + ws.send(JSON.stringify({ + type: 'relaySessionDescription', + config: { + 'peer_id': peer_id, + 'session_description': local_description + } + })); + console.log("Offer setLocalDescription succeeded"); + }, () => { + console.error("Offer setLocalDescription failed!"); + }) + }, (error) => { + console.log("Error sending offer: ", error); + }); + } + } + + const sessionDescription = ({ peer_id, session_description }) => { + /** + * Peers exchange session descriptions which contains information + * about their audio / video settings and that sort of stuff. First + * the 'offerer' sends a description to the 'answerer' (with type + * "offer"), then the answerer sends one back (with type "answer"). + */ + console.log('Remote description received: ', peer_id, session_description); + const peer = peers[peer_id]; + console.log(session_description); + + const desc = new RTCSessionDescription(session_description); + const stuff = peer.setRemoteDescription(desc, () => { + console.log("setRemoteDescription succeeded"); + if (session_description.type == "offer") { + console.log("Creating answer"); + peer.createAnswer((local_description) => { + console.log("Answer description is: ", local_description); + peer.setLocalDescription(local_description, () => { + ws.send(JSON.stringify({ + type: 'relaySessionDescription', + config: { + peer_id, + session_description: local_description + } + })); + console.log("Answer setLocalDescription succeeded"); + }, () => { + console.error("Answer setLocalDescription failed!"); + }); + }, (error) => { + console.log("Error creating answer: ", error); + console.log(peer); + }); + } + }, (error) => { + console.log("setRemoteDescription error: ", error); + }); + + console.log("Description Object: ", desc); + }; + + const removePeer = ({peer_id}) => { + console.log('Signaling server said to remove peer:', peer_id); + if (peer_id in peers) { + peers[peer_id].close(); + } + + delete peers[peer_id]; + setPeers(Object.assign({}, peers)); + }; + + const iceCandidate = (config) => { + /** + * The offerer will send a number of ICE Candidate blobs to the answerer so they + * can begin trying to find the best path to one another on the net. + */ + const peer = peers[config.peer_id], + ice_candidate = config.ice_candidate; + if (!peer) { + console.error(`No peer for ${config.peer_id}`); + return; + } + peer.addIceCandidate(new RTCIceCandidate(ice_candidate)) + .then(() => { + }) + .catch((error) => { + console.error(peer, ice_candidate); + console.error(error); + }); + }; + + ws.addEventListener('message', (event) => { + let data; + try { + data = JSON.parse(event.data); + } catch (error) { + console.error(error); + return; + } + switch (data.type) { + case 'addPeer': addPeer(data.data); break; + case 'removePeer': removePeer(data.data); break; + case 'iceCandidate': iceCandidate(data.data); break; + case 'sessionDescription': sessionDescription(data.data); break; + } + }); + + ws.addEventListener('error', (event) => { + console.error(`${table.game.name} WebSocket error`); + }); + + ws.addEventListener('close', (event) => { + console.log(`${table.game.name} Disconnected from signaling server`); + /* Tear down all of our peer connections and remove all the + * media divs when we disconnect */ + for (let peer_id in peers) { + peers[peer_id].close(); + } + + for (let id in peers) { + delete peers[id]; + } + setPeers(Object.assign({}, peers)); + }); + + ws.addEventListener('open', (event) => { + setup_local_media(() => { + /* once the user has given us access to their + * microphone/camcorder, join the channel and start peering up */ + join_chat_channel(ws, table.game.id, {'whatever-you-want-here': 'stuff'}); + }); + }); + + if (!table.game) { + return <>; + } + + const setup_local_media = (callback, errorback) => { + if (local_media_stream !== undefined) { /* ie, if we've already been initialized */ + if (callback) callback(); + return; + } + + /* Ask user for permission to use the computers microphone and/or camera, + * attach it to an