@voltkit-rtc/client 0.3.0 → 0.5.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/README.md +196 -24
  3. package/dist/cjs/chat.d.ts +2 -0
  4. package/dist/cjs/chat.d.ts.map +1 -1
  5. package/dist/cjs/chat.js +23 -4
  6. package/dist/cjs/chat.js.map +1 -1
  7. package/dist/cjs/device.d.ts +85 -0
  8. package/dist/cjs/device.d.ts.map +1 -0
  9. package/dist/cjs/device.js +134 -0
  10. package/dist/cjs/device.js.map +1 -0
  11. package/dist/cjs/index.d.ts +212 -6
  12. package/dist/cjs/index.d.ts.map +1 -1
  13. package/dist/cjs/index.js +822 -52
  14. package/dist/cjs/index.js.map +1 -1
  15. package/dist/cjs/music.d.ts +1 -1
  16. package/dist/cjs/music.d.ts.map +1 -1
  17. package/dist/cjs/quality.d.ts +43 -16
  18. package/dist/cjs/quality.d.ts.map +1 -1
  19. package/dist/cjs/quality.js +81 -31
  20. package/dist/cjs/quality.js.map +1 -1
  21. package/dist/cjs/stats.d.ts +151 -0
  22. package/dist/cjs/stats.d.ts.map +1 -0
  23. package/dist/cjs/stats.js +243 -0
  24. package/dist/cjs/stats.js.map +1 -0
  25. package/dist/esm/chat.d.ts +2 -0
  26. package/dist/esm/chat.d.ts.map +1 -1
  27. package/dist/esm/chat.js +23 -4
  28. package/dist/esm/chat.js.map +1 -1
  29. package/dist/esm/device.d.ts +85 -0
  30. package/dist/esm/device.d.ts.map +1 -0
  31. package/dist/esm/device.js +122 -0
  32. package/dist/esm/device.js.map +1 -0
  33. package/dist/esm/index.d.ts +212 -6
  34. package/dist/esm/index.d.ts.map +1 -1
  35. package/dist/esm/index.js +823 -53
  36. package/dist/esm/index.js.map +1 -1
  37. package/dist/esm/music.d.ts +1 -1
  38. package/dist/esm/music.d.ts.map +1 -1
  39. package/dist/esm/quality.d.ts +43 -16
  40. package/dist/esm/quality.d.ts.map +1 -1
  41. package/dist/esm/quality.js +76 -31
  42. package/dist/esm/quality.js.map +1 -1
  43. package/dist/esm/stats.d.ts +151 -0
  44. package/dist/esm/stats.d.ts.map +1 -0
  45. package/dist/esm/stats.js +232 -0
  46. package/dist/esm/stats.js.map +1 -0
  47. package/package.json +1 -1
  48. package/src/chat.ts +21 -4
  49. package/src/device.ts +168 -0
  50. package/src/index.ts +879 -58
  51. package/src/music.ts +1 -1
  52. package/src/quality.ts +96 -32
  53. package/src/stats.ts +328 -0
package/dist/esm/index.js CHANGED
@@ -6,8 +6,10 @@
6
6
  * setup, consumer resume ordering and reconnection, each slightly wrong.
7
7
  */
8
8
  import { Device } from 'mediasoup-client';
9
+ import { chooseCodec, currentEnv, defaultLayerCount, findCodec, h264HardwareEncode, isAppleMobile, isPhoneOrTablet, normalizeCodec, } from './device.js';
9
10
  import { MusicSource } from './music.js';
10
- import { allowedQuality, DEFAULT_VIDEO_POLICY, videoEncodings } from './quality.js';
11
+ import { allowedQuality, DEFAULT_VIDEO_POLICY, sameLayout, describeVideoLayers, receiveSpatialLayer, screenEncodings, topLayerSize, videoEncodings, } from './quality.js';
12
+ import { parseReceiveStats, parseSendStats, pruneBaseline, VideoLimitWatch, } from './stats.js';
11
13
  export { BroadcastPublisher, WhipError, whipEnd, whipOffer } from './broadcast.js';
12
14
  export { AudioMixer, MusicSource } from './music.js';
13
15
  export { ChatClient, ChatRpcError } from './chat.js';
@@ -48,6 +50,14 @@ const CLOSE_REPLACED = 4001;
48
50
  /** The server ended the session on purpose and said why first: do not reconnect. */
49
51
  const CLOSE_ENDED = 4003;
