@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.
- package/README.md +18 -5
- package/apps/meet/[token].tsx +11 -10
- package/apps/meet/_CallControls.tsx +28 -2
- package/apps/meet/_CallView.tsx +26 -2
- package/apps/meet/_EffectsPanel.tsx +134 -0
- package/apps/meet/_MeetChrome.tsx +1 -1
- package/apps/meet/_MeetLobby.tsx +36 -4
- package/apps/meet/_ParticipantTile.tsx +73 -10
- package/apps/meet/_icons.tsx +4 -0
- package/apps/meet/_layout.tsx +2 -2
- package/apps/meet/_meetApi.ts +7 -1
- package/apps/meet/index.tsx +2 -2
- package/apps/settings-video-conferencing/_PersonalRoomCard.tsx +4 -4
- package/apps/settings-video-conferencing/_layout.tsx +2 -2
- package/apps/settings-video-conferencing/index.tsx +3 -3
- package/apps/shared/media/filters/VideoFilterProcessor.ts +408 -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/push/GuestSignalingClient.ts +4 -4
- 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 +159 -7
- package/apps/shared/webrtc/realPeerConnection.ts +22 -0
- package/apps/shared/webrtc/types.ts +32 -2
- package/dist/apps/meet/[token].d.ts +3 -3
- package/dist/apps/meet/[token].js +10 -10
- 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 +20 -3
- package/dist/apps/meet/_EffectsPanel.d.ts +17 -0
- package/dist/apps/meet/_EffectsPanel.js +60 -0
- package/dist/apps/meet/_MeetChrome.d.ts +1 -1
- package/dist/apps/meet/_MeetLobby.d.ts +8 -0
- package/dist/apps/meet/_MeetLobby.js +15 -4
- package/dist/apps/meet/_ParticipantTile.d.ts +17 -2
- package/dist/apps/meet/_ParticipantTile.js +27 -4
- package/dist/apps/meet/_icons.d.ts +1 -0
- package/dist/apps/meet/_icons.js +1 -0
- package/dist/apps/meet/_layout.d.ts +1 -1
- package/dist/apps/meet/_layout.js +1 -1
- package/dist/apps/meet/_meetApi.d.ts +6 -0
- package/dist/apps/meet/_meetApi.js +1 -1
- package/dist/apps/meet/index.js +2 -2
- package/dist/apps/settings-video-conferencing/_PersonalRoomCard.d.ts +1 -1
- package/dist/apps/settings-video-conferencing/_PersonalRoomCard.js +4 -4
- package/dist/apps/settings-video-conferencing/_layout.d.ts +1 -1
- package/dist/apps/settings-video-conferencing/_layout.js +1 -1
- package/dist/apps/settings-video-conferencing/index.d.ts +1 -1
- package/dist/apps/settings-video-conferencing/index.js +3 -3
- package/dist/apps/shared/media/filters/VideoFilterProcessor.d.ts +78 -0
- package/dist/apps/shared/media/filters/VideoFilterProcessor.js +329 -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/push/GuestSignalingClient.d.ts +1 -1
- package/dist/apps/shared/push/GuestSignalingClient.js +3 -3
- 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 +52 -2
- package/dist/apps/shared/webrtc/MeshConnectionManager.js +138 -7
- 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 +25 -11
|
@@ -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
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* same `booking-plugin`'s `_BookingChrome.tsx` shell-component pattern this plugin's Phase 2 spec calls for. Not a
|
|
3
3
|
* page itself: `_`-prefixed files in `apps/` aren't routed. */
|
|
4
4
|
import React, { PropsWithChildren } from "react";
|
|
5
|
-
import { Branding } from "@rapidmx/
|
|
5
|
+
import { Branding } from "@rapidmx/web-client/lib/branding/brandingApi.js";
|
|
6
6
|
export declare function MeetPageShell({ branding, children }: PropsWithChildren<{
|
|
7
7
|
branding: Branding | null;
|
|
8
8
|
}>): React.JSX.Element;
|
|
@@ -4,11 +4,19 @@
|
|
|
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
|
|
10
14
|
* nothing to send can still see and hear the call, and turn a camera or microphone on later from the call controls.
|
|
11
15
|
*
|
|
16
|
+
* The preview is mirrored, like a real mirror, so it feels natural - except with a custom background image, a fixed
|
|
17
|
+
* picture rather than a live reflection: mirroring it would show it backwards to no one but the participant
|
|
18
|
+
* themselves (see `_ParticipantTile.tsx`'s doc comment, which the in-call tile follows for the same reason).
|
|
19
|
+
*
|
|
12
20
|
* Per this plugin's Phase 2 spec, the name field is always editable - it is only ever *prefilled* when a session is
|
|
13
21
|
* genuinely known (see this component's `initialName` prop and `[token].tsx`'s own doc comment on why that never
|
|
14
22
|
* actually happens on this page today).
|
|
@@ -9,20 +9,30 @@ 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
|
|
15
19
|
* nothing to send can still see and hear the call, and turn a camera or microphone on later from the call controls.
|
|
16
20
|
*
|
|
21
|
+
* The preview is mirrored, like a real mirror, so it feels natural - except with a custom background image, a fixed
|
|
22
|
+
* picture rather than a live reflection: mirroring it would show it backwards to no one but the participant
|
|
23
|
+
* themselves (see `_ParticipantTile.tsx`'s doc comment, which the in-call tile follows for the same reason).
|
|
24
|
+
*
|
|
17
25
|
* Per this plugin's Phase 2 spec, the name field is always editable - it is only ever *prefilled* when a session is
|
|
18
26
|
* genuinely known (see this component's `initialName` prop and `[token].tsx`'s own doc comment on why that never
|
|
19
27
|
* actually happens on this page today).
|
|
20
28
|
*/
|
|
21
29
|
import { useEffect, useRef, useState } from "react";
|
|
22
|
-
import Alert from "@rapidmx/
|
|
23
|
-
import Button from "@rapidmx/
|
|
24
|
-
import FormField from "@rapidmx/
|
|
30
|
+
import Alert from "@rapidmx/web-client/lib/components/feedback/Alert.js";
|
|
31
|
+
import Button from "@rapidmx/web-client/lib/components/buttons/Button.js";
|
|
32
|
+
import FormField from "@rapidmx/web-client/lib/components/forms/FormField.js";
|
|
25
33
|
import { LevelBars } from "./_CallControls.js";
|
|
34
|
+
import EffectsPanel from "./_EffectsPanel.js";
|
|
35
|
+
import { filtersActive } from "../shared/media/filters/filterTypes.js";
|
|
26
36
|
import { MicIcon, MicOffIcon, VideoIcon, VideoOffIcon } from "./_icons.js";
|
|
27
37
|
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
38
|
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 +60,7 @@ function previewMessage(media) {
|
|
|
50
60
|
}
|
|
51
61
|
export default function MeetLobby({ meeting, media, initialName, onJoin }) {
|
|
52
62
|
const [name, setName] = useState(initialName ?? "");
|
|
63
|
+
const [showEffects, setShowEffects] = useState(false);
|
|
53
64
|
const videoRef = useRef(null);
|
|
54
65
|
const { requestAccess } = media;
|
|
55
66
|
// Asks once, when the lobby opens - see this module's doc comment for the button that asks again.
|
|
@@ -66,5 +77,5 @@ export default function MeetLobby({ meeting, media, initialName, onJoin }) {
|
|
|
66
77
|
const cameraOk = media.status.video === "live" || media.status.video === "off";
|
|
67
78
|
const needsAccess = media.supported && !media.requesting && (media.status.audio !== "live" || !cameraOk);
|
|
68
79
|
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:
|
|
80
|
+
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 ${media.filters.background !== "image" ? "[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
81
|
}
|
|
@@ -4,8 +4,15 @@
|
|
|
4
4
|
*
|
|
5
5
|
* A tile never plays sound: its `<video>` is always muted, and what a participant says is played by the call view's
|
|
6
6
|
* own `<audio>` elements (`_CallView.tsx`'s `RemoteAudio`), so a tile that isn't showing video - or isn't on screen
|
|
7
|
-
* in the current layout - can never silence anyone.
|
|
7
|
+
* in the current layout - can never silence anyone.
|
|
8
|
+
*
|
|
9
|
+
* The local tile is shown mirrored (`scaleX(-1)`), like a real mirror, so a participant's own movements feel
|
|
10
|
+
* natural - this is a local display transform only, never applied to the video actually sent to anyone, so
|
|
11
|
+
* everyone else always sees the participant the right way round. `mirrored={false}` (`_CallView.tsx`, when a custom
|
|
12
|
+
* background image is on) turns that off: the picture the participant chose is a fixed reference, not a live
|
|
13
|
+
* reflection, and mirroring it would show it backwards to no one but themselves. */
|
|
8
14
|
import React from "react";
|
|
15
|
+
import type { MediaTransport } from "../shared/webrtc/types.js";
|
|
9
16
|
export interface ParticipantTileProps {
|
|
10
17
|
name: string;
|
|
11
18
|
stream?: MediaStream | null;
|
|
@@ -14,11 +21,19 @@ export interface ParticipantTileProps {
|
|
|
14
21
|
cameraOff?: boolean;
|
|
15
22
|
micMuted?: boolean;
|
|
16
23
|
handRaised?: boolean;
|
|
24
|
+
/** How this participant's media reaches you - a badge is shown for everything but a direct or still-connecting one. */
|
|
25
|
+
transport?: MediaTransport;
|
|
26
|
+
/** Says the connection is still being made, e.g. "Connecting..." on the local tile and "Awaiting connection..." on a
|
|
27
|
+
* participant's - shown with a spinner until the caller clears it. */
|
|
28
|
+
status?: string;
|
|
17
29
|
/** Highlights this tile as the current presenter/focus. */
|
|
18
30
|
isFocused?: boolean;
|
|
19
31
|
/** Fits the whole picture inside the tile instead of filling it - a shared screen must not be cropped. */
|
|
20
32
|
contain?: boolean;
|
|
33
|
+
/** Whether the local tile is shown mirrored, like a real mirror - ignored for a remote tile, which is never
|
|
34
|
+
* mirrored. Default `true`; see this module's doc comment. */
|
|
35
|
+
mirrored?: boolean;
|
|
21
36
|
onClick?: () => void;
|
|
22
37
|
className?: string;
|
|
23
38
|
}
|
|
24
|
-
export default function ParticipantTile({ name, stream, isLocal, cameraOff, micMuted, handRaised, isFocused, contain, onClick, className, }: ParticipantTileProps): React.JSX.Element;
|
|
39
|
+
export default function ParticipantTile({ name, stream, isLocal, cameraOff, micMuted, handRaised, transport, status, isFocused, contain, mirrored, 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
|
|
@@ -9,13 +9,36 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
9
9
|
*
|
|
10
10
|
* A tile never plays sound: its `<video>` is always muted, and what a participant says is played by the call view's
|
|
11
11
|
* own `<audio>` elements (`_CallView.tsx`'s `RemoteAudio`), so a tile that isn't showing video - or isn't on screen
|
|
12
|
-
* in the current layout - can never silence anyone.
|
|
12
|
+
* in the current layout - can never silence anyone.
|
|
13
|
+
*
|
|
14
|
+
* The local tile is shown mirrored (`scaleX(-1)`), like a real mirror, so a participant's own movements feel
|
|
15
|
+
* natural - this is a local display transform only, never applied to the video actually sent to anyone, so
|
|
16
|
+
* everyone else always sees the participant the right way round. `mirrored={false}` (`_CallView.tsx`, when a custom
|
|
17
|
+
* background image is on) turns that off: the picture the participant chose is a fixed reference, not a live
|
|
18
|
+
* reflection, and mirroring it would show it backwards to no one but themselves. */
|
|
13
19
|
import { useEffect, useRef } from "react";
|
|
14
20
|
import { MicOffIcon } from "./_icons.js";
|
|
21
|
+
/** What a tile says about how its participant's media is arriving, for the paths that are not the ordinary direct one
|
|
22
|
+
* (which needs no comment). Text rather than only an icon, so the reason a picture is degraded is never a guess. */
|
|
23
|
+
const TRANSPORT_BADGES = {
|
|
24
|
+
turn: { label: "Relayed", title: "Connected through the relay server, because a direct connection was not possible." },
|
|
25
|
+
websocket: {
|
|
26
|
+
label: "Server relay",
|
|
27
|
+
title: "Sent through the server as a last resort, because no other connection was possible. Video is lower quality and sound may lag.",
|
|
28
|
+
},
|
|
29
|
+
failed: { label: "Can't connect", title: "This participant could not be reached from your network." },
|
|
30
|
+
};
|
|
31
|
+
/** A short line saying the connection is still being made, with a spinner - a polite live region (not `role="status"`,
|
|
32
|
+
* which the call view's own announcement region already is), so a participant who cannot see the tile still learns
|
|
33
|
+
* why nothing is happening yet. */
|
|
34
|
+
function StatusPill({ status, className }) {
|
|
35
|
+
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] }));
|
|
36
|
+
}
|
|
15
37
|
function initialOf(name) {
|
|
16
38
|
return (Array.from(name.trim())[0] ?? "?").toUpperCase();
|
|
17
39
|
}
|
|
18
|
-
export default function ParticipantTile({ name, stream, isLocal, cameraOff, micMuted, handRaised, isFocused, contain, onClick, className, }) {
|
|
40
|
+
export default function ParticipantTile({ name, stream, isLocal, cameraOff, micMuted, handRaised, transport, status, isFocused, contain, mirrored = true, onClick, className, }) {
|
|
41
|
+
const badge = transport ? TRANSPORT_BADGES[transport] : undefined;
|
|
19
42
|
const videoRef = useRef(null);
|
|
20
43
|
const showVideo = !!stream && !cameraOff;
|
|
21
44
|
// The `<video>` element only exists while `showVideo`, so its stream is bound whenever it (re)appears too.
|
|
@@ -24,5 +47,5 @@ export default function ParticipantTile({ name, stream, isLocal, cameraOff, micM
|
|
|
24
47
|
videoRef.current.srcObject = stream ?? null;
|
|
25
48
|
}
|
|
26
49
|
}, [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)" : ""] })] }));
|
|
50
|
+
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 && mirrored ? "[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
51
|
}
|
|
@@ -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;
|
package/dist/apps/meet/_icons.js
CHANGED
|
@@ -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" }));
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React, { PropsWithChildren } from "react";
|
|
2
|
-
import { Branding } from "@rapidmx/
|
|
2
|
+
import { Branding } from "@rapidmx/web-client/lib/branding/brandingApi.js";
|
|
3
3
|
export interface LayoutProps {
|
|
4
4
|
/** Supplied by the server's public page route (`PublicPageRoute`), which every `public` plugin app's route
|
|
5
5
|
* extends - see `booking-plugin`'s `apps/book/_layout.tsx`'s identical `LayoutProps` doc comment for the
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { CUSTOM_STYLESHEET_LINK_ID } from "@rapidmx/
|
|
2
|
+
import { CUSTOM_STYLESHEET_LINK_ID } from "@rapidmx/web-client/lib/branding/useBranding.js";
|
|
3
3
|
export default function Layout({ children, branding }) {
|
|
4
4
|
const title = branding?.title || branding?.companyName ? `${branding?.title || branding?.companyName}: Meet` : "RapidMX: Meet";
|
|
5
5
|
const iconHref = branding?.iconUrl || branding?.logoUrl || "/images/logo.svg";
|
|
@@ -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
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* own `tsconfig.apps.json` (bundler resolution, DOM-facing), independent of the backend's `tsconfig.json` (NodeNext),
|
|
10
10
|
* and every other plugin app in this codebase keeps that boundary the same way.
|
|
11
11
|
*/
|
|
12
|
-
import { apiFetch } from "@rapidmx/
|
|
12
|
+
import { apiFetch } from "@rapidmx/web-client/lib/util/api.js";
|
|
13
13
|
/** Resolves a join token (an invitee's own link) or a public meeting's slug to its meeting info, ICE servers, and
|
|
14
14
|
* either a short-lived guest signaling token (the common anonymous case) or confirmation that the caller's own
|
|
15
15
|
* existing session already grants them signaling access (`authenticated: true` - see
|
package/dist/apps/meet/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import useBranding from "@rapidmx/
|
|
3
|
-
import Alert from "@rapidmx/
|
|
2
|
+
import useBranding from "@rapidmx/web-client/lib/branding/useBranding.js";
|
|
3
|
+
import Alert from "@rapidmx/web-client/lib/components/feedback/Alert.js";
|
|
4
4
|
import { MeetCard, MeetPageShell } from "./_MeetChrome.js";
|
|
5
5
|
export default function NoMeetingTokenPage() {
|
|
6
6
|
const { branding } = useBranding();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import { VideoMeetingDetail } from "@rapidmx/
|
|
2
|
+
import { VideoMeetingDetail } from "@rapidmx/web-client/lib/videoconf/videoMeetingsApi.js";
|
|
3
3
|
/** The title a freshly created personal room starts with - editable afterward like any other meeting's title. */
|
|
4
4
|
export declare const PERSONAL_ROOM_TITLE = "Personal Meeting Room";
|
|
5
5
|
export interface PersonalRoomCardProps {
|
|
@@ -4,10 +4,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
4
4
|
// SPDX-License-Identifier: MPL-2.0
|
|
5
5
|
///////////////////////////////////////////////////////////////////////////////
|
|
6
6
|
import { useEffect, useState } from "react";
|
|
7
|
-
import { ApiRequestError } from "@rapidmx/
|
|
8
|
-
import { createVideoMeeting, updateVideoMeeting } from "@rapidmx/
|
|
9
|
-
import Alert from "@rapidmx/
|
|
10
|
-
import Button from "@rapidmx/
|
|
7
|
+
import { ApiRequestError } from "@rapidmx/web-client/lib/util/api.js";
|
|
8
|
+
import { createVideoMeeting, updateVideoMeeting } from "@rapidmx/web-client/lib/videoconf/videoMeetingsApi.js";
|
|
9
|
+
import Alert from "@rapidmx/web-client/lib/components/feedback/Alert.js";
|
|
10
|
+
import Button from "@rapidmx/web-client/lib/components/buttons/Button.js";
|
|
11
11
|
const INPUT_CLASS = "w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
|
|
12
12
|
/** The title a freshly created personal room starts with - editable afterward like any other meeting's title. */
|
|
13
13
|
export const PERSONAL_ROOM_TITLE = "Personal Meeting Room";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React, { PropsWithChildren } from "react";
|
|
2
|
-
import { Branding } from "@rapidmx/
|
|
2
|
+
import { Branding } from "@rapidmx/web-client/lib/branding/brandingApi.js";
|
|
3
3
|
export interface LayoutProps {
|
|
4
4
|
/**
|
|
5
5
|
* Supplied by the server's `WwwRoute` `fetchProps()`, which every `www` plugin app's route extends. This file
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { CUSTOM_STYLESHEET_LINK_ID } from "@rapidmx/
|
|
2
|
+
import { CUSTOM_STYLESHEET_LINK_ID } from "@rapidmx/web-client/lib/branding/useBranding.js";
|
|
3
3
|
export default function Layout({ children, branding }) {
|
|
4
4
|
const title = branding?.title || branding?.companyName ? `${branding?.title || branding?.companyName}: Mail` : "RapidMX: Mail";
|
|
5
5
|
const iconHref = branding?.iconUrl || branding?.logoUrl || "/images/logo.svg";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import { VideoMeetingDetail } from "@rapidmx/
|
|
2
|
+
import { VideoMeetingDetail } from "@rapidmx/web-client/lib/videoconf/videoMeetingsApi.js";
|
|
3
3
|
import { SettingsShellProps } from "@rapidmx/web-client/shared/components/settings/layout/SettingsShell.js";
|
|
4
4
|
export type SettingsVideoConferencingPageProps = Omit<SettingsShellProps, "active">;
|
|
5
5
|
export default function SettingsVideoConferencingPage(props: SettingsVideoConferencingPageProps): React.JSX.Element;
|
|
@@ -4,10 +4,10 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
4
4
|
// SPDX-License-Identifier: MPL-2.0
|
|
5
5
|
///////////////////////////////////////////////////////////////////////////////
|
|
6
6
|
import { useEffect, useState } from "react";
|
|
7
|
-
import { ApiRequestError } from "@rapidmx/
|
|
8
|
-
import { listVideoMeetings, updateVideoMeeting } from "@rapidmx/
|
|
7
|
+
import { ApiRequestError } from "@rapidmx/web-client/lib/util/api.js";
|
|
8
|
+
import { listVideoMeetings, updateVideoMeeting } from "@rapidmx/web-client/lib/videoconf/videoMeetingsApi.js";
|
|
9
9
|
import SettingsShell, { useSettingsShell } from "@rapidmx/web-client/shared/components/settings/layout/SettingsShell.js";
|
|
10
|
-
import Alert from "@rapidmx/
|
|
10
|
+
import Alert from "@rapidmx/web-client/lib/components/feedback/Alert.js";
|
|
11
11
|
import PersonalRoomCard from "./_PersonalRoomCard.js";
|
|
12
12
|
/** However many of a mailbox's meetings this page ever needs to see at once - generous enough that a real user's
|
|
13
13
|
* whole history fits in one request (this is a personal settings page, not a paged archive), matching
|
|
@@ -0,0 +1,78 @@
|
|
|
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 lastMask;
|
|
55
|
+
private segmentTicks;
|
|
56
|
+
private faceTicks;
|
|
57
|
+
private maskImage;
|
|
58
|
+
private renderFailed;
|
|
59
|
+
private status;
|
|
60
|
+
constructor(options: VideoFilterProcessorOptions);
|
|
61
|
+
/** Changes the filters (and the background picture) without interrupting the video. Also retries a model that failed
|
|
62
|
+
* to load, so choosing a filter again is a retry. */
|
|
63
|
+
update(filters: VideoFilters, backgroundImage: BackgroundPicture | null): void;
|
|
64
|
+
stop(): void;
|
|
65
|
+
private syncModels;
|
|
66
|
+
private publishStatus;
|
|
67
|
+
private tick;
|
|
68
|
+
/** A scratch canvas of the given size, kept between frames. */
|
|
69
|
+
private layer;
|
|
70
|
+
private render;
|
|
71
|
+
/** The background, then - once the segmentation model is ready - the participant on top of it. */
|
|
72
|
+
private drawBackground;
|
|
73
|
+
private segment;
|
|
74
|
+
private applyEffect;
|
|
75
|
+
private paintAccessory;
|
|
76
|
+
/** The face's landmarks for this frame - the last known ones for a few frames after it is lost. */
|
|
77
|
+
private trackFace;
|
|
78
|
+
}
|