@rapidmx/meet-plugin 0.15.0 → 0.16.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 (74) hide show
  1. package/README.md +35 -55
  2. package/apps/meet/[token].tsx +341 -341
  3. package/apps/meet/_CallControls.tsx +508 -483
  4. package/apps/meet/_CallView.tsx +1060 -924
  5. package/apps/meet/_ChatPanel.tsx +194 -0
  6. package/apps/meet/_ColorWheel.tsx +136 -0
  7. package/apps/meet/_MeetLobby.tsx +224 -224
  8. package/apps/meet/_ParticipantTile.tsx +243 -242
  9. package/apps/meet/_Whiteboard.tsx +577 -0
  10. package/apps/meet/_icons.tsx +135 -90
  11. package/apps/meet/_meetApi.ts +213 -212
  12. package/apps/shared/collab/CollabSession.ts +675 -0
  13. package/apps/shared/collab/WhiteboardModel.ts +216 -0
  14. package/apps/shared/collab/chatMarkdown.tsx +375 -0
  15. package/apps/shared/collab/color.ts +67 -0
  16. package/apps/shared/collab/protocol.ts +321 -0
  17. package/apps/shared/collab/whiteboardRender.ts +311 -0
  18. package/apps/shared/media/filters/VideoFilterProcessor.ts +408 -408
  19. package/apps/shared/media/filters/mlModels.ts +174 -174
  20. package/apps/shared/relay/RelayClient.ts +2 -1
  21. package/apps/shared/relay/RelayTransport.ts +2 -1
  22. package/apps/shared/relay/relayEnv.ts +1 -1
  23. package/apps/shared/signaling/SignalingClient.ts +335 -0
  24. package/apps/shared/webrtc/MeshConnectionManager.ts +971 -971
  25. package/apps/shared/webrtc/types.ts +335 -316
  26. package/dist/apps/meet/[token].js +1 -1
  27. package/dist/apps/meet/_CallControls.d.ts +12 -4
  28. package/dist/apps/meet/_CallControls.js +5 -5
  29. package/dist/apps/meet/_CallView.d.ts +19 -4
  30. package/dist/apps/meet/_CallView.js +100 -11
  31. package/dist/apps/meet/_ChatPanel.d.ts +24 -0
  32. package/dist/apps/meet/_ChatPanel.js +116 -0
  33. package/dist/apps/meet/_ColorWheel.d.ts +14 -0
  34. package/dist/apps/meet/_ColorWheel.js +82 -0
  35. package/dist/apps/meet/_ParticipantTile.d.ts +2 -1
  36. package/dist/apps/meet/_ParticipantTile.js +1 -1
  37. package/dist/apps/meet/_Whiteboard.d.ts +64 -0
  38. package/dist/apps/meet/_Whiteboard.js +312 -0
  39. package/dist/apps/meet/_icons.d.ts +12 -0
  40. package/dist/apps/meet/_icons.js +12 -0
  41. package/dist/apps/meet/_meetApi.d.ts +8 -7
  42. package/dist/apps/meet/_meetApi.js +2 -2
  43. package/dist/apps/shared/collab/CollabSession.d.ts +208 -0
  44. package/dist/apps/shared/collab/CollabSession.js +559 -0
  45. package/dist/apps/shared/collab/WhiteboardModel.d.ts +54 -0
  46. package/dist/apps/shared/collab/WhiteboardModel.js +197 -0
  47. package/dist/apps/shared/collab/chatMarkdown.d.ts +65 -0
  48. package/dist/apps/shared/collab/chatMarkdown.js +311 -0
  49. package/dist/apps/shared/collab/color.d.ts +18 -0
  50. package/dist/apps/shared/collab/color.js +59 -0
  51. package/dist/apps/shared/collab/protocol.d.ts +157 -0
  52. package/dist/apps/shared/collab/protocol.js +222 -0
  53. package/dist/apps/shared/collab/whiteboardRender.d.ts +31 -0
  54. package/dist/apps/shared/collab/whiteboardRender.js +268 -0
  55. package/dist/apps/shared/relay/RelayClient.js +2 -1
  56. package/dist/apps/shared/relay/RelayTransport.d.ts +2 -1
  57. package/dist/apps/shared/relay/RelayTransport.js +2 -1
  58. package/dist/apps/shared/relay/relayEnv.d.ts +1 -1
  59. package/dist/apps/shared/signaling/SignalingClient.d.ts +95 -0
  60. package/dist/apps/shared/signaling/SignalingClient.js +278 -0
  61. package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +7 -7
  62. package/dist/apps/shared/webrtc/MeshConnectionManager.js +8 -8
  63. package/dist/apps/shared/webrtc/types.d.ts +38 -24
  64. package/dist/apps/shared/webrtc/types.js +3 -4
  65. package/dist/lib/routes/BaseVideoMeetingRoute.js +171 -69
  66. package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
  67. package/dist/lib/util/SignalingHub.js +339 -0
  68. package/dist/lib/util/SignalingHub.js.map +1 -0
  69. package/dist/types/routes/BaseVideoMeetingRoute.d.ts +68 -46
  70. package/dist/types/util/SignalingHub.d.ts +112 -0
  71. package/package.json +208 -208
  72. package/apps/shared/push/GuestSignalingClient.ts +0 -359
  73. package/dist/apps/shared/push/GuestSignalingClient.d.ts +0 -98
  74. package/dist/apps/shared/push/GuestSignalingClient.js +0 -312
