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 PropTypes from 'prop-types';
import { DEFAULT_ASPECT_RATIO } from '../../constants';
import Video from './Video';
import { Video } from './Video';
import { ReactComponent as Avatar } from './avatar.svg';
const SM_TILE_MAX_WIDTH = 300;
@ -99,8 +99,9 @@ export const Tile = memo(
{videoTrack ? (
<Video
ref={videoRef}
fit={videoFit}
isScreen={participant.isScreenshare}
participantId={participant?.id}
videoTrack={videoTrack}
/>
) : (
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 PropTypes from 'prop-types';
import { shallowEqualObjects } from 'shallow-equal';
import classNames from 'classnames';
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 { browser, platform, os } = Bowser.parse(navigator.userAgent);
return (
@ -19,43 +35,114 @@ export const Video = memo(
}, []);
/**
* Effect: Umount
* Note: nullify src to ensure media object is not counted
* Determine if video needs to be mirrored.
*/
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(() => {
const video = videoEl.current;
if (!video) return false;
// clean up when video renders for different participant
video.srcObject = null;
if (isChrome92) video.load();
return () => {
// clean up when unmounted
video.srcObject = null;
if (isChrome92) video.load();
if (!video) return;
const handleCanPlay = () => {
if (!video.paused) return;
video.play();
};
}, [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(() => {
const video = videoEl.current;
if (!video || !videoTrack) return;
video.srcObject = new MediaStream([videoTrack]);
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} />;
}),
(p, n) => shallowEqualObjects(p, n)
return (
<>
<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.propTypes = {
videoTrack: PropTypes.any,
mirrored: PropTypes.bool,
participantId: PropTypes.string,
};
export default Video;
Video.displayName = 'Video';

View File

@ -1,44 +1,36 @@
import React, {
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useReducer,
useState,
useMemo,
} from 'react';
import {
useUIState,
} from '@custom/shared/contexts/UIStateProvider';
import PropTypes from 'prop-types';
import { sortByKey } from '../lib/sortByKey';
import { sortByKey } from '@custom/shared/lib/sortByKey';
import { useNetworkState } from '../hooks/useNetworkState';
import { useCallState } from './CallProvider';
import { useUIState } from './UIStateProvider';
import {
initialParticipantsState,
isLocalId,
ACTIVE_SPEAKER,
PARTICIPANT_JOINED,
PARTICIPANT_LEFT,
PARTICIPANT_UPDATED,
participantsReducer,
SWAP_POSITION,
} from './participantsState';
export const ParticipantsContext = createContext();
export const ParticipantsContext = createContext(null);
export const ParticipantsProvider = ({ children }) => {
const { callObject, videoQuality, networkState, broadcast } = useCallState();
const [state, dispatch] = useReducer(
participantsReducer,
initialParticipantsState
);
const { viewMode } = useUIState();
const [
participantMarkedForRemoval,
setParticipantMarkedForRemoval,
] = useState(null);
const { isMobile, pinnedId, viewMode } = useUIState();
const {
broadcast,
broadcastRole,
callObject: daily,
videoQuality,
} = useCallState();
const [state, dispatch] = useReducer(participantsReducer, initialParticipantsState);
const [participantMarkedForRemoval, setParticipantMarkedForRemoval] = useState(null);
const { threshold } = useNetworkState();
/**
* ALL participants (incl. shared screens) in a convenient array
@ -58,14 +50,6 @@ export const ParticipantsProvider = ({ children }) => {
return 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
* (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(() => {
/**
* 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
* if everyone else is muted when AP leaves, the value will be stale.
*/
const isPresent = participants.some((p) => p?.id === activeParticipant?.id);
if (isPresent) {
return activeParticipant;
}
const pinned = participants.find((p) => p?.id === pinnedId);
/**
* If the activeParticipant has left, calculate the remaining displayable participants
*/
const displayableParticipants = participants.filter((p) => !p?.isLocal);
if (pinned) return pinned;
const displayableParticipants = participants.filter((p) =>
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 (
!isPresent &&
displayableParticipants.length > 0 &&
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 (
displayableParticipants.find((p) => !p.isCamMuted) ??
displayableParticipants?.[0]
@ -132,13 +114,20 @@ export const ParticipantsProvider = ({ children }) => {
}
const sorted = displayableParticipants
.sort((a, b) => sortByKey(a, b, 'lastActiveDate'))
.sort(sortByKey('lastActiveDate'))
.reverse();
const lastActiveSpeaker = sorted?.[0];
const fallback = broadcastRole === 'attendee' ? null : localParticipant;
return lastActiveSpeaker || localParticipant;
}, [activeParticipant, localParticipant, participants]);
return isPresent ? activeParticipant : sorted?.[0] ?? fallback;
}, [
activeParticipant,
broadcastRole,
isMobile,
localParticipant,
participants,
pinnedId,
]);
/**
* Screen shares
@ -148,33 +137,17 @@ export const ParticipantsProvider = ({ children }) => {
/**
* 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
* @param name The new username
*/
const setUsername = (name) => {
callObject.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);
const setUsername = useCallback(
(name) => {
daily.setUserName(name);
},
[callObject, localParticipant, participants]
[daily]
);
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(
(event = null) => {
switch (event?.action) {
case 'participant-joined':
dispatch({
type: PARTICIPANT_JOINED,
type: 'PARTICIPANT_JOINED',
participant: event.participant,
});
if (muteNewParticipants && daily) {
daily.updateParticipant(event.participant.session_id, {
setAudio: false,
});
}
break;
case 'participant-updated':
dispatch({
type: PARTICIPANT_UPDATED,
type: 'PARTICIPANT_UPDATED',
participant: event.participant,
});
break;
case 'participant-left':
dispatch({
type: PARTICIPANT_LEFT,
type: 'PARTICIPANT_LEFT',
participant: event.participant,
});
break;
default:
break;
}
},
[dispatch]
[daily, dispatch, muteNewParticipants]
);
/**
* Start listening for participant changes, when the callObject is set.
*/
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 = [
'joined-meeting',
'participant-joined',
'participant-updated',
'participant-left',
];
return () => {
daily.off('participant-joined', handleParticipantJoined);
daily.off('participant-joined', handleNewParticipantsState);
daily.off('participant-updated', handleNewParticipantsState);
daily.off('participant-left', handleNewParticipantsState);
};
}, [daily, handleNewParticipantsState, handleParticipantJoined]);
// Use initial state
handleNewParticipantsState();
// 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 participantIds = useMemo(
() => participants.map((p) => p.id).join(','),
[participants]
);
const setBandWidthControls = useCallback(() => {
if (!(callObject && callObject.meetingState() === 'joined-meeting')) return;
if (!(daily && daily.meetingState() === 'joined-meeting')) return;
const ids = participantIds.split(',').filter(Boolean);
const receiveSettings = {};
@ -263,7 +257,7 @@ export const ParticipantsProvider = ({ children }) => {
if (
// 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
['bandwidth-saver', 'low'].includes(videoQuality)
) {
@ -284,38 +278,44 @@ export const ParticipantsProvider = ({ children }) => {
// Mobile view settings are handled separately in MobileCall
});
callObject.updateReceiveSettings(receiveSettings);
}, [callObject, participantIds, networkState, videoQuality, viewMode, currentSpeaker?.id]);
daily.updateReceiveSettings(receiveSettings);
}, [
currentSpeaker?.id,
daily,
participantIds,
threshold,
videoQuality,
viewMode,
]);
useEffect(() => {
setBandWidthControls();
}, [setBandWidthControls]);
useEffect(() => {
if (!callObject) return false;
if (!daily) return;
const handleActiveSpeakerChange = ({ activeSpeaker }) => {
/**
* Ignore active-speaker-change events for the local user.
* 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;
if (localId === activeSpeakerId) return;
dispatch({
type: ACTIVE_SPEAKER,
type: 'ACTIVE_SPEAKER',
id: activeSpeakerId,
});
};
callObject.on('active-speaker-change', handleActiveSpeakerChange);
daily.on('active-speaker-change', handleActiveSpeakerChange);
return () =>
callObject.off('active-speaker-change', handleActiveSpeakerChange);
}, [callObject]);
daily.off('active-speaker-change', handleActiveSpeakerChange);
}, [daily]);
return (
<ParticipantsContext.Provider
value={{
activeParticipant,
allParticipants,
currentSpeaker,
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 React, {
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useReducer,
useRef,
useState,
} from 'react';
import { sortByKey } from '@custom/shared/lib/sortByKey';
import deepEqual from 'fast-deep-equal';
import PropTypes from 'prop-types';
import { useDeepCompareEffect } from 'use-deep-compare';
import { sortByKey } from '../lib/sortByKey';
import { useDeepCompareCallback } from 'use-deep-compare';
import { useCallState } from './CallProvider';
import { useParticipants } from './ParticipantsProvider';
import { useUIState } from './UIStateProvider';
import { isLocalId, isScreenId } from './participantsState';
import {
initialTracksState,
REMOVE_TRACKS,
TRACK_STARTED,
TRACK_STOPPED,
TRACK_VIDEO_UPDATED,
TRACK_AUDIO_UPDATED,
tracksReducer,
} from './tracksState';
import { getScreenId, isLocalId, isScreenId } from './participantsState';
import { initialTracksState, 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,
* cam subscriptions are defined by UI view modes.
*/
@ -41,16 +32,17 @@ const SUBSCRIBE_OR_STAGE_ALL_VIDEO_THRESHOLD = 9;
const TracksContext = createContext(null);
export const TracksProvider = ({ children }) => {
const { callObject, subscribeToTracksAutomatically } = useCallState();
const { callObject: daily, optimizeLargeCalls } = useCallState();
const { participants } = useParticipants();
const { viewMode } = useUIState();
const [state, dispatch] = useReducer(tracksReducer, initialTracksState);
const [maxCamSubscriptions, setMaxCamSubscriptions] = useState(null);
const recentSpeakerIds = useMemo(
() =>
participants
.filter((p) => Boolean(p.lastActiveDate) && !p.isLocal)
.sort((a, b) => sortByKey(a, b, 'lastActiveDate'))
.filter((p) => Boolean(p.lastActiveDate))
.sort(sortByKey('lastActiveDate'))
.slice(-MAX_RECENT_SPEAKER_COUNT)
.map((p) => p.id)
.reverse(),
@ -64,22 +56,27 @@ export const TracksProvider = ({ children }) => {
const subscribeToCam = useCallback(
(id) => {
// Ignore undefined, local or screenshare.
/**
* Ignore undefined, local or screenshare.
*/
if (!id || isLocalId(id) || isScreenId(id)) return;
callObject.updateParticipant(id, {
daily.updateParticipant(id, {
setSubscribedTracks: { video: true },
});
},
[callObject]
[daily]
);
/**
* 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(
(subscribedIds, stagedIds = []) => {
if (!callObject) return;
if (!daily) return;
// If total number of remote participants is less than a threshold, simply
// 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) => {
let desiredSubscription;
const currentSubscription =
callObject.participants()?.[id]?.tracks?.video?.subscribed;
daily.participants()?.[id]?.tracks?.video?.subscribed;
// Ignore undefined, local or screenshare participant ids
if (!id || isLocalId(id) || isScreenId(id)) return u;
@ -131,110 +128,126 @@ export const TracksProvider = ({ children }) => {
}, {});
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(() => {
if (!callObject) return false;
const trackStoppedQueue = [];
const handleTrackStarted = ({ 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.findIndex(
([p, t]) =>
p.session_id === participant.session_id && t.kind === track.kind
);
if (stoppingIdx >= 0) {
trackStoppedQueue.splice(stoppingIdx, 1);
}
dispatch({
type: TRACK_STARTED,
participant,
track,
});
};
const trackStoppedBatchInterval = setInterval(() => {
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] = {
if (!daily) return;
/**
* A little throttling as we want daily-js to have some room to breathe
*/
const timeout = setTimeout(() => {
const participants = daily.participants();
const updates = remoteParticipantIds.reduce((u, id) => {
// Ignore undefined, local or screenshare participant ids
if (!id || isLocalId(id) || isScreenId(id)) return u;
const isSpeaker = recentSpeakerIds.includes(id);
const hasSubscribed = participants[id]?.tracks?.audio?.subscribed;
const shouldSubscribe = optimizeLargeCalls ? isSpeaker : true;
/**
* In optimized calls:
* - subscribe to speakers we're not subscribed to, yet
* - unsubscribe from non-speakers we're subscribed to
* In non-optimized calls:
* - subscribe to all who we're not to subscribed to, yet
*/
if (
(!hasSubscribed && shouldSubscribe) ||
(hasSubscribed && !shouldSubscribe)
) {
u[id] = {
setSubscribedTracks: {
audio: true,
screenAudio: true,
screenVideo: true,
audio: shouldSubscribe,
},
};
}
if (topology === 'peer') {
o[id] = { setSubscribedTracks: true };
}
return o;
return u;
}, {});
if (!subscribeToTracksAutomatically && Object.keys(updates).length0) {
callObject.updateParticipants(updates);
}
if (Object.keys(updates).length === 0) return;
daily.updateParticipants(updates);
}, 100);
callObject.on('track-started', handleTrackStarted);
callObject.on('track-stopped', handleTrackStopped);
callObject.on('participant-joined', handleParticipantJoined);
callObject.on('participant-left', handleParticipantLeft);
return () => {
clearInterval(joinBatchInterval);
clearInterval(trackStoppedBatchInterval);
callObject.off('track-started', handleTrackStarted);
callObject.off('track-stopped', handleTrackStopped);
callObject.off('participant-joined', handleParticipantJoined);
callObject.off('participant-left', handleParticipantLeft);
clearTimeout(timeout);
};
}, [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 =
// State changed
participant.tracks.audio?.state !==
state.audioTracks?.[participant.user_id]?.state ||
participant.tracks.audio.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
!deepEqual(
{
@ -242,14 +255,14 @@ export const TracksProvider = ({ children }) => {
...(participant.tracks.audio?.off ?? {}),
},
{
...(state.audioTracks?.[participant.user_id]?.blocked ?? {}),
...(state.audioTracks?.[participant.user_id]?.off ?? {}),
...(state.audioTracks?.[participant.user_id].blocked ?? {}),
...(state.audioTracks?.[participant.user_id].off ?? {}),
}
);
const hasVideoChanged =
// State changed
participant.tracks.video?.state !==
state.videoTracks?.[participant.user_id]?.state ||
state.videoTracks?.[participant.user_id]?.state ||
// Off/blocked reason changed
!deepEqual(
{
@ -265,7 +278,7 @@ export const TracksProvider = ({ children }) => {
if (hasAudioChanged) {
// Update audio track state
dispatch({
type: TRACK_AUDIO_UPDATED,
type: 'UPDATE_AUDIO_TRACK',
participant,
});
}
@ -273,27 +286,92 @@ export const TracksProvider = ({ children }) => {
if (hasVideoChanged) {
// Update video track state
dispatch({
type: TRACK_VIDEO_UPDATED,
type: 'UPDATE_VIDEO_TRACK',
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 () => {
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 (
<TracksContext.Provider
value={{
audioTracks: state.audioTracks,
videoTracks: state.videoTracks,
maxCamSubscriptions,
subscribeToCam,
updateCamSubscriptions,
remoteParticipantIds,
recentSpeakerIds,
}}
>
{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 {
DEVICE_STATE_OFF,
DEVICE_STATE_BLOCKED,
DEVICE_STATE_LOADING,
} from './useDevices';
import { MAX_RECENT_SPEAKER_COUNT } from './TracksProvider';
const initialParticipantsState = {
lastPendingUnknownActiveSpeaker: null,
@ -39,26 +26,168 @@ const initialParticipantsState = {
screens: [],
};
// --- Derived data ---
// --- Reducer and helpers --
function getId(participant) {
return participant.local ? 'local' : participant.session_id;
function participantsReducer(
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) {
const id = getId(participant);
@ -128,159 +257,29 @@ function getScreenItem(participant) {
};
}
// --- Actions ---
// --- Derived data ---
const ACTIVE_SPEAKER = 'ACTIVE_SPEAKER';
const PARTICIPANT_JOINED = 'PARTICIPANT_JOINED';
const PARTICIPANT_UPDATED = 'PARTICIPANT_UPDATED';
const PARTICIPANT_LEFT = 'PARTICIPANT_LEFT';
const SWAP_POSITION = 'SWAP_POSITION';
function getId(participant) {
return participant.local ? 'local' : participant.session_id;
}
// --- Reducer --
function getScreenId(id) {
return `${id}-screen`;
}
function participantsReducer(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 PARTICIPANT_JOINED: {
const item = getNewParticipant(action.participant);
function isLocalId(id) {
return typeof id === 'string' && id === 'local';
}
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);
}
}
// 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();
}
function isScreenId(id) {
return typeof id === 'string' && id.endsWith('-screen');
}
export {
ACTIVE_SPEAKER,
getId,
getScreenId,
initialParticipantsState,
isLocalId,
isScreenId,
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';
export const initialTracksState = {
const initialTracksState = {
audioTracks: {},
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 --
export function tracksReducer(prevState, action) {
function tracksReducer(
prevState,
action
) {
switch (action.type) {
case TRACK_STARTED: {
case 'TRACK_STARTED': {
const id = getId(action.participant);
const screenId = getScreenId(id);
@ -63,17 +50,15 @@ export function tracksReducer(prevState, action) {
},
};
}
case TRACK_STOPPED: {
case 'TRACKS_STOPPED': {
const { audioTracks, videoTracks } = prevState;
const newAudioTracks = { ...audioTracks };
const newVideoTracks = { ...videoTracks };
action.items.forEach(([participant, track]) => {
for (const [participant, track] of action.items) {
const id = participant ? getId(participant) : null;
const screenId = participant ? getScreenId(id) : null;
if (track.kind === 'audio') {
if (!participant?.local) {
// Ignore local audio from mic and screen share
@ -88,16 +73,16 @@ export function tracksReducer(prevState, action) {
newVideoTracks[screenId] = participant.tracks.screenVideo;
}
}
});
}
return {
audioTracks: newAudioTracks,
videoTracks: newVideoTracks,
};
}
case TRACK_AUDIO_UPDATED: {
case 'UPDATE_AUDIO_TRACK': {
const id = getId(action.participant);
const screenId = getScreenId(id);
if (action.participant?.local) {
// Ignore local audio from mic and screen share
return prevState;
@ -105,14 +90,14 @@ export function tracksReducer(prevState, action) {
const newAudioTracks = {
...prevState.audioTracks,
[id]: action.participant.tracks.audio,
[screenId]: action.participant.tracks.screenAudio,
};
return {
...prevState,
audioTracks: newAudioTracks,
};
}
case TRACK_VIDEO_UPDATED: {
case 'UPDATE_VIDEO_TRACK': {
const id = getId(action.participant);
const newVideoTracks = {
...prevState.videoTracks,
@ -123,8 +108,7 @@ export function tracksReducer(prevState, action) {
videoTracks: newVideoTracks,
};
}
case REMOVE_TRACKS: {
case 'REMOVE_TRACKS': {
const { audioTracks, videoTracks } = prevState;
const id = getId(action.participant);
const screenId = getScreenId(id);
@ -139,8 +123,9 @@ export function tracksReducer(prevState, action) {
videoTracks,
};
}
default:
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_LOW_BITRATE_CAP = 300;
export const useNetworkState = (
callObject = null,
quality = VIDEO_QUALITY_HIGH
co = null,
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(
(q) => {
async (q) => {
if (!callObject) return;
const peers = Object.keys(callObject.participants()).length - 1;
const isSFU = callObject.getNetworkTopology().topology === 'sfu';
const isSFU = (await callObject.getNetworkTopology()).topology === 'sfu';
const lowKbs = isSFU
? 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) {
case VIDEO_QUALITY_HIGH:
callObject.setBandwidth({ kbs: STANDARD_HIGH_BITRATE_CAP });
case 'auto':
case 'high':
if (lastSetKBS.current === highKbs) break;
callObject.setBandwidth({
kbs: highKbs,
});
lastSetKBS.current = highKbs;
break;
case VIDEO_QUALITY_LOW:
case 'low':
if (lastSetKBS.current === lowKbs) break;
callObject.setBandwidth({
kbs: lowKbs,
});
lastSetKBS.current = lowKbs;
break;
case VIDEO_QUALITY_BANDWIDTH_SAVER:
case 'bandwidth-saver':
callObject.setLocalVideo(false);
if (lastSetKBS.current === lowKbs) break;
callObject.setBandwidth({
kbs: lowKbs,
});
break;
default:
lastSetKBS.current = lowKbs;
break;
}
},
@ -56,43 +61,50 @@ export const useNetworkState = (
if (ev.threshold === threshold) return;
switch (ev.threshold) {
case NETWORK_STATE_VERY_LOW:
setQuality(VIDEO_QUALITY_BANDWIDTH_SAVER);
setThreshold(NETWORK_STATE_VERY_LOW);
case 'very-low':
setQuality('bandwidth-saver');
setThreshold('very-low');
break;
case NETWORK_STATE_LOW:
case 'low':
setQuality(quality === 'bandwidth-saver' ? quality : 'low');
setThreshold('low');
break;
case 'good':
setQuality(
quality === VIDEO_QUALITY_BANDWIDTH_SAVER
? quality
: NETWORK_STATE_LOW
['bandwidth-saver', 'low'].includes(quality) ? quality : 'high'
);
setThreshold(NETWORK_STATE_LOW);
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:
setThreshold('good');
break;
}
},
[setQuality, threshold, quality]
[quality, setQuality, threshold]
);
useEffect(() => {
if (!callObject) return false;
if (!callObject) return;
callObject.on('network-quality-change', handleNetworkQualityChange);
return () =>
return () => {
callObject.off('network-quality-change', handleNetworkQualityChange);
};
}, [callObject, handleNetworkQualityChange]);
useEffect(() => {
if (!callObject) return;
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;
};
};

View File

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