@tribe-nest/forge 3.42.1 → 3.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/package.json +2 -2
  2. package/src/data/queries/useCallChat.ts +162 -0
  3. package/src/data/queries/useCallRecording.ts +80 -0
  4. package/src/data/queries/useMembershipCalls.ts +212 -0
  5. package/src/i18n/de.json +74 -1
  6. package/src/i18n/en.json +74 -1
  7. package/src/index.ts +7 -0
  8. package/src/types/models.ts +58 -5
  9. package/src/ui/headless/memberHome/index.ts +4 -0
  10. package/src/ui/headless/memberHome/useMemberCalls.ts +150 -0
  11. package/src/ui/headless/memberHome/useMemberHomeNav.ts +29 -5
  12. package/src/ui/index.ts +3 -0
  13. package/src/ui/media/CallStage.tsx +691 -553
  14. package/src/ui/media/PreJoin.tsx +269 -0
  15. package/src/ui/media/_tests/CallStage.spec.tsx +338 -5
  16. package/src/ui/media/_tests/callLayout.spec.ts +182 -0
  17. package/src/ui/media/_tests/callSignals.spec.ts +87 -0
  18. package/src/ui/media/_tests/callState.spec.ts +93 -1
  19. package/src/ui/media/_tests/fakeNode.ts +4 -1
  20. package/src/ui/media/callChat.ts +33 -0
  21. package/src/ui/media/callState.ts +125 -9
  22. package/src/ui/media/hooks/useCallSignals.ts +135 -0
  23. package/src/ui/media/hooks/useMeasuredWidth.ts +39 -0
  24. package/src/ui/media/index.ts +41 -1
  25. package/src/ui/media/panels/CallChatPanel.tsx +200 -0
  26. package/src/ui/media/panels/CallParticipantsPanel.tsx +125 -0
  27. package/src/ui/media/panels/CallSidePanel.tsx +152 -0
  28. package/src/ui/media/stage/CallCenterNotice.tsx +89 -0
  29. package/src/ui/media/stage/CallControlsBar.tsx +547 -0
  30. package/src/ui/media/stage/CallHealthPanel.tsx +132 -0
  31. package/src/ui/media/stage/CallToasts.tsx +49 -0
  32. package/src/ui/media/stage/CallTopBar.tsx +257 -0
  33. package/src/ui/media/stage/DeviceMenu.tsx +142 -0
  34. package/src/ui/media/stage/ReactionOverlay.tsx +38 -0
  35. package/src/ui/media/stage/SelfView.tsx +146 -0
  36. package/src/ui/media/stage/VideoTile.tsx +232 -0
  37. package/src/ui/media/stage/callLayout.ts +253 -0
  38. package/src/ui/media/stage/callSignals.ts +91 -0
  39. package/src/ui/media/stage/callSounds.ts +78 -0
  40. package/src/ui/media/stage/stageTheme.ts +50 -0
  41. package/src/ui/styled/CohortPage.tsx +15 -1
  42. package/src/ui/styled/CourseAccess.tsx +19 -6
  43. package/src/ui/styled/CourseCallsSection.tsx +99 -0
  44. package/src/ui/styled/FormRenderer.tsx +14 -4
  45. package/src/ui/styled/_tests/CourseCallsSection.spec.tsx +163 -0
  46. package/src/ui/styled/forge-utilities.css +13 -0
  47. package/src/ui/styled/members/MemberCallsCalendar.tsx +589 -0
  48. package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +135 -0
  49. package/src/ui/styled/members/index.ts +1 -0
@@ -1,20 +1,8 @@
1
- import { useCallback, useEffect, useMemo, useRef, type CSSProperties, type ReactNode } from "react";
2
- import {
3
- AlertTriangle,
4
- Loader2,
5
- Mic,
6
- MicOff,
7
- MonitorUp,
8
- MonitorX,
9
- PhoneOff,
10
- RefreshCw,
11
- Video,
12
- VideoOff,
13
- } from "lucide-react";
1
+ import { useCallback, useEffect, useMemo, useReducer, useRef, useState, type ReactNode } from "react";
2
+ import { ChevronLeft, ChevronRight } from "lucide-react";
14
3
 
