@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,105 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* What a participant chose last time - which camera and microphone, whether each was on, and their video filters -
|
|
7
|
+
* kept in this browser's `localStorage` so the next meeting starts the same way.
|
|
8
|
+
*
|
|
9
|
+
* It lives on the device rather than on the server on purpose: most participants are guests with no account, and a
|
|
10
|
+
* camera's id means nothing on another machine anyway. Nothing here is sent anywhere.
|
|
11
|
+
*
|
|
12
|
+
* Storage can be missing or refuse to work (private windows, blocked site data, a full quota, server-side rendering),
|
|
13
|
+
* and none of that is worth failing a call over: every function tolerates it and behaves as if nothing was saved.
|
|
14
|
+
* Whatever is read back is validated, since it may be from another version of this plugin or edited by hand.
|
|
15
|
+
*
|
|
16
|
+
* The custom background picture is stored under its own key: it is by far the largest thing kept, and if it doesn't
|
|
17
|
+
* fit the rest of the settings should still be saved.
|
|
18
|
+
*/
|
|
19
|
+
import { type VideoFilters, sanitizeFilters } from "./filters/filterTypes.js";
|
|
20
|
+
|
|
21
|
+
export interface MediaPreferences {
|
|
22
|
+
/** The camera and microphone the participant picked, by `deviceId`. */
|
|
23
|
+
cameraId?: string;
|
|
24
|
+
microphoneId?: string;
|
|
25
|
+
/** Whether the microphone / camera was on when they left. */
|
|
26
|
+
micEnabled?: boolean;
|
|
27
|
+
cameraEnabled?: boolean;
|
|
28
|
+
filters?: VideoFilters;
|
|
29
|
+
/** The custom background, as a (downscaled) image data URL. */
|
|
30
|
+
backgroundImage?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const PREFERENCES_KEY = "rapidmx.meet.preferences";
|
|
34
|
+
export const BACKGROUND_KEY = "rapidmx.meet.background";
|
|
35
|
+
|
|
36
|
+
/** The `Storage` to use, or `undefined` where there is none - looked up lazily and guarded, since merely reading
|
|
37
|
+
* `localStorage` can throw (a browser blocking site data) and it doesn't exist on the server. */
|
|
38
|
+
export function defaultStorage(): Storage | undefined {
|
|
39
|
+
try {
|
|
40
|
+
return typeof localStorage === "undefined" ? undefined : localStorage;
|
|
41
|
+
} catch {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function readJson(storage: Storage, key: string): Record<string, unknown> {
|
|
47
|
+
try {
|
|
48
|
+
const parsed: unknown = JSON.parse(storage.getItem(key) ?? "{}");
|
|
49
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {};
|
|
50
|
+
} catch {
|
|
51
|
+
return {};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const text = (value: unknown): string | undefined => (typeof value === "string" && value ? value : undefined);
|
|
56
|
+
const flag = (value: unknown): boolean | undefined => (typeof value === "boolean" ? value : undefined);
|
|
57
|
+
|
|
58
|
+
export function loadPreferences(storage: Storage | undefined = defaultStorage()): MediaPreferences {
|
|
59
|
+
if (!storage) {
|
|
60
|
+
return {};
|
|
61
|
+
}
|
|
62
|
+
const raw = readJson(storage, PREFERENCES_KEY);
|
|
63
|
+
let backgroundImage: string | undefined;
|
|
64
|
+
try {
|
|
65
|
+
const stored = storage.getItem(BACKGROUND_KEY);
|
|
66
|
+
backgroundImage = stored?.startsWith("data:image/") ? stored : undefined;
|
|
67
|
+
} catch {
|
|
68
|
+
backgroundImage = undefined;
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
cameraId: text(raw.cameraId),
|
|
72
|
+
microphoneId: text(raw.microphoneId),
|
|
73
|
+
micEnabled: flag(raw.micEnabled),
|
|
74
|
+
cameraEnabled: flag(raw.cameraEnabled),
|
|
75
|
+
filters: raw.filters === undefined ? undefined : sanitizeFilters(raw.filters),
|
|
76
|
+
backgroundImage,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Merges `patch` into what is saved. A key set to `undefined` is forgotten; a failure to save is ignored. */
|
|
81
|
+
export function savePreferences(patch: Partial<MediaPreferences>, storage: Storage | undefined = defaultStorage()): void {
|
|
82
|
+
if (!storage) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const { backgroundImage, ...rest } = patch;
|
|
86
|
+
if (Object.keys(rest).length > 0) {
|
|
87
|
+
try {
|
|
88
|
+
const merged = { ...readJson(storage, PREFERENCES_KEY), ...rest };
|
|
89
|
+
storage.setItem(PREFERENCES_KEY, JSON.stringify(merged));
|
|
90
|
+
} catch {
|
|
91
|
+
// Nowhere to save - the settings just won't carry over.
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if ("backgroundImage" in patch) {
|
|
95
|
+
try {
|
|
96
|
+
if (backgroundImage) {
|
|
97
|
+
storage.setItem(BACKGROUND_KEY, backgroundImage);
|
|
98
|
+
} else {
|
|
99
|
+
storage.removeItem(BACKGROUND_KEY);
|
|
100
|
+
}
|
|
101
|
+
} catch {
|
|
102
|
+
// Most likely the image is too big for what is left of the quota.
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -20,10 +20,18 @@
|
|
|
20
20
|
* - **Devices**: `selectDevice()` swaps in another camera or microphone; `devicechange` refreshes the lists.
|
|
21
21
|
* - **Level**: `audioLevel` (0-5) follows the microphone while it is unmuted, for the "your audio is being sent"
|
|
22
22
|
* indicator.
|
|
23
|
+
* - **Filters**: while any video filter is on (`setFilters()`), `videoTrack`/`videoStream` are the *filtered*
|
|
24
|
+
* picture (`filters/VideoFilterProcessor.ts`) rather than the camera's own track - so everything downstream, the
|
|
25
|
+
* lobby preview and every path the call sends over, gets it without knowing. The camera's track stays owned here; the
|
|
26
|
+
* processor only reads it. If the filtered picture can't be made there is no video at all rather than an unfiltered
|
|
27
|
+
* one, since the filter may be hiding the participant's room.
|
|
28
|
+
* - **Remembered settings**: the devices picked, whether the microphone and camera were on, and the filters are
|
|
29
|
+
* saved on this device (`mediaPreferences.ts`) and applied the next time - a saved device is asked for as a
|
|
30
|
+
* preference (`ideal`), so one that has since been unplugged falls back to the default instead of failing.
|
|
23
31
|
*
|
|
24
32
|
* Nothing here runs during SSR: browser APIs are only touched from effects and event handlers.
|
|
25
33
|
*/
|
|
26
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
34
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
27
35
|
import {
|
|
28
36
|
type DeviceLists,
|
|
29
37
|
type MediaAccessError,
|
|
@@ -33,6 +41,10 @@ import {
|
|
|
33
41
|
requestUserMedia,
|
|
34
42
|
} from "./deviceMedia.js";
|
|
35
43
|
import { startLevelMeter } from "./levelMeter.js";
|
|
44
|
+
import { loadPicture, prepareBackgroundImage } from "./filters/backgroundImage.js";
|
|
45
|
+
import { NO_FILTERS, type VideoFilters, filtersActive } from "./filters/filterTypes.js";
|
|
46
|
+
import { type BackgroundPicture, type FilterStatus, VideoFilterProcessor } from "./filters/VideoFilterProcessor.js";
|
|
47
|
+
import { type MediaPreferences, loadPreferences, savePreferences } from "./mediaPreferences.js";
|
|
36
48
|
|
|
37
49
|
export type MediaKind = "audio" | "video";
|
|
38
50
|
|
|
@@ -47,6 +59,9 @@ const VIDEO_CONSTRAINTS: MediaTrackConstraints = { facingMode: "user", width: {
|
|
|
47
59
|
/** The mean deviation (the level meter's 0-100 scale) that fills the whole indicator - ordinary speech sits well
|
|
48
60
|
* below full scale. */
|
|
49
61
|
const LEVEL_FULL_SCALE = 20;
|
|
62
|
+
const FILTERS_UNSUPPORTED =
|
|
63
|
+
"Video effects aren't supported in this browser, so your camera is off. Turn the effects off to show your camera.";
|
|
64
|
+
|
|
50
65
|
export const AUDIO_LEVEL_STEPS = 5;
|
|
51
66
|
|
|
52
67
|
export interface LocalMedia {
|
|
@@ -70,6 +85,16 @@ export interface LocalMedia {
|
|
|
70
85
|
selectedDeviceIds: Partial<Record<MediaKind, string>>;
|
|
71
86
|
/** 0-`AUDIO_LEVEL_STEPS`: how loud the microphone is right now - always 0 while muted. */
|
|
72
87
|
audioLevel: number;
|
|
88
|
+
/** The video filters in effect - `NO_FILTERS` when the camera is sent as it is. */
|
|
89
|
+
filters: VideoFilters;
|
|
90
|
+
/** Whether the filters' models are still loading, and why a filter isn't fully working. */
|
|
91
|
+
filterStatus: FilterStatus;
|
|
92
|
+
/** Whether a custom background image has been chosen (and so "custom image" can be switched to at once). */
|
|
93
|
+
hasBackgroundImage: boolean;
|
|
94
|
+
/** Changes some of the filters, keeping the rest. */
|
|
95
|
+
setFilters(patch: Partial<VideoFilters>): void;
|
|
96
|
+
/** Uses `file` as the custom background and switches to it. Resolves to why the file can't be used, or `null`. */
|
|
97
|
+
chooseBackgroundImage(file: File): Promise<string | null>;
|
|
73
98
|
/** Asks for the camera and microphone (or just `kind`), see this module's doc comment. */
|
|
74
99
|
requestAccess(kind?: MediaKind): Promise<void>;
|
|
75
100
|
toggleMic(): Promise<void>;
|
|
@@ -90,7 +115,17 @@ function statusFor(error: MediaAccessError): TrackStatus {
|
|
|
90
115
|
}
|
|
91
116
|
}
|
|
92
117
|
|
|
93
|
-
export
|
|
118
|
+
export interface LocalMediaOptions {
|
|
119
|
+
/** Where the filters' models are hosted, when not on the default CDN - see `filters/mlModels.ts`. */
|
|
120
|
+
effectsAssetsUrl?: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** `base`, asking for `preferredId` (the device used last time) when there is one - as a preference, not a demand. */
|
|
124
|
+
function preferring(base: MediaTrackConstraints, preferredId?: string): MediaTrackConstraints {
|
|
125
|
+
return preferredId ? { ...base, deviceId: { ideal: preferredId } } : base;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function useLocalMedia({ effectsAssetsUrl }: LocalMediaOptions = {}): LocalMedia {
|
|
94
129
|
const [supported, setSupported] = useState(true);
|
|
95
130
|
const [requesting, setRequesting] = useState(false);
|
|
96
131
|
const [error, setError] = useState<MediaAccessError | null>(null);
|
|
@@ -99,10 +134,66 @@ export function useLocalMedia(): LocalMedia {
|
|
|
99
134
|
const [micEnabled, setMicEnabled] = useState(true);
|
|
100
135
|
const [devices, setDevices] = useState<DeviceLists>({ cameras: [], microphones: [] });
|
|
101
136
|
const [audioLevel, setAudioLevel] = useState(0);
|
|
137
|
+
const [filters, setFiltersState] = useState<VideoFilters>(NO_FILTERS);
|
|
138
|
+
const [background, setBackground] = useState<BackgroundPicture | null>(null);
|
|
139
|
+
const [filterStatus, setFilterStatus] = useState<FilterStatus>({ loading: false, error: null });
|
|
140
|
+
const [processed, setProcessed] = useState<MediaStreamTrack | null>(null);
|
|
102
141
|
|
|
103
142
|
const tracksRef = useRef<Record<MediaKind, MediaStreamTrack | null>>({ audio: null, video: null });
|
|
104
143
|
const micEnabledRef = useRef(true);
|
|
105
144
|
const mountedRef = useRef(true);
|
|
145
|
+
const prefsRef = useRef<MediaPreferences | null>(null);
|
|
146
|
+
const filtersRef = useRef<VideoFilters>(NO_FILTERS);
|
|
147
|
+
const backgroundRef = useRef<BackgroundPicture | null>(null);
|
|
148
|
+
const processorRef = useRef<VideoFilterProcessor | null>(null);
|
|
149
|
+
|
|
150
|
+
const applyFilters = useCallback((next: VideoFilters) => {
|
|
151
|
+
filtersRef.current = next;
|
|
152
|
+
setFiltersState(next);
|
|
153
|
+
}, []);
|
|
154
|
+
|
|
155
|
+
const applyBackground = useCallback((picture: BackgroundPicture) => {
|
|
156
|
+
backgroundRef.current = picture;
|
|
157
|
+
setBackground(picture);
|
|
158
|
+
}, []);
|
|
159
|
+
|
|
160
|
+
/** What was remembered from last time - read once, the first time anything needs it, and applied to the state it
|
|
161
|
+
* covers. Never during render: storage doesn't exist on the server, and reading it would make the first client
|
|
162
|
+
* render differ from the server's. */
|
|
163
|
+
const ensurePrefs = useCallback((): MediaPreferences => {
|
|
164
|
+
if (prefsRef.current) {
|
|
165
|
+
return prefsRef.current;
|
|
166
|
+
}
|
|
167
|
+
const prefs = loadPreferences();
|
|
168
|
+
prefsRef.current = prefs;
|
|
169
|
+
if (prefs.micEnabled === false) {
|
|
170
|
+
micEnabledRef.current = false;
|
|
171
|
+
setMicEnabled(false);
|
|
172
|
+
}
|
|
173
|
+
if (prefs.filters) {
|
|
174
|
+
applyFilters(prefs.filters);
|
|
175
|
+
}
|
|
176
|
+
if (prefs.backgroundImage) {
|
|
177
|
+
loadPicture(prefs.backgroundImage).then(
|
|
178
|
+
(picture) => {
|
|
179
|
+
if (mountedRef.current) {
|
|
180
|
+
applyBackground(picture);
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
() => undefined,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
return prefs;
|
|
187
|
+
}, [applyBackground, applyFilters]);
|
|
188
|
+
|
|
189
|
+
/** Saves a change to the remembered settings. */
|
|
190
|
+
const remember = useCallback(
|
|
191
|
+
(patch: Partial<MediaPreferences>) => {
|
|
192
|
+
prefsRef.current = { ...ensurePrefs(), ...patch };
|
|
193
|
+
savePreferences(patch);
|
|
194
|
+
},
|
|
195
|
+
[ensurePrefs],
|
|
196
|
+
);
|
|
106
197
|
|
|
107
198
|
const refreshDevices = useCallback(async () => {
|
|
108
199
|
const list = await listDevices();
|
|
@@ -138,7 +229,10 @@ export function useLocalMedia(): LocalMedia {
|
|
|
138
229
|
tracksRef.current = { ...tracksRef.current, [kind]: track };
|
|
139
230
|
setTracks(tracksRef.current);
|
|
140
231
|
setStatus((prev) => ({ ...prev, [kind]: "live" }));
|
|
141
|
-
|
|
232
|
+
if (kind === "video") {
|
|
233
|
+
remember({ cameraEnabled: true });
|
|
234
|
+
}
|
|
235
|
+
}, [remember]);
|
|
142
236
|
|
|
143
237
|
const adoptStream = useCallback(
|
|
144
238
|
(stream: MediaStream) => {
|
|
@@ -149,11 +243,14 @@ export function useLocalMedia(): LocalMedia {
|
|
|
149
243
|
[adoptTrack],
|
|
150
244
|
);
|
|
151
245
|
|
|
152
|
-
/** Asks for one kind on its own - a specific device when `deviceId` is given. */
|
|
246
|
+
/** Asks for one kind on its own - a specific device when `deviceId` is given, else the one used last time. */
|
|
153
247
|
const acquire = useCallback(
|
|
154
248
|
async (kind: MediaKind, deviceId?: string): Promise<boolean> => {
|
|
155
249
|
const base = kind === "audio" ? AUDIO_CONSTRAINTS : VIDEO_CONSTRAINTS;
|
|
156
|
-
const
|
|
250
|
+
const prefs = ensurePrefs();
|
|
251
|
+
const constraint: MediaTrackConstraints = deviceId
|
|
252
|
+
? { ...base, deviceId: { exact: deviceId } }
|
|
253
|
+
: preferring(base, kind === "audio" ? prefs.microphoneId : prefs.cameraId);
|
|
157
254
|
const result = await requestUserMedia({ [kind]: constraint });
|
|
158
255
|
if (!result.ok) {
|
|
159
256
|
if (mountedRef.current) {
|
|
@@ -168,12 +265,24 @@ export function useLocalMedia(): LocalMedia {
|
|
|
168
265
|
}
|
|
169
266
|
return true;
|
|
170
267
|
},
|
|
171
|
-
[adoptStream],
|
|
268
|
+
[adoptStream, ensurePrefs],
|
|
172
269
|
);
|
|
173
270
|
|
|
174
271
|
/** Asks for the camera and the microphone in a single prompt. */
|
|
175
272
|
const acquireBoth = useCallback(async () => {
|
|
176
|
-
const
|
|
273
|
+
const prefs = ensurePrefs();
|
|
274
|
+
if (prefs.cameraEnabled === false) {
|
|
275
|
+
// The camera was off when the participant last left: join with it off, without switching it on first.
|
|
276
|
+
if (mountedRef.current) {
|
|
277
|
+
setStatus((prev) => ({ ...prev, video: "off" }));
|
|
278
|
+
}
|
|
279
|
+
await acquire("audio");
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const both = await requestUserMedia({
|
|
283
|
+
audio: preferring(AUDIO_CONSTRAINTS, prefs.microphoneId),
|
|
284
|
+
video: preferring(VIDEO_CONSTRAINTS, prefs.cameraId),
|
|
285
|
+
});
|
|
177
286
|
if (both.ok) {
|
|
178
287
|
adoptStream(both.value);
|
|
179
288
|
} else if (both.error.kind === "permission-denied") {
|
|
@@ -187,7 +296,7 @@ export function useLocalMedia(): LocalMedia {
|
|
|
187
296
|
await acquire("audio");
|
|
188
297
|
await acquire("video");
|
|
189
298
|
}
|
|
190
|
-
}, [acquire, adoptStream]);
|
|
299
|
+
}, [acquire, adoptStream, ensurePrefs]);
|
|
191
300
|
|
|
192
301
|
const requestAccess = useCallback(
|
|
193
302
|
async (kind?: MediaKind) => {
|
|
@@ -211,6 +320,7 @@ export function useLocalMedia(): LocalMedia {
|
|
|
211
320
|
if (!track) {
|
|
212
321
|
micEnabledRef.current = true;
|
|
213
322
|
setMicEnabled(true);
|
|
323
|
+
remember({ micEnabled: true });
|
|
214
324
|
await acquire("audio");
|
|
215
325
|
await refreshDevices();
|
|
216
326
|
return;
|
|
@@ -219,7 +329,8 @@ export function useLocalMedia(): LocalMedia {
|
|
|
219
329
|
micEnabledRef.current = next;
|
|
220
330
|
track.enabled = next;
|
|
221
331
|
setMicEnabled(next);
|
|
222
|
-
|
|
332
|
+
remember({ micEnabled: next });
|
|
333
|
+
}, [acquire, refreshDevices, remember]);
|
|
223
334
|
|
|
224
335
|
const toggleCamera = useCallback(async () => {
|
|
225
336
|
const track = tracksRef.current.video;
|
|
@@ -233,7 +344,8 @@ export function useLocalMedia(): LocalMedia {
|
|
|
233
344
|
tracksRef.current = { ...tracksRef.current, video: null };
|
|
234
345
|
setTracks(tracksRef.current);
|
|
235
346
|
setStatus((prev) => ({ ...prev, video: "off" }));
|
|
236
|
-
|
|
347
|
+
remember({ cameraEnabled: false });
|
|
348
|
+
}, [acquire, refreshDevices, remember]);
|
|
237
349
|
|
|
238
350
|
const selectDevice = useCallback(
|
|
239
351
|
async (kind: MediaKind, deviceId: string) => {
|
|
@@ -247,10 +359,37 @@ export function useLocalMedia(): LocalMedia {
|
|
|
247
359
|
setTracks(tracksRef.current);
|
|
248
360
|
}
|
|
249
361
|
}
|
|
250
|
-
await acquire(kind, deviceId)
|
|
362
|
+
if (await acquire(kind, deviceId)) {
|
|
363
|
+
remember(kind === "audio" ? { microphoneId: deviceId } : { cameraId: deviceId });
|
|
364
|
+
}
|
|
251
365
|
await refreshDevices();
|
|
252
366
|
},
|
|
253
|
-
[acquire, refreshDevices],
|
|
367
|
+
[acquire, refreshDevices, remember],
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
const setFilters = useCallback(
|
|
371
|
+
(patch: Partial<VideoFilters>) => {
|
|
372
|
+
const next = { ...filtersRef.current, ...patch };
|
|
373
|
+
applyFilters(next);
|
|
374
|
+
remember({ filters: next });
|
|
375
|
+
},
|
|
376
|
+
[applyFilters, remember],
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
const chooseBackgroundImage = useCallback(
|
|
380
|
+
async (file: File): Promise<string | null> => {
|
|
381
|
+
const prepared = await prepareBackgroundImage(file);
|
|
382
|
+
if (!prepared.ok) {
|
|
383
|
+
return prepared.message;
|
|
384
|
+
}
|
|
385
|
+
if (mountedRef.current) {
|
|
386
|
+
applyBackground(prepared.picture);
|
|
387
|
+
remember({ backgroundImage: prepared.dataUrl });
|
|
388
|
+
setFilters({ background: "image" });
|
|
389
|
+
}
|
|
390
|
+
return null;
|
|
391
|
+
},
|
|
392
|
+
[applyBackground, remember, setFilters],
|
|
254
393
|
);
|
|
255
394
|
|
|
256
395
|
const release = useCallback(() => {
|
|
@@ -304,7 +443,47 @@ export function useLocalMedia(): LocalMedia {
|
|
|
304
443
|
};
|
|
305
444
|
}, [tracks.audio, micEnabled]);
|
|
306
445
|
|
|
307
|
-
|
|
446
|
+
// Puts the camera through the filters for as long as any is on - see this module's doc comment. A layout effect,
|
|
447
|
+
// so the filtered track is in place before the first frame is painted rather than after an unfiltered one.
|
|
448
|
+
const filtersOn = filtersActive(filters);
|
|
449
|
+
const source = tracks.video;
|
|
450
|
+
useLayoutEffect(() => {
|
|
451
|
+
if (!source || !filtersOn) {
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
let processor: VideoFilterProcessor;
|
|
455
|
+
try {
|
|
456
|
+
processor = new VideoFilterProcessor({
|
|
457
|
+
source,
|
|
458
|
+
filters: filtersRef.current,
|
|
459
|
+
backgroundImage: backgroundRef.current,
|
|
460
|
+
assetsUrl: effectsAssetsUrl,
|
|
461
|
+
onStatus: setFilterStatus,
|
|
462
|
+
});
|
|
463
|
+
} catch {
|
|
464
|
+
setFilterStatus({ loading: false, error: FILTERS_UNSUPPORTED });
|
|
465
|
+
// Turning the filters off (or the camera) clears the message again.
|
|
466
|
+
return () => setFilterStatus({ loading: false, error: null });
|
|
467
|
+
}
|
|
468
|
+
processorRef.current = processor;
|
|
469
|
+
setProcessed(processor.track);
|
|
470
|
+
return () => {
|
|
471
|
+
processor.stop();
|
|
472
|
+
processorRef.current = null;
|
|
473
|
+
setProcessed(null);
|
|
474
|
+
setFilterStatus({ loading: false, error: null });
|
|
475
|
+
};
|
|
476
|
+
}, [source, filtersOn, effectsAssetsUrl]);
|
|
477
|
+
|
|
478
|
+
// Passes a change of filters (or of the background picture) on to the running processor.
|
|
479
|
+
useEffect(() => {
|
|
480
|
+
processorRef.current?.update(filters, background);
|
|
481
|
+
}, [filters, background]);
|
|
482
|
+
|
|
483
|
+
// While filters are on, the camera's own track is never what's sent: the filtered one is, or nothing.
|
|
484
|
+
const videoTrack = filtersOn ? (source ? processed : null) : source;
|
|
485
|
+
|
|
486
|
+
const videoStream = useMemo(() => (videoTrack ? new MediaStream([videoTrack]) : null), [videoTrack]);
|
|
308
487
|
|
|
309
488
|
const selectedDeviceIds = useMemo(
|
|
310
489
|
() => ({
|
|
@@ -319,7 +498,7 @@ export function useLocalMedia(): LocalMedia {
|
|
|
319
498
|
requesting,
|
|
320
499
|
error,
|
|
321
500
|
audioTrack: tracks.audio,
|
|
322
|
-
videoTrack
|
|
501
|
+
videoTrack,
|
|
323
502
|
videoStream,
|
|
324
503
|
micEnabled,
|
|
325
504
|
micOn: !!tracks.audio && micEnabled,
|
|
@@ -328,6 +507,11 @@ export function useLocalMedia(): LocalMedia {
|
|
|
328
507
|
devices,
|
|
329
508
|
selectedDeviceIds,
|
|
330
509
|
audioLevel,
|
|
510
|
+
filters,
|
|
511
|
+
filterStatus,
|
|
512
|
+
hasBackgroundImage: !!background,
|
|
513
|
+
setFilters,
|
|
514
|
+
chooseBackgroundImage,
|
|
331
515
|
requestAccess,
|
|
332
516
|
toggleMic,
|
|
333
517
|
toggleCamera,
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
5
|
/**
|
|
6
6
|
* A standalone `SignalingChannel` (`../webrtc/types.js`) over `@rapidrest/service-core`'s `/push` WebSocket -
|
|
7
|
-
* built from scratch rather than reusing `@rapidmx/
|
|
7
|
+
* built from scratch rather than reusing `@rapidmx/web-client`'s `PushClient`/`getPushClient()` (`lib/mail/pushClient.js`).
|
|
8
8
|
*
|
|
9
9
|
* ## Why not `PushClient`
|
|
10
10
|
*
|
|
@@ -63,8 +63,8 @@
|
|
|
63
63
|
* absent, as described above - the browser's real cookie was always going to win that write anyway, so the
|
|
64
64
|
* correct behavior is to not fight it and let it authenticate normally instead.
|
|
65
65
|
*/
|
|
66
|
-
import { apiOrigin } from "@rapidmx/
|
|
67
|
-
import { pushUrl } from "@rapidmx/
|
|
66
|
+
import { apiOrigin } from "@rapidmx/web-client/lib/util/api.js";
|
|
67
|
+
import { pushUrl } from "@rapidmx/web-client/lib/mail/pushClient.js";
|
|
68
68
|
import type { SignalMessage, SignalingChannel } from "../webrtc/types.js";
|
|
69
69
|
|
|
70
70
|
/** The subset of the browser's `WebSocket` this client uses - matches `PushClient`'s own identical seam, so a
|
|
@@ -99,7 +99,7 @@ export interface GuestSignalingClientOptions {
|
|
|
99
99
|
* `Authorization` header at all, relying entirely on the browser's own already-existing `jwt` session cookie
|
|
100
100
|
* for both the WebSocket upgrade and each `POST /push/:id` publish - see this module's doc comment. */
|
|
101
101
|
token?: string;
|
|
102
|
-
/** Defaults to `@rapidmx/
|
|
102
|
+
/** Defaults to `@rapidmx/web-client`'s `pushUrl()` (`lib/mail/pushClient.js`) - the exact URL `PushClient` itself connects to. */
|
|
103
103
|
url?: () => string | undefined;
|
|
104
104
|
createSocket?: PushSocketFactory;
|
|
105
105
|
fetchImpl?: typeof fetch;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { TimestampUnwrapper, type Frame } from "./frames.js";
|
|
6
|
+
import type { AudioContextLike, AudioDataLike, DecoderLike, RelayEnv } from "./relayEnv.js";
|
|
7
|
+
import { safeClose } from "./senderCommon.js";
|
|
8
|
+
|
|
9
|
+
export const PLAYBACK_SAMPLE_RATE = 48_000;
|
|
10
|
+
/** How far ahead of the clock the first packet after a start or an underrun is scheduled. It is the jitter buffer:
|
|
11
|
+
* packets arriving up to this late (relative to their neighbours) still play in order without a gap. */
|
|
12
|
+
export const JITTER_BUFFER_SECONDS = 0.08;
|
|
13
|
+
/** If scheduled audio already runs this far past the clock the receiver has fallen behind (a burst, or the sender's
|
|
14
|
+
* clock running fast), so further packets are dropped until playback catches up - better a brief skip than latency
|
|
15
|
+
* that keeps growing. */
|
|
16
|
+
export const MAX_AHEAD_SECONDS = 0.4;
|
|
17
|
+
/** Skip a packet instead of queueing it when the decoder is this far behind. */
|
|
18
|
+
export const AUDIO_MAX_DECODE_QUEUE = 16;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Decodes one sender's Opus packets and schedules them for playback into a `MediaStreamAudioDestinationNode`.
|
|
22
|
+
*
|
|
23
|
+
* Each decoded block is copied into an `AudioBuffer` and started on its own `AudioBufferSourceNode` at
|
|
24
|
+
* `max(currentTime + jitter, nextTime)`, where `nextTime` is when the previous block ends - so consecutive blocks
|
|
25
|
+
* join seamlessly, and after a gap in the stream playback restarts with the jitter buffer's worth of headroom.
|
|
26
|
+
* Opus packets are independently decodable, so a lost packet costs only its own 20 ms and needs no recovery step;
|
|
27
|
+
* the decoder is created lazily and discarded on any error, and replaced by the next packet.
|
|
28
|
+
*/
|
|
29
|
+
export class AudioPlayer {
|
|
30
|
+
private decoder: DecoderLike | undefined;
|
|
31
|
+
private nextTime = 0;
|
|
32
|
+
private readonly timestamps = new TimestampUnwrapper();
|
|
33
|
+
|
|
34
|
+
/** `destination` is the node whose stream the peer's `MediaStream` carries. */
|
|
35
|
+
constructor(
|
|
36
|
+
private readonly env: RelayEnv,
|
|
37
|
+
private readonly ctx: AudioContextLike,
|
|
38
|
+
private readonly destination: unknown,
|
|
39
|
+
) {}
|
|
40
|
+
|
|
41
|
+
/** Feeds one reassembled audio frame (one Opus packet). */
|
|
42
|
+
push(frame: Frame): void {
|
|
43
|
+
const decoder = this.decoder ?? this.createDecoder();
|
|
44
|
+
if (!decoder || decoder.decodeQueueSize > AUDIO_MAX_DECODE_QUEUE) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
decoder.decode(
|
|
49
|
+
this.env.createEncodedAudioChunk({ type: "key", timestamp: this.timestamps.next(frame.timestampMs) * 1000, data: frame.data }),
|
|
50
|
+
);
|
|
51
|
+
} catch {
|
|
52
|
+
this.discardDecoder();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
close(): void {
|
|
57
|
+
this.discardDecoder();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private discardDecoder(): void {
|
|
61
|
+
const decoder = this.decoder;
|
|
62
|
+
this.decoder = undefined;
|
|
63
|
+
safeClose(decoder);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private createDecoder(): DecoderLike | undefined {
|
|
67
|
+
let decoder: DecoderLike | undefined;
|
|
68
|
+
try {
|
|
69
|
+
decoder = this.env.createAudioDecoder({
|
|
70
|
+
output: (data) => this.play(data),
|
|
71
|
+
error: () => {
|
|
72
|
+
// Only the current decoder's failure matters; a stale one was already replaced.
|
|
73
|
+
if (this.decoder === decoder) {
|
|
74
|
+
this.discardDecoder();
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
decoder.configure({ codec: "opus", sampleRate: PLAYBACK_SAMPLE_RATE, numberOfChannels: 1 });
|
|
79
|
+
} catch {
|
|
80
|
+
safeClose(decoder);
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
this.decoder = decoder;
|
|
84
|
+
return decoder;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Schedules one decoded block. Always closes `data`. */
|
|
88
|
+
private play(data: AudioDataLike): void {
|
|
89
|
+
try {
|
|
90
|
+
const now = this.ctx.currentTime;
|
|
91
|
+
if (this.nextTime - now > MAX_AHEAD_SECONDS) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const buffer = this.ctx.createBuffer(1, data.numberOfFrames, data.sampleRate);
|
|
95
|
+
const pcm = new Float32Array(data.numberOfFrames);
|
|
96
|
+
data.copyTo(pcm, { planeIndex: 0, format: "f32-planar" });
|
|
97
|
+
buffer.copyToChannel(pcm, 0);
|
|
98
|
+
const source = this.ctx.createBufferSource();
|
|
99
|
+
source.buffer = buffer;
|
|
100
|
+
source.connect(this.destination);
|
|
101
|
+
source.onended = () => source.disconnect();
|
|
102
|
+
const start = Math.max(now + JITTER_BUFFER_SECONDS, this.nextTime);
|
|
103
|
+
source.start(start);
|
|
104
|
+
this.nextTime = start + buffer.duration;
|
|
105
|
+
} catch {
|
|
106
|
+
// A block the browser will not take (an odd sample rate, a closed context) is dropped like a lost packet.
|
|
107
|
+
} finally {
|
|
108
|
+
data.close();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|