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