15
4
  import {
16
5
  useActiveSpeakers,
17
- useGrants,
18
6
  useLocalMedia,
19
7
  useLocalPublications,
20
8
  useMediaRoom,
@@ -22,107 +10,195 @@ import {
22
10
  useRecording,
23
11
  useRemoteTrack,
24
12
  useRoomState,
13
+ useSpeakers,
14
+ useTileViewport,
25
15
  useVisibleProducers,
26
- type LocalSource,
27
16
  } from "@tribe-nest/media-client/react";
28
17
 
29
18
  import { useForgeT, type ForgeT } from "../../i18n";
30
- import { useThemeTokens, type ResolvedThemeTokens } from "../theme/ForgeThemeProvider";
31
- import { CallHelpHint } from "./CallHelpHint";
19
+ import { useThemeTokens } from "../theme/ForgeThemeProvider";
20
+ import type { CallStageChat } from "./callChat";
32
21
  import {
33
22
  callHeadCount,
34
23
  callStatus,
35
24
  callTiles,
36
- canPublishSource,
37
25
  remoteAudioProducerIds,
38
- type CallStatus,
26
+ stageNotice,
39
27
  type CallTile,
40
28
  } from "./callState";
29
+ import { useFlyingReactions, useRaisedHands } from "./hooks/useCallSignals";
30
+ import { CallSidePanel, type SidePanelTab } from "./panels/CallSidePanel";
31
+ import { resolveLayout, GALLERY_PAGE_SIZE, pickDominant, type StageLayout, type ViewChoice } from "./stage/callLayout";
32
+ import { diffPeers, unreadCount } from "./stage/callSignals";
33
+ import { createCallSounds } from "./stage/callSounds";
34
+ import { CallCenterNotice } from "./stage/CallCenterNotice";
35
+ import { CallControlsBar } from "./stage/CallControlsBar";
36
+ import { CallToasts, type CallToast } from "./stage/CallToasts";
37
+ import { useMeasuredWidth, STAGE_NARROW_MAX_PX } from "./hooks/useMeasuredWidth";
38
+ import { CallHealthPanel } from "./stage/CallHealthPanel";
39
+ import { CallTopBar } from "./stage/CallTopBar";
40
+ import { ReactionOverlay } from "./stage/ReactionOverlay";
41
+ import { SelfView, type SelfViewCorner } from "./stage/SelfView";
42
+ import { stageTokens, type StageTokens } from "./stage/stageTheme";
43
+ import { VideoTile } from "./stage/VideoTile";
41
44
 
42
45
  /**
43
- * The call, as a screen. One implementation, both surfaces.
44
- *
45
- * Everything above this file was reachable only from a test: the room, the
46
- * hooks and `BookingCallProvider` all existed and nothing rendered a call, so
47
- * no human could join one. This is the part that makes it reachable, and it is
48
- * built ONCE here because there are two rendering surfaces (admin, and code
49
- * websites through Forge) and a second implementation would mean every fix to
50
- * the ordering rules had to be made twice. The second copy is always the one
51
- * that drifts.
46
+ * The call, as a screen. One implementation, three rendering surfaces.
52
47
  *
53
- * ## Where the colours come from
48
+ * This is the ORCHESTRATOR: the decisions live in the pure modules
49
+ * (`callState`, `stage/callLayout`, `stage/callSignals`) and the drawing in
50
+ * the components under `stage/` and `panels/`. What stays here is wiring the
51
+ * two together and the one `useReducer` of UI state.
54
52
  *
55
- * `useThemeTokens()`, which on a code website is the creator's own
56
- * `themeSettings` fed through `<ForgeThemeProvider>`, and in admin is the
57
- * dashboard's own tokens fed through the same provider. Not one colour is
58
- * written down in this file. A call screen with a hard-coded palette is a call
59
- * screen that looks like somebody else's product on every site that installs
60
- * it.
53
+ * ## The stage is dark on purpose
61
54
  *
62
- * ## Where the words come from
55
+ * `stage/stageTheme.ts` is the ONE sanctioned exception to theme-only
56
+ * colors: near-black chrome from constants, every accent from the theme.
63
57
  *
64
- * `useForgeT()`, like every other Forge component, with keys under
65
- * `forge.call_stage.*`. `callStatus` returns KEYS for the same reason it is
66
- * pure: it has no locale. The one thing rendered verbatim is the server's own
67
- * sentence (a refusal, a close reason), because a friendlier translation of a
68
- * refusal is a lie about why you were refused. Admin renders this same
69
- * component inside `<ForgeI18nProvider>` with the dashboard's locale; outside
70
- * any provider the hook answers in English, so a site that has not mounted one
71
- * still gets a working call.
58
+ * ## Words come from `useForgeT()`
72
59
  *
73
- * ## What it does NOT decide
60
+ * Keys under `forge.call_stage.*`. The one thing rendered verbatim is the
61
+ * server's own sentence (a refusal, a close reason).
74
62
  *
75
- * Whether a control may be used. `useGrants()` says what the node minted this
76
- * token for, and it is read here so a button that would be refused is not
77
- * offered; the node checks the same thing on the publish itself and its answer
78
- * is the only one that counts. Hiding a control is courtesy, never enforcement.
63
+ * ## Mount it behind PreJoin (or another Join control)
79
64
  *
80
- * ## Mount it behind a Join control
81
- *
82
- * The provider connects on mount and closes on unmount. Rendering the whole
83
- * thing inside a list of sessions would open a media connection for every row.
65
+ * The provider connects on mount and closes on unmount.
84
66
  */
85
67
 
68
+ export type CallStageRecording = {
69
+ isRecording: boolean;
70
+ canControl: boolean;
71
+ /** A request is in flight. Holds the button rather than letting it be pressed twice. */
72
+ pending?: boolean;
73
+ onStart: () => void;
74
+ onStop: () => void;
75
+ };
76
+
77
+ export type CallStageMediaDefaults = {
78
+ audioDeviceId?: string;
79
+ videoDeviceId?: string;
80
+ joinWithMicOn?: boolean;
81
+ joinWithCameraOn?: boolean;
82
+ };
83
+
86
84
  export type CallStageProps = {
87
- /**
88
- * Called once the local participant has left. The host is expected to unmount
89
- * the provider: this component closes the room, but a provider left mounted
90
- * would reconnect on the next render.
91
- */
85
+ /** Called once the local participant has left. Unmount the provider. */
92
86
  onLeave?: () => void;
93
- /** Whose call this is. Rendered above the grid. */
87
+ /** Whose call this is. Rendered in the top bar. */
94
88
  title?: ReactNode;
95
- /** Ratio for each tile. Defaults to 16 / 9. */
89
+ /** Ratio for gallery tiles. Defaults to 16 / 9. */
96
90
  aspectRatio?: number;
91
+ /** The host's start/stop control, when the page offers one. */
92
+ recordingControl?: CallStageRecording;
93
+ /**
94
+ * The chat adapter, injected like `recordingControl`: the surfaces
95
+ * authenticate the platform socket differently and the stage must not know
96
+ * how. Absent means no chat tab or button is drawn.
97
+ */
98
+ chat?: CallStageChat;
99
+ /** Rendering hint for host courtesies (lower-hand). The server enforces
100
+ * every real host action itself. */
101
+ isHost?: boolean;
102
+ /** From PreJoin: which devices to open, and whether to open them at all. */
103
+ mediaDefaults?: CallStageMediaDefaults;
104
+ /** Opt call cameras into simulcast (group calls). Wrong for a 1:1, where
105
+ * one encoding carries and extra layers are encoder time spent on nobody. */
106
+ cameraSimulcast?: boolean;
107
+ };
108
+
109
+ type StageUiState = {
110
+ view: ViewChoice;
111
+ page: number;
112
+ pinnedKey: string | null;
113
+ panel: SidePanelTab | null;
114
+ selfHidden: boolean;
115
+ selfCorner: SelfViewCorner;
116
+ lastSeenMessageId: string | null;
117
+ };
118
+
119
+ type StageUiAction =
120
+ | { type: "view"; view: ViewChoice }
121
+ | { type: "page"; delta: number }
122
+ | { type: "pin"; key: string | null }
123
+ | { type: "panel"; panel: SidePanelTab | null }
124
+ | { type: "selfHidden"; hidden: boolean }
125
+ | { type: "selfCorner"; corner: SelfViewCorner }
126
+ | { type: "seen"; messageId: string | null };
127
+
128
+ const initialUi: StageUiState = {
129
+ view: "gallery",
130
+ page: 0,
131
+ pinnedKey: null,
132
+ panel: null,
133
+ selfHidden: false,
134
+ selfCorner: "br",
135
+ lastSeenMessageId: null,
97
136
  };
98
137
 
99
- export function CallStage({ onLeave, title, aspectRatio = 16 / 9 }: CallStageProps) {
100
- const tokens = useThemeTokens();
138
+ function uiReducer(state: StageUiState, action: StageUiAction): StageUiState {
139
+ switch (action.type) {
140
+ case "view":
141
+ // Changing how you look resets where you were looking.
142
+ return { ...state, view: action.view, page: 0, pinnedKey: null };
143
+ case "page":
144
+ return { ...state, page: Math.max(0, state.page + action.delta) };
145
+ case "pin":
146
+ return { ...state, pinnedKey: action.key, page: 0 };
147
+ case "panel":
148
+ return { ...state, panel: action.panel };
149
+ case "selfHidden":
150
+ return { ...state, selfHidden: action.hidden };
151
+ case "selfCorner":
152
+ return { ...state, selfCorner: action.corner };
153
+ case "seen":
154
+ return { ...state, lastSeenMessageId: action.messageId };
155
+ }
156
+ }
157
+
158
+ export function CallStage({
159
+ onLeave,
160
+ title,
161
+ aspectRatio = 16 / 9,
162
+ recordingControl,
163
+ chat,
164
+ isHost = false,
165
+ mediaDefaults,
166
+ cameraSimulcast = false,
167
+ }: CallStageProps) {
168
+ const themeTokens = useThemeTokens();
169
+ const tokens = useMemo(() => stageTokens(themeTokens), [themeTokens]);
101
170
  const t = useForgeT();
102
171
  const { connectionState, error, recovering, retry } = useMediaRoom();
103
172
  const state = useRoomState();
104
173
  const peers = useParticipants();
105
174
  const activeSpeakers = useActiveSpeakers();
106
175
  const recording = useRecording();
107
- /**
108
- * The barrier, applied. NOT `state.producers`.
109
- *
110
- * The reducer keeps every producer the node has ever announced so a subscribe
111
- * rule that narrows and later widens is reversible, which means the raw list
112
- * can hold people this seat has been barred from seeing. On a sealed room
113
- * their presence is most of what the barrier was hiding, and a tile carries
114
- * their identity.
115
- */
176
+ // The barrier, applied: NOT `state.producers`. The raw list can hold people
177
+ // this seat is barred from, and a tile carries their identity.
116
178
  const producers = useVisibleProducers();
179
+ const publications = useLocalPublications();
180
+ const speakers = useSpeakers();
181
+ const { observeTile } = useTileViewport();
182
+ const hands = useRaisedHands();
183
+ const flying = useFlyingReactions();
184
+ const media = useLocalMedia();
185
+
186
+ const [ui, dispatch] = useReducer(uiReducer, initialUi);
117
187
 
118
188
  const status = useMemo(
119
189
  () => callStatus({ connectionState, error, recovering, phase: state.phase, closedReason: state.closedReason }),
120
190
  [connectionState, error, recovering, state.phase, state.closedReason],
121
191
  );
122
192
 
123
- // Memoised on the room's own snapshots, every one of which is a stable
124
- // reference while nothing changed. Rebuilding these per render is what turns
125
- // a call screen into a component that re-renders per FRAME.
193
+ // Everyone by name, INCLUDING self as "You" - the peer list is other people
194
+ // only, so without this a person's own raised hand (and their own tile in
195
+ // the health panel) read as a raw identity, while everyone ELSE saw their
196
+ // name. Matches the self-view's own "You" label.
197
+ const peerNames = useMemo(() => {
198
+ const map = new Map(peers.map((peer) => [peer.identity, peer.name ?? peer.identity]));
199
+ if (state.identity) map.set(state.identity, t("forge.call_stage.you"));
200
+ return map;
201
+ }, [peers, state.identity, t]);
126
202
  const tiles = useMemo(
127
203
  () => callTiles({ identity: state.identity, peers, producers, activeSpeakers }),
128
204
  [state.identity, peers, producers, activeSpeakers],
@@ -132,560 +208,622 @@ export function CallStage({ onLeave, title, aspectRatio = 16 / 9 }: CallStagePro
132
208
  [state.identity, producers],
133
209
  );
134
210
 
211
+ const remoteCount = useMemo(() => new Set(tiles.map((tile) => tile.identity)).size, [tiles]);
212
+ const everSawPeersRef = useRef(false);
213
+ if (remoteCount > 0) everSawPeersRef.current = true;
214
+ const notice = useMemo(
215
+ () => stageNotice({ status, remoteCount, everSawPeers: everSawPeersRef.current }),
216
+ [status, remoteCount],
217
+ );
218
+
219
+ // Dominant speaker with a hold, so the rail and speaker view do not flap.
220
+ const [dominant, setDominant] = useState<{ identity: string | null; since: number }>({ identity: null, since: 0 });
221
+ useEffect(() => {
222
+ setDominant((previous) =>
223
+ pickDominant({ speakers, previous: previous.identity, previousSince: previous.since, now: Date.now() }),
224
+ );
225
+ }, [speakers]);
226
+
227
+ const localPresenting = useMemo(() => publications.some((p) => p.source === "screen"), [publications]);
228
+
229
+ // The stage's OWN width, not the window's: it embeds inside pages and modals.
230
+ // Below the breakpoint the tiles stack as portraits and the side panel folds
231
+ // into a full-screen overlay, so nothing is crushed to a sliver on a phone.
232
+ const [stageRef, stageWidth] = useMeasuredWidth<HTMLDivElement>();
233
+ const narrow = stageWidth > 0 && stageWidth < STAGE_NARROW_MAX_PX;
234
+
235
+ const layout = useMemo(
236
+ () =>
237
+ resolveLayout({
238
+ tiles,
239
+ view: ui.view,
240
+ page: ui.page,
241
+ pinnedKey: ui.pinnedKey,
242
+ dominantIdentity: dominant.identity,
243
+ localPresenting,
244
+ narrow,
245
+ pageSize: GALLERY_PAGE_SIZE,
246
+ }),
247
+ [tiles, ui.view, ui.page, ui.pinnedKey, dominant.identity, localPresenting, narrow],
248
+ );
249
+
250
+ // Chimes: unlocked by any control press, played on arrivals and departures.
251
+ const sounds = useMemo(() => createCallSounds(), []);
252
+ const unlockSounds = useCallback(() => sounds.unlock(), [sounds]);
253
+
254
+ // A hand going up rings for EVERYONE: each client watches the shared
255
+ // raised-hands state and chimes when it gains an identity. Seeded with the
256
+ // current set so the first render and a reconnect's snapshot do not fire a
257
+ // burst for hands that were already up.
258
+ const previousHandIdsRef = useRef(new Set(hands.hands.map((h) => h.identity)));
259
+ useEffect(() => {
260
+ const now = new Set(hands.hands.map((h) => h.identity));
261
+ let raised = false;
262
+ for (const id of now) if (!previousHandIdsRef.current.has(id)) raised = true;
263
+ previousHandIdsRef.current = now;
264
+ if (raised) sounds.raiseHand();
265
+ }, [hands.hands, sounds]);
266
+
267
+ // Join/leave toasts from the peer snapshot, collapsed under bursts.
268
+ const [toasts, setToasts] = useState<readonly CallToast[]>([]);
269
+ const previousPeersRef = useRef(peers);
270
+ const toastCounter = useRef(0);
271
+ useEffect(() => {
272
+ const changes = diffPeers(previousPeersRef.current, peers);
273
+ previousPeersRef.current = peers;
274
+ if (changes.length === 0) return;
275
+ if (changes.some((c) => c.kind === "joined")) sounds.join();
276
+ if (changes.some((c) => c.kind === "left")) sounds.leave();
277
+ const texts =
278
+ changes.length > 3
279
+ ? [t("forge.call_stage.many_joined", { count: changes.length })]
280
+ : changes.map((c) =>
281
+ c.kind === "joined"
282
+ ? t("forge.call_stage.peer_joined", { name: c.name })
283
+ : t("forge.call_stage.peer_left", { name: c.name }),
284
+ );
285
+ const added: CallToast[] = texts.map((text) => ({ id: `toast-${toastCounter.current++}`, text }));
286
+ setToasts((current) => [...current, ...added].slice(-3));
287
+ const timer = setTimeout(() => {
288
+ setToasts((current) => current.filter((toast) => !added.some((a) => a.id === toast.id)));
289
+ }, 4000);
290
+ return () => clearTimeout(timer);
291
+ }, [peers, sounds, t]);
292
+
293
+ // The unread badge and its "seen" watermark: reading the panel reads it.
294
+ const chatMessageIds = useMemo(() => (chat ? chat.messages.map((m) => m.id) : []), [chat?.messages]);
295
+ const chatUnread = ui.panel === "chat" ? 0 : unreadCount(chatMessageIds, ui.lastSeenMessageId);
296
+ useEffect(() => {
297
+ if (ui.panel !== "chat") return;
298
+ const last = chatMessageIds[chatMessageIds.length - 1] ?? null;
299
+ if (last !== ui.lastSeenMessageId) dispatch({ type: "seen", messageId: last });
300
+ }, [ui.panel, chatMessageIds, ui.lastSeenMessageId]);
301
+
302
+ // The timer starts at the FIRST successful connect and survives blips.
303
+ const [connectedAt, setConnectedAt] = useState<number | null>(null);
304
+ const [healthOpen, setHealthOpen] = useState(false);
305
+ useEffect(() => {
306
+ if (connectionState === "connected") setConnectedAt((current) => current ?? Date.now());
307
+ }, [connectionState]);
308
+
309
+ const togglePanel = (tab: SidePanelTab) => dispatch({ type: "panel", panel: ui.panel === tab ? null : tab });
310
+
311
+ const showSelfView =
312
+ !ui.selfHidden && layout.mode !== "duo" && connectionState === "connected" && (!notice || notice.kind === "waiting" || notice.kind === "everyone_left");
313
+
135
314
  return (
136
315
  <div
316
+ ref={stageRef}
137
317
  style={{
138
318
  display: "flex",
139
319
  flexDirection: "column",
140
- gap: "0.75rem",
141
- padding: "1rem",
320
+ gap: narrow ? "0.375rem" : "0.5rem",
321
+ padding: narrow ? "0.5rem" : "0.75rem",
142
322
  borderRadius: tokens.cornerRadius,
143
- border: `1px solid ${tokens.border}`,
144
- backgroundColor: tokens.background,
145
- color: tokens.text,
323
+ backgroundColor: tokens.stageBg,
324
+ color: tokens.stageText,
146
325
  fontFamily: tokens.fontFamily,
147
- /**
148
- * Fill the surface rather than growing to fit the tiles.
149
- *
150
- * This laid out as a content-sized panel, which was right when a call
151
- * was a modal and wrong the moment it became a full-screen page. With
152
- * ONE participant `auto-fit` collapses the grid to a single full-width
153
- * column, and a 16/9 tile at full width is taller than the viewport, so
154
- * the controls were pushed off the bottom of the screen. Two people
155
- * halved the width and hid the bug, which is why it only appeared to
156
- * somebody sitting in a call alone.
157
- *
158
- * `100%` degrades to `auto` inside a parent with no height, so the
159
- * embedded use (`BookingCallScreen`) is unaffected.
160
- */
161
326
  height: "100%",
162
327
  minHeight: 0,
163
328
  boxSizing: "border-box",
164
329
  overflow: "hidden",
165
330
  }}
166
331
  >
332
+ <CallTopBar
333
+ title={title}
334
+ tokens={tokens}
335
+ t={t}
336
+ status={status}
337
+ recording={recording}
338
+ connectedAt={connectedAt}
339
+ view={ui.view}
340
+ onView={(view) => dispatch({ type: "view", view })}
341
+ headCount={callHeadCount(tiles)}
342
+ showViewToggle={layout.mode === "gallery" || layout.mode === "speaker"}
343
+ healthOpen={healthOpen}
344
+ onToggleHealth={() => setHealthOpen((open) => !open)}
345
+ raisedHands={hands.hands.map((h) => ({ identity: h.identity, name: peerNames.get(h.identity) ?? h.identity }))}
346
+ onOpenHands={() => togglePanel("people")}
347
+ compact={narrow}
348
+ />
349
+
350
+ <div style={{ display: "flex", gap: "0.5rem", flex: 1, minHeight: 0 }}>
351
+ <div style={{ position: "relative", flex: 1, minWidth: 0, minHeight: 0, display: "flex" }}>
352
+ {healthOpen && (
353
+ <CallHealthPanel
354
+ tokens={tokens}
355
+ t={t}
356
+ names={peerNames}
357
+ onClose={() => setHealthOpen(false)}
358
+ />
359
+ )}
360
+ <StageArea
361
+ layout={layout}
362
+ tokens={tokens}
363
+ t={t}
364
+ narrow={narrow}
365
+ aspectRatio={aspectRatio}
366
+ observeTile={observeTile}
367
+ hands={hands.hands}
368
+ pinnedKey={ui.pinnedKey}
369
+ onTogglePin={(key) => dispatch({ type: "pin", key: ui.pinnedKey === key ? null : key })}
370
+ onPage={(delta) => dispatch({ type: "page", delta })}
371
+ localPresenting={localPresenting}
372
+ onStopPresenting={() => void media.unpublish("screen")}
373
+ />
374
+
375
+ {notice && <CallCenterNotice notice={notice} tokens={tokens} t={t} onRetry={retry} />}
376
+ <ReactionOverlay reactions={flying.reactions} />
377
+ <CallToasts toasts={toasts} tokens={tokens} t={t} />
378
+ {layout.offPageSpeakerNames.length > 0 && (
379
+ <span
380
+ data-testid="off-page-speakers"
381
+ style={{
382
+ position: "absolute",
383
+ bottom: "0.5rem",
384
+ left: "50%",
385
+ transform: "translateX(-50%)",
386
+ fontSize: "0.75rem",
387
+ padding: "0.2rem 0.65rem",
388
+ borderRadius: 9999,
389
+ backgroundColor: "rgba(0,0,0,0.65)",
390
+ color: tokens.stageText,
391
+ pointerEvents: "none",
392
+ }}
393
+ >
394
+ {t("forge.call_stage.speaking_off_page", { names: layout.offPageSpeakerNames.join(", ") })}
395
+ </span>
396
+ )}
397
+ {showSelfView && (
398
+ <SelfView
399
+ tokens={tokens}
400
+ t={t}
401
+ corner={ui.selfCorner}
402
+ onCorner={(corner) => dispatch({ type: "selfCorner", corner })}
403
+ onHide={() => dispatch({ type: "selfHidden", hidden: true })}
404
+ />
405
+ )}
406
+
407
+ {/* On a phone the panel COVERS the stage instead of docking beside
408
+ it, so the video never shrinks to a sliver. Absolute inside the
409
+ relative stage container, above the tiles. */}
410
+ {ui.panel && narrow && (
411
+ <div data-testid="call-panel-overlay" style={{ position: "absolute", inset: 0, zIndex: 30, display: "flex" }}>
412
+ <CallSidePanel
413
+ tab={chat ? ui.panel : "people"}
414
+ onTab={(tab) => dispatch({ type: "panel", panel: tab })}
415
+ onClose={() => dispatch({ type: "panel", panel: null })}
416
+ tokens={tokens}
417
+ t={t}
418
+ {...(chat ? { chat } : {})}
419
+ tiles={tiles}
420
+ hands={hands.hands}
421
+ isHost={isHost}
422
+ myIdentity={state.identity}
423
+ onLowerHand={hands.lowerHandOf}
424
+ onLowerAll={hands.lowerAll}
425
+ unread={chatUnread}
426
+ fill
427
+ />
428
+ </div>
429
+ )}
430
+ </div>
431
+
432
+ {ui.panel && !narrow && (
433
+ <CallSidePanel
434
+ tab={chat ? ui.panel : "people"}
435
+ onTab={(tab) => dispatch({ type: "panel", panel: tab })}
436
+ onClose={() => dispatch({ type: "panel", panel: null })}
437
+ tokens={tokens}
438
+ t={t}
439
+ {...(chat ? { chat } : {})}
440
+ tiles={tiles}
441
+ hands={hands.hands}
442
+ isHost={isHost}
443
+ myIdentity={state.identity}
444
+ onLowerHand={hands.lowerHandOf}
445
+ onLowerAll={hands.lowerAll}
446
+ unread={chatUnread}
447
+ />
448
+ )}
449
+ </div>
450
+
451
+ {audioProducerIds.map((producerId) => (
452
+ <RemoteAudio key={producerId} producerId={producerId} />
453
+ ))}
454
+
455
+ <CallControlsBar
456
+ tokens={tokens}
457
+ connected={connectionState === "connected"}
458
+ onLeave={onLeave}
459
+ recordingControl={recordingControl}
460
+ cameraSimulcast={cameraSimulcast}
461
+ myHandRaised={hands.myHandRaised}
462
+ onRaiseHand={hands.raiseHand}
463
+ onLowerHand={hands.lowerHand}
464
+ onSendReaction={flying.sendReaction}
465
+ chatAvailable={!!chat}
466
+ chatUnread={chatUnread}
467
+ onToggleChat={() => togglePanel("chat")}
468
+ onTogglePeople={() => togglePanel("people")}
469
+ chatOpen={ui.panel === "chat"}
470
+ peopleOpen={ui.panel === "people"}
471
+ onGesture={unlockSounds}
472
+ />
473
+
474
+ <AutoPublish connected={connectionState === "connected"} mediaDefaults={mediaDefaults} cameraSimulcast={cameraSimulcast} />
475
+ </div>
476
+ );
477
+ }
478
+
479
+ /**
480
+ * Publish what PreJoin chose, once, when the room is first connected.
481
+ *
482
+ * Its own component so it can use the SAME `useLocalMedia` lock family as the
483
+ * control bar without adding effects to the orchestrator; the ref makes it
484
+ * once-per-mount, so a reconnect does not re-open a camera the person turned
485
+ * off mid-call (`lostSources` handles that conversation).
486
+ */
487
+ function AutoPublish({
488
+ connected,
489
+ mediaDefaults,
490
+ cameraSimulcast,
491
+ }: {
492
+ connected: boolean;
493
+ mediaDefaults?: CallStageMediaDefaults | undefined;
494
+ cameraSimulcast?: boolean | undefined;
495
+ }) {
496
+ const media = useLocalMedia();
497
+ const done = useRef(false);
498
+ useEffect(() => {
499
+ if (!connected || done.current || !mediaDefaults) return;
500
+ done.current = true;
501
+ if (mediaDefaults.joinWithMicOn) {
502
+ void media.publishMicrophone(mediaDefaults.audioDeviceId ? { deviceId: mediaDefaults.audioDeviceId } : undefined);
503
+ }
504
+ if (mediaDefaults.joinWithCameraOn) {
505
+ void media.publishCamera({
506
+ ...(mediaDefaults.videoDeviceId ? { deviceId: mediaDefaults.videoDeviceId } : {}),
507
+ ...(cameraSimulcast ? { simulcast: true } : {}),
508
+ });
509
+ }
510
+ }, [connected, mediaDefaults, cameraSimulcast, media]);
511
+ return null;
512
+ }
513
+
514
+ /** The tiles, arranged. Which arrangement is `resolveLayout`'s answer. */
515
+ function StageArea({
516
+ layout,
517
+ tokens,
518
+ t,
519
+ narrow,
520
+ aspectRatio,
521
+ observeTile,
522
+ hands,
523
+ pinnedKey,
524
+ onTogglePin,
525
+ onPage,
526
+ localPresenting,
527
+ onStopPresenting,
528
+ }: {
529
+ layout: StageLayout;
530
+ tokens: StageTokens;
531
+ t: ForgeT;
532
+ narrow: boolean;
533
+ aspectRatio: number;
534
+ observeTile: (producerId: string) => (element: HTMLElement | null) => void;
535
+ hands: readonly { identity: string; raisedAt: number }[];
536
+ pinnedKey: string | null;
537
+ onTogglePin: (key: string) => void;
538
+ onPage: (delta: number) => void;
539
+ localPresenting: boolean;
540
+ onStopPresenting: () => void;
541
+ }) {
542
+ const handAt = (identity: string) => hands.find((h) => h.identity === identity)?.raisedAt;
543
+
544
+ const pager =
545
+ layout.pageCount > 1 ? (
546
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: "0.5rem", flexShrink: 0 }}>
547
+ <PageArrow direction={-1} disabled={layout.page === 0} onPage={onPage} tokens={tokens} t={t} />
548
+ <span style={{ fontSize: "0.75rem", color: tokens.stageMuted }}>
549
+ {t("forge.call_stage.page_of", { page: layout.page + 1, pages: layout.pageCount })}
550
+ </span>
551
+ <PageArrow direction={1} disabled={layout.page >= layout.pageCount - 1} onPage={onPage} tokens={tokens} t={t} />
552
+ </div>
553
+ ) : null;
554
+
555
+ const tileFor = (tile: CallTile, fill: boolean) => (
556
+ <VideoTile
557
+ key={tile.key}
558
+ tile={tile}
559
+ tokens={tokens}
560
+ t={t}
561
+ observeTile={observeTile}
562
+ handRaisedAt={handAt(tile.identity)}
563
+ pinned={pinnedKey === tile.key}
564
+ onTogglePin={() => onTogglePin(tile.key)}
565
+ fill={fill}
566
+ aspectRatio={aspectRatio}
567
+ />
568
+ );
569
+
570
+ if (layout.mode === "presenting") {
571
+ return (
167
572
  <div
573
+ data-testid="presenting-stage"
168
574
  style={{
169
575
  display: "flex",
170
- alignItems: "center",
171
- justifyContent: "space-between",
172
- gap: "0.75rem",
173
- flexWrap: "wrap",
576
+ // On a phone the share sits on TOP at full width with the faces as a
577
+ // strip beneath it; a side rail would squeeze the share into a sliver
578
+ // on one edge. On a desktop the rail is beside it as before.
579
+ flexDirection: narrow ? "column" : "row",
580
+ gap: "0.5rem",
581
+ flex: 1,
582
+ minWidth: 0,
583
+ minHeight: 0,
174
584
  }}
175
585
  >
176
- {title ? <div style={{ fontWeight: 600, fontFamily: tokens.headingFontFamily }}>{title}</div> : <span />}
177
- <div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
178
- {recording && <RecordingIndicator tokens={tokens} t={t} />}
179
- <ConnectionStatus status={status} tokens={tokens} t={t} onRetry={retry} />
586
+ <div style={{ flex: 1, minWidth: 0, minHeight: 0, display: "flex", flexDirection: "column", gap: "0.375rem" }}>
587
+ {layout.main ? (
588
+ <>
589
+ <span data-testid="presenter-label" style={{ fontSize: "0.8125rem", color: tokens.stageMuted, flexShrink: 0 }}>
590
+ {t("forge.call_stage.presenting_name", { name: layout.presenter?.name ?? "" })}
591
+ </span>
592
+ <div style={{ flex: 1, minHeight: 0, display: "flex" }}>
593
+ {tileFor(layout.main, true)}
594
+ </div>
595
+ </>
596
+ ) : (
597
+ // The viewer's OWN share: the browser does not hand a preview of
598
+ // it back cheaply, and mirroring your own screen recursively is
599
+ // the hall-of-mirrors everyone knows. A calm placard instead.
600
+ <div
601
+ data-testid="local-presenting"
602
+ style={{
603
+ flex: 1,
604
+ display: "flex",
605
+ flexDirection: "column",
606
+ alignItems: "center",
607
+ justifyContent: "center",
608
+ gap: "0.75rem",
609
+ borderRadius: tokens.cornerRadius,
610
+ border: `1px dashed ${tokens.stageBorder}`,
611
+ color: tokens.stageMuted,
612
+ }}
613
+ >
614
+ <span>{t("forge.call_stage.you_are_presenting")}</span>
615
+ <button
616
+ type="button"
617
+ onClick={onStopPresenting}
618
+ style={{
619
+ padding: "0.4rem 0.9rem",
620
+ borderRadius: tokens.cornerRadius,
621
+ border: `1px solid ${tokens.danger}`,
622
+ backgroundColor: "transparent",
623
+ color: tokens.stageText,
624
+ cursor: "pointer",
625
+ font: "inherit",
626
+ fontSize: "0.8125rem",
627
+ }}
628
+ >
629
+ {t("forge.call_stage.stop_presenting")}
630
+ </button>
631
+ </div>
632
+ )}
633
+ {localPresenting && layout.main && (
634
+ <span role="status" style={{ fontSize: "0.75rem", color: tokens.stageMuted, flexShrink: 0 }}>
635
+ {t("forge.call_stage.you_are_presenting")}
636
+ </span>
637
+ )}
180
638
  </div>
639
+ {layout.pageTiles.length > 0 &&
640
+ (narrow ? (
641
+ <div style={{ display: "flex", flexDirection: "column", gap: "0.375rem", flexShrink: 0 }}>
642
+ <div style={{ display: "flex", gap: "0.375rem", height: "5rem", overflowX: "auto", overflowY: "hidden" }}>
643
+ {layout.pageTiles.map((tile) => (
644
+ <div key={tile.key} style={{ flexShrink: 0, aspectRatio: "16 / 9", height: "100%" }}>
645
+ {tileFor(tile, true)}
646
+ </div>
647
+ ))}
648
+ </div>
649
+ {pager}
650
+ </div>
651
+ ) : (
652
+ <div style={{ width: "clamp(8rem, 18%, 13rem)", display: "flex", flexDirection: "column", gap: "0.375rem", minHeight: 0 }}>
653
+ {layout.pageTiles.map((tile) => (
654
+ <div key={tile.key} style={{ flexShrink: 0 }}>
655
+ {tileFor(tile, false)}
656
+ </div>
657
+ ))}
658
+ {pager}
659
+ </div>
660
+ ))}
661
+ </div>
662
+ );
663
+ }
664
+
665
+ if (layout.mode === "speaker" && layout.main) {
666
+ return (
667
+ <div style={{ display: "flex", flexDirection: "column", gap: "0.5rem", flex: 1, minWidth: 0, minHeight: 0 }}>
668
+ <div style={{ flex: 1, minHeight: 0, display: "flex" }}>{tileFor(layout.main, true)}</div>
669
+ {layout.pageTiles.length > 0 && (
670
+ <div style={{ display: "flex", gap: "0.375rem", justifyContent: "center", flexShrink: 0, height: "5.5rem" }}>
671
+ {layout.pageTiles.map((tile) => (
672
+ <div key={tile.key} style={{ height: "100%", aspectRatio: String(aspectRatio) }}>
673
+ {tileFor(tile, true)}
674
+ </div>
675
+ ))}
676
+ {pager}
677
+ </div>
678
+ )}
181
679
  </div>
680
+ );
681
+ }
182
682
 
183
- <div style={{ display: "flex", alignItems: "center", gap: "0.375rem", fontSize: "0.75rem", color: tokens.muted }}>
184
- <span>{t("forge.call_stage.head_count", { count: callHeadCount(tiles) })}</span>
185
- <CallHelpHint label={t("forge.call_stage.help_hear_not_see_label")}>
186
- {t("forge.call_stage.help_hear_not_see")}
187
- </CallHelpHint>
683
+ if (layout.mode === "duo") {
684
+ return (
685
+ <div
686
+ style={{
687
+ display: "grid",
688
+ gridTemplateColumns: `repeat(${layout.columns}, 1fr)`,
689
+ // Explicit rows so the stacked phone case (one column, a row each)
690
+ // divides the height EVENLY into portraits, rather than letting the
691
+ // second tile size to content. Desktop (one row) is unchanged.
692
+ gridTemplateRows: `repeat(${layout.rows}, 1fr)`,
693
+ gap: "0.5rem",
694
+ flex: 1,
695
+ minWidth: 0,
696
+ minHeight: 0,
697
+ }}
698
+ >
699
+ {layout.pageTiles.map((tile) => tileFor(tile, true))}
700
+ <LocalDuoTile tokens={tokens} t={t} />
188
701
  </div>
702
+ );
703
+ }
189
704
 
705
+ return (
706
+ <div style={{ display: "flex", flexDirection: "column", gap: "0.5rem", flex: 1, minWidth: 0, minHeight: 0 }}>
190
707
  <div
191
708
  style={{
192
709
  display: "grid",
193
- gridTemplateColumns: "repeat(auto-fit, minmax(12rem, 1fr))",
194
- gap: "0.75rem",
195
- // The only part that may grow, and the only part that may scroll.
196
- // `minHeight: 0` is what actually lets it shrink: a flex item defaults
197
- // to `min-height: auto`, so without it the grid refuses to go below
198
- // its content and pushes the controls out of the box instead.
710
+ gridTemplateColumns: `repeat(${layout.columns}, 1fr)`,
711
+ gap: "0.5rem",
199
712
  flex: 1,
200
713
  minHeight: 0,
201
- overflowY: "auto",
202
- /**
203
- * Centred in the free space, not stacked at the top.
204
- *
205
- * `safe` is the load-bearing word: plain `center` centres the
206
- * OVERFLOW too, so once there are more tiles than fit, the first row
207
- * is pushed above the scroll origin and cannot be reached. `safe
208
- * center` centres while there is room and falls back to start the
209
- * moment there is not. An engine that does not understand it drops
210
- * the declaration and starts at the top, which is the same fallback.
211
- */
212
714
  alignContent: "safe center",
213
- // Horizontally centred too. A tile clamped by `maxHeight` shrinks its
214
- // width to keep 16/9, so it no longer fills its track and would
215
- // otherwise sit against the left edge. With two or more tiles each
216
- // fills its track and this changes nothing.
217
715
  justifyItems: "center",
716
+ overflowY: "auto",
218
717
  }}
219
718
  >
220
- <LocalPreview tokens={tokens} aspectRatio={aspectRatio} t={t} />
221
- {tiles.map((tile) => (
222
- <ParticipantTile key={tile.key} tile={tile} tokens={tokens} aspectRatio={aspectRatio} t={t} />
223
- ))}
719
+ {layout.pageTiles.map((tile) => tileFor(tile, false))}
224
720
  </div>
225
-
226
- {audioProducerIds.map((producerId) => (
227
- <RemoteAudio key={producerId} producerId={producerId} />
228
- ))}
229
-
230
- <CallControls tokens={tokens} t={t} connected={connectionState === "connected"} onLeave={onLeave} />
721
+ {pager}
231
722
  </div>
232
723
  );
233
724
  }
234
725
 
235
- /**
236
- * The connection, said plainly.
237
- *
238
- * Four different words for four different situations, because "connecting",
239
- * "reconnecting" and "we were refused" call for three different responses and a
240
- * single spinner tells you which of them you are in exactly never.
241
- */
242
- function ConnectionStatus({
243
- status,
726
+ function PageArrow({
727
+ direction,
728
+ disabled,
729
+ onPage,
244
730
  tokens,
245
731
  t,
246
- onRetry,
247
732
  }: {
248
- status: CallStatus;
249
- tokens: ResolvedThemeTokens;
733
+ direction: -1 | 1;
734
+ disabled: boolean;
735
+ onPage: (delta: number) => void;
736
+ tokens: StageTokens;
250
737
  t: ForgeT;
251
- onRetry: () => void;
252
738
  }) {
253
- const busy = status.tone === "connecting" || status.tone === "reconnecting";
254
- const alarming = status.tone === "failed";
255
- // Our own copy is translated; the server's sentence is shown as it came.
256
- const detail = status.detailKey ? t(status.detailKey) : status.detailText;
257
-
258
739
  return (
259
- <div
260
- role="status"
261
- aria-live="polite"
262
- style={{
263
- display: "flex",
264
- alignItems: "center",
265
- gap: "0.5rem",
266
- fontSize: "0.8125rem",
267
- color: alarming ? tokens.text : tokens.muted,
268
- }}
269
- >
270
- {busy && <Loader2 width={14} height={14} aria-hidden="true" />}
271
- {alarming && <AlertTriangle width={14} height={14} aria-hidden="true" color={tokens.secondary} />}
272
- {status.tone === "live" && (
273
- <span
274
- aria-hidden="true"
275
- style={{ width: 8, height: 8, borderRadius: 9999, backgroundColor: tokens.primary, display: "inline-block" }}
276
- />
277
- )}
278
- <span style={{ fontWeight: alarming ? 600 : 400 }}>{t(status.headlineKey)}</span>
279
- {detail && <span style={{ color: tokens.muted }}>{detail}</span>}
280
- {status.canRetry && (
281
- <button
282
- type="button"
283
- onClick={onRetry}
284
- style={{
285
- display: "inline-flex",
286
- alignItems: "center",
287
- gap: "0.25rem",
288
- border: `1px solid ${tokens.border}`,
289
- borderRadius: tokens.cornerRadius,
290
- backgroundColor: "transparent",
291
- color: tokens.text,
292
- padding: "0.125rem 0.5rem",
293
- cursor: "pointer",
294
- font: "inherit",
295
- fontSize: "0.75rem",
296
- }}
297
- >
298
- <RefreshCw width={12} height={12} aria-hidden="true" />
299
- {t("forge.call_stage.retry")}
300
- </button>
301
- )}
302
- </div>
303
- );
304
- }
305
-
306
- /** Somebody is recording. A consent signal, so it is content and always shown. */
307
- function RecordingIndicator({ tokens, t }: { tokens: ResolvedThemeTokens; t: ForgeT }) {
308
- return (
309
- <span
740
+ <button
741
+ type="button"
742
+ data-testid={direction === 1 ? "page-next" : "page-prev"}
743
+ onClick={() => onPage(direction)}
744
+ disabled={disabled}
745
+ aria-label={direction === 1 ? t("forge.call_stage.page_next") : t("forge.call_stage.page_prev")}
310
746
  style={{
311
747
  display: "inline-flex",
312
748
  alignItems: "center",
313
- gap: "0.375rem",
314
- fontSize: "0.75rem",
315
- fontWeight: 600,
316
- color: tokens.text,
317
- border: `1px solid ${tokens.border}`,
318
- borderRadius: tokens.cornerRadius,
319
- padding: "0.125rem 0.5rem",
749
+ justifyContent: "center",
750
+ width: 28,
751
+ height: 28,
752
+ borderRadius: 9999,
753
+ border: `1px solid ${tokens.stageBorder}`,
754
+ backgroundColor: "transparent",
755
+ color: disabled ? tokens.stageMuted : tokens.stageText,
756
+ cursor: disabled ? "default" : "pointer",
757
+ opacity: disabled ? 0.5 : 1,
320
758
  }}
321
759
  >
322
- <span
323
- aria-hidden="true"
324
- style={{ width: 8, height: 8, borderRadius: 9999, backgroundColor: tokens.secondary, display: "inline-block" }}
325
- />
326
- {t("forge.call_stage.recording")}
327
- </span>
328
- );
329
- }
330
-
331
- const tileFrame = (tokens: ResolvedThemeTokens, aspectRatio: number, speaking: boolean): CSSProperties => ({
332
- position: "relative",
333
- overflow: "hidden",
334
- aspectRatio: String(aspectRatio),
335
- // A lone tile spans the whole grid, and 16/9 of a wide surface is taller than
336
- // the surface. Clamping crops rather than distorts, because the video inside
337
- // is `object-fit: cover`.
338
- maxHeight: "100%",
339
- borderRadius: tokens.cornerRadius,
340
- backgroundColor: tokens.surface,
341
- // The active speaker is shown by the FRAME rather than by re-ordering the
342
- // grid: a grid that reshuffles itself every time somebody says "mm" is
343
- // unusable, and the person you were looking at moves out from under the eye.
344
- border: `2px solid ${speaking ? tokens.primary : tokens.border}`,
345
- display: "flex",
346
- alignItems: "center",
347
- justifyContent: "center",
348
- });
349
-
350
- const tileName = (tokens: ResolvedThemeTokens): CSSProperties => ({
351
- position: "absolute",
352
- left: "0.5rem",
353
- bottom: "0.5rem",
354
- maxWidth: "calc(100% - 1rem)",
355
- overflow: "hidden",
356
- textOverflow: "ellipsis",
357
- whiteSpace: "nowrap",
358
- fontSize: "0.75rem",
359
- padding: "0.125rem 0.375rem",
360
- borderRadius: tokens.cornerRadius,
361
- backgroundColor: tokens.background,
362
- color: tokens.text,
363
- });
364
-
365
- /**
366
- * One participant.
367
- *
368
- * A tile with no video renders the NAME, at size, and keeps its place in the
369
- * grid. That is the whole point: somebody with the camera off has not left, and
370
- * a grid that drops them re-flows under everyone else's eyes the moment one
371
- * person turns a camera off.
372
- */
373
- function ParticipantTile({
374
- tile,
375
- tokens,
376
- aspectRatio,
377
- t,
378
- }: {
379
- tile: CallTile;
380
- tokens: ResolvedThemeTokens;
381
- aspectRatio: number;
382
- t: ForgeT;
383
- }) {
384
- // `useRemoteTrack` takes a producer id unconditionally so the hook order is
385
- // the same for a tile with video and a tile without. An id that matches
386
- // nothing simply returns no track, which is the state we want anyway.
387
- const { track, attach } = useRemoteTrack(tile.videoProducerId ?? "");
388
- const showVideo = !!tile.videoProducerId && !!track && !tile.videoPaused;
389
-
390
- return (
391
- <div
392
- style={tileFrame(tokens, aspectRatio, tile.isSpeaking)}
393
- data-testid={`tile-${tile.identity}`}
394
- // The active set as an attribute as well as a border: a site restyling
395
- // the grid needs the fact, and a colour is not something a test can
396
- // assert without pinning a palette this component deliberately has none of.
397
- data-speaking={tile.isSpeaking ? "true" : "false"}
398
- >
399
- {showVideo ? (
400
- <video ref={attach} autoPlay playsInline muted style={{ width: "100%", height: "100%", objectFit: "cover" }} />
401
- ) : (
402
- <span
403
- style={{
404
- fontSize: "0.9375rem",
405
- fontWeight: 600,
406
- color: tokens.text,
407
- padding: "0 0.75rem",
408
- textAlign: "center",
409
- }}
410
- >
411
- {tile.name}
412
- </span>
413
- )}
414
- <span style={tileName(tokens)}>
415
- {tile.name}
416
- {!tile.hasAudio && <span style={{ color: tokens.muted }}> {t("forge.call_stage.listening")}</span>}
417
- </span>
418
- </div>
760
+ {direction === 1 ? <ChevronRight width={14} height={14} aria-hidden="true" /> : <ChevronLeft width={14} height={14} aria-hidden="true" />}
761
+ </button>
419
762
  );
420
763
  }
421
764
 
422
- /**
423
- * Remote audio, attached and never drawn.
424
- *
425
- * Audio has to live on an element or the call is silent. It is deliberately NOT
426
- * attached to the participant's `<video>`: a tile whose camera is off has no
427
- * video element, and hanging the audio off one would mute whoever turned their
428
- * camera off.
429
- */
430
- function RemoteAudio({ producerId }: { producerId: string }) {
431
- const { attach } = useRemoteTrack(producerId);
432
- return <audio ref={attach} autoPlay style={{ display: "none" }} />;
433
- }
434
-
435
- /** This viewer, as they are being sent. Muted, or it is a feedback loop. */
436
- function LocalPreview({ tokens, aspectRatio, t }: { tokens: ResolvedThemeTokens; aspectRatio: number; t: ForgeT }) {
765
+ /** The 1:1 layout's second portrait: the viewer, at size, not a floating chip. */
766
+ function LocalDuoTile({ tokens, t }: { tokens: StageTokens; t: ForgeT }) {
437
767
  const state = useRoomState();
438
768
  const publications = useLocalPublications();
439
769
  const videoRef = useRef<HTMLVideoElement | null>(null);
440
-
441
770
  const camera = useMemo(() => publications.find((p) => p.source === "camera"), [publications]);
442
- // A paused camera is still captured and still a publication, but nothing is
443
- // being sent, so the preview shows the name the others are seeing.
444
771
  const track = camera && !camera.paused ? camera.track : undefined;
445
772
 
446
773
  useEffect(() => {
447
774
  const element = videoRef.current;
448
775
  if (!element) return;
449
- // Rebuilt only when the TRACK changes. Doing this in a ref callback would
450
- // build a new `MediaStream` on every render, which is the per-frame churn
451
- // this component exists to avoid.
452
776
  element.srcObject = track ? new MediaStream([track]) : null;
453
777
  return () => {
454
778
  element.srcObject = null;
455
779
  };
456
780
  }, [track]);
457
781
 
458
- const name = state.identity ? t("forge.call_stage.you") : t("forge.call_stage.you_not_connected");
459
-
460
- return (
461
- <div style={tileFrame(tokens, aspectRatio, false)} data-testid="tile-local">
462
- {track ? (
463
- <video
464
- ref={videoRef}
465
- autoPlay
466
- playsInline
467
- muted
468
- style={{ width: "100%", height: "100%", objectFit: "cover" }}
469
- />
470
- ) : (
471
- <span style={{ fontSize: "0.9375rem", fontWeight: 600, color: tokens.text }}>{name}</span>
472
- )}
473
- <span style={tileName(tokens)}>{name}</span>
474
- </div>
475
- );
476
- }
477
-
478
- /** One sentence per source a drop took away, keyed by the SDK's word for it. */
479
- const LOST_SOURCE_KEYS: Record<LocalSource, string> = {
480
- microphone: "forge.call_stage.lost_microphone",
481
- camera: "forge.call_stage.lost_camera",
482
- screen: "forge.call_stage.lost_screen",
483
- };
484
-
485
- const controlButton = (tokens: ResolvedThemeTokens, active: boolean, busy = false): CSSProperties => ({
486
- display: "inline-flex",
487
- alignItems: "center",
488
- gap: "0.375rem",
489
- padding: "0.5rem 0.75rem",
490
- borderRadius: tokens.cornerRadius,
491
- border: `1px solid ${active ? tokens.primary : tokens.border}`,
492
- backgroundColor: active ? tokens.primary : "transparent",
493
- color: active ? tokens.textPrimary : tokens.text,
494
- cursor: busy ? "progress" : "pointer",
495
- opacity: busy ? 0.6 : 1,
496
- font: "inherit",
497
- fontSize: "0.8125rem",
498
- });
499
-
500
- /**
501
- * Microphone, camera, screen and leave.
502
- *
503
- * Each toggle is drawn only when the token was minted with the grant for it, so
504
- * a listen-only seat is not offered a microphone it would be refused. That is a
505
- * rendering decision; the node refuses the publish itself either way.
506
- *
507
- * ## Mute is a pause, not a republish
508
- *
509
- * The first press on Unmute or Start camera captures and publishes. Every press
510
- * after that PAUSES or RESUMES the publication the room already holds, through
511
- * `setPaused`. A toggle built as unpublish-then-publish starts every unmute
512
- * with `getUserMedia`, and Safari puts a permission prompt in front of every
513
- * call to it, so a person on an iPad was asked whether the site may use the
514
- * microphone each time they spoke. Pausing keeps the capture open (the
515
- * browser's device indicator stays on, which the camera hint says) and the way
516
- * back asks nobody anything. Screen share is the exception: stopping it ends
517
- * the capture, because sharing again has to go through the picker anyway.
518
- *
519
- * ## Why every toggle is disabled while it is working
520
- *
521
- * Each of these is a read-then-write: it reads whether the source is live and
522
- * publishes, pauses or resumes accordingly. The flag it reads does not move
523
- * until the publish has resolved, and a publish starts with a permission
524
- * prompt that can sit on screen for seconds - so a button that looks untouched
525
- * is a button a person presses again, and two presses used to mean two
526
- * producers from one identity and everyone else hearing them doubled.
527
- * `useLocalMedia` holds the real lock (a synchronous ref, since both clicks
528
- * land before React re-renders anything); this is the part that says so on
529
- * screen, because a control that silently ignores you is only marginally
530
- * better than one that misbehaves.
531
- */
532
- function CallControls({
533
- tokens,
534
- t,
535
- connected,
536
- onLeave,
537
- }: {
538
- tokens: ResolvedThemeTokens;
539
- t: ForgeT;
540
- connected: boolean;
541
- onLeave?: () => void;
542
- }) {
543
- const grants = useGrants();
544
- const { room } = useMediaRoom();
545
- const media = useLocalMedia();
546
-
547
- // What the person is actually SENDING, which is what the button says.
548
- const microphoneLive = media.isMicrophoneEnabled && !media.paused.microphone;
549
- const cameraLive = media.isCameraEnabled && !media.paused.camera;
550
-
551
- // Plain handlers, deliberately not `useCallback`. `useLocalMedia()` returns a
552
- // fresh object every render, so memoising on it would rebuild the callback
553
- // every render anyway while looking as though it did not.
554
- const toggleMicrophone = () => {
555
- if (!media.isMicrophoneEnabled) {
556
- void media.publishMicrophone();
557
- return;
558
- }
559
- void media.setPaused("microphone", microphoneLive);
560
- };
561
- const toggleCamera = () => {
562
- if (!media.isCameraEnabled) {
563
- void media.publishCamera();
564
- return;
565
- }
566
- void media.setPaused("camera", cameraLive);
567
- };
568
- const toggleScreen = () => {
569
- void (media.screenSharing ? media.unpublish("screen") : media.publishScreen());
570
- };
571
-
572
- const leave = useCallback(() => {
573
- // Closed here AND unmounted by the host. Closing alone would leave a
574
- // provider mounted that reconnects on its next render; unmounting alone
575
- // relies on a cleanup running before the person walks away from the laptop.
576
- void room?.close().catch(() => undefined);
577
- onLeave?.();
578
- }, [room, onLeave]);
579
-
782
+ const name = state.identity ? t("forge.call_stage.you") : t("forge.call_stage.you_joining");
580
783
  return (
581
784
  <div
785
+ data-testid="tile-local"
582
786
  style={{
787
+ position: "relative",
788
+ overflow: "hidden",
789
+ borderRadius: tokens.cornerRadius,
790
+ backgroundColor: tokens.stageSurface,
791
+ border: `2px solid ${tokens.stageBorder}`,
583
792
  display: "flex",
584
- flexWrap: "wrap",
585
793
  alignItems: "center",
586
- gap: "0.5rem",
587
- // Pinned to the bottom of the surface. The tile grid already claims the
588
- // free space with `flex: 1`, so this holds even if that ever changes:
589
- // the controls are the one thing that must never move off screen,
590
- // because Leave is here.
591
- marginTop: "auto",
592
- flexShrink: 0,
794
+ justifyContent: "center",
795
+ minHeight: 0,
593
796
  }}
594
797
  >
595
- {canPublishSource(grants, "microphone") && (
596
- <button
597
- type="button"
598
- onClick={toggleMicrophone}
599
- disabled={media.pending.microphone}
600
- aria-busy={media.pending.microphone}
601
- style={controlButton(tokens, microphoneLive, media.pending.microphone)}
602
- >
603
- {microphoneLive ? (
604
- <Mic width={16} height={16} aria-hidden="true" />
605
- ) : (
606
- <MicOff width={16} height={16} aria-hidden="true" />
607
- )}
608
- {microphoneLive ? t("forge.call_stage.mute") : t("forge.call_stage.unmute")}
609
- </button>
610
- )}
611
-
612
- {canPublishSource(grants, "camera") && (
613
- <span style={{ display: "inline-flex", alignItems: "center", gap: "0.25rem" }}>
614
- <button
615
- type="button"
616
- onClick={toggleCamera}
617
- disabled={media.pending.camera}
618
- aria-busy={media.pending.camera}
619
- style={controlButton(tokens, cameraLive, media.pending.camera)}
620
- >
621
- {cameraLive ? (
622
- <Video width={16} height={16} aria-hidden="true" />
623
- ) : (
624
- <VideoOff width={16} height={16} aria-hidden="true" />
625
- )}
626
- {cameraLive ? t("forge.call_stage.camera_stop") : t("forge.call_stage.camera_start")}
627
- </button>
628
- <CallHelpHint label={t("forge.call_stage.help_camera_stop_label")}>
629
- {t("forge.call_stage.help_camera_stop")}
630
- </CallHelpHint>
631
- </span>
632
- )}
633
-
634
- {canPublishSource(grants, "screen") && (
635
- <span style={{ display: "inline-flex", alignItems: "center", gap: "0.25rem" }}>
636
- <button
637
- type="button"
638
- onClick={toggleScreen}
639
- disabled={media.pending.screen}
640
- aria-busy={media.pending.screen}
641
- style={controlButton(tokens, media.screenSharing, media.pending.screen)}
642
- >
643
- {media.screenSharing ? (
644
- <MonitorX width={16} height={16} aria-hidden="true" />
645
- ) : (
646
- <MonitorUp width={16} height={16} aria-hidden="true" />
647
- )}
648
- {media.screenSharing ? t("forge.call_stage.screen_stop") : t("forge.call_stage.screen_start")}
649
- </button>
650
- <CallHelpHint label={t("forge.call_stage.help_screen_label")}>
651
- {t("forge.call_stage.help_screen")}
652
- </CallHelpHint>
653
- </span>
798
+ {track ? (
799
+ <video ref={videoRef} autoPlay playsInline muted style={{ width: "100%", height: "100%", objectFit: "cover" }} />
800
+ ) : (
801
+ <span style={{ fontSize: "0.9375rem", fontWeight: 600 }}>{name}</span>
654
802
  )}
655
-
656
- <button
657
- type="button"
658
- onClick={leave}
659
- style={{ ...controlButton(tokens, false), marginLeft: "auto", borderColor: tokens.secondary }}
803
+ <span
804
+ style={{
805
+ position: "absolute",
806
+ left: "0.5rem",
807
+ bottom: "0.5rem",
808
+ fontSize: "0.75rem",
809
+ padding: "0.125rem 0.45rem",
810
+ borderRadius: tokens.cornerRadius,
811
+ backgroundColor: "rgba(0,0,0,0.55)",
812
+ color: tokens.stageText,
813
+ }}
660
814
  >
661
- <PhoneOff width={16} height={16} aria-hidden="true" />
662
- {t("forge.call_stage.leave")}
663
- </button>
664
-
665
- {/* A denied permission prompt is something the person has to be told
666
- about without asking, so it stays visible rather than going behind
667
- the `?`. */}
668
- {media.error && (
669
- <span role="alert" style={{ width: "100%", fontSize: "0.8125rem", color: tokens.text }}>
670
- {media.error.message}
671
- </span>
672
- )}
673
-
674
- {/* State the person has to act on, so it is content and stays visible: a
675
- drop stopped their capture (the light went out, on purpose), the room
676
- is back, and nothing will turn the microphone on again but them. Shown
677
- once the room is back rather than during the drop, when the status
678
- line above is already saying "reconnecting". An alert rather than a
679
- status: somebody who misses it is talking to nobody. */}
680
- {connected && media.lostSources.length > 0 && (
681
- <span role="alert" style={{ width: "100%", fontSize: "0.8125rem", color: tokens.text }}>
682
- {media.lostSources.map((source) => (
683
- <span key={source} style={{ display: "block" }}>
684
- {t(LOST_SOURCE_KEYS[source])}
685
- </span>
686
- ))}
687
- </span>
688
- )}
815
+ {name}
816
+ </span>
689
817
  </div>
690
818
  );
691
819
  }
820
+
821
+ /**
822
+ * Remote audio, attached and never drawn. Deliberately NOT on the tile's
823
+ * `<video>`: a camera-off tile has no video element, and hanging audio off
824
+ * one would mute whoever turned their camera off.
825
+ */
826
+ function RemoteAudio({ producerId }: { producerId: string }) {
827
+ const { attach } = useRemoteTrack(producerId);
828
+ return <audio ref={attach} autoPlay style={{ display: "none" }} />;
829
+ }