@rapidmx/meet-plugin 0.2.0 → 0.3.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 (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 +275 -115
  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 +212 -44
  12. package/apps/shared/webrtc/realPeerConnection.ts +16 -3
  13. package/apps/shared/webrtc/types.ts +52 -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 +23 -4
  18. package/dist/apps/meet/_CallView.js +141 -58
  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 +57 -17
  33. package/dist/apps/shared/webrtc/MeshConnectionManager.js +212 -31
  34. package/dist/apps/shared/webrtc/realPeerConnection.js +16 -3
  35. package/dist/apps/shared/webrtc/types.d.ts +52 -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,58 @@ 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 on the signaling channel: the account (or guest) uid plus a random suffix, so the same
97
+ * account joining from two devices - or two tabs - is two participants rather than one that ignores itself. */
98
+ export function newPeerId(selfUid: string): string {
99
+ return `${selfUid}~${globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2)}`;
100
+ }
101
+
102
+ export default function CallView({ channel, token, selfUid, selfName, meetingTitle, iceServers, media, onLeave }: CallViewProps) {
103
+ const [peerId] = useState(() => newPeerId(selfUid));
80
104
  const [connectError, setConnectError] = useState<string | null>(null);
81
105
  const [participants, setParticipants] = useState<MeshParticipant[]>([]);
82
106
  const [remoteStreams, setRemoteStreams] = useState<Record<string, MediaStream>>({});
83
107
  const [levels, setLevels] = useState<Record<string, number>>({});
84
108
  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);
109
+ const [screenStream, setScreenStream] = useState<MediaStream | null>(null);
110
+ const [handRaised, setHandRaised] = useState(false);
88
111
  const [viewMode, setViewMode] = useState<CallViewMode>("grid");
89
112
  const [pinnedUid, setPinnedUid] = useState<string | undefined>(undefined);
90
113
  const [activeSpeakerUid, setActiveSpeakerUid] = useState<string | undefined>(undefined);
114
+ const [reactions, setReactions] = useState<Reaction[]>([]);
115
+ const [announcement, setAnnouncement] = useState("");
116
+ const [audioBlocked, setAudioBlocked] = useState(false);
117
+ const [audioNonce, setAudioNonce] = useState(0);
91
118
 
92
119
  const managerRef = useRef<MeshConnectionManager | null>(null);
93
- const clientRef = useRef<GuestSignalingClient | null>(null);
94
- const localStreamRef = useRef(initialStream);
95
120
  const screenStreamRef = useRef<MediaStream | null>(null);
96
- const cameraTrackRef = useRef<MediaStreamTrack | null>(initialStream.getVideoTracks()[0] ?? null);
97
121
  const levelMetersRef = useRef<Record<string, LevelMeterHandle>>({});
98
- const isPresentingRef = useRef(false);
122
+ const reactionSeqRef = useRef(0);
123
+ const reactionTimersRef = useRef(new Set<ReturnType<typeof setTimeout>>());
124
+ const isPresenting = !!screenStream;
125
+
126
+ const showReaction = useCallback((emoji: string, name: string) => {
127
+ const id = ++reactionSeqRef.current;
128
+ setReactions((prev) => [...prev.slice(-(MAX_REACTIONS - 1)), { id, emoji, name }]);
129
+ const timer = setTimeout(() => {
130
+ reactionTimersRef.current.delete(timer);
131
+ setReactions((prev) => prev.filter((reaction) => reaction.id !== id));
132
+ }, REACTION_MS);
133
+ reactionTimersRef.current.add(timer);
134
+ }, []);
99
135
 
100
136
  useEffect(() => {
101
137
  let cancelled = false;
102
138
  const client = new GuestSignalingClient({ channel, token });
103
- clientRef.current = client;
104
139
  const manager = new MeshConnectionManager({
105
- selfUid,
140
+ selfUid: peerId,
106
141
  selfName,
107
142
  iceServers,
108
143
  channel: client,
109
144
  createPeerConnection: createBrowserPeerConnection,
110
- localStream: localStreamRef.current,
145
+ localAudioTrack: media.audioTrack,
146
+ localVideoTrack: media.videoTrack,
147
+ localState: { audioOn: media.micOn, videoOn: media.cameraOn },
111
148
  });
112
149
  managerRef.current = manager;
113
150
 
@@ -116,6 +153,9 @@ export default function CallView({
116
153
  case "participant-joined":
117
154
  setParticipants((prev) => [...prev.filter((p) => p.uid !== event.participant.uid), event.participant]);
118
155
  return;
156
+ case "participant-updated":
157
+ setParticipants((prev) => prev.map((p) => (p.uid === event.participant.uid ? event.participant : p)));
158
+ return;
119
159
  case "participant-left":
120
160
  setParticipants((prev) => prev.filter((p) => p.uid !== event.uid));
121
161
  setRemoteStreams((prev) => {
@@ -135,9 +175,16 @@ export default function CallView({
135
175
  setLevels((prev) => ({ ...prev, [event.uid]: level })),
136
176
  );
137
177
  return;
178
+ case "hand-raised":
179
+ playRaisedHandChime();
180
+ setAnnouncement(`${event.name} raised a hand`);
181
+ return;
182
+ case "reaction":
183
+ showReaction(event.emoji, event.name);
184
+ return;
138
185
  case "presenter-changed":
139
186
  setPresenterUid(event.uid);
140
- if (event.uid !== selfUid && isPresentingRef.current) {
187
+ if (event.uid !== peerId && screenStreamRef.current) {
141
188
  // Lost a presenter-claim collision (see `MeshConnectionManager`'s doc comment) - stop our
142
189
  // own capture without re-sending a release the manager already handled internally.
143
190
  stopLocalPresentation(false);
@@ -159,8 +206,15 @@ export default function CallView({
159
206
  }
160
207
  });
161
208
 
209
+ // A closing tab doesn't unmount React, so say goodbye explicitly - otherwise everyone else keeps a tile for a
210
+ // participant who is gone.
211
+ const onPageHide = () => manager.stop();
212
+ window.addEventListener("pagehide", onPageHide);
213
+
214
+ const timers = reactionTimersRef.current;
162
215
  return () => {
163
216
  cancelled = true;
217
+ window.removeEventListener("pagehide", onPageHide);
164
218
  unsubscribe();
165
219
  manager.stop();
166
220
  client.close();
@@ -168,13 +222,29 @@ export default function CallView({
168
222
  handle.stop();
169
223
  }
170
224
  levelMetersRef.current = {};
171
- stopStream(localStreamRef.current);
225
+ // Only the screen capture is this view's to stop - the camera and microphone belong to the page, which
226
+ // releases them when the participant leaves.
172
227
  stopStream(screenStreamRef.current);
228
+ for (const timer of timers) {
229
+ clearTimeout(timer);
230
+ }
231
+ timers.clear();
173
232
  };
174
233
  // Deliberately runs once - the call's identity (channel/token/selfUid) never changes for the life of this
175
234
  // component; a real identity change is a new call, which unmounts/remounts this view from `[token].tsx`.
176
235
  }, []);
177
236
 
237
+ // What is sent follows what the participant has: the camera or the shared screen, and the microphone.
238
+ useEffect(() => {
239
+ managerRef.current?.setLocalTrack("audio", media.audioTrack);
240
+ }, [media.audioTrack]);
241
+ useEffect(() => {
242
+ managerRef.current?.setLocalTrack("video", screenStream?.getVideoTracks()[0] ?? media.videoTrack);
243
+ }, [media.videoTrack, screenStream]);
244
+ useEffect(() => {
245
+ managerRef.current?.setLocalState({ audioOn: media.micOn, videoOn: media.cameraOn || isPresenting, handRaised });
246
+ }, [media.micOn, media.cameraOn, isPresenting, handRaised]);
247
+
178
248
  useEffect(() => {
179
249
  if (!presenterUid) {
180
250
  setActiveSpeakerUid((prev) => pickActiveSpeaker(levels, prev));
@@ -182,21 +252,17 @@ export default function CallView({
182
252
  }, [levels, presenterUid]);
183
253
 
184
254
  function stopLocalPresentation(alsoRelease: boolean): void {
185
- isPresentingRef.current = false;
186
- setIsPresenting(false);
187
255
  if (alsoRelease) {
188
256
  managerRef.current?.releasePresenter();
189
257
  }
190
258
  stopStream(screenStreamRef.current);
191
259
  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);
260
+ // The sync effect above swaps the camera back in as the outgoing video track.
261
+ setScreenStream(null);
196
262
  }
197
263
 
198
264
  async function handleToggleShare() {
199
- if (isPresenting) {
265
+ if (screenStreamRef.current) {
200
266
  stopLocalPresentation(true);
201
267
  return;
202
268
  }
@@ -211,114 +277,208 @@ export default function CallView({
211
277
  return;
212
278
  }
213
279
  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);
280
+ setScreenStream(result.value);
281
+ result.value.getVideoTracks()[0].onended = () => stopLocalPresentation(true);
219
282
  }
220
283
 
221
- function handleToggleMic() {
222
- setMicOn((prev) => {
223
- const next = !prev;
224
- setTracksEnabled(localStreamRef.current, "audio", next);
225
- return next;
226
- });
284
+ function handleToggleHand() {
285
+ setHandRaised((prev) => !prev);
227
286
  }
228
287
 
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();
288
+ function handleReaction(emoji: string) {
289
+ if (managerRef.current?.sendReaction(emoji)) {
290
+ showReaction(emoji, "You");
291
+ }
242
292
  }
243
293
 
244
294
  function togglePin(uid: string) {
245
295
  setPinnedUid((prev) => (prev === uid ? undefined : uid));
246
296
  }
247
297
 
298
+ function handleAudioBlocked() {
299
+ setAudioBlocked(true);
300
+ }
301
+
302
+ function handleResumeAudio() {
303
+ setAudioBlocked(false);
304
+ setAudioNonce((prev) => prev + 1);
305
+ }
306
+
248
307
  const otherUids = participants.map((p) => p.uid);
308
+ // A pin or an active-speaker pick can name someone who has just left, until the state catches up.
309
+ const stillHere = (uid: string | undefined) => (uid && otherUids.includes(uid) ? uid : undefined);
249
310
  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
- );
311
+ const mainUid = showFocusLayout
312
+ ? computeMainUid({ presenterUid, pinnedUid: stillHere(pinnedUid), activeSpeakerUid: stillHere(activeSpeakerUid), otherUids })
313
+ : undefined;
314
+ const presenterName = presenterUid ? (presenterUid === peerId ? selfName : (participants.find((p) => p.uid === presenterUid)?.name ?? "Someone")) : undefined;
315
+ const raisedNames = [...(handRaised ? ["You"] : []), ...participants.filter((p) => p.handRaised).map((p) => p.name)];
316
+ const alone = participants.length === 0;
265
317
 
266
- const remoteTiles = participants.map((p) => (
318
+ const remoteTile = (p: MeshParticipant, className?: string) => (
267
319
  <ParticipantTile
268
320
  key={p.uid}
269
321
  name={p.name}
270
322
  stream={remoteStreams[p.uid] ?? null}
271
- cameraOff={!remoteStreams[p.uid]}
323
+ cameraOff={!p.videoOn}
324
+ micMuted={!p.audioOn}
325
+ handRaised={p.handRaised}
272
326
  isFocused={mainUid === p.uid}
327
+ contain={presenterUid === p.uid}
328
+ className={className}
273
329
  onClick={() => togglePin(p.uid)}
274
330
  />
275
- ));
331
+ );
276
332
 
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;
333
+ const selfTile = (className?: string) => (
334
+ <ParticipantTile
335
+ name={selfName}
336
+ stream={media.videoStream}
337
+ isLocal
338
+ cameraOff={!media.cameraOn}
339
+ micMuted={!media.micOn}
340
+ handRaised={handRaised}
341
+ className={className}
342
+ />
343
+ );
344
+
345
+ const mainParticipant = participants.find((p) => p.uid === mainUid);
346
+ const thumbnails = participants.filter((p) => p.uid !== mainUid);
347
+
348
+ let stage: React.ReactNode;
349
+ if (alone) {
350
+ // Nobody else yet: the local participant fills the tile area, presenting or not.
351
+ stage = isPresenting ? (
352
+ <ParticipantTile name={selfName} stream={screenStream} isLocal contain isFocused className="h-full" />
353
+ ) : (
354
+ selfTile("h-full")
355
+ );
356
+ } else if (showFocusLayout && (mainParticipant || isPresenting)) {
357
+ stage = (
358
+ <div className="h-full flex flex-col gap-2">
359
+ <div className="flex-1 min-h-0" data-testid="main-tile">
360
+ {mainParticipant ? (
361
+ remoteTile(mainParticipant, "h-full")
362
+ ) : (
363
+ <ParticipantTile name={selfName} stream={screenStream} isLocal contain isFocused className="h-full" />
364
+ )}
365
+ </div>
366
+ {thumbnails.length > 0 && (
367
+ <div className="flex gap-2 overflow-x-auto h-24 shrink-0" data-testid="thumbnails">
368
+ {thumbnails.map((p) => (
369
+ <div key={p.uid} className="w-36 shrink-0">
370
+ {remoteTile(p, "h-full")}
371
+ </div>
372
+ ))}
373
+ </div>
374
+ )}
375
+ </div>
376
+ );
377
+ } else {
378
+ stage = (
379
+ <div className="h-full grid gap-2 auto-rows-fr [grid-template-columns:repeat(auto-fit,minmax(min(100%,320px),1fr))]">
380
+ {participants.map((p) => remoteTile(p, "h-full"))}
381
+ </div>
382
+ );
383
+ }
284
384
 
285
385
  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>
386
+ <div className="fixed inset-0 z-50 flex flex-col bg-[#202124] text-white overflow-hidden">
387
+ <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>
388
+ <header className="shrink-0 flex items-center justify-between gap-3 px-4 py-3">
389
+ <h1 className="min-w-0 truncate text-base font-medium">{meetingTitle}</h1>
390
+ <div className="flex items-center gap-2 shrink-0">
391
+ {raisedNames.length > 0 && (
392
+ <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">
393
+ ✋ {raisedNames.join(", ")}
394
+ </span>
395
+ )}
396
+ <span className="px-3 py-1.5 rounded-full bg-[#3c4043] text-sm" aria-label={`${participants.length + 1} participants`}>
397
+ {participants.length + 1}
398
+ </span>
399
+ </div>
400
+ </header>
401
+ {connectError && (
402
+ <div role="alert" className="shrink-0 mx-3 mb-2 px-3 py-2 rounded-lg bg-[#601410] text-[#f9dedc] text-sm">
403
+ {connectError}
404
+ </div>
405
+ )}
406
+ {audioBlocked && (
407
+ <button
408
+ type="button"
409
+ className="shrink-0 mx-3 mb-2 px-3 py-2 rounded-lg bg-[#a8c7fa] text-[#062e6f] text-sm font-medium"
410
+ onClick={handleResumeAudio}
411
+ >
412
+ Click here to turn on sound
413
+ </button>
414
+ )}
415
+ <main className="relative flex-1 min-h-0 px-3 pb-3">
416
+ {stage}
417
+ {presenterName && presenterUid !== peerId && (
418
+ <p className="absolute top-2 left-5 px-2 py-0.5 rounded bg-black/60 text-sm">{presenterName} is presenting</p>
304
419
  )}
420
+ </main>
421
+ <footer className="shrink-0 pt-1 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
422
+ <CallControls
423
+ media={media}
424
+ isPresenting={isPresenting}
425
+ presentingElsewhereName={presenterUid && presenterUid !== peerId ? presenterName : undefined}
426
+ onToggleShare={() => void handleToggleShare()}
427
+ handRaised={handRaised}
428
+ onToggleHand={handleToggleHand}
429
+ onReaction={handleReaction}
430
+ viewMode={viewMode}
431
+ onToggleViewMode={() => setViewMode((prev) => (prev === "grid" ? "focus" : "grid"))}
432
+ onLeave={onLeave}
433
+ />
434
+ </footer>
435
+
436
+ {!alone && (
437
+ <div
438
+ 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"
439
+ data-testid="self-view"
440
+ >
441
+ {selfTile("h-full")}
442
+ </div>
443
+ )}
444
+
445
+ <div className="pointer-events-none absolute left-4 bottom-28 w-40 h-[45vh]" aria-hidden="true">
446
+ {reactions.map((reaction) => (
447
+ <div
448
+ key={reaction.id}
449
+ className="absolute bottom-0 flex flex-col items-center"
450
+ style={{ left: `${(reaction.id % 4) * 36}px`, animation: `meet-float ${REACTION_MS}ms ease-out forwards` }}
451
+ data-testid="reaction"
452
+ >
453
+ <span className="text-4xl leading-none">{reaction.emoji}</span>
454
+ <span className="mt-1 px-1.5 rounded-full bg-[#a8c7fa] text-[#062e6f] text-xs">{reaction.name}</span>
455
+ </div>
456
+ ))}
457
+ </div>
458
+
459
+ {participants.map((p) => {
460
+ const stream = remoteStreams[p.uid];
461
+ return stream ? <RemoteAudio key={`${p.uid}:${audioNonce}`} stream={stream} onBlocked={handleAudioBlocked} /> : null;
462
+ })}
463
+ <div role="status" aria-live="polite" className="sr-only">
464
+ {announcement}
305
465
  </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
466
  </div>
319
467
  );
320
468
  }
321
469
 
470
+ /** Plays one remote participant's stream through a hidden `<audio>` element. `onBlocked` fires if the browser
471
+ * refuses to start it (an autoplay policy) - the view then asks for a click. */
472
+ function RemoteAudio({ stream, onBlocked }: { stream: MediaStream; onBlocked: () => void }) {
473
+ const audioRef = useRef<HTMLAudioElement>(null);
474
+ useEffect(() => {
475
+ const element = audioRef.current!;
476
+ element.srcObject = stream;
477
+ void Promise.resolve(element.play()).catch(onBlocked);
478
+ }, [stream, onBlocked]);
479
+ return <audio ref={audioRef} autoPlay data-testid="remote-audio" />;
480
+ }
481
+
322
482
  function stopLevelMeter(ref: React.MutableRefObject<Record<string, LevelMeterHandle>>, uid: string): void {
323
483
  ref.current[uid]?.stop();
324
484
  delete ref.current[uid];