@rapidmx/meet-plugin 0.6.0 → 0.8.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 (131) hide show
  1. package/README.md +18 -5
  2. package/apps/meet/[token].tsx +11 -10
  3. package/apps/meet/_CallControls.tsx +28 -2
  4. package/apps/meet/_CallView.tsx +26 -2
  5. package/apps/meet/_EffectsPanel.tsx +134 -0
  6. package/apps/meet/_MeetChrome.tsx +1 -1
  7. package/apps/meet/_MeetLobby.tsx +36 -4
  8. package/apps/meet/_ParticipantTile.tsx +73 -10
  9. package/apps/meet/_icons.tsx +4 -0
  10. package/apps/meet/_layout.tsx +2 -2
  11. package/apps/meet/_meetApi.ts +7 -1
  12. package/apps/meet/index.tsx +2 -2
  13. package/apps/settings-video-conferencing/_PersonalRoomCard.tsx +4 -4
  14. package/apps/settings-video-conferencing/_layout.tsx +2 -2
  15. package/apps/settings-video-conferencing/index.tsx +3 -3
  16. package/apps/shared/media/filters/VideoFilterProcessor.ts +408 -0
  17. package/apps/shared/media/filters/accessories.ts +262 -0
  18. package/apps/shared/media/filters/backgroundImage.ts +69 -0
  19. package/apps/shared/media/filters/filterTypes.ts +89 -0
  20. package/apps/shared/media/filters/mlModels.ts +174 -0
  21. package/apps/shared/media/filters/pixelEffects.ts +61 -0
  22. package/apps/shared/media/mediaPreferences.ts +105 -0
  23. package/apps/shared/media/useLocalMedia.ts +198 -14
  24. package/apps/shared/push/GuestSignalingClient.ts +4 -4
  25. package/apps/shared/relay/AudioPlayer.ts +111 -0
  26. package/apps/shared/relay/AudioSender.ts +194 -0
  27. package/apps/shared/relay/RelayClient.ts +262 -0
  28. package/apps/shared/relay/RelayReceiver.ts +72 -0
  29. package/apps/shared/relay/RelaySender.ts +74 -0
  30. package/apps/shared/relay/RelayTransport.ts +188 -0
  31. package/apps/shared/relay/VideoPlayer.ts +110 -0
  32. package/apps/shared/relay/VideoSender.ts +216 -0
  33. package/apps/shared/relay/audioResume.ts +55 -0
  34. package/apps/shared/relay/frames.ts +275 -0
  35. package/apps/shared/relay/relayEnv.ts +230 -0
  36. package/apps/shared/relay/senderCommon.ts +30 -0
  37. package/apps/shared/webrtc/MeshConnectionManager.ts +159 -7
  38. package/apps/shared/webrtc/realPeerConnection.ts +22 -0
  39. package/apps/shared/webrtc/types.ts +32 -2
  40. package/dist/apps/meet/[token].d.ts +3 -3
  41. package/dist/apps/meet/[token].js +10 -10
  42. package/dist/apps/meet/_CallControls.d.ts +2 -1
  43. package/dist/apps/meet/_CallControls.js +6 -3
  44. package/dist/apps/meet/_CallView.d.ts +4 -1
  45. package/dist/apps/meet/_CallView.js +20 -3
  46. package/dist/apps/meet/_EffectsPanel.d.ts +17 -0
  47. package/dist/apps/meet/_EffectsPanel.js +60 -0
  48. package/dist/apps/meet/_MeetChrome.d.ts +1 -1
  49. package/dist/apps/meet/_MeetLobby.d.ts +8 -0
  50. package/dist/apps/meet/_MeetLobby.js +15 -4
  51. package/dist/apps/meet/_ParticipantTile.d.ts +17 -2
  52. package/dist/apps/meet/_ParticipantTile.js +27 -4
  53. package/dist/apps/meet/_icons.d.ts +1 -0
  54. package/dist/apps/meet/_icons.js +1 -0
  55. package/dist/apps/meet/_layout.d.ts +1 -1
  56. package/dist/apps/meet/_layout.js +1 -1
  57. package/dist/apps/meet/_meetApi.d.ts +6 -0
  58. package/dist/apps/meet/_meetApi.js +1 -1
  59. package/dist/apps/meet/index.js +2 -2
  60. package/dist/apps/settings-video-conferencing/_PersonalRoomCard.d.ts +1 -1
  61. package/dist/apps/settings-video-conferencing/_PersonalRoomCard.js +4 -4
  62. package/dist/apps/settings-video-conferencing/_layout.d.ts +1 -1
  63. package/dist/apps/settings-video-conferencing/_layout.js +1 -1
  64. package/dist/apps/settings-video-conferencing/index.d.ts +1 -1
  65. package/dist/apps/settings-video-conferencing/index.js +3 -3
  66. package/dist/apps/shared/media/filters/VideoFilterProcessor.d.ts +78 -0
  67. package/dist/apps/shared/media/filters/VideoFilterProcessor.js +329 -0
  68. package/dist/apps/shared/media/filters/accessories.d.ts +37 -0
  69. package/dist/apps/shared/media/filters/accessories.js +211 -0
  70. package/dist/apps/shared/media/filters/backgroundImage.d.ts +22 -0
  71. package/dist/apps/shared/media/filters/backgroundImage.js +54 -0
  72. package/dist/apps/shared/media/filters/filterTypes.d.ts +40 -0
  73. package/dist/apps/shared/media/filters/filterTypes.js +63 -0
  74. package/dist/apps/shared/media/filters/mlModels.d.ts +54 -0
  75. package/dist/apps/shared/media/filters/mlModels.js +106 -0
  76. package/dist/apps/shared/media/filters/pixelEffects.d.ts +14 -0
  77. package/dist/apps/shared/media/filters/pixelEffects.js +55 -0
  78. package/dist/apps/shared/media/mediaPreferences.d.ts +34 -0
  79. package/dist/apps/shared/media/mediaPreferences.js +93 -0
  80. package/dist/apps/shared/media/useLocalMedia.d.ts +17 -1
  81. package/dist/apps/shared/media/useLocalMedia.js +160 -14
  82. package/dist/apps/shared/push/GuestSignalingClient.d.ts +1 -1
  83. package/dist/apps/shared/push/GuestSignalingClient.js +3 -3
  84. package/dist/apps/shared/relay/AudioPlayer.d.ts +38 -0
  85. package/dist/apps/shared/relay/AudioPlayer.js +103 -0
  86. package/dist/apps/shared/relay/AudioSender.d.ts +50 -0
  87. package/dist/apps/shared/relay/AudioSender.js +172 -0
  88. package/dist/apps/shared/relay/RelayClient.d.ts +65 -0
  89. package/dist/apps/shared/relay/RelayClient.js +236 -0
  90. package/dist/apps/shared/relay/RelayReceiver.d.ts +21 -0
  91. package/dist/apps/shared/relay/RelayReceiver.js +59 -0
  92. package/dist/apps/shared/relay/RelaySender.d.ts +34 -0
  93. package/dist/apps/shared/relay/RelaySender.js +54 -0
  94. package/dist/apps/shared/relay/RelayTransport.d.ts +38 -0
  95. package/dist/apps/shared/relay/RelayTransport.js +142 -0
  96. package/dist/apps/shared/relay/VideoPlayer.d.ts +31 -0
  97. package/dist/apps/shared/relay/VideoPlayer.js +102 -0
  98. package/dist/apps/shared/relay/VideoSender.d.ts +56 -0
  99. package/dist/apps/shared/relay/VideoSender.js +204 -0
  100. package/dist/apps/shared/relay/audioResume.d.ts +12 -0
  101. package/dist/apps/shared/relay/audioResume.js +45 -0
  102. package/dist/apps/shared/relay/frames.d.ts +101 -0
  103. package/dist/apps/shared/relay/frames.js +221 -0
  104. package/dist/apps/shared/relay/relayEnv.d.ts +181 -0
  105. package/dist/apps/shared/relay/relayEnv.js +69 -0
  106. package/dist/apps/shared/relay/senderCommon.d.ts +19 -0
  107. package/dist/apps/shared/relay/senderCommon.js +10 -0
  108. package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +52 -2
  109. package/dist/apps/shared/webrtc/MeshConnectionManager.js +138 -7
  110. package/dist/apps/shared/webrtc/realPeerConnection.d.ts +7 -0
  111. package/dist/apps/shared/webrtc/realPeerConnection.js +20 -0
  112. package/dist/apps/shared/webrtc/types.d.ts +29 -2
  113. package/dist/lib/index.js +2 -0
  114. package/dist/lib/index.js.map +1 -1
  115. package/dist/lib/routes/BaseVideoMeetingRoute.js +237 -3
  116. package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
  117. package/dist/lib/util/EffectsUtils.js +21 -0
  118. package/dist/lib/util/EffectsUtils.js.map +1 -0
  119. package/dist/lib/util/IceServerUtils.js +25 -4
  120. package/dist/lib/util/IceServerUtils.js.map +1 -1
  121. package/dist/lib/util/RedisRelayBus.js +241 -0
  122. package/dist/lib/util/RedisRelayBus.js.map +1 -0
  123. package/dist/lib/util/RelayHub.js +405 -0
  124. package/dist/lib/util/RelayHub.js.map +1 -0
  125. package/dist/types/index.d.ts +2 -0
  126. package/dist/types/routes/BaseVideoMeetingRoute.d.ts +79 -0
  127. package/dist/types/util/EffectsUtils.d.ts +10 -0
  128. package/dist/types/util/IceServerUtils.d.ts +12 -1
  129. package/dist/types/util/RedisRelayBus.d.ts +131 -0
  130. package/dist/types/util/RelayHub.d.ts +188 -0
  131. package/package.json +25 -11
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.
@@ -29,9 +42,9 @@ infrastructure and no UI yet - see `.claude/NOTES.md`'s dated entry for the full
29
42
  ## Development