50
52
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
53
+ const isLayer = (value) => Number.isInteger(value) && value >= 0;
54
+ /**
55
+ * Where a new camera stream's bandwidth estimate starts, in kbps. Chrome
56
+ * otherwise starts low and takes many seconds to climb to the top layer.
57
+ */
58
+ const VIDEO_START_BITRATE_KBPS = 2000;
59
+ /** Why a publish gave up: unpublish() ran while it was under way, and unpublish wins. */
60
+ const unpublishedWhile = (what) => `the ${what} was unpublished while it was being published`;
51
61
  export class StreamClient {
52
62
  wsUrl;
53
63
  ws = null;
@@ -78,11 +88,47 @@ export class StreamClient {
78
88
  /** In-app music being published, kept across reconnects so it can be published again. */
79
89
  music = null;
80
90
  musicProducerId = null;
91
+ screenStream = null;
92
+ screenMode = 'detail';
93
+ screenAudioEnabled = true;
94
+ screenGeneration = 0;
95
+ screenPending = false;
96
+ screenStarted = false;
97
+ screenEndedListener = null;
98
+ screenProducerIds = new Set();
99
+ remoteVideoLayers = new Map();
100
+ receiveQualities = new Map();
101
+ cameraFrameRate = 30;
102
+ frameRateFallback = false;
103
+ frameRateLowSamples = 0;
104
+ frameRateWarmupSamples = 2;
81
105
  /** What each remote producer carries, so a pause can say "music" rather than just "audio". */
82
106
  remoteSources = new Map();
107
+ /**
108
+ * The layers asked for each remote video, by producerId. A reconnect
109
+ * consumes everything again, and the new consumer would otherwise start at
110
+ * the full picture.
111
+ */
112
+ preferredLayers = new Map();
83
113
  closedByUs = false;
84
114
  reconnecting = false;
85
115
  reconnectAttempt = 0;
116
+ /** Set once a connect() has joined and set up. Only such a session is reconnected. */
117
+ joinedOnce = false;
118
+ /** The camera producer's layers, to tell whether a new track fits them. */
119
+ cameraEncodings = null;
120
+ // Changes to the camera producer run one at a time. Two overlapping swaps
121
+ // (an effect toggled on and off quickly) would otherwise both close the
122
+ // same producer and both produce a new one.
123
+ cameraQueue = Promise.resolve();
124
+ // Bumped by unpublish(). A producer still being made when it runs (camera,
125
+ // microphone or music) belongs to the publish it ended: it is closed, not
126
+ // kept.
127
+ publishGeneration = 0;
128
+ codecFallbackLogged = false;
129
+ sendStatsBaseline = new Map();
130
+ recvStatsBaseline = new Map();
131
+ limitWatch = new VideoLimitWatch((baseline) => this.sampleCamera(baseline), (event) => this.emit('videoLimited', event));
86
132
  constructor(wsUrl, token) {
87
133
  this.wsUrl = wsUrl;
88
134
  this.token = token;
@@ -94,7 +140,17 @@ export class StreamClient {
94
140
  this.listeners.set(event, set);
95
141
  }
96
142
  set.add(fn);
97
- return () => set.delete(fn);
143
+ // videoLimited costs a getStats every 2 s, so it runs only while heard.
144
+ if (event === 'videoLimited')
145
+ this.syncLimitWatch();
146
+ return () => {
147
+ set.delete(fn);
148
+ if (event === 'videoLimited')
149
+ this.syncLimitWatch();
150
+ };
151
+ }
152
+ listenerCount(event) {
153
+ return this.listeners.get(event)?.size ?? 0;
98
154
  }
99
155
  emit(event, ...args) {
100
156
  for (const fn of this.listeners.get(event) ?? []) {
@@ -108,12 +164,52 @@ export class StreamClient {
108
164
  }
109
165
  // ------------------------------------------------------------- connection
110
166
  async connect() {
111
- await this.openSocket();
112
- const result = (await this.rpc('join', { token: this.token }));
113
- this.joinResult = result;
114
- this.device = new Device();
115
- await this.device.load({ routerRtpCapabilities: result.routerRtpCapabilities });
167
+ let result;
168
+ try {
169
+ await this.openSocket();
170
+ result = (await this.rpc('join', { token: this.token }));
171
+ this.joinResult = result;
172
+ this.device = this.createDevice();
173
+ await this.device.load({ routerRtpCapabilities: result.routerRtpCapabilities });
174
+ }
175
+ catch (err) {
176
+ // A session that never got going: the app has the error. Left open,
177
+ // the socket would be closed by the server's join deadline instead, and
178
+ // that close would start reconnecting a session that never began.
179
+ if (!this.joinedOnce)
180
+ this.detachSocket();
181
+ throw err;
182
+ }
183
+ // Joined and set up: the next drop gets every reconnect attempt again.
184
+ // Not when the socket opens -- a join refused with a code worth retrying
185
+ // (no-capacity) never used them up, and every client of a full cluster
186
+ // retried about twice a second, forever.
187
+ this.reconnectAttempt = 0;
188
+ this.joinedOnce = true;
189
+ // A role or mute can change while the socket is away. The fresh roster
190
+ // decides what may return; reconnecting never turns a blocked device on.
191
+ if (result.role !== 'host' && result.role !== 'cohost' && (this.wasPublishing || this.screenPending)) {
192
+ await this.unpublish();
193
+ }
194
+ const own = result.participants.find((p) => p.identity === result.identity);
195
+ if (own?.muted)
196
+ this.applyLocalModeration('audio');
197
+ if (own?.videoBlocked)
198
+ this.applyLocalModeration('video');
116
199
  this.emit('joined', result);
200
+ // A video that ended while this client was away sends it no
201
+ // producerClosed: forget the layers asked for it.
202
+ const present = new Set(result.producers.map((p) => p.producerId));
203
+ for (const producerId of this.preferredLayers.keys()) {
204
+ if (!present.has(producerId)) {
205
+ this.preferredLayers.delete(producerId);
206
+ this.receiveQualities.delete(producerId);
207
+ this.remoteVideoLayers.delete(producerId);
208
+ }
209
+ }
210
+ for (const producerId of this.remoteVideoLayers.keys())
211
+ if (!present.has(producerId))
212
+ this.remoteVideoLayers.delete(producerId);
117
213
  // Subscribe to everything already publishing before we announce readiness.
118
214
  for (const p of result.producers) {
119
215
  await this.consume(p).catch((err) => this.emit('error', err));
@@ -126,6 +222,10 @@ export class StreamClient {
126
222
  }
127
223
  return result;
128
224
  }
225
+ /** A new mediasoup Device for each join. Its own method so tests, which have no WebRTC, can stand one in. */
226
+ createDevice() {
227
+ return new Device();
228
+ }
129
229
  openSocket() {
130
230
  // Abandon any previous socket first. Its close handler checks identity
131
231
  // against `this.ws`, so once it is detached it can no longer trigger a
@@ -137,7 +237,6 @@ export class StreamClient {
137
237
  const ws = new WebSocket(url.toString());
138
238
  this.ws = ws;
139
239
  const onOpen = () => {
140
- this.reconnectAttempt = 0;
141
240
  this.emit('connected');
142
241
  resolve();
143
242
  };
@@ -163,6 +262,9 @@ export class StreamClient {
163
262
  // displace it, and it would displace us right back, forever.
164
263
  this.emit('error', new Error('this identity connected from somewhere else'));
165
264
  this.closedByUs = true;
265
+ // The producers outlive this close until the app calls close(); the
266
+ // videoLimited sampling must not, or it reports after `closed`.
267
+ this.limitWatch.reset();
166
268
  this.emit('closed', 'replaced');
167
269
  return;
168
270
  }
@@ -170,9 +272,14 @@ export class StreamClient {
170
272
  // Normally roomClosed has already ended the session; this covers a
171
273
  // close that arrived without it.
172
274
  this.closedByUs = true;
275
+ this.limitWatch.reset();
173
276
  this.emit('closed', 'room-closed');
174
277
  return;
175
278
  }
279
+ // Before a first successful join the app is still waiting on
280
+ // connect(), which reports the failure; there is nothing to restore.
281
+ if (!this.joinedOnce)
282
+ return;
176
283
  void this.scheduleReconnect();
177
284
  });
178
285
  });
@@ -216,8 +323,13 @@ export class StreamClient {
216
323
  return;
217
324
  try {
218
325
  this.teardownMedia();
219
- await this.connect();
220
- return; // connect() resets the attempt counter once the socket opens
326
+ await this.connect(); // which resets the attempt counter once joined
327
+ // The socket can drop again while the session is being restored,
328
+ // and its close handler then finds this loop still running and
329
+ // leaves it to us. Returning would leave the client disconnected
330
+ // for good, with no `closed`.
331
+ if (this.ws)
332
+ return;
221
333
  }
222
334
  catch (err) {
223
335
  this.emit('error', err);
@@ -241,8 +353,17 @@ export class StreamClient {
241
353
  // others have already reported themselves and set closedByUs.
242
354
  const wasOpen = !this.closedByUs;
243
355
  this.closedByUs = true;
356
+ this.publishGeneration += 1;
244
357
  this.wasPublishing = false;
245
358
  this.teardownMedia();
359
+ this.preferredLayers.clear();
360
+ this.receiveQualities.clear();
361
+ this.remoteVideoLayers.clear();
362
+ this.releaseScreen();
363
+ this.music?.close();
364
+ this.music = null;
365
+ // Nothing is emitted after closing, so not even a clearing videoLimited.
366
+ this.limitWatch.reset();
246
367
  // Producers no longer stop their tracks, so releasing the camera and
247
368
  // microphone is ours to do -- otherwise the device light stays on.
248
369
  this.localStream?.getTracks().forEach((t) => t.stop());
@@ -261,6 +382,11 @@ export class StreamClient {
261
382
  p.close();
262
383
  this.producers.clear();
263
384
  this.musicProducerId = null;
385
+ this.screenProducerIds.clear();
386
+ this.cameraEncodings = null;
387
+ // Paused, not reset: a reconnect's republish starts it again, and a
388
+ // limitation already reported is not reported twice.
389
+ this.syncLimitWatch();
264
390
  this.sendTransport?.close();
265
391
  this.recvTransport?.close();
266
392
  this.sendTransport = null;
@@ -329,6 +455,9 @@ export class StreamClient {
329
455
  break;
330
456
  case 'producerClosed': {
331
457
  const { producerId, identity } = data;
458
+ this.preferredLayers.delete(producerId);
459
+ this.receiveQualities.delete(producerId);
460
+ this.remoteVideoLayers.delete(producerId);
332
461
  const consumer = this.consumers.get(producerId);
333
462
  if (!consumer)
334
463
  break; // never consumed it, or already cleaned up
@@ -352,16 +481,29 @@ export class StreamClient {
352
481
  case 'participantJoined':
353
482
  this.emit('participantJoined', data);
354
483
  break;
484
+ case 'participantMuted': {
485
+ const wire = data;
486
+ const e = { identity: wire.identity, kind: wire.kind, paused: wire.paused ?? wire.muted ?? false };
487
+ if (this.joinResult)
488
+ this.joinResult.participants = this.joinResult.participants.map((p) => p.identity !== e.identity ? p : { ...p,
489
+ ...(!e.kind || e.kind === 'audio' ? { muted: e.paused } : {}),
490
+ ...(!e.kind || e.kind === 'video' ? { videoBlocked: e.paused } : {}),
491
+ });
492
+ this.emit('participantMuted', e);
493
+ break;
494
+ }
355
495
  case 'participantLeft':
356
496
  this.emit('participantLeft', data);
357
497
  break;
358
498
  case 'roleChanged': {
359
499
  const e = data;
500
+ if (e.identity === this.joinResult?.identity)
501
+ this.joinResult.role = e.role;
360
502
  this.emit('roleChanged', e);
361
503
  // Demoted: the server has already closed our producers. Release the
362
504
  // camera and microphone too, instead of capturing for nobody.
363
505
  const canPublish = e.role === 'host' || e.role === 'cohost';
364
- if (e.identity === this.joinResult?.identity && !canPublish && this.producers.size > 0) {
506
+ if (e.identity === this.joinResult?.identity && !canPublish && (this.wasPublishing || this.screenPending)) {
365
507
  void this.unpublish().catch(() => undefined);
366
508
  }
367
509
  break;
@@ -376,10 +518,7 @@ export class StreamClient {
376
518
  // changes nothing here -- switching back on is the user's choice.
377
519
  const e = data;
378
520
  if (e.paused) {
379
- for (const p of this.producers.values()) {
380
- if (!e.kind || p.kind === e.kind)
381
- p.pause();
382
- }
521
+ this.applyLocalModeration(e.kind);
383
522
  this.emitLocalMedia();
384
523
  }
385
524
  this.emit('forceMuted', e);
@@ -467,7 +606,7 @@ export class StreamClient {
467
606
  .catch(fail);
468
607
  });
469
608
  transport.on('produce', ({ kind, rtpParameters, appData }, ok, fail) => {
470
- this.rpc('produce', { transportId: transport.id, kind, rtpParameters, appData, source: appData.source })
609
+ this.rpc('produce', { transportId: transport.id, kind, rtpParameters, appData, source: appData.source, videoLayers: appData.videoLayers })
471
610
  .then((res) => ok({ id: res.producerId }))
472
611
  .catch(fail);
473
612
  });
@@ -508,15 +647,26 @@ export class StreamClient {
508
647
  *
509
648
  * `options.quality` picks 1080p, 720p or 480p (see `videoPolicy` for what
510
649
  * the project allows). Capture at least that size for the full picture.
650
+ * `options.layers`, `codec` and `degradationPreference` tune how the video
651
+ * is encoded; the defaults suit both phones and computers. The options are
652
+ * kept for a reconnect's republish.
511
653
  */
512
654
  async publish(stream, constraints, options) {
655
+ const generation = this.publishGeneration;
513
656
  const transport = await this.ensureSendTransport();
657
+ // unpublish() ran first: this publish is over before it began, and must
658
+ // leave nothing behind that a reconnect would publish again.
659
+ if (generation !== this.publishGeneration)
660
+ throw new Error(unpublishedWhile('stream'));
514
661
  this.localStream = stream;
515
662
  this.wasPublishing = true;
516
663
  if (constraints)
517
664
  this.publishConstraints = constraints;
518
- if (options)
665
+ if (options) {
519
666
  this.publishOptions = { ...options };
667
+ this.frameRateFallback = false;
668
+ this.frameRateLowSamples = 0;
669
+ }
520
670
  const audio = stream.getAudioTracks()[0];
521
671
  if (audio) {
522
672
  const producer = await transport.produce({
@@ -527,13 +677,18 @@ export class StreamClient {
527
677
  codecOptions: { opusStereo: true, opusDtx: true, opusFec: true },
528
678
  appData: { source: 'mic' },
529
679
  });
530
- this.producers.set(producer.id, producer);
680
+ await this.keepProducer(producer, generation, 'microphone');
681
+ // A microphone handed over switched off, or muted before a reconnect.
682
+ await this.pauseOnServer(producer);
531
683
  }
532
684
  const video = stream.getVideoTracks()[0];
533
- if (video)
534
- await this.produceCamera(transport, video);
685
+ if (video) {
686
+ // The track is read when the camera's turn comes: one handed to
687
+ // replaceVideoTrack while this publish was under way is the one to send.
688
+ await this.cameraTask(() => this.produceCamera(transport, this.localStream?.getVideoTracks()[0] ?? video, generation));
689
+ }
535
690
  }
536
- /** What this project may publish. Known once joined; 720p before that. */
691
+ /** What this project may publish. Known once joined; 1080p before that. */
537
692
  get videoPolicy() {
538
693
  return this.joinResult?.video ?? DEFAULT_VIDEO_POLICY;
539
694
  }
@@ -549,36 +704,401 @@ export class StreamClient {
549
704
  */
550
705
  async setVideoQuality(quality) {
551
706
  this.publishOptions = { ...this.publishOptions, quality };
552
- const current = [...this.producers.values()].find((p) => p.kind === 'video' && p.appData.source === 'camera');
553
- const track = current?.track;
554
- if (!current || !track || track.readyState !== 'live')
555
- return this.videoQuality;
707
+ await this.cameraTask(async () => {
708
+ const current = this.cameraProducer();
709
+ const track = current?.track;
710
+ if (!current || !track || track.readyState !== 'live')
711
+ return;
712
+ await this.reproduceCamera(current, track);
713
+ });
714
+ return this.videoQuality;
715
+ }
716
+ /** Change the camera frame rate without stopping screen, microphone or music. */
717
+ async setVideoFrameRate(frameRate) {
718
+ if (frameRate !== 30 && frameRate !== 60)
719
+ throw new RangeError('setVideoFrameRate: expected 30 or 60');
720
+ this.publishOptions = { ...this.publishOptions, frameRate };
721
+ this.frameRateFallback = false;
722
+ this.frameRateLowSamples = 0;
723
+ await this.cameraTask(async () => {
724
+ const current = this.cameraProducer();
725
+ const track = current?.track;
726
+ if (!current || !track || track.readyState !== 'live')
727
+ return;
728
+ try {
729
+ await track.applyConstraints?.({ frameRate: { ideal: frameRate, max: frameRate } });
730
+ }
731
+ catch { /* Existing capture settings decide whether 60 fps is possible. */ }
732
+ await this.reproduceCamera(current, track);
733
+ });
734
+ return this.cameraProducer() ? this.cameraFrameRate : this.videoPolicy.maxFrameRate === 30 ? 30 : frameRate;
735
+ }
736
+ /**
737
+ * Send a different video track from now on: a canvas with effects drawn on
738
+ * it, the raw camera again, another camera. When the new track needs the
739
+ * same dimensions and layers as the current one, viewers see no pause, and the layers
740
+ * take the new picture's bitrates. Otherwise the camera producer is
741
+ * replaced, as `setVideoQuality` does. Either way a paused camera stays
742
+ * paused.
743
+ *
744
+ * Your own stream is left as it is: the client keeps a stream of its own
745
+ * (`local`, announced by `localStreamChanged`) with the new track and your
746
+ * microphone, and a reconnect republishes that. The track you replaced is
747
+ * not stopped; it is yours again, switched back on if pausing the camera
748
+ * had switched it off. Rejects when no camera is being published.
749
+ */
750
+ async replaceVideoTrack(track) {
751
+ if (!track || track.kind !== 'video')
752
+ throw new TypeError('replaceVideoTrack needs a video track');
753
+ if (track.readyState === 'ended')
754
+ throw new Error('replaceVideoTrack: that track has ended');
755
+ await this.cameraTask(async () => {
756
+ const current = this.cameraProducer();
757
+ // Between a dropped connection and the republish there is no producer,
758
+ // but the camera is still being published: the republish sends this.
759
+ const reconnecting = !current && this.wasPublishing && (this.localStream?.getVideoTracks().length ?? 0) > 0;
760
+ if (!current && !reconnecting)
761
+ throw new Error('replaceVideoTrack: no camera is being published');
762
+ if (!current) {
763
+ // The republish reads the camera's state off the track it sends, and
764
+ // the track being replaced is switched off exactly when the camera is
765
+ // paused. Hand that state on, or a camera that was off comes back on;
766
+ // and the old track back switched on, as below.
767
+ const replaced = this.localStream?.getVideoTracks()[0];
768
+ if (replaced && replaced !== track) {
769
+ track.enabled = replaced.enabled;
770
+ replaced.enabled = true;
771
+ }
772
+ }
773
+ this.adoptVideoTrack(track);
774
+ if (!current || current.track === track)
775
+ return;
776
+ const previous = current.track;
777
+ const paused = current.paused;
778
+ try {
779
+ const { width = 0, height = 0 } = track.getSettings();
780
+ const next = videoEncodings(width, height, this.videoQuality, this.layerCount(), this.frameRateFor(track));
781
+ const previousSize = current.track?.getSettings();
782
+ if (this.cameraEncodings && sameLayout(this.cameraEncodings, next) &&
783
+ previousSize?.width === width && previousSize?.height === height) {
784
+ await current.replaceTrack({ track });
785
+ await this.applyLayerBitrates(current, next);
786
+ return;
787
+ }
788
+ await this.reproduceCamera(current, track);
789
+ }
790
+ finally {
791
+ // Pausing switched the old track off (mediasoup-client pauses that
792
+ // way), and nothing switches it back on once it is replaced. It is the
793
+ // app's again, often the camera its canvas draws from: handed back
794
+ // off, that canvas stays black after the camera is resumed.
795
+ if (paused && previous && previous !== this.cameraProducer()?.track)
796
+ previous.enabled = true;
797
+ }
798
+ });
799
+ }
800
+ /**
801
+ * Give a camera producer whose track was swapped in place the bitrates of
802
+ * its new picture. The layout matched, but a bigger picture at the same
803
+ * scales (a 1280×720 canvas for a 960×540 camera) would otherwise stay
804
+ * capped at the smaller one's. Best effort: a browser that refuses keeps
805
+ * the old caps.
806
+ */
807
+ async applyLayerBitrates(producer, encodings) {
808
+ const before = this.cameraEncodings;
809
+ if (before && before.every((encoding, i) => encoding.maxBitrate === encodings[i]?.maxBitrate)) {
810
+ this.cameraEncodings = encodings;
811
+ return;
812
+ }
813
+ const sender = producer.rtpSender;
814
+ if (!sender)
815
+ return;
816
+ try {
817
+ const parameters = sender.getParameters();
818
+ // One entry per layer, smallest first, as they were produced. A browser
819
+ // that reports them some other way is left with its own.
820
+ if (parameters.encodings.length !== encodings.length)
821
+ return;
822
+ parameters.encodings.forEach((encoding, i) => {
823
+ encoding.maxBitrate = encodings[i].maxBitrate;
824
+ });
825
+ await sender.setParameters(parameters);
826
+ this.cameraEncodings = encodings;
827
+ }
828
+ catch {
829
+ // Unsupported here; the layers keep the caps they were produced with.
830
+ }
831
+ }
832
+ /**
833
+ * What your camera video is sending now, layer by layer, or null when no
834
+ * camera is being published. The top-level fields describe the biggest
835
+ * layer going out. `qualityLimitation` says why it is less than asked:
836
+ * `cpu` is this device, `bandwidth` the upload, `none` neither (then check
837
+ * the camera and the light). Bitrates are averaged since the previous call,
838
+ * so call it every second or two; the first call reports 0.
839
+ */
840
+ async getVideoStats() {
841
+ return this.collectVideoStats(this.sendStatsBaseline);
842
+ }
843
+ /**
844
+ * What this client receives of a remote video (by its `producerId`, from
845
+ * the `track` event), or null when it is not receiving that video. Bitrates
846
+ * are averaged since the previous call for it; the first call reports 0.
847
+ */
848
+ async getReceiveStats(producerId) {
849
+ const consumer = this.consumers.get(producerId);
850
+ if (!consumer || consumer.kind !== 'video')
851
+ return null;
852
+ let report;
853
+ try {
854
+ report = await consumer.getStats();
855
+ }
856
+ catch (err) {
857
+ if (consumer.closed)
858
+ return null;
859
+ throw err;
860
+ }
861
+ const live = new Set([...this.consumers.values()].map((c) => c.id));
862
+ pruneBaseline(this.recvStatsBaseline, (key) => live.has(key));
863
+ return parseReceiveStats(report.values(), { producerId, baseline: this.recvStatsBaseline, key: consumer.id });
864
+ }
865
+ /**
866
+ * Receive a smaller copy of someone's video (by its `producerId`, from the
867
+ * `track` event), for a tile that does not need the full picture. Spatial
868
+ * layer 0 is the smallest copy the publisher sends: a computer sends three
869
+ * (at 720p 320×180, 640×360 and 1280×720), a phone two (640×360 and
870
+ * 1280×720), and a layer above the biggest means the biggest. Leave
871
+ * `temporalLayer` out to keep the full frame rate. It is the most this
872
+ * client is sent: a connection that cannot carry it still gets less. The
873
+ * choice is kept across reconnects and forgotten when the track ends.
874
+ * Rejects for a producer whose video this client is not receiving.
875
+ */
876
+ async setPreferredLayers(producerId, spatialLayer, temporalLayer) {
877
+ if (!isLayer(spatialLayer) || (temporalLayer !== undefined && !isLayer(temporalLayer))) {
878
+ throw new RangeError('setPreferredLayers: layers are whole numbers from 0');
879
+ }
880
+ const consumer = this.consumers.get(producerId);
881
+ if (!consumer || consumer.kind !== 'video') {
882
+ throw new Error(`setPreferredLayers: this client is not receiving a video with producerId ${producerId}`);
883
+ }
884
+ const layers = temporalLayer === undefined ? { spatialLayer } : { spatialLayer, temporalLayer };
885
+ this.receiveQualities.delete(producerId);
886
+ // Kept first: a reconnect that interrupts the request still applies it.
887
+ this.preferredLayers.set(producerId, layers);
888
+ await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers });
889
+ }
890
+ async collectVideoStats(baseline) {
891
+ const producer = this.cameraProducer();
892
+ if (!producer)
893
+ return null;
894
+ let report;
895
+ try {
896
+ report = await producer.getStats();
897
+ }
898
+ catch (err) {
899
+ if (producer.closed)
900
+ return null;
901
+ throw err;
902
+ }
903
+ const prefix = `${producer.id}:`;
904
+ pruneBaseline(baseline, (key) => key.startsWith(prefix));
905
+ return parseSendStats(report.values(), { quality: this.videoQuality, baseline, keyPrefix: prefix });
906
+ }
907
+ /** Friendly receive ceiling using this producer's real layer sizes; auto restores adaptation. */
908
+ async setReceiveQuality(producerId, quality) {
909
+ if (!['auto', '1080p', '720p', '480p'].includes(quality))
910
+ throw new RangeError('setReceiveQuality: unsupported quality');
911
+ const consumer = this.consumers.get(producerId);
912
+ if (!consumer || consumer.kind !== 'video')
913
+ throw new Error('setReceiveQuality: this client is not receiving that video');
914
+ let layers;
915
+ if (quality === 'auto')
916
+ layers = { spatialLayer: -1, temporalLayer: -1 };
917
+ else {
918
+ try {
919
+ layers = { spatialLayer: receiveSpatialLayer(this.remoteVideoLayers.get(producerId) ?? [], quality) };
920
+ }
921
+ catch {
922
+ throw new RpcError('quality-unavailable', 'this publisher does not describe its video layers; use Auto');
923
+ }
924
+ }
925
+ this.receiveQualities.set(producerId, quality);
926
+ this.preferredLayers.set(producerId, layers);
927
+ await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers });
928
+ }
929
+ cameraProducer() {
930
+ for (const p of this.producers.values()) {
931
+ if (p.kind === 'video' && p.appData.source === 'camera')
932
+ return p;
933
+ }
934
+ return undefined;
935
+ }
936
+ cameraTask(task) {
937
+ const run = this.cameraQueue.then(task, task);
938
+ this.cameraQueue = run.catch(() => undefined);
939
+ return run;
940
+ }
941
+ /** Swap the video in the client's own stream, never the app's. */
942
+ adoptVideoTrack(track) {
943
+ const previous = this.localStream;
944
+ if (previous && previous.getVideoTracks()[0] === track)
945
+ return;
946
+ const others = previous ? previous.getTracks().filter((t) => t.kind !== 'video') : [];
947
+ this.localStream = new MediaStream([...others, track]);
948
+ this.emit('localStreamChanged', this.localStream);
949
+ }
950
+ /** Close the camera producer and produce `track` in its place, paused on both ends if it was. */
951
+ async reproduceCamera(current, track) {
952
+ const generation = this.publishGeneration;
556
953
  const paused = current.paused;
557
954
  current.close();
558
955
  this.producers.delete(current.id);
956
+ this.cameraEncodings = null;
957
+ this.syncLimitWatch();
559
958
  await this.rpc('closeProducer', { producerId: current.id }).catch(() => undefined);
560
- const producer = await this.produceCamera(await this.ensureSendTransport(), track);
561
- if (paused) {
562
- producer.pause();
563
- await this.rpc('pauseProducer', { producerId: producer.id, paused: true });
564
- }
565
- return this.videoQuality;
959
+ return this.produceCamera(await this.ensureSendTransport(), track, generation, paused);
960
+ }
961
+ /** `options.layers` when valid, else 2 on phones and tablets and 3 elsewhere. */
962
+ layerCount() {
963
+ const asked = this.publishOptions.layers;
964
+ return asked === 1 || asked === 2 || asked === 3 ? asked : defaultLayerCount();
566
965
  }
567
- async produceCamera(transport, track) {
966
+ /**
967
+ * Produce the camera from `track`, paused on both ends when `paused`. By
968
+ * default the track says: one handed over switched off is a camera that is
969
+ * off, and so is one a pause switched off before a reconnect.
970
+ */
971
+ async produceCamera(transport, track, generation, paused = !track.enabled) {
972
+ if (generation !== this.publishGeneration)
973
+ throw new Error(unpublishedWhile('camera'));
974
+ // mediasoup-client starts a producer paused exactly when its track is
975
+ // off. Setting the track to the camera's state first makes the producer
976
+ // start in it, and a camera that is off never sends a frame of the new
977
+ // track; the server is told below.
978
+ track.enabled = !paused;
568
979
  // Simulcast from day one: it is what lets one node serve far more viewers.
569
- // The layers follow the picture's real size and the project's quality, so
570
- // the browser sends every layer asked for (see videoEncodings).
980
+ // The layers follow the picture's real size, the project's quality and
981
+ // the device, so the browser sends every layer asked for (see
982
+ // videoEncodings).
571
983
  const { width = 0, height = 0 } = track.getSettings();
572
- const producer = await transport.produce({
984
+ const quality = this.videoQuality;
985
+ this.cameraFrameRate = this.frameRateFor(track);
986
+ const encodings = videoEncodings(width, height, quality, this.layerCount(), this.cameraFrameRate);
987
+ let codecs;
988
+ try {
989
+ codecs = this.device?.sendRtpCapabilities.codecs;
990
+ }
991
+ catch {
992
+ codecs = undefined;
993
+ }
994
+ const chosen = await this.cameraCodec(codecs, encodings, width, height, quality);
995
+ const produce = (codec) => transport.produce({
573
996
  track,
574
997
  stopTracks: false,
575
- encodings: videoEncodings(width, height, this.videoQuality),
576
- codecOptions: { videoGoogleStartBitrate: 1000 },
577
- appData: { source: 'camera' },
998
+ encodings,
999
+ codecOptions: { videoGoogleStartBitrate: VIDEO_START_BITRATE_KBPS },
1000
+ ...(codec ? { codec } : {}),
1001
+ appData: { source: 'camera', videoLayers: describeVideoLayers(width, height, encodings, quality) },
578
1002
  });
579
- this.producers.set(producer.id, producer);
1003
+ let producer;
1004
+ try {
1005
+ producer = await produce(chosen.capability);
1006
+ }
1007
+ catch (err) {
1008
+ // Never fail a publish over the codec: H.264 refused is VP8 instead.
1009
+ // A closed transport (a reconnect under way) fails either way.
1010
+ if (chosen.codec !== 'h264' || transport.closed)
1011
+ throw err;
1012
+ this.reportCodecFallback(`H.264 was refused (${err.message}): publishing VP8 instead`);
1013
+ producer = await produce(findCodec(codecs, 'vp8'));
1014
+ }
1015
+ // unpublish() may have run while this was being made (a publish, a
1016
+ // quality change or a track swap under way).
1017
+ await this.keepProducer(producer, generation, 'camera');
1018
+ this.cameraEncodings = encodings;
1019
+ this.frameRateLowSamples = 0;
1020
+ this.frameRateWarmupSamples = 2;
1021
+ await this.pauseOnServer(producer);
1022
+ await this.applyDegradationPreference(producer);
1023
+ this.syncLimitWatch();
580
1024
  return producer;
581
1025
  }
1026
+ /**
1027
+ * Keep a producer just made -- unless unpublish() ran while it was being
1028
+ * made. Kept, it would publish again what was just unpublished, so it is
1029
+ * closed instead, on both ends.
1030
+ */
1031
+ async keepProducer(producer, generation, what) {
1032
+ if (generation !== this.publishGeneration) {
1033
+ producer.close();
1034
+ await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
1035
+ throw new Error(unpublishedWhile(what));
1036
+ }
1037
+ this.producers.set(producer.id, producer);
1038
+ }
1039
+ /**
1040
+ * mediasoup-client makes a producer paused when its track is off, and the
1041
+ * server makes every producer live. Unless the server is told, viewers get
1042
+ * black video or silence from a camera or microphone they are told is on.
1043
+ */
1044
+ async pauseOnServer(producer) {
1045
+ if (producer.paused)
1046
+ await this.rpc('pauseProducer', { producerId: producer.id, paused: true });
1047
+ }
1048
+ /**
1049
+ * H.264 or VP8 for the camera (see chooseCodec), with the device's own
1050
+ * capability entry for it: produce() must only ever get one of those.
1051
+ */
1052
+ async cameraCodec(codecs, encodings, width, height, quality) {
1053
+ const requested = normalizeCodec(this.publishOptions.codec);
1054
+ const env = currentEnv();
1055
+ const phoneOrTablet = isPhoneOrTablet(env);
1056
+ const h264 = findCodec(codecs, 'h264');
1057
+ let h264Hardware = null;
1058
+ if (requested === 'auto' && phoneOrTablet && h264) {
1059
+ const top = topLayerSize(width, height, quality);
1060
+ h264Hardware = await h264HardwareEncode({ width: top.width, height: top.height, bitrate: encodings[encodings.length - 1].maxBitrate });
1061
+ }
1062
+ const choice = chooseCodec({ requested, phoneOrTablet, appleMobile: isAppleMobile(env), h264Available: Boolean(h264), h264Hardware });
1063
+ if (choice.warning)
1064
+ this.reportCodecFallback(choice.warning);
1065
+ else if (requested === 'auto' && phoneOrTablet && !h264)
1066
+ this.reportCodecFallback('this browser cannot send H.264 to this server: publishing VP8');
1067
+ if (choice.codec === 'h264' && h264)
1068
+ return { codec: 'h264', capability: h264 };
1069
+ return { codec: 'vp8', capability: findCodec(codecs, 'vp8') };
1070
+ }
1071
+ /** A forced H.264 that could not be used is the app's to hear about; 'auto' just notes it, once. */
1072
+ reportCodecFallback(message) {
1073
+ if (normalizeCodec(this.publishOptions.codec) === 'h264') {
1074
+ this.emit('error', new Error(message));
1075
+ }
1076
+ else if (!this.codecFallbackLogged) {
1077
+ this.codecFallbackLogged = true;
1078
+ console.warn(`[voltkit-rtc] ${message}`);
1079
+ }
1080
+ }
1081
+ /** Best effort: a browser that ignores or refuses it keeps its own default. */
1082
+ async applyDegradationPreference(producer) {
1083
+ const preference = this.publishOptions.degradationPreference;
1084
+ const sender = producer.rtpSender;
1085
+ if (!sender)
1086
+ return;
1087
+ if (preference !== 'maintain-resolution' && preference !== 'maintain-framerate' && preference !== 'balanced')
1088
+ return;
1089
+ try {
1090
+ const parameters = sender.getParameters();
1091
+ parameters.degradationPreference = preference;
1092
+ await sender.setParameters(parameters);
1093
+ }
1094
+ catch {
1095
+ // Unsupported here; the encoder keeps the browser's own trade-off.
1096
+ }
1097
+ }
1098
+ /** videoLimited sampling runs while a camera is published and someone listens. */
1099
+ syncLimitWatch() {
1100
+ this.limitWatch.set(!this.closedByUs && this.cameraProducer() !== undefined && (this.listenerCount('videoLimited') > 0 || this.cameraFrameRate === 60));
1101
+ }
582
1102
  /**
583
1103
  * Re-establish this client's broadcast after a reconnect.
584
1104
  *
@@ -587,10 +1107,22 @@ export class StreamClient {
587
1107
  * when the tracks have genuinely ended.
588
1108
  */
589
1109
  async republish() {
1110
+ // unpublish() can run while this is under way, and wins: nothing it
1111
+ // stopped is published again.
1112
+ const generation = this.publishGeneration;
590
1113
  // Music is not a device and cannot be "gone": publish it again whatever
591
1114
  // happens to the camera and microphone below.
1115
+ const screen = this.screenStream;
1116
+ if (screen)
1117
+ await this.restoreScreen().catch(async (err) => {
1118
+ if (this.screenStream === screen)
1119
+ await this.stopScreen('stopped');
1120
+ this.emit('error', err);
1121
+ });
592
1122
  if (this.music)
593
- await this.produceMusic(this.music).catch((err) => this.emit('error', err));
1123
+ await this.produceMusic(this.music, generation).catch((err) => this.emit('error', err));
1124
+ if (generation !== this.publishGeneration)
1125
+ return;
594
1126
  let stream = this.localStream;
595
1127
  if (!stream && !this.publishConstraints)
596
1128
  return; // music was all that was being published
@@ -600,7 +1132,20 @@ export class StreamClient {
600
1132
  this.emit('error', new Error('cannot restore the broadcast: the devices are gone'));
601
1133
  return;
602
1134
  }
1135
+ const ended = stream;
603
1136
  stream = await navigator.mediaDevices.getUserMedia(this.publishConstraints);
1137
+ if (generation !== this.publishGeneration) {
1138
+ for (const track of stream.getTracks())
1139
+ track.stop();
1140
+ return;
1141
+ }
1142
+ // New devices come back switched on. A camera or microphone the user
1143
+ // had switched off stays off: a dropped connection is no reason to
1144
+ // switch it back on.
1145
+ for (const track of stream.getTracks()) {
1146
+ if (ended?.getTracks().some((t) => t.kind === track.kind && !t.enabled))
1147
+ track.enabled = false;
1148
+ }
604
1149
  this.localStream = stream;
605
1150
  this.emit('localStreamChanged', stream);
606
1151
  }
@@ -608,6 +1153,170 @@ export class StreamClient {
608
1153
  return;
609
1154
  await this.publish(stream);
610
1155
  }
1156
+ // ------------------------------------------------------------- screen sharing
1157
+ /** Publish a screen beside the camera. Call from a click so capture permission can open. */
1158
+ async publishScreen(options = {}) {
1159
+ if (this.joinResult?.role !== 'host' && this.joinResult?.role !== 'cohost')
1160
+ throw new RpcError('forbidden', 'only hosts and cohosts can share a screen');
1161
+ if (this.screenStream || this.screenPending)
1162
+ throw new RpcError('screen-share-active', 'a screen share is already active');
1163
+ if (options.mode !== undefined && options.mode !== 'detail' && options.mode !== 'motion')
1164
+ throw new TypeError('publishScreen: mode must be detail or motion');
1165
+ const mode = options.mode ?? 'detail';
1166
+ if (!options.stream && (typeof navigator === 'undefined' || !navigator.mediaDevices?.getDisplayMedia)) {
1167
+ throw new RpcError('screen-share-unsupported', 'screen capture is not supported by this browser; use a supported desktop browser');
1168
+ }
1169
+ const generation = this.screenGeneration;
1170
+ this.screenPending = true;
1171
+ let stream;
1172
+ try {
1173
+ stream = options.stream ?? await navigator.mediaDevices.getDisplayMedia({
1174
+ video: { width: { ideal: 1920, max: 1920 }, height: { ideal: 1080, max: 1080 }, frameRate: { ideal: mode === 'motion' ? 30 : 15, max: mode === 'motion' ? 30 : 15 } },
1175
+ audio: options.audio ?? false,
1176
+ });
1177
+ const video = stream.getVideoTracks()[0];
1178
+ if (!video || video.readyState !== 'live')
1179
+ throw new RpcError('screen-share-no-video', 'screen capture did not provide a live video track');
1180
+ if (generation !== this.screenGeneration || this.closedByUs)
1181
+ throw new Error(unpublishedWhile('screen'));
1182
+ this.screenStream = stream;
1183
+ this.screenMode = mode;
1184
+ this.screenAudioEnabled = options.audio !== false;
1185
+ video.contentHint = mode;
1186
+ const onEnded = () => { void this.stopScreen('track-ended').catch((err) => this.emit('error', err)); };
1187
+ this.screenEndedListener = onEnded;
1188
+ video.addEventListener('ended', onEnded, { once: true });
1189
+ await this.restoreScreen();
1190
+ if (generation !== this.screenGeneration || video.readyState !== 'live')
1191
+ throw new Error(unpublishedWhile('screen'));
1192
+ this.screenStarted = true;
1193
+ this.wasPublishing = true;
1194
+ this.emit('screenShareStarted', { stream, mode });
1195
+ return stream;
1196
+ }
1197
+ catch (error) {
1198
+ if (generation === this.screenGeneration)
1199
+ await this.stopScreen('stopped');
1200
+ stream?.getTracks().forEach((track) => track.stop());
1201
+ throw error;
1202
+ }
1203
+ finally {
1204
+ if (generation === this.screenGeneration)
1205
+ this.screenPending = false;
1206
+ }
1207
+ }
1208
+ /** Stop only the screen and its tab/system audio; keep camera, microphone and music. */
1209
+ async unpublishScreen() {
1210
+ await this.stopScreen('stopped');
1211
+ }
1212
+ releaseScreen() {
1213
+ this.screenGeneration += 1;
1214
+ this.screenPending = false;
1215
+ const stream = this.screenStream;
1216
+ if (this.screenEndedListener)
1217
+ stream?.getVideoTracks()[0]?.removeEventListener('ended', this.screenEndedListener);
1218
+ this.screenEndedListener = null;
1219
+ this.screenStream = null;
1220
+ this.screenStarted = false;
1221
+ stream?.getTracks().forEach((track) => track.stop());
1222
+ }
1223
+ async stopScreen(reason) {
1224
+ const started = this.screenStarted;
1225
+ const ids = [...this.screenProducerIds];
1226
+ this.screenProducerIds.clear();
1227
+ this.releaseScreen();
1228
+ for (const id of ids) {
1229
+ this.producers.get(id)?.close();
1230
+ this.producers.delete(id);
1231
+ }
1232
+ if (!this.localStream && !this.music)
1233
+ this.wasPublishing = false;
1234
+ if (started && !this.closedByUs)
1235
+ this.emit('screenShareEnded', { reason });
1236
+ await Promise.all(ids.map((producerId) => this.rpc('closeProducer', { producerId }).catch(() => undefined)));
1237
+ }
1238
+ async restoreScreen() {
1239
+ const stream = this.screenStream;
1240
+ const video = stream?.getVideoTracks()[0];
1241
+ if (!stream || !video)
1242
+ return;
1243
+ if (video.readyState !== 'live') {
1244
+ await this.stopScreen('track-ended');
1245
+ return;
1246
+ }
1247
+ const generation = this.screenGeneration;
1248
+ const publishing = this.publishGeneration;
1249
+ const transport = await this.ensureSendTransport();
1250
+ if (generation !== this.screenGeneration)
1251
+ return;
1252
+ const { width = 0, height = 0 } = video.getSettings();
1253
+ const encodings = screenEncodings(width, height, this.screenMode);
1254
+ // A project's explicit safety restriction also applies to screen video.
1255
+ const cap = topLayerSize(width, height, this.videoPolicy.maxQuality);
1256
+ const screenScale = encodings.at(-1).scaleResolutionDownBy;
1257
+ if (cap.scale > screenScale)
1258
+ encodings.forEach((e) => { e.scaleResolutionDownBy *= cap.scale / screenScale; });
1259
+ const keep = async (producer) => {
1260
+ if (generation !== this.screenGeneration || publishing !== this.publishGeneration || video.readyState !== 'live') {
1261
+ producer.close();
1262
+ await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
1263
+ throw new Error(unpublishedWhile('screen'));
1264
+ }
1265
+ this.producers.set(producer.id, producer);
1266
+ this.screenProducerIds.add(producer.id);
1267
+ await this.pauseOnServer(producer);
1268
+ };
1269
+ await keep(await transport.produce({
1270
+ track: video, stopTracks: false, encodings,
1271
+ codecOptions: { videoGoogleStartBitrate: VIDEO_START_BITRATE_KBPS },
1272
+ appData: { source: 'screen', videoLayers: describeVideoLayers(width, height, encodings) },
1273
+ }));
1274
+ const audio = stream.getAudioTracks().find((track) => track.readyState === 'live');
1275
+ if (audio && this.screenAudioEnabled && generation === this.screenGeneration)
1276
+ await keep(await transport.produce({
1277
+ track: audio, stopTracks: false,
1278
+ codecOptions: { opusStereo: true, opusDtx: false, opusFec: true, opusMaxAverageBitrate: 128000 },
1279
+ appData: { source: 'screen-audio' },
1280
+ }));
1281
+ }
1282
+ frameRateFor(track) {
1283
+ if (this.publishOptions.frameRate !== 60 || this.frameRateFallback)
1284
+ return 30;
1285
+ const captured = track.getSettings().frameRate;
1286
+ if (this.videoPolicy.maxFrameRate === 30 || (typeof captured === 'number' && captured < 50)) {
1287
+ this.frameRateFallback = true;
1288
+ this.emit('error', new RpcError('frame-rate-fallback', '60 fps is unavailable from this camera or project; publishing at 30 fps'));
1289
+ return 30;
1290
+ }
1291
+ return 60;
1292
+ }
1293
+ async sampleCamera(baseline) {
1294
+ const stats = await this.collectVideoStats(baseline);
1295
+ if (this.cameraFrameRate !== 60 || this.frameRateFallback || !stats)
1296
+ return stats;
1297
+ if (this.cameraProducer()?.paused)
1298
+ return stats;
1299
+ // The lower simulcast copies intentionally run at 30 fps. During the
1300
+ // initial bandwidth ramp they may be the only active copies, which does
1301
+ // not establish that the camera cannot sustain 60. Match native warm-up:
1302
+ // two 2-second samples, then require three consecutive limited samples.
1303
+ if (this.frameRateWarmupSamples-- > 0)
1304
+ return stats;
1305
+ const limited = stats.layers.some((layer) => layer.active && (layer.qualityLimitation === 'cpu' || layer.qualityLimitation === 'bandwidth'));
1306
+ this.frameRateLowSamples = limited && stats.fps > 0 && stats.fps < 50 ? this.frameRateLowSamples + 1 : 0;
1307
+ if (this.frameRateLowSamples >= 3) {
1308
+ this.frameRateFallback = true;
1309
+ void this.cameraTask(async () => {
1310
+ const producer = this.cameraProducer();
1311
+ if (!producer?.track || producer.closed)
1312
+ return;
1313
+ await producer.track.applyConstraints?.({ frameRate: { ideal: 30, max: 30 } }).catch(() => undefined);
1314
+ await this.reproduceCamera(producer, producer.track);
1315
+ this.emit('error', new RpcError('frame-rate-fallback', 'this device or upload cannot sustain 60 fps; publishing at 30 fps'));
1316
+ }).catch((error) => this.emit('error', error));
1317
+ }
1318
+ return stats;
1319
+ }
611
1320
  // ------------------------------------------------------------------ music
612
1321
  /**
613
1322
  * Publish music playing inside the page, as its own track.
@@ -620,11 +1329,17 @@ export class StreamClient {
620
1329
  * the user has not interacted with.
621
1330
  */
622
1331
  async publishMusic(input, options) {
1332
+ const generation = this.publishGeneration;
623
1333
  if (this.music)
624
1334
  await this.stopMusic();
625
1335
  const music = new MusicSource(input, options);
626
1336
  try {
627
- await this.produceMusic(music);
1337
+ await this.produceMusic(music, generation);
1338
+ // Checked again here: an unpublish() after the producer was kept has
1339
+ // already closed it, and the music must not be remembered for a
1340
+ // reconnect to publish again.
1341
+ if (generation !== this.publishGeneration)
1342
+ throw new Error(unpublishedWhile('music'));
628
1343
  }
629
1344
  catch (err) {
630
1345
  music.close();
@@ -634,8 +1349,10 @@ export class StreamClient {
634
1349
  this.wasPublishing = true;
635
1350
  this.emitLocalMedia();
636
1351
  }
637
- async produceMusic(music) {
1352
+ async produceMusic(music, generation) {
638
1353
  const transport = await this.ensureSendTransport();
1354
+ if (generation !== this.publishGeneration)
1355
+ throw new Error(unpublishedWhile('music'));
639
1356
  const producer = await transport.produce({
640
1357
  track: music.track,
641
1358
  stopTracks: false,
@@ -644,8 +1361,10 @@ export class StreamClient {
644
1361
  codecOptions: { opusStereo: true, opusDtx: false, opusFec: true, opusMaxAverageBitrate: 128_000 },
645
1362
  appData: { source: 'music' },
646
1363
  });
647
- this.producers.set(producer.id, producer);
1364
+ await this.keepProducer(producer, generation, 'music');
648
1365
  this.musicProducerId = producer.id;
1366
+ // Music switched off before a reconnect comes back paused here.
1367
+ await this.pauseOnServer(producer);
649
1368
  }
650
1369
  /** True while music is published, whether or not it is switched on. */
651
1370
  get musicPublishing() {
@@ -708,6 +1427,24 @@ export class StreamClient {
708
1427
  }));
709
1428
  const consumer = await transport.consume(params);
710
1429
  this.consumers.set(remote.producerId, consumer);
1430
+ this.remoteVideoLayers.set(remote.producerId, remote.videoLayers ?? remote.appData?.videoLayers ?? params.videoLayers ?? []);
1431
+ // Layers asked for before a reconnect: the new consumer gets them before
1432
+ // its first frame instead of starting at the full picture. Not getting
1433
+ // them costs data, not the track, so a refusal is only reported.
1434
+ let layers = this.preferredLayers.get(remote.producerId);
1435
+ const receiveQuality = this.receiveQualities.get(remote.producerId);
1436
+ if (receiveQuality && receiveQuality !== 'auto') {
1437
+ try {
1438
+ layers = { spatialLayer: receiveSpatialLayer(this.remoteVideoLayers.get(remote.producerId) ?? [], receiveQuality) };
1439
+ }
1440
+ catch {
1441
+ layers = undefined;
1442
+ this.emit('error', new RpcError('quality-unavailable', 'this publisher no longer describes its video layers; using Auto'));
1443
+ }
1444
+ }
1445
+ if (layers) {
1446
+ await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers }).catch((err) => this.emit('error', err));
1447
+ }
711
1448
  // The server created it paused. Resume only now that our end exists,
712
1449
  // otherwise the first frames arrive before there is anything to decode them.
713
1450
  await this.rpc('resumeConsumer', { consumerId: consumer.id });
@@ -736,7 +1473,7 @@ export class StreamClient {
736
1473
  }
737
1474
  trackEnabled(kind) {
738
1475
  for (const p of this.producers.values()) {
739
- if (p.kind === kind && p.id !== this.musicProducerId)
1476
+ if (p.kind === kind && p.appData.source === defaultSource(kind))
740
1477
  return !p.paused;
741
1478
  }
742
1479
  return false;
@@ -752,43 +1489,76 @@ export class StreamClient {
752
1489
  async setCamEnabled(enabled) {
753
1490
  await this.setKindEnabled('video', enabled);
754
1491
  }
1492
+ applyLocalModeration(kind) {
1493
+ for (const producer of this.producers.values()) {
1494
+ if (!kind || producer.kind === kind)
1495
+ producer.pause();
1496
+ }
1497
+ // During reconnect there are no producers, but the retained tracks still
1498
+ // capture. Keep their muted state for republish as well.
1499
+ for (const stream of [this.localStream, this.screenStream]) {
1500
+ for (const track of stream?.getTracks() ?? [])
1501
+ if (!kind || track.kind === kind)
1502
+ track.enabled = false;
1503
+ }
1504
+ if (!kind || kind === 'audio')
1505
+ this.music?.setEnabled(false);
1506
+ }
755
1507
  async setKindEnabled(kind, enabled) {
756
1508
  for (const producer of this.producers.values()) {
757
1509
  // The music has its own switch. Muting the microphone must not stop the song.
758
- if (producer.kind !== kind || producer.id === this.musicProducerId)
1510
+ if (producer.kind !== kind || producer.appData.source !== defaultSource(kind))
759
1511
  continue;
760
- if (enabled)
761
- producer.resume();
762
- else
1512
+ if (!enabled)
763
1513
  producer.pause();
764
1514
  await this.rpc('pauseProducer', { producerId: producer.id, paused: !enabled });
1515
+ if (enabled)
1516
+ producer.resume();
765
1517
  }
766
1518
  this.emitLocalMedia();
767
1519
  }
768
1520
  /** Stop publishing entirely and release the local devices. */
769
1521
  async unpublish() {
1522
+ // All of it takes effect now; only telling the server is waited for. So
1523
+ // publish() and unpublish() act in the order they are called: a publish
1524
+ // called while this still waits is not undone by it, and one already
1525
+ // under way stops at its next step (keepProducer).
1526
+ this.publishGeneration += 1;
1527
+ const screenWasStarted = this.screenStarted;
1528
+ this.releaseScreen();
1529
+ if (screenWasStarted)
1530
+ this.emit('screenShareEnded', { reason: 'stopped' });
770
1531
  const ids = [...this.producers.keys()];
771
1532
  for (const producer of this.producers.values())
772
1533
  producer.close();
773
1534
  this.producers.clear();
774
- for (const id of ids) {
775
- await this.rpc('closeProducer', { producerId: id }).catch(() => undefined);
776
- }
1535
+ this.cameraEncodings = null;
1536
+ // Start over for the next publish, and clear a limitation still showing:
1537
+ // nothing is being held back once nothing is being sent.
1538
+ const cleared = this.limitWatch.reset();
1539
+ if (cleared)
1540
+ this.emit('videoLimited', cleared);
777
1541
  this.localStream?.getTracks().forEach((t) => t.stop());
778
1542
  this.localStream = null;
1543
+ // Forgotten with the stream: kept, a reconnect would ask for the camera
1544
+ // and microphone again and publish them, after the app unpublished them.
1545
+ this.publishConstraints = null;
779
1546
  this.music?.close();
780
1547
  this.music = null;
781
1548
  this.musicProducerId = null;
782
1549
  this.wasPublishing = false;
783
1550
  this.emit('localStreamChanged', null);
784
1551
  this.emit('localMediaChanged', { micEnabled: false, camEnabled: false, musicEnabled: false });
1552
+ for (const id of ids) {
1553
+ await this.rpc('closeProducer', { producerId: id }).catch(() => undefined);
1554
+ }
785
1555
  }
786
1556
  get local() {
787
1557
  return this.localStream;
788
1558
  }
789
1559
  // ------------------------------------------------------------- moderation
790
- raiseHand() { return this.rpc('raiseHand'); }
791
- lowerHand() { return this.rpc('lowerHand'); }
1560
+ raiseHand(metadata) { return this.rpc('raiseHand', metadata ? { metadata } : {}); }
1561
+ lowerHand(identity) { return this.rpc('lowerHand', identity ? { identity } : {}); }
792
1562
  muteParticipant(identity) { return this.rpc('requestMute', { identity }); }
793
1563
  /**
794
1564
  * Moderating roles: switch someone's microphone (`audio`), camera (`video`)