@unityevolv/ofiskit-realtime-client 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +36 -0
- package/dist/client.d.ts +190 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +326 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +55 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +47 -0
- package/dist/index.js.map +1 -0
- package/dist/office-state.d.ts +143 -0
- package/dist/office-state.d.ts.map +1 -0
- package/dist/office-state.js +228 -0
- package/dist/office-state.js.map +1 -0
- package/dist/rtc/adapter.d.ts +238 -0
- package/dist/rtc/adapter.d.ts.map +1 -0
- package/dist/rtc/adapter.js +80 -0
- package/dist/rtc/adapter.js.map +1 -0
- package/dist/rtc/devices.d.ts +80 -0
- package/dist/rtc/devices.d.ts.map +1 -0
- package/dist/rtc/devices.js +107 -0
- package/dist/rtc/devices.js.map +1 -0
- package/dist/rtc/mesh.d.ts +13 -0
- package/dist/rtc/mesh.d.ts.map +1 -0
- package/dist/rtc/mesh.js +756 -0
- package/dist/rtc/mesh.js.map +1 -0
- package/dist/rtc/screen.d.ts +104 -0
- package/dist/rtc/screen.d.ts.map +1 -0
- package/dist/rtc/screen.js +103 -0
- package/dist/rtc/screen.js.map +1 -0
- package/package.json +21 -0
package/dist/rtc/mesh.js
ADDED
|
@@ -0,0 +1,756 @@
|
|
|
1
|
+
import { AUDIO_BITRATE, CONNECT_TIMEOUT_MS, LEVEL_INTERVAL_MS, SCREEN_CEILING, SPEAKING_LEVEL, VIDEO_STEPS, rmsLevel, speakingNow, } from './adapter.js';
|
|
2
|
+
import { desktopConstraints, describeShareError, shareCancelled, } from './screen.js';
|
|
3
|
+
export function meshAdapter(signaller) {
|
|
4
|
+
const handlers = new Set();
|
|
5
|
+
const peers = new Map();
|
|
6
|
+
let selfDeviceId = '';
|
|
7
|
+
let iceServers = [];
|
|
8
|
+
let stopSignals = null;
|
|
9
|
+
let microphone = null;
|
|
10
|
+
let camera = null;
|
|
11
|
+
let screen = null;
|
|
12
|
+
let audioDeviceId;
|
|
13
|
+
let videoDeviceId;
|
|
14
|
+
let videoStep = 0;
|
|
15
|
+
let statsTimer = null;
|
|
16
|
+
let levelTimer = null;
|
|
17
|
+
let audioContext = null;
|
|
18
|
+
let analyser = null;
|
|
19
|
+
let speaking = false;
|
|
20
|
+
/** When the level was last above the threshold, which is what the hold measures from. */
|
|
21
|
+
let loudAt = 0;
|
|
22
|
+
const emit = (event) => {
|
|
23
|
+
for (const handler of [...handlers])
|
|
24
|
+
handler(event);
|
|
25
|
+
};
|
|
26
|
+
const state = () => emit({
|
|
27
|
+
type: 'state',
|
|
28
|
+
muted: microphone === null || !(microphone.getAudioTracks()[0]?.enabled ?? false),
|
|
29
|
+
cameraOn: camera !== null,
|
|
30
|
+
sharing: screen !== null,
|
|
31
|
+
});
|
|
32
|
+
// ------------------------------------------------------------ local media
|
|
33
|
+
async function ensureMicrophone() {
|
|
34
|
+
if (microphone)
|
|
35
|
+
return microphone;
|
|
36
|
+
try {
|
|
37
|
+
microphone = await navigator.mediaDevices.getUserMedia({
|
|
38
|
+
audio: {
|
|
39
|
+
...(audioDeviceId ? { deviceId: { exact: audioDeviceId } } : {}),
|
|
40
|
+
// What the browser gives us for free, and what makes a laptop in a
|
|
41
|
+
// room with three other people usable at all.
|
|
42
|
+
echoCancellation: true,
|
|
43
|
+
noiseSuppression: true,
|
|
44
|
+
autoGainControl: true,
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
watchLevel(microphone);
|
|
48
|
+
return microphone;
|
|
49
|
+
}
|
|
50
|
+
catch (cause) {
|
|
51
|
+
emit({ type: 'failed', reason: describeMediaError(cause, 'microphone') });
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async function ensureCamera() {
|
|
56
|
+
if (camera)
|
|
57
|
+
return camera;
|
|
58
|
+
try {
|
|
59
|
+
const step = VIDEO_STEPS[videoStep] ?? VIDEO_STEPS[0];
|
|
60
|
+
camera = await navigator.mediaDevices.getUserMedia({
|
|
61
|
+
video: {
|
|
62
|
+
...(videoDeviceId ? { deviceId: { exact: videoDeviceId } } : {}),
|
|
63
|
+
height: { ideal: step.height },
|
|
64
|
+
frameRate: { ideal: step.maxFramerate },
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
emit({ type: 'local', stream: camera, source: 'camera' });
|
|
68
|
+
return camera;
|
|
69
|
+
}
|
|
70
|
+
catch (cause) {
|
|
71
|
+
emit({ type: 'failed', reason: describeMediaError(cause, 'camera') });
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Get hold of a screen, a window or a tab.
|
|
77
|
+
*
|
|
78
|
+
* Two paths, and the difference is who asked. With a source id the person has
|
|
79
|
+
* already chosen from a picker their host drew — on a desktop app, where there is
|
|
80
|
+
* no browser picker to open — so this captures it and asks nothing. Without one,
|
|
81
|
+
* the browser's own picker is the right answer and the better one: it is the
|
|
82
|
+
* picker people already know, and it is the only one that can offer a single tab.
|
|
83
|
+
*/
|
|
84
|
+
async function captureScreen(sourceId) {
|
|
85
|
+
if (sourceId) {
|
|
86
|
+
try {
|
|
87
|
+
return await navigator.mediaDevices.getUserMedia(desktopConstraints(sourceId));
|
|
88
|
+
}
|
|
89
|
+
catch (cause) {
|
|
90
|
+
// No picker was involved, so there is nothing the person could have
|
|
91
|
+
// cancelled: whatever went wrong here is worth saying.
|
|
92
|
+
emit({ type: 'failed', reason: describeShareError(cause) });
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const video = { frameRate: { ideal: SCREEN_CEILING.maxFramerate } };
|
|
97
|
+
try {
|
|
98
|
+
/*
|
|
99
|
+
* Audio is asked for and never taken.
|
|
100
|
+
*
|
|
101
|
+
* Where the browser supports it, asking is what puts an unticked "also share
|
|
102
|
+
* tab audio" box in its picker — so the person decides, and the default is
|
|
103
|
+
* off. Not asking would mean a share of a video call or a demo with the sound
|
|
104
|
+
* missing and no way to add it.
|
|
105
|
+
*/
|
|
106
|
+
return await navigator.mediaDevices.getDisplayMedia({ video, audio: true });
|
|
107
|
+
}
|
|
108
|
+
catch (cause) {
|
|
109
|
+
if (shareCancelled(cause))
|
|
110
|
+
return null;
|
|
111
|
+
// Some browsers refuse the whole request rather than ignoring the audio they
|
|
112
|
+
// cannot provide. The screen is the point; the sound is not worth losing it.
|
|
113
|
+
try {
|
|
114
|
+
return await navigator.mediaDevices.getDisplayMedia({ video, audio: false });
|
|
115
|
+
}
|
|
116
|
+
catch (retry) {
|
|
117
|
+
if (!shareCancelled(retry))
|
|
118
|
+
emit({ type: 'failed', reason: describeShareError(retry) });
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Own microphone level, measured locally.
|
|
125
|
+
*
|
|
126
|
+
* Client-side because in a mesh there is no server in the media path to
|
|
127
|
+
* measure it. Reported only when it crosses the threshold, not on a tick, so
|
|
128
|
+
* the socket carries a handful of events per person rather than a stream.
|
|
129
|
+
*
|
|
130
|
+
* The measure is the root mean square of the waveform, which is how loud the
|
|
131
|
+
* sound is. Muting stops it immediately rather than waiting out the hold: a
|
|
132
|
+
* muted microphone is silent, and an indicator saying otherwise for a second
|
|
133
|
+
* afterwards is the one mistake this indicator must never make.
|
|
134
|
+
*/
|
|
135
|
+
function watchLevel(stream) {
|
|
136
|
+
try {
|
|
137
|
+
audioContext = new AudioContext();
|
|
138
|
+
analyser = audioContext.createAnalyser();
|
|
139
|
+
analyser.fftSize = 512;
|
|
140
|
+
audioContext.createMediaStreamSource(stream).connect(analyser);
|
|
141
|
+
const samples = new Uint8Array(analyser.fftSize);
|
|
142
|
+
levelTimer = setInterval(() => {
|
|
143
|
+
if (!analyser)
|
|
144
|
+
return;
|
|
145
|
+
analyser.getByteTimeDomainData(samples);
|
|
146
|
+
const level = rmsLevel(samples);
|
|
147
|
+
const muted = !(microphone?.getAudioTracks()[0]?.enabled ?? false);
|
|
148
|
+
if (!muted && level > SPEAKING_LEVEL)
|
|
149
|
+
loudAt = Date.now();
|
|
150
|
+
// Held briefly after the level drops, because the gap between two words
|
|
151
|
+
// is not the end of somebody talking.
|
|
152
|
+
const now = speakingNow({ muted, loudAt, now: Date.now() });
|
|
153
|
+
if (now !== speaking) {
|
|
154
|
+
speaking = now;
|
|
155
|
+
emit({ type: 'speaking', speaking: now, level });
|
|
156
|
+
}
|
|
157
|
+
}, LEVEL_INTERVAL_MS);
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
// No audio context is survivable: speaking indicators stop working and
|
|
161
|
+
// the call itself is unaffected, which is the right thing to lose.
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// ------------------------------------------------------------------ peers
|
|
165
|
+
function createPeer(participant) {
|
|
166
|
+
const connection = new RTCPeerConnection({
|
|
167
|
+
iceServers: iceServers.map((server) => ({
|
|
168
|
+
urls: server.urls,
|
|
169
|
+
...(server.username ? { username: server.username } : {}),
|
|
170
|
+
...(server.credential ? { credential: server.credential } : {}),
|
|
171
|
+
})),
|
|
172
|
+
// Relay is never forced. A direct connection costs nobody anything and is
|
|
173
|
+
// the better path; TURN is what happens when there is no other option.
|
|
174
|
+
iceTransportPolicy: 'all',
|
|
175
|
+
});
|
|
176
|
+
const peer = {
|
|
177
|
+
...participant,
|
|
178
|
+
connection,
|
|
179
|
+
polite: selfDeviceId < participant.deviceId,
|
|
180
|
+
makingOffer: false,
|
|
181
|
+
ignoreOffer: false,
|
|
182
|
+
senders: { audio: null, video: null, screen: null, screenAudio: null },
|
|
183
|
+
wantsVideo: true,
|
|
184
|
+
videoStreams: new Map(),
|
|
185
|
+
shareStreamId: null,
|
|
186
|
+
shown: { camera: null, screen: null },
|
|
187
|
+
connectTimer: null,
|
|
188
|
+
};
|
|
189
|
+
connection.onicecandidate = ({ candidate }) => {
|
|
190
|
+
if (candidate) {
|
|
191
|
+
signaller.send({ to: peer.deviceId, type: 'candidate', payload: candidate.toJSON() });
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
connection.onnegotiationneeded = () => {
|
|
195
|
+
void (async () => {
|
|
196
|
+
try {
|
|
197
|
+
peer.makingOffer = true;
|
|
198
|
+
await connection.setLocalDescription();
|
|
199
|
+
signaller.send({
|
|
200
|
+
to: peer.deviceId,
|
|
201
|
+
type: 'offer',
|
|
202
|
+
payload: connection.localDescription?.toJSON(),
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
// A failed renegotiation must not take the call down. Audio keeps
|
|
207
|
+
// flowing on the existing description and the next change tries again.
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
peer.makingOffer = false;
|
|
211
|
+
}
|
|
212
|
+
})();
|
|
213
|
+
};
|
|
214
|
+
connection.ontrack = ({ track, streams }) => {
|
|
215
|
+
const stream = streams[0];
|
|
216
|
+
if (!stream)
|
|
217
|
+
return;
|
|
218
|
+
/*
|
|
219
|
+
* A shared tab's sound travels inside the share's own stream, so it is not
|
|
220
|
+
* the voice: it goes out of the share, where what is making the noise is on
|
|
221
|
+
* screen, rather than out of the element playing this person's microphone —
|
|
222
|
+
* which it would otherwise replace, leaving somebody inaudible for as long
|
|
223
|
+
* as they share a tab.
|
|
224
|
+
*/
|
|
225
|
+
if (track.kind === 'audio') {
|
|
226
|
+
if (stream.id === peer.shareStreamId)
|
|
227
|
+
return;
|
|
228
|
+
emit({ type: 'track', deviceId: peer.deviceId, stream, source: 'audio' });
|
|
229
|
+
track.onended = () => emit({ type: 'track.ended', deviceId: peer.deviceId, source: 'audio' });
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
peer.videoStreams.set(stream.id, stream);
|
|
233
|
+
track.onended = () => {
|
|
234
|
+
peer.videoStreams.delete(stream.id);
|
|
235
|
+
syncVideo(peer);
|
|
236
|
+
};
|
|
237
|
+
syncVideo(peer);
|
|
238
|
+
};
|
|
239
|
+
connection.onconnectionstatechange = () => {
|
|
240
|
+
if (connection.connectionState === 'connected') {
|
|
241
|
+
if (peer.connectTimer)
|
|
242
|
+
clearTimeout(peer.connectTimer);
|
|
243
|
+
peer.connectTimer = null;
|
|
244
|
+
}
|
|
245
|
+
if (connection.connectionState === 'failed') {
|
|
246
|
+
// An ICE restart handles the ordinary case: switching from wifi to
|
|
247
|
+
// wired, or a phone changing network, should not end a call.
|
|
248
|
+
void restart(peer);
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
// Bounded, so a network that blocks everything produces a clear message
|
|
252
|
+
// rather than a spinner that never resolves.
|
|
253
|
+
peer.connectTimer = setTimeout(() => {
|
|
254
|
+
if (connection.connectionState !== 'connected') {
|
|
255
|
+
emit({
|
|
256
|
+
type: 'failed',
|
|
257
|
+
deviceId: peer.deviceId,
|
|
258
|
+
reason: `Could not connect to ${peer.displayName} on this network.`,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}, CONNECT_TIMEOUT_MS);
|
|
262
|
+
peers.set(peer.deviceId, peer);
|
|
263
|
+
emit({
|
|
264
|
+
type: 'participant.joined',
|
|
265
|
+
deviceId: peer.deviceId,
|
|
266
|
+
userId: peer.userId,
|
|
267
|
+
displayName: peer.displayName,
|
|
268
|
+
});
|
|
269
|
+
return peer;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Work out which of a peer's video streams is the camera and which is the screen,
|
|
273
|
+
* and tell the app only about what changed.
|
|
274
|
+
*
|
|
275
|
+
* The two inputs — the streams that have arrived and the stream id the peer says
|
|
276
|
+
* is its screen — arrive independently, and either can come first. So this is
|
|
277
|
+
* recomputed on both and is deliberately idempotent: calling it twice with the
|
|
278
|
+
* same inputs emits nothing, and a share announced a moment after its track
|
|
279
|
+
* arrived corrects itself by moving that stream from one slot to the other.
|
|
280
|
+
*
|
|
281
|
+
* A second video stream nobody announced is left alone rather than guessed at. A
|
|
282
|
+
* spreadsheet drawn in a face tile looks like a bug in the tiles, and a peer that
|
|
283
|
+
* never announces is a peer running something other than this adapter.
|
|
284
|
+
*/
|
|
285
|
+
function syncVideo(peer) {
|
|
286
|
+
const screen = peer.shareStreamId && peer.videoStreams.has(peer.shareStreamId) ? peer.shareStreamId : null;
|
|
287
|
+
const camera = [...peer.videoStreams.keys()].find((id) => id !== screen) ?? null;
|
|
288
|
+
for (const [source, id] of [
|
|
289
|
+
['camera', camera],
|
|
290
|
+
['screen', screen],
|
|
291
|
+
]) {
|
|
292
|
+
if (peer.shown[source] === id)
|
|
293
|
+
continue;
|
|
294
|
+
peer.shown[source] = id;
|
|
295
|
+
const stream = id === null ? undefined : peer.videoStreams.get(id);
|
|
296
|
+
if (stream)
|
|
297
|
+
emit({ type: 'track', deviceId: peer.deviceId, stream, source });
|
|
298
|
+
else
|
|
299
|
+
emit({ type: 'track.ended', deviceId: peer.deviceId, source });
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Tell a peer which stream is our screen.
|
|
304
|
+
*
|
|
305
|
+
* Over signalling rather than over the call's own events, because it is nobody
|
|
306
|
+
* else's business: it is a stream id, it means nothing outside this pair of
|
|
307
|
+
* connections, and a provider whose SDK labels its own tracks never sends it. The
|
|
308
|
+
* core relays it without looking inside, exactly as it does an offer.
|
|
309
|
+
*
|
|
310
|
+
* Sent before the track is added, which is what keeps the receiver from having to
|
|
311
|
+
* guess: signalling is one hop over an open socket and media needs a
|
|
312
|
+
* renegotiation, so the description arrives first in any ordinary case — and the
|
|
313
|
+
* receiver corrects itself in the case where it does not.
|
|
314
|
+
*/
|
|
315
|
+
function announceShare(peer) {
|
|
316
|
+
signaller.send({
|
|
317
|
+
to: peer.deviceId,
|
|
318
|
+
type: 'share',
|
|
319
|
+
payload: { streamId: screen?.id ?? null, active: screen !== null },
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
async function restart(peer) {
|
|
323
|
+
try {
|
|
324
|
+
await peer.connection.setLocalDescription(await peer.connection.createOffer({ iceRestart: true }));
|
|
325
|
+
signaller.send({
|
|
326
|
+
to: peer.deviceId,
|
|
327
|
+
type: 'offer',
|
|
328
|
+
payload: peer.connection.localDescription?.toJSON(),
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
emit({
|
|
333
|
+
type: 'failed',
|
|
334
|
+
deviceId: peer.deviceId,
|
|
335
|
+
reason: `Lost the connection to ${peer.displayName}.`,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
async function publishTo(peer) {
|
|
340
|
+
const audioTrack = microphone?.getAudioTracks()[0];
|
|
341
|
+
if (audioTrack && !peer.senders.audio) {
|
|
342
|
+
peer.senders.audio = peer.connection.addTrack(audioTrack, microphone);
|
|
343
|
+
await applyAudioCeiling(peer.senders.audio);
|
|
344
|
+
}
|
|
345
|
+
const videoTrack = camera?.getVideoTracks()[0];
|
|
346
|
+
if (videoTrack && !peer.senders.video) {
|
|
347
|
+
peer.senders.video = peer.connection.addTrack(videoTrack, camera);
|
|
348
|
+
await applyVideoCeiling(peer.senders.video, peer.wantsVideo);
|
|
349
|
+
}
|
|
350
|
+
const sharing = screen;
|
|
351
|
+
const screenTrack = sharing?.getVideoTracks()[0];
|
|
352
|
+
if (sharing && screenTrack && !peer.senders.screen) {
|
|
353
|
+
// Which stream is the screen, said before the stream itself turns up.
|
|
354
|
+
announceShare(peer);
|
|
355
|
+
peer.senders.screen = peer.connection.addTrack(screenTrack, sharing);
|
|
356
|
+
await applyScreenCeiling(peer.senders.screen);
|
|
357
|
+
/*
|
|
358
|
+
* A shared tab's own sound, if the person ticked the browser's box.
|
|
359
|
+
*
|
|
360
|
+
* Sent inside the share's stream rather than beside it, so it arrives as part
|
|
361
|
+
* of the thing making the noise: what is playing comes out of the share, and
|
|
362
|
+
* this person's voice keeps coming out of their own element.
|
|
363
|
+
*/
|
|
364
|
+
const soundTrack = sharing.getAudioTracks()[0];
|
|
365
|
+
if (soundTrack && !peer.senders.screenAudio) {
|
|
366
|
+
peer.senders.screenAudio = peer.connection.addTrack(soundTrack, sharing);
|
|
367
|
+
await applyAudioCeiling(peer.senders.screenAudio);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
async function applyAudioCeiling(sender) {
|
|
372
|
+
const parameters = sender.getParameters();
|
|
373
|
+
parameters.encodings = [{ maxBitrate: AUDIO_BITRATE, priority: 'high' }];
|
|
374
|
+
await sender.setParameters(parameters).catch(() => { });
|
|
375
|
+
}
|
|
376
|
+
async function applyVideoCeiling(sender, wanted) {
|
|
377
|
+
const step = VIDEO_STEPS[videoStep] ?? VIDEO_STEPS[VIDEO_STEPS.length - 1];
|
|
378
|
+
const parameters = sender.getParameters();
|
|
379
|
+
parameters.degradationPreference = 'maintain-framerate';
|
|
380
|
+
parameters.encodings = [
|
|
381
|
+
wanted
|
|
382
|
+
? { maxBitrate: step.maxBitrate, maxFramerate: step.maxFramerate, active: true }
|
|
383
|
+
: // Not one of the visible tiles: stop sending video to this peer
|
|
384
|
+
// entirely rather than sending something nobody is looking at.
|
|
385
|
+
{ active: false },
|
|
386
|
+
];
|
|
387
|
+
await sender.setParameters(parameters).catch(() => { });
|
|
388
|
+
}
|
|
389
|
+
async function applyScreenCeiling(sender) {
|
|
390
|
+
const parameters = sender.getParameters();
|
|
391
|
+
// Resolution over frame rate: a share that is sharp and slightly jerky is
|
|
392
|
+
// far more useful than a smooth one nobody can read.
|
|
393
|
+
parameters.degradationPreference = 'maintain-resolution';
|
|
394
|
+
parameters.encodings = [
|
|
395
|
+
{ maxBitrate: SCREEN_CEILING.maxBitrate, maxFramerate: SCREEN_CEILING.maxFramerate },
|
|
396
|
+
];
|
|
397
|
+
await sender.setParameters(parameters).catch(() => { });
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Watch the connection and step video down before audio suffers.
|
|
401
|
+
*
|
|
402
|
+
* Video degrades first, always. If audio itself starts failing the client
|
|
403
|
+
* drops its own outgoing video and says so, rather than letting both collapse
|
|
404
|
+
* and leaving the person wondering what happened.
|
|
405
|
+
*/
|
|
406
|
+
function watchStats() {
|
|
407
|
+
statsTimer = setInterval(() => {
|
|
408
|
+
void (async () => {
|
|
409
|
+
let worstLoss = 0;
|
|
410
|
+
let worstRtt = 0;
|
|
411
|
+
for (const peer of peers.values()) {
|
|
412
|
+
const stats = await peer.connection.getStats().catch(() => null);
|
|
413
|
+
if (!stats)
|
|
414
|
+
continue;
|
|
415
|
+
let relayed = false;
|
|
416
|
+
let packetLoss = 0;
|
|
417
|
+
let roundTripMs = 0;
|
|
418
|
+
stats.forEach((report) => {
|
|
419
|
+
if (report.type === 'candidate-pair' && report.state === 'succeeded') {
|
|
420
|
+
roundTripMs = Math.round((report.currentRoundTripTime ?? 0) * 1000);
|
|
421
|
+
}
|
|
422
|
+
if (report.type === 'local-candidate' && report.candidateType === 'relay') {
|
|
423
|
+
relayed = true;
|
|
424
|
+
}
|
|
425
|
+
if (report.type === 'remote-inbound-rtp') {
|
|
426
|
+
packetLoss = Math.max(packetLoss, report.fractionLost ?? 0);
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
worstLoss = Math.max(worstLoss, packetLoss);
|
|
430
|
+
worstRtt = Math.max(worstRtt, roundTripMs);
|
|
431
|
+
emit({ type: 'quality', deviceId: peer.deviceId, relayed, packetLoss, roundTripMs });
|
|
432
|
+
}
|
|
433
|
+
await adaptTo(worstLoss, worstRtt);
|
|
434
|
+
})();
|
|
435
|
+
}, 3000);
|
|
436
|
+
}
|
|
437
|
+
async function adaptTo(loss, roundTripMs) {
|
|
438
|
+
const before = videoStep;
|
|
439
|
+
if ((loss > 0.08 || roundTripMs > 400) && videoStep < VIDEO_STEPS.length - 1) {
|
|
440
|
+
videoStep += 1;
|
|
441
|
+
}
|
|
442
|
+
else if (loss < 0.02 && roundTripMs < 200 && videoStep > 0) {
|
|
443
|
+
// Step back up as it recovers, so a brief wobble does not leave somebody
|
|
444
|
+
// at 180p for the rest of the call.
|
|
445
|
+
videoStep -= 1;
|
|
446
|
+
}
|
|
447
|
+
if (videoStep !== before) {
|
|
448
|
+
for (const peer of peers.values()) {
|
|
449
|
+
if (peer.senders.video)
|
|
450
|
+
await applyVideoCeiling(peer.senders.video, peer.wantsVideo);
|
|
451
|
+
}
|
|
452
|
+
emit({
|
|
453
|
+
type: 'degraded',
|
|
454
|
+
videoDropped: false,
|
|
455
|
+
reason: videoStep > before
|
|
456
|
+
? 'Your video was reduced to keep the audio clear.'
|
|
457
|
+
: 'Your video quality has recovered.',
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
// Audio itself is in trouble. Drop our own video rather than letting both
|
|
461
|
+
// collapse, and say so.
|
|
462
|
+
if (loss > 0.2 && camera) {
|
|
463
|
+
await setCamera(false);
|
|
464
|
+
emit({
|
|
465
|
+
type: 'degraded',
|
|
466
|
+
videoDropped: true,
|
|
467
|
+
reason: 'Your connection could not carry video, so it was turned off to protect the audio.',
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
// -------------------------------------------------------------- signalling
|
|
472
|
+
async function onSignal(message) {
|
|
473
|
+
let peer = peers.get(message.from);
|
|
474
|
+
/*
|
|
475
|
+
* Somebody we have not met is offering.
|
|
476
|
+
*
|
|
477
|
+
* This is the other half of the join sequence. A new participant connects
|
|
478
|
+
* out to everyone already in the call; from the point of view of those
|
|
479
|
+
* already here, the first they hear of the new arrival is this offer, and
|
|
480
|
+
* without creating a peer for it the offer is dropped and the two sides
|
|
481
|
+
* never connect. The new person joins a call in which nobody can hear them.
|
|
482
|
+
*
|
|
483
|
+
* The display name is left empty deliberately: the UI takes names from the
|
|
484
|
+
* office state, which already knows everybody, so the adapter does not need
|
|
485
|
+
* to be told twice.
|
|
486
|
+
*/
|
|
487
|
+
if (!peer && message.type === 'offer') {
|
|
488
|
+
peer = createPeer({ deviceId: message.from, userId: '', displayName: '' });
|
|
489
|
+
}
|
|
490
|
+
if (!peer)
|
|
491
|
+
return;
|
|
492
|
+
try {
|
|
493
|
+
/*
|
|
494
|
+
* Which of this peer's streams is its screen.
|
|
495
|
+
*
|
|
496
|
+
* Not media and not negotiation: it is the one thing about a share the
|
|
497
|
+
* receiver cannot see for itself, and it arrives on the same channel because
|
|
498
|
+
* that is the channel the pair of them already have.
|
|
499
|
+
*/
|
|
500
|
+
if (message.type === 'share') {
|
|
501
|
+
const payload = message.payload;
|
|
502
|
+
const streamId = typeof payload?.streamId === 'string' ? payload.streamId : null;
|
|
503
|
+
peer.shareStreamId = payload?.active === true ? streamId : null;
|
|
504
|
+
syncVideo(peer);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
if (message.type === 'candidate') {
|
|
508
|
+
await peer.connection.addIceCandidate(message.payload);
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
const description = message.payload;
|
|
512
|
+
// Perfect negotiation: both sides can offer at once without deadlocking,
|
|
513
|
+
// which matters here because turning a camera on renegotiates and two
|
|
514
|
+
// people do that at the same moment more often than you would think.
|
|
515
|
+
const offerCollision = description.type === 'offer' &&
|
|
516
|
+
(peer.makingOffer || peer.connection.signalingState !== 'stable');
|
|
517
|
+
peer.ignoreOffer = !peer.polite && offerCollision;
|
|
518
|
+
if (peer.ignoreOffer)
|
|
519
|
+
return;
|
|
520
|
+
await peer.connection.setRemoteDescription(description);
|
|
521
|
+
if (description.type === 'offer') {
|
|
522
|
+
await peer.connection.setLocalDescription();
|
|
523
|
+
signaller.send({
|
|
524
|
+
to: peer.deviceId,
|
|
525
|
+
type: 'answer',
|
|
526
|
+
payload: peer.connection.localDescription?.toJSON(),
|
|
527
|
+
});
|
|
528
|
+
// Send our own microphone and camera to them, once the answer is out.
|
|
529
|
+
// Doing it after rather than before means one clean renegotiation
|
|
530
|
+
// instead of an offer colliding with the one we are already answering.
|
|
531
|
+
await publishTo(peer);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
catch {
|
|
535
|
+
// A malformed or out-of-order message is not worth ending a call over.
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
// --------------------------------------------------------------- the API
|
|
539
|
+
async function setCamera(on) {
|
|
540
|
+
if (on) {
|
|
541
|
+
const stream = await ensureCamera();
|
|
542
|
+
if (!stream)
|
|
543
|
+
return;
|
|
544
|
+
for (const peer of peers.values())
|
|
545
|
+
await publishTo(peer);
|
|
546
|
+
}
|
|
547
|
+
else if (camera) {
|
|
548
|
+
for (const peer of peers.values()) {
|
|
549
|
+
if (peer.senders.video) {
|
|
550
|
+
peer.connection.removeTrack(peer.senders.video);
|
|
551
|
+
peer.senders.video = null;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
for (const track of camera.getTracks())
|
|
555
|
+
track.stop();
|
|
556
|
+
camera = null;
|
|
557
|
+
emit({ type: 'local', stream: null, source: 'camera' });
|
|
558
|
+
}
|
|
559
|
+
state();
|
|
560
|
+
}
|
|
561
|
+
return {
|
|
562
|
+
async join(options) {
|
|
563
|
+
selfDeviceId = options.deviceId;
|
|
564
|
+
iceServers = options.iceServers;
|
|
565
|
+
audioDeviceId = options.audioDeviceId;
|
|
566
|
+
videoDeviceId = options.videoDeviceId;
|
|
567
|
+
videoStep = 0;
|
|
568
|
+
stopSignals = signaller.receive((message) => {
|
|
569
|
+
void onSignal(message);
|
|
570
|
+
});
|
|
571
|
+
if (options.audio)
|
|
572
|
+
await ensureMicrophone();
|
|
573
|
+
if (options.video)
|
|
574
|
+
await ensureCamera();
|
|
575
|
+
// The new arrival connects out to everyone already here. Existing
|
|
576
|
+
// participants learn about them from the offer that follows.
|
|
577
|
+
for (const participant of options.participants) {
|
|
578
|
+
const peer = createPeer(participant);
|
|
579
|
+
await publishTo(peer);
|
|
580
|
+
}
|
|
581
|
+
watchStats();
|
|
582
|
+
state();
|
|
583
|
+
},
|
|
584
|
+
async leave() {
|
|
585
|
+
for (const peer of peers.values()) {
|
|
586
|
+
if (peer.connectTimer)
|
|
587
|
+
clearTimeout(peer.connectTimer);
|
|
588
|
+
peer.connection.close();
|
|
589
|
+
emit({ type: 'participant.left', deviceId: peer.deviceId });
|
|
590
|
+
}
|
|
591
|
+
peers.clear();
|
|
592
|
+
for (const stream of [microphone, camera, screen]) {
|
|
593
|
+
for (const track of stream?.getTracks() ?? [])
|
|
594
|
+
track.stop();
|
|
595
|
+
}
|
|
596
|
+
microphone = null;
|
|
597
|
+
camera = null;
|
|
598
|
+
screen = null;
|
|
599
|
+
// Said as well as done. Leaving stops the capture, and a stream the UI still
|
|
600
|
+
// believes in is a share still drawn over an office nobody is in a call in.
|
|
601
|
+
emit({ type: 'local', stream: null, source: 'camera' });
|
|
602
|
+
emit({ type: 'local', stream: null, source: 'screen' });
|
|
603
|
+
if (statsTimer)
|
|
604
|
+
clearInterval(statsTimer);
|
|
605
|
+
if (levelTimer)
|
|
606
|
+
clearInterval(levelTimer);
|
|
607
|
+
statsTimer = null;
|
|
608
|
+
levelTimer = null;
|
|
609
|
+
analyser = null;
|
|
610
|
+
await audioContext?.close().catch(() => { });
|
|
611
|
+
audioContext = null;
|
|
612
|
+
stopSignals?.();
|
|
613
|
+
stopSignals = null;
|
|
614
|
+
speaking = false;
|
|
615
|
+
loudAt = 0;
|
|
616
|
+
},
|
|
617
|
+
async setMicrophone(on) {
|
|
618
|
+
const stream = on ? await ensureMicrophone() : microphone;
|
|
619
|
+
const track = stream?.getAudioTracks()[0];
|
|
620
|
+
if (track)
|
|
621
|
+
track.enabled = on;
|
|
622
|
+
if (on && stream) {
|
|
623
|
+
for (const peer of peers.values())
|
|
624
|
+
await publishTo(peer);
|
|
625
|
+
}
|
|
626
|
+
state();
|
|
627
|
+
},
|
|
628
|
+
setCamera,
|
|
629
|
+
async startScreenShare(options) {
|
|
630
|
+
const captured = await captureScreen(options?.sourceId);
|
|
631
|
+
// Either the person closed the picker, which is not a failure and says
|
|
632
|
+
// nothing, or the capture failed and has already said so.
|
|
633
|
+
if (!captured)
|
|
634
|
+
return false;
|
|
635
|
+
screen = captured;
|
|
636
|
+
/*
|
|
637
|
+
* Stopping from outside this app has to clean up inside it.
|
|
638
|
+
*
|
|
639
|
+
* The browser's own sharing bar and closing the shared window are how people
|
|
640
|
+
* actually stop, and forgetting to stop at all is the commonest failure in any
|
|
641
|
+
* call product — so the track ending is treated as the person having stopped,
|
|
642
|
+
* which it is.
|
|
643
|
+
*/
|
|
644
|
+
const track = screen.getVideoTracks()[0];
|
|
645
|
+
if (track) {
|
|
646
|
+
track.onended = () => {
|
|
647
|
+
void this.stopScreenShare();
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
for (const peer of peers.values())
|
|
651
|
+
await publishTo(peer);
|
|
652
|
+
emit({ type: 'local', stream: screen, source: 'screen' });
|
|
653
|
+
state();
|
|
654
|
+
return true;
|
|
655
|
+
},
|
|
656
|
+
async stopScreenShare() {
|
|
657
|
+
if (!screen)
|
|
658
|
+
return;
|
|
659
|
+
// Stopped before anything is unpublished: the capture is what the operating
|
|
660
|
+
// system is recording, and it is the thing that must stop first.
|
|
661
|
+
for (const capture of screen.getTracks())
|
|
662
|
+
capture.stop();
|
|
663
|
+
screen = null;
|
|
664
|
+
for (const peer of peers.values()) {
|
|
665
|
+
for (const kind of ['screen', 'screenAudio']) {
|
|
666
|
+
const sender = peer.senders[kind];
|
|
667
|
+
if (!sender)
|
|
668
|
+
continue;
|
|
669
|
+
peer.connection.removeTrack(sender);
|
|
670
|
+
peer.senders[kind] = null;
|
|
671
|
+
}
|
|
672
|
+
// And told, so a peer holding the last frame knows it is not a share any
|
|
673
|
+
// more rather than keeping a still of a spreadsheet on screen.
|
|
674
|
+
announceShare(peer);
|
|
675
|
+
}
|
|
676
|
+
emit({ type: 'local', stream: null, source: 'screen' });
|
|
677
|
+
state();
|
|
678
|
+
},
|
|
679
|
+
setVideoSubscriptions(deviceIds) {
|
|
680
|
+
const wanted = new Set(deviceIds);
|
|
681
|
+
for (const peer of peers.values()) {
|
|
682
|
+
const next = wanted.has(peer.deviceId);
|
|
683
|
+
if (next === peer.wantsVideo)
|
|
684
|
+
continue;
|
|
685
|
+
peer.wantsVideo = next;
|
|
686
|
+
if (peer.senders.video)
|
|
687
|
+
void applyVideoCeiling(peer.senders.video, next);
|
|
688
|
+
}
|
|
689
|
+
},
|
|
690
|
+
async useDevices(devices) {
|
|
691
|
+
audioDeviceId = devices.audioDeviceId ?? audioDeviceId;
|
|
692
|
+
videoDeviceId = devices.videoDeviceId ?? videoDeviceId;
|
|
693
|
+
// Replace the track in place rather than renegotiating: swapping a headset
|
|
694
|
+
// mid-call should not interrupt the conversation.
|
|
695
|
+
if (devices.audioDeviceId && microphone) {
|
|
696
|
+
const replacement = await navigator.mediaDevices
|
|
697
|
+
.getUserMedia({ audio: { deviceId: { exact: devices.audioDeviceId } } })
|
|
698
|
+
.catch(() => null);
|
|
699
|
+
const track = replacement?.getAudioTracks()[0];
|
|
700
|
+
if (track) {
|
|
701
|
+
for (const peer of peers.values())
|
|
702
|
+
await peer.senders.audio?.replaceTrack(track);
|
|
703
|
+
for (const old of microphone.getAudioTracks())
|
|
704
|
+
old.stop();
|
|
705
|
+
microphone = replacement;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
if (devices.videoDeviceId && camera) {
|
|
709
|
+
const replacement = await navigator.mediaDevices
|
|
710
|
+
.getUserMedia({ video: { deviceId: { exact: devices.videoDeviceId } } })
|
|
711
|
+
.catch(() => null);
|
|
712
|
+
const track = replacement?.getVideoTracks()[0];
|
|
713
|
+
if (track) {
|
|
714
|
+
for (const peer of peers.values())
|
|
715
|
+
await peer.senders.video?.replaceTrack(track);
|
|
716
|
+
for (const old of camera.getVideoTracks())
|
|
717
|
+
old.stop();
|
|
718
|
+
camera = replacement;
|
|
719
|
+
emit({ type: 'local', stream: camera, source: 'camera' });
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
},
|
|
723
|
+
on(handler) {
|
|
724
|
+
handlers.add(handler);
|
|
725
|
+
return () => handlers.delete(handler);
|
|
726
|
+
},
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* Turn a getUserMedia failure into something worth reading.
|
|
731
|
+
*
|
|
732
|
+
* Denied permission is the most common support question in any call product,
|
|
733
|
+
* and the browser's own message is no help at all. A device held by another app
|
|
734
|
+
* is a different problem with a different fix, and on some platforms it arrives
|
|
735
|
+
* as a silent black stream rather than an error, so the two must not be
|
|
736
|
+
* collapsed into "something went wrong".
|
|
737
|
+
*/
|
|
738
|
+
export function describeMediaError(cause, device) {
|
|
739
|
+
const name = cause instanceof Error ? cause.name : '';
|
|
740
|
+
switch (name) {
|
|
741
|
+
case 'NotAllowedError':
|
|
742
|
+
case 'SecurityError':
|
|
743
|
+
return `Your browser is blocking the ${device}. Open the padlock in the address bar, allow the ${device}, and try again.`;
|
|
744
|
+
case 'NotReadableError':
|
|
745
|
+
case 'TrackStartError':
|
|
746
|
+
return `Another app is using your ${device}. Close it and try again — video calls and recording apps hold on to it.`;
|
|
747
|
+
case 'NotFoundError':
|
|
748
|
+
case 'DevicesNotFoundError':
|
|
749
|
+
return `No ${device} was found. Check that it is plugged in.`;
|
|
750
|
+
case 'OverconstrainedError':
|
|
751
|
+
return `That ${device} is no longer available. Pick a different one in settings.`;
|
|
752
|
+
default:
|
|
753
|
+
return `The ${device} could not be started.`;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
//# sourceMappingURL=mesh.js.map
|