Skip to content

Commit 0061d3c

Browse files
rodrigokclaude
andcommitted
feat(video-conf): add a layout picker with grid, spotlight, and sidebar modes (NV-11)
A dropdown in the call controls lets users switch the video tile layout between grid (equal tiles), spotlight (active speaker fills the stage with a self-view PiP), and sidebar (active speaker large, others in a strip). The choice is persisted in localStorage across calls. Active speaker detection uses a single AudioContext with one AnalyserNode per participant, sampling at ~12 Hz with 1.5 s hysteresis to avoid flicker. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b34368a commit 0061d3c

6 files changed

Lines changed: 273 additions & 3 deletions

File tree

docs/features/video-conference-persistent-chat/README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,18 @@ The conference is a column: a row holding the call and the chat panel, then `Cal
213213

214214
The panel is docked to the inline end, so its close button sits at the far end of its header — matching every other closable surface in the product. Both panels share that header (`CallPanelHeader`, the contextual bar's own header/title/close), so two docked side by side can't disagree about where their own edges are.
215215

216+
### Stage layout
217+
218+
The call stage (`CallStage`) supports three layouts, cycled by a button in the control bar:
219+
220+
- **Grid** (default) — all participants in equal-sized tiles, rows/cols computed by `useTileGridLayout` to fill the stage within a [3:4 .. 16:9] aspect band.
221+
- **Spotlight** — the active speaker fills the stage; the local user's self-view floats as a small PiP in the bottom-right corner. When the local user *is* the active speaker, the first remote participant is shown large instead.
222+
- **Sidebar** — the active speaker is large on the left, all other participants are shown in a thumb column on the right (or row at the bottom on narrow stages), reusing the same structure as the screen-share spotlight.
223+
224+
Active speaker detection runs in `useActiveSpeakerId`: a single `AudioContext` with one `AnalyserNode` per participant, sampling at ~12 Hz. A 1.5 s hold prevents flickering between speakers during conversational pauses. When nobody is speaking, the fallback is the first remote participant.
225+
226+
When a screen share is active, the existing screen-share spotlight takes over regardless of the selected layout — the screen always wins.
227+
216228
The bar carries two counts: how many people are in the call, and what is unread in the chat while its panel is
217229
closed. The unread one goes through `useUnreadDisplay`, the sidebar's own rules, so a mention reads as urgent in
218230
both places and a muted room stays quiet in both. The members count is deliberately `secondary` — a count of who is
@@ -1002,6 +1014,7 @@ could not be loaded" panel, because the detail panel is contact-call-shaped.
10021014
| Conference model | `packages/models/src/models/VideoConference.ts` |
10031015
| Route + viewport | `apps/meteor/client/views/conference/ConferenceRoute.tsx`, `ConferenceViewport.tsx` |
10041016
| Call chrome | `apps/meteor/client/views/conference/ConferenceEmbeddedPage.tsx`, `ConferenceIframe.tsx`, `components/CallBar/`, `components/CallPanel/` |
1017+
| Stage layout + active speaker | `packages/ui-voip/src/views/MediaCallRoomSection/CallStage.tsx`, `MediaCallRoomSection.tsx`, `providers/useActiveSpeakerId.ts` |
10051018
| Chat panel | `apps/meteor/client/views/conference/ConferenceChat.tsx`, `ConferenceRoom.tsx`, `ConferenceThread.tsx`, `ConferenceThreadChat.tsx`, `ConferenceThreadModal.tsx`, `ConferenceStoresReady.tsx`, `CallPanelHeader.tsx`, `ConferenceChatNotShared.tsx` |
10061019
| Nothing to show | `apps/meteor/client/views/conference/ConferenceStatePage.tsx`, `ConferencePageError.tsx`, `ConferenceUnauthorizedPage.tsx` |
10071020
| Conference data | `apps/meteor/client/views/conference/hooks/useConferenceEmbedded.tsx` |
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { Box, RadioButton } from '@rocket.chat/fuselage';
2+
import { GenericMenu } from '@rocket.chat/ui-client';
3+
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
4+
5+
import { ActionButton } from '.';
6+
import type { StageLayout } from '../views/MediaCallRoomSection/CallStage';
7+
8+
const LAYOUTS: StageLayout[] = ['grid', 'spotlight', 'sidebar'];
9+
10+
const LAYOUT_ICONS: Record<StageLayout, string> = {
11+
grid: 'squares',
12+
spotlight: 'user',
13+
sidebar: 'stack',
14+
};
15+
16+
const LAYOUT_LABELS: Record<StageLayout, string> = {
17+
grid: 'Grid',
18+
spotlight: 'Spotlight',
19+
sidebar: 'Sidebar',
20+
};
21+
22+
type LayoutPickerProps = {
23+
layout: StageLayout;
24+
onLayoutChange: (layout: StageLayout) => void;
25+
};
26+
27+
const LayoutPicker = ({ layout, onLayoutChange }: LayoutPickerProps) => {
28+
const items: GenericMenuItemProps[] = LAYOUTS.map((l) => ({
29+
id: l,
30+
textValue: LAYOUT_LABELS[l],
31+
icon: LAYOUT_ICONS[l] as any,
32+
content: (
33+
<Box is='span' title={LAYOUT_LABELS[l]} fontSize={14}>
34+
{LAYOUT_LABELS[l]}
35+
</Box>
36+
),
37+
addon: <RadioButton checked={layout === l} readOnly />,
38+
}));
39+
40+
return (
41+
<GenericMenu
42+
title='Layout'
43+
items={items}
44+
placement='top-end'
45+
selectionMode='multiple'
46+
onAction={(id) => {
47+
if (typeof id === 'string' && LAYOUTS.includes(id as StageLayout)) {
48+
onLayoutChange(id as StageLayout);
49+
}
50+
}}
51+
button={<ActionButton secondary label='Layout' icon={LAYOUT_ICONS[layout] as any} />}
52+
/>
53+
);
54+
};
55+
56+
export default LayoutPicker;

packages/ui-voip/src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,6 @@ export { default as PeerAutocomplete } from './PeerAutocomplete';
1313
export { default as Timer } from './Timer';
1414
export { default as DevicePicker } from './DevicePicker';
1515
export { default as CameraPicker } from './CameraPicker';
16+
export { default as LayoutPicker } from './LayoutPicker';
1617
export { default as CallHistoryInternalUser } from './CallHistoryInternalUser';
1718
export { default as CallHistoryExternalUser } from './CallHistoryExternalUser';
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { useEffect, useRef, useState } from 'react';
2+
3+
const SAMPLE_INTERVAL_MS = 80;
4+
const SPEAKING_THRESHOLD = 0.12;
5+
// How long a speaker holds the "active" slot after they stop, to avoid
6+
// flickering between speakers during natural conversation pauses.
7+
const HOLD_MS = 1500;
8+
9+
/**
10+
* Tracks the dominant speaker across all participants by sampling audio levels
11+
* from a single shared AudioContext. Returns the participant id that is speaking
12+
* the loudest (above threshold), with hysteresis to avoid flicker.
13+
*
14+
* Falls back to `fallbackId` when nobody is speaking.
15+
*/
16+
export const useActiveSpeakerId = (
17+
participants: ReadonlyArray<{ id: string; audioStream?: MediaStream | null }>,
18+
fallbackId: string | null,
19+
): string | null => {
20+
const [activeSpeakerId, setActiveSpeakerId] = useState<string | null>(null);
21+
22+
// Stable identity for the participant list so the effect only re-runs when
23+
// the actual set of (id, stream) pairs changes — not on every render.
24+
const participantsKey = participants
25+
.map((p) => `${p.id}:${p.audioStream?.id ?? 'none'}`)
26+
.sort()
27+
.join(',');
28+
29+
const holdRef = useRef<{ id: string | null; changedAt: number }>({ id: null, changedAt: 0 });
30+
31+
useEffect(() => {
32+
const AC: typeof AudioContext | undefined = (window as any).AudioContext || (window as any).webkitAudioContext;
33+
if (!AC) return undefined;
34+
35+
const ctx = new AC();
36+
const sources: Array<{ id: string; source: MediaStreamAudioSourceNode; analyser: AnalyserNode }> = [];
37+
38+
for (const p of participants) {
39+
if (!p.audioStream || typeof p.audioStream.getAudioTracks !== 'function') continue;
40+
const tracks = p.audioStream.getAudioTracks();
41+
if (!tracks.length) continue;
42+
43+
const source = ctx.createMediaStreamSource(p.audioStream);
44+
const analyser = ctx.createAnalyser();
45+
analyser.fftSize = 512;
46+
analyser.smoothingTimeConstant = 0.5;
47+
source.connect(analyser);
48+
sources.push({ id: p.id, source, analyser });
49+
}
50+
51+
if (sources.length === 0) {
52+
setActiveSpeakerId(null);
53+
return () => void ctx.close().catch(() => undefined);
54+
}
55+
56+
const buf = new Uint8Array(512);
57+
let cancelled = false;
58+
let rafId = 0;
59+
let lastUpdate = 0;
60+
61+
const tick = (ts: number) => {
62+
if (cancelled) return;
63+
if (ts - lastUpdate >= SAMPLE_INTERVAL_MS) {
64+
let maxLevel = 0;
65+
let maxId: string | null = null;
66+
67+
for (const { id, analyser } of sources) {
68+
analyser.getByteTimeDomainData(buf);
69+
let sumSq = 0;
70+
for (let i = 0; i < buf.length; i++) {
71+
const v = (buf[i] - 128) / 128;
72+
sumSq += v * v;
73+
}
74+
const rms = Math.sqrt(sumSq / buf.length);
75+
const level = Math.min(1, rms * 4);
76+
if (level > maxLevel && level > SPEAKING_THRESHOLD) {
77+
maxLevel = level;
78+
maxId = id;
79+
}
80+
}
81+
82+
const now = Date.now();
83+
const hold = holdRef.current;
84+
85+
if (maxId && maxId !== hold.id && (now - hold.changedAt > HOLD_MS || !hold.id)) {
86+
hold.id = maxId;
87+
hold.changedAt = now;
88+
setActiveSpeakerId(maxId);
89+
}
90+
91+
lastUpdate = ts;
92+
}
93+
rafId = requestAnimationFrame(tick);
94+
};
95+
rafId = requestAnimationFrame(tick);
96+
97+
return () => {
98+
cancelled = true;
99+
cancelAnimationFrame(rafId);
100+
for (const { source } of sources) {
101+
try {
102+
source.disconnect();
103+
} catch {
104+
// may already be disconnected
105+
}
106+
}
107+
void ctx.close().catch(() => undefined);
108+
};
109+
// eslint-disable-next-line react-hooks/exhaustive-deps
110+
}, [participantsKey]);
111+
112+
return activeSpeakerId ?? fallbackId;
113+
};

packages/ui-voip/src/views/MediaCallRoomSection/CallStage.tsx

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
44

55
import CallTile from './CallTile';
66
import type { RemoteParticipantInfo } from '../../context/MediaCallViewContext';
7+
import { useActiveSpeakerId } from '../../providers/useActiveSpeakerId';
78
import { usePlayMediaStream } from '../../providers/usePlayMediaStream';
89
import { useTileGridLayout } from '../../providers/useTileGridLayout';
910

@@ -20,12 +21,16 @@ type LocalParticipant = {
2021
audioStream?: MediaStream | null;
2122
};
2223

24+
export type StageLayout = 'grid' | 'spotlight' | 'sidebar';
25+
2326
type CallStageProps = {
2427
localParticipant: LocalParticipant;
2528
remoteParticipants: RemoteParticipantInfo[];
2629
onStopLocalScreenShare?: () => void;
2730
/** Map from participantId → 1-based queue position for the raise-hand badge. */
2831
handPositions?: Record<string, number>;
32+
/** Which layout to use when no screen share is active. Defaults to `'grid'`. */
33+
layout?: StageLayout;
2934
};
3035

3136
const stageStyles = css`
@@ -213,6 +218,19 @@ const ownBadgeStyles = css`
213218
pointer-events: none;
214219
`;
215220

