@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
@@ -3,148 +3,87 @@
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  /**
6
- * The join/lobby step: name entry, device preview and pickers, mute/camera-off starting preferences, and the
7
- * "Join" button. Per this plugin's Phase 2 spec, the name field is always editable - it is only ever *prefilled*
8
- * when a session is genuinely known (see this component's `initialName` prop and `[token].tsx`'s own doc comment
9
- * on why that never actually happens on this page today).
6
+ * The join/lobby step: name entry, a preview of the camera, the microphone and camera on/off buttons and pickers,
7
+ * and the "Join" button. The camera and microphone are `LocalMedia` (`apps/shared/media/useLocalMedia.ts`), owned by
8
+ * the page (`[token].tsx`) rather than by this component - the tracks previewed here are the tracks the call sends,
9
+ * so leaving the lobby for the call must not (and does not) stop them.
10
10
  *
11
- * Device support/permission errors (`navigator.mediaDevices` missing entirely - e.g. a non-HTTPS context in some
12
- * browsers - a denied prompt, or no camera/microphone at all) are shown as a plain message rather than crashing;
13
- * joining is still allowed with no local media in that case (a signaling-only participant - see this plugin's
14
- * Phase 2 report's known-limitations note on why that participant's peer connections can carry no media at all
15
- * without this design's simpler, renegotiation-free mesh needing to grow one).
11
+ * Access is requested when the lobby opens, and again from the "Allow" / "Try again" button: a click is a user
12
+ * gesture, which some browsers require before they will show a permission prompt at all. When access is refused, or
13
+ * there is no camera or microphone, the lobby says so and why, and joining is still allowed - a participant with
14
+ * nothing to send can still see and hear the call, and turn a camera or microphone on later from the call controls.
15
+ *
16
+ * Per this plugin's Phase 2 spec, the name field is always editable - it is only ever *prefilled* when a session is
17
+ * genuinely known (see this component's `initialName` prop and `[token].tsx`'s own doc comment on why that never
18
+ * actually happens on this page today).
16
19
  */
17
20
  import React, { useEffect, useRef, useState } from "react";
18
21
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
19
22
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
20
23
  import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
21
- import {
22
- type DeviceLists,
23
- type MediaAccessError,
24
- isMediaDevicesSupported,
25
- listDevices,
26
- requestUserMedia,
27
- setTracksEnabled,
28
- stopStream,
29
- } from "../shared/media/deviceMedia.js";
24
+ import type { LocalMedia, TrackStatus } from "../shared/media/useLocalMedia.js";
30
25
  import type { PublicVideoMeeting } from "./_meetApi.js";
26
+ import { LevelBars } from "./_CallControls.js";
27
+ import { MicIcon, MicOffIcon, VideoIcon, VideoOffIcon } from "./_icons.js";
31
28
 
32
29
  const INPUT_CLASS =
33
30
  "w-full text-base py-2.5 px-3.5 border border-border rounded-md bg-surface text-text focus:outline-none focus:border-primary";
34
31
 
35
- export interface JoinPreferences {
36
- name: string;
37
- micOn: boolean;
38
- cameraOn: boolean;
39
- /** Never `null` - an empty `MediaStream` when no device access was ever obtained, so the call view always has
40
- * a stream to work with. */
41
- stream: MediaStream;
42
- }
32
+ const ROUND_BUTTON =
33
+ "flex items-center justify-center gap-1.5 h-12 min-w-12 px-3 rounded-full transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80";
43
34
 
