Skip to content

Commit 7b6a5cb

Browse files
committed
improve speaking indicator and screen share sidebar
1 parent 039fc42 commit 7b6a5cb

12 files changed

Lines changed: 283 additions & 88 deletions

src/Components/Cards/VideoCard.tsx

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,6 @@ interface Participant {
2828
streamName: string;
2929
}
3030

31-
interface Talker {
32-
streamId: string;
33-
audioLevel?: number;
34-
}
35-
3631
interface OverlayButtonProps {
3732
title: string;
3833
icon: string;
@@ -110,8 +105,9 @@ interface VideoCardProps extends VideoHTMLAttributes<HTMLVideoElement> {
110105
setParticipantIdMuted: (participant: Participant) => void;
111106
setMuteParticipantDialogOpen: (open: boolean) => void;
112107
connectionQuality?: number;
113-
talkers?: Talker[];
108+
talkers?: string[];
114109
metaData?: string;
110+
isScreenShare?: boolean;
115111
}
116112

117113
// Styled components
@@ -507,6 +503,7 @@ const VideoCard = React.memo<VideoCardProps>((props) => {
507503
connectionQuality = 0,
508504
talkers = [],
509505
metaData,
506+
isScreenShare = false,
510507
...videoProps
511508
} = props;
512509
const theme = useTheme();
@@ -611,8 +608,8 @@ const VideoCard = React.memo<VideoCardProps>((props) => {
611608
/>
612609

613610
<Box className="single-video-card" id={`card-${streamId || ''}`} style={cardStyle}>
614-
{/*@ts-ignore*/}
615-
<TalkingIndicator streamId={streamId} talkers={talkers} />
611+
{/* A screen share carries no speaker of its own, so it never gets the ring. */}
612+
{!isScreenShare && <TalkingIndicator streamId={streamId} talkers={talkers} />}
616613

617614
<VideoPlayer
618615
isMine={isMine}

src/Components/TalkingIndicator.tsx

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useEffect, useRef, useState } from 'react';
1+
import React, { useMemo } from 'react';
22
import { styled } from '@mui/material/styles';
33
import { useTheme } from '@mui/material';
44

@@ -15,7 +15,6 @@ interface TalkingIndicatorProps {
1515

1616
const TalkingIndicatorWrapper = styled('div')<TalkingIndicatorWrapperProps>(
1717
({ isVisible, borderColor }) => ({
18-
display: isVisible ? 'block' : 'none',
1918
position: 'absolute',
2019
top: 0,
2120
left: 0,
@@ -30,23 +29,20 @@ const TalkingIndicatorWrapper = styled('div')<TalkingIndicatorWrapperProps>(
3029

3130
// Use box-shadow instead of border to avoid taking up space
3231
boxShadow: `inset 0 0 0 2px ${borderColor}`,
32+
33+
// Fade in fast, fade out slow, so the ring never pops on a brief pause.
34+
opacity: isVisible ? 1 : 0,
35+
transition: isVisible ? 'opacity 120ms ease-out' : 'opacity 400ms ease-in',
36+
willChange: 'opacity',
3337
}),
3438
);
3539

3640
const TalkingIndicator: React.FC<TalkingIndicatorProps> = ({ streamId, talkers }) => {
3741
const theme = useTheme();
38-
// const isTalkingRef = useRef<boolean>(false);
39-
const [isTalking, setIsTalking] = useState<boolean>(false);
40-
41-
useEffect(() => {
42-
const talking = talkers.some((talkerId) => {
43-
const baseStreamId = streamId.split('_')[0];
44-
const baseTalkerId = talkerId.split('_')[0];
45-
return baseStreamId === baseTalkerId || streamId === talkerId;
46-
});
47-
48-
setIsTalking(talking);
49-
}, [streamId, talkers]);
42+
43+
// Exact match only: a screen share publishes under a separate id derived from its
44+
// owner's, so loose matching used to light up the sharer's tiles too.
45+
const isTalking = useMemo(() => !!streamId && talkers.includes(streamId), [streamId, talkers]);
5046

5147
return (
5248
<TalkingIndicatorWrapper

src/hooks/useConferenceActions.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ interface ParticipantsHook {
6666
setParticipants: (participants: Participants) => void;
6767
setSubscribedParticipants: (participants: Participants) => void;
6868
talkerAudioLevelsRef: MutableRefObject<{ [key: string]: number }>;
69+
resetTalkers: () => void;
6970
pinnedParticipantIdRef: MutableRefObject<string | null>;
7071
setPinnedParticipantId: (id: string | null) => void;
7172
setGuestsWaitingApproval: React.Dispatch<React.SetStateAction<Participants>>;
@@ -289,7 +290,7 @@ export const useConferenceActions = (
289290
await client.leaveRoom();
290291
participantsHook.setParticipants({});
291292
participantsHook.setSubscribedParticipants({});
292-
participantsHook.talkerAudioLevelsRef.current = {};
293+
participantsHook.resetTalkers();
293294
roomState.setIsJoining(false);
294295
roomState.setIsWaitingApproval(false);
295296
roomState.setIsPublished(false);

src/hooks/useConferenceEvents.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ type RoomState = {
6666
setIsReconnecting: (val: boolean) => void;
6767
publishStreamIdRef: React.MutableRefObject<string | null>;
6868
streamNameRef: React.MutableRefObject<string | null>;
69+
streamName: string | null;
6970
};
7071

7172
type ScreenShare = {
@@ -541,7 +542,14 @@ export const useConferenceEvents = (
541542
},
542543

543544
handleAudioLevel: (data: any) => {
544-
depsRef.current.participantsHook.updateTalkerLevel(data.userId, data.level.normalized);
545+
const { participantsHook, roomState } = depsRef.current;
546+
547+
// Our own level arrives under the publish stream id, but our tile is keyed by
548+
// stream name. Key talkers by tile id so consumers can match exactly.
549+
const isMine = data.userId === roomState.publishStreamIdRef.current;
550+
const talkerId = isMine ? roomState.streamName || data.userId : data.userId;
551+
552+
participantsHook.updateTalkerLevel(talkerId, data.level.normalized);
545553
},
546554

547555
handleSubscribeStop: (data: any) => {
@@ -790,10 +798,8 @@ export const useConferenceEvents = (
790798
return newSubscribed;
791799
});
792800

793-
// Remove audio level for disconnected participant
794-
const newTalkers = { ...participantsHook.talkerAudioLevelsRef.current };
795-
delete newTalkers[data.participant.uid];
796-
participantsHook.talkerAudioLevelsRef.current = newTalkers;
801+
// Drops audio level and speaking state for the disconnected participant
802+
participantsHook.clearParticipant(data.participant.uid);
797803
},
798804

799805
handleUserPublished: async (data: any) => {

src/hooks/useParticipants.ts

Lines changed: 96 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// hooks/useParticipants.ts
2-
import { useState, useRef, useCallback, MutableRefObject } from 'react';
2+
import { useState, useRef, useCallback, useEffect, MutableRefObject } from 'react';
33

44
// Type definitions
55
interface Participant {
@@ -43,6 +43,7 @@ interface UseParticipantsReturn {
4343
talkerAudioLevelsRef: MutableRefObject<TalkerAudioLevels>;
4444
pinnedParticipantIdRef: MutableRefObject<string | null>;
4545
updateTalkerLevel: (userId: string, level: number) => void;
46+
resetTalkers: () => void;
4647
clearParticipant: (streamId: string) => void;
4748
guestParticipantRequestList: string[];
4849
setGuestParticipantRequestList: React.Dispatch<React.SetStateAction<string[]>>;
@@ -52,6 +53,13 @@ interface UseParticipantsReturn {
5253
removeFakeParticipant: () => void;
5354
}
5455

56+
// One threshold, and a hold window so short dips between words don't blink the indicator.
57+
// A lower "still speaking" threshold does not work here: the level decays gradually after
58+
// speech stops, so it would keep re-arming the hold long after the person went quiet.
59+
const SPEAKING_LEVEL = 75;
60+
const SPEAKING_HOLD_MS = 800;
61+
const SPEAKING_SWEEP_MS = 100;
62+
5563
const FAKE_PARTICIPANT_NAMES = [
5664
'Alice',
5765
'Bob',
@@ -83,24 +91,76 @@ export const useParticipants = (): UseParticipantsReturn => {
8391
return participant?.name || participant?.streamName || 'Unknown';
8492
}, []);
8593

86-
const updateTalkers = useCallback((): void => {
87-
const updatedTalkers = Object.keys(talkerAudioLevelsRef.current).filter(
88-
(streamId) => talkerAudioLevelsRef.current[streamId] > 75,
89-
);
90-
setTalkers(updatedTalkers);
94+
// streamId -> timestamp of the last sample loud enough to count as speech
95+
const speakingSinceRef = useRef<Record<string, number>>({});
96+
const talkersRef = useRef<string[]>([]);
97+
const sweepTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
98+
99+
// Only push a new array when the set of talkers actually changed, so consumers
100+
// don't re-render on every audio-level event.
101+
const commitTalkers = useCallback((): void => {
102+
const next = Object.keys(speakingSinceRef.current);
103+
const prev = talkersRef.current;
104+
105+
if (next.length === prev.length && next.every((id) => prev.includes(id))) return;
106+
107+
talkersRef.current = next;
108+
setTalkers(next);
109+
}, []);
110+
111+
const stopSweep = useCallback((): void => {
112+
if (sweepTimerRef.current) {
113+
clearInterval(sweepTimerRef.current);
114+
sweepTimerRef.current = null;
115+
}
91116
}, []);
92117

118+
// Drops talkers whose hold window expired (also covers users that stop sending levels).
119+
const startSweep = useCallback((): void => {
120+
if (sweepTimerRef.current) return;
121+
122+
sweepTimerRef.current = setInterval(() => {
123+
const now = Date.now();
124+
let changed = false;
125+
126+
Object.keys(speakingSinceRef.current).forEach((streamId) => {
127+
if (now - speakingSinceRef.current[streamId] > SPEAKING_HOLD_MS) {
128+
delete speakingSinceRef.current[streamId];
129+
changed = true;
130+
}
131+
});
132+
133+
if (changed) commitTalkers();
134+
if (Object.keys(speakingSinceRef.current).length === 0) stopSweep();
135+
}, SPEAKING_SWEEP_MS);
136+
}, [commitTalkers, stopSweep]);
137+
93138
const updateTalkerLevel = useCallback(
94139
(userId: string, level: number): void => {
95-
talkerAudioLevelsRef.current = {
96-
...talkerAudioLevelsRef.current,
97-
[userId]: level,
98-
};
99-
updateTalkers();
140+
talkerAudioLevelsRef.current[userId] = level;
141+
142+
if (level < SPEAKING_LEVEL) return;
143+
144+
const wasSpeaking = userId in speakingSinceRef.current;
145+
speakingSinceRef.current[userId] = Date.now();
146+
147+
if (!wasSpeaking) {
148+
commitTalkers();
149+
startSweep();
150+
}
100151
},
101-
[updateTalkers],
152+
[commitTalkers, startSweep],
102153
);
103154

155+
const resetTalkers = useCallback((): void => {
156+
talkerAudioLevelsRef.current = {};
157+
speakingSinceRef.current = {};
158+
stopSweep();
159+
commitTalkers();
160+
}, [commitTalkers, stopSweep]);
161+
162+
useEffect(() => stopSweep, [stopSweep]);
163+
104164
const [guestParticipantRequestList, setGuestParticipantRequestList] = useState<string[]>([]);
105165
const [guestsWaitingApproval, setGuestsWaitingApproval] = useState<Participants>({});
106166

@@ -137,23 +197,29 @@ export const useParticipants = (): UseParticipantsReturn => {
137197
});
138198
}, []);
139199

140-
const clearParticipant = useCallback((streamId: string): void => {
141-
setParticipants((prev) => {
142-
const newParticipants = { ...prev };
143-
delete newParticipants[streamId];
144-
return newParticipants;
145-
});
146-
147-
setSubscribedParticipants((prev) => {
148-
const newSubscribed = { ...prev };
149-
delete newSubscribed[streamId];
150-
return newSubscribed;
151-
});
152-
153-
const newTalkers = { ...talkerAudioLevelsRef.current };
154-
delete newTalkers[streamId];
155-
talkerAudioLevelsRef.current = newTalkers;
156-
}, []);
200+
const clearParticipant = useCallback(
201+
(streamId: string): void => {
202+
setParticipants((prev) => {
203+
const newParticipants = { ...prev };
204+
delete newParticipants[streamId];
205+
return newParticipants;
206+
});
207+
208+
setSubscribedParticipants((prev) => {
209+
const newSubscribed = { ...prev };
210+
delete newSubscribed[streamId];
211+
return newSubscribed;
212+
});
213+
214+
delete talkerAudioLevelsRef.current[streamId];
215+
216+
if (streamId in speakingSinceRef.current) {
217+
delete speakingSinceRef.current[streamId];
218+
commitTalkers();
219+
}
220+
},
221+
[commitTalkers],
222+
);
157223

158224
return {
159225
participants,
@@ -173,6 +239,7 @@ export const useParticipants = (): UseParticipantsReturn => {
173239
talkerAudioLevelsRef,
174240
pinnedParticipantIdRef,
175241
updateTalkerLevel,
242+
resetTalkers,
176243
clearParticipant,
177244
guestsWaitingApproval,
178245
setGuestsWaitingApproval,

src/hooks/useSpeakerOrder.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { useMemo, useState } from 'react';
2+
3+
import { ParticipantObject } from '../pages/Layout/types.ts';
4+
import { isScreenShareParticipant, screenShareOwnerId } from '../utils/utils.tsx';
5+
6+
// Anyone speaking right now outranks anyone who only spoke earlier.
7+
const ACTIVE_SPEAKER_BOOST = Number.MAX_SAFE_INTEGER / 2;
8+
const NO_TALKERS: string[] = [];
9+
10+
interface UseSpeakerOrderOptions {
11+
allParticipants: ParticipantObject[];
12+
pinnedParticipantId?: string | null;
13+
talkers?: string[];
14+
}
15+
16+
interface SpeechHistory {
17+
talkers: string[];
18+
// Counter instead of a clock: only the relative order of turns matters.
19+
turn: number;
20+
lastTurn: Record<string, number>;
21+
}
22+
23+
const INITIAL_HISTORY: SpeechHistory = { talkers: NO_TALKERS, turn: 0, lastTurn: {} };
24+
25+
/**
26+
* Orders participants so the sidebar always shows who matters: the presenter first,
27+
* then whoever is talking, then the most recent speakers. Without this a speaker can
28+
* sit outside the few visible slots and appear to talk from nowhere.
29+
*/
30+
export const useSpeakerOrder = ({
31+
allParticipants,
32+
pinnedParticipantId,
33+
talkers = NO_TALKERS,
34+
}: UseSpeakerOrderOptions): { orderedParticipants: ParticipantObject[] } => {
35+
const [speech, setSpeech] = useState<SpeechHistory>(INITIAL_HISTORY);
36+
37+
// Stamp speakers during render. `talkers` keeps its identity until the set of
38+
// talkers actually changes, so this settles in one extra render.
39+
if (speech.talkers !== talkers) {
40+
const turn = speech.turn + 1;
41+
const lastTurn = { ...speech.lastTurn };
42+
talkers.forEach((streamId) => {
43+
lastTurn[streamId] = turn;
44+
});
45+
setSpeech({ talkers, turn, lastTurn });
46+
}
47+
48+
// The presenter is the owner of the screen share currently pinned as the main view.
49+
const presenterId = useMemo(() => {
50+
const pinned = allParticipants.find((p) => p.participant.uid === pinnedParticipantId);
51+
return isScreenShareParticipant(pinned?.participant)
52+
? screenShareOwnerId(pinned?.participant)
53+
: undefined;
54+
}, [allParticipants, pinnedParticipantId]);
55+
56+
const orderedParticipants = useMemo(() => {
57+
const talking = new Set(talkers);
58+
59+
const rankOf = (uid: string): number => {
60+
if (presenterId && uid === presenterId) return Number.MAX_SAFE_INTEGER;
61+
62+
const turn = speech.lastTurn[uid] ?? 0;
63+
return talking.has(uid) ? turn + ACTIVE_SPEAKER_BOOST : turn;
64+
};
65+
66+
// Array.sort is stable, so participants of equal rank keep their incoming order.
67+
return [...allParticipants].sort(
68+
(a, b) => rankOf(b.participant.uid) - rankOf(a.participant.uid),
69+
);
70+
}, [allParticipants, talkers, speech.lastTurn, presenterId]);
71+
72+
return { orderedParticipants };
73+
};

0 commit comments

Comments
 (0)