import React, { createContext, useCallback, useContext, useEffect, useReducer, useState, useMemo, } from 'react'; import { useUIState, } from '@custom/shared/contexts/UIStateProvider'; import PropTypes from 'prop-types'; import { sortByKey } from '../lib/sortByKey'; import { useCallState } from './CallProvider'; import { initialParticipantsState, isLocalId, ACTIVE_SPEAKER, PARTICIPANT_JOINED, PARTICIPANT_LEFT, PARTICIPANT_UPDATED, participantsReducer, SWAP_POSITION, } from './participantsState'; export const ParticipantsContext = createContext(); export const ParticipantsProvider = ({ children }) => { const { callObject, videoQuality, networkState, broadcast } = useCallState(); const [state, dispatch] = useReducer( participantsReducer, initialParticipantsState ); const { viewMode } = useUIState(); const [ participantMarkedForRemoval, setParticipantMarkedForRemoval, ] = useState(null); /** * ALL participants (incl. shared screens) in a convenient array */ const allParticipants = useMemo( () => [...state.participants, ...state.screens], [state?.participants, state?.screens] ); /** * Only return participants that should be visible in the call */ const participants = useMemo(() => { if (broadcast) { return state.participants.filter((p) => p?.isOwner); } 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) */ const participantCount = useMemo( () => participants.filter(({ isScreenshare }) => !isScreenshare).length, [participants] ); /** * The participant who most recently got mentioned via a `active-speaker-change` event */ const activeParticipant = useMemo( () => participants.find(({ isActiveSpeaker }) => isActiveSpeaker), [participants] ); /** * The local participant */ const localParticipant = useMemo( () => allParticipants.find( ({ isLocal, isScreenshare }) => isLocal && !isScreenshare ), [allParticipants] ); const isOwner = useMemo(() => !!localParticipant?.isOwner, [ localParticipant, ]); /** * The participant who should be rendered prominently right now */ const currentSpeaker = useMemo(() => { /** * If the activeParticipant is still in the call, return the activeParticipant. * 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; } /** * If the activeParticipant has left, calculate the remaining displayable participants */ const displayableParticipants = participants.filter((p) => !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 ( displayableParticipants.length > 0 && displayableParticipants.every((p) => p.isMicMuted && !p.lastActiveDate) ) { return ( displayableParticipants.find((p) => !p.isCamMuted) ?? displayableParticipants?.[0] ); } const sorted = displayableParticipants .sort((a, b) => sortByKey(a, b, 'lastActiveDate')) .reverse(); const lastActiveSpeaker = sorted?.[0]; return lastActiveSpeaker || localParticipant; }, [activeParticipant, localParticipant, participants]); /** * Screen shares */ const screens = useMemo(() => state?.screens, [state?.screens]); /** * The local participant's name */ const username = callObject?.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); }, [callObject, localParticipant, participants] ); const swapParticipantPosition = useCallback((id1, id2) => { /** * Ignore in the following cases: * - id1 and id2 are equal * - one of both ids is not set * - one of both ids is 'local' */ if (id1 === id2 || !id1 || !id2 || isLocalId(id1) || isLocalId(id2)) return; dispatch({ type: 'SWAP_POSITION', id1, id2, }); }, []); const handleNewParticipantsState = useCallback( (event = null) => { switch (event?.action) { case 'participant-joined': dispatch({ type: PARTICIPANT_JOINED, participant: event.participant, }); break; case 'participant-updated': dispatch({ type: PARTICIPANT_UPDATED, participant: event.participant, }); break; case 'participant-left': dispatch({ type: PARTICIPANT_LEFT, participant: event.participant, }); break; default: break; } }, [dispatch] ); /** * Start listening for participant changes, when the callObject is set. */ useEffect(() => { if (!callObject) return false; console.log('👥 Participant provider events bound'); const events = [ 'joined-meeting', 'participant-joined', 'participant-updated', 'participant-left', ]; // 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 setBandWidthControls = useCallback(() => { if (!(callObject && callObject.meetingState() === 'joined-meeting')) return; const ids = participantIds.split(',').filter(Boolean); const receiveSettings = {}; ids.forEach((id) => { if (isLocalId(id)) return; if ( // weak or bad network (['low', 'very-low'].includes(networkState) && videoQuality === 'auto') || // Low quality or Bandwidth saver mode enabled ['bandwidth-saver', 'low'].includes(videoQuality) ) { receiveSettings[id] = { video: { layer: 0 } }; return; } // Speaker view settings based on speaker status or pinned user if (viewMode === 'speaker') { if (currentSpeaker?.id === id) { receiveSettings[id] = { video: { layer: 2 } }; } else { receiveSettings[id] = { video: { layer: 0 } }; } } // Grid view settings are handled separately in GridView // Mobile view settings are handled separately in MobileCall }); callObject.updateReceiveSettings(receiveSettings); }, [callObject, participantIds, networkState, videoQuality, viewMode, currentSpeaker?.id]); useEffect(() => { setBandWidthControls(); }, [setBandWidthControls]); useEffect(() => { if (!callObject) return false; 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 activeSpeakerId = activeSpeaker?.peerId; if (localId === activeSpeakerId) return; dispatch({ type: ACTIVE_SPEAKER, id: activeSpeakerId, }); }; callObject.on('active-speaker-change', handleActiveSpeakerChange); return () => callObject.off('active-speaker-change', handleActiveSpeakerChange); }, [callObject]); return ( {children} ); }; ParticipantsProvider.propTypes = { children: PropTypes.node, }; export const useParticipants = () => useContext(ParticipantsContext);