@rapidmx/meet-plugin 0.6.0 → 0.8.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 (110) hide show
  1. package/README.md +15 -2
  2. package/apps/meet/[token].tsx +3 -2
  3. package/apps/meet/_CallControls.tsx +28 -2
  4. package/apps/meet/_CallView.tsx +23 -2
  5. package/apps/meet/_EffectsPanel.tsx +134 -0
  6. package/apps/meet/_MeetLobby.tsx +22 -0
  7. package/apps/meet/_ParticipantTile.tsx +62 -9
  8. package/apps/meet/_icons.tsx +4 -0
  9. package/apps/meet/_meetApi.ts +6 -0
  10. package/apps/shared/media/filters/VideoFilterProcessor.ts +383 -0
  11. package/apps/shared/media/filters/accessories.ts +262 -0
  12. package/apps/shared/media/filters/backgroundImage.ts +69 -0
  13. package/apps/shared/media/filters/filterTypes.ts +89 -0
  14. package/apps/shared/media/filters/mlModels.ts +174 -0
  15. package/apps/shared/media/filters/pixelEffects.ts +61 -0
  16. package/apps/shared/media/mediaPreferences.ts +105 -0
  17. package/apps/shared/media/useLocalMedia.ts +198 -14
  18. package/apps/shared/relay/AudioPlayer.ts +111 -0
  19. package/apps/shared/relay/AudioSender.ts +194 -0
  20. package/apps/shared/relay/RelayClient.ts +262 -0
  21. package/apps/shared/relay/RelayReceiver.ts +72 -0
  22. package/apps/shared/relay/RelaySender.ts +74 -0
  23. package/apps/shared/relay/RelayTransport.ts +188 -0
  24. package/apps/shared/relay/VideoPlayer.ts +110 -0
  25. package/apps/shared/relay/VideoSender.ts +216 -0
  26. package/apps/shared/relay/audioResume.ts +55 -0
  27. package/apps/shared/relay/frames.ts +275 -0
  28. package/apps/shared/relay/relayEnv.ts +230 -0
  29. package/apps/shared/relay/senderCommon.ts +30 -0
  30. package/apps/shared/webrtc/MeshConnectionManager.ts +158 -6
  31. package/apps/shared/webrtc/realPeerConnection.ts +22 -0
  32. package/apps/shared/webrtc/types.ts +32 -2
  33. package/dist/apps/meet/[token].js +3 -3
  34. package/dist/apps/meet/_CallControls.d.ts +2 -1
  35. package/dist/apps/meet/_CallControls.js +6 -3
  36. package/dist/apps/meet/_CallView.d.ts +4 -1
  37. package/dist/apps/meet/_CallView.js +17 -3
  38. package/dist/apps/meet/_EffectsPanel.d.ts +17 -0
  39. package/dist/apps/meet/_EffectsPanel.js +60 -0
  40. package/dist/apps/meet/_MeetLobby.d.ts +4 -0
  41. package/dist/apps/meet/_MeetLobby.js +8 -1
  42. package/dist/apps/meet/_ParticipantTile.d.ts +7 -1
  43. package/dist/apps/meet/_ParticipantTile.js +20 -3
  44. package/dist/apps/meet/_icons.d.ts +1 -0
  45. package/dist/apps/meet/_icons.js +1 -0
  46. package/dist/apps/meet/_meetApi.d.ts +6 -0
  47. package/dist/apps/shared/media/filters/VideoFilterProcessor.d.ts +75 -0
  48. package/dist/apps/shared/media/filters/VideoFilterProcessor.js +304 -0
  49. package/dist/apps/shared/media/filters/accessories.d.ts +37 -0
  50. package/dist/apps/shared/media/filters/accessories.js +211 -0
  51. package/dist/apps/shared/media/filters/backgroundImage.d.ts +22 -0
  52. package/dist/apps/shared/media/filters/backgroundImage.js +54 -0
  53. package/dist/apps/shared/media/filters/filterTypes.d.ts +40 -0
  54. package/dist/apps/shared/media/filters/filterTypes.js +63 -0
  55. package/dist/apps/shared/media/filters/mlModels.d.ts +54 -0
  56. package/dist/apps/shared/media/filters/mlModels.js +106 -0
  57. package/dist/apps/shared/media/filters/pixelEffects.d.ts +14 -0
  58. package/dist/apps/shared/media/filters/pixelEffects.js +55 -0
  59. package/dist/apps/shared/media/mediaPreferences.d.ts +34 -0
  60. package/dist/apps/shared/media/mediaPreferences.js +93 -0
  61. package/dist/apps/shared/media/useLocalMedia.d.ts +17 -1
  62. package/dist/apps/shared/media/useLocalMedia.js +160 -14
  63. package/dist/apps/shared/relay/AudioPlayer.d.ts +38 -0
  64. package/dist/apps/shared/relay/AudioPlayer.js +103 -0
  65. package/dist/apps/shared/relay/AudioSender.d.ts +50 -0
  66. package/dist/apps/shared/relay/AudioSender.js +172 -0
  67. package/dist/apps/shared/relay/RelayClient.d.ts +65 -0
  68. package/dist/apps/shared/relay/RelayClient.js +236 -0
  69. package/dist/apps/shared/relay/RelayReceiver.d.ts +21 -0
  70. package/dist/apps/shared/relay/RelayReceiver.js +59 -0
  71. package/dist/apps/shared/relay/RelaySender.d.ts +34 -0
  72. package/dist/apps/shared/relay/RelaySender.js +54 -0
  73. package/dist/apps/shared/relay/RelayTransport.d.ts +38 -0
  74. package/dist/apps/shared/relay/RelayTransport.js +142 -0
  75. package/dist/apps/shared/relay/VideoPlayer.d.ts +31 -0
  76. package/dist/apps/shared/relay/VideoPlayer.js +102 -0
  77. package/dist/apps/shared/relay/VideoSender.d.ts +56 -0
  78. package/dist/apps/shared/relay/VideoSender.js +204 -0
  79. package/dist/apps/shared/relay/audioResume.d.ts +12 -0
  80. package/dist/apps/shared/relay/audioResume.js +45 -0
  81. package/dist/apps/shared/relay/frames.d.ts +101 -0
  82. package/dist/apps/shared/relay/frames.js +221 -0
  83. package/dist/apps/shared/relay/relayEnv.d.ts +181 -0
  84. package/dist/apps/shared/relay/relayEnv.js +69 -0
  85. package/dist/apps/shared/relay/senderCommon.d.ts +19 -0
  86. package/dist/apps/shared/relay/senderCommon.js +10 -0
  87. package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +51 -1
  88. package/dist/apps/shared/webrtc/MeshConnectionManager.js +137 -6
  89. package/dist/apps/shared/webrtc/realPeerConnection.d.ts +7 -0
  90. package/dist/apps/shared/webrtc/realPeerConnection.js +20 -0
  91. package/dist/apps/shared/webrtc/types.d.ts +29 -2
  92. package/dist/lib/index.js +2 -0
  93. package/dist/lib/index.js.map +1 -1
  94. package/dist/lib/routes/BaseVideoMeetingRoute.js +237 -3
  95. package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
  96. package/dist/lib/util/EffectsUtils.js +21 -0
  97. package/dist/lib/util/EffectsUtils.js.map +1 -0
  98. package/dist/lib/util/IceServerUtils.js +25 -4
  99. package/dist/lib/util/IceServerUtils.js.map +1 -1
  100. package/dist/lib/util/RedisRelayBus.js +241 -0
  101. package/dist/lib/util/RedisRelayBus.js.map +1 -0
  102. package/dist/lib/util/RelayHub.js +405 -0
  103. package/dist/lib/util/RelayHub.js.map +1 -0
  104. package/dist/types/index.d.ts +2 -0
  105. package/dist/types/routes/BaseVideoMeetingRoute.d.ts +79 -0
  106. package/dist/types/util/EffectsUtils.d.ts +10 -0
  107. package/dist/types/util/IceServerUtils.d.ts +12 -1
  108. package/dist/types/util/RedisRelayBus.d.ts +131 -0
  109. package/dist/types/util/RelayHub.d.ts +188 -0
  110. package/package.json +23 -6
