Update the track provider and participant providers
This commit is contained in:
parent
0927582f5c
commit
5a1fdc59b7
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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;
|
||||
};
|
||||
};
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue