@rapidmx/meet-plugin 0.2.0 → 0.3.1

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 (46) hide show
  1. package/apps/meet/[token].tsx +51 -46
  2. package/apps/meet/_CallControls.tsx +255 -32
  3. package/apps/meet/_CallView.tsx +276 -114
  4. package/apps/meet/_MeetLobby.tsx +111 -142
  5. package/apps/meet/_ParticipantTile.tsx +51 -19
  6. package/apps/meet/_icons.tsx +54 -0
  7. package/apps/shared/media/chime.ts +74 -0
  8. package/apps/shared/media/deviceMedia.ts +3 -13
  9. package/apps/shared/media/useLocalMedia.ts +337 -0
  10. package/apps/shared/push/GuestSignalingClient.ts +2 -0
  11. package/apps/shared/webrtc/MeshConnectionManager.ts +237 -55
  12. package/apps/shared/webrtc/realPeerConnection.ts +16 -3
  13. package/apps/shared/webrtc/types.ts +58 -15
  14. package/dist/apps/meet/[token].js +31 -19
  15. package/dist/apps/meet/_CallControls.d.ts +20 -7
  16. package/dist/apps/meet/_CallControls.js +72 -3
  17. package/dist/apps/meet/_CallView.d.ts +24 -4
  18. package/dist/apps/meet/_CallView.js +142 -57
  19. package/dist/apps/meet/_MeetLobby.d.ts +16 -19
  20. package/dist/apps/meet/_MeetLobby.js +49 -99
  21. package/dist/apps/meet/_ParticipantTile.d.ts +12 -6
  22. package/dist/apps/meet/_ParticipantTile.js +12 -9
  23. package/dist/apps/meet/_icons.d.ts +15 -0
  24. package/dist/apps/meet/_icons.js +16 -0
  25. package/dist/apps/shared/media/chime.d.ts +31 -0
  26. package/dist/apps/shared/media/chime.js +49 -0
  27. package/dist/apps/shared/media/deviceMedia.d.ts +3 -7
  28. package/dist/apps/shared/media/deviceMedia.js +3 -12
  29. package/dist/apps/shared/media/useLocalMedia.d.ts +37 -0
  30. package/dist/apps/shared/media/useLocalMedia.js +259 -0
  31. package/dist/apps/shared/push/GuestSignalingClient.js +2 -0
  32. package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +63 -18
  33. package/dist/apps/shared/webrtc/MeshConnectionManager.js +232 -42
  34. package/dist/apps/shared/webrtc/realPeerConnection.js +16 -3
  35. package/dist/apps/shared/webrtc/types.d.ts +58 -15
  36. package/dist/apps/shared/webrtc/types.js +3 -1
  37. package/dist/lib/routes/BaseVideoMeetingRoute.js +58 -3
  38. package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
  39. package/dist/lib/routes/mongo/VideoMeetingRouteMongo.js +2 -1
  40. package/dist/lib/routes/mongo/VideoMeetingRouteMongo.js.map +1 -1
  41. package/dist/lib/routes/sql/VideoMeetingRouteSQL.js +2 -1
  42. package/dist/lib/routes/sql/VideoMeetingRouteSQL.js.map +1 -1
  43. package/dist/types/routes/BaseVideoMeetingRoute.d.ts +15 -0
  44. package/dist/types/routes/mongo/VideoMeetingRouteMongo.d.ts +1 -0
  45. package/dist/types/routes/sql/VideoMeetingRouteSQL.d.ts +1 -0
  46. package/package.json +181 -181
@@ -11,6 +11,21 @@
11
11
  * program rooted at `apps/`, which cannot reference files outside it (`TS6059`), matching `booking-plugin`'s own
12
12
  * identical `apps/shared/` convention for reusable frontend-only code.
13
13
  *
14
+ * ## Fitting the window
15
+ *
16
+ * The call fills the viewport (`fixed inset-0`) as three rows - a slim header, the tiles (which take whatever room
17
+ * is left and never scroll the page), and the control bar pinned to the bottom - instead of being laid out inside
18
+ * the branded page shell, whose header, footer and padding pushed it off the screen. The local participant's own
19
+ * tile is a small tile in the bottom-right corner while anyone else is in the call (above the control bar on a narrower
20
+ * window, and at the top on a phone, where the bar wraps onto a second row), and fills the tile area while they are
21
+ * alone.
22
+ *
23
+ * ## Who is heard
24
+ *
25
+ * A tile never plays sound. Each remote stream is played by its own hidden `<audio>` element, so a participant is
26
+ * heard whether or not their camera is on and whichever layout is showing. If the browser refuses to start the
27
+ * audio (an autoplay policy), a banner asks for a click, which is the gesture that allows it.
28
+ *
14
29
  * ## Layout precedence
15
30
  *
16
31
  * `computeMainUid()` decides who is shown large, in this order: an active presenter always wins (presentation
@@ -19,15 +34,16 @@
19
34
  * slot); else, in focus mode with nobody yet speaking, the first other participant - so focus mode never shows an
20
35
  * empty main slot once someone else has joined.
21
36
  */
22
- import React, { useEffect, useRef, useState } from "react";
37
+ import React, { useCallback, useEffect, useRef, useState } from "react";
38
+ import { playRaisedHandChime } from "../shared/media/chime.js";
23
39
  import { pickActiveSpeaker } from "../shared/media/activeSpeaker.js";
24
40
  import { startLevelMeter, type LevelMeterHandle } from "../shared/media/levelMeter.js";
25
- import { requestDisplayMedia, setTracksEnabled, stopStream } from "../shared/media/deviceMedia.js";
41
+ import { requestDisplayMedia, stopStream } from "../shared/media/deviceMedia.js";
42
+ import type { LocalMedia } from "../shared/media/useLocalMedia.js";
26
43
  import { createBrowserPeerConnection } from "../shared/webrtc/realPeerConnection.js";
27
44
  import { MeshConnectionManager } from "../shared/webrtc/MeshConnectionManager.js";
28
45
  import type { MeshParticipant } from "../shared/webrtc/types.js";
29
46
  import { GuestSignalingClient } from "../shared/push/GuestSignalingClient.js";
30
- import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
31
47
  import CallControls, { type CallViewMode } from "./_CallControls.js";
32
48
  import ParticipantTile from "./_ParticipantTile.js";
33
49
 
@@ -39,13 +55,24 @@ export interface CallViewProps {
39
55
  token?: string;
40
56
  selfUid: string;
41
57
  selfName: string;
58
+ meetingTitle: string;
42
59
  iceServers: RTCIceServer[];
43
- initialStream: MediaStream;
44
- initialMicOn: boolean;
45
- initialCameraOn: boolean;
60
+ /** The camera and microphone, owned by the page (`[token].tsx`) - the lobby's tracks carried into the call. */
61
+ media: LocalMedia;
46
62
  onLeave: () => void;
47
63
  }
48
64
 
65
+ /** How long a reaction floats on screen. */
66
+ const REACTION_MS = 4_000;
67
+ /** The most reactions shown at once - a burst beyond this drops the oldest. */
68
+ const MAX_REACTIONS = 12;
69
+
70
+ interface Reaction {
71
+ id: number;
72
+ emoji: string;
73
+ name: string;
74
+ }
75
+
49
76
  /** Who is shown large - see this module's doc comment. `undefined` when nobody but the local participant has
50
77
  * joined yet (the grid/focus toggle still works, there's just nobody else to focus on). */
