diff --git a/custom/basic-call/components/Call/Container.js b/custom/basic-call/components/Call/Container.js index 22e0d08..5e9cb68 100644 --- a/custom/basic-call/components/Call/Container.js +++ b/custom/basic-call/components/Call/Container.js @@ -2,7 +2,7 @@ import React, { useMemo } from 'react'; import { Audio } from '@custom/shared/components/Audio'; import { BasicTray } from '@custom/shared/components/Tray'; import { useParticipants } from '@custom/shared/contexts/ParticipantsProvider'; -import useJoinSound from '@custom/shared/hooks/useJoinSound'; +import { useJoinSound } from '@custom/shared/hooks/useJoinSound'; import PropTypes from 'prop-types'; import { WaitingRoom } from './WaitingRoom'; diff --git a/custom/shared/components/Tile/Tile.js b/custom/shared/components/Tile/Tile.js index a65b0f4..b6d28bd 100644 --- a/custom/shared/components/Tile/Tile.js +++ b/custom/shared/components/Tile/Tile.js @@ -1,5 +1,5 @@ import React, { memo, useEffect, useState, useRef } from 'react'; -import useVideoTrack from '@custom/shared/hooks/useVideoTrack'; +import { useVideoTrack } from '@custom/shared/hooks/useVideoTrack'; import { ReactComponent as IconMicMute } from '@custom/shared/icons/mic-off-sm.svg'; import classNames from 'classnames'; import PropTypes from 'prop-types'; @@ -21,7 +21,7 @@ export const Tile = memo( onVideoResize, ...props }) => { - const videoTrack = useVideoTrack(participant); + const videoTrack = useVideoTrack(participant.id); const videoRef = useRef(null); const tileRef = useRef(null); const [tileWidth, setTileWidth] = useState(0); diff --git a/custom/shared/components/Tray/TrayMicButton.js b/custom/shared/components/Tray/TrayMicButton.js index f88a476..878c52a 100644 --- a/custom/shared/components/Tray/TrayMicButton.js +++ b/custom/shared/components/Tray/TrayMicButton.js @@ -1,14 +1,11 @@ import React from 'react'; import { TrayButton } from '@custom/shared/components/Tray'; -import { useAudioLevel } from '@custom/shared/hooks/useAudioLevel'; import { ReactComponent as IconMicOff } from '@custom/shared/icons/mic-off-md.svg'; import { ReactComponent as IconMicOn } from '@custom/shared/icons/mic-on-md.svg'; import PropTypes from 'prop-types'; export const TrayMicButton = ({ isMuted, onClick }) => { - const audioLevel = useAudioLevel('local'); - return ( {isMuted ? : } diff --git a/custom/shared/contexts/CallProvider.js b/custom/shared/contexts/CallProvider.js index db5febb..763e836 100644 --- a/custom/shared/contexts/CallProvider.js +++ b/custom/shared/contexts/CallProvider.js @@ -37,6 +37,7 @@ export const CallProvider = ({ const router = useRouter(); const [roomInfo, setRoomInfo] = useState(null); const [enableScreenShare, setEnableScreenShare] = useState(false); + const [enableJoinSound, setEnableJoinSound] = useState(true); const [videoQuality, setVideoQuality] = useState(VIDEO_QUALITY_AUTO); const [showLocalVideo, setShowLocalVideo] = useState(true); const [preJoinNonAuthorized, setPreJoinNonAuthorized] = useState(false); @@ -144,6 +145,7 @@ export const CallProvider = ({ roomExp, enableRecording, enableScreenShare, + enableJoinSound, videoQuality, setVideoQuality, roomInfo, @@ -154,6 +156,7 @@ export const CallProvider = ({ setEnableScreenShare, startCloudRecording, subscribeToTracksAutomatically, + setEnableJoinSound }} > {children} diff --git a/custom/shared/contexts/useCallMachine.js b/custom/shared/contexts/useCallMachine.js index f691796..2d693c9 100644 --- a/custom/shared/contexts/useCallMachine.js +++ b/custom/shared/contexts/useCallMachine.js @@ -78,10 +78,16 @@ export const useCallMachine = ({ const join = useCallback( async (callObject) => { setState(CALL_STATE_JOINING); + + // Force mute clients when joining a call with experimental_optimize_large_calls enabled. + if (room?.config?.experimental_optimize_large_calls) { + callObject.setLocalAudio(false); + } + await callObject.join({ subscribeToTracksAutomatically, token, url }); setState(CALL_STATE_JOINED); }, - [token, subscribeToTracksAutomatically, url] + [room, token, subscribeToTracksAutomatically, url] ); /** diff --git a/custom/shared/hooks/useActiveSpeaker.js b/custom/shared/hooks/useActiveSpeaker.js index c6e0a21..f8f6449 100644 --- a/custom/shared/hooks/useActiveSpeaker.js +++ b/custom/shared/hooks/useActiveSpeaker.js @@ -6,12 +6,12 @@ import { useParticipants } from '../contexts/ParticipantsProvider'; * (= the current one and only actively speaking person) */ export const useActiveSpeaker = () => { - const { showLocalVideo } = useCallState(); + const { broadcastRole, showLocalVideo } = useCallState(); const { activeParticipant, localParticipant, participantCount } = useParticipants(); // we don't show active speaker indicators EVER in a 1:1 call or when the user is alone in-call - if (participantCount <= 2) return null; + if (broadcastRole !== 'attendee' && participantCount <= 2) return null; if (!activeParticipant?.isMicMuted) { return activeParticipant?.id; diff --git a/custom/shared/hooks/useAudioLevel.js b/custom/shared/hooks/useAudioLevel.js index dc8a193..184f17a 100644 --- a/custom/shared/hooks/useAudioLevel.js +++ b/custom/shared/hooks/useAudioLevel.js @@ -1,36 +1,54 @@ import { useEffect, useState } from 'react'; +import getConfig from 'next/config'; -export const useAudioLevel = (sessionId) => { - const [audioLevel, setAudioLevel] = useState(0); +export const useAudioLevel = (stream) => { + const [micVolume, setMicVolume] = useState(0); + const { assetPrefix } = getConfig().publicRuntimeConfig; useEffect(() => { - if (!sessionId) { - return false; + if (!stream) { + setMicVolume(0); + return; } + const AudioCtx = + typeof AudioContext !== 'undefined' + ? AudioContext + : typeof webkitAudioContext !== 'undefined' + ? webkitAudioContext + : null; + if (!AudioCtx) return; + const audioContext = new AudioCtx(); + const mediaStreamSource = audioContext.createMediaStreamSource(stream); + let node; - const i = setInterval(async () => { + const startProcessing = async () => { try { - if (!(window.rtcpeers && window.rtcpeers.sfu)) { - return; - } - const consumer = - window.rtcpeers.sfu.consumers[`${sessionId}/cam-audio`]; - if (!(consumer && consumer.getStats)) { - return; - } - const level = Array.from((await consumer.getStats()).values()).find( - (s) => 'audioLevel' in s - ).audioLevel; - setAudioLevel(level); - } catch (e) { - console.error(e); - } - }, 2000); + await audioContext.audioWorklet.addModule( + `${assetPrefix}/audiolevel-processor.js` + ); - return () => clearInterval(i); - }, [sessionId]); + node = new AudioWorkletNode(audioContext, 'audiolevel'); - return audioLevel; -}; + node.port.onmessage = (event) => { + let volume = 0; + if (event.data.volume) volume = event.data.volume; + if (!node) return; + setMicVolume(volume); + }; -export default useAudioLevel; + mediaStreamSource.connect(node).connect(audioContext.destination); + } catch {} + }; + + startProcessing(); + + return () => { + node?.disconnect(); + node = null; + mediaStreamSource?.disconnect(); + audioContext?.close(); + }; + }, [assetPrefix, stream]); + + return micVolume; +}; \ No newline at end of file diff --git a/custom/shared/hooks/useAudioTrack.js b/custom/shared/hooks/useAudioTrack.js index 58d37ea..df64fde 100644 --- a/custom/shared/hooks/useAudioTrack.js +++ b/custom/shared/hooks/useAudioTrack.js @@ -2,14 +2,13 @@ import { useDeepCompareMemo } from 'use-deep-compare'; import { useTracks } from '../contexts/TracksProvider'; -export const useAudioTrack = (participant) => { +export const useAudioTrack = (id) => { const { audioTracks } = useTracks(); return useDeepCompareMemo(() => { - const audioTrack = audioTracks?.[participant?.id]; - // @ts-ignore + const audioTrack = audioTracks?.[id]; return audioTrack?.persistentTrack; - }, [participant?.id, audioTracks]); + }, [id, audioTracks]); }; -export default useAudioTrack; +export default useAudioTrack; \ No newline at end of file diff --git a/custom/shared/hooks/useCamSubscriptions.js b/custom/shared/hooks/useCamSubscriptions.js index 0cee45a..c5e3384 100644 --- a/custom/shared/hooks/useCamSubscriptions.js +++ b/custom/shared/hooks/useCamSubscriptions.js @@ -15,12 +15,10 @@ export const useCamSubscriptions = ( const { updateCamSubscriptions } = useTracks(); useDeepCompareEffect(() => { - if (!subscribedIds || !stagedIds) return false; + if (!subscribedIds || !stagedIds) return; const timeout = setTimeout(() => { updateCamSubscriptions(subscribedIds, stagedIds); }, throttle); return () => clearTimeout(timeout); }, [subscribedIds, stagedIds, throttle, updateCamSubscriptions]); -}; - -export default useCamSubscriptions; +}; \ No newline at end of file diff --git a/custom/shared/hooks/useJoinSound.js b/custom/shared/hooks/useJoinSound.js index bbf8566..97a45ca 100644 --- a/custom/shared/hooks/useJoinSound.js +++ b/custom/shared/hooks/useJoinSound.js @@ -1,39 +1,42 @@ -import { useEffect, useMemo } from 'react'; +import { useEffect, useState } from 'react'; -import { debounce } from 'debounce'; import { useCallState } from '../contexts/CallProvider'; -import { useSound } from './useSound'; +import { useSoundLoader } from './useSoundLoader'; /** - * Convenience hook to play `join.mp3` when participants join the call + * Convenience hook to play `join.mp3` when first other participants joins. */ export const useJoinSound = () => { - const { callObject } = useCallState(); - const { load, play } = useSound('assets/join.mp3'); + const { callObject: daily } = useCallState(); + const { joinSound } = useSoundLoader(); + const [playJoinSound, setPlayJoinSound] = useState(false); useEffect(() => { - load(); - }, [load]); - - const debouncedPlay = useMemo(() => debounce(() => play(), 200), [play]); - - useEffect(() => { - if (!callObject) return false; - - const handleParticipantJoined = () => { - debouncedPlay(); - }; - - callObject.on('participant-joined', handleParticipantJoined); - + if (!daily) return; + /** + * We don't want to immediately play a joined sound, when the user joins the meeting: + * Upon joining all other participants, that were already in-call, will emit a + * participant-joined event. + * In waiting 2 seconds we make sure, that the sound is only played when the user + * is **really** the first participant. + */ setTimeout(() => { - handleParticipantJoined(); + setPlayJoinSound(true); }, 2000); + }, [daily]); - return () => { - callObject.off('participant-joined', handleParticipantJoined); + useEffect(() => { + if (!daily) return; + const handleParticipantJoined = () => { + // first other participant joined --> play sound + if (!playJoinSound || Object.keys(daily.participants()).length !== 2) + return; + joinSound.play(); }; - }, [callObject, debouncedPlay]); -}; -export default useJoinSound; + daily.on('participant-joined', handleParticipantJoined); + return () => { + daily.off('participant-joined', handleParticipantJoined); + }; + }, [daily, joinSound, playJoinSound]); +}; \ No newline at end of file diff --git a/custom/shared/hooks/useNetworkState.js b/custom/shared/hooks/useNetworkState.js index d0397bd..16a739f 100644 --- a/custom/shared/hooks/useNetworkState.js +++ b/custom/shared/hooks/useNetworkState.js @@ -1,4 +1,3 @@ -/* global rtcpeers */ import { useCallback, useEffect, useState } from 'react'; import { @@ -21,10 +20,10 @@ export const useNetworkState = ( const setQuality = useCallback( (q) => { - if (!callObject || typeof rtcpeers === 'undefined') return; + if (!callObject) return; const peers = Object.keys(callObject.participants()).length - 1; - const isSFU = rtcpeers?.currentlyPreferred?.typeName?.() === 'sfu'; + const isSFU = callObject.getNetworkTopology().topology === 'sfu'; const lowKbs = isSFU ? STANDARD_LOW_BITRATE_CAP diff --git a/custom/shared/hooks/useSound.js b/custom/shared/hooks/useSound.js index d8d2195..fa988bb 100644 --- a/custom/shared/hooks/useSound.js +++ b/custom/shared/hooks/useSound.js @@ -1,6 +1,8 @@ import { useCallback, useEffect, useRef } from 'react'; -export const useSound = (src) => { +const defaultNotMuted = () => false; + +export const useSound = (src, isMuted = defaultNotMuted) => { const audio = useRef(null); useEffect(() => { @@ -22,17 +24,15 @@ export const useSound = (src) => { audio.current.load(); }, [audio]); - const play = useCallback(() => { - if (!audio.current) return; + const play = useCallback(async () => { + if (!audio.current || isMuted()) return; try { audio.current.currentTime = 0; - audio.current.play(); + await audio.current.play(); } catch (e) { console.error(e); } - }, [audio]); + }, [audio, isMuted]); return { load, play }; -}; - -export default useSound; +}; \ No newline at end of file diff --git a/custom/shared/hooks/useSoundLoader.js b/custom/shared/hooks/useSoundLoader.js new file mode 100644 index 0000000..eb3eb07 --- /dev/null +++ b/custom/shared/hooks/useSoundLoader.js @@ -0,0 +1,24 @@ +import { useCallback, useMemo } from 'react'; + +import { useCallState } from '../contexts/CallProvider'; +import { useSound } from './useSound'; + +export const useSoundLoader = () => { + const { enableJoinSound } = useCallState(); + + const isJoinSoundMuted = useCallback( + () => !enableJoinSound, + [enableJoinSound] + ); + + const joinSound = useSound(`assets/join.mp3`, isJoinSoundMuted); + + const load = useCallback(() => { + joinSound.load(); + }, [joinSound]); + + return useMemo( + () => ({ joinSound, load }), + [joinSound, load] + ); +}; \ No newline at end of file diff --git a/custom/shared/hooks/useVideoTrack.js b/custom/shared/hooks/useVideoTrack.js index 0b2129f..5b2c5ff 100644 --- a/custom/shared/hooks/useVideoTrack.js +++ b/custom/shared/hooks/useVideoTrack.js @@ -1,22 +1,28 @@ import { useDeepCompareMemo } from 'use-deep-compare'; -import { useTracks } from '../contexts/TracksProvider'; -import { DEVICE_STATE_BLOCKED, DEVICE_STATE_OFF } from '../contexts/useDevices'; -export const useVideoTrack = (participant) => { +import { useTracks } from '../contexts/TracksProvider'; +import { isLocalId, isScreenId } from '../contexts/participantsState'; + +export const useVideoTrack = (id) => { const { videoTracks } = useTracks(); + const videoTrack = useDeepCompareMemo( + () => videoTracks?.[id], + [id, videoTracks] + ); + + /** + * MediaStreamTrack's are difficult to compare. + * Changes to a video track's id will likely need to be reflected in the UI / DOM. + * This usually happens on P2P / SFU switches. + */ return useDeepCompareMemo(() => { - const videoTrack = videoTracks?.[participant?.id]; if ( - videoTrack?.state === DEVICE_STATE_OFF || - videoTrack?.state === DEVICE_STATE_BLOCKED || - (!videoTrack?.subscribed && - participant?.id !== 'local' && - !participant.isScreenshare) + videoTrack?.state === 'off' || + videoTrack?.state === 'blocked' || + (!videoTrack?.subscribed && !isLocalId(id) && !isScreenId(id)) ) return null; return videoTrack?.persistentTrack; - }, [participant?.id, videoTracks]); -}; - -export default useVideoTrack; + }, [id, videoTrack]); +}; \ No newline at end of file