44
35
  export interface MeetLobbyProps {
45
36
  meeting: PublicVideoMeeting;
37
+ media: LocalMedia;
46
38
  /** Prefilled from `Profile.givenName` when a session was detected - still always editable. */
47
39
  initialName?: string;
48
- onJoin: (preferences: JoinPreferences) => void;
40
+ onJoin: (name: string) => void;
41
+ }
42
+
43
+ /** What the preview says when there is no picture to show. */
44
+ function previewMessage(media: LocalMedia): string {
45
+ if (!media.supported) {
46
+ return "This browser can't use a camera or microphone here.";
47
+ }
48
+ if (media.requesting) {
49
+ return "Waiting for camera and microphone access…";
50
+ }
51
+ const status: TrackStatus = media.status.video;
52
+ switch (status) {
53
+ case "off":
54
+ return "Camera is off";
55
+ case "denied":
56
+ return "Camera access is blocked";
57
+ case "unavailable":
58
+ return "No camera found";
59
+ case "error":
60
+ return "Camera couldn't start";
61
+ default:
62
+ return "No camera preview";
63
+ }
49
64
  }
50
65
 
51
- export default function MeetLobby({ meeting, initialName, onJoin }: MeetLobbyProps) {
66
+ export default function MeetLobby({ meeting, media, initialName, onJoin }: MeetLobbyProps) {
52
67
  const [name, setName] = useState(initialName ?? "");
53
- const [micOn, setMicOn] = useState(true);
54
- const [cameraOn, setCameraOn] = useState(true);
55
- const [stream, setStream] = useState<MediaStream | null>(null);
56
- const [devices, setDevices] = useState<DeviceLists>({ cameras: [], microphones: [] });
57
- const [error, setError] = useState<MediaAccessError | null>(null);
58
- const [supported, setSupported] = useState(true);
59
68
  const videoRef = useRef<HTMLVideoElement>(null);
69
+ const { requestAccess } = media;
60
70
 
61
- // Requests device access once, on mount - never re-requested just because `micOn`/`cameraOn` toggle (those
62
- // toggles disable/enable the already-granted tracks instead, see `handleToggleMic`/`handleToggleCamera`).
71
+ // Asks once, when the lobby opens - see this module's doc comment for the button that asks again.
63
72
  useEffect(() => {
64
- let cancelled = false;
65
- if (!isMediaDevicesSupported()) {
66
- setSupported(false);
67
- return;
68
- }
69
- void requestUserMedia({ audio: true, video: true }).then(async (result) => {
70
- if (cancelled) {
71
- return;
72
- }
73
- if (!result.ok) {
74
- setError(result.error);
75
- return;
76
- }
77
- setStream(result.value);
78
- const list = await listDevices();
79
- if (!cancelled && list.ok) {
80
- setDevices(list.value);
81
- }
82
- });
83
- return () => {
84
- cancelled = true;
85
- };
86
- // Intentionally empty deps - see this effect's own comment on why this never re-runs.
73
+ void requestAccess();
74
+ // Only on mount: `requestAccess` is stable, and a later change of devices is the participant's own doing.
87
75
  }, []);
88
76
 
89
- // Releases the camera/microphone the moment this component unmounts (leaving the lobby without joining, or
90
- // transitioning into the call - `[token].tsx` requests its own continuation of these tracks first).
91
- useEffect(() => stopOnUnmount(stream), [stream]);
92
-
77
+ // The `<video>` element only exists while there is a stream, so its stream is bound whenever it (re)appears.
93
78
  useEffect(() => {
94
79
  if (videoRef.current) {
95
- videoRef.current.srcObject = stream;
80
+ videoRef.current.srcObject = media.videoStream;
96
81
  }
97
- }, [stream]);
82
+ }, [media.videoStream]);
98
83
 