package/README.md CHANGED
@@ -8,8 +8,21 @@ Private and public WebRTC video meetings for a [RapidMX server](https://github.c
8
8
  turns on video conferencing for a calendar event; a private meeting mints one link per invitee (inserted into the
9
9
  invite's location and body), a public meeting shares a single link. Joining needs no account: a guest picks a name
10
10
  (auto-filled from their RapidMX profile when signed in), checks their camera and microphone, and enters. Calls are
11
- peer-to-peer WebRTC (no media server), relayed through a configured TURN server only for a participant a direct
12
- connection can't reach.
11
+ peer-to-peer WebRTC (no media server). Each pair of participants uses the best path that works, tried in order:
12
+
13
+ 1. **Direct peer-to-peer**, the preferred path.
14
+ 2. **Relayed through a configured TURN server**, for a participant a direct connection can't reach. A plain `turn:`
15
+ address is offered over both UDP and TCP, so a network that blocks UDP can still reach it.
16
+ 3. **Proxied by the RapidMX server itself over a WebSocket**, the last resort for a participant whose network lets
17
+ nothing but ordinary HTTPS out. Video is lower quality and sound may lag; it needs a browser with WebCodecs, and
18
+ can be turned off with the `mail:videoconf:relay:enabled` setting. A tile says when a participant is on the TURN
19
+ relay or the server relay.
20
+
21
+ Participants can put **video filters** on their camera from the lobby or during the call: a blurred or custom
22
+ background, black and white / sepia / night vision / pixelate looks, and face accessories. They run entirely in the
23
+ browser (MediaPipe, loaded only when a filter needs it - from public CDNs by default, or from your own server with
24
+ `mail:videoconf:effects:assets_url`), and the devices, on/off state and filters a participant chose are remembered in
25
+ their browser's `localStorage` for next time.
13
26
 
14
27
  This plugin's `apps/` also ship the public join/lobby/in-call pages and the personal Settings screen - see
15
28
  `package.json`'s `rapidmx.plugin.ui` for exactly what's mounted where.
@@ -53,10 +53,10 @@ export default function MeetJoinPage({ params }: { params: { token: string } })
53
53
  * window instead (see `_CallView.tsx`). */
54
54
  function MeetJoinContent({ token, branding }: { token: string; branding: Branding | null }) {
55
55
  const [phase, setPhase] = useState<Phase>("loading");
56
- const [joinResult, setJoinResult] = useState<VideoMeetingJoinResult | null>(null);
57
56
  const [name, setName] = useState("");
58
57
  const [loadError, setLoadError] = useState<string | null>(null);
59
- const media = useLocalMedia();
58
+ const [joinResult, setJoinResult] = useState<VideoMeetingJoinResult | null>(null);
59
+ const media = useLocalMedia({ effectsAssetsUrl: joinResult?.effectsAssetsUrl });
60
60
  const { release } = media;
61
61
 
62
62
  React.useEffect(() => {
@@ -108,6 +108,7 @@ function MeetJoinContent({ token, branding }: { token: string; branding: Brandin
108
108
  selfName={name}
109
109
  meetingTitle={joinResult.meeting.title}
110
110
  iceServers={joinResult.iceServers}
111
+ relayEnabled={joinResult.relayEnabled}
111
112
  media={media}
112
113
  onLeave={handleLeave}
113
114
  />
@@ -6,17 +6,21 @@
6
6
  * The in-call control bar, fixed to the bottom of the call (`_CallView.tsx` lays it out below the tiles, never over
7
7
  * them). Its menus open upward, centered on the whole bar on a phone (where the bar wraps onto two rows and a menu
8
8
  * hung from its own button would run off the edge) and from their own button on a wider window: microphone and camera (each a mute/unmute or on/off button next to a menu that picks the device), share
9
- * screen, reactions, raise hand, grid/focus and leave.
9
+ * screen, reactions, raise hand, grid/focus and leave. The effects button opens the video filter picker
10
+ * (`_EffectsPanel.tsx`) and lights up while any filter is on.
10
11
  *
11
12
  * The microphone button shows a live level while it is unmuted - bars that move with the sound the microphone is
12
13
  * picking up, so a participant can see their audio is being sent - and the camera button shows a green dot while a
13
14
  * camera is sending. Both are driven by `LocalMedia` (`apps/shared/media/useLocalMedia.ts`).
14
15
  */
15
16
  import React, { useEffect, useRef, useState } from "react";
17
+ import { filtersActive } from "../shared/media/filters/filterTypes.js";
16
18
  import type { LocalMedia, MediaKind } from "../shared/media/useLocalMedia.js";
17
19
  import { REACTION_EMOJIS } from "../shared/webrtc/types.js";
20
+ import EffectsPanel from "./_EffectsPanel.js";
18
21
  import {
19
22
  ChevronUpIcon,
23
+ EffectsIcon,
20
24
  EmojiIcon,
21
25
  FocusIcon,
22
26
  GridIcon,
@@ -45,7 +49,7 @@ export interface CallControlsProps {
45
49
  onLeave: () => void;
46
50
  }
47
51
 
48
- type OpenMenu = MediaKind | "emoji" | null;
52
+ type OpenMenu = MediaKind | "emoji" | "effects" | null;
49
53
 
50
54
  const BUTTON = "flex items-center justify-center h-12 rounded-full transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80";
51
55
  const NEUTRAL = "bg-[#3c4043] text-white hover:bg-[#4b4f53]";
@@ -166,6 +170,28 @@ export default function CallControls({
166
170
  {openMenu === "video" && <DeviceMenu kind="video" media={media} onClose={() => setOpenMenu(null)} />}
167
171
  </div>
168
172
 
173
+ <div className="sm:relative">
174
+ <button
175
+ type="button"
176
+ className={`${BUTTON} w-12 ${filtersActive(media.filters) ? ACTIVE : NEUTRAL}`}
177
+ aria-label="Video effects"
178
+ aria-haspopup="dialog"
179
+ aria-expanded={openMenu === "effects"}
180
+ onClick={() => toggleMenu("effects")}
181
+ >
182
+ <EffectsIcon />
183
+ </button>
184
+ {openMenu === "effects" && (
185
+ <div
186
+ role="dialog"
187
+ aria-label="Video effects"
188
+ className="absolute bottom-full mb-3 left-1/2 -translate-x-1/2 w-96 max-w-[calc(100vw-1rem)] max-h-[60vh] overflow-y-auto p-3 rounded-2xl bg-[#2b2d30] text-white shadow-xl"
189
+ >
190
+ <EffectsPanel media={media} tone="dark" />
191
+ </div>
192
+ )}
193
+ </div>
194
+
169
195
  <button
170
196
  type="button"
171
197
  className={`${BUTTON} w-12 ${isPresenting ? ACTIVE : NEUTRAL} disabled:opacity-40 disabled:cursor-not-allowed`}
@@ -42,7 +42,8 @@ import { requestDisplayMedia, stopStream } from "../shared/media/deviceMedia.js"
42
42
  import type { LocalMedia } from "../shared/media/useLocalMedia.js";
43
43
  import { createBrowserPeerConnection } from "../shared/webrtc/realPeerConnection.js";
44
44
  import { MeshConnectionManager } from "../shared/webrtc/MeshConnectionManager.js";
45
- import type { MeshParticipant } from "../shared/webrtc/types.js";
45
+ import type { MeshParticipant, RelayTransportLike } from "../shared/webrtc/types.js";
46
+ import { createRelayTransport } from "../shared/relay/RelayTransport.js";
46
47
  import { GuestSignalingClient } from "../shared/push/GuestSignalingClient.js";
47
48
  import CallControls, { type CallViewMode } from "./_CallControls.js";
48
49
  import ParticipantTile from "./_ParticipantTile.js";
@@ -57,6 +58,9 @@ export interface CallViewProps {
57
58
  selfName: string;
58
59
  meetingTitle: string;
59
60
  iceServers: RTCIceServer[];
61
+ /** Whether the server offers the WebSocket media relay, the last-resort path for a participant neither a direct
62
+ * connection nor the TURN server can reach - `VideoMeetingJoinResult.relayEnabled`. Off when absent. */
63
+ relayEnabled?: boolean;
60
64
  /** The camera and microphone, owned by the page (`[token].tsx`) - the lobby's tracks carried into the call. */
61
65
  media: LocalMedia;
62
66
  onLeave: () => void;
@@ -100,7 +104,7 @@ export function newPeerId(selfUid: string): string {
100
104
  return `${selfUid}~${globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2)}`;
101
105
  }
102
106
 
103
- export default function CallView({ channel, token, selfUid, selfName, meetingTitle, iceServers, media, onLeave }: CallViewProps) {
107
+ export default function CallView({ channel, token, selfUid, selfName, meetingTitle, iceServers, relayEnabled, media, onLeave }: CallViewProps) {
104
108
  const [peerId] = useState(() => newPeerId(selfUid));
105
109
  const [connectError, setConnectError] = useState<string | null>(null);
106
110
  const [participants, setParticipants] = useState<MeshParticipant[]>([]);
@@ -116,6 +120,7 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
116
120
  const [announcement, setAnnouncement] = useState("");
117
121
  const [audioBlocked, setAudioBlocked] = useState(false);
118
122
  const [audioNonce, setAudioNonce] = useState(0);
123
+ const [signalingReady, setSignalingReady] = useState(false);
119
124
 
120
125
  const managerRef = useRef<MeshConnectionManager | null>(null);
121
126
  const screenStreamRef = useRef<MediaStream | null>(null);
@@ -144,6 +149,7 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
144
149
  iceServers,
145
150
  channel: client,
146
151
  createPeerConnection: createBrowserPeerConnection,
152
+ relay: relayEnabled ? createRelay(channel, peerId) : undefined,
147
153
  localAudioTrack: media.audioTrack,
148
154
  localVideoTrack: media.videoTrack,
149
155
  localState: { audioOn: media.micOn, videoOn: media.cameraOn },
@@ -200,6 +206,7 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
200
206
  .then(() => {
201
207
  if (!cancelled) {
202
208
  manager.start();
209
+ setSignalingReady(true);
203
210
  }
204
211
  })
205
212
  .catch((err: Error) => {
@@ -316,6 +323,10 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
316
323
  const presenterName = presenterUid ? (presenterUid === peerId ? selfName : (participants.find((p) => p.uid === presenterUid)?.name ?? "Someone")) : undefined;
317
324
  const raisedNames = [...(handRaised ? ["You"] : []), ...participants.filter((p) => p.handRaised).map((p) => p.name)];
318
325
  const alone = participants.length === 0;
326
+ // This participant has no working link yet: the signaling channel is still opening, or there is someone in the call
327
+ // and every connection to them is still being made. Once any one is up (or nobody else is here) it is not shown,
328
+ // so someone already in the call is not told they are "connecting" each time a newcomer arrives.
329
+ const selfConnecting = !connectError && (!signalingReady || (!alone && participants.every((p) => p.transport === "connecting")));
319
330
 
320
331
  const remoteTile = (p: MeshParticipant, className?: string) => (
321
332
  <ParticipantTile
@@ -325,6 +336,8 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
325
336
  cameraOff={!p.videoOn}
326
337
  micMuted={!p.audioOn}
327
338
  handRaised={p.handRaised}
339
+ transport={p.transport}
340
+ status={p.transport === "connecting" ? "Awaiting connection…" : undefined}
328
341
  isFocused={mainUid === p.uid}
329
342
  contain={presenterUid === p.uid}
330
343
  className={className}
@@ -340,6 +353,7 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
340
353
  cameraOff={!media.cameraOn}
341
354
  micMuted={!media.micOn}
342
355
  handRaised={handRaised}
356
+ status={selfConnecting ? "Connecting…" : undefined}
343
357
  className={className}
344
358
  />
345
359
  );
@@ -481,6 +495,13 @@ function RemoteAudio({ stream, onBlocked }: { stream: MediaStream; onBlocked: ()
481
495
  return <audio ref={audioRef} autoPlay data-testid="remote-audio" />;
482
496
  }
483
497
 
498
+ /** The WebSocket media relay for this call, or `undefined` when this browser cannot run it (no WebCodecs) - the
499
+ * mesh then never falls back to it, and says so by marking an unreachable participant `"failed"`. */
500
+ function createRelay(meetingUid: string, peerId: string): RelayTransportLike | undefined {
501
+ const relay = createRelayTransport({ meetingUid, peerId });
502
+ return relay.supported ? relay : undefined;
503
+ }
504
+
484
505
  function stopLevelMeter(ref: React.MutableRefObject<Record<string, LevelMeterHandle>>, uid: string): void {
485
506
  ref.current[uid]?.stop();
486
507
  delete ref.current[uid];
@@ -0,0 +1,134 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * The picker for the video filters (`apps/shared/media/filters/`): a background, a colour effect and a face accessory,
7
+ * each one of a short list, chosen independently so they combine. Shared by the lobby (on the page's light theme) and
8
+ * the in-call control bar (on its dark one) - `tone` picks the colours, nothing else differs.
9
+ *
10
+ * The background can also be a picture from the participant's own device: choosing "Custom image" with none picked yet
11
+ * opens the file picker, and once one is picked "Change image" is offered beside the options. The picture is read and
12
+ * kept in the browser (`filters/backgroundImage.ts`) - it is never uploaded.
13
+ */
14
+ import React, { useRef, useState } from "react";
15
+ import type { LocalMedia } from "../shared/media/useLocalMedia.js";
16
+ import {
17
+ ACCESSORY_OPTIONS,
18
+ BACKGROUND_OPTIONS,
19
+ EFFECT_OPTIONS,
20
+ type FilterOption,
21
+ type VideoFilters,
22
+ } from "../shared/media/filters/filterTypes.js";
23
+
24
+ export type EffectsTone = "light" | "dark";
25
+
26
+ const CHIP = "flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm transition-colors focus:outline-none focus-visible:ring-2";
27
+ const TONES: Record<EffectsTone, { idle: string; selected: string; muted: string; ring: string; link: string }> = {
28
+ light: {
29
+ idle: "border border-border bg-surface text-text hover:bg-surface-alt",
30
+ selected: "border border-primary bg-surface-alt text-text font-semibold",
31
+ muted: "text-text-muted",
32
+ ring: "focus-visible:ring-primary",
33
+ link: "text-primary",
34
+ },
35
+ dark: {
36
+ idle: "bg-[#3c4043] text-white hover:bg-[#4b4f53]",
37
+ selected: "bg-[#a8c7fa] text-[#062e6f] hover:bg-[#8ab4f8]",
38
+ muted: "text-white/70",
39
+ ring: "focus-visible:ring-white/80",
40
+ link: "text-[#8ab4f8]",
41
+ },
42
+ };
43
+
44
+ export interface EffectsPanelProps {
45
+ media: LocalMedia;
46
+ tone: EffectsTone;
47
+ }
48
+
49
+ export default function EffectsPanel({ media, tone }: EffectsPanelProps) {
50
+ const colors = TONES[tone];
51
+ const fileInput = useRef<HTMLInputElement>(null);
52
+ const [fileError, setFileError] = useState<string | null>(null);
53
+ const { filters, filterStatus } = media;
54
+
55
+ function group<K extends keyof VideoFilters>(
56
+ key: K,
57
+ label: string,
58
+ options: FilterOption<VideoFilters[K]>[],
59
+ onChoose: (id: VideoFilters[K]) => void,
60
+ ) {
61
+ return (
62
+ <div role="group" aria-label={label} className="mt-3 first:mt-0">
63
+ <p className={`text-xs font-semibold uppercase tracking-wide mb-1.5 ${colors.muted}`}>{label}</p>
64
+ <div className="flex flex-wrap gap-1.5">
65
+ {options.map((option) => (
66
+ <button
67
+ key={option.id}
68
+ type="button"
69
+ className={`${CHIP} ${colors.ring} ${filters[key] === option.id ? colors.selected : colors.idle}`}
70
+ aria-pressed={filters[key] === option.id}
71
+ onClick={() => onChoose(option.id)}
72
+ >
73
+ <span aria-hidden="true">{option.emoji}</span>
74
+ {option.label}
75
+ </button>
76
+ ))}
77
+ </div>
78
+ </div>
79
+ );
80
+ }
81
+
82
+ async function handleFile(event: React.ChangeEvent<HTMLInputElement>) {
83
+ const input = event.target;
84
+ const file = input.files?.[0];
85
+ // Cleared so picking the same file again still counts as a change.
86
+ input.value = "";
87
+ if (file) {
88
+ setFileError(await media.chooseBackgroundImage(file));
89
+ }
90
+ }
91
+
92
+ function chooseBackground(id: VideoFilters["background"]) {
93
+ setFileError(null);
94
+ if (id === "image" && !media.hasBackgroundImage) {
95
+ fileInput.current?.click();
96
+ return;
97
+ }
98
+ media.setFilters({ background: id });
99
+ }
100
+
101
+ return (
102
+ <div>
103
+ {group("background", "Background", BACKGROUND_OPTIONS, chooseBackground)}
104
+ {media.hasBackgroundImage && (
105
+ <button
106
+ type="button"
107
+ className={`mt-1.5 text-sm underline ${colors.link} focus:outline-none focus-visible:ring-2 ${colors.ring}`}
108
+ onClick={() => fileInput.current?.click()}
109
+ >
110
+ Change image
111
+ </button>
112
+ )}
113
+ <input ref={fileInput} type="file" accept="image/*" className="hidden" aria-label="Background image" onChange={handleFile} />
114
+ {fileError && (
115
+ <p role="alert" className="mt-1.5 text-sm text-[#d93025]">
116
+ {fileError}
117
+ </p>
118
+ )}
119
+ {group("effect", "Look", EFFECT_OPTIONS, (id) => media.setFilters({ effect: id }))}
120
+ {group("accessory", "Fun", ACCESSORY_OPTIONS, (id) => media.setFilters({ accessory: id }))}
121
+ {filterStatus.loading && (
122
+ <p role="status" className={`mt-3 text-sm ${colors.muted}`}>
123
+ Loading effects…
124
+ </p>
125
+ )}
126
+ {filterStatus.error && (
127
+ <p role="alert" className="mt-3 text-sm text-[#d93025]">
128
+ {filterStatus.error}
129
+ </p>
130
+ )}
131
+ {!media.cameraOn && <p className={`mt-3 text-sm ${colors.muted}`}>Turn on your camera to see effects.</p>}
132
+ </div>
133
+ );
134
+ }
@@ -8,6 +8,10 @@
8
8
  * the page (`[token].tsx`) rather than by this component - the tracks previewed here are the tracks the call sends,
9
9
  * so leaving the lobby for the call must not (and does not) stop them.
10
10
  *
11
+ * The "Effects" section opens the video filter picker (`_EffectsPanel.tsx`), so the participant sees themselves with
12
+ * a blurred or replaced background, or a fun accessory, before anyone else does. What they chose last time is
13
+ * already applied when the lobby opens (`useLocalMedia()` remembers it).
14
+ *
11
15
  * Access is requested when the lobby opens, and again from the "Allow" / "Try again" button: a click is a user
12
16
  * gesture, which some browsers require before they will show a permission prompt at all. When access is refused, or
13
17
  * there is no camera or microphone, the lobby says so and why, and joining is still allowed - a participant with
@@ -24,6 +28,8 @@ import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
24
28
  import type { LocalMedia, TrackStatus } from "../shared/media/useLocalMedia.js";
25
29
  import type { PublicVideoMeeting } from "./_meetApi.js";
26
30
  import { LevelBars } from "./_CallControls.js";
31
+ import EffectsPanel from "./_EffectsPanel.js";
32
+ import { filtersActive } from "../shared/media/filters/filterTypes.js";
27
33
  import { MicIcon, MicOffIcon, VideoIcon, VideoOffIcon } from "./_icons.js";
28
34
 
29
35
  const INPUT_CLASS =
@@ -65,6 +71,7 @@ function previewMessage(media: LocalMedia): string {
65
71
 
66
72
  export default function MeetLobby({ meeting, media, initialName, onJoin }: MeetLobbyProps) {
67
73
  const [name, setName] = useState(initialName ?? "");
74
+ const [showEffects, setShowEffects] = useState(false);
68
75
  const videoRef = useRef<HTMLVideoElement>(null);
69
76
  const { requestAccess } = media;
70
77
 
@@ -152,6 +159,21 @@ export default function MeetLobby({ meeting, media, initialName, onJoin }: MeetL
152
159
  </select>
153
160
  </label>
154
161
  )}
162
+ <div className="mt-3">
163
+ <button
164
+ type="button"
165
+ className="text-sm text-primary underline focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
166
+ aria-expanded={showEffects}
167
+ onClick={() => setShowEffects((shown) => !shown)}
168
+ >
169
+ {filtersActive(media.filters) ? "Effects (on)" : "Effects"}
170
+ </button>
171
+ {showEffects && (
172
+ <div className="mt-2">
173
+ <EffectsPanel media={media} tone="light" />
174
+ </div>
175
+ )}
176
+ </div>
155
177
  {media.devices.microphones.length > 1 && (
156
178
  <label className="block text-sm mt-3">
157
179
  Microphone
@@ -10,8 +10,36 @@
10
10
  * own `<audio>` elements (`_CallView.tsx`'s `RemoteAudio`), so a tile that isn't showing video - or isn't on screen
11
11
  * in the current layout - can never silence anyone. */
12
12
  import React, { useEffect, useRef } from "react";
13
+ import type { MediaTransport } from "../shared/webrtc/types.js";
13
14
  import { MicOffIcon } from "./_icons.js";
14
15
 
16
+ /** What a tile says about how its participant's media is arriving, for the paths that are not the ordinary direct one
17
+ * (which needs no comment). Text rather than only an icon, so the reason a picture is degraded is never a guess. */
18
+ const TRANSPORT_BADGES: Partial<Record<MediaTransport, { label: string; title: string }>> = {
19
+ turn: { label: "Relayed", title: "Connected through the relay server, because a direct connection was not possible." },
20
+ websocket: {
21
+ label: "Server relay",
22
+ title: "Sent through the server as a last resort, because no other connection was possible. Video is lower quality and sound may lag.",
23
+ },
24
+ failed: { label: "Can't connect", title: "This participant could not be reached from your network." },
25
+ };
26
+
27
+ /** A short line saying the connection is still being made, with a spinner - a polite live region (not `role="status"`,
28
+ * which the call view's own announcement region already is), so a participant who cannot see the tile still learns
29
+ * why nothing is happening yet. */
30
+ function StatusPill({ status, className }: { status: string; className?: string }) {
31
+ return (
32
+ <span
33
+ aria-live="polite"
34
+ data-testid="tile-status"
35
+ className={`flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-black/60 text-white text-xs ${className ?? ""}`}
36
+ >
37
+ <span className="inline-block w-3 h-3 rounded-full border-2 border-white/30 border-t-white animate-spin" aria-hidden="true" />
38
+ {status}
39
+ </span>
40
+ );
41
+ }
42
+
15
43
  export interface ParticipantTileProps {
16
44
  name: string;
17
45
  stream?: MediaStream | null;
@@ -20,6 +48,11 @@ export interface ParticipantTileProps {
20
48
  cameraOff?: boolean;
21
49
  micMuted?: boolean;
22
50
  handRaised?: boolean;
51
+ /** How this participant's media reaches you - a badge is shown for everything but a direct or still-connecting one. */
52
+ transport?: MediaTransport;
53
+ /** Says the connection is still being made, e.g. "Connecting..." on the local tile and "Awaiting connection..." on a
54
+ * participant's - shown with a spinner until the caller clears it. */
55
+ status?: string;
23
56
  /** Highlights this tile as the current presenter/focus. */
24
57
  isFocused?: boolean;
25
58
  /** Fits the whole picture inside the tile instead of filling it - a shared screen must not be cropped. */
@@ -39,11 +72,14 @@ export default function ParticipantTile({
39
72
  cameraOff,
40
73
  micMuted,
41
74
  handRaised,
75
+ transport,
76
+ status,
42
77
  isFocused,
43
78
  contain,
44
79
  onClick,
45
80
  className,
46
81
  }: ParticipantTileProps) {
82
+ const badge = transport ? TRANSPORT_BADGES[transport] : undefined;
47
83
  const videoRef = useRef<HTMLVideoElement>(null);
48
84
  const showVideo = !!stream && !cameraOff;
49
85
 
@@ -65,16 +101,22 @@ export default function ParticipantTile({
65
101
  aria-label={onClick ? `${name}${isLocal ? " (you)" : ""}` : undefined}
66
102
  >
67
103
  {showVideo ? (
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
- />
104
+ <>
105
+ <video
106
+ ref={videoRef}
107
+ autoPlay
108
+ playsInline
109
+ muted
110
+ className={`w-full h-full ${contain ? "object-contain bg-black" : "object-cover"} ${isLocal && !contain ? "[transform:scaleX(-1)]" : ""}`}
111
+ />
112
+ {status && <StatusPill status={status} className="absolute top-2 left-1/2 -translate-x-1/2 whitespace-nowrap" />}
113
+ </>
75
114
  ) : (
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">
77
- {initialOf(name)}
115
+ <div className="flex flex-col items-center gap-2 max-w-full px-1">
116
+ <div className="w-16 h-16 rounded-full bg-primary text-white flex items-center justify-center text-2xl font-bold" aria-hidden="true">
117
+ {initialOf(name)}
118
+ </div>
119
+ {status && <StatusPill status={status} />}
78
120
  </div>
79
121
  )}
80
122
  {handRaised && (
@@ -89,6 +131,17 @@ export default function ParticipantTile({
89
131
  </span>
90
132
  </span>
91
133
  )}
134
+ {badge && (
135
+ <span
136
+ className={`absolute bottom-2 right-2 px-2 py-0.5 rounded text-xs ${
137
+ transport === "failed" ? "bg-[#601410] text-[#f9dedc]" : "bg-black/60 text-white"
138
+ }`}
139
+ title={badge.title}
140
+ data-testid="transport-badge"
141
+ >
142
+ {badge.label}
143
+ </span>
144
+ )}
92
145
  <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
146
  {name}
94
147
  {isLocal ? " (you)" : ""}
@@ -52,3 +52,7 @@ export const HandIcon = () => (
52
52
  ✋
53
53
  </span>
54
54
  );
55
+
56
+ export const EffectsIcon = () => (
57
+ <Icon path="M19 9l1.25-2.75L23 5l-2.75-1.25L19 1l-1.25 2.75L15 5l2.75 1.25L19 9zm-7.5.5L9 4 6.5 9.5 1 12l5.5 2.5L9 20l2.5-5.5L17 12l-5.5-2.5zM19 15l-1.25 2.75L15 19l2.75 1.25L19 23l1.25-2.75L23 19l-2.75-1.25L19 15z" />
58
+ );
@@ -51,6 +51,12 @@ export interface VideoMeetingJoinResult {
51
51
  token?: string;
52
52
  /** ISO 8601 instant `token` expires at. Present only when `authenticated` is `false`. */
53
53
  expiresAt?: string;
54
+ /** Whether the server offers the WebSocket media relay - the last-resort path for a participant neither a direct
55
+ * connection nor the TURN server can reach. Absent from a server that predates it, which reads as off. */
56
+ relayEnabled?: boolean;
57
+ /** The base URL the video filters' machine-learning runtime and models are hosted under, when the administrator
58
+ * hosts them (`mail:videoconf:effects:assets_url`). Absent means the public CDN defaults. */
59
+ effectsAssetsUrl?: string;
54
60
  }
55
61
 
56
62
  /** Resolves a join token (an invitee's own link) or a public meeting's slug to its meeting info, ICE servers, and