382 lines
12 KiB
JavaScript
382 lines
12 KiB
JavaScript
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 { useDeepCompareCallback } from 'use-deep-compare';
|
|
|
|
import { useCallState } from './CallProvider';
|
|
import { useParticipants } from './ParticipantsProvider';
|
|
import { useUIState } from './UIStateProvider';
|
|
import { getScreenId, isLocalId, isScreenId } from './participantsState';
|
|
import { initialTracksState, tracksReducer } from './tracksState';
|
|
|
|
/**
|
|
* Maximum amount of concurrently subscribed or staged most recent speakers.
|
|
*/
|
|
export const MAX_RECENT_SPEAKER_COUNT = 8;
|
|
/**
|
|
* 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.
|
|
*/
|
|
const SUBSCRIBE_OR_STAGE_ALL_VIDEO_THRESHOLD = 9;
|
|
|
|
const TracksContext = createContext(null);
|
|
|
|
export const TracksProvider = ({ children }) => {
|
|
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))
|
|
.sort(sortByKey('lastActiveDate'))
|
|
.slice(-MAX_RECENT_SPEAKER_COUNT)
|
|
.map((p) => p.id)
|
|
.reverse(),
|
|
[participants]
|
|
);
|
|
|
|
const remoteParticipantIds = useMemo(
|
|
() => participants.filter((p) => !p.isLocal).map((p) => p.id),
|
|
[participants]
|
|
);
|
|
|
|
const subscribeToCam = useCallback(
|
|
(id) => {
|
|
/**
|
|
* Ignore undefined, local or screenshare.
|
|
*/
|
|
if (!id || isLocalId(id) || isScreenId(id)) return;
|
|
daily.updateParticipant(id, {
|
|
setSubscribedTracks: { video: true },
|
|
});
|
|
},
|
|
[daily]
|
|
);
|
|
|
|
/**
|
|
* Updates cam subscriptions based on passed subscribedIds and stagedIds.
|
|
* 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 (!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.
|
|
// Otherwise, honor the provided stagedIds, with recent speakers appended
|
|
// who aren't already marked for subscription.
|
|
if (
|
|
remoteParticipantIds.length <= SUBSCRIBE_OR_STAGE_ALL_VIDEO_THRESHOLD
|
|
) {
|
|
stagedIds = remoteParticipantIds.filter(
|
|
(id) => !subscribedIds.includes(id)
|
|
);
|
|
} else {
|
|
if (viewMode !== 'grid') {
|
|
stagedIds.push(
|
|
...recentSpeakerIds.filter((id) => !subscribedIds.includes(id))
|
|
);
|
|
}
|
|
}
|
|
|
|
// Assemble updates to get to desired cam subscriptions
|
|
const updates = remoteParticipantIds.reduce((u, id) => {
|
|
let desiredSubscription;
|
|
const currentSubscription =
|
|
daily.participants()?.[id]?.tracks?.video?.subscribed;
|
|
|
|
// Ignore undefined, local or screenshare participant ids
|
|
if (!id || isLocalId(id) || isScreenId(id)) return u;
|
|
|
|
// Decide on desired cam subscription for this participant:
|
|
// subscribed, staged, or unsubscribed
|
|
if (subscribedIds.includes(id)) {
|
|
desiredSubscription = true;
|
|
} else if (stagedIds.includes(id)) {
|
|
desiredSubscription = 'staged';
|
|
} else {
|
|
desiredSubscription = false;
|
|
}
|
|
|
|
// Skip if we already have the desired subscription to this
|
|
// participant's cam
|
|
if (desiredSubscription === currentSubscription) return u;
|
|
|
|
u[id] = {
|
|
setSubscribedTracks: {
|
|
video: desiredSubscription,
|
|
},
|
|
};
|
|
return u;
|
|
}, {});
|
|
|
|
if (Object.keys(updates).length === 0) return;
|
|
daily.updateParticipants(updates);
|
|
},
|
|
[daily, remoteParticipantIds, recentSpeakerIds, viewMode]
|
|
);
|
|
|
|
/**
|
|
* Automatically update audio subscriptions.
|
|
*/
|
|
useEffect(() => {
|
|
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: shouldSubscribe,
|
|
},
|
|
};
|
|
}
|
|
return u;
|
|
}, {});
|
|
if (Object.keys(updates).length === 0) return;
|
|
daily.updateParticipants(updates);
|
|
}, 100);
|
|
return () => {
|
|
clearTimeout(timeout);
|
|
};
|
|
}, [daily, optimizeLargeCalls, recentSpeakerIds, remoteParticipantIds]);
|
|
|
|
/**
|
|
* Notify user when pushed out of recent speakers queue.
|
|
*/
|
|
const showMutedMessage = useRef(false);
|
|
useEffect(() => {
|
|
if (!daily || !optimizeLargeCalls) return;
|
|
|
|
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 ||
|
|
// Screen state changed
|
|
participant.tracks.screenAudio.state !==
|
|
state.audioTracks?.[getScreenId(participant.user_id)]?.state ||
|
|
// Off/blocked reason changed
|
|
!deepEqual(
|
|
{
|
|
...(participant.tracks.audio?.blocked ?? {}),
|
|
...(participant.tracks.audio?.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 ||
|
|
// Off/blocked reason changed
|
|
!deepEqual(
|
|
{
|
|
...(participant.tracks.video?.blocked ?? {}),
|
|
...(participant.tracks.video?.off ?? {}),
|
|
},
|
|
{
|
|
...(state.videoTracks?.[participant.user_id]?.blocked ?? {}),
|
|
...(state.videoTracks?.[participant.user_id]?.off ?? {}),
|
|
}
|
|
);
|
|
|
|
if (hasAudioChanged) {
|
|
// Update audio track state
|
|
dispatch({
|
|
type: 'UPDATE_AUDIO_TRACK',
|
|
participant,
|
|
});
|
|
}
|
|
|
|
if (hasVideoChanged) {
|
|
// Update video track state
|
|
dispatch({
|
|
type: 'UPDATE_VIDEO_TRACK',
|
|
participant,
|
|
});
|
|
}
|
|
},
|
|
[state.audioTracks, state.videoTracks]
|
|
);
|
|
|
|
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 () => {
|
|
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);
|
|
};
|
|
}, [
|
|
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,
|
|
}}
|
|
>
|
|
{children}
|
|
</TracksContext.Provider>
|
|
);
|
|
};
|
|
|
|
export const useTracks = () => useContext(TracksContext); |