@@ -1,242 +1,243 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
- /** One participant's video tile - used by the grid, the focused/thumbnail-strip layouts and the local participant's
6
- * corner tile in `_CallView.tsx`. Renders a placeholder (the participant's initial) instead of a `<video>` element
7
- * whenever there's no stream or their camera is off, rather than showing a black/frozen frame.
8
- *
9
- * A tile never plays sound: its `<video>` is always muted, and what a participant says is played by the call view's
10
- * own `<audio>` elements (`_CallView.tsx`'s `RemoteAudio`), so a tile that isn't showing video - or isn't on screen
11
- * in the current layout - can never silence anyone.
12
- *
13
- * The local tile is shown mirrored (`scaleX(-1)`), like a real mirror, so a participant's own movements feel
14
- * natural - this is a local display transform only, never applied to the video actually sent to anyone, so
15
- * everyone else always sees the participant the right way round. `mirrored={false}` (`_CallView.tsx`, when a custom
16
- * background image is on) turns that off: the picture the participant chose is a fixed reference, not a live
17
- * reflection, and mirroring it would show it backwards to no one but themselves.
18
- *
19
- * `onHideSelf` adds a small "…" menu to the tile's corner, local-tile-only - see `_CallView.tsx`'s doc comment on
20
- * hiding the local tile from grid view. */
21
- import React, { useEffect, useRef, useState } from "react";
22
- import type { MediaTransport } from "../shared/webrtc/types.js";
23
- import { MicOffIcon, OverflowIcon } from "./_icons.js";
24
-
25
- /** What a tile says about how its participant's media is arriving, for the paths that are not the ordinary direct one
26
- * (which needs no comment). Text rather than only an icon, so the reason a picture is degraded is never a guess.
27
- * Exported so `_DiagnosticsPanel.tsx` shows the identical label/title rather than describing the same paths in
28
- * slightly different words. */
29
- export const TRANSPORT_BADGES: Partial<Record<MediaTransport, { label: string; title: string }>> = {
30
- turn: { label: "Relayed", title: "Connected through the relay server, because a direct connection was not possible." },
31
- "turn-tcp": {
32
- label: "Relayed (TCP)",
33
- title:
34
- "Connected through the relay server over TCP, because this network blocks the relay's usual UDP connection too. " +
35
- "Audio or video may pause briefly under network strain rather than just glitching - a property of TCP, not a dropped connection.",
36
- },
37
- websocket: {
38
- label: "Server relay",
39
- title: "Sent through the server as a last resort, because no other connection was possible. Video is lower quality and sound may lag.",
40
- },
41
- failed: { label: "Can't connect", title: "This participant could not be reached from your network." },
42
- };
43
-
44
- /** A short line saying the connection is still being made, with a spinner - a polite live region (not `role="status"`,
45
- * which the call view's own announcement region already is), so a participant who cannot see the tile still learns
46
- * why nothing is happening yet. */
47
- function StatusPill({ status, className }: { status: string; className?: string }) {
48
- return (
49
- <span
50
- aria-live="polite"
51
- data-testid="tile-status"
52
- className={`flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-black/60 text-white text-xs ${className ?? ""}`}
53
- >
54
- <span className="inline-block w-3 h-3 rounded-full border-2 border-white/30 border-t-white animate-spin" aria-hidden="true" />
55
- {status}
56
- </span>
57
- );
58
- }
59
-
60
- export interface ParticipantTileProps {
61
- name: string;
62
- stream?: MediaStream | null;
63
- isLocal?: boolean;
64
- /** Shows the initial instead of the video. */
65
- cameraOff?: boolean;
66
- micMuted?: boolean;
67
- handRaised?: boolean;
68
- /** How this participant's media reaches you - a badge is shown for everything but a direct or still-connecting one. */
69
- transport?: MediaTransport;
70
- /** Says the connection is still being made, e.g. "Connecting..." on the local tile and "Awaiting connection..." on a
71
- * participant's - shown with a spinner until the caller clears it. */
72
- status?: string;
73
- /** Highlights this tile as the current presenter/focus. */
74
- isFocused?: boolean;
75
- /** Fits the whole picture inside the tile instead of filling it - a shared screen must not be cropped. */
76
- contain?: boolean;
77
- /** Whether the local tile is shown mirrored, like a real mirror - ignored for a remote tile, which is never
78
- * mirrored. Default `true`; see this module's doc comment. */
79
- mirrored?: boolean;
80
- /** Offers a small "…" menu in the tile's top-right corner with one item, "Hide self" - see `_CallView.tsx`'s
81
- * doc comment on hiding the local tile. Omitted everywhere that shouldn't offer it: every remote tile, and the
82
- * local tile while alone in the call, where hiding it would just blank the only thing on screen. */
83
- onHideSelf?: () => void;
84
- onClick?: () => void;
85
- className?: string;
86
- }
87
-
88
- function initialOf(name: string): string {
89
- return (Array.from(name.trim())[0] ?? "?").toUpperCase();
90
- }
91
-
92
- export default function ParticipantTile({
93
- name,
94
- stream,
95
- isLocal,
96
- cameraOff,
97
- micMuted,
98
- handRaised,
99
- transport,
100
- status,
101
- isFocused,
102
- contain,
103
- mirrored = true,
104
- onHideSelf,
105
- onClick,
106
- className,
107
- }: ParticipantTileProps) {
108
- const badge = transport ? TRANSPORT_BADGES[transport] : undefined;
109
- const videoRef = useRef<HTMLVideoElement>(null);
110
- const showVideo = !!stream && !cameraOff;
111
- const [hideMenuOpen, setHideMenuOpen] = useState(false);
112
- const hideMenuRef = useRef<HTMLDivElement>(null);
113
-
114
- // The `<video>` element only exists while `showVideo`, so its stream is bound whenever it (re)appears too.
115
- useEffect(() => {
116
- if (videoRef.current) {
117
- videoRef.current.srcObject = stream ?? null;
118
- }
119
- }, [stream, showVideo]);
120
-
121
- // The tile's own "…" menu closes on Escape or a press anywhere outside it, same shape as every other menu in
122
- // this app.
123
- useEffect(() => {
124
- if (!hideMenuOpen) {
125
- return;
126
- }
127
- const onPointerDown = (event: PointerEvent) => {
128
- if (!hideMenuRef.current?.contains(event.target as Node)) {
129
- setHideMenuOpen(false);
130
- }
131
- };
132
- const onKeyDown = (event: KeyboardEvent) => {
133
- if (event.key === "Escape") {
134
- setHideMenuOpen(false);
135
- }
136
- };
137
- document.addEventListener("pointerdown", onPointerDown);
138
- document.addEventListener("keydown", onKeyDown);
139
- return () => {
140
- document.removeEventListener("pointerdown", onPointerDown);
141
- document.removeEventListener("keydown", onKeyDown);
142
- };
143
- }, [hideMenuOpen]);
144
-
145
- return (
146
- <div
147
- className={`relative overflow-hidden rounded-xl bg-[#3c4043] flex items-center justify-center min-h-[90px] ${
148
- isFocused ? "ring-2 ring-[#8ab4f8]" : ""
149
- } ${className ?? ""}`}
150
- onClick={onClick}
151
- role={onClick ? "button" : undefined}
152
- tabIndex={onClick ? 0 : undefined}
153
- aria-label={onClick ? `${name}${isLocal ? " (you)" : ""}` : undefined}
154
- >
155
- {showVideo ? (
156
- <>
157
- <video
158
- ref={videoRef}
159
- autoPlay
160
- playsInline
161
- muted
162
- className={`w-full h-full ${contain ? "object-contain bg-black" : "object-cover"} ${isLocal && !contain && mirrored ? "[transform:scaleX(-1)]" : ""}`}
163
- />
164
- {status && <StatusPill status={status} className="absolute top-2 left-1/2 -translate-x-1/2 whitespace-nowrap" />}
165
- </>
166
- ) : (
167
- <div className="flex flex-col items-center gap-2 max-w-full px-1">
168
- <div className="w-16 h-16 rounded-full bg-primary text-white flex items-center justify-center text-2xl font-bold" aria-hidden="true">
169
- {initialOf(name)}
170
- </div>
171
- {status && <StatusPill status={status} />}
172
- </div>
173
- )}
174
- {handRaised && (
175
- <span className="absolute top-2 left-2 flex items-center justify-center w-8 h-8 rounded-full bg-[#a8c7fa] text-base" role="img" aria-label="Hand raised">
176
- ✋
177
- </span>
178
- )}
179
- {(micMuted || onHideSelf) && (
180
- <div className="absolute top-2 right-2 flex items-center gap-1">
181
- {micMuted && (
182
- <span className="flex items-center justify-center w-7 h-7 rounded-full bg-black/60 text-white" role="img" aria-label="Muted">
183
- <span className="w-4 h-4 [&>svg]:w-4 [&>svg]:h-4">
184
- <MicOffIcon />
185
- </span>
186
- </span>
187
- )}
188
- {onHideSelf && (
189
- <div className="relative" ref={hideMenuRef}>
190
- <button
191
- type="button"
192
- className="flex items-center justify-center w-7 h-7 rounded-full bg-black/60 hover:bg-black/80 text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80"
193
- aria-label="Local video options"
194
- aria-haspopup="menu"
195
- aria-expanded={hideMenuOpen}
196
- onClick={() => setHideMenuOpen((prev) => !prev)}
197
- >
198
- <span className="w-4 h-4 [&>svg]:w-4 [&>svg]:h-4">
199
- <OverflowIcon />
200
- </span>
201
- </button>
202
- {hideMenuOpen && (
203
- <div
204
- role="menu"
205
- aria-label="Local video options"
206
- className="absolute top-full right-0 mt-1 w-32 rounded-xl bg-[#2b2d30] text-white shadow-xl py-1 text-sm"
207
- >
208
- <button
209
- type="button"
210
- role="menuitem"
211
- className="w-full px-3 py-1.5 text-left hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80"
212
- onClick={() => {
213
- setHideMenuOpen(false);
214
- onHideSelf();
215
- }}
216
- >
217
- Hide self
218
- </button>
219
- </div>
220
- )}
221
- </div>
222
- )}
223
- </div>
224
- )}
225
- {badge && (
226
- <span
227
- className={`absolute bottom-2 right-2 px-2 py-0.5 rounded text-xs ${
228
- transport === "failed" ? "bg-[#601410] text-[#f9dedc]" : "bg-black/60 text-white"
229
- }`}
230
- title={badge.title}
231
- data-testid="transport-badge"
232
- >
233
- {badge.label}
234
- </span>
235
- )}
236
- <div className="absolute bottom-2 left-2 max-w-[calc(100%-1rem)] truncate px-2 py-0.5 rounded bg-black/60 text-white text-sm">
237
- {name}
238
- {isLocal ? " (you)" : ""}
239
- </div>
240
- </div>
241
- );
242
- }
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /** One participant's video tile - used by the grid, the focused/thumbnail-strip layouts and the local participant's
6
+ * corner tile in `_CallView.tsx`. Renders a placeholder (the participant's initial) instead of a `<video>` element
7
+ * whenever there's no stream or their camera is off, rather than showing a black/frozen frame.
8
+ *
9
+ * A tile never plays sound: its `<video>` is always muted, and what a participant says is played by the call view's
10
+ * own `<audio>` elements (`_CallView.tsx`'s `RemoteAudio`), so a tile that isn't showing video - or isn't on screen
11
+ * in the current layout - can never silence anyone.
12
+ *
13
+ * The local tile is shown mirrored (`scaleX(-1)`), like a real mirror, so a participant's own movements feel
14
+ * natural - this is a local display transform only, never applied to the video actually sent to anyone, so
15
+ * everyone else always sees the participant the right way round. `mirrored={false}` (`_CallView.tsx`, when a custom
16
+ * background image is on) turns that off: the picture the participant chose is a fixed reference, not a live
17
+ * reflection, and mirroring it would show it backwards to no one but themselves.
18
+ *
19
+ * `onHideSelf` adds a small "…" menu to the tile's corner, local-tile-only - see `_CallView.tsx`'s doc comment on
20
+ * hiding the local tile from grid view. */
21
+ import React, { useEffect, useRef, useState } from "react";
22
+ import type { MediaTransport } from "../shared/webrtc/types.js";
23
+ import { MicOffIcon, OverflowIcon } from "./_icons.js";
24
+
25
+ /** What a tile says about how its participant's media is arriving, for the paths that are not the ordinary direct one
26
+ * (which needs no comment). Text rather than only an icon, so the reason a picture is degraded is never a guess.
27
+ * Exported so `_DiagnosticsPanel.tsx` shows the identical label/title rather than describing the same paths in
28
+ * slightly different words. */
29
+ export const TRANSPORT_BADGES: Partial<Record<MediaTransport, { label: string; title: string }>> = {
30
+ turn: { label: "Relayed", title: "Connected through the relay server, because a direct connection was not possible." },
31
+ "turn-tcp": {
32
+ label: "Relayed (TCP)",
33
+ title:
34
+ "Connected through the relay server over TCP, because this network blocks the relay's usual UDP connection too. " +
35
+ "Audio or video may pause briefly under network strain rather than just glitching - a property of TCP, not a dropped connection.",
36
+ },
37
+ websocket: {
38
+ label: "Server relay",
39
+ title: "Sent through the server as a last resort, because no other connection was possible. Video is lower quality and sound may lag.",
40
+ },
41
+ failed: { label: "Can't connect", title: "This participant could not be reached from your network." },
42
+ };
43
+
44
+ /** A short line saying the connection is still being made, with a spinner - a polite live region (not `role="status"`,
45
+ * which the call view's own announcement region already is), so a participant who cannot see the tile still learns
46
+ * why nothing is happening yet. */
47
+ function StatusPill({ status, className }: { status: string; className?: string }) {
48
+ return (
49
+ <span
50
+ aria-live="polite"
51
+ data-testid="tile-status"
52
+ className={`flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-black/60 text-white text-xs ${className ?? ""}`}
53
+ >
54
+ <span className="inline-block w-3 h-3 rounded-full border-2 border-white/30 border-t-white animate-spin" aria-hidden="true" />
55
+ {status}
56
+ </span>
57
+ );
58
+ }
59
+
60
+ export interface ParticipantTileProps {
61
+ name: string;
62
+ stream?: MediaStream | null;
63
+ isLocal?: boolean;
64
+ /** Shows the initial instead of the video. */
65
+ cameraOff?: boolean;
66
+ micMuted?: boolean;
67
+ handRaised?: boolean;
68
+ /** How this participant's media reaches you - a badge is shown for everything but a direct or still-connecting one. */
69
+ transport?: MediaTransport;
70
+ /** Says the connection is still being made, e.g. "Connecting..." on the local tile and "Awaiting connection..." on a
71
+ * participant's - shown with a spinner until the caller clears it. */
72
+ status?: string;
73
+ /** Highlights this tile as the current presenter/focus. */
74
+ isFocused?: boolean;
75
+ /** Marks this tile as showing a screen share rather than a camera - a screen share is never mirrored
76
+ * (regardless of `mirrored`/`isLocal`), since mirroring someone else's shared content would be disorienting. */
77
+ contain?: boolean;
78
+ /** Whether the local tile is shown mirrored, like a real mirror - ignored for a remote tile, which is never
79
+ * mirrored. Default `true`; see this module's doc comment. */
80
+ mirrored?: boolean;
81
+ /** Offers a small "…" menu in the tile's top-right corner with one item, "Hide self" - see `_CallView.tsx`'s
82
+ * doc comment on hiding the local tile. Omitted everywhere that shouldn't offer it: every remote tile, and the
83
+ * local tile while alone in the call, where hiding it would just blank the only thing on screen. */
84
+ onHideSelf?: () => void;
85
+ onClick?: () => void;
86
+ className?: string;
87
+ }
88
+
89
+ function initialOf(name: string): string {
90
+ return (Array.from(name.trim())[0] ?? "?").toUpperCase();
91
+ }
92
+
93
+ export default function ParticipantTile({
94
+ name,
95
+ stream,
96
+ isLocal,
97
+ cameraOff,
98
+ micMuted,
99
+ handRaised,
100
+ transport,
101
+ status,
102
+ isFocused,
103
+ contain,
104
+ mirrored = true,
105
+ onHideSelf,
106
+ onClick,
107
+ className,
108
+ }: ParticipantTileProps) {
109
+ const badge = transport ? TRANSPORT_BADGES[transport] : undefined;
110
+ const videoRef = useRef<HTMLVideoElement>(null);
111
+ const showVideo = !!stream && !cameraOff;
112
+ const [hideMenuOpen, setHideMenuOpen] = useState(false);
113
+ const hideMenuRef = useRef<HTMLDivElement>(null);
114
+
115
+ // The `<video>` element only exists while `showVideo`, so its stream is bound whenever it (re)appears too.
116
+ useEffect(() => {
117
+ if (videoRef.current) {
118
+ videoRef.current.srcObject = stream ?? null;
119
+ }
120
+ }, [stream, showVideo]);
121
+
122
+ // The tile's own "…" menu closes on Escape or a press anywhere outside it, same shape as every other menu in
123
+ // this app.
124
+ useEffect(() => {
125
+ if (!hideMenuOpen) {
126
+ return;
127
+ }
128
+ const onPointerDown = (event: PointerEvent) => {
129
+ if (!hideMenuRef.current?.contains(event.target as Node)) {
130
+ setHideMenuOpen(false);
131
+ }
132
+ };
133
+ const onKeyDown = (event: KeyboardEvent) => {
134
+ if (event.key === "Escape") {
135
+ setHideMenuOpen(false);
136
+ }
137
+ };
138
+ document.addEventListener("pointerdown", onPointerDown);
139
+ document.addEventListener("keydown", onKeyDown);
140
+ return () => {
141
+ document.removeEventListener("pointerdown", onPointerDown);
142
+ document.removeEventListener("keydown", onKeyDown);
143
+ };
144
+ }, [hideMenuOpen]);
145
+
146
+ return (
147
+ <div
148
+ className={`relative overflow-hidden rounded-xl bg-[#3c4043] flex items-center justify-center min-h-[90px] ${
149
+ isFocused ? "ring-2 ring-[#8ab4f8]" : ""
150
+ } ${className ?? ""}`}
151
+ onClick={onClick}
152
+ role={onClick ? "button" : undefined}
153
+ tabIndex={onClick ? 0 : undefined}
154
+ aria-label={onClick ? `${name}${isLocal ? " (you)" : ""}` : undefined}
155
+ >
156
+ {showVideo ? (
157
+ <>
158
+ <video
159
+ ref={videoRef}
160
+ autoPlay
161
+ playsInline
162
+ muted
163
+ className={`w-full h-full object-contain bg-black ${isLocal && !contain && mirrored ? "[transform:scaleX(-1)]" : ""}`}
164
+ />
165
+ {status && <StatusPill status={status} className="absolute top-2 left-1/2 -translate-x-1/2 whitespace-nowrap" />}
166
+ </>
167
+ ) : (
168
+ <div className="flex flex-col items-center gap-2 max-w-full px-1">
169
+ <div className="w-16 h-16 rounded-full bg-primary text-white flex items-center justify-center text-2xl font-bold" aria-hidden="true">
170
+ {initialOf(name)}
171
+ </div>
172
+ {status && <StatusPill status={status} />}
173
+ </div>
174
+ )}
175
+ {handRaised && (
176
+ <span className="absolute top-2 left-2 flex items-center justify-center w-8 h-8 rounded-full bg-[#a8c7fa] text-base" role="img" aria-label="Hand raised">
177
+ ✋
178
+ </span>
179
+ )}
180
+ {(micMuted || onHideSelf) && (
181
+ <div className="absolute top-2 right-2 flex items-center gap-1">
182
+ {micMuted && (
183
+ <span className="flex items-center justify-center w-7 h-7 rounded-full bg-black/60 text-white" role="img" aria-label="Muted">
184
+ <span className="w-4 h-4 [&>svg]:w-4 [&>svg]:h-4">
185
+ <MicOffIcon />
186
+ </span>
187
+ </span>
188
+ )}
189
+ {onHideSelf && (
190
+ <div className="relative" ref={hideMenuRef}>
191
+ <button
192
+ type="button"
193
+ className="flex items-center justify-center w-7 h-7 rounded-full bg-black/60 hover:bg-black/80 text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80"
194
+ aria-label="Local video options"
195
+ aria-haspopup="menu"
196
+ aria-expanded={hideMenuOpen}
197
+ onClick={() => setHideMenuOpen((prev) => !prev)}
198
+ >
199
+ <span className="w-4 h-4 [&>svg]:w-4 [&>svg]:h-4">
200
+ <OverflowIcon />
201
+ </span>
202
+ </button>
203
+ {hideMenuOpen && (
204
+ <div
205
+ role="menu"
206
+ aria-label="Local video options"
207
+ className="absolute top-full right-0 mt-1 w-32 rounded-xl bg-[#2b2d30] text-white shadow-xl py-1 text-sm"
208
+ >
209
+ <button
210
+ type="button"
211
+ role="menuitem"
212
+ className="w-full px-3 py-1.5 text-left hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80"
213
+ onClick={() => {
214
+ setHideMenuOpen(false);
215
+ onHideSelf();
216
+ }}
217
+ >
218
+ Hide self
219
+ </button>
220
+ </div>
221
+ )}
222
+ </div>
223
+ )}
224
+ </div>
225
+ )}
226
+ {badge && (
227
+ <span
228
+ className={`absolute bottom-2 right-2 px-2 py-0.5 rounded text-xs ${
229
+ transport === "failed" ? "bg-[#601410] text-[#f9dedc]" : "bg-black/60 text-white"
230
+ }`}
231
+ title={badge.title}
232
+ data-testid="transport-badge"
233
+ >
234
+ {badge.label}
235
+ </span>
236
+ )}
237
+ <div className="absolute bottom-2 left-2 max-w-[calc(100%-1rem)] truncate px-2 py-0.5 rounded bg-black/60 text-white text-sm">
238
+ {name}
239
+ {isLocal ? " (you)" : ""}
240
+ </div>
241
+ </div>
242
+ );
243
+ }