Update the track provider and participant providers

This commit is contained in:
harshithpabbati 2022-02-02 17:40:38 +05:30
parent 0927582f5c
commit 5a1fdc59b7
8 changed files with 699 additions and 544 deletions

View File

@ -4,7 +4,7 @@ import { ReactComponent as IconMicMute } from '@custom/shared/icons/mic-off-sm.s
import classNames from 'classnames'; import classNames from 'classnames';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { DEFAULT_ASPECT_RATIO } from '../../constants'; import { DEFAULT_ASPECT_RATIO } from '../../constants';
import Video from './Video'; import { Video } from './Video';
import { ReactComponent as Avatar } from './avatar.svg'; import { ReactComponent as Avatar } from './avatar.svg';
const SM_TILE_MAX_WIDTH = 300; const SM_TILE_MAX_WIDTH = 300;
@ -99,8 +99,9 @@ export const Tile = memo(
{videoTrack ? ( {videoTrack ? (
<Video <Video
ref={videoRef} ref={videoRef}
fit={videoFit}
isScreen={participant.isScreenshare}
participantId={participant?.id} participantId={participant?.id}
videoTrack={videoTrack}
/> />
) : ( ) : (
showAvatar && ( showAvatar && (

View File

@ -1,14 +1,30 @@
import React, { useMemo, forwardRef, memo, useEffect } from 'react'; import {
forwardRef,
useEffect,
useMemo,
useState,
} from 'react';
import Bowser from 'bowser'; import Bowser from 'bowser';
import PropTypes from 'prop-types'; import classNames from 'classnames';
import { shallowEqualObjects } from 'shallow-equal';
import { useCallState } from '../../contexts/CallProvider';
import { useUIState } from '../../contexts/UIStateProvider';
import { useVideoTrack } from '../../hooks/useVideoTrack';
export const Video = forwardRef(
(
{ fit = 'contain', isScreen = false, participantId, ...props },
videoEl
) => {
const { callObject: daily } = useCallState();
const { isMobile } = useUIState();
const isLocalCam = useMemo(() => {
const localParticipant = daily.participants()?.local;
return participantId === localParticipant.session_id && !isScreen;
}, [daily, isScreen, participantId]);
const [isMirrored, setIsMirrored] = useState(isLocalCam);
const videoTrack = useVideoTrack(participantId);
export const Video = memo(
forwardRef(({ participantId, videoTrack, ...rest }, videoEl) => {
/**
* Memo: Chrome >= 92?
* See: https://bugs.chromium.org/p/chromium/issues/detail?id=1232649
*/
const isChrome92 = useMemo(() => { const isChrome92 = useMemo(() => {
const { browser, platform, os } = Bowser.parse(navigator.userAgent); const { browser, platform, os } = Bowser.parse(navigator.userAgent);
return ( return (
@ -19,43 +35,114 @@ export const Video = memo(
}, []); }, []);
/** /**
* Effect: Umount * Determine if video needs to be mirrored.
* Note: nullify src to ensure media object is not counted */
useEffect(() => {
if (!videoTrack) return;
const videoTrackSettings = videoTrack.getSettings();
const isUsersFrontCamera =
'facingMode' in videoTrackSettings
? isLocalCam && videoTrackSettings.facingMode === 'user'
: isLocalCam;
// only apply mirror effect to user facing camera
if (isMirrored !== isUsersFrontCamera) {
setIsMirrored(isUsersFrontCamera);
}
}, [isMirrored, isLocalCam, videoTrack]);
/**
* Handle canplay & picture-in-picture events.
*/ */
useEffect(() => { useEffect(() => {
const video = videoEl.current; const video = videoEl.current;
if (!video) return false; if (!video) return;
// clean up when video renders for different participant const handleCanPlay = () => {
video.srcObject = null; if (!video.paused) return;
if (isChrome92) video.load(); video.play();
return () => {
// clean up when unmounted
video.srcObject = null;
if (isChrome92) video.load();
}; };
}, [videoEl, isChrome92, participantId]); const handleEnterPIP = () => {
video.style.transform = 'scale(1)';
};
const handleLeavePIP = () => {
video.style.transform = '';
setTimeout(() => {
if (video.paused) video.play();
}, 100);
};
video.addEventListener('canplay', handleCanPlay);
video.addEventListener('enterpictureinpicture', handleEnterPIP);
video.addEventListener('leavepictureinpicture', handleLeavePIP);
return () => {
video.removeEventListener('canplay', handleCanPlay);
video.removeEventListener('enterpictureinpicture', handleEnterPIP);
video.removeEventListener('leavepictureinpicture', handleLeavePIP);
};
}, [isChrome92, videoEl]);
/** /**
* Effect: mount source (and force load on Chrome) * Update srcObject.
*/ */
useEffect(() => { useEffect(() => {
const video = videoEl.current; const video = videoEl.current;
if (!video || !videoTrack) return; if (!video || !videoTrack) return;
video.srcObject = new MediaStream([videoTrack]); video.srcObject = new MediaStream([videoTrack]);
if (isChrome92) video.load(); if (isChrome92) video.load();
}, [videoEl, isChrome92, videoTrack]); return () => {
// clean up when unmounted
video.srcObject = null;
if (isChrome92) video.load();
};
}, [isChrome92, participantId, videoEl, videoTrack, videoTrack?.id]);
return <video autoPlay muted playsInline ref={videoEl} {...rest} />; return (
}), <>
(p, n) => shallowEqualObjects(p, n) <video
className={classNames(fit, {
isMirrored,
isMobile,
playable: videoTrack?.enabled,
})}
autoPlay
muted
playsInline
ref={videoEl}
{...props}
/>
<style jsx>{`
video {
opacity: 0;
}
video.playable {
opacity: 1;
}
video.isMirrored {
transform: scale(-1, 1);
}
video.isMobile {
border-radius: 4px;
display: block;
height: 100%;
position: relative;
width: 100%;
}
video:not(.isMobile) {
height: calc(100% + 4px);
left: -2px;
object-position: center;
position: absolute;
top: -2px;
width: calc(100% + 4px);
}
video.contain {
object-fit: contain;
}
video.cover {
object-fit: cover;
}
`}</style>
</>
);
}
); );
Video.displayName = 'Video'; Video.displayName = 'Video';
Video.propTypes = {
videoTrack: PropTypes.any,
mirrored: PropTypes.bool,
participantId: PropTypes.string,
};
export default Video;

View File

@ -1,44 +1,36 @@
import React, { import {
createContext, createContext,
useCallback, useCallback,
useContext, useContext,
useEffect, useEffect,
useMemo,
useReducer, useReducer,
useState, useState,
useMemo,
} from 'react'; } from 'react';
import { import { sortByKey } from '@custom/shared/lib/sortByKey';
useUIState, import { useNetworkState } from '../hooks/useNetworkState';
} from '@custom/shared/contexts/UIStateProvider';
import PropTypes from 'prop-types';
import { sortByKey } from '../lib/sortByKey';
import { useCallState } from './CallProvider'; import { useCallState } from './CallProvider';
import { useUIState } from './UIStateProvider';
import { import {
initialParticipantsState, initialParticipantsState,
isLocalId, isLocalId,
ACTIVE_SPEAKER,
PARTICIPANT_JOINED,
PARTICIPANT_LEFT,
PARTICIPANT_UPDATED,
participantsReducer, participantsReducer,
SWAP_POSITION,
} from './participantsState'; } from './participantsState';
export const ParticipantsContext = createContext(); export const ParticipantsContext = createContext(null);
export const ParticipantsProvider = ({ children }) => { export const ParticipantsProvider = ({ children }) => {
const { callObject, videoQuality, networkState, broadcast } = useCallState(); const { isMobile, pinnedId, viewMode } = useUIState();
const [state, dispatch] = useReducer( const {
participantsReducer, broadcast,
initialParticipantsState broadcastRole,
); callObject: daily,
const { viewMode } = useUIState(); videoQuality,
const [ } = useCallState();
participantMarkedForRemoval, const [state, dispatch] = useReducer(participantsReducer, initialParticipantsState);
setParticipantMarkedForRemoval, const [participantMarkedForRemoval, setParticipantMarkedForRemoval] = useState(null);
] = useState(null);
const { threshold } = useNetworkState();
/** /**
* ALL participants (incl. shared screens) in a convenient array * ALL participants (incl. shared screens) in a convenient array
@ -58,14 +50,6 @@ export const ParticipantsProvider = ({ children }) => {
return state.participants; return state.participants;
}, [broadcast, state.participants]); }, [broadcast, state.participants]);
/**
* Array of participant IDs
*/
const participantIds = useMemo(
() => participants.map((p) => p.id).join(','),
[participants]
);
/** /**
* The number of participants, who are not a shared screen * The number of participants, who are not a shared screen
* (technically a shared screen counts as a participant, but we shouldn't tell humans) * (technically a shared screen counts as a participant, but we shouldn't tell humans)
@ -103,28 +87,26 @@ export const ParticipantsProvider = ({ children }) => {
*/ */
const currentSpeaker = useMemo(() => { const currentSpeaker = useMemo(() => {
/** /**
* If the activeParticipant is still in the call, return the activeParticipant. * Ensure activeParticipant is still present in the call.
* The activeParticipant only updates to a new active participant so * The activeParticipant only updates to a new active participant so
* if everyone else is muted when AP leaves, the value will be stale. * if everyone else is muted when AP leaves, the value will be stale.
*/ */
const isPresent = participants.some((p) => p?.id === activeParticipant?.id); const isPresent = participants.some((p) => p?.id === activeParticipant?.id);
if (isPresent) { const pinned = participants.find((p) => p?.id === pinnedId);
return activeParticipant;
}
/** if (pinned) return pinned;
* If the activeParticipant has left, calculate the remaining displayable participants
*/ const displayableParticipants = participants.filter((p) =>
const displayableParticipants = participants.filter((p) => !p?.isLocal); isMobile ? !p?.isLocal && !p?.isScreenshare : !p?.isLocal
);
/**
* If nobody ever unmuted, return the first participant with a camera on
* Or, if all cams are off, return the first remote participant
*/
if ( if (
!isPresent &&
displayableParticipants.length > 0 && displayableParticipants.length > 0 &&
displayableParticipants.every((p) => p.isMicMuted && !p.lastActiveDate) displayableParticipants.every((p) => p.isMicMuted && !p.lastActiveDate)
) { ) {
// Return first cam on participant in case everybody is muted and nobody ever talked
// or first remote participant, in case everybody's cam is muted, too.
return ( return (
displayableParticipants.find((p) => !p.isCamMuted) ?? displayableParticipants.find((p) => !p.isCamMuted) ??
displayableParticipants?.[0] displayableParticipants?.[0]
@ -132,13 +114,20 @@ export const ParticipantsProvider = ({ children }) => {
} }
const sorted = displayableParticipants const sorted = displayableParticipants
.sort((a, b) => sortByKey(a, b, 'lastActiveDate')) .sort(sortByKey('lastActiveDate'))
.reverse(); .reverse();
const lastActiveSpeaker = sorted?.[0]; const fallback = broadcastRole === 'attendee' ? null : localParticipant;
return lastActiveSpeaker || localParticipant; return isPresent ? activeParticipant : sorted?.[0] ?? fallback;
}, [activeParticipant, localParticipant, participants]); }, [
activeParticipant,
broadcastRole,
isMobile,
localParticipant,
participants,
pinnedId,
]);
/** /**
* Screen shares * Screen shares
@ -148,33 +137,17 @@ export const ParticipantsProvider = ({ children }) => {
/** /**
* The local participant's name * The local participant's name
*/ */
const username = callObject?.participants()?.local?.user_name ?? ''; const username = daily?.participants()?.local?.user_name ?? '';
/** /**
* Sets the local participant's name in daily-js * Sets the local participant's name in daily-js
* @param name The new username * @param name The new username
*/ */
const setUsername = (name) => { const setUsername = useCallback(
callObject.setUserName(name); (name) => {
}; daily.setUserName(name);
const [muteNewParticipants, setMuteNewParticipants] = useState(false);
const muteAll = useCallback(
(muteFutureParticipants = false) => {
if (!localParticipant.isOwner) return;
setMuteNewParticipants(muteFutureParticipants);
const unmutedParticipants = participants.filter(
(p) => !p.isLocal && !p.isMicMuted
);
if (!unmutedParticipants.length) return;
const result = unmutedParticipants.reduce(
(o, p) => ({ ...o[p.id], setAudio: false }),
{}
);
callObject.updateParticipants(result);
}, },
[callObject, localParticipant, participants] [daily]
); );
const swapParticipantPosition = useCallback((id1, id2) => { const swapParticipantPosition = useCallback((id1, id2) => {
@ -192,68 +165,89 @@ export const ParticipantsProvider = ({ children }) => {
}); });
}, []); }, []);
const [muteNewParticipants, setMuteNewParticipants] = useState(false);
const muteAll = useCallback(
(muteFutureParticipants = false) => {
if (!localParticipant.isOwner) return;
setMuteNewParticipants(muteFutureParticipants);
const unmutedParticipants = participants.filter(
(p) => !p.isLocal && !p.isMicMuted
);
if (!unmutedParticipants.length) return;
daily.updateParticipants(
unmutedParticipants.reduce((o, p) => {
o[p.id] = {
setAudio: false,
};
return o;
}, {})
);
},
[daily, localParticipant, participants]
);
const handleParticipantJoined = useCallback(() => {
dispatch({
type: 'JOINED_MEETING',
participant: daily.participants().local,
});
}, [daily]);
const handleNewParticipantsState = useCallback( const handleNewParticipantsState = useCallback(
(event = null) => { (event = null) => {
switch (event?.action) { switch (event?.action) {
case 'participant-joined': case 'participant-joined':
dispatch({ dispatch({
type: PARTICIPANT_JOINED, type: 'PARTICIPANT_JOINED',
participant: event.participant, participant: event.participant,
}); });
if (muteNewParticipants && daily) {
daily.updateParticipant(event.participant.session_id, {
setAudio: false,
});
}
break; break;
case 'participant-updated': case 'participant-updated':
dispatch({ dispatch({
type: PARTICIPANT_UPDATED, type: 'PARTICIPANT_UPDATED',
participant: event.participant, participant: event.participant,
}); });
break; break;
case 'participant-left': case 'participant-left':
dispatch({ dispatch({
type: PARTICIPANT_LEFT, type: 'PARTICIPANT_LEFT',
participant: event.participant, participant: event.participant,
}); });
break; break;
default:
break;
} }
}, },
[dispatch] [daily, dispatch, muteNewParticipants]
); );
/**
* Start listening for participant changes, when the callObject is set.
*/
useEffect(() => { useEffect(() => {
if (!callObject) return false; if (!daily) return;
console.log('👥 Participant provider events bound'); daily.on('participant-joined', handleParticipantJoined);
daily.on('participant-joined', handleNewParticipantsState);
daily.on('participant-updated', handleNewParticipantsState);
daily.on('participant-left', handleNewParticipantsState);
const events = [ return () => {
'joined-meeting', daily.off('participant-joined', handleParticipantJoined);
'participant-joined', daily.off('participant-joined', handleNewParticipantsState);
'participant-updated', daily.off('participant-updated', handleNewParticipantsState);
'participant-left', daily.off('participant-left', handleNewParticipantsState);
]; };
}, [daily, handleNewParticipantsState, handleParticipantJoined]);
// Use initial state const participantIds = useMemo(
handleNewParticipantsState(); () => participants.map((p) => p.id).join(','),
[participants]
// Listen for changes in state );
events.forEach((event) => callObject.on(event, handleNewParticipantsState));
// Stop listening for changes in state
return () =>
events.forEach((event) =>
callObject.off(event, handleNewParticipantsState)
);
}, [callObject, handleNewParticipantsState]);
/**
* Change between the simulcast layers based on view / available bandwidth
*/
const setBandWidthControls = useCallback(() => { const setBandWidthControls = useCallback(() => {
if (!(callObject && callObject.meetingState() === 'joined-meeting')) return; if (!(daily && daily.meetingState() === 'joined-meeting')) return;
const ids = participantIds.split(',').filter(Boolean); const ids = participantIds.split(',').filter(Boolean);
const receiveSettings = {}; const receiveSettings = {};
@ -263,7 +257,7 @@ export const ParticipantsProvider = ({ children }) => {
if ( if (
// weak or bad network // weak or bad network
(['low', 'very-low'].includes(networkState) && videoQuality === 'auto') || (['low', 'very-low'].includes(threshold) && videoQuality === 'auto') ||
// Low quality or Bandwidth saver mode enabled // Low quality or Bandwidth saver mode enabled
['bandwidth-saver', 'low'].includes(videoQuality) ['bandwidth-saver', 'low'].includes(videoQuality)
) { ) {
@ -284,38 +278,44 @@ export const ParticipantsProvider = ({ children }) => {
// Mobile view settings are handled separately in MobileCall // Mobile view settings are handled separately in MobileCall
}); });
callObject.updateReceiveSettings(receiveSettings); daily.updateReceiveSettings(receiveSettings);
}, [callObject, participantIds, networkState, videoQuality, viewMode, currentSpeaker?.id]); }, [
currentSpeaker?.id,
daily,
participantIds,
threshold,
videoQuality,
viewMode,
]);
useEffect(() => { useEffect(() => {
setBandWidthControls(); setBandWidthControls();
}, [setBandWidthControls]); }, [setBandWidthControls]);
useEffect(() => { useEffect(() => {
if (!callObject) return false; if (!daily) return;
const handleActiveSpeakerChange = ({ activeSpeaker }) => { const handleActiveSpeakerChange = ({ activeSpeaker }) => {
/** /**
* Ignore active-speaker-change events for the local user. * Ignore active-speaker-change events for the local user.
* Our UX doesn't ever highlight the local user as the active speaker. * Our UX doesn't ever highlight the local user as the active speaker.
*/ */
const localId = callObject.participants().local.session_id; const localId = daily.participants().local.session_id;
const activeSpeakerId = activeSpeaker?.peerId; const activeSpeakerId = activeSpeaker?.peerId;
if (localId === activeSpeakerId) return; if (localId === activeSpeakerId) return;
dispatch({ dispatch({
type: ACTIVE_SPEAKER, type: 'ACTIVE_SPEAKER',
id: activeSpeakerId, id: activeSpeakerId,
}); });
}; };
callObject.on('active-speaker-change', handleActiveSpeakerChange); daily.on('active-speaker-change', handleActiveSpeakerChange);
return () => return () =>
callObject.off('active-speaker-change', handleActiveSpeakerChange); daily.off('active-speaker-change', handleActiveSpeakerChange);
}, [callObject]); }, [daily]);
return ( return (
<ParticipantsContext.Provider <ParticipantsContext.Provider
value={{ value={{
activeParticipant,
allParticipants, allParticipants,
currentSpeaker, currentSpeaker,
localParticipant, localParticipant,
@ -337,8 +337,4 @@ export const ParticipantsProvider = ({ children }) => {
); );
}; };
ParticipantsProvider.propTypes = {
children: PropTypes.node,
};
export const useParticipants = () => useContext(ParticipantsContext); export const useParticipants = () => useContext(ParticipantsContext);

View File

@ -1,38 +1,29 @@
/* global rtcpeers */ import {
import React, {
createContext, createContext,
useCallback, useCallback,
useContext, useContext,
useEffect, useEffect,
useMemo, useMemo,
useReducer, useReducer,
useRef,
useState,
} from 'react'; } from 'react';
import { sortByKey } from '@custom/shared/lib/sortByKey';
import deepEqual from 'fast-deep-equal'; import deepEqual from 'fast-deep-equal';
import PropTypes from 'prop-types'; import { useDeepCompareCallback } from 'use-deep-compare';
import { useDeepCompareEffect } from 'use-deep-compare';
import { sortByKey } from '../lib/sortByKey';
import { useCallState } from './CallProvider'; import { useCallState } from './CallProvider';
import { useParticipants } from './ParticipantsProvider'; import { useParticipants } from './ParticipantsProvider';
import { useUIState } from './UIStateProvider'; import { useUIState } from './UIStateProvider';
import { isLocalId, isScreenId } from './participantsState'; import { getScreenId, isLocalId, isScreenId } from './participantsState';
import { import { initialTracksState, tracksReducer } from './tracksState';
initialTracksState,
REMOVE_TRACKS,
TRACK_STARTED,
TRACK_STOPPED,
TRACK_VIDEO_UPDATED,
TRACK_AUDIO_UPDATED,
tracksReducer,
} from './tracksState';
/** /**
* Maximum amount of concurrently subscribed most recent speakers. * Maximum amount of concurrently subscribed or staged most recent speakers.
*/ */
const MAX_RECENT_SPEAKER_COUNT = 6; export const MAX_RECENT_SPEAKER_COUNT = 8;
/** /**
* Threshold up to which all videos will be subscribed. * Threshold up to which all cams will be subscribed to or staged.
* If the remote participant count passes this threshold, * If the remote participant count passes this threshold,
* cam subscriptions are defined by UI view modes. * cam subscriptions are defined by UI view modes.
*/ */
@ -41,16 +32,17 @@ const SUBSCRIBE_OR_STAGE_ALL_VIDEO_THRESHOLD = 9;
const TracksContext = createContext(null); const TracksContext = createContext(null);
export const TracksProvider = ({ children }) => { export const TracksProvider = ({ children }) => {
const { callObject, subscribeToTracksAutomatically } = useCallState(); const { callObject: daily, optimizeLargeCalls } = useCallState();
const { participants } = useParticipants(); const { participants } = useParticipants();
const { viewMode } = useUIState(); const { viewMode } = useUIState();
const [state, dispatch] = useReducer(tracksReducer, initialTracksState); const [state, dispatch] = useReducer(tracksReducer, initialTracksState);
const [maxCamSubscriptions, setMaxCamSubscriptions] = useState(null);
const recentSpeakerIds = useMemo( const recentSpeakerIds = useMemo(
() => () =>
participants participants
.filter((p) => Boolean(p.lastActiveDate) && !p.isLocal) .filter((p) => Boolean(p.lastActiveDate))
.sort((a, b) => sortByKey(a, b, 'lastActiveDate')) .sort(sortByKey('lastActiveDate'))
.slice(-MAX_RECENT_SPEAKER_COUNT) .slice(-MAX_RECENT_SPEAKER_COUNT)
.map((p) => p.id) .map((p) => p.id)
.reverse(), .reverse(),
@ -64,22 +56,27 @@ export const TracksProvider = ({ children }) => {
const subscribeToCam = useCallback( const subscribeToCam = useCallback(
(id) => { (id) => {
// Ignore undefined, local or screenshare. /**
* Ignore undefined, local or screenshare.
*/
if (!id || isLocalId(id) || isScreenId(id)) return; if (!id || isLocalId(id) || isScreenId(id)) return;
callObject.updateParticipant(id, { daily.updateParticipant(id, {
setSubscribedTracks: { video: true }, setSubscribedTracks: { video: true },
}); });
}, },
[callObject] [daily]
); );
/** /**
* Updates cam subscriptions based on passed subscribedIds and stagedIds. * Updates cam subscriptions based on passed subscribedIds and stagedIds.
* For ids not provided, cam tracks will be unsubscribed from * For ids not provided, cam tracks will be unsubscribed from.
*
* @param subscribedIds Participant ids whose cam tracks should be subscribed to.
* @param stagedIds Participant ids whose cam tracks should be staged.
*/ */
const updateCamSubscriptions = useCallback( const updateCamSubscriptions = useCallback(
(subscribedIds, stagedIds = []) => { (subscribedIds, stagedIds = []) => {
if (!callObject) return; if (!daily) return;
// If total number of remote participants is less than a threshold, simply // If total number of remote participants is less than a threshold, simply
// stage all remote cams that aren't already marked for subscription. // stage all remote cams that aren't already marked for subscription.
@ -103,7 +100,7 @@ export const TracksProvider = ({ children }) => {
const updates = remoteParticipantIds.reduce((u, id) => { const updates = remoteParticipantIds.reduce((u, id) => {
let desiredSubscription; let desiredSubscription;
const currentSubscription = const currentSubscription =
callObject.participants()?.[id]?.tracks?.video?.subscribed; daily.participants()?.[id]?.tracks?.video?.subscribed;
// Ignore undefined, local or screenshare participant ids // Ignore undefined, local or screenshare participant ids
if (!id || isLocalId(id) || isScreenId(id)) return u; if (!id || isLocalId(id) || isScreenId(id)) return u;
@ -131,110 +128,126 @@ export const TracksProvider = ({ children }) => {
}, {}); }, {});
if (Object.keys(updates).length === 0) return; if (Object.keys(updates).length === 0) return;
callObject.updateParticipants(updates); daily.updateParticipants(updates);
}, },
[callObject, remoteParticipantIds, viewMode, recentSpeakerIds] [daily, remoteParticipantIds, recentSpeakerIds, viewMode]
); );
/**
* Automatically update audio subscriptions.
*/
useEffect(() => { useEffect(() => {
if (!callObject) return false; if (!daily) return;
/**
const trackStoppedQueue = []; * A little throttling as we want daily-js to have some room to breathe
*/
const handleTrackStarted = ({ participant, track }) => { const timeout = setTimeout(() => {
/** const participants = daily.participants();
* If track for participant was recently stopped, remove it from queue, const updates = remoteParticipantIds.reduce((u, id) => {
* so we don't run into a stale state // Ignore undefined, local or screenshare participant ids
*/ if (!id || isLocalId(id) || isScreenId(id)) return u;
const stoppingIdx = trackStoppedQueue.findIndex( const isSpeaker = recentSpeakerIds.includes(id);
([p, t]) => const hasSubscribed = participants[id]?.tracks?.audio?.subscribed;
p.session_id === participant.session_id && t.kind === track.kind const shouldSubscribe = optimizeLargeCalls ? isSpeaker : true;
); /**
if (stoppingIdx >= 0) { * In optimized calls:
trackStoppedQueue.splice(stoppingIdx, 1); * - subscribe to speakers we're not subscribed to, yet
} * - unsubscribe from non-speakers we're subscribed to
dispatch({ * In non-optimized calls:
type: TRACK_STARTED, * - subscribe to all who we're not to subscribed to, yet
participant, */
track, if (
}); (!hasSubscribed && shouldSubscribe) ||
}; (hasSubscribed && !shouldSubscribe)
) {
const trackStoppedBatchInterval = setInterval(() => { u[id] = {
if (!trackStoppedQueue.length) return;
dispatch({
type: TRACK_STOPPED,
items: trackStoppedQueue.splice(0, trackStoppedQueue.length),
});
}, 3000);
const handleTrackStopped = ({ participant, track }) => {
if (participant) {
trackStoppedQueue.push([participant, track]);
}
};
const handleParticipantLeft = ({ participant }) => {
dispatch({
type: REMOVE_TRACKS,
participant,
});
};
const joinedSubscriptionQueue = [];
const handleParticipantJoined = ({ participant }) => {
joinedSubscriptionQueue.push(participant.session_id);
};
const joinBatchInterval = setInterval(async () => {
if (!joinedSubscriptionQueue.length) return;
const ids = joinedSubscriptionQueue.splice(0);
const participants = callObject.participants();
const topology = (await callObject.getNetworkTopology())?.topology;
const updates = ids.reduce((o, id) => {
if (!participants?.[id]?.tracks?.audio?.subscribed) {
o[id] = {
setSubscribedTracks: { setSubscribedTracks: {
audio: true, audio: shouldSubscribe,
screenAudio: true,
screenVideo: true,
}, },
}; };
} }
if (topology === 'peer') { return u;
o[id] = { setSubscribedTracks: true };
}
return o;
}, {}); }, {});
if (Object.keys(updates).length === 0) return;
if (!subscribeToTracksAutomatically && Object.keys(updates).length0) { daily.updateParticipants(updates);
callObject.updateParticipants(updates);
}
}, 100); }, 100);
callObject.on('track-started', handleTrackStarted);
callObject.on('track-stopped', handleTrackStopped);
callObject.on('participant-joined', handleParticipantJoined);
callObject.on('participant-left', handleParticipantLeft);
return () => { return () => {
clearInterval(joinBatchInterval); clearTimeout(timeout);
clearInterval(trackStoppedBatchInterval);
callObject.off('track-started', handleTrackStarted);
callObject.off('track-stopped', handleTrackStopped);
callObject.off('participant-joined', handleParticipantJoined);
callObject.off('participant-left', handleParticipantLeft);
}; };
}, [callObject, subscribeToTracksAutomatically]); }, [daily, optimizeLargeCalls, recentSpeakerIds, remoteParticipantIds]);
useDeepCompareEffect(() => { /**
if (!callObject) return; * Notify user when pushed out of recent speakers queue.
*/
const showMutedMessage = useRef(false);
useEffect(() => {
if (!daily || !optimizeLargeCalls) return;
const handleParticipantUpdated = ({ participant }) => { if (recentSpeakerIds.some((id) => isLocalId(id))) {
showMutedMessage.current = true;
return;
}
if (showMutedMessage.current && daily.participants().local.audio) {
daily.setLocalAudio(false);
showMutedMessage.current = false;
}
}, [daily, optimizeLargeCalls, recentSpeakerIds]);
const trackStoppedQueue = useRef([]);
useEffect(() => {
const trackStoppedBatchInterval = setInterval(() => {
if (!trackStoppedQueue.current.length) return;
dispatch({
type: 'TRACKS_STOPPED',
items: trackStoppedQueue.current.splice(
0,
trackStoppedQueue.current.length
),
});
}, 3000);
return () => {
clearInterval(trackStoppedBatchInterval);
};
}, []);
const handleTrackStarted = useCallback(({ participant, track }) => {
/**
* If track for participant was recently stopped, remove it from queue,
* so we don't run into a stale state.
*/
const stoppingIdx = trackStoppedQueue.current.findIndex(
([p, t]) =>
p.session_id === participant.session_id && t.kind === track.kind
);
if (stoppingIdx >= 0) {
trackStoppedQueue.current.splice(stoppingIdx, 1);
}
dispatch({
type: 'TRACK_STARTED',
participant,
track,
});
}, []);
const handleTrackStopped = useCallback(({ participant, track }) => {
if (participant) {
trackStoppedQueue.current.push([participant, track]);
}
}, []);
const handleParticipantJoined = useCallback(({ participant }) => {
joinedSubscriptionQueue.current.push(participant.session_id);
}, []);
const handleParticipantUpdated = useDeepCompareCallback(
({ participant }) => {
const hasAudioChanged = const hasAudioChanged =
// State changed // State changed
participant.tracks.audio?.state !== participant.tracks.audio.state !==
state.audioTracks?.[participant.user_id]?.state || state.audioTracks?.[participant.user_id]?.state ||
// Screen state changed
participant.tracks.screenAudio.state !==
state.audioTracks?.[getScreenId(participant.user_id)]?.state ||
// Off/blocked reason changed // Off/blocked reason changed
!deepEqual( !deepEqual(
{ {
@ -242,14 +255,14 @@ export const TracksProvider = ({ children }) => {
...(participant.tracks.audio?.off ?? {}), ...(participant.tracks.audio?.off ?? {}),
}, },
{ {
...(state.audioTracks?.[participant.user_id]?.blocked ?? {}), ...(state.audioTracks?.[participant.user_id].blocked ?? {}),
...(state.audioTracks?.[participant.user_id]?.off ?? {}), ...(state.audioTracks?.[participant.user_id].off ?? {}),
} }
); );
const hasVideoChanged = const hasVideoChanged =
// State changed // State changed
participant.tracks.video?.state !== participant.tracks.video?.state !==
state.videoTracks?.[participant.user_id]?.state || state.videoTracks?.[participant.user_id]?.state ||
// Off/blocked reason changed // Off/blocked reason changed
!deepEqual( !deepEqual(
{ {
@ -265,7 +278,7 @@ export const TracksProvider = ({ children }) => {
if (hasAudioChanged) { if (hasAudioChanged) {
// Update audio track state // Update audio track state
dispatch({ dispatch({
type: TRACK_AUDIO_UPDATED, type: 'UPDATE_AUDIO_TRACK',
participant, participant,
}); });
} }
@ -273,27 +286,92 @@ export const TracksProvider = ({ children }) => {
if (hasVideoChanged) { if (hasVideoChanged) {
// Update video track state // Update video track state
dispatch({ dispatch({
type: TRACK_VIDEO_UPDATED, type: 'UPDATE_VIDEO_TRACK',
participant, participant,
}); });
} }
}; },
[state.audioTracks, state.videoTracks]
);
callObject.on('participant-updated', handleParticipantUpdated); const handleParticipantLeft = useCallback(({ participant }) => {
dispatch({
type: 'REMOVE_TRACKS',
participant,
});
}, []);
useEffect(() => {
if (!daily) return;
daily.on('track-started', handleTrackStarted);
daily.on('track-stopped', handleTrackStopped);
daily.on('participant-joined', handleParticipantJoined);
daily.on('participant-updated', handleParticipantUpdated);
daily.on('participant-left', handleParticipantLeft);
return () => { return () => {
callObject.off('participant-updated', handleParticipantUpdated); daily.off('track-started', handleTrackStarted);
daily.off('track-stopped', handleTrackStopped);
daily.off('participant-joined', handleParticipantJoined);
daily.off('participant-updated', handleParticipantUpdated);
daily.off('participant-left', handleParticipantLeft);
}; };
}, [callObject, state.audioTracks, state.videoTracks]); }, [
daily,
handleParticipantJoined,
handleParticipantLeft,
handleParticipantUpdated,
handleTrackStarted,
handleTrackStopped
]);
const joinedSubscriptionQueue = useRef([]);
useEffect(() => {
if (!daily) return;
const joinBatchInterval = setInterval(async () => {
if (!joinedSubscriptionQueue.current.length) return;
const ids = joinedSubscriptionQueue.current.splice(0);
const participants = daily.participants();
const topology = (await daily.getNetworkTopology())?.topology;
const updates = ids.reduce(
(o, id) => {
if (!participants?.[id]?.tracks?.audio?.subscribed) {
o[id] = {
setSubscribedTracks: {
screenAudio: true,
screenVideo: true,
},
};
}
if (topology === 'peer') {
o[id] = { setSubscribedTracks: true };
}
return o;
},
{}
);
if (Object.keys(updates).length === 0) return;
daily.updateParticipants(updates);
}, 100);
return () => {
clearInterval(joinBatchInterval);
};
}, [daily]);
useEffect(() => {
if (optimizeLargeCalls) {
setMaxCamSubscriptions(30);
}
}, [optimizeLargeCalls]);
return ( return (
<TracksContext.Provider <TracksContext.Provider
value={{ value={{
audioTracks: state.audioTracks, audioTracks: state.audioTracks,
videoTracks: state.videoTracks, videoTracks: state.videoTracks,
maxCamSubscriptions,
subscribeToCam, subscribeToCam,
updateCamSubscriptions, updateCamSubscriptions,
remoteParticipantIds,
recentSpeakerIds,
}} }}
> >
{children} {children}
@ -301,8 +379,4 @@ export const TracksProvider = ({ children }) => {
); );
}; };
TracksProvider.propTypes = {
children: PropTypes.node,
};
export const useTracks = () => useContext(TracksContext); export const useTracks = () => useContext(TracksContext);

View File

@ -1,19 +1,6 @@
/**
* Call state is comprised of:
* - "Call items" (inputs to the call, i.e. participants or shared screens)
* - UI state that depends on call items (for now, just whether to show "click allow" message)
*
* Call items are keyed by id:
* - "local" for the current participant
* - A session id for each remote participant
* - "<id>-screen" for each shared screen
*/
import fasteq from 'fast-deep-equal'; import fasteq from 'fast-deep-equal';
import {
DEVICE_STATE_OFF, import { MAX_RECENT_SPEAKER_COUNT } from './TracksProvider';
DEVICE_STATE_BLOCKED,
DEVICE_STATE_LOADING,
} from './useDevices';
const initialParticipantsState = { const initialParticipantsState = {
lastPendingUnknownActiveSpeaker: null, lastPendingUnknownActiveSpeaker: null,
@ -39,26 +26,168 @@ const initialParticipantsState = {
screens: [], screens: [],
}; };
// --- Derived data --- // --- Reducer and helpers --
function getId(participant) { function participantsReducer(
return participant.local ? 'local' : participant.session_id; prevState,
action
) {
switch (action.type) {
case 'ACTIVE_SPEAKER': {
const { participants, ...state } = prevState;
if (!action.id)
return {
...prevState,
lastPendingUnknownActiveSpeaker: null,
};
const date = new Date();
const isParticipantKnown = participants.some((p) => p.id === action.id);
return {
...state,
lastPendingUnknownActiveSpeaker: isParticipantKnown
? null
: {
date,
id: action.id,
},
participants: participants.map((p) => ({
...p,
isActiveSpeaker: p.id === action.id,
lastActiveDate: p.id === action.id ? date : p?.lastActiveDate,
})),
};
}
case 'JOINED_MEETING': {
const localItem = getNewParticipant(action.participant);
const participants = [...prevState.participants].map((p) =>
p.isLocal ? localItem : p
);
return {
...prevState,
participants,
};
}
case 'PARTICIPANT_JOINED': {
const item = getNewParticipant(action.participant);
const participants = [...prevState.participants];
const screens = [...prevState.screens];
const isPendingActiveSpeaker =
item.id === prevState.lastPendingUnknownActiveSpeaker?.id;
if (isPendingActiveSpeaker) {
item.isActiveSpeaker = true;
item.lastActiveDate = prevState.lastPendingUnknownActiveSpeaker?.date;
}
if (item.isCamMuted) {
participants.push(item);
} else {
const firstInactiveCamOffIndex = prevState.participants.findIndex(
(p) => p.isCamMuted && !p.isLocal && !p.isActiveSpeaker
);
if (firstInactiveCamOffIndex >= 0) {
participants.splice(firstInactiveCamOffIndex, 0, item);
} else {
participants.push(item);
}
}
// Mark new participant as active speaker, for quicker audio subscription
if (
!item.isMicMuted &&
participants.length <= MAX_RECENT_SPEAKER_COUNT + 1 // + 1 for local participant
) {
item.lastActiveDate = new Date();
}
// Participant is sharing screen
if (action.participant.screen) {
screens.push(getScreenItem(action.participant));
}
return {
...prevState,
lastPendingUnknownActiveSpeaker: isPendingActiveSpeaker
? null
: prevState.lastPendingUnknownActiveSpeaker,
participants,
screens,
};
}
case 'PARTICIPANT_UPDATED': {
const item = getUpdatedParticipant(
action.participant,
prevState.participants
);
const { id } = item;
const screenId = getScreenId(id);
const participants = [...prevState.participants];
const idx = participants.findIndex((p) => p.id === id);
if (!item.isMicMuted && participants[idx].isMicMuted) {
// Participant unmuted mic
item.lastActiveDate = new Date();
}
participants[idx] = item;
const screens = [...prevState.screens];
const screenIdx = screens.findIndex((s) => s.id === screenId);
if (action.participant.screen) {
const screenItem = getScreenItem(action.participant);
if (screenIdx >= 0) {
screens[screenIdx] = screenItem;
} else {
screens.push(screenItem);
}
} else if (screenIdx >= 0) {
screens.splice(screenIdx, 1);
}
const newState = {
...prevState,
participants,
screens,
};
if (fasteq(newState, prevState)) {
return prevState;
}
return newState;
}
case 'PARTICIPANT_LEFT': {
const id = getId(action.participant);
const screenId = getScreenId(id);
return {
...prevState,
participants: [...prevState.participants].filter((p) => p.id !== id),
screens: [...prevState.screens].filter((s) => s.id !== screenId),
};
}
case 'SWAP_POSITION': {
const participants = [...prevState.participants];
if (!action.id1 || !action.id2) return prevState;
const idx1 = participants.findIndex((p) => p.id === action.id1);
const idx2 = participants.findIndex((p) => p.id === action.id2);
if (idx1 === -1 || idx2 === -1) return prevState;
const tmp = participants[idx1];
participants[idx1] = participants[idx2];
participants[idx2] = tmp;
return {
...prevState,
participants,
};
}
default:
throw new Error();
}
} }
function getScreenId(id) {
return `${id}-screen`;
}
function isLocalId(id) {
return typeof id === 'string' && id === 'local';
}
function isScreenId(id) {
return typeof id === 'string' && id.endsWith('-screen');
}
// ---Helpers ---
function getNewParticipant(participant) { function getNewParticipant(participant) {
const id = getId(participant); const id = getId(participant);
@ -128,159 +257,29 @@ function getScreenItem(participant) {
}; };
} }
// --- Actions --- // --- Derived data ---
const ACTIVE_SPEAKER = 'ACTIVE_SPEAKER'; function getId(participant) {
const PARTICIPANT_JOINED = 'PARTICIPANT_JOINED'; return participant.local ? 'local' : participant.session_id;
const PARTICIPANT_UPDATED = 'PARTICIPANT_UPDATED'; }
const PARTICIPANT_LEFT = 'PARTICIPANT_LEFT';
const SWAP_POSITION = 'SWAP_POSITION';
// --- Reducer -- function getScreenId(id) {
return `${id}-screen`;
}
function participantsReducer(prevState, action) { function isLocalId(id) {
switch (action.type) { return typeof id === 'string' && id === 'local';
case ACTIVE_SPEAKER: { }
const { participants, ...state } = prevState;
if (!action.id)
return {
...prevState,
lastPendingUnknownActiveSpeaker: null,
};
const date = new Date();
const isParticipantKnown = participants.some((p) => p.id === action.id);
return {
...state,
lastPendingUnknownActiveSpeaker: isParticipantKnown
? null
: {
date,
id: action.id,
},
participants: participants.map((p) => ({
...p,
isActiveSpeaker: p.id === action.id,
lastActiveDate: p.id === action.id ? date : p?.lastActiveDate,
})),
};
}
case PARTICIPANT_JOINED: {
const item = getNewParticipant(action.participant);
const participants = [...prevState.participants]; function isScreenId(id) {
const screens = [...prevState.screens]; return typeof id === 'string' && id.endsWith('-screen');
const isPendingActiveSpeaker =
item.id === prevState.lastPendingUnknownActiveSpeaker?.id;
if (isPendingActiveSpeaker) {
item.isActiveSpeaker = true;
item.lastActiveDate = prevState.lastPendingUnknownActiveSpeaker?.date;
}
if (item.isCamMuted) {
participants.push(item);
} else {
const firstInactiveCamOffIndex = prevState.participants.findIndex(
(p) => p.isCamMuted && !p.isLocal && !p.isActiveSpeaker
);
if (firstInactiveCamOffIndex >= 0) {
participants.splice(firstInactiveCamOffIndex, 0, item);
} else {
participants.push(item);
}
}
// Participant is sharing screen
if (action.participant.screen) {
screens.push(getScreenItem(action.participant));
}
return {
...prevState,
lastPendingUnknownActiveSpeaker: isPendingActiveSpeaker
? null
: prevState.lastPendingUnknownActiveSpeaker,
participants,
screens,
};
}
case PARTICIPANT_UPDATED: {
const item = getUpdatedParticipant(
action.participant,
prevState.participants
);
const { id } = item;
const screenId = getScreenId(id);
const participants = [...prevState.participants];
const idx = participants.findIndex((p) => p.id === id);
participants[idx] = item;
const screens = [...prevState.screens];
const screenIdx = screens.findIndex((s) => s.id === screenId);
if (action.participant.screen) {
const screenItem = getScreenItem(action.participant);
if (screenIdx >= 0) {
screens[screenIdx] = screenItem;
} else {
screens.push(screenItem);
}
} else if (screenIdx >= 0) {
screens.splice(screenIdx, 1);
}
const newState = {
...prevState,
participants,
screens,
};
if (fasteq(newState, prevState)) {
return prevState;
}
return newState;
}
case PARTICIPANT_LEFT: {
const id = getId(action.participant);
const screenId = getScreenId(id);
return {
...prevState,
participants: [...prevState.participants].filter((p) => p.id !== id),
screens: [...prevState.screens].filter((s) => s.id !== screenId),
};
}
case SWAP_POSITION: {
const participants = [...prevState.participants];
if (!action.id1 || !action.id2) return prevState;
const idx1 = participants.findIndex((p) => p.id === action.id1);
const idx2 = participants.findIndex((p) => p.id === action.id2);
if (idx1 === -1 || idx2 === -1) return prevState;
const tmp = participants[idx1];
participants[idx1] = participants[idx2];
participants[idx2] = tmp;
return {
...prevState,
participants,
};
}
default:
throw new Error();
}
} }
export { export {
ACTIVE_SPEAKER,
getId, getId,
getScreenId, getScreenId,
initialParticipantsState,
isLocalId, isLocalId,
isScreenId, isScreenId,
participantsReducer, participantsReducer,
initialParticipantsState,
PARTICIPANT_JOINED,
PARTICIPANT_LEFT,
PARTICIPANT_UPDATED,
SWAP_POSITION,
}; };

View File

@ -1,31 +1,18 @@
/**
* Track state & reducer
* ---
* All (participant & screen) video and audio tracks indexed on participant ID
* If using manual track subscriptions, we'll also keep a record of those
* and their playing / paused state
*/
import { getId, getScreenId } from './participantsState'; import { getId, getScreenId } from './participantsState';
export const initialTracksState = { const initialTracksState = {
audioTracks: {}, audioTracks: {},
videoTracks: {}, videoTracks: {},
}; };
// --- Actions ---
export const TRACK_STARTED = 'TRACK_STARTED';
export const TRACK_STOPPED = 'TRACK_STOPPED';
export const TRACK_VIDEO_UPDATED = 'TRACK_VIDEO_UPDATED';
export const TRACK_AUDIO_UPDATED = 'TRACK_AUDIO_UPDATED';
export const REMOVE_TRACKS = 'REMOVE_TRACKS';
// --- Reducer and helpers -- // --- Reducer and helpers --
export function tracksReducer(prevState, action) { function tracksReducer(
prevState,
action
) {
switch (action.type) { switch (action.type) {
case TRACK_STARTED: { case 'TRACK_STARTED': {
const id = getId(action.participant); const id = getId(action.participant);
const screenId = getScreenId(id); const screenId = getScreenId(id);
@ -63,17 +50,15 @@ export function tracksReducer(prevState, action) {
}, },
}; };
} }
case 'TRACKS_STOPPED': {
case TRACK_STOPPED: {
const { audioTracks, videoTracks } = prevState; const { audioTracks, videoTracks } = prevState;
const newAudioTracks = { ...audioTracks }; const newAudioTracks = { ...audioTracks };
const newVideoTracks = { ...videoTracks }; const newVideoTracks = { ...videoTracks };
action.items.forEach(([participant, track]) => { for (const [participant, track] of action.items) {
const id = participant ? getId(participant) : null; const id = participant ? getId(participant) : null;
const screenId = participant ? getScreenId(id) : null; const screenId = participant ? getScreenId(id) : null;
if (track.kind === 'audio') { if (track.kind === 'audio') {
if (!participant?.local) { if (!participant?.local) {
// Ignore local audio from mic and screen share // Ignore local audio from mic and screen share
@ -88,16 +73,16 @@ export function tracksReducer(prevState, action) {
newVideoTracks[screenId] = participant.tracks.screenVideo; newVideoTracks[screenId] = participant.tracks.screenVideo;
} }
} }
}); }
return { return {
audioTracks: newAudioTracks, audioTracks: newAudioTracks,
videoTracks: newVideoTracks, videoTracks: newVideoTracks,
}; };
} }
case 'UPDATE_AUDIO_TRACK': {
case TRACK_AUDIO_UPDATED: {
const id = getId(action.participant); const id = getId(action.participant);
const screenId = getScreenId(id);
if (action.participant?.local) { if (action.participant?.local) {
// Ignore local audio from mic and screen share // Ignore local audio from mic and screen share
return prevState; return prevState;
@ -105,14 +90,14 @@ export function tracksReducer(prevState, action) {
const newAudioTracks = { const newAudioTracks = {
...prevState.audioTracks, ...prevState.audioTracks,
[id]: action.participant.tracks.audio, [id]: action.participant.tracks.audio,
[screenId]: action.participant.tracks.screenAudio,
}; };
return { return {
...prevState, ...prevState,
audioTracks: newAudioTracks, audioTracks: newAudioTracks,
}; };
} }
case 'UPDATE_VIDEO_TRACK': {
case TRACK_VIDEO_UPDATED: {
const id = getId(action.participant); const id = getId(action.participant);
const newVideoTracks = { const newVideoTracks = {
...prevState.videoTracks, ...prevState.videoTracks,
@ -123,8 +108,7 @@ export function tracksReducer(prevState, action) {
videoTracks: newVideoTracks, videoTracks: newVideoTracks,
}; };
} }
case 'REMOVE_TRACKS': {
case REMOVE_TRACKS: {
const { audioTracks, videoTracks } = prevState; const { audioTracks, videoTracks } = prevState;
const id = getId(action.participant); const id = getId(action.participant);
const screenId = getScreenId(id); const screenId = getScreenId(id);
@ -139,8 +123,9 @@ export function tracksReducer(prevState, action) {
videoTracks, videoTracks,
}; };
} }
default: default:
throw new Error(); throw new Error();
} }
} }
export { initialTracksState, tracksReducer };

View File

@ -1,50 +1,55 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallState } from '../contexts/CallProvider';
import {
VIDEO_QUALITY_HIGH,
VIDEO_QUALITY_LOW,
VIDEO_QUALITY_BANDWIDTH_SAVER,
} from '../constants';
export const NETWORK_STATE_GOOD = 'good';
export const NETWORK_STATE_LOW = 'low';
export const NETWORK_STATE_VERY_LOW = 'very-low';
const STANDARD_HIGH_BITRATE_CAP = 980; const STANDARD_HIGH_BITRATE_CAP = 980;
const STANDARD_LOW_BITRATE_CAP = 300; const STANDARD_LOW_BITRATE_CAP = 300;
export const useNetworkState = ( export const useNetworkState = (
callObject = null, co = null,
quality = VIDEO_QUALITY_HIGH quality = 'high'
) => { ) => {
const [threshold, setThreshold] = useState(NETWORK_STATE_GOOD); const [threshold, setThreshold] = useState('good');
const lastSetKBS = useRef(null);
const callState = useCallState();
const callObject = co ?? callState?.callObject;
const setQuality = useCallback( const setQuality = useCallback(
(q) => { async (q) => {
if (!callObject) return; if (!callObject) return;
const peers = Object.keys(callObject.participants()).length - 1; const peers = Object.keys(callObject.participants()).length - 1;
const isSFU = callObject.getNetworkTopology().topology === 'sfu'; const isSFU = (await callObject.getNetworkTopology()).topology === 'sfu';
const lowKbs = isSFU const lowKbs = isSFU
? STANDARD_LOW_BITRATE_CAP ? STANDARD_LOW_BITRATE_CAP
: STANDARD_LOW_BITRATE_CAP / Math.max(1, peers); : Math.floor(STANDARD_LOW_BITRATE_CAP / Math.max(1, peers));
const highKbs = isSFU
? STANDARD_HIGH_BITRATE_CAP
: Math.floor(STANDARD_HIGH_BITRATE_CAP / Math.max(1, peers));
switch (q) { switch (q) {
case VIDEO_QUALITY_HIGH: case 'auto':
callObject.setBandwidth({ kbs: STANDARD_HIGH_BITRATE_CAP }); case 'high':
if (lastSetKBS.current === highKbs) break;
callObject.setBandwidth({
kbs: highKbs,
});
lastSetKBS.current = highKbs;
break; break;
case VIDEO_QUALITY_LOW: case 'low':
if (lastSetKBS.current === lowKbs) break;
callObject.setBandwidth({ callObject.setBandwidth({
kbs: lowKbs, kbs: lowKbs,
}); });
lastSetKBS.current = lowKbs;
break; break;
case VIDEO_QUALITY_BANDWIDTH_SAVER: case 'bandwidth-saver':
callObject.setLocalVideo(false); callObject.setLocalVideo(false);
if (lastSetKBS.current === lowKbs) break;
callObject.setBandwidth({ callObject.setBandwidth({
kbs: lowKbs, kbs: lowKbs,
}); });
break; lastSetKBS.current = lowKbs;
default:
break; break;
} }
}, },
@ -56,43 +61,50 @@ export const useNetworkState = (
if (ev.threshold === threshold) return; if (ev.threshold === threshold) return;
switch (ev.threshold) { switch (ev.threshold) {
case NETWORK_STATE_VERY_LOW: case 'very-low':
setQuality(VIDEO_QUALITY_BANDWIDTH_SAVER); setQuality('bandwidth-saver');
setThreshold(NETWORK_STATE_VERY_LOW); setThreshold('very-low');
break; break;
case NETWORK_STATE_LOW: case 'low':
setQuality(quality === 'bandwidth-saver' ? quality : 'low');
setThreshold('low');
break;
case 'good':
setQuality( setQuality(
quality === VIDEO_QUALITY_BANDWIDTH_SAVER ['bandwidth-saver', 'low'].includes(quality) ? quality : 'high'
? quality
: NETWORK_STATE_LOW
); );
setThreshold(NETWORK_STATE_LOW); setThreshold('good');
break;
case NETWORK_STATE_GOOD:
setQuality(
[VIDEO_QUALITY_BANDWIDTH_SAVER, VIDEO_QUALITY_LOW].includes(quality)
? quality
: VIDEO_QUALITY_HIGH
);
setThreshold(NETWORK_STATE_GOOD);
break;
default:
break; break;
} }
}, },
[setQuality, threshold, quality] [quality, setQuality, threshold]
); );
useEffect(() => { useEffect(() => {
if (!callObject) return false; if (!callObject) return;
callObject.on('network-quality-change', handleNetworkQualityChange); callObject.on('network-quality-change', handleNetworkQualityChange);
return () => return () => {
callObject.off('network-quality-change', handleNetworkQualityChange); callObject.off('network-quality-change', handleNetworkQualityChange);
};
}, [callObject, handleNetworkQualityChange]); }, [callObject, handleNetworkQualityChange]);
useEffect(() => { useEffect(() => {
if (!callObject) return;
setQuality(quality); setQuality(quality);
}, [quality, setQuality]); let timeout;
const handleParticipantCountChange = () => {
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => {
setQuality(quality);
}, 500);
};
callObject.on('participant-joined', handleParticipantCountChange);
callObject.on('participant-left', handleParticipantCountChange);
return () => {
callObject.off('participant-joined', handleParticipantCountChange);
callObject.off('participant-left', handleParticipantCountChange);
};
}, [callObject, quality, setQuality]);
return threshold; return threshold;
}; };

View File

@ -1,4 +1,5 @@
export const sortByKey = (a, b, key, caseSensitive = true) => { export const sortByKey = (key, caseSensitive = true) =>
(a, b) => {
const aKey = const aKey =
!caseSensitive && typeof a[key] === 'string' !caseSensitive && typeof a[key] === 'string'
? String(a[key])?.toLowerCase() ? String(a[key])?.toLowerCase()