51
78
  export function computeMainUid(options: {
@@ -66,48 +93,60 @@ export function computeMainUid(options: {
66
93
  return options.otherUids[0];
67
94
  }
68
95
 
69
- export default function CallView({
70
- channel,
71
- token,
72
- selfUid,
73
- selfName,
74
- iceServers,
75
- initialStream,
76
- initialMicOn,
77
- initialCameraOn,
78
- onLeave,
79
- }: CallViewProps) {
96
+ /** This tab's identity in the call: the account (or guest) uid plus a random suffix, so the same account joining
97
+ * from two devices - or two tabs - is two participants rather than one that ignores itself. It travels as the
98
+ * `peer` of each message; `from` stays the exact authenticated uid, which the server requires. */
99
+ export function newPeerId(selfUid: string): string {
100
+ return `${selfUid}~${globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2)}`;
101
+ }
102
+
103
+ export default function CallView({ channel, token, selfUid, selfName, meetingTitle, iceServers, media, onLeave }: CallViewProps) {
104
+ const [peerId] = useState(() => newPeerId(selfUid));
80
105
  const [connectError, setConnectError] = useState<string | null>(null);
81
106
  const [participants, setParticipants] = useState<MeshParticipant[]>([]);
82
107
  const [remoteStreams, setRemoteStreams] = useState<Record<string, MediaStream>>({});
83
108
  const [levels, setLevels] = useState<Record<string, number>>({});
84
109
  const [presenterUid, setPresenterUid] = useState<string | undefined>(undefined);
85
- const [micOn, setMicOn] = useState(initialMicOn);
86
- const [cameraOn, setCameraOn] = useState(initialCameraOn);
87
- const [isPresenting, setIsPresenting] = useState(false);
110
+ const [screenStream, setScreenStream] = useState<MediaStream | null>(null);
111
+ const [handRaised, setHandRaised] = useState(false);
88
112
  const [viewMode, setViewMode] = useState<CallViewMode>("grid");
89
113
  const [pinnedUid, setPinnedUid] = useState<string | undefined>(undefined);
90
114
  const [activeSpeakerUid, setActiveSpeakerUid] = useState<string | undefined>(undefined);
115
+ const [reactions, setReactions] = useState<Reaction[]>([]);
116
+ const [announcement, setAnnouncement] = useState("");
117
+ const [audioBlocked, setAudioBlocked] = useState(false);
118
+ const [audioNonce, setAudioNonce] = useState(0);
91
119
 
92
120
  const managerRef = useRef<MeshConnectionManager | null>(null);
93
- const clientRef = useRef<GuestSignalingClient | null>(null);
94
- const localStreamRef = useRef(initialStream);
95
121
  const screenStreamRef = useRef<MediaStream | null>(null);
96
- const cameraTrackRef = useRef<MediaStreamTrack | null>(initialStream.getVideoTracks()[0] ?? null);
97
122
  const levelMetersRef = useRef<Record<string, LevelMeterHandle>>({});
98
- const isPresentingRef = useRef(false);
123
+ const reactionSeqRef = useRef(0);
124
+ const reactionTimersRef = useRef(new Set<ReturnType<typeof setTimeout>>());
125
+ const isPresenting = !!screenStream;
126
+
127
+ const showReaction = useCallback((emoji: string, name: string) => {
128
+ const id = ++reactionSeqRef.current;
129
+ setReactions((prev) => [...prev.slice(-(MAX_REACTIONS - 1)), { id, emoji, name }]);
130
+ const timer = setTimeout(() => {
131
+ reactionTimersRef.current.delete(timer);
132
+ setReactions((prev) => prev.filter((reaction) => reaction.id !== id));
133
+ }, REACTION_MS);
134
+ reactionTimersRef.current.add(timer);
135
+ }, []);
99
136
 
100
137
  useEffect(() => {
101
138
  let cancelled = false;
102
139
  const client = new GuestSignalingClient({ channel, token });
103
- clientRef.current = client;
104
140
  const manager = new MeshConnectionManager({
105
141
  selfUid,
142
+ peerId,
106
143
  selfName,
107
144
  iceServers,
108
145
  channel: client,
109
146
  createPeerConnection: createBrowserPeerConnection,
110
- localStream: localStreamRef.current,
147
+ localAudioTrack: media.audioTrack,
148
+ localVideoTrack: media.videoTrack,
149
+ localState: { audioOn: media.micOn, videoOn: media.cameraOn },
111
150
  });
112
151
  managerRef.current = manager;
113
152
 
@@ -116,6 +155,9 @@ export default function CallView({
116
155
  case "participant-joined":
117
156
  setParticipants((prev) => [...prev.filter((p) => p.uid !== event.participant.uid), event.participant]);
118
157
  return;
158
+ case "participant-updated":
159
+ setParticipants((prev) => prev.map((p) => (p.uid === event.participant.uid ? event.participant : p)));
160
+ return;
119
161
  case "participant-left":
120
162
  setParticipants((prev) => prev.filter((p) => p.uid !== event.uid));
121
163
  setRemoteStreams((prev) => {
@@ -135,9 +177,16 @@ export default function CallView({
135
177
  setLevels((prev) => ({ ...prev, [event.uid]: level })),
136
178
  );
137
179
  return;
180
+ case "hand-raised":
181
+ playRaisedHandChime();
182
+ setAnnouncement(`${event.name} raised a hand`);
183
+ return;
184
+ case "reaction":
185
+ showReaction(event.emoji, event.name);
186
+ return;
138
187
  case "presenter-changed":
139
188
  setPresenterUid(event.uid);
140
- if (event.uid !== selfUid && isPresentingRef.current) {
189
+ if (event.uid !== peerId && screenStreamRef.current) {
141
190
  // Lost a presenter-claim collision (see `MeshConnectionManager`'s doc comment) - stop our
142
191
  // own capture without re-sending a release the manager already handled internally.
143
192
  stopLocalPresentation(false);
@@ -159,8 +208,15 @@ export default function CallView({
159
208
  }
160
209
  });
161
210
 
211
+ // A closing tab doesn't unmount React, so say goodbye explicitly - otherwise everyone else keeps a tile for a
212
+ // participant who is gone.
213
+ const onPageHide = () => manager.stop();
214
+ window.addEventListener("pagehide", onPageHide);
215
+
216
+ const timers = reactionTimersRef.current;
162
217
  return () => {
163
218
  cancelled = true;
219
+ window.removeEventListener("pagehide", onPageHide);
164
220
  unsubscribe();
165
221
  manager.stop();
166
222
  client.close();
@@ -168,13 +224,29 @@ export default function CallView({
168
224
  handle.stop();
169
225
  }
170
226
  levelMetersRef.current = {};
171
- stopStream(localStreamRef.current);
227
+ // Only the screen capture is this view's to stop - the camera and microphone belong to the page, which
228
+ // releases them when the participant leaves.
172
229
  stopStream(screenStreamRef.current);
230
+ for (const timer of timers) {
231
+ clearTimeout(timer);
232
+ }
233
+ timers.clear();
173
234
  };
174
235
  // Deliberately runs once - the call's identity (channel/token/selfUid) never changes for the life of this
175
236
  // component; a real identity change is a new call, which unmounts/remounts this view from `[token].tsx`.
176
237
  }, []);
177
238
 
239
+ // What is sent follows what the participant has: the camera or the shared screen, and the microphone.
240
+ useEffect(() => {
241
+ managerRef.current?.setLocalTrack("audio", media.audioTrack);
242
+ }, [media.audioTrack]);
243
+ useEffect(() => {
244
+ managerRef.current?.setLocalTrack("video", screenStream?.getVideoTracks()[0] ?? media.videoTrack);
245
+ }, [media.videoTrack, screenStream]);
246
+ useEffect(() => {
247
+ managerRef.current?.setLocalState({ audioOn: media.micOn, videoOn: media.cameraOn || isPresenting, handRaised });
248
+ }, [media.micOn, media.cameraOn, isPresenting, handRaised]);
249
+
178
250
  useEffect(() => {
179
251
  if (!presenterUid) {
180
252
  setActiveSpeakerUid((prev) => pickActiveSpeaker(levels, prev));
@@ -182,21 +254,17 @@ export default function CallView({
182
254
  }, [levels, presenterUid]);
183
255
 
184
256
  function stopLocalPresentation(alsoRelease: boolean): void {
185
- isPresentingRef.current = false;
186
- setIsPresenting(false);
187
257
  if (alsoRelease) {
188
258
  managerRef.current?.releasePresenter();
189
259
  }
190
260
  stopStream(screenStreamRef.current);
191
261
  screenStreamRef.current = null;
192
- // Restores the camera track as the outgoing video sender - its own `enabled` flag (toggled by
193
- // `handleToggleCamera`, untouched by presenting) already governs whether it actually sends frames, so
194
- // this is correct whether or not the camera happens to be off right now.
195
- managerRef.current?.replaceLocalVideoTrack(cameraTrackRef.current);
262
+ // The sync effect above swaps the camera back in as the outgoing video track.
263
+ setScreenStream(null);
196
264
  }
197
265
 
198
266
  async function handleToggleShare() {
199
- if (isPresenting) {
267
+ if (screenStreamRef.current) {
200
268
  stopLocalPresentation(true);
201
269
  return;
202
270
  }
@@ -211,114 +279,208 @@ export default function CallView({
211
279
  return;
212
280
  }
213
281
  screenStreamRef.current = result.value;
214
- const [screenTrack] = result.value.getVideoTracks();
215
- managerRef.current?.replaceLocalVideoTrack(screenTrack);
216
- isPresentingRef.current = true;
217
- setIsPresenting(true);
218
- screenTrack.onended = () => stopLocalPresentation(true);
282
+ setScreenStream(result.value);
283
+ result.value.getVideoTracks()[0].onended = () => stopLocalPresentation(true);
219
284
  }
220
285
 
221
- function handleToggleMic() {
222
- setMicOn((prev) => {
223
- const next = !prev;
224
- setTracksEnabled(localStreamRef.current, "audio", next);
225
- return next;
226
- });
286
+ function handleToggleHand() {
287
+ setHandRaised((prev) => !prev);
227
288
  }
228
289
 
229
- function handleToggleCamera() {
230
- setCameraOn((prev) => {
231
- const next = !prev;
232
- // Just the track's own `enabled` flag (see `deviceMedia.ts`'s `setTracksEnabled()` doc comment) -
233
- // whether that track is actually the one being sent (camera) or has been swapped out for a screen
234
- // share (`replaceLocalVideoTrack()`) is an orthogonal concern this toggle never touches.
235
- setTracksEnabled(localStreamRef.current, "video", next);
236
- return next;
237
- });
238
- }
239
-
240
- function handleLeave() {
241
- onLeave();
290
+ function handleReaction(emoji: string) {
291
+ if (managerRef.current?.sendReaction(emoji)) {
292
+ showReaction(emoji, "You");
293
+ }
242
294
  }
243
295
 
244
296
  function togglePin(uid: string) {
245
297
  setPinnedUid((prev) => (prev === uid ? undefined : uid));
246
298
  }
247
299
 
300
+ function handleAudioBlocked() {
301
+ setAudioBlocked(true);
302
+ }
303
+
304
+ function handleResumeAudio() {
305
+ setAudioBlocked(false);
306
+ setAudioNonce((prev) => prev + 1);
307
+ }
308
+
248
309
  const otherUids = participants.map((p) => p.uid);
310
+ // A pin or an active-speaker pick can name someone who has just left, until the state catches up.
311
+ const stillHere = (uid: string | undefined) => (uid && otherUids.includes(uid) ? uid : undefined);
249
312
  const showFocusLayout = !!presenterUid || viewMode === "focus";
250
- const mainUid = showFocusLayout ? computeMainUid({ presenterUid, pinnedUid, activeSpeakerUid, otherUids }) : undefined;
251
- const presenterName = presenterUid ? (presenterUid === selfUid ? selfName : (participants.find((p) => p.uid === presenterUid)?.name ?? "Someone")) : undefined;
252
-
253
- const localTile = (
254
- <ParticipantTile
255
- key="__self"
256
- name={selfName}
257
- stream={isPresenting ? null : localStreamRef.current}
258
- isLocal
259
- cameraOff={!cameraOn && !isPresenting}
260
- micMuted={!micOn}
261
- isFocused={mainUid === selfUid}
262
- onClick={() => togglePin(selfUid)}
263
- />
264
- );
313
+ const mainUid = showFocusLayout
314
+ ? computeMainUid({ presenterUid, pinnedUid: stillHere(pinnedUid), activeSpeakerUid: stillHere(activeSpeakerUid), otherUids })
315
+ : undefined;
316
+ const presenterName = presenterUid ? (presenterUid === peerId ? selfName : (participants.find((p) => p.uid === presenterUid)?.name ?? "Someone")) : undefined;
317
+ const raisedNames = [...(handRaised ? ["You"] : []), ...participants.filter((p) => p.handRaised).map((p) => p.name)];
318
+ const alone = participants.length === 0;
265
319
 
266
- const remoteTiles = participants.map((p) => (
320
+ const remoteTile = (p: MeshParticipant, className?: string) => (
267
321
  <ParticipantTile
268
322
  key={p.uid}
269
323
  name={p.name}
270
324
  stream={remoteStreams[p.uid] ?? null}
271
- cameraOff={!remoteStreams[p.uid]}
325
+ cameraOff={!p.videoOn}
326
+ micMuted={!p.audioOn}
327
+ handRaised={p.handRaised}
272
328
  isFocused={mainUid === p.uid}
329
+ contain={presenterUid === p.uid}
330
+ className={className}
273
331
  onClick={() => togglePin(p.uid)}
274
332
  />
275
- ));
333
+ );
276
334
 
277
- const allTiles = [localTile, ...remoteTiles];
278
- // The presenter's own screen content is rendered from their local capture, not their (replaced) outgoing
279
- // video sender - `mainStream` picks whichever is right for `mainUid`.
280
- const mainStream =
281
- mainUid === selfUid ? (isPresenting ? screenStreamRef.current : localStreamRef.current) : mainUid ? (remoteStreams[mainUid] ?? null) : null;
282
- const mainName = mainUid === selfUid ? selfName : (participants.find((p) => p.uid === mainUid)?.name ?? "");
283
- const thumbnailTiles = showFocusLayout ? allTiles.filter((tile) => tile.key !== (mainUid === selfUid ? "__self" : mainUid)) : allTiles;
335
+ const selfTile = (className?: string) => (
336
+ <ParticipantTile
337
+ name={selfName}
338
+ stream={media.videoStream}
339
+ isLocal
340
+ cameraOff={!media.cameraOn}
341
+ micMuted={!media.micOn}
342
+ handRaised={handRaised}
343
+ className={className}
344
+ />
345
+ );
346
+
347
+ const mainParticipant = participants.find((p) => p.uid === mainUid);
348
+ const thumbnails = participants.filter((p) => p.uid !== mainUid);
349
+
350
+ let stage: React.ReactNode;
351
+ if (alone) {
352
+ // Nobody else yet: the local participant fills the tile area, presenting or not.
353
+ stage = isPresenting ? (
354
+ <ParticipantTile name={selfName} stream={screenStream} isLocal contain isFocused className="h-full" />
355
+ ) : (
356
+ selfTile("h-full")
357
+ );
358
+ } else if (showFocusLayout && (mainParticipant || isPresenting)) {
359
+ stage = (
360
+ <div className="h-full flex flex-col gap-2">
361
+ <div className="flex-1 min-h-0" data-testid="main-tile">
362
+ {mainParticipant ? (
363
+ remoteTile(mainParticipant, "h-full")
364
+ ) : (
365
+ <ParticipantTile name={selfName} stream={screenStream} isLocal contain isFocused className="h-full" />
366
+ )}
367
+ </div>
368
+ {thumbnails.length > 0 && (
369
+ <div className="flex gap-2 overflow-x-auto h-24 shrink-0" data-testid="thumbnails">
370
+ {thumbnails.map((p) => (
371
+ <div key={p.uid} className="w-36 shrink-0">
372
+ {remoteTile(p, "h-full")}
373
+ </div>
374
+ ))}
375
+ </div>
376
+ )}
377
+ </div>
378
+ );
379
+ } else {
380
+ stage = (
381
+ <div className="h-full grid gap-2 auto-rows-fr [grid-template-columns:repeat(auto-fit,minmax(min(100%,320px),1fr))]">
382
+ {participants.map((p) => remoteTile(p, "h-full"))}
383
+ </div>
384
+ );
385
+ }
284
386
 
285
387
  return (
286
- <div className="min-h-screen flex flex-col bg-surface-alt">
287
- {connectError && <Alert>{connectError}</Alert>}
288
- <div className="flex-1 p-3 flex flex-col gap-3 min-h-0">
289
- {showFocusLayout && mainUid ? (
290
- <>
291
- <div className="flex-1 min-h-0">
292
- <ParticipantTile name={mainName} stream={mainStream} isLocal={mainUid === selfUid} isFocused className="h-full" />
293
- </div>
294
- <div className="flex gap-2 overflow-x-auto h-24 shrink-0">
295
- {thumbnailTiles.map((tile) => (
296
- <div key={tile.key} className="w-32 shrink-0">
297
- {tile}
298
- </div>
299
- ))}
300
- </div>
301
- </>
302
- ) : (
303
- <div className="flex-1 grid gap-2 [grid-template-columns:repeat(auto-fit,minmax(180px,1fr))]">{allTiles}</div>
388
+ <div className="fixed inset-0 z-50 flex flex-col bg-[#202124] text-white overflow-hidden">
389
+ <style>{`@keyframes meet-float { 0% { transform: translateY(0) scale(.6); opacity: 0; } 12% { opacity: 1; transform: translateY(-4vh) scale(1); } 100% { transform: translateY(-45vh) scale(1); opacity: 0; } }`}</style>
390
+ <header className="shrink-0 flex items-center justify-between gap-3 px-4 py-3">
391
+ <h1 className="min-w-0 truncate text-base font-medium">{meetingTitle}</h1>
392
+ <div className="flex items-center gap-2 shrink-0">
393
+ {raisedNames.length > 0 && (
394
+ <span className="max-w-[45vw] truncate px-3 py-1.5 rounded-full bg-[#a8c7fa] text-[#062e6f] text-sm font-medium" data-testid="raised-hands">
395
+ ✋ {raisedNames.join(", ")}
396
+ </span>
397
+ )}
398
+ <span className="px-3 py-1.5 rounded-full bg-[#3c4043] text-sm" aria-label={`${participants.length + 1} participants`}>
399
+ {participants.length + 1}
400
+ </span>
401
+ </div>
402
+ </header>
403
+ {connectError && (
404
+ <div role="alert" className="shrink-0 mx-3 mb-2 px-3 py-2 rounded-lg bg-[#601410] text-[#f9dedc] text-sm">
405
+ {connectError}
406
+ </div>
407
+ )}
408
+ {audioBlocked && (
409
+ <button
410
+ type="button"
411
+ className="shrink-0 mx-3 mb-2 px-3 py-2 rounded-lg bg-[#a8c7fa] text-[#062e6f] text-sm font-medium"
412
+ onClick={handleResumeAudio}
413
+ >
414
+ Click here to turn on sound
415
+ </button>
416
+ )}
417
+ <main className="relative flex-1 min-h-0 px-3 pb-3">
418
+ {stage}
419
+ {presenterName && presenterUid !== peerId && (
420
+ <p className="absolute top-2 left-5 px-2 py-0.5 rounded bg-black/60 text-sm">{presenterName} is presenting</p>
304
421
  )}
422
+ </main>
423
+ <footer className="shrink-0 pt-1 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
424
+ <CallControls
425
+ media={media}
426
+ isPresenting={isPresenting}
427
+ presentingElsewhereName={presenterUid && presenterUid !== peerId ? presenterName : undefined}
428
+ onToggleShare={() => void handleToggleShare()}
429
+ handRaised={handRaised}
430
+ onToggleHand={handleToggleHand}
431
+ onReaction={handleReaction}
432
+ viewMode={viewMode}
433
+ onToggleViewMode={() => setViewMode((prev) => (prev === "grid" ? "focus" : "grid"))}
434
+ onLeave={onLeave}
435
+ />
436
+ </footer>
437
+
438
+ {!alone && (
439
+ <div
440
+ className="absolute z-10 right-3 top-14 w-28 sm:top-auto sm:bottom-24 sm:w-52 aspect-video shadow-xl xl:right-4 xl:bottom-4"
441
+ data-testid="self-view"
442
+ >
443
+ {selfTile("h-full")}
444
+ </div>
445
+ )}
446
+
447
+ <div className="pointer-events-none absolute left-4 bottom-28 w-40 h-[45vh]" aria-hidden="true">
448
+ {reactions.map((reaction) => (
449
+ <div
450
+ key={reaction.id}
451
+ className="absolute bottom-0 flex flex-col items-center"
452
+ style={{ left: `${(reaction.id % 4) * 36}px`, animation: `meet-float ${REACTION_MS}ms ease-out forwards` }}
453
+ data-testid="reaction"
454
+ >
455
+ <span className="text-4xl leading-none">{reaction.emoji}</span>
456
+ <span className="mt-1 px-1.5 rounded-full bg-[#a8c7fa] text-[#062e6f] text-xs">{reaction.name}</span>
457
+ </div>
458
+ ))}
459
+ </div>
460
+
461
+ {participants.map((p) => {
462
+ const stream = remoteStreams[p.uid];
463
+ return stream ? <RemoteAudio key={`${p.uid}:${audioNonce}`} stream={stream} onBlocked={handleAudioBlocked} /> : null;
464
+ })}
465
+ <div role="status" aria-live="polite" className="sr-only">
466
+ {announcement}
305
467
  </div>
306
- <CallControls
307
- micOn={micOn}
308
- onToggleMic={handleToggleMic}
309
- cameraOn={cameraOn}
310
- onToggleCamera={handleToggleCamera}
311
- isPresenting={isPresenting}
312
- presentingElsewhereName={presenterUid && presenterUid !== selfUid ? presenterName : undefined}
313
- onToggleShare={handleToggleShare}
314
- viewMode={viewMode}
315
- onToggleViewMode={() => setViewMode((prev) => (prev === "grid" ? "focus" : "grid"))}
316
- onLeave={handleLeave}
317
- />
318
468
  </div>
319
469
  );
320
470
  }
321
471
 
472
+ /** Plays one remote participant's stream through a hidden `<audio>` element. `onBlocked` fires if the browser
473
+ * refuses to start it (an autoplay policy) - the view then asks for a click. */
474
+ function RemoteAudio({ stream, onBlocked }: { stream: MediaStream; onBlocked: () => void }) {
475
+ const audioRef = useRef<HTMLAudioElement>(null);
476
+ useEffect(() => {
477
+ const element = audioRef.current!;
478
+ element.srcObject = stream;
479
+ void Promise.resolve(element.play()).catch(onBlocked);
480
+ }, [stream, onBlocked]);
481
+ return <audio ref={audioRef} autoPlay data-testid="remote-audio" />;
482
+ }
483
+
322
484
  function stopLevelMeter(ref: React.MutableRefObject<Record<string, LevelMeterHandle>>, uid: string): void {
323
485
  ref.current[uid]?.stop();
324
486
  delete ref.current[uid];