@rapidmx/meet-plugin 0.2.0 → 0.3.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/apps/meet/[token].tsx +51 -46
- package/apps/meet/_CallControls.tsx +255 -32
- package/apps/meet/_CallView.tsx +276 -114
- package/apps/meet/_MeetLobby.tsx +111 -142
- package/apps/meet/_ParticipantTile.tsx +51 -19
- package/apps/meet/_icons.tsx +54 -0
- package/apps/shared/media/chime.ts +74 -0
- package/apps/shared/media/deviceMedia.ts +3 -13
- package/apps/shared/media/useLocalMedia.ts +337 -0
- package/apps/shared/push/GuestSignalingClient.ts +2 -0
- package/apps/shared/webrtc/MeshConnectionManager.ts +237 -55
- package/apps/shared/webrtc/realPeerConnection.ts +16 -3
- package/apps/shared/webrtc/types.ts +58 -15
- package/dist/apps/meet/[token].js +31 -19
- package/dist/apps/meet/_CallControls.d.ts +20 -7
- package/dist/apps/meet/_CallControls.js +72 -3
- package/dist/apps/meet/_CallView.d.ts +24 -4
- package/dist/apps/meet/_CallView.js +142 -57
- package/dist/apps/meet/_MeetLobby.d.ts +16 -19
- package/dist/apps/meet/_MeetLobby.js +49 -99
- package/dist/apps/meet/_ParticipantTile.d.ts +12 -6
- package/dist/apps/meet/_ParticipantTile.js +12 -9
- package/dist/apps/meet/_icons.d.ts +15 -0
- package/dist/apps/meet/_icons.js +16 -0
- package/dist/apps/shared/media/chime.d.ts +31 -0
- package/dist/apps/shared/media/chime.js +49 -0
- package/dist/apps/shared/media/deviceMedia.d.ts +3 -7
- package/dist/apps/shared/media/deviceMedia.js +3 -12
- package/dist/apps/shared/media/useLocalMedia.d.ts +37 -0
- package/dist/apps/shared/media/useLocalMedia.js +259 -0
- package/dist/apps/shared/push/GuestSignalingClient.js +2 -0
- package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +63 -18
- package/dist/apps/shared/webrtc/MeshConnectionManager.js +232 -42
- package/dist/apps/shared/webrtc/realPeerConnection.js +16 -3
- package/dist/apps/shared/webrtc/types.d.ts +58 -15
- package/dist/apps/shared/webrtc/types.js +3 -1
- package/dist/lib/routes/BaseVideoMeetingRoute.js +58 -3
- package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
- package/dist/lib/routes/mongo/VideoMeetingRouteMongo.js +2 -1
- package/dist/lib/routes/mongo/VideoMeetingRouteMongo.js.map +1 -1
- package/dist/lib/routes/sql/VideoMeetingRouteSQL.js +2 -1
- package/dist/lib/routes/sql/VideoMeetingRouteSQL.js.map +1 -1
- package/dist/types/routes/BaseVideoMeetingRoute.d.ts +15 -0
- package/dist/types/routes/mongo/VideoMeetingRouteMongo.d.ts +1 -0
- package/dist/types/routes/sql/VideoMeetingRouteSQL.d.ts +1 -0
- package/package.json +181 -181
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* The local participant's camera and microphone, owned by one component for the whole visit (`apps/meet/[token].tsx`)
|
|
7
|
+
* and handed to both the lobby and the call - so the tracks the lobby previews are the very tracks the call sends.
|
|
8
|
+
* (The lobby used to own its stream and stop it when it unmounted, which is exactly when the call starts using it:
|
|
9
|
+
* everyone who joined had dead tracks.) Every track is stopped once, when the owner unmounts or `release()` is
|
|
10
|
+
* called.
|
|
11
|
+
*
|
|
12
|
+
* - **Permission**: `requestAccess()` asks for the camera and microphone in one browser prompt, and falls back to
|
|
13
|
+
* asking for each on its own if that fails for any reason other than an outright denial - a machine with no camera
|
|
14
|
+
* still gets its microphone. It is safe to call again (a "try again" button is a user gesture, which some browsers
|
|
15
|
+
* require before they will prompt at all), and the per-kind `status` says what is and isn't working.
|
|
16
|
+
* - **Mute**: the microphone track is kept and disabled (`micEnabled`), so unmuting is instant. If there is no
|
|
17
|
+
* microphone track yet, unmuting asks for one.
|
|
18
|
+
* - **Camera**: turning the camera off stops its track (the camera light goes out and the device is released);
|
|
19
|
+
* turning it on asks for a new one.
|
|
20
|
+
* - **Devices**: `selectDevice()` swaps in another camera or microphone; `devicechange` refreshes the lists.
|
|
21
|
+
* - **Level**: `audioLevel` (0-5) follows the microphone while it is unmuted, for the "your audio is being sent"
|
|
22
|
+
* indicator.
|
|
23
|
+
*
|
|
24
|
+
* Nothing here runs during SSR: browser APIs are only touched from effects and event handlers.
|
|
25
|
+
*/
|
|
26
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
27
|
+
import { defaultMediaDevices, isMediaDevicesSupported, listDevices, requestUserMedia, } from "./deviceMedia.js";
|
|
28
|
+
import { startLevelMeter } from "./levelMeter.js";
|
|
29
|
+
const AUDIO_CONSTRAINTS = { echoCancellation: true, noiseSuppression: true, autoGainControl: true };
|
|
30
|
+
const VIDEO_CONSTRAINTS = { facingMode: "user", width: { ideal: 1280 }, height: { ideal: 720 } };
|
|
31
|
+
/** The mean deviation (the level meter's 0-100 scale) that fills the whole indicator - ordinary speech sits well
|
|
32
|
+
* below full scale. */
|
|
33
|
+
const LEVEL_FULL_SCALE = 20;
|
|
34
|
+
export const AUDIO_LEVEL_STEPS = 5;
|
|
35
|
+
function statusFor(error) {
|
|
36
|
+
switch (error.kind) {
|
|
37
|
+
case "permission-denied":
|
|
38
|
+
return "denied";
|
|
39
|
+
case "not-found":
|
|
40
|
+
return "unavailable";
|
|
41
|
+
default:
|
|
42
|
+
return "error";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export function useLocalMedia() {
|
|
46
|
+
const [supported, setSupported] = useState(true);
|
|
47
|
+
const [requesting, setRequesting] = useState(false);
|
|
48
|
+
const [error, setError] = useState(null);
|
|
49
|
+
const [tracks, setTracks] = useState({ audio: null, video: null });
|
|
50
|
+
const [status, setStatus] = useState({ audio: "pending", video: "pending" });
|
|
51
|
+
const [micEnabled, setMicEnabled] = useState(true);
|
|
52
|
+
const [devices, setDevices] = useState({ cameras: [], microphones: [] });
|
|
53
|
+
const [audioLevel, setAudioLevel] = useState(0);
|
|
54
|
+
const tracksRef = useRef({ audio: null, video: null });
|
|
55
|
+
const micEnabledRef = useRef(true);
|
|
56
|
+
const mountedRef = useRef(true);
|
|
57
|
+
const refreshDevices = useCallback(async () => {
|
|
58
|
+
const list = await listDevices();
|
|
59
|
+
if (list.ok && mountedRef.current) {
|
|
60
|
+
setDevices(list.value);
|
|
61
|
+
}
|
|
62
|
+
}, []);
|
|
63
|
+
/** Makes `track` the current track of its kind, stopping whatever it replaces - or stops it at once if the
|
|
64
|
+
* owner has already gone away (a request that resolved after unmount). */
|
|
65
|
+
const adoptTrack = useCallback((track) => {
|
|
66
|
+
const kind = track.kind;
|
|
67
|
+
if (!mountedRef.current) {
|
|
68
|
+
track.stop();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const previous = tracksRef.current[kind];
|
|
72
|
+
if (previous && previous !== track) {
|
|
73
|
+
previous.onended = null;
|
|
74
|
+
previous.stop();
|
|
75
|
+
}
|
|
76
|
+
if (kind === "audio") {
|
|
77
|
+
track.enabled = micEnabledRef.current;
|
|
78
|
+
}
|
|
79
|
+
track.onended = () => {
|
|
80
|
+
// The device went away (unplugged, or the browser revoked permission).
|
|
81
|
+
if (tracksRef.current[kind] === track) {
|
|
82
|
+
tracksRef.current = { ...tracksRef.current, [kind]: null };
|
|
83
|
+
setTracks(tracksRef.current);
|
|
84
|
+
setStatus((prev) => ({ ...prev, [kind]: "unavailable" }));
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
tracksRef.current = { ...tracksRef.current, [kind]: track };
|
|
88
|
+
setTracks(tracksRef.current);
|
|
89
|
+
setStatus((prev) => ({ ...prev, [kind]: "live" }));
|
|
90
|
+
}, []);
|
|
91
|
+
const adoptStream = useCallback((stream) => {
|
|
92
|
+
for (const track of stream.getTracks()) {
|
|
93
|
+
adoptTrack(track);
|
|
94
|
+
}
|
|
95
|
+
}, [adoptTrack]);
|
|
96
|
+
/** Asks for one kind on its own - a specific device when `deviceId` is given. */
|
|
97
|
+
const acquire = useCallback(async (kind, deviceId) => {
|
|
98
|
+
const base = kind === "audio" ? AUDIO_CONSTRAINTS : VIDEO_CONSTRAINTS;
|
|
99
|
+
const constraint = deviceId ? { ...base, deviceId: { exact: deviceId } } : base;
|
|
100
|
+
const result = await requestUserMedia({ [kind]: constraint });
|
|
101
|
+
if (!result.ok) {
|
|
102
|
+
if (mountedRef.current) {
|
|
103
|
+
setError(result.error);
|
|
104
|
+
setStatus((prev) => ({ ...prev, [kind]: statusFor(result.error) }));
|
|
105
|
+
}
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
adoptStream(result.value);
|
|
109
|
+
if (mountedRef.current) {
|
|
110
|
+
setError(null);
|
|
111
|
+
}
|
|
112
|
+
return true;
|
|
113
|
+
}, [adoptStream]);
|
|
114
|
+
/** Asks for the camera and the microphone in a single prompt. */
|
|
115
|
+
const acquireBoth = useCallback(async () => {
|
|
116
|
+
const both = await requestUserMedia({ audio: AUDIO_CONSTRAINTS, video: VIDEO_CONSTRAINTS });
|
|
117
|
+
if (both.ok) {
|
|
118
|
+
adoptStream(both.value);
|
|
119
|
+
}
|
|
120
|
+
else if (both.error.kind === "permission-denied") {
|
|
121
|
+
if (mountedRef.current) {
|
|
122
|
+
setError(both.error);
|
|
123
|
+
setStatus({ audio: "denied", video: "denied" });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
// No camera, no microphone, or the two together couldn't be satisfied: ask for each on its own, so one
|
|
128
|
+
// missing device doesn't cost the participant the other.
|
|
129
|
+
await acquire("audio");
|
|
130
|
+
await acquire("video");
|
|
131
|
+
}
|
|
132
|
+
}, [acquire, adoptStream]);
|
|
133
|
+
const requestAccess = useCallback(async (kind) => {
|
|
134
|
+
if (!isMediaDevicesSupported()) {
|
|
135
|
+
setSupported(false);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
setRequesting(true);
|
|
139
|
+
setError(null);
|
|
140
|
+
await (kind ? acquire(kind) : acquireBoth());
|
|
141
|
+
await refreshDevices();
|
|
142
|
+
if (mountedRef.current) {
|
|
143
|
+
setRequesting(false);
|
|
144
|
+
}
|
|
145
|
+
}, [acquire, acquireBoth, refreshDevices]);
|
|
146
|
+
const toggleMic = useCallback(async () => {
|
|
147
|
+
const track = tracksRef.current.audio;
|
|
148
|
+
if (!track) {
|
|
149
|
+
micEnabledRef.current = true;
|
|
150
|
+
setMicEnabled(true);
|
|
151
|
+
await acquire("audio");
|
|
152
|
+
await refreshDevices();
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const next = !micEnabledRef.current;
|
|
156
|
+
micEnabledRef.current = next;
|
|
157
|
+
track.enabled = next;
|
|
158
|
+
setMicEnabled(next);
|
|
159
|
+
}, [acquire, refreshDevices]);
|
|
160
|
+
const toggleCamera = useCallback(async () => {
|
|
161
|
+
const track = tracksRef.current.video;
|
|
162
|
+
if (!track) {
|
|
163
|
+
await acquire("video");
|
|
164
|
+
await refreshDevices();
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
track.onended = null;
|
|
168
|
+
track.stop();
|
|
169
|
+
tracksRef.current = { ...tracksRef.current, video: null };
|
|
170
|
+
setTracks(tracksRef.current);
|
|
171
|
+
setStatus((prev) => ({ ...prev, video: "off" }));
|
|
172
|
+
}, [acquire, refreshDevices]);
|
|
173
|
+
const selectDevice = useCallback(async (kind, deviceId) => {
|
|
174
|
+
if (kind === "video") {
|
|
175
|
+
// Some phones can't open a second camera while the first is still running.
|
|
176
|
+
const current = tracksRef.current.video;
|
|
177
|
+
if (current) {
|
|
178
|
+
current.onended = null;
|
|
179
|
+
current.stop();
|
|
180
|
+
tracksRef.current = { ...tracksRef.current, video: null };
|
|
181
|
+
setTracks(tracksRef.current);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
await acquire(kind, deviceId);
|
|
185
|
+
await refreshDevices();
|
|
186
|
+
}, [acquire, refreshDevices]);
|
|
187
|
+
const release = useCallback(() => {
|
|
188
|
+
for (const track of Object.values(tracksRef.current)) {
|
|
189
|
+
if (track) {
|
|
190
|
+
track.onended = null;
|
|
191
|
+
track.stop();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
tracksRef.current = { audio: null, video: null };
|
|
195
|
+
setTracks(tracksRef.current);
|
|
196
|
+
setStatus({ audio: "pending", video: "pending" });
|
|
197
|
+
}, []);
|
|
198
|
+
// Stops everything when the owner goes away.
|
|
199
|
+
useEffect(() => {
|
|
200
|
+
mountedRef.current = true;
|
|
201
|
+
return () => {
|
|
202
|
+
mountedRef.current = false;
|
|
203
|
+
for (const track of Object.values(tracksRef.current)) {
|
|
204
|
+
track?.stop();
|
|
205
|
+
}
|
|
206
|
+
tracksRef.current = { audio: null, video: null };
|
|
207
|
+
};
|
|
208
|
+
}, []);
|
|
209
|
+
// Keeps the device lists current as devices are plugged in and out.
|
|
210
|
+
useEffect(() => {
|
|
211
|
+
const devicesApi = defaultMediaDevices();
|
|
212
|
+
if (!devicesApi?.addEventListener) {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const onChange = () => void refreshDevices();
|
|
216
|
+
devicesApi.addEventListener("devicechange", onChange);
|
|
217
|
+
return () => devicesApi.removeEventListener("devicechange", onChange);
|
|
218
|
+
}, [refreshDevices]);
|
|
219
|
+
// The "your audio is being sent" level: follows the microphone while it is live and unmuted.
|
|
220
|
+
useEffect(() => {
|
|
221
|
+
if (!tracks.audio || !micEnabled) {
|
|
222
|
+
setAudioLevel(0);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const handle = startLevelMeter(new MediaStream([tracks.audio]), (level) => {
|
|
226
|
+
const step = Math.round(Math.min(1, level / LEVEL_FULL_SCALE) * AUDIO_LEVEL_STEPS);
|
|
227
|
+
setAudioLevel((prev) => (prev === step ? prev : step));
|
|
228
|
+
});
|
|
229
|
+
return () => {
|
|
230
|
+
handle?.stop();
|
|
231
|
+
setAudioLevel(0);
|
|
232
|
+
};
|
|
233
|
+
}, [tracks.audio, micEnabled]);
|
|
234
|
+
const videoStream = useMemo(() => (tracks.video ? new MediaStream([tracks.video]) : null), [tracks.video]);
|
|
235
|
+
const selectedDeviceIds = useMemo(() => ({
|
|
236
|
+
audio: tracks.audio?.getSettings?.().deviceId,
|
|
237
|
+
video: tracks.video?.getSettings?.().deviceId,
|
|
238
|
+
}), [tracks.audio, tracks.video]);
|
|
239
|
+
return {
|
|
240
|
+
supported,
|
|
241
|
+
requesting,
|
|
242
|
+
error,
|
|
243
|
+
audioTrack: tracks.audio,
|
|
244
|
+
videoTrack: tracks.video,
|
|
245
|
+
videoStream,
|
|
246
|
+
micEnabled,
|
|
247
|
+
micOn: !!tracks.audio && micEnabled,
|
|
248
|
+
cameraOn: !!tracks.video,
|
|
249
|
+
status,
|
|
250
|
+
devices,
|
|
251
|
+
selectedDeviceIds,
|
|
252
|
+
audioLevel,
|
|
253
|
+
requestAccess,
|
|
254
|
+
toggleMic,
|
|
255
|
+
toggleCamera,
|
|
256
|
+
selectDevice,
|
|
257
|
+
release,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
@@ -150,6 +150,8 @@ export class GuestSignalingClient {
|
|
|
150
150
|
method: "POST",
|
|
151
151
|
headers,
|
|
152
152
|
body: JSON.stringify(message),
|
|
153
|
+
// Lets a `bye` sent as the page unloads still go out.
|
|
154
|
+
keepalive: true,
|
|
153
155
|
}).catch(() => {
|
|
154
156
|
// Best-effort - see this method's doc comment.
|
|
155
157
|
});
|
|
@@ -11,6 +11,20 @@
|
|
|
11
11
|
* both sides independently compute the same answer with no coordination round trip, and exactly one side ever
|
|
12
12
|
* calls `createOffer()` for a given pair.
|
|
13
13
|
*
|
|
14
|
+
* ## Media: two permanent transceivers, tracks swapped in and out
|
|
15
|
+
*
|
|
16
|
+
* Every connection carries one send-and-receive audio transceiver and one send-and-receive video transceiver,
|
|
17
|
+
* whether or not the local participant has a microphone or camera to send yet. The offerer adds them before its
|
|
18
|
+
* offer; the answerer takes the ones the offer created (`claimTransceivers()`) - a browser does not match an offer's
|
|
19
|
+
* m-lines to transceivers the answerer added itself, so adding them up front on both sides leaves the answerer
|
|
20
|
+
* sending nothing at all. A participant who declined permission, has no camera, or turns the camera on halfway
|
|
21
|
+
* through therefore still negotiates both directions from the first offer, and later `setLocalTrack()` calls just
|
|
22
|
+
* `replaceTrack()` on the existing senders - never a renegotiation. (Attaching tracks once at connection creation,
|
|
23
|
+
* as this manager used to, meant a participant with nothing to send at that moment negotiated no media in either
|
|
24
|
+
* direction, for the whole call.)
|
|
25
|
+
* The remote side's tracks are collected into one `MediaStream` per peer (`remote-stream` events), rather than
|
|
26
|
+
* read from `event.streams[0]`: a transceiver whose sender has no track yet announces no stream to the far end.
|
|
27
|
+
*
|
|
14
28
|
* ## Join announcement and roster discovery
|
|
15
29
|
*
|
|
16
30
|
* The channel has no history (a push event published while a socket was down is never replayed - see
|
|
@@ -22,6 +36,17 @@
|
|
|
22
36
|
* roster regardless of join order. This is naturally bounded (each participant echoes at most once per peer it
|
|
23
37
|
* ever discovers) rather than a risk of runaway flooding.
|
|
24
38
|
*
|
|
39
|
+
* Each message is its own `POST`, so nothing guarantees they arrive in the order they were sent. Two consequences
|
|
40
|
+
* are handled here: an `offer` that beats its sender's `hello` creates the peer under a placeholder name that the
|
|
41
|
+
* `hello` then replaces (`participant-updated`), and an ICE candidate that beats its offer is held until the peer
|
|
42
|
+
* exists rather than dropped.
|
|
43
|
+
*
|
|
44
|
+
* ## Participant state, hands and reactions
|
|
45
|
+
*
|
|
46
|
+
* What each participant is sending (`audioOn`/`videoOn`) and whether their hand is up travels in `hello` and in a
|
|
47
|
+
* `state` message on every change (`setLocalState()`), because a remote track gives no dependable signal that its
|
|
48
|
+
* sender stopped. `reaction` messages carry one emoji from `REACTION_EMOJIS`; anything else is ignored.
|
|
49
|
+
*
|
|
25
50
|
* ## Presenter (single-writer screen share)
|
|
26
51
|
*
|
|
27
52
|
* `presenter-claim` is only ever sent locally when `presenterUid` is unset (`claimPresenter()` refuses otherwise).
|
|
@@ -31,33 +56,44 @@
|
|
|
31
56
|
* smaller uid wins, and the losing claimant self-revokes (stops its own share locally and sends
|
|
32
57
|
* `presenter-release`) once it observes the winning claim. See `handlePresenterClaim()`.
|
|
33
58
|
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* Sharing a screen never adds a second video track or renegotiates a connection - `replaceLocalVideoTrack()`
|
|
37
|
-
* simply swaps each peer connection's existing outgoing video `RTCRtpSender`'s track (`sender.replaceTrack()`),
|
|
38
|
-
* the same track object already flowing to everyone from the moment they joined. Stopping a share replaces it
|
|
39
|
-
* back to the camera track the same way.
|
|
59
|
+
* Sharing a screen is `setLocalTrack("video", screenTrack)`; stopping is `setLocalTrack("video", cameraTrack)`.
|
|
40
60
|
*/
|
|
41
|
-
import type
|
|
61
|
+
import { type MeshEvent, type MeshParticipant, type ParticipantState, type RTCPeerConnectionFactory, type SignalingChannel } from "./types.js";
|
|
42
62
|
/** The lexicographically smaller uid is always the offerer for that pair - see this module's doc comment. */
|
|
43
|
-
export declare function isOfferer(
|
|
63
|
+
export declare function isOfferer(selfId: string, peerId: string): boolean;
|
|
64
|
+
type MediaKind = "audio" | "video";
|
|
44
65
|
export interface MeshConnectionManagerOptions {
|
|
66
|
+
/** The authenticated uid - what `from` must be on the wire (see `SignalMessage.from`). */
|
|
45
67
|
selfUid: string;
|
|
68
|
+
/** This tab's identity in the call, `<selfUid>~<random>` - defaults to `selfUid`. Everything the manager keys on
|
|
69
|
+
* (the roster, who offers, who presents) is a peer id, so two tabs of one account are two participants. */
|
|
70
|
+
peerId?: string;
|
|
46
71
|
selfName: string;
|
|
47
72
|
iceServers: RTCIceServer[];
|
|
48
73
|
channel: SignalingChannel;
|
|
49
74
|
createPeerConnection: RTCPeerConnectionFactory;
|
|
50
|
-
/** The
|
|
51
|
-
|
|
75
|
+
/** The tracks to send from the start - either may be absent (no permission, no device) and supplied or
|
|
76
|
+
* replaced later with `setLocalTrack()`. */
|
|
77
|
+
localAudioTrack?: MediaStreamTrack | null;
|
|
78
|
+
localVideoTrack?: MediaStreamTrack | null;
|
|
79
|
+
/** What to announce at first - defaults to "sending whatever tracks were given, hand down". */
|
|
80
|
+
localState?: Partial<ParticipantState>;
|
|
81
|
+
/** Builds the (initially empty) stream one peer's incoming tracks are gathered into. Defaults to
|
|
82
|
+
* `new MediaStream()`; a test supplies a fake. */
|
|
83
|
+
createMediaStream?: () => MediaStream;
|
|
52
84
|
}
|
|
53
85
|
export declare class MeshConnectionManager {
|
|
54
86
|
private readonly options;
|
|
55
87
|
private readonly peers;
|
|
56
88
|
private readonly listeners;
|
|
89
|
+
private readonly orphanCandidates;
|
|
90
|
+
private readonly localTracks;
|
|
91
|
+
private localState;
|
|
57
92
|
private unsubscribe;
|
|
58
93
|
private started;
|
|
59
94
|
private stopped;
|
|
60
95
|
private currentPresenterUid;
|
|
96
|
+
private readonly selfId;
|
|
61
97
|
constructor(options: MeshConnectionManagerOptions);
|
|
62
98
|
get participants(): MeshParticipant[];
|
|
63
99
|
get presenterUid(): string | undefined;
|
|
@@ -65,15 +101,18 @@ export declare class MeshConnectionManager {
|
|
|
65
101
|
/** Subscribes to the channel and announces this participant. Idempotent - a second call is a no-op. */
|
|
66
102
|
start(): void;
|
|
67
103
|
/** Announces departure, closes every peer connection and unsubscribes - idempotent, and safe to call whether
|
|
68
|
-
* or not `start()` ever ran. Never removes the camera/microphone indicator itself - stopping
|
|
69
|
-
*
|
|
70
|
-
*
|
|
104
|
+
* or not `start()` ever ran. Never removes the camera/microphone indicator itself - stopping the local
|
|
105
|
+
* tracks is the caller's job (see `deviceMedia.ts`'s `stopStream()`), since this manager never owns their
|
|
106
|
+
* lifecycle, only sends them. */
|
|
71
107
|
stop(): void;
|
|
72
|
-
/**
|
|
73
|
-
*
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
108
|
+
/** Sets the track sent as `kind` (`null` to send nothing) on every connection, now and for every peer that
|
|
109
|
+
* joins later - see this module's doc comment. */
|
|
110
|
+
setLocalTrack(kind: MediaKind, track: MediaStreamTrack | null): void;
|
|
111
|
+
/** Updates what this participant announces (`audioOn`/`videoOn`/`handRaised`) and tells everyone if anything
|
|
112
|
+
* actually changed. Before `start()` it only records the state, which `hello` then carries. */
|
|
113
|
+
setLocalState(partial: Partial<ParticipantState>): void;
|
|
114
|
+
/** Sends one emoji to everyone. Returns `false`, sending nothing, for an emoji outside `REACTION_EMOJIS`. */
|
|
115
|
+
sendReaction(emoji: string): boolean;
|
|
77
116
|
/** Claims presenter status for the local participant. Refuses (returns `false`, sends nothing) when someone
|
|
78
117
|
* else already presents - the caller (`_CallView.tsx`) uses this to disable its own "share screen" control
|
|
79
118
|
* rather than let a claim silently do nothing. */
|
|
@@ -85,6 +124,11 @@ export declare class MeshConnectionManager {
|
|
|
85
124
|
private emit;
|
|
86
125
|
private handleMessage;
|
|
87
126
|
private handleHello;
|
|
127
|
+
private handleState;
|
|
128
|
+
private handleReaction;
|
|
129
|
+
/** Applies a newly learned name and/or state to `peer`, emitting `participant-updated` (and `hand-raised` on a
|
|
130
|
+
* hand going up) only when something actually changed. */
|
|
131
|
+
private updatePeer;
|
|
88
132
|
private handleBye;
|
|
89
133
|
private createPeer;
|
|
90
134
|
private initiateOffer;
|
|
@@ -95,3 +139,4 @@ export declare class MeshConnectionManager {
|
|
|
95
139
|
private handlePresenterClaim;
|
|
96
140
|
private handlePresenterRelease;
|
|
97
141
|
}
|
|
142
|
+
export {};
|