99
- function handleToggleMic() {
100
- setMicOn((prev) => {
101
- const next = !prev;
102
- setTracksEnabled(stream, "audio", next);
103
- return next;
104
- });
105
- }
106
-
107
- function handleToggleCamera() {
108
- setCameraOn((prev) => {
109
- const next = !prev;
110
- setTracksEnabled(stream, "video", next);
111
- return next;
112
- });
113
- }
114
-
115
- async function handleSwitchDevice(kind: "audio" | "video", deviceId: string) {
116
- const result = await requestUserMedia({ [kind]: { deviceId: { exact: deviceId } }, [kind === "audio" ? "video" : "audio"]: false });
117
- if (!result.ok) {
118
- setError(result.error);
119
- return;
120
- }
121
- const [newTrack] = kind === "audio" ? result.value.getAudioTracks() : result.value.getVideoTracks();
122
- // `stream` is always set by the time this can be called - the device picker this is wired to only ever
123
- // renders once `devices` is populated, which itself only happens after an initial successful
124
- // `requestUserMedia()` call has already set `stream` (see the mount effect above).
125
- if (!newTrack) {
126
- return;
127
- }
128
- const [oldTrack] = kind === "audio" ? stream!.getAudioTracks() : stream!.getVideoTracks();
129
- if (oldTrack) {
130
- stream!.removeTrack(oldTrack);
131
- oldTrack.stop();
132
- }
133
- newTrack.enabled = kind === "audio" ? micOn : cameraOn;
134
- stream!.addTrack(newTrack);
135
- // Re-triggers the preview `<video>` binding and any downstream consumer without minting a whole new
136
- // `MediaStream` object (the mesh's already-negotiated connections keep the same stream identity).
137
- setStream((current) => current);
138
- }
139
-
140
- function handleJoin() {
141
- onJoin({
142
- name: name.trim(),
143
- micOn,
144
- cameraOn,
145
- stream: stream ?? new MediaStream(),
146
- });
147
- }
84
+ const cameraOk = media.status.video === "live" || media.status.video === "off";
85
+ const needsAccess = media.supported && !media.requesting && (media.status.audio !== "live" || !cameraOk);
86
+ const nothingToSend = !media.audioTrack && !media.videoTrack && !media.requesting;
148
87
 