30
43
 
31
44
  Same layout and tooling as this project's other plugins (`@rapidmx/booking-plugin`, `@rapidmx/mapi-plugin`,
32
- `@rapidmx/autodiscover-plugin`): `yarn install`, `yarn test:prod`, `yarn build`. `@rapidmx/restapi`,
33
- `@rapidmx/react-shared` and `@rapidmx/web-client` are portal-linked in `package.json`'s `resolutions` during local
34
- development against a matching working tree of those repos.
45
+ `@rapidmx/autodiscover-plugin`): `yarn install`, `yarn test:prod`, `yarn build`. `@rapidmx/restapi` and
46
+ `@rapidmx/web-client` are portal-linked in `package.json`'s `resolutions` during local development against a
47
+ matching working tree of those repos.
35
48
 
36
49
  ## License
37
50
 
@@ -10,14 +10,14 @@
10
10
  * ## Session-based name prefill - NOT implemented, and why
11
11
  *
12
12
  * The spec asks this page to prefill the name field from `Profile.givenName` when the visiting browser already
13
- * holds a RapidMX session (investigating `@rapidmx/react-shared`'s `profileApi.ts`/`session.ts` was part of this
14
- * plugin's Phase 2 work). That turned out not to be wireable from here: `profileApi.ts`'s `getMyProfile()` needs
13
+ * holds a RapidMX session (investigating `@rapidmx/web-client`'s `lib/profileApi.ts`/`lib/session.ts` was part of
14
+ * this plugin's Phase 2 work). That turned out not to be wireable from here: `profileApi.ts`'s `getMyProfile()` needs
15
15
  * an `authServerUrl` to call auth-server (a *different* origin) with, and `session.ts`'s own doc comment confirms
16
16
  * `userUid`/`authServerUrl` are supplied only via server-side `fetchProps` on the `www`/admin console hosts
17
17
  * (`wwwRoute`/`AdminConsoleRoute` in `@rapidmx/server`) - never on the `public` host this page is mounted on.
18
18
  * `PublicPageRoute` (`@rapidmx/server`, this page's actual host route) returns only branding props; it has no
19
19
  * concept of the caller's session at all. There is, today, no public-host plugin page anywhere in this codebase
20
- * that does this, so there was no convention to extend - only two, cross-repo (`@rapidmx/server`/`react-shared`)
20
+ * that does this, so there was no convention to extend - only two, cross-repo (`@rapidmx/server`/`web-client`)
21
21
  * ways to add one (flagged in this plugin's Phase 2 report rather than built here, both out of this frontend-only
22
22
  * phase's scope):
23
23
  *
@@ -30,11 +30,11 @@
30
30
  * invitees have no RapidMX account at all), just never prefilled even for a mailbox owner testing their own link.
31
31
  */
32
32
  import React, { useState } from "react";
33
- import useBranding from "@rapidmx/react-shared/branding/useBranding.js";
34
- import { Branding } from "@rapidmx/react-shared/branding/brandingApi.js";
35
- import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
36
- import Button from "@rapidmx/react-shared/components/buttons/Button.js";
37
- import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
33
+ import useBranding from "@rapidmx/web-client/lib/branding/useBranding.js";
34
+ import { Branding } from "@rapidmx/web-client/lib/branding/brandingApi.js";
35
+ import { ApiRequestError } from "@rapidmx/web-client/lib/util/api.js";
36
+ import Button from "@rapidmx/web-client/lib/components/buttons/Button.js";
37
+ import Alert from "@rapidmx/web-client/lib/components/feedback/Alert.js";
38
38
  import { MeetCard, MeetPageShell } from "./_MeetChrome.js";
39
39
  import { useLocalMedia } from "../shared/media/useLocalMedia.js";
40
40
  import MeetLobby from "./_MeetLobby.js";
@@ -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,10 @@ 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}
357
+ // A custom background is a fixed picture, not a live reflection - mirroring it would show it backwards
358
+ // to no one but the participant themselves (see `_ParticipantTile.tsx`'s doc comment).
359
+ mirrored={media.filters.background !== "image"}
343
360
  className={className}
344
361
  />
345
362
  );
@@ -481,6 +498,13 @@ function RemoteAudio({ stream, onBlocked }: { stream: MediaStream; onBlocked: ()
481
498
  return <audio ref={audioRef} autoPlay data-testid="remote-audio" />;
482
499
  }
483
500
 
501
+ /** The WebSocket media relay for this call, or `undefined` when this browser cannot run it (no WebCodecs) - the
502
+ * mesh then never falls back to it, and says so by marking an unreachable participant `"failed"`. */
503
+ function createRelay(meetingUid: string, peerId: string): RelayTransportLike | undefined {
504
+ const relay = createRelayTransport({ meetingUid, peerId });
505
+ return relay.supported ? relay : undefined;
506
+ }
507
+
484
508
  function stopLevelMeter(ref: React.MutableRefObject<Record<string, LevelMeterHandle>>, uid: string): void {
485
509
  ref.current[uid]?.stop();
486
510
  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
+ }
@@ -6,7 +6,7 @@
6
6
  * same `booking-plugin`'s `_BookingChrome.tsx` shell-component pattern this plugin's Phase 2 spec calls for. Not a
7
7
  * page itself: `_`-prefixed files in `apps/` aren't routed. */
8
8
  import React, { PropsWithChildren } from "react";
9
- import { Branding } from "@rapidmx/react-shared/branding/brandingApi.js";
9
+ import { Branding } from "@rapidmx/web-client/lib/branding/brandingApi.js";
10
10
  import { BrandingFooter, BrandingHeader } from "@rapidmx/web-client/shared/components/layout/BrandingChrome.js";
11
11
 
12
12
  export function MeetPageShell({ branding, children }: PropsWithChildren<{ branding: Branding | null }>) {
@@ -8,22 +8,32 @@
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
14
18
  * nothing to send can still see and hear the call, and turn a camera or microphone on later from the call controls.
15
19
  *
20
+ * The preview is mirrored, like a real mirror, so it feels natural - except with a custom background image, a fixed
21
+ * picture rather than a live reflection: mirroring it would show it backwards to no one but the participant
22
+ * themselves (see `_ParticipantTile.tsx`'s doc comment, which the in-call tile follows for the same reason).
23
+ *
16
24
  * Per this plugin's Phase 2 spec, the name field is always editable - it is only ever *prefilled* when a session is
17
25
  * genuinely known (see this component's `initialName` prop and `[token].tsx`'s own doc comment on why that never
18
26
  * actually happens on this page today).
19
27
  */
20
28
  import React, { useEffect, useRef, useState } from "react";
21
- import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
22
- import Button from "@rapidmx/react-shared/components/buttons/Button.js";
23
- import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
29
+ import Alert from "@rapidmx/web-client/lib/components/feedback/Alert.js";
30
+ import Button from "@rapidmx/web-client/lib/components/buttons/Button.js";
31
+ import FormField from "@rapidmx/web-client/lib/components/forms/FormField.js";
24
32
  import type { LocalMedia, TrackStatus } from "../shared/media/useLocalMedia.js";
25
33
  import type { PublicVideoMeeting } from "./_meetApi.js";
26
34
  import { LevelBars } from "./_CallControls.js";
35
+ import EffectsPanel from "./_EffectsPanel.js";
36
+ import { filtersActive } from "../shared/media/filters/filterTypes.js";
27
37
  import { MicIcon, MicOffIcon, VideoIcon, VideoOffIcon } from "./_icons.js";
28
38
 
29
39
  const INPUT_CLASS =
@@ -65,6 +75,7 @@ function previewMessage(media: LocalMedia): string {
65
75
 
66
76
  export default function MeetLobby({ meeting, media, initialName, onJoin }: MeetLobbyProps) {
67
77
  const [name, setName] = useState(initialName ?? "");
78
+ const [showEffects, setShowEffects] = useState(false);
68
79
  const videoRef = useRef<HTMLVideoElement>(null);
69
80
  const { requestAccess } = media;
70
81
 
@@ -94,7 +105,13 @@ export default function MeetLobby({ meeting, media, initialName, onJoin }: MeetL
94
105
  <div>
95
106
  <div className="relative aspect-video bg-[#202124] rounded-lg overflow-hidden flex items-center justify-center">
96
107
  {media.videoStream ? (
97
- <video ref={videoRef} autoPlay playsInline muted className="w-full h-full object-cover [transform:scaleX(-1)]" />
108
+ <video
109
+ ref={videoRef}
110
+ autoPlay
111
+ playsInline
112
+ muted
113
+ className={`w-full h-full object-cover ${media.filters.background !== "image" ? "[transform:scaleX(-1)]" : ""}`}
114
+ />
98
115
  ) : (
99
116
  <p className="px-4 text-center text-white/70 text-sm">{previewMessage(media)}</p>
100
117
  )}
@@ -152,6 +169,21 @@ export default function MeetLobby({ meeting, media, initialName, onJoin }: MeetL
152
169
  </select>
153
170
  </label>
154
171
  )}
172
+ <div className="mt-3">
173
+ <button
174
+ type="button"
175
+ className="text-sm text-primary underline focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
176
+ aria-expanded={showEffects}
177
+ onClick={() => setShowEffects((shown) => !shown)}
178
+ >
179
+ {filtersActive(media.filters) ? "Effects (on)" : "Effects"}
180
+ </button>
181
+ {showEffects && (
182
+ <div className="mt-2">
183
+ <EffectsPanel media={media} tone="light" />
184
+ </div>
185
+ )}
186
+ </div>
155
187
  {media.devices.microphones.length > 1 && (
156
188
  <label className="block text-sm mt-3">
157
189
  Microphone
@@ -8,10 +8,44 @@
8
8
  *
9
9
  * A tile never plays sound: its `<video>` is always muted, and what a participant says is played by the call view's
10
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. */
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. */
12
18
  import React, { useEffect, useRef } from "react";
19
+ import type { MediaTransport } from "../shared/webrtc/types.js";
13
20
  import { MicOffIcon } from "./_icons.js";
14
21
 
22
+ /** What a tile says about how its participant's media is arriving, for the paths that are not the ordinary direct one
23
+ * (which needs no comment). Text rather than only an icon, so the reason a picture is degraded is never a guess. */
24
+ const TRANSPORT_BADGES: Partial<Record<MediaTransport, { label: string; title: string }>> = {
25
+ turn: { label: "Relayed", title: "Connected through the relay server, because a direct connection was not possible." },
26
+ websocket: {
27
+ label: "Server relay",
28
+ title: "Sent through the server as a last resort, because no other connection was possible. Video is lower quality and sound may lag.",
29
+ },
30
+ failed: { label: "Can't connect", title: "This participant could not be reached from your network." },
31
+ };
32
+
33
+ /** A short line saying the connection is still being made, with a spinner - a polite live region (not `role="status"`,
34
+ * which the call view's own announcement region already is), so a participant who cannot see the tile still learns
35
+ * why nothing is happening yet. */
36
+ function StatusPill({ status, className }: { status: string; className?: string }) {
37
+ return (
38
+ <span
39
+ aria-live="polite"
40
+ data-testid="tile-status"
41
+ className={`flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-black/60 text-white text-xs ${className ?? ""}`}
42
+ >
43
+ <span className="inline-block w-3 h-3 rounded-full border-2 border-white/30 border-t-white animate-spin" aria-hidden="true" />
44
+ {status}
45
+ </span>
46
+ );
47
+ }
48
+
15
49
  export interface ParticipantTileProps {
16
50
  name: string;
17
51
  stream?: MediaStream | null;
@@ -20,10 +54,18 @@ export interface ParticipantTileProps {
20
54
  cameraOff?: boolean;
21
55
  micMuted?: boolean;
22
56
  handRaised?: boolean;
57
+ /** How this participant's media reaches you - a badge is shown for everything but a direct or still-connecting one. */
58
+ transport?: MediaTransport;
59
+ /** Says the connection is still being made, e.g. "Connecting..." on the local tile and "Awaiting connection..." on a
60
+ * participant's - shown with a spinner until the caller clears it. */
61
+ status?: string;
23
62
  /** Highlights this tile as the current presenter/focus. */
24
63
  isFocused?: boolean;
25
64
  /** Fits the whole picture inside the tile instead of filling it - a shared screen must not be cropped. */
26
65
  contain?: boolean;
66
+ /** Whether the local tile is shown mirrored, like a real mirror - ignored for a remote tile, which is never
67
+ * mirrored. Default `true`; see this module's doc comment. */
68
+ mirrored?: boolean;
27
69
  onClick?: () => void;
28
70
  className?: string;
29
71
  }
@@ -39,11 +81,15 @@ export default function ParticipantTile({
39
81
  cameraOff,
40
82
  micMuted,
41
83
  handRaised,
84
+ transport,
85
+ status,
42
86
  isFocused,
43
87
  contain,
88
+ mirrored = true,
44
89
  onClick,
45
90
  className,
46
91
  }: ParticipantTileProps) {
92
+ const badge = transport ? TRANSPORT_BADGES[transport] : undefined;
47
93
  const videoRef = useRef<HTMLVideoElement>(null);
48
94
  const showVideo = !!stream && !cameraOff;
49
95
 
@@ -65,16 +111,22 @@ export default function ParticipantTile({
65
111
  aria-label={onClick ? `${name}${isLocal ? " (you)" : ""}` : undefined}
66
112
  >
67
113
  {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
- />
114
+ <>
115
+ <video
116
+ ref={videoRef}
117
+ autoPlay
118
+ playsInline
119
+ muted
120
+ className={`w-full h-full ${contain ? "object-contain bg-black" : "object-cover"} ${isLocal && !contain && mirrored ? "[transform:scaleX(-1)]" : ""}`}
121
+ />
122
+ {status && <StatusPill status={status} className="absolute top-2 left-1/2 -translate-x-1/2 whitespace-nowrap" />}
123
+ </>
75
124
  ) : (
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)}
125
+ <div className="flex flex-col items-center gap-2 max-w-full px-1">
126
+ <div className="w-16 h-16 rounded-full bg-primary text-white flex items-center justify-center text-2xl font-bold" aria-hidden="true">
127
+ {initialOf(name)}
128
+ </div>
129
+ {status && <StatusPill status={status} />}
78
130
  </div>
79
131
  )}
80
132
  {handRaised && (
@@ -89,6 +141,17 @@ export default function ParticipantTile({
89
141
  </span>
90
142
  </span>
91
143
  )}
144
+ {badge && (
145
+ <span
146
+ className={`absolute bottom-2 right-2 px-2 py-0.5 rounded text-xs ${
147
+ transport === "failed" ? "bg-[#601410] text-[#f9dedc]" : "bg-black/60 text-white"
148
+ }`}
149
+ title={badge.title}
150
+ data-testid="transport-badge"
151
+ >
152
+ {badge.label}
153
+ </span>
154
+ )}
92
155
  <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
156
  {name}
94
157
  {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
+ );
@@ -3,8 +3,8 @@
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import React, { PropsWithChildren } from "react";
6
- import { Branding } from "@rapidmx/react-shared/branding/brandingApi.js";
7
- import { CUSTOM_STYLESHEET_LINK_ID } from "@rapidmx/react-shared/branding/useBranding.js";
6
+ import { Branding } from "@rapidmx/web-client/lib/branding/brandingApi.js";
7
+ import { CUSTOM_STYLESHEET_LINK_ID } from "@rapidmx/web-client/lib/branding/useBranding.js";
8
8
 
9
9
  export interface LayoutProps {
10
10
  /** Supplied by the server's public page route (`PublicPageRoute`), which every `public` plugin app's route