From fb9d6c23d7f6a3a1c2870f7cad8703ad2f7a267a Mon Sep 17 00:00:00 2001 From: Drew Fisher Date: Sun, 28 Aug 2022 04:34:30 -0700 Subject: [PATCH] Reimplement WebRTC controls as a hook This replaces the hierarchy of components that managed our WebRTC interactions with a new `useCallState` hook which encapsulates all of the call state management functionality. This provides a key benefit: we can hoist the actual state storage higher in the component tree, which means that we won't lose call state when switching layouts between desktop and mobile screen widths. As a bonus, this change also corrects a bunch of things from the audio calls implementation that would have gone awry under `StrictMode` in React 18 due to doing side-effectful actions outside of `useEffect` blocks. Fixes #594. --- imports/client/components/CallSection.tsx | 615 ++--------------- imports/client/components/ChatPeople.tsx | 198 +----- imports/client/components/PuzzlePage.tsx | 53 ++ imports/client/hooks/useCallState.ts | 789 ++++++++++++++++++++++ 4 files changed, 922 insertions(+), 733 deletions(-) create mode 100644 imports/client/hooks/useCallState.ts diff --git a/imports/client/components/CallSection.tsx b/imports/client/components/CallSection.tsx index 37021d2ed..b8820c9a4 100644 --- a/imports/client/components/CallSection.tsx +++ b/imports/client/components/CallSection.tsx @@ -1,20 +1,14 @@ /* eslint-disable no-console */ import { Meteor } from 'meteor/meteor'; -import { useTracker, useSubscribe, useFind } from 'meteor/react-meteor-data'; +import { useTracker } from 'meteor/react-meteor-data'; import { _ } from 'meteor/underscore'; import { faCaretDown } from '@fortawesome/free-solid-svg-icons/faCaretDown'; import { faCaretRight } from '@fortawesome/free-solid-svg-icons/faCaretRight'; import { faMicrophoneSlash } from '@fortawesome/free-solid-svg-icons/faMicrophoneSlash'; import { faVolumeMute } from '@fortawesome/free-solid-svg-icons/faVolumeMute'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { Device, types } from 'mediasoup-client'; import React, { - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, + useCallback, useEffect, useRef, useState, } from 'react'; import Alert from 'react-bootstrap/Alert'; import OverlayTrigger from 'react-bootstrap/OverlayTrigger'; @@ -22,20 +16,8 @@ import Tooltip from 'react-bootstrap/Tooltip'; import styled from 'styled-components'; import Flags from '../../Flags'; import MeteorUsers from '../../lib/models/MeteorUsers'; -import ConnectAcks from '../../lib/models/mediasoup/ConnectAcks'; -import Consumers from '../../lib/models/mediasoup/Consumers'; -import Peers from '../../lib/models/mediasoup/Peers'; -import ProducerServers from '../../lib/models/mediasoup/ProducerServers'; -import Routers from '../../lib/models/mediasoup/Routers'; -import Transports from '../../lib/models/mediasoup/Transports'; -import { ConsumerType } from '../../lib/schemas/mediasoup/Consumer'; import { PeerType } from '../../lib/schemas/mediasoup/Peer'; -import { RouterType } from '../../lib/schemas/mediasoup/Router'; -import { TransportType } from '../../lib/schemas/mediasoup/Transport'; -import mediasoupAckConsumer from '../../methods/mediasoupAckConsumer'; -import mediasoupConnectTransport from '../../methods/mediasoupConnectTransport'; -import mediasoupSetProducerPaused from '../../methods/mediasoupSetProducerPaused'; -import { trace } from '../tracing'; +import { Action, CallState } from '../hooks/useCallState'; import Avatar from './Avatar'; import Loading from './Loading'; import Spectrum from './Spectrum'; @@ -99,114 +81,17 @@ const JoiningCall = ({ details }: { details?: string }) => { ); }; -type ProducerCallback = ({ id }: { id: string }) => void; - -const ProducerManager = ({ - paused, - track, - transport, -}: { - paused: boolean; - track: MediaStreamTrack; - transport: types.Transport; -}) => { - const [dupedTrack, setDupedTrack] = useState(); - useEffect(() => { - setDupedTrack(track.clone()); - }, [track]); - - const [producer, setProducer] = useState(); - useEffect(() => { - return () => { - producer?.close(); - }; - }, [producer]); - - const [producerParams, setProducerParams] = useState<[string, string]>(); - const producerServerCallback = useRef(); - - useEffect(() => { - if (producer && paused !== producer.paused) { - if (paused) { - producer.pause(); - } else { - producer.resume(); - } - mediasoupSetProducerPaused.call({ - mediasoupProducerId: producer.id, - paused, - }); - } - }, [paused, producer]); - - const onProduce = useCallback(( - { kind, rtpParameters, appData }: { - kind: string, - rtpParameters: - types.RtpParameters, - appData: any, - }, - callback: ProducerCallback, - ) => { - if (dupedTrack?.id !== appData.trackId) { - return; - } - - producerServerCallback.current = callback; - setProducerParams([kind, JSON.stringify(rtpParameters)]); - }, [dupedTrack?.id]); - useEffect(() => { - transport.on('produce', onProduce); - return () => { - transport.off('produce', onProduce); - }; - }, [transport, onProduce]); - - useEffect(() => { - const observer = ProducerServers.find({ trackId: dupedTrack?.id }).observeChanges({ - added: (_id, fields) => { - producerServerCallback.current?.({ id: fields.producerId! }); - producerServerCallback.current = undefined; - }, - }); - return () => observer.stop(); - }, [dupedTrack?.id]); - - useEffect(() => { - if (!dupedTrack) { - return; - } - void (async () => { - console.log('Creating Mediasoup producer', { track: dupedTrack.id }); - // transport.produce will emit a 'produce' event before it resolves, - // triggering onProduce above - const newProducer = await transport.produce({ - track: dupedTrack, - zeroRtpOnPause: true, - appData: { trackId: dupedTrack.id }, - }); - setProducer(newProducer); - })(); - }, [transport, dupedTrack]); - - useSubscribe(producerParams ? 'mediasoup:producer' : undefined, transport.appData._id, dupedTrack?.id, ...(producerParams ?? [])); - - return null; -}; - -const ProducerBox = ({ +const SelfBox = ({ muted, deafened, audioContext, stream, - transport, popperBoundaryRef, }: { muted: boolean, deafened: boolean, audioContext: AudioContext, stream: MediaStream, - transport: types.Transport, popperBoundaryRef: React.RefObject, }) => { const spectraDisabled = useTracker(() => Flags.active('disable.spectra')); @@ -219,21 +104,6 @@ const ProducerBox = ({ }; }); - const [tracks, setTracks] = useState([]); - useEffect(() => { - // Use Meteor.defer here because the addtrack/removetrack events seem to - // sometimes fire _before_ the track has actually been added to the stream's - // track set. - const captureTracks = () => Meteor.defer(() => setTracks(stream.getTracks())); - captureTracks(); - stream.addEventListener('addtrack', captureTracks); - stream.addEventListener('removetrack', captureTracks); - return () => { - stream.removeEventListener('addtrack', captureTracks); - stream.removeEventListener('removetrack', captureTracks); - }; - }, [stream]); - return ( ) : null} - {tracks.map((track) => ( - - ))} ); }; -const ConsumerManager = ({ - setTrack, - recvTransport, - serverConsumer, -}: { - setTrack: (consumer: string, track?: MediaStreamTrack) => void; - recvTransport: types.Transport, - serverConsumer: ConsumerType, -}) => { - const [consumer, setConsumer] = useState(); - useEffect(() => { - setTrack(serverConsumer._id, consumer?.track); - return () => setTrack(serverConsumer._id, undefined); - }, [serverConsumer._id, consumer?.track, setTrack]); - - const { - _id: meteorConsumerId, - consumerId: mediasoupConsumerId, - producerId, - kind, - rtpParameters, - paused, - } = serverConsumer; - - useEffect(() => { - if (!consumer) { - return; - } - - if (paused) { - consumer.pause(); - } else { - consumer.resume(); - } - }, [consumer, paused]); - - useEffect(() => { - void (async () => { - console.log('Creating new Mediasoup consumer', { mediasoupConsumerId, producerId }); - const newConsumer = await recvTransport.consume({ - id: mediasoupConsumerId, - producerId, - kind, - rtpParameters: JSON.parse(rtpParameters), - }); - setConsumer(newConsumer); - mediasoupAckConsumer.call({ consumerId: meteorConsumerId }); - })(); - }, [meteorConsumerId, mediasoupConsumerId, producerId, kind, rtpParameters, recvTransport]); - - // Use a separate useEffect here since the above will return before the promise resolves - useEffect(() => { - return () => consumer?.close(); - }, [consumer]); - - return null; -}; - const ChatterTooltip = styled(Tooltip)` // Force chatter tooltip overlay to get larger than the default // react-bootstrap stylesheet permits. We can only apply classes to the root @@ -355,19 +159,18 @@ const ChatterTooltip = styled(Tooltip)` const PeerBox = ({ audioContext, selfDeafened, - recvTransport, peer, - consumers, popperBoundaryRef, + stream, }: { audioContext: AudioContext, selfDeafened: boolean, - recvTransport: types.Transport, peer: PeerType, - consumers: ConsumerType[], popperBoundaryRef: React.RefObject, + stream: MediaStream | undefined, }) => { const spectraDisabled = useTracker(() => Flags.active('disable.spectra')); + const audioRef = React.createRef(); const { userId, name, discordAccount } = useTracker(() => { const user = MeteorUsers.findOne(peer.createdBy); return { @@ -376,34 +179,16 @@ const PeerBox = ({ discordAccount: user?.discordAccount, }; }, [peer.createdBy]); - - const { current: stream } = useRef(new MediaStream()); - - const [tracks, setTracks] = useState>(new Map()); - const setTrack = useCallback((consumer: string, track?: MediaStreamTrack) => { - setTracks((prevTracks) => { - const prevTrack = prevTracks.get(consumer); - const newTracks = new Map(prevTracks); - - if (prevTrack) { - stream.removeTrack(prevTrack); - newTracks.delete(consumer); - } - if (track) { - stream.addTrack(track); - newTracks.set(consumer, track); - } - - return newTracks; - }); - }, [stream]); - - const audioRef = useRef(null); useEffect(() => { - if (audioRef.current && tracks.size > 0) { - audioRef.current.srcObject = stream; + if (audioRef.current) { + if (stream) { + // eslint-disable-next-line no-param-reassign + audioRef.current.srcObject = stream; + } else { + audioRef.current.srcObject = null; + } } - }, [tracks, stream]); + }, [stream, audioRef]); const { muted, deafened } = peer; @@ -437,7 +222,7 @@ const PeerBox = ({
{muted && } {deafened && } - {!spectraDisabled && !muted && (tracks.size > 0) ? ( + {!spectraDisabled && !muted && stream && stream.getTracks().length > 0 ? ( ) : null}
-