149
88
  return (
150
89
  <div>
@@ -153,42 +92,78 @@ export default function MeetLobby({ meeting, initialName, onJoin }: MeetLobbyPro
153
92
 
154
93
  <div className="mt-6 grid sm:grid-cols-2 gap-6">
155
94
  <div>
156
- <div className="aspect-video bg-black/80 rounded-md overflow-hidden flex items-center justify-center">
157
- {stream && cameraOn ? (
158
- <video ref={videoRef} autoPlay playsInline muted className="w-full h-full object-cover" />
95
+ <div className="relative aspect-video bg-[#202124] rounded-lg overflow-hidden flex items-center justify-center">
96
+ {media.videoStream ? (
97
+ <video ref={videoRef} autoPlay playsInline muted className="w-full h-full object-cover [transform:scaleX(-1)]" />
159
98
  ) : (
160
- <p className="text-white/70 text-sm">{cameraOn ? "No camera preview" : "Camera is off"}</p>
99
+ <p className="px-4 text-center text-white/70 text-sm">{previewMessage(media)}</p>
161
100
  )}
101
+ <div className="absolute bottom-3 inset-x-0 flex justify-center gap-3">
102
+ <button
103
+ type="button"
104
+ className={`${ROUND_BUTTON} ${media.micOn ? "bg-[#3c4043] text-white hover:bg-[#4b4f53]" : "bg-[#f9dedc] text-[#8c1d18] hover:bg-[#f2c4c0]"}`}
105
+ aria-label={media.micOn ? "Mute microphone" : "Unmute microphone"}
106
+ aria-pressed={!media.micOn}
107
+ onClick={() => void media.toggleMic()}
108
+ >
109
+ {media.micOn && <LevelBars level={media.audioLevel} />}
110
+ {media.micOn ? <MicIcon /> : <MicOffIcon />}
111
+ </button>
112
+ <button
113
+ type="button"
114
+ className={`${ROUND_BUTTON} ${media.cameraOn ? "bg-[#3c4043] text-white hover:bg-[#4b4f53]" : "bg-[#f9dedc] text-[#8c1d18] hover:bg-[#f2c4c0]"}`}
115
+ aria-label={media.cameraOn ? "Turn off camera" : "Turn on camera"}
116
+ aria-pressed={!media.cameraOn}
117
+ onClick={() => void media.toggleCamera()}
118
+ >
119
+ {media.cameraOn ? <VideoIcon /> : <VideoOffIcon />}
120
+ </button>
121
+ </div>
162
122
  </div>
163
- <div className="flex gap-2 mt-3">
164
- <Button type="button" variant="secondary" className="!w-auto" onClick={handleToggleMic} disabled={!stream}>
165
- {micOn ? "Mute mic" : "Unmute mic"}
166
- </Button>
167
- <Button type="button" variant="secondary" className="!w-auto" onClick={handleToggleCamera} disabled={!stream}>
168
- {cameraOn ? "Turn camera off" : "Turn camera on"}
123
+
124
+ {!media.supported && (
125
+ <Alert>
126
+ This browser (or this page's connection) doesn't support camera/microphone access. Try a current browser
127
+ over HTTPS - a link opened inside another app often needs to be opened in the browser itself.
128
+ </Alert>
129
+ )}
130
+ {media.error && <Alert>{media.error.message}</Alert>}
131
+ {needsAccess && (
132
+ <Button type="button" variant="secondary" className="!w-auto mt-3" onClick={() => void requestAccess()}>
133
+ {media.error ? "Try again" : "Allow camera and microphone"}
169
134
  </Button>
170
- </div>
171
- {!supported && <Alert>This browser (or this page's connection) doesn't support camera/microphone access.</Alert>}
172
- {error && <Alert>{error.message}</Alert>}
173
- {devices.cameras.length > 1 && (
135
+ )}
136
+ {nothingToSend && <p className="text-sm text-text-muted mt-3">You'll join without a camera or microphone.</p>}
137
+
138
+ {media.devices.cameras.length > 1 && (
174
139
  <label className="block text-sm mt-3">
175
140
  Camera
176
- <select className={`${INPUT_CLASS} mt-1`} onChange={(e) => handleSwitchDevice("video", e.target.value)}>
177
- {devices.cameras.map((d) => (
141
+ <select
142
+ className={`${INPUT_CLASS} mt-1`}
143
+ value={media.selectedDeviceIds.video ?? ""}
144
+ onChange={(e) => void media.selectDevice("video", e.target.value)}
145
+ >
146
+ {!media.selectedDeviceIds.video && <option value="">Choose a camera</option>}
147
+ {media.devices.cameras.map((d, i) => (
178
148
  <option key={d.deviceId} value={d.deviceId}>
179
- {d.label || "Camera"}
149
+ {d.label || `Camera ${i + 1}`}
180
150
  </option>
181
151
  ))}
182
152
  </select>
183
153
  </label>
184
154
  )}
185
- {devices.microphones.length > 1 && (
155
+ {media.devices.microphones.length > 1 && (
186
156
  <label className="block text-sm mt-3">
187
157
  Microphone
188
- <select className={`${INPUT_CLASS} mt-1`} onChange={(e) => handleSwitchDevice("audio", e.target.value)}>
189
- {devices.microphones.map((d) => (
158
+ <select
159
+ className={`${INPUT_CLASS} mt-1`}
160
+ value={media.selectedDeviceIds.audio ?? ""}
161
+ onChange={(e) => void media.selectDevice("audio", e.target.value)}
162
+ >
163
+ {!media.selectedDeviceIds.audio && <option value="">Choose a microphone</option>}
164
+ {media.devices.microphones.map((d, i) => (
190
165
  <option key={d.deviceId} value={d.deviceId}>
191
- {d.label || "Microphone"}
166
+ {d.label || `Microphone ${i + 1}`}
192
167
  </option>
193
168
  ))}
194
169
  </select>
@@ -207,7 +182,7 @@ export default function MeetLobby({ meeting, initialName, onJoin }: MeetLobbyPro
207
182
  placeholder="Enter your name"
208
183
  />
209
184
  </FormField>
210
- <Button type="button" className="!w-auto" disabled={!name.trim()} onClick={handleJoin}>
185
+ <Button type="button" className="!w-auto" disabled={!name.trim()} onClick={() => onJoin(name.trim())}>
211
186
  Join meeting
212
187
  </Button>
213
188
  </div>
@@ -215,9 +190,3 @@ export default function MeetLobby({ meeting, initialName, onJoin }: MeetLobbyPro
215
190
  </div>
216
191
  );
217
192
  }
218
-
219
- /** `useEffect` cleanup helper for stopping a lobby preview stream on unmount/replacement - a named function
220
- * (rather than an inline arrow) purely so its intent reads clearly at the call site. */
221
- function stopOnUnmount(stream: MediaStream | null): () => void {
222
- return () => stopStream(stream);
223
- }
@@ -2,21 +2,28 @@
2
2
  // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
- /** One participant's video tile - used by both the grid and the focused/thumbnail-strip layouts in `_CallView.tsx`.
6
- * Renders a placeholder (the participant's initial) instead of a `<video>` element whenever there's no stream or
7
- * their camera is off, rather than showing a black/frozen frame. */
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. */
8
12
  import React, { useEffect, useRef } from "react";
13
+ import { MicOffIcon } from "./_icons.js";
9
14
 
10
15
  export interface ParticipantTileProps {
11
16
  name: string;
12
17
  stream?: MediaStream | null;
13
- /** The local participant's own tile must be `muted` (never plays back its own audio) - a real remote tile
14
- * never sets this. */
15
18
  isLocal?: boolean;
19
+ /** Shows the initial instead of the video. */
16
20
  cameraOff?: boolean;
17
21
  micMuted?: boolean;
22
+ handRaised?: boolean;
18
23
  /** Highlights this tile as the current presenter/focus. */
19
24
  isFocused?: boolean;
25
+ /** Fits the whole picture inside the tile instead of filling it - a shared screen must not be cropped. */
26
+ contain?: boolean;
20
27
  onClick?: () => void;
21
28
  className?: string;
22
29
  }
@@ -25,41 +32,66 @@ function initialOf(name: string): string {
25
32
  return (Array.from(name.trim())[0] ?? "?").toUpperCase();
26
33
  }
27
34
 
28
- export default function ParticipantTile({ name, stream, isLocal, cameraOff, micMuted, isFocused, onClick, className }: ParticipantTileProps) {
35
+ export default function ParticipantTile({
36
+ name,
37
+ stream,
38
+ isLocal,
39
+ cameraOff,
40
+ micMuted,
41
+ handRaised,
42
+ isFocused,
43
+ contain,
44
+ onClick,
45
+ className,
46
+ }: ParticipantTileProps) {
29
47
  const videoRef = useRef<HTMLVideoElement>(null);
30
48
  const showVideo = !!stream && !cameraOff;
31
49
 
50
+ // The `<video>` element only exists while `showVideo`, so its stream is bound whenever it (re)appears too.
32
51
  useEffect(() => {
33
52
  if (videoRef.current) {
34
- /* v8 ignore next -- unreachable via real usage: the `<video>` element only ever renders at all when
35
- `showVideo` (`!!stream && !cameraOff`) is true, so `stream` is always truthy whenever `videoRef.current`
36
- is non-null here; the `?? null` exists purely to satisfy `srcObject`'s type, not for a real code path. */
37
53
  videoRef.current.srcObject = stream ?? null;
38
54
  }
39
- }, [stream]);
55
+ }, [stream, showVideo]);
40
56
 
41
57
  return (
42
58
  <div
43
- className={`relative overflow-hidden rounded-md bg-black/80 flex items-center justify-center min-h-[90px] ${
44
- isFocused ? "ring-2 ring-accent" : ""
59
+ className={`relative overflow-hidden rounded-xl bg-[#3c4043] flex items-center justify-center min-h-[90px] ${
60
+ isFocused ? "ring-2 ring-[#8ab4f8]" : ""
45
61
  } ${className ?? ""}`}
46
62
  onClick={onClick}
47
63
  role={onClick ? "button" : undefined}
48
64
  tabIndex={onClick ? 0 : undefined}
65
+ aria-label={onClick ? `${name}${isLocal ? " (you)" : ""}` : undefined}
49
66
  >
50
67
  {showVideo ? (
51
- <video ref={videoRef} autoPlay playsInline muted={isLocal} className="w-full h-full object-cover" />
68
+ <video
69
+ ref={videoRef}
70
+ autoPlay
71
+ playsInline
72
+ muted
73
+ className={`w-full h-full ${contain ? "object-contain bg-black" : "object-cover"} ${isLocal && !contain ? "[transform:scaleX(-1)]" : ""}`}
74
+ />
52
75
  ) : (
53
- <div className="w-14 h-14 rounded-full bg-primary text-white flex items-center justify-center text-xl font-bold" aria-hidden="true">
76
+ <div className="w-16 h-16 rounded-full bg-primary text-white flex items-center justify-center text-2xl font-bold" aria-hidden="true">
54
77
  {initialOf(name)}
55
78
  </div>
56
79
  )}
57
- <div className="absolute bottom-1.5 left-1.5 flex items-center gap-1.5 px-2 py-0.5 rounded bg-black/60 text-white text-xs">
58
- {micMuted && <span title="Muted">(muted)</span>}
59
- <span>
60
- {name}
61
- {isLocal ? " (you)" : ""}
80
+ {handRaised && (
81
+ <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">
82
+ ✋
62
83
  </span>
84
+ )}
85
+ {micMuted && (
86
+ <span className="absolute top-2 right-2 flex items-center justify-center w-7 h-7 rounded-full bg-black/60 text-white" role="img" aria-label="Muted">
87
+ <span className="w-4 h-4 [&>svg]:w-4 [&>svg]:h-4">
88
+ <MicOffIcon />
89
+ </span>
90
+ </span>
91
+ )}
92
+ <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">
93
+ {name}
94
+ {isLocal ? " (you)" : ""}
63
95
  </div>
64
96
  </div>
65
97
  );
@@ -0,0 +1,54 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /** The in-call control icons, as inline SVG (24x24, Material Design paths, `currentColor`) so this plugin needs no
6
+ * icon package of its own. Decorative - every button that uses one carries its own `aria-label`. */
7
+ import React from "react";
8
+
9
+ function Icon({ path, className = "w-6 h-6" }: { path: string; className?: string }) {
10
+ return (
11
+ <svg viewBox="0 0 24 24" className={className} fill="currentColor" aria-hidden="true" focusable="false">
12
+ <path d={path} />
13
+ </svg>
14
+ );
15
+ }
16
+
17
+ export const MicIcon = () => (
18
+ <Icon path="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5.3-3c0 3-2.54 5.1-5.3 5.1S6.7 14 6.7 11H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c3.28-.48 6-3.3 6-6.72h-1.7z" />
19
+ );
20
+
21
+ export const MicOffIcon = () => (
22
+ <Icon path="M19 11h-1.7c0 .74-.16 1.43-.43 2.05l1.23 1.23c.56-.98.9-2.09.9-3.28zm-4.02.17c0-.06.02-.11.02-.17V5c0-1.66-1.34-3-3-3S9 3.34 9 5v.18l5.98 5.99zM4.27 3L3 4.27l6.01 6.01V11c0 1.66 1.33 3 2.99 3 .22 0 .44-.03.65-.08l1.66 1.66c-.71.33-1.5.52-2.31.52-2.76 0-5.3-2.1-5.3-5.1H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c.91-.13 1.77-.45 2.54-.9L19.73 21 21 19.73 4.27 3z" />
23
+ );
24
+
25
+ export const VideoIcon = () => <Icon path="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z" />;
26
+
27
+ export const VideoOffIcon = () => (
28
+ <Icon path="M21 6.5l-4 4V7c0-.55-.45-1-1-1H9.82L21 17.18V6.5zM3.27 2L2 3.27 4.73 6H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.21 0 .39-.08.54-.18L19.73 21 21 19.73 3.27 2z" />
29
+ );
30
+
31
+ export const ScreenShareIcon = () => (
32
+ <Icon path="M21 3H3c-1.11 0-2 .89-2 2v14c0 1.11.89 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.11-.9-2-2-2zm0 16.02H3V4.98h18v14.04zM10 12H8l4-4 4 4h-2v4h-4v-4z" />
33
+ );
34
+
35
+ export const EmojiIcon = () => (
36
+ <Icon path="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5 7.67 11 8.5 11zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z" />
37
+ );
38
+
39
+ export const GridIcon = () => <Icon path="M3 3h8v8H3V3zm10 0h8v8h-8V3zM3 13h8v8H3v-8zm10 0h8v8h-8v-8z" />;
40
+
41
+ export const FocusIcon = () => <Icon path="M3 3h18v12H3V3zm0 14h5v4H3v-4zm6.5 0h5v4h-5v-4zM16 17h5v4h-5v-4z" />;
42
+
43
+ export const LeaveIcon = () => (
44
+ <Icon path="M12 9c-1.6 0-3.15.25-4.6.72v3.1c0 .39-.23.74-.56.9-.98.49-1.87 1.12-2.66 1.85-.18.18-.43.28-.7.28-.28 0-.53-.11-.71-.29L.29 13.08c-.18-.17-.29-.42-.29-.7 0-.28.11-.53.29-.71C3.34 8.78 7.46 7 12 7s8.66 1.78 11.71 4.67c.18.18.29.43.29.71 0 .28-.11.53-.29.71l-2.48 2.48c-.18.18-.43.29-.71.29-.27 0-.52-.11-.7-.28-.79-.74-1.69-1.36-2.67-1.85-.33-.16-.56-.5-.56-.9v-3.1C15.15 9.25 13.6 9 12 9z" />
45
+ );
46
+
47
+ export const ChevronUpIcon = () => <Icon path="M12 8l-6 6 1.41 1.41L12 10.83l4.59 4.58L18 14z" className="w-5 h-5" />;
48
+
49
+ /** A raised hand, as an emoji glyph - Material's own "back hand" path isn't among the ones bundled here. */
50
+ export const HandIcon = () => (
51
+ <span className="text-xl leading-none" aria-hidden="true">
52
+ ✋
53
+ </span>
54
+ );
@@ -0,0 +1,74 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /** The short two-note chime played when another participant raises their hand - synthesized with Web Audio, so there
6
+ * is no sound file to ship. Never throws and does nothing where there is no `AudioContext` (SSR, an old browser):
7
+ * the raised hand is also shown on screen, the chime is an extra. */
8
+
9
+ /** The subset of `AudioContext` this module uses - what a test's fake implements. */
10
+ export interface ChimeAudioContext {
11
+ readonly currentTime: number;
12
+ readonly state: string;
13
+ readonly destination: unknown;
14
+ createGain(): {
15
+ gain: { setValueAtTime(value: number, time: number): void; exponentialRampToValueAtTime(value: number, time: number): void };
16
+ connect(destination: unknown): void;
17
+ };
18
+ createOscillator(): {
19
+ type: string;
20
+ frequency: { setValueAtTime(value: number, time: number): void };
21
+ connect(destination: unknown): void;
22
+ start(time: number): void;
23
+ stop(time: number): void;
24
+ };
25
+ resume(): Promise<void> | void;
26
+ close(): Promise<void> | void;
27
+ }
28
+
29
+ export type ChimeContextFactory = () => ChimeAudioContext | undefined;
30
+
31
+ /** The real `AudioContext`/`webkitAudioContext`, or `undefined` where there is none. */
32
+ export function defaultChimeContext(): ChimeAudioContext | undefined {
33
+ if (typeof window === "undefined") {
34
+ return undefined;
35
+ }
36
+ const Ctor: (new () => ChimeAudioContext) | undefined =
37
+ (window as unknown as { AudioContext?: new () => ChimeAudioContext }).AudioContext ??
38
+ (window as unknown as { webkitAudioContext?: new () => ChimeAudioContext }).webkitAudioContext;
39
+ return Ctor ? new Ctor() : undefined;
40
+ }
41
+
42
+ /** The two notes (Hz) and how long each lasts (seconds). */
43
+ const NOTES = [880, 1320] as const;
44
+ const NOTE_SECONDS = 0.18;
45
+
46
+ export function playRaisedHandChime(createContext: ChimeContextFactory = defaultChimeContext): void {
47
+ try {
48
+ const context = createContext();
49
+ if (!context) {
50
+ return;
51
+ }
52
+ if (context.state === "suspended") {
53
+ void context.resume();
54
+ }
55
+ const start = context.currentTime;
56
+ const end = start + NOTE_SECONDS * NOTES.length + 0.25;
57
+ const gain = context.createGain();
58
+ gain.gain.setValueAtTime(0.0001, start);
59
+ gain.gain.exponentialRampToValueAtTime(0.25, start + 0.03);
60
+ gain.gain.exponentialRampToValueAtTime(0.0001, end);
61
+ gain.connect(context.destination);
62
+ NOTES.forEach((frequency, index) => {
63
+ const oscillator = context.createOscillator();
64
+ oscillator.type = "sine";
65
+ oscillator.frequency.setValueAtTime(frequency, start);
66
+ oscillator.connect(gain);
67
+ oscillator.start(start + index * NOTE_SECONDS);
68
+ oscillator.stop(start + (index + 1) * NOTE_SECONDS + 0.1);
69
+ });
70
+ setTimeout(() => void context.close(), (end - start) * 1000 + 200);
71
+ } catch {
72
+ // Audio is best-effort here - the hand is shown on screen regardless.
73
+ }
74
+ }
@@ -4,9 +4,9 @@
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  /**
6
6
  * Thin, dependency-injectable wrappers over `navigator.mediaDevices` - `enumerateDevices()`/`getUserMedia()`/
7
- * `getDisplayMedia()` - used by both the join/lobby page's device preview and the in-call view's mute/camera/share
8
- * controls (see this plugin's Phase 2 `.claude/NOTES.md` entry). Kept under `src/` rather than `apps/meet/` because
9
- * it is pure logic with no JSX, reused by more than one page/component.
7
+ * `getDisplayMedia()` - used by `useLocalMedia()` (the camera and microphone) and the in-call view's share-screen
8
+ * control (see this plugin's Phase 2 `.claude/NOTES.md` entry). Kept under `apps/shared/` rather than `apps/meet/`
9
+ * because it is pure logic with no JSX, reused by more than one module.
10
10
  *
11
11
  * Every function accepts an optional `MediaDevicesLike` so a test can inject a fake without a real browser; the
12
12
  * real `navigator.mediaDevices` is used by default, resolved lazily inside each function (never at module scope)
@@ -129,13 +129,3 @@ export async function requestDisplayMedia(devices: MediaDevicesLike | undefined
129
129
  export function stopStream(stream: MediaStream | undefined | null): void {
130
130
  stream?.getTracks().forEach((track) => track.stop());
131
131
  }
132
-
133
- /** Enables/disables every track of `stream` matching `kind`, for the mute/camera-off toggles - tracks are kept
134
- * (not stopped/removed), matching every mainstream video-call app's "mute" behavior: instant, reversible, and
135
- * without re-requesting `getUserMedia()` or renegotiating the `RTCPeerConnection`. */
136
- export function setTracksEnabled(stream: MediaStream | undefined | null, kind: "audio" | "video", enabled: boolean): void {
137
- const tracks: MediaStreamTrack[] = kind === "audio" ? (stream?.getAudioTracks() ?? []) : (stream?.getVideoTracks() ?? []);
138
- for (const track of tracks) {
139
- track.enabled = enabled;
140
- }
141
- }