@rapidmx/meet-plugin 0.5.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 +24 -7
@@ -7,15 +7,18 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
7
7
  * The in-call control bar, fixed to the bottom of the call (`_CallView.tsx` lays it out below the tiles, never over
8
8
  * them). Its menus open upward, centered on the whole bar on a phone (where the bar wraps onto two rows and a menu
9
9
  * 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
10
- * screen, reactions, raise hand, grid/focus and leave.
10
+ * screen, reactions, raise hand, grid/focus and leave. The effects button opens the video filter picker
11
+ * (`_EffectsPanel.tsx`) and lights up while any filter is on.
11
12
  *
12
13
  * The microphone button shows a live level while it is unmuted - bars that move with the sound the microphone is
13
14
  * picking up, so a participant can see their audio is being sent - and the camera button shows a green dot while a
14
15
  * camera is sending. Both are driven by `LocalMedia` (`apps/shared/media/useLocalMedia.ts`).
15
16
  */
16
17
  import { useEffect, useRef, useState } from "react";
18
+ import { filtersActive } from "../shared/media/filters/filterTypes.js";
17
19
  import { REACTION_EMOJIS } from "../shared/webrtc/types.js";
18
- import { ChevronUpIcon, EmojiIcon, FocusIcon, GridIcon, HandIcon, LeaveIcon, MicIcon, MicOffIcon, ScreenShareIcon, VideoIcon, VideoOffIcon, } from "./_icons.js";
20
+ import EffectsPanel from "./_EffectsPanel.js";
21
+ import { ChevronUpIcon, EffectsIcon, EmojiIcon, FocusIcon, GridIcon, HandIcon, LeaveIcon, MicIcon, MicOffIcon, ScreenShareIcon, VideoIcon, VideoOffIcon, } from "./_icons.js";
19
22
  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";
20
23
  const NEUTRAL = "bg-[#3c4043] text-white hover:bg-[#4b4f53]";
21
24
  const ALERT = "bg-[#f9dedc] text-[#8c1d18] hover:bg-[#f2c4c0]";
@@ -53,7 +56,7 @@ export default function CallControls({ media, isPresenting, presentingElsewhereN
53
56
  function toggleMenu(menu) {
54
57
  setOpenMenu((prev) => (prev === menu ? null : menu));
55
58
  }
56
- return (_jsxs("div", { ref: barRef, className: "relative flex flex-wrap items-center justify-center gap-2 px-3", role: "toolbar", "aria-label": "Call controls", children: [_jsxs("div", { className: "sm:relative flex items-stretch gap-px", children: [_jsx("button", { type: "button", className: `${BUTTON} w-9 rounded-r-none ${media.micOn ? NEUTRAL : ALERT}`, "aria-label": "Choose microphone", "aria-haspopup": "menu", "aria-expanded": openMenu === "audio", onClick: () => toggleMenu("audio"), children: _jsx(ChevronUpIcon, {}) }), _jsxs("button", { type: "button", className: `${BUTTON} min-w-14 gap-1.5 px-3 rounded-l-none ${media.micOn ? NEUTRAL : ALERT}`, "aria-label": media.micOn ? "Mute microphone" : "Unmute microphone", "aria-pressed": !media.micOn, onClick: () => void media.toggleMic(), children: [media.micOn && _jsx(LevelBars, { level: media.audioLevel }), media.micOn ? _jsx(MicIcon, {}) : _jsx(MicOffIcon, {})] }), openMenu === "audio" && _jsx(DeviceMenu, { kind: "audio", media: media, onClose: () => setOpenMenu(null) })] }), _jsxs("div", { className: "sm:relative flex items-stretch gap-px", children: [_jsx("button", { type: "button", className: `${BUTTON} w-9 rounded-r-none ${media.cameraOn ? NEUTRAL : ALERT}`, "aria-label": "Choose camera", "aria-haspopup": "menu", "aria-expanded": openMenu === "video", onClick: () => toggleMenu("video"), children: _jsx(ChevronUpIcon, {}) }), _jsxs("button", { type: "button", className: `${BUTTON} relative min-w-14 px-3 rounded-l-none ${media.cameraOn ? NEUTRAL : ALERT}`, "aria-label": media.cameraOn ? "Turn off camera" : "Turn on camera", "aria-pressed": !media.cameraOn, onClick: () => void media.toggleCamera(), children: [media.cameraOn ? _jsx(VideoIcon, {}) : _jsx(VideoOffIcon, {}), media.cameraOn && (_jsx("span", { className: "absolute top-2 right-2 w-2 h-2 rounded-full bg-[#34a853] animate-pulse", "data-testid": "camera-live", title: "Your camera is sending" }))] }), openMenu === "video" && _jsx(DeviceMenu, { kind: "video", media: media, onClose: () => setOpenMenu(null) })] }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${isPresenting ? ACTIVE : NEUTRAL} disabled:opacity-40 disabled:cursor-not-allowed`, "aria-label": isPresenting ? "Stop sharing" : "Share screen", "aria-pressed": isPresenting, disabled: shareDisabled, title: shareDisabled ? `${presentingElsewhereName} is presenting - stop their share to present yourself.` : undefined, onClick: onToggleShare, children: _jsx(ScreenShareIcon, {}) }), _jsxs("div", { className: "sm:relative", children: [_jsx("button", { type: "button", className: `${BUTTON} w-12 ${NEUTRAL}`, "aria-label": "Send a reaction", "aria-haspopup": "menu", "aria-expanded": openMenu === "emoji", onClick: () => toggleMenu("emoji"), children: _jsx(EmojiIcon, {}) }), openMenu === "emoji" && (_jsx("div", { role: "menu", "aria-label": "Reactions", className: "absolute bottom-full mb-3 left-1/2 -translate-x-1/2 w-max grid grid-cols-4 gap-1 p-2 rounded-2xl bg-[#2b2d30] shadow-xl", children: REACTION_EMOJIS.map((emoji) => (_jsx("button", { type: "button", role: "menuitem", className: "w-11 h-11 rounded-lg text-2xl hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", "aria-label": `Send ${emoji}`, onClick: () => {
59
+ return (_jsxs("div", { ref: barRef, className: "relative flex flex-wrap items-center justify-center gap-2 px-3", role: "toolbar", "aria-label": "Call controls", children: [_jsxs("div", { className: "sm:relative flex items-stretch gap-px", children: [_jsx("button", { type: "button", className: `${BUTTON} w-9 rounded-r-none ${media.micOn ? NEUTRAL : ALERT}`, "aria-label": "Choose microphone", "aria-haspopup": "menu", "aria-expanded": openMenu === "audio", onClick: () => toggleMenu("audio"), children: _jsx(ChevronUpIcon, {}) }), _jsxs("button", { type: "button", className: `${BUTTON} min-w-14 gap-1.5 px-3 rounded-l-none ${media.micOn ? NEUTRAL : ALERT}`, "aria-label": media.micOn ? "Mute microphone" : "Unmute microphone", "aria-pressed": !media.micOn, onClick: () => void media.toggleMic(), children: [media.micOn && _jsx(LevelBars, { level: media.audioLevel }), media.micOn ? _jsx(MicIcon, {}) : _jsx(MicOffIcon, {})] }), openMenu === "audio" && _jsx(DeviceMenu, { kind: "audio", media: media, onClose: () => setOpenMenu(null) })] }), _jsxs("div", { className: "sm:relative flex items-stretch gap-px", children: [_jsx("button", { type: "button", className: `${BUTTON} w-9 rounded-r-none ${media.cameraOn ? NEUTRAL : ALERT}`, "aria-label": "Choose camera", "aria-haspopup": "menu", "aria-expanded": openMenu === "video", onClick: () => toggleMenu("video"), children: _jsx(ChevronUpIcon, {}) }), _jsxs("button", { type: "button", className: `${BUTTON} relative min-w-14 px-3 rounded-l-none ${media.cameraOn ? NEUTRAL : ALERT}`, "aria-label": media.cameraOn ? "Turn off camera" : "Turn on camera", "aria-pressed": !media.cameraOn, onClick: () => void media.toggleCamera(), children: [media.cameraOn ? _jsx(VideoIcon, {}) : _jsx(VideoOffIcon, {}), media.cameraOn && (_jsx("span", { className: "absolute top-2 right-2 w-2 h-2 rounded-full bg-[#34a853] animate-pulse", "data-testid": "camera-live", title: "Your camera is sending" }))] }), openMenu === "video" && _jsx(DeviceMenu, { kind: "video", media: media, onClose: () => setOpenMenu(null) })] }), _jsxs("div", { className: "sm:relative", children: [_jsx("button", { type: "button", className: `${BUTTON} w-12 ${filtersActive(media.filters) ? ACTIVE : NEUTRAL}`, "aria-label": "Video effects", "aria-haspopup": "dialog", "aria-expanded": openMenu === "effects", onClick: () => toggleMenu("effects"), children: _jsx(EffectsIcon, {}) }), openMenu === "effects" && (_jsx("div", { role: "dialog", "aria-label": "Video effects", 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", children: _jsx(EffectsPanel, { media: media, tone: "dark" }) }))] }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${isPresenting ? ACTIVE : NEUTRAL} disabled:opacity-40 disabled:cursor-not-allowed`, "aria-label": isPresenting ? "Stop sharing" : "Share screen", "aria-pressed": isPresenting, disabled: shareDisabled, title: shareDisabled ? `${presentingElsewhereName} is presenting - stop their share to present yourself.` : undefined, onClick: onToggleShare, children: _jsx(ScreenShareIcon, {}) }), _jsxs("div", { className: "sm:relative", children: [_jsx("button", { type: "button", className: `${BUTTON} w-12 ${NEUTRAL}`, "aria-label": "Send a reaction", "aria-haspopup": "menu", "aria-expanded": openMenu === "emoji", onClick: () => toggleMenu("emoji"), children: _jsx(EmojiIcon, {}) }), openMenu === "emoji" && (_jsx("div", { role: "menu", "aria-label": "Reactions", className: "absolute bottom-full mb-3 left-1/2 -translate-x-1/2 w-max grid grid-cols-4 gap-1 p-2 rounded-2xl bg-[#2b2d30] shadow-xl", children: REACTION_EMOJIS.map((emoji) => (_jsx("button", { type: "button", role: "menuitem", className: "w-11 h-11 rounded-lg text-2xl hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80", "aria-label": `Send ${emoji}`, onClick: () => {
57
60
  onReaction(emoji);
58
61
  setOpenMenu(null);
59
62
  }, children: emoji }, emoji))) }))] }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${handRaised ? ACTIVE : NEUTRAL}`, "aria-label": handRaised ? "Lower hand" : "Raise hand", "aria-pressed": handRaised, onClick: onToggleHand, children: _jsx(HandIcon, {}) }), _jsx("button", { type: "button", className: `${BUTTON} w-12 ${NEUTRAL}`, "aria-label": viewMode === "grid" ? "Switch to focused view" : "Switch to grid view", onClick: onToggleViewMode, children: viewMode === "grid" ? _jsx(FocusIcon, {}) : _jsx(GridIcon, {}) }), _jsx("button", { type: "button", className: `${BUTTON} w-16 bg-[#d93025] text-white hover:bg-[#b3261e]`, "aria-label": "Leave call", onClick: onLeave, children: _jsx(LeaveIcon, {}) })] }));
@@ -42,6 +42,9 @@ export interface CallViewProps {
42
42
  selfName: string;
43
43
  meetingTitle: string;
44
44
  iceServers: RTCIceServer[];
45
+ /** Whether the server offers the WebSocket media relay, the last-resort path for a participant neither a direct
46
+ * connection nor the TURN server can reach - `VideoMeetingJoinResult.relayEnabled`. Off when absent. */
47
+ relayEnabled?: boolean;
45
48
  /** The camera and microphone, owned by the page (`[token].tsx`) - the lobby's tracks carried into the call. */
46
49
  media: LocalMedia;
47
50
  onLeave: () => void;
@@ -58,4 +61,4 @@ export declare function computeMainUid(options: {
58
61
  * from two devices - or two tabs - is two participants rather than one that ignores itself. It travels as the
59
62
  * `peer` of each message; `from` stays the exact authenticated uid, which the server requires. */
60
63
  export declare function newPeerId(selfUid: string): string;
61
- export default function CallView({ channel, token, selfUid, selfName, meetingTitle, iceServers, media, onLeave }: CallViewProps): React.JSX.Element;
64
+ export default function CallView({ channel, token, selfUid, selfName, meetingTitle, iceServers, relayEnabled, media, onLeave }: CallViewProps): React.JSX.Element;
@@ -42,6 +42,7 @@ import { startLevelMeter } from "../shared/media/levelMeter.js";
42
42
  import { requestDisplayMedia, stopStream } from "../shared/media/deviceMedia.js";
43
43
  import { createBrowserPeerConnection } from "../shared/webrtc/realPeerConnection.js";
44
44
  import { MeshConnectionManager } from "../shared/webrtc/MeshConnectionManager.js";
45
+ import { createRelayTransport } from "../shared/relay/RelayTransport.js";
45
46
  import { GuestSignalingClient } from "../shared/push/GuestSignalingClient.js";
46
47
  import CallControls from "./_CallControls.js";
47
48
  import ParticipantTile from "./_ParticipantTile.js";
@@ -69,7 +70,7 @@ export function computeMainUid(options) {
69
70
  export function newPeerId(selfUid) {
70
71
  return `${selfUid}~${globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2)}`;
71
72
  }
72
- export default function CallView({ channel, token, selfUid, selfName, meetingTitle, iceServers, media, onLeave }) {
73
+ export default function CallView({ channel, token, selfUid, selfName, meetingTitle, iceServers, relayEnabled, media, onLeave }) {
73
74
  const [peerId] = useState(() => newPeerId(selfUid));
74
75
  const [connectError, setConnectError] = useState(null);
75
76
  const [participants, setParticipants] = useState([]);
@@ -85,6 +86,7 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
85
86
  const [announcement, setAnnouncement] = useState("");
86
87
  const [audioBlocked, setAudioBlocked] = useState(false);
87
88
  const [audioNonce, setAudioNonce] = useState(0);
89
+ const [signalingReady, setSignalingReady] = useState(false);
88
90
  const managerRef = useRef(null);
89
91
  const screenStreamRef = useRef(null);
90
92
  const levelMetersRef = useRef({});
@@ -110,6 +112,7 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
110
112
  iceServers,
111
113
  channel: client,
112
114
  createPeerConnection: createBrowserPeerConnection,
115
+ relay: relayEnabled ? createRelay(channel, peerId) : undefined,
113
116
  localAudioTrack: media.audioTrack,
114
117
  localVideoTrack: media.videoTrack,
115
118
  localState: { audioOn: media.micOn, videoOn: media.cameraOn },
@@ -162,6 +165,7 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
162
165
  .then(() => {
163
166
  if (!cancelled) {
164
167
  manager.start();
168
+ setSignalingReady(true);
165
169
  }
166
170
  })
167
171
  .catch((err) => {
@@ -266,8 +270,12 @@ export default function CallView({ channel, token, selfUid, selfName, meetingTit
266
270
  const presenterName = presenterUid ? (presenterUid === peerId ? selfName : (participants.find((p) => p.uid === presenterUid)?.name ?? "Someone")) : undefined;
267
271
  const raisedNames = [...(handRaised ? ["You"] : []), ...participants.filter((p) => p.handRaised).map((p) => p.name)];
268
272
  const alone = participants.length === 0;
269
- const remoteTile = (p, className) => (_jsx(ParticipantTile, { name: p.name, stream: remoteStreams[p.uid] ?? null, cameraOff: !p.videoOn, micMuted: !p.audioOn, handRaised: p.handRaised, isFocused: mainUid === p.uid, contain: presenterUid === p.uid, className: className, onClick: () => togglePin(p.uid) }, p.uid));
270
- const selfTile = (className) => (_jsx(ParticipantTile, { name: selfName, stream: media.videoStream, isLocal: true, cameraOff: !media.cameraOn, micMuted: !media.micOn, handRaised: handRaised, className: className }));
273
+ // This participant has no working link yet: the signaling channel is still opening, or there is someone in the call
274
+ // and every connection to them is still being made. Once any one is up (or nobody else is here) it is not shown,
275
+ // so someone already in the call is not told they are "connecting" each time a newcomer arrives.
276
+ const selfConnecting = !connectError && (!signalingReady || (!alone && participants.every((p) => p.transport === "connecting")));
277
+ const remoteTile = (p, className) => (_jsx(ParticipantTile, { name: p.name, stream: remoteStreams[p.uid] ?? null, cameraOff: !p.videoOn, micMuted: !p.audioOn, handRaised: p.handRaised, transport: p.transport, status: p.transport === "connecting" ? "Awaiting connection…" : undefined, isFocused: mainUid === p.uid, contain: presenterUid === p.uid, className: className, onClick: () => togglePin(p.uid) }, p.uid));
278
+ const selfTile = (className) => (_jsx(ParticipantTile, { name: selfName, stream: media.videoStream, isLocal: true, cameraOff: !media.cameraOn, micMuted: !media.micOn, handRaised: handRaised, status: selfConnecting ? "Connecting…" : undefined, className: className }));
271
279
  const mainParticipant = participants.find((p) => p.uid === mainUid);
272
280
  const thumbnails = participants.filter((p) => p.uid !== mainUid);
273
281
  let stage;
@@ -297,6 +305,12 @@ function RemoteAudio({ stream, onBlocked }) {
297
305
  }, [stream, onBlocked]);
298
306
  return _jsx("audio", { ref: audioRef, autoPlay: true, "data-testid": "remote-audio" });
299
307
  }
308
+ /** The WebSocket media relay for this call, or `undefined` when this browser cannot run it (no WebCodecs) - the
309
+ * mesh then never falls back to it, and says so by marking an unreachable participant `"failed"`. */
310
+ function createRelay(meetingUid, peerId) {
311
+ const relay = createRelayTransport({ meetingUid, peerId });
312
+ return relay.supported ? relay : undefined;
313
+ }
300
314
  function stopLevelMeter(ref, uid) {
301
315
  ref.current[uid]?.stop();
302
316
  delete ref.current[uid];
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The picker for the video filters (`apps/shared/media/filters/`): a background, a colour effect and a face accessory,
3
+ * each one of a short list, chosen independently so they combine. Shared by the lobby (on the page's light theme) and
4
+ * the in-call control bar (on its dark one) - `tone` picks the colours, nothing else differs.
5
+ *
6
+ * The background can also be a picture from the participant's own device: choosing "Custom image" with none picked yet
7
+ * opens the file picker, and once one is picked "Change image" is offered beside the options. The picture is read and
8
+ * kept in the browser (`filters/backgroundImage.ts`) - it is never uploaded.
9
+ */
10
+ import React from "react";
11
+ import type { LocalMedia } from "../shared/media/useLocalMedia.js";
12
+ export type EffectsTone = "light" | "dark";
13
+ export interface EffectsPanelProps {
14
+ media: LocalMedia;
15
+ tone: EffectsTone;
16
+ }
17
+ export default function EffectsPanel({ media, tone }: EffectsPanelProps): React.JSX.Element;
@@ -0,0 +1,60 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ /**
7
+ * The picker for the video filters (`apps/shared/media/filters/`): a background, a colour effect and a face accessory,
8
+ * each one of a short list, chosen independently so they combine. Shared by the lobby (on the page's light theme) and
9
+ * the in-call control bar (on its dark one) - `tone` picks the colours, nothing else differs.
10
+ *
11
+ * The background can also be a picture from the participant's own device: choosing "Custom image" with none picked yet
12
+ * opens the file picker, and once one is picked "Change image" is offered beside the options. The picture is read and
13
+ * kept in the browser (`filters/backgroundImage.ts`) - it is never uploaded.
14
+ */
15
+ import { useRef, useState } from "react";
16
+ import { ACCESSORY_OPTIONS, BACKGROUND_OPTIONS, EFFECT_OPTIONS, } from "../shared/media/filters/filterTypes.js";
17
+ 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";
18
+ const TONES = {
19
+ light: {
20
+ idle: "border border-border bg-surface text-text hover:bg-surface-alt",
21
+ selected: "border border-primary bg-surface-alt text-text font-semibold",
22
+ muted: "text-text-muted",
23
+ ring: "focus-visible:ring-primary",
24
+ link: "text-primary",
25
+ },
26
+ dark: {
27
+ idle: "bg-[#3c4043] text-white hover:bg-[#4b4f53]",
28
+ selected: "bg-[#a8c7fa] text-[#062e6f] hover:bg-[#8ab4f8]",
29
+ muted: "text-white/70",
30
+ ring: "focus-visible:ring-white/80",
31
+ link: "text-[#8ab4f8]",
32
+ },
33
+ };
34
+ export default function EffectsPanel({ media, tone }) {
35
+ const colors = TONES[tone];
36
+ const fileInput = useRef(null);
37
+ const [fileError, setFileError] = useState(null);
38
+ const { filters, filterStatus } = media;
39
+ function group(key, label, options, onChoose) {
40
+ return (_jsxs("div", { role: "group", "aria-label": label, className: "mt-3 first:mt-0", children: [_jsx("p", { className: `text-xs font-semibold uppercase tracking-wide mb-1.5 ${colors.muted}`, children: label }), _jsx("div", { className: "flex flex-wrap gap-1.5", children: options.map((option) => (_jsxs("button", { type: "button", className: `${CHIP} ${colors.ring} ${filters[key] === option.id ? colors.selected : colors.idle}`, "aria-pressed": filters[key] === option.id, onClick: () => onChoose(option.id), children: [_jsx("span", { "aria-hidden": "true", children: option.emoji }), option.label] }, option.id))) })] }));
41
+ }
42
+ async function handleFile(event) {
43
+ const input = event.target;
44
+ const file = input.files?.[0];
45
+ // Cleared so picking the same file again still counts as a change.
46
+ input.value = "";
47
+ if (file) {
48
+ setFileError(await media.chooseBackgroundImage(file));
49
+ }
50
+ }
51
+ function chooseBackground(id) {
52
+ setFileError(null);
53
+ if (id === "image" && !media.hasBackgroundImage) {
54
+ fileInput.current?.click();
55
+ return;
56
+ }
57
+ media.setFilters({ background: id });
58
+ }
59
+ return (_jsxs("div", { children: [group("background", "Background", BACKGROUND_OPTIONS, chooseBackground), media.hasBackgroundImage && (_jsx("button", { type: "button", className: `mt-1.5 text-sm underline ${colors.link} focus:outline-none focus-visible:ring-2 ${colors.ring}`, onClick: () => fileInput.current?.click(), children: "Change image" })), _jsx("input", { ref: fileInput, type: "file", accept: "image/*", className: "hidden", "aria-label": "Background image", onChange: handleFile }), fileError && (_jsx("p", { role: "alert", className: "mt-1.5 text-sm text-[#d93025]", children: fileError })), group("effect", "Look", EFFECT_OPTIONS, (id) => media.setFilters({ effect: id })), group("accessory", "Fun", ACCESSORY_OPTIONS, (id) => media.setFilters({ accessory: id })), filterStatus.loading && (_jsx("p", { role: "status", className: `mt-3 text-sm ${colors.muted}`, children: "Loading effects\u2026" })), filterStatus.error && (_jsx("p", { role: "alert", className: "mt-3 text-sm text-[#d93025]", children: filterStatus.error })), !media.cameraOn && _jsx("p", { className: `mt-3 text-sm ${colors.muted}`, children: "Turn on your camera to see effects." })] }));
60
+ }
@@ -4,6 +4,10 @@
4
4
  * the page (`[token].tsx`) rather than by this component - the tracks previewed here are the tracks the call sends,
5
5
  * so leaving the lobby for the call must not (and does not) stop them.
6
6
  *
7
+ * The "Effects" section opens the video filter picker (`_EffectsPanel.tsx`), so the participant sees themselves with
8
+ * a blurred or replaced background, or a fun accessory, before anyone else does. What they chose last time is
9
+ * already applied when the lobby opens (`useLocalMedia()` remembers it).
10
+ *
7
11
  * Access is requested when the lobby opens, and again from the "Allow" / "Try again" button: a click is a user
8
12
  * gesture, which some browsers require before they will show a permission prompt at all. When access is refused, or
9
13
  * there is no camera or microphone, the lobby says so and why, and joining is still allowed - a participant with
@@ -9,6 +9,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
9
9
  * the page (`[token].tsx`) rather than by this component - the tracks previewed here are the tracks the call sends,
10
10
  * so leaving the lobby for the call must not (and does not) stop them.
11
11
  *
12
+ * The "Effects" section opens the video filter picker (`_EffectsPanel.tsx`), so the participant sees themselves with
13
+ * a blurred or replaced background, or a fun accessory, before anyone else does. What they chose last time is
14
+ * already applied when the lobby opens (`useLocalMedia()` remembers it).
15
+ *
12
16
  * Access is requested when the lobby opens, and again from the "Allow" / "Try again" button: a click is a user
13
17
  * gesture, which some browsers require before they will show a permission prompt at all. When access is refused, or
14
18
  * there is no camera or microphone, the lobby says so and why, and joining is still allowed - a participant with
@@ -23,6 +27,8 @@ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
23
27
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
24
28
  import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
25
29
  import { LevelBars } from "./_CallControls.js";
30
+ import EffectsPanel from "./_EffectsPanel.js";
31
+ import { filtersActive } from "../shared/media/filters/filterTypes.js";
26
32
  import { MicIcon, MicOffIcon, VideoIcon, VideoOffIcon } from "./_icons.js";
27
33
  const INPUT_CLASS = "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";
28
34
  const ROUND_BUTTON = "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";
@@ -50,6 +56,7 @@ function previewMessage(media) {
50
56
  }
51
57
  export default function MeetLobby({ meeting, media, initialName, onJoin }) {
52
58
  const [name, setName] = useState(initialName ?? "");
59
+ const [showEffects, setShowEffects] = useState(false);
53
60
  const videoRef = useRef(null);
54
61
  const { requestAccess } = media;
55
62
  // Asks once, when the lobby opens - see this module's doc comment for the button that asks again.
@@ -66,5 +73,5 @@ export default function MeetLobby({ meeting, media, initialName, onJoin }) {
66
73
  const cameraOk = media.status.video === "live" || media.status.video === "off";
67
74
  const needsAccess = media.supported && !media.requesting && (media.status.audio !== "live" || !cameraOk);
68
75
  const nothingToSend = !media.audioTrack && !media.videoTrack && !media.requesting;
69
- return (_jsxs("div", { children: [_jsx("h1", { className: "text-2xl font-bold tracking-tight", children: meeting.title }), meeting.hostDisplayName && _jsxs("p", { className: "text-sm text-text-muted mt-1", children: ["Hosted by ", meeting.hostDisplayName] }), _jsxs("div", { className: "mt-6 grid sm:grid-cols-2 gap-6", children: [_jsxs("div", { children: [_jsxs("div", { className: "relative aspect-video bg-[#202124] rounded-lg overflow-hidden flex items-center justify-center", children: [media.videoStream ? (_jsx("video", { ref: videoRef, autoPlay: true, playsInline: true, muted: true, className: "w-full h-full object-cover [transform:scaleX(-1)]" })) : (_jsx("p", { className: "px-4 text-center text-white/70 text-sm", children: previewMessage(media) })), _jsxs("div", { className: "absolute bottom-3 inset-x-0 flex justify-center gap-3", children: [_jsxs("button", { type: "button", className: `${ROUND_BUTTON} ${media.micOn ? "bg-[#3c4043] text-white hover:bg-[#4b4f53]" : "bg-[#f9dedc] text-[#8c1d18] hover:bg-[#f2c4c0]"}`, "aria-label": media.micOn ? "Mute microphone" : "Unmute microphone", "aria-pressed": !media.micOn, onClick: () => void media.toggleMic(), children: [media.micOn && _jsx(LevelBars, { level: media.audioLevel }), media.micOn ? _jsx(MicIcon, {}) : _jsx(MicOffIcon, {})] }), _jsx("button", { type: "button", className: `${ROUND_BUTTON} ${media.cameraOn ? "bg-[#3c4043] text-white hover:bg-[#4b4f53]" : "bg-[#f9dedc] text-[#8c1d18] hover:bg-[#f2c4c0]"}`, "aria-label": media.cameraOn ? "Turn off camera" : "Turn on camera", "aria-pressed": !media.cameraOn, onClick: () => void media.toggleCamera(), children: media.cameraOn ? _jsx(VideoIcon, {}) : _jsx(VideoOffIcon, {}) })] })] }), !media.supported && (_jsx(Alert, { children: "This browser (or this page's connection) doesn't support camera/microphone access. Try a current browser over HTTPS - a link opened inside another app often needs to be opened in the browser itself." })), media.error && _jsx(Alert, { children: media.error.message }), needsAccess && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto mt-3", onClick: () => void requestAccess(), children: media.error ? "Try again" : "Allow camera and microphone" })), nothingToSend && _jsx("p", { className: "text-sm text-text-muted mt-3", children: "You'll join without a camera or microphone." }), media.devices.cameras.length > 1 && (_jsxs("label", { className: "block text-sm mt-3", children: ["Camera", _jsxs("select", { className: `${INPUT_CLASS} mt-1`, value: media.selectedDeviceIds.video ?? "", onChange: (e) => void media.selectDevice("video", e.target.value), children: [!media.selectedDeviceIds.video && _jsx("option", { value: "", children: "Choose a camera" }), media.devices.cameras.map((d, i) => (_jsx("option", { value: d.deviceId, children: d.label || `Camera ${i + 1}` }, d.deviceId)))] })] })), media.devices.microphones.length > 1 && (_jsxs("label", { className: "block text-sm mt-3", children: ["Microphone", _jsxs("select", { className: `${INPUT_CLASS} mt-1`, value: media.selectedDeviceIds.audio ?? "", onChange: (e) => void media.selectDevice("audio", e.target.value), children: [!media.selectedDeviceIds.audio && _jsx("option", { value: "", children: "Choose a microphone" }), media.devices.microphones.map((d, i) => (_jsx("option", { value: d.deviceId, children: d.label || `Microphone ${i + 1}` }, d.deviceId)))] })] }))] }), _jsxs("div", { className: "flex flex-col justify-center gap-3", children: [_jsx(FormField, { label: "Your name", htmlFor: "meet-name", children: _jsx("input", { id: "meet-name", type: "text", className: INPUT_CLASS, value: name, onChange: (e) => setName(e.target.value), placeholder: "Enter your name" }) }), _jsx(Button, { type: "button", className: "!w-auto", disabled: !name.trim(), onClick: () => onJoin(name.trim()), children: "Join meeting" })] })] })] }));
76
+ return (_jsxs("div", { children: [_jsx("h1", { className: "text-2xl font-bold tracking-tight", children: meeting.title }), meeting.hostDisplayName && _jsxs("p", { className: "text-sm text-text-muted mt-1", children: ["Hosted by ", meeting.hostDisplayName] }), _jsxs("div", { className: "mt-6 grid sm:grid-cols-2 gap-6", children: [_jsxs("div", { children: [_jsxs("div", { className: "relative aspect-video bg-[#202124] rounded-lg overflow-hidden flex items-center justify-center", children: [media.videoStream ? (_jsx("video", { ref: videoRef, autoPlay: true, playsInline: true, muted: true, className: "w-full h-full object-cover [transform:scaleX(-1)]" })) : (_jsx("p", { className: "px-4 text-center text-white/70 text-sm", children: previewMessage(media) })), _jsxs("div", { className: "absolute bottom-3 inset-x-0 flex justify-center gap-3", children: [_jsxs("button", { type: "button", className: `${ROUND_BUTTON} ${media.micOn ? "bg-[#3c4043] text-white hover:bg-[#4b4f53]" : "bg-[#f9dedc] text-[#8c1d18] hover:bg-[#f2c4c0]"}`, "aria-label": media.micOn ? "Mute microphone" : "Unmute microphone", "aria-pressed": !media.micOn, onClick: () => void media.toggleMic(), children: [media.micOn && _jsx(LevelBars, { level: media.audioLevel }), media.micOn ? _jsx(MicIcon, {}) : _jsx(MicOffIcon, {})] }), _jsx("button", { type: "button", className: `${ROUND_BUTTON} ${media.cameraOn ? "bg-[#3c4043] text-white hover:bg-[#4b4f53]" : "bg-[#f9dedc] text-[#8c1d18] hover:bg-[#f2c4c0]"}`, "aria-label": media.cameraOn ? "Turn off camera" : "Turn on camera", "aria-pressed": !media.cameraOn, onClick: () => void media.toggleCamera(), children: media.cameraOn ? _jsx(VideoIcon, {}) : _jsx(VideoOffIcon, {}) })] })] }), !media.supported && (_jsx(Alert, { children: "This browser (or this page's connection) doesn't support camera/microphone access. Try a current browser over HTTPS - a link opened inside another app often needs to be opened in the browser itself." })), media.error && _jsx(Alert, { children: media.error.message }), needsAccess && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto mt-3", onClick: () => void requestAccess(), children: media.error ? "Try again" : "Allow camera and microphone" })), nothingToSend && _jsx("p", { className: "text-sm text-text-muted mt-3", children: "You'll join without a camera or microphone." }), media.devices.cameras.length > 1 && (_jsxs("label", { className: "block text-sm mt-3", children: ["Camera", _jsxs("select", { className: `${INPUT_CLASS} mt-1`, value: media.selectedDeviceIds.video ?? "", onChange: (e) => void media.selectDevice("video", e.target.value), children: [!media.selectedDeviceIds.video && _jsx("option", { value: "", children: "Choose a camera" }), media.devices.cameras.map((d, i) => (_jsx("option", { value: d.deviceId, children: d.label || `Camera ${i + 1}` }, d.deviceId)))] })] })), _jsxs("div", { className: "mt-3", children: [_jsx("button", { type: "button", className: "text-sm text-primary underline focus:outline-none focus-visible:ring-2 focus-visible:ring-primary", "aria-expanded": showEffects, onClick: () => setShowEffects((shown) => !shown), children: filtersActive(media.filters) ? "Effects (on)" : "Effects" }), showEffects && (_jsx("div", { className: "mt-2", children: _jsx(EffectsPanel, { media: media, tone: "light" }) }))] }), media.devices.microphones.length > 1 && (_jsxs("label", { className: "block text-sm mt-3", children: ["Microphone", _jsxs("select", { className: `${INPUT_CLASS} mt-1`, value: media.selectedDeviceIds.audio ?? "", onChange: (e) => void media.selectDevice("audio", e.target.value), children: [!media.selectedDeviceIds.audio && _jsx("option", { value: "", children: "Choose a microphone" }), media.devices.microphones.map((d, i) => (_jsx("option", { value: d.deviceId, children: d.label || `Microphone ${i + 1}` }, d.deviceId)))] })] }))] }), _jsxs("div", { className: "flex flex-col justify-center gap-3", children: [_jsx(FormField, { label: "Your name", htmlFor: "meet-name", children: _jsx("input", { id: "meet-name", type: "text", className: INPUT_CLASS, value: name, onChange: (e) => setName(e.target.value), placeholder: "Enter your name" }) }), _jsx(Button, { type: "button", className: "!w-auto", disabled: !name.trim(), onClick: () => onJoin(name.trim()), children: "Join meeting" })] })] })] }));
70
77
  }
@@ -6,6 +6,7 @@
6
6
  * own `<audio>` elements (`_CallView.tsx`'s `RemoteAudio`), so a tile that isn't showing video - or isn't on screen
7
7
  * in the current layout - can never silence anyone. */
8
8
  import React from "react";
9
+ import type { MediaTransport } from "../shared/webrtc/types.js";
9
10
  export interface ParticipantTileProps {
10
11
  name: string;
11
12
  stream?: MediaStream | null;
@@ -14,6 +15,11 @@ export interface ParticipantTileProps {
14
15
  cameraOff?: boolean;
15
16
  micMuted?: boolean;
16
17
  handRaised?: boolean;
18
+ /** How this participant's media reaches you - a badge is shown for everything but a direct or still-connecting one. */
19
+ transport?: MediaTransport;
20
+ /** Says the connection is still being made, e.g. "Connecting..." on the local tile and "Awaiting connection..." on a
21
+ * participant's - shown with a spinner until the caller clears it. */
22
+ status?: string;
17
23
  /** Highlights this tile as the current presenter/focus. */
18
24
  isFocused?: boolean;
19
25
  /** Fits the whole picture inside the tile instead of filling it - a shared screen must not be cropped. */
@@ -21,4 +27,4 @@ export interface ParticipantTileProps {
21
27
  onClick?: () => void;
22
28
  className?: string;
23
29
  }
24
- export default function ParticipantTile({ name, stream, isLocal, cameraOff, micMuted, handRaised, isFocused, contain, onClick, className, }: ParticipantTileProps): React.JSX.Element;
30
+ export default function ParticipantTile({ name, stream, isLocal, cameraOff, micMuted, handRaised, transport, status, isFocused, contain, onClick, className, }: ParticipantTileProps): React.JSX.Element;
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  ///////////////////////////////////////////////////////////////////////////////
3
3
  // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
4
4
  // SPDX-License-Identifier: MPL-2.0
@@ -12,10 +12,27 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
12
12
  * in the current layout - can never silence anyone. */
13
13
  import { useEffect, useRef } from "react";
14
14
  import { MicOffIcon } from "./_icons.js";
15
+ /** What a tile says about how its participant's media is arriving, for the paths that are not the ordinary direct one
16
+ * (which needs no comment). Text rather than only an icon, so the reason a picture is degraded is never a guess. */
17
+ const TRANSPORT_BADGES = {
18
+ turn: { label: "Relayed", title: "Connected through the relay server, because a direct connection was not possible." },
19
+ websocket: {
20
+ label: "Server relay",
21
+ title: "Sent through the server as a last resort, because no other connection was possible. Video is lower quality and sound may lag.",
22
+ },
23
+ failed: { label: "Can't connect", title: "This participant could not be reached from your network." },
24
+ };
25
+ /** A short line saying the connection is still being made, with a spinner - a polite live region (not `role="status"`,
26
+ * which the call view's own announcement region already is), so a participant who cannot see the tile still learns
27
+ * why nothing is happening yet. */
28
+ function StatusPill({ status, className }) {
29
+ return (_jsxs("span", { "aria-live": "polite", "data-testid": "tile-status", className: `flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-black/60 text-white text-xs ${className ?? ""}`, children: [_jsx("span", { className: "inline-block w-3 h-3 rounded-full border-2 border-white/30 border-t-white animate-spin", "aria-hidden": "true" }), status] }));
30
+ }
15
31
  function initialOf(name) {
16
32
  return (Array.from(name.trim())[0] ?? "?").toUpperCase();
17
33
  }
18
- export default function ParticipantTile({ name, stream, isLocal, cameraOff, micMuted, handRaised, isFocused, contain, onClick, className, }) {
34
+ export default function ParticipantTile({ name, stream, isLocal, cameraOff, micMuted, handRaised, transport, status, isFocused, contain, onClick, className, }) {
35
+ const badge = transport ? TRANSPORT_BADGES[transport] : undefined;
19
36
  const videoRef = useRef(null);
20
37
  const showVideo = !!stream && !cameraOff;
21
38
  // The `<video>` element only exists while `showVideo`, so its stream is bound whenever it (re)appears too.
@@ -24,5 +41,5 @@ export default function ParticipantTile({ name, stream, isLocal, cameraOff, micM
24
41
  videoRef.current.srcObject = stream ?? null;
25
42
  }
26
43
  }, [stream, showVideo]);
27
- return (_jsxs("div", { className: `relative overflow-hidden rounded-xl bg-[#3c4043] flex items-center justify-center min-h-[90px] ${isFocused ? "ring-2 ring-[#8ab4f8]" : ""} ${className ?? ""}`, onClick: onClick, role: onClick ? "button" : undefined, tabIndex: onClick ? 0 : undefined, "aria-label": onClick ? `${name}${isLocal ? " (you)" : ""}` : undefined, children: [showVideo ? (_jsx("video", { ref: videoRef, autoPlay: true, playsInline: true, muted: true, className: `w-full h-full ${contain ? "object-contain bg-black" : "object-cover"} ${isLocal && !contain ? "[transform:scaleX(-1)]" : ""}` })) : (_jsx("div", { className: "w-16 h-16 rounded-full bg-primary text-white flex items-center justify-center text-2xl font-bold", "aria-hidden": "true", children: initialOf(name) })), handRaised && (_jsx("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", children: "\u270B" })), micMuted && (_jsx("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", children: _jsx("span", { className: "w-4 h-4 [&>svg]:w-4 [&>svg]:h-4", children: _jsx(MicOffIcon, {}) }) })), _jsxs("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", children: [name, isLocal ? " (you)" : ""] })] }));
44
+ return (_jsxs("div", { className: `relative overflow-hidden rounded-xl bg-[#3c4043] flex items-center justify-center min-h-[90px] ${isFocused ? "ring-2 ring-[#8ab4f8]" : ""} ${className ?? ""}`, onClick: onClick, role: onClick ? "button" : undefined, tabIndex: onClick ? 0 : undefined, "aria-label": onClick ? `${name}${isLocal ? " (you)" : ""}` : undefined, children: [showVideo ? (_jsxs(_Fragment, { children: [_jsx("video", { ref: videoRef, autoPlay: true, playsInline: true, muted: true, className: `w-full h-full ${contain ? "object-contain bg-black" : "object-cover"} ${isLocal && !contain ? "[transform:scaleX(-1)]" : ""}` }), status && _jsx(StatusPill, { status: status, className: "absolute top-2 left-1/2 -translate-x-1/2 whitespace-nowrap" })] })) : (_jsxs("div", { className: "flex flex-col items-center gap-2 max-w-full px-1", children: [_jsx("div", { className: "w-16 h-16 rounded-full bg-primary text-white flex items-center justify-center text-2xl font-bold", "aria-hidden": "true", children: initialOf(name) }), status && _jsx(StatusPill, { status: status })] })), handRaised && (_jsx("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", children: "\u270B" })), micMuted && (_jsx("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", children: _jsx("span", { className: "w-4 h-4 [&>svg]:w-4 [&>svg]:h-4", children: _jsx(MicOffIcon, {}) }) })), badge && (_jsx("span", { className: `absolute bottom-2 right-2 px-2 py-0.5 rounded text-xs ${transport === "failed" ? "bg-[#601410] text-[#f9dedc]" : "bg-black/60 text-white"}`, title: badge.title, "data-testid": "transport-badge", children: badge.label })), _jsxs("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", children: [name, isLocal ? " (you)" : ""] })] }));
28
45
  }
@@ -13,3 +13,4 @@ export declare const LeaveIcon: () => React.JSX.Element;
13
13
  export declare const ChevronUpIcon: () => React.JSX.Element;
14
14
  /** A raised hand, as an emoji glyph - Material's own "back hand" path isn't among the ones bundled here. */
15
15
  export declare const HandIcon: () => React.JSX.Element;
16
+ export declare const EffectsIcon: () => React.JSX.Element;
@@ -14,3 +14,4 @@ export const LeaveIcon = () => (_jsx(Icon, { path: "M12 9c-1.6 0-3.15.25-4.6.72v
14
14
  export const ChevronUpIcon = () => _jsx(Icon, { path: "M12 8l-6 6 1.41 1.41L12 10.83l4.59 4.58L18 14z", className: "w-5 h-5" });
15
15
  /** A raised hand, as an emoji glyph - Material's own "back hand" path isn't among the ones bundled here. */
16
16
  export const HandIcon = () => (_jsx("span", { className: "text-xl leading-none", "aria-hidden": "true", children: "\u270B" }));
17
+ export const EffectsIcon = () => (_jsx(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" }));
@@ -35,6 +35,12 @@ export interface VideoMeetingJoinResult {
35
35
  token?: string;
36
36
  /** ISO 8601 instant `token` expires at. Present only when `authenticated` is `false`. */
37
37
  expiresAt?: string;
38
+ /** Whether the server offers the WebSocket media relay - the last-resort path for a participant neither a direct
39
+ * connection nor the TURN server can reach. Absent from a server that predates it, which reads as off. */
40
+ relayEnabled?: boolean;
41
+ /** The base URL the video filters' machine-learning runtime and models are hosted under, when the administrator
42
+ * hosts them (`mail:videoconf:effects:assets_url`). Absent means the public CDN defaults. */
43
+ effectsAssetsUrl?: string;
38
44
  }
39
45
  /** Resolves a join token (an invitee's own link) or a public meeting's slug to its meeting info, ICE servers, and
40
46
  * either a short-lived guest signaling token (the common anonymous case) or confirmation that the caller's own
@@ -0,0 +1,75 @@
1
+ import { type VideoFilters } from "./filterTypes.js";
2
+ import { type FaceTracker, type PersonSegmenter } from "./mlModels.js";
3
+ /** A picture to show behind the participant. */
4
+ export interface BackgroundPicture {
5
+ source: CanvasImageSource;
6
+ width: number;
7
+ height: number;
8
+ }
9
+ export interface FilterStatus {
10
+ /** A model the current filters need is still downloading. */
11
+ loading: boolean;
12
+ /** Why a filter isn't fully working, ready to show - `null` when all is well. */
13
+ error: string | null;
14
+ }
15
+ export interface ProcessorDeps {
16
+ createCanvas(): HTMLCanvasElement;
17
+ createVideo(): HTMLVideoElement;
18
+ createSegmenter(assetsUrl?: string): Promise<PersonSegmenter>;
19
+ createFaceTracker(assetsUrl?: string): Promise<FaceTracker>;
20
+ now(): number;
21
+ }
22
+ export interface VideoFilterProcessorOptions {
23
+ /** The camera's track. The processor only reads it - stopping it stays with whoever owns it. */
24
+ source: MediaStreamTrack;
25
+ filters: VideoFilters;
26
+ backgroundImage?: BackgroundPicture | null;
27
+ /** Where the models are hosted, when not on the default CDN - see `mlModels.ts`. */
28
+ assetsUrl?: string;
29
+ onStatus?: (status: FilterStatus) => void;
30
+ frameRate?: number;
31
+ deps?: Partial<ProcessorDeps>;
32
+ }
33
+ export declare class VideoFilterProcessor {
34
+ /** The filtered video - what to send in place of the camera's track. */
35
+ readonly track: MediaStreamTrack;
36
+ private readonly deps;
37
+ private readonly assetsUrl?;
38
+ private readonly onStatus?;
39
+ private readonly interval;
40
+ private readonly video;
41
+ private readonly canvas;
42
+ private readonly ctx;
43
+ private readonly layers;
44
+ private filters;
45
+ private backgroundImage;
46
+ private stopped;
47
+ private timer;
48
+ private segmenter;
49
+ private segmenterState;
50
+ private faceTracker;
51
+ private faceState;
52
+ private lastFace;
53
+ private framesWithoutFace;
54
+ private maskImage;
55
+ private renderFailed;
56
+ private status;
57
+ constructor(options: VideoFilterProcessorOptions);
58
+ /** Changes the filters (and the background picture) without interrupting the video. Also retries a model that failed
59
+ * to load, so choosing a filter again is a retry. */
60
+ update(filters: VideoFilters, backgroundImage: BackgroundPicture | null): void;
61
+ stop(): void;
62
+ private syncModels;
63
+ private publishStatus;
64
+ private tick;
65
+ /** A scratch canvas of the given size, kept between frames. */
66
+ private layer;
67
+ private render;
68
+ /** The background, then - once the segmentation model is ready - the participant on top of it. */
69
+ private drawBackground;
70
+ private segment;
71
+ private applyEffect;
72
+ private paintAccessory;
73
+ /** The face's landmarks for this frame - the last known ones for a few frames after it is lost. */
74
+ private trackFace;
75
+ }