-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathParticipantGrid.tsx
More file actions
324 lines (291 loc) · 12.6 KB
/
Copy pathParticipantGrid.tsx
File metadata and controls
324 lines (291 loc) · 12.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
/* eslint-disable @typescript-eslint/naming-convention */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { TrackReferenceOrPlaceholder, VideoTrack, useIsSpeaking, useTracks } from '@livekit/components-react'
import MicOffIcon from '@mui/icons-material/MicOff'
import VideocamOffIcon from '@mui/icons-material/VideocamOff'
import { Track } from 'livekit-client'
import { getDisplayName, isPresentationBot } from '../../../features/cast2/cast2.utils'
import { useCastTranslation } from '../../../features/cast2/useCastTranslation'
import { Avatar } from '../Avatar/Avatar'
import { SpeakingIndicator } from '../LiveKitEnhancements/SpeakingIndicator'
import { ParticipantGridProps } from './ParticipantGrid.types'
import {
AvatarFallback,
FloatingVideoContainer,
LoadingSpinner,
LoadingText,
MutedIndicator,
NoParticipants,
NoParticipantsIcon,
OverflowAvatarStack,
OverflowCard,
OverflowCount,
ParticipantGridContainer,
ParticipantName,
ParticipantTileContainer,
SpeakingIndicatorWrapper,
ThumbnailGrid,
ThumbnailItem,
ThumbnailOverflowCard
} from './ParticipantGrid.styled'
const MAX_VISIBLE_PARTICIPANTS = 9
const MAX_THUMBNAILS = 1
function ParticipantGrid({ localParticipantVisible = true }: ParticipantGridProps) {
const { t } = useCastTranslation()
const [expandedTrackSid, setExpandedTrackSid] = useState<string | null>(null)
const [showAllParticipants, setShowAllParticipants] = useState(false)
const videoTracks = useTracks([Track.Source.Camera, Track.Source.ScreenShare], {
updateOnlyOn: []
})
const filteredTracks = useMemo(
() =>
localParticipantVisible
? videoTracks
: videoTracks.filter((track: TrackReferenceOrPlaceholder) => track.participant.isLocal === false),
[localParticipantVisible, videoTracks]
)
const finalTracks = useMemo(
() => filteredTracks.filter((track: TrackReferenceOrPlaceholder) => track.publication !== undefined),
[filteredTracks]
)
// Auto-focus a "spotlight" tile on its first appearance only, so manual tile selections by
// the user aren't snapped back on every rerender. Priority: presentation bot > screen share >
// (cameras render normally, never force-spotlighted). The latch fires once per spotlight-present
// cycle so a second share by another participant never steals focus.
//
// Presentations OUTRANK screen shares: a presentation that starts while a screen share is
// already spotlighted preempts it (the `hadPresentationRef` edge), and when that presentation
// ends the latch re-arms so a still-active screen share reclaims the spotlight in the same pass.
const autoExpandedRef = useRef(false)
const hadPresentationRef = useRef(false)
useEffect(() => {
const presentationTrack = finalTracks.find(t => isPresentationBot(t.participant))
const screenShareTrack = finalTracks.find(t => t.source === Track.Source.ScreenShare && !isPresentationBot(t.participant))
const focusTrack = presentationTrack ?? screenShareTrack
if (presentationTrack && !hadPresentationRef.current) {
// A presentation just started — preempt whatever is currently spotlighted.
setExpandedTrackSid(presentationTrack.participant.sid + presentationTrack.source)
autoExpandedRef.current = true
} else if (!presentationTrack && hadPresentationRef.current) {
// The presentation ended — re-arm so a still-active screen share reclaims the spotlight.
autoExpandedRef.current = false
}
hadPresentationRef.current = !!presentationTrack
if (focusTrack && !autoExpandedRef.current) {
setExpandedTrackSid(focusTrack.participant.sid + focusTrack.source)
autoExpandedRef.current = true
} else if (!focusTrack) {
autoExpandedRef.current = false
setExpandedTrackSid(prev => (prev && !finalTracks.some(t => t.participant.sid + t.source === prev) ? null : prev))
}
}, [finalTracks])
const participantCount = finalTracks.length
const isFullscreen = participantCount === 1
const hasMultipleParticipants = participantCount >= 2
const hasOverflow = participantCount > MAX_VISIBLE_PARTICIPANTS && !showAllParticipants
// Determine which tracks to display
const displayTracks = useMemo(() => {
if (!hasOverflow) return finalTracks
return finalTracks.slice(0, MAX_VISIBLE_PARTICIPANTS - 1)
}, [hasOverflow, finalTracks])
const overflowCount = finalTracks.length - displayTracks.length
const overflowAvatars = finalTracks.slice(displayTracks.length, displayTracks.length + 2)
const handleTileClick = useCallback(
(trackSid: string) => {
if (!hasMultipleParticipants) return
if (expandedTrackSid === trackSid) {
setExpandedTrackSid(null)
} else {
setExpandedTrackSid(trackSid)
}
},
[hasMultipleParticipants, expandedTrackSid]
)
const handleShowAll = useCallback(() => {
setShowAllParticipants(true)
}, [])
if (finalTracks.length === 0) {
return (
<ParticipantGridContainer $participantCount={0} $expandedView={false}>
<NoParticipants>
<NoParticipantsIcon>
<VideocamOffIcon />
</NoParticipantsIcon>
<div>{localParticipantVisible ? t('empty_state.no_video_streams') : t('empty_state.waiting_participants')}</div>
</NoParticipants>
</ParticipantGridContainer>
)
}
// When there are multiple participants and one is expanded
if (hasMultipleParticipants && expandedTrackSid) {
const expandedTrack = finalTracks.find(t => t.participant.sid + t.source === expandedTrackSid)
const otherTracks = finalTracks.filter(t => t.participant.sid + t.source !== expandedTrackSid)
const isPresentationExpanded = expandedTrack ? isPresentationBot(expandedTrack.participant) : false
// Check if we have more thumbnails than MAX_THUMBNAILS
// Only show overflow card if there are at least 2 more participants (+2 minimum)
const hasEnoughForOverflow = otherTracks.length > MAX_THUMBNAILS + 1
const hasThumbnailOverflow = hasEnoughForOverflow
const visibleThumbnails = hasThumbnailOverflow ? otherTracks.slice(0, MAX_THUMBNAILS) : otherTracks
const thumbnailOverflowCount = otherTracks.length - visibleThumbnails.length
const thumbnailOverflowAvatars = otherTracks.slice(visibleThumbnails.length, visibleThumbnails.length + 2)
return (
<ParticipantGridContainer $participantCount={participantCount} $expandedView={true}>
{expandedTrack && (
<ParticipantTile
trackRef={expandedTrack}
isFullscreen={true}
onClick={isPresentationExpanded ? undefined : () => handleTileClick(expandedTrack.participant.sid + expandedTrack.source)}
/>
)}
{/* Hide participant thumbnails during presentation — slides are the focus */}
{isPresentationExpanded ? null : otherTracks.length === 1 ? (
// Single floating video
<FloatingVideoContainer>
<ParticipantTile
trackRef={otherTracks[0]}
isFullscreen={false}
onClick={() => handleTileClick(otherTracks[0].participant.sid + otherTracks[0].source)}
/>
</FloatingVideoContainer>
) : (
// Multiple thumbnails in vertical grid
<ThumbnailGrid>
{visibleThumbnails.map(trackRef => (
<ThumbnailItem key={trackRef.participant.sid + trackRef.source}>
<ParticipantTile
trackRef={trackRef}
isFullscreen={false}
onClick={() => handleTileClick(trackRef.participant.sid + trackRef.source)}
/>
</ThumbnailItem>
))}
{hasThumbnailOverflow && (
<ThumbnailOverflowCard onClick={handleShowAll}>
<OverflowAvatarStack>
{thumbnailOverflowAvatars.map(track => (
<Avatar
key={track.participant.sid + track.source}
name={getDisplayName(track.participant)}
ethAddress={track.participant.identity}
size={50}
/>
))}
</OverflowAvatarStack>
<OverflowCount>+{thumbnailOverflowCount}</OverflowCount>
</ThumbnailOverflowCard>
)}
</ThumbnailGrid>
)}
</ParticipantGridContainer>
)
}
// Calculate the count to pass to the container (for grid layout)
const gridCount = hasOverflow ? MAX_VISIBLE_PARTICIPANTS : participantCount
return (
<ParticipantGridContainer $participantCount={gridCount} $expandedView={false}>
{displayTracks.map(trackRef => (
<ParticipantTile
key={trackRef.participant.sid + trackRef.source}
trackRef={trackRef}
isFullscreen={isFullscreen}
onClick={hasMultipleParticipants ? () => handleTileClick(trackRef.participant.sid + trackRef.source) : undefined}
/>
))}
{hasOverflow && (
<OverflowCard onClick={handleShowAll}>
<OverflowAvatarStack>
{overflowAvatars.map(track => (
<Avatar
key={track.participant.sid + track.source}
name={getDisplayName(track.participant)}
ethAddress={track.participant.identity}
size={50}
/>
))}
</OverflowAvatarStack>
<OverflowCount>+{overflowCount}</OverflowCount>
</OverflowCard>
)}
</ParticipantGridContainer>
)
}
function ParticipantTile({
trackRef,
isFullscreen = false,
onClick
}: {
trackRef: TrackReferenceOrPlaceholder
isFullscreen?: boolean
onClick?: () => void
}) {
const { t } = useCastTranslation()
const { participant, source, publication } = trackRef
const isScreenShare = source === Track.Source.ScreenShare
const isPresentation = isPresentationBot(participant)
// Only apply speaking indicator to camera, not screen share or presentation bot
const isSpeaking = useIsSpeaking(participant) && !isScreenShare && !isPresentation
// Get audio track for speaking indicator and muted state
const audioTrackRefs = useTracks([Track.Source.Microphone], {
updateOnlyOn: [],
onlySubscribed: false
})
const participantAudioTrack = audioTrackRefs.find(track => track.participant.identity === participant.identity)
const isMuted = participantAudioTrack ? participantAudioTrack.publication?.isMuted === true : true
// Mirror the video if it's the local participant's camera (not screen share)
const shouldMirror = participant.isLocal && source === Track.Source.Camera
// Check if video track is actually publishing (not muted/disabled)
// For camera tracks, also check if mediaStream is active (handles when camera is turned off)
const hasActiveVideo =
publication &&
publication.track &&
!publication.isMuted &&
(isScreenShare || !publication.track.mediaStream || publication.track.mediaStream.active)
// Check if track is initializing (readyState is 'live' when ready)
// Note: readyState might not be available on all track types
const isTrackInitializing =
publication && publication.track && 'readyState' in publication.track && publication.track.readyState !== 'live' && !publication.isMuted
// Only render if publication exists
if (!publication) {
return null
}
// Get display name: translated "Presentation" for bot, " - screen" suffix for screen share
const displayName = isPresentation
? t('streaming_controls.presentation')
: isScreenShare
? `${getDisplayName(participant)} - screen`
: getDisplayName(participant)
return (
<ParticipantTileContainer
$isSpeaking={isSpeaking}
$isFullscreen={isFullscreen}
$clickable={!!onClick}
$mirror={shouldMirror}
onClick={onClick}
>
{isTrackInitializing ? (
<AvatarFallback>
<LoadingSpinner />
<LoadingText>{t('streaming_controls.initializing_video')}</LoadingText>
</AvatarFallback>
) : hasActiveVideo ? (
<VideoTrack trackRef={trackRef} />
) : (
<AvatarFallback>
<Avatar name={displayName} ethAddress={participant.identity} size={120} />
</AvatarFallback>
)}
{!isScreenShare && !isPresentation && (
<SpeakingIndicatorWrapper>
<SpeakingIndicator participant={participant} trackRef={participantAudioTrack} />
</SpeakingIndicatorWrapper>
)}
{isMuted && !isScreenShare && !isPresentation && (
<MutedIndicator>
<MicOffIcon />
</MutedIndicator>
)}
<ParticipantName>{displayName}</ParticipantName>
</ParticipantTileContainer>
)
}
export { ParticipantGrid }