221+
// Spotlight layout: active speaker fills the stage, local user floats in the corner.
222+
const spotlightSelfPipStyles = css`
223+
position: absolute;
224+
bottom: 16px;
225+
right: 16px;
226+
width: 180px;
227+
aspect-ratio: 16 / 9;
228+
border-radius: 8px;
229+
overflow: hidden;
230+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
231+
z-index: 2;
232+
`;
233+
216234
type ScreenViewerProps = {
217235
stream: MediaStream;
218236
label: string;
@@ -273,7 +291,7 @@ const ScreenShareThumb = ({
273291
};
274292

275293
// eslint-disable-next-line react/no-multi-comp
276-
const CallStage = ({ localParticipant, remoteParticipants, onStopLocalScreenShare, handPositions }: CallStageProps) => {
294+
const CallStage = ({ localParticipant, remoteParticipants, onStopLocalScreenShare, handPositions, layout = 'grid' }: CallStageProps) => {
277295
// All currently-active screen shares (local + remote), in a stable shape
278296
// the rest of the component consumes. Re-derived each render from the
279297
// participants list; tracking of "when did each share start" lives in a
@@ -373,6 +391,18 @@ const CallStage = ({ localParticipant, remoteParticipants, onStopLocalScreenShar
373391
return all;
374392
}, [localParticipant, remoteParticipants, handPositions]);
375393

394+
// Active speaker: used by spotlight and sidebar layouts to decide which
395+
// participant gets the large view. Falls back to the first remote
396+
// participant when nobody is speaking.
397+
const audioParticipants = useMemo(
398+
() => [
399+
{ id: localParticipant.id, audioStream: localParticipant.audioStream },
400+
...remoteParticipants.map((p) => ({ id: p.id, audioStream: p.audioStream })),
401+
],
402+
[localParticipant.id, localParticipant.audioStream, remoteParticipants],
403+
);
404+
const activeSpeakerId = useActiveSpeakerId(audioParticipants, remoteParticipants[0]?.id ?? localParticipant.id);
405+
376406
// IMPORTANT: hooks must run unconditionally on every render. Both the
377407
// grid layout hook and its companion ref live above any conditional
378408
// return — when a screen share starts mid-call featuredScreen flips
@@ -448,6 +478,58 @@ const CallStage = ({ localParticipant, remoteParticipants, onStopLocalScreenShar
448478
);
449479
}
450480

481+
// Spotlight layout: active speaker fills the stage, local user's self-view
482+
// floats as a small PiP in the bottom-right corner.
483+
if (layout === 'spotlight') {
484+
const featured = tiles.find((t) => t.id === activeSpeakerId) ?? tiles[0];
485+
const selfTile = tiles.find((t) => t.id === localParticipant.id);
486+
// When the local user is the active speaker, show the first remote instead.
487+
const mainTile = featured.id === localParticipant.id && tiles.length > 1 ? tiles.find((t) => t.id !== localParticipant.id)! : featured;
488+
return (
489+
<Box className={stageStyles}>
490+
<Box display='flex' width='full' height='full' position='relative'>
491+
<Box className={mainStreamStyles}>
492+
<CallTile {...mainTile} />
493+
</Box>
494+
{selfTile && selfTile.id !== mainTile.id && (
495+
<Box className={spotlightSelfPipStyles}>
496+
<CallTile {...selfTile} compact />
497+
</Box>
498+
)}
499+
</Box>
500+
</Box>
501+
);
502+
}
503+
504+
// Sidebar layout: active speaker large on the left, everyone else in a
505+
// thumb column on the right — the same structure as the screen-share
506+
// spotlight, but with a camera feed instead of a screen.
507+
if (layout === 'sidebar') {
508+
const isSideBySide = spotlightOrientation === 'side-by-side';
509+
const featured = tiles.find((t) => t.id === activeSpeakerId) ?? tiles[0];
510+
const others = tiles.filter((t) => t.id !== featured.id);
511+
return (
512+
<Box className={stageStyles} ref={stageRefCallback}>
513+
<Box className={isSideBySide ? spotlightSideBySideStyles : spotlightStackedStyles}>
514+
<Box className={mainStreamStyles}>
515+
<CallTile {...featured} />
516+
</Box>
517+
{others.length > 0 && (
518+
<Box className={isSideBySide ? thumbColumnStyles : thumbStripStyles} data-thumb-orientation={isSideBySide ? 'column' : 'row'}>
519+
{others.map((t) => (
520+
<Box key={t.id} className={isSideBySide ? thumbItemColumnStyles : thumbItemStyles}>
521+
<CallTile {...t} compact />
522+
</Box>
523+
))}
524+
</Box>
525+
)}
526+
</Box>
527+
</Box>
528+
);
529+
}
530+
531+
// Grid layout (default): all participants in equal-sized tiles.
532+
451533
// When the last row has fewer tiles than `cols`, we center the orphans at
452534
// their natural single-column width using `gridColumnStart` — never spanning.
453535
// An earlier version expanded each orphan to fill remaining columns, which

packages/ui-voip/src/views/MediaCallRoomSection/MediaCallRoomSection.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { css } from '@rocket.chat/css-in-js';
22
import { Box, ButtonGroup } from '@rocket.chat/fuselage';
3+
import { useLocalStorage } from '@rocket.chat/fuselage-hooks';
34
import { memo, useEffect, useMemo, useRef, useState } from 'react';
45
import { createPortal } from 'react-dom';
56
import { useTranslation } from 'react-i18next';
67

78
import CallReactions, { type CallReaction } from './CallReactions';
8-
import CallStage from './CallStage';
9-
import { ToggleButton, Timer, DevicePicker, CameraPicker, ActionButton, ActionStrip, ActionToggleChat } from '../../components';
9+
import CallStage, { type StageLayout } from './CallStage';
10+
import { ToggleButton, Timer, DevicePicker, CameraPicker, LayoutPicker, ActionButton, ActionStrip, ActionToggleChat } from '../../components';
1011
import { useMediaCallInstance } from '../../context/MediaCallInstanceContext';
1112
import type { RemoteParticipantInfo } from '../../context/MediaCallViewContext';
1213
import { useMediaCallView } from '../../context/MediaCallViewContext';
@@ -224,6 +225,8 @@ const MediaCallRoomSection = ({
224225
const [reactionPickerOpen, setReactionPickerOpen] = useState(false);
225226
const reactionPickerRef = useRef<HTMLDivElement>(null);
226227

228+
const [stageLayout, setStageLayout] = useLocalStorage<StageLayout>('videoconf-stage-layout', 'grid');
229+
227230
// Click-outside dismiss for the reaction popover. Stays open while the
228231
// user clicks emojis inside it (so they can send several in a row), but
229232
// closes when they click anywhere else on the page.
@@ -389,6 +392,7 @@ const MediaCallRoomSection = ({
389392
onToggle={onToggleHand}
390393
/>
391394
)}
395+
{isLiveKitCall && <LayoutPicker layout={stageLayout} onLayoutChange={setStageLayout} />}
392396
{onSendReaction && (
393397
<Box className={reactionPickerWrapStyles} ref={reactionPickerRef}>
394398
<ToggleButton
@@ -462,6 +466,7 @@ const MediaCallRoomSection = ({
462466
remoteParticipants={remoteParticipants}
463467
onStopLocalScreenShare={onToggleScreenSharing}
464468
handPositions={handPositions}
469+
layout={stageLayout}
465470
/>
466471
<CallReactions reactions={reactions} />
467472
</Box>

0 commit comments

Comments
 (0)