@voltkit-rtc/client 0.2.0 → 0.4.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 (48) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/README.md +147 -3
  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 +184 -1
  12. package/dist/cjs/index.d.ts.map +1 -1
  13. package/dist/cjs/index.js +546 -32
  14. package/dist/cjs/index.js.map +1 -1
  15. package/dist/cjs/quality.d.ts +74 -0
  16. package/dist/cjs/quality.d.ts.map +1 -0
  17. package/dist/cjs/quality.js +115 -0
  18. package/dist/cjs/quality.js.map +1 -0
  19. package/dist/cjs/stats.d.ts +151 -0
  20. package/dist/cjs/stats.d.ts.map +1 -0
  21. package/dist/cjs/stats.js +243 -0
  22. package/dist/cjs/stats.js.map +1 -0
  23. package/dist/esm/chat.d.ts +2 -0
  24. package/dist/esm/chat.d.ts.map +1 -1
  25. package/dist/esm/chat.js +23 -4
  26. package/dist/esm/chat.js.map +1 -1
  27. package/dist/esm/device.d.ts +85 -0
  28. package/dist/esm/device.d.ts.map +1 -0
  29. package/dist/esm/device.js +122 -0
  30. package/dist/esm/device.js.map +1 -0
  31. package/dist/esm/index.d.ts +184 -1
  32. package/dist/esm/index.d.ts.map +1 -1
  33. package/dist/esm/index.js +541 -31
  34. package/dist/esm/index.js.map +1 -1
  35. package/dist/esm/quality.d.ts +74 -0
  36. package/dist/esm/quality.d.ts.map +1 -0
  37. package/dist/esm/quality.js +107 -0
  38. package/dist/esm/quality.js.map +1 -0
  39. package/dist/esm/stats.d.ts +151 -0
  40. package/dist/esm/stats.d.ts.map +1 -0
  41. package/dist/esm/stats.js +232 -0
  42. package/dist/esm/stats.js.map +1 -0
  43. package/package.json +3 -2
  44. package/src/chat.ts +21 -4
  45. package/src/device.ts +168 -0
  46. package/src/index.ts +627 -29
  47. package/src/quality.ts +126 -0
  48. package/src/stats.ts +328 -0
package/dist/cjs/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.StreamClient = exports.RpcError = exports.ChatRpcError = exports.ChatClient = exports.MusicSource = exports.AudioMixer = exports.whipOffer = exports.whipEnd = exports.WhipError = exports.BroadcastPublisher = void 0;
3
+ exports.StreamClient = exports.RpcError = exports.VIDEO_QUALITIES = exports.videoEncodings = exports.layerBitrate = exports.allowedQuality = exports.ChatRpcError = exports.ChatClient = exports.MusicSource = exports.AudioMixer = exports.whipOffer = exports.whipEnd = exports.WhipError = exports.BroadcastPublisher = void 0;
4
4
  /**
5
5
  * Thin client SDK over the orchestrator's signaling protocol.
6
6
  *
@@ -9,7 +9,10 @@ exports.StreamClient = exports.RpcError = exports.ChatRpcError = exports.ChatCli
9
9
  * setup, consumer resume ordering and reconnection, each slightly wrong.
10
10
  */
11
11
  const mediasoup_client_1 = require("mediasoup-client");
12
+ const device_js_1 = require("./device.js");
12
13
  const music_js_1 = require("./music.js");
14
+ const quality_js_1 = require("./quality.js");
15
+ const stats_js_1 = require("./stats.js");
13
16
  var broadcast_js_1 = require("./broadcast.js");
14
17
  Object.defineProperty(exports, "BroadcastPublisher", { enumerable: true, get: function () { return broadcast_js_1.BroadcastPublisher; } });
15
18
  Object.defineProperty(exports, "WhipError", { enumerable: true, get: function () { return broadcast_js_1.WhipError; } });
@@ -21,6 +24,11 @@ Object.defineProperty(exports, "MusicSource", { enumerable: true, get: function
21
24
  var chat_js_1 = require("./chat.js");
22
25
  Object.defineProperty(exports, "ChatClient", { enumerable: true, get: function () { return chat_js_1.ChatClient; } });
23
26
  Object.defineProperty(exports, "ChatRpcError", { enumerable: true, get: function () { return chat_js_1.ChatRpcError; } });
27
+ var quality_js_2 = require("./quality.js");
28
+ Object.defineProperty(exports, "allowedQuality", { enumerable: true, get: function () { return quality_js_2.allowedQuality; } });
29
+ Object.defineProperty(exports, "layerBitrate", { enumerable: true, get: function () { return quality_js_2.layerBitrate; } });
30
+ Object.defineProperty(exports, "videoEncodings", { enumerable: true, get: function () { return quality_js_2.videoEncodings; } });
31
+ Object.defineProperty(exports, "VIDEO_QUALITIES", { enumerable: true, get: function () { return quality_js_2.VIDEO_QUALITIES; } });
24
32
  const defaultSource = (kind) => (kind === 'audio' ? 'mic' : 'camera');
25
33
  /** An error returned by the server, carrying its machine-readable code. */
26
34
  class RpcError extends Error {
@@ -58,6 +66,14 @@ const CLOSE_REPLACED = 4001;
58
66
  /** The server ended the session on purpose and said why first: do not reconnect. */
59
67
  const CLOSE_ENDED = 4003;
60
68
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
69
+ const isLayer = (value) => Number.isInteger(value) && value >= 0;
70
+ /**
71
+ * Where a new camera stream's bandwidth estimate starts, in kbps. Chrome
72
+ * otherwise starts low and takes many seconds to climb to the top layer.
73
+ */
74
+ const VIDEO_START_BITRATE_KBPS = 1500;
75
+ /** Why a publish gave up: unpublish() ran while it was under way, and unpublish wins. */
76
+ const unpublishedWhile = (what) => `the ${what} was unpublished while it was being published`;
61
77
  class StreamClient {
62
78
  wsUrl;
63
79
  ws = null;
@@ -84,14 +100,37 @@ class StreamClient {
84
100
  // disconnected: the room looks healthy and shows nothing.
85
101
  wasPublishing = false;
86
102
  publishConstraints = null;
103
+ publishOptions = {};
87
104
  /** In-app music being published, kept across reconnects so it can be published again. */
88
105
  music = null;
89
106
  musicProducerId = null;
90
107
  /** What each remote producer carries, so a pause can say "music" rather than just "audio". */
91
108
  remoteSources = new Map();
109
+ /**
110
+ * The layers asked for each remote video, by producerId. A reconnect
111
+ * consumes everything again, and the new consumer would otherwise start at
112
+ * the full picture.
113
+ */
114
+ preferredLayers = new Map();
92
115
  closedByUs = false;
93
116
  reconnecting = false;
94
117
  reconnectAttempt = 0;
118
+ /** Set once a connect() has joined and set up. Only such a session is reconnected. */
119
+ joinedOnce = false;
120
+ /** The camera producer's layers, to tell whether a new track fits them. */
121
+ cameraEncodings = null;
122
+ // Changes to the camera producer run one at a time. Two overlapping swaps
123
+ // (an effect toggled on and off quickly) would otherwise both close the
124
+ // same producer and both produce a new one.
125
+ cameraQueue = Promise.resolve();
126
+ // Bumped by unpublish(). A producer still being made when it runs (camera,
127
+ // microphone or music) belongs to the publish it ended: it is closed, not
128
+ // kept.
129
+ publishGeneration = 0;
130
+ codecFallbackLogged = false;
131
+ sendStatsBaseline = new Map();
132
+ recvStatsBaseline = new Map();
133
+ limitWatch = new stats_js_1.VideoLimitWatch((baseline) => this.collectVideoStats(baseline), (event) => this.emit('videoLimited', event));
95
134
  constructor(wsUrl, token) {
96
135
  this.wsUrl = wsUrl;
97
136
  this.token = token;
@@ -103,7 +142,17 @@ class StreamClient {
103
142
  this.listeners.set(event, set);
104
143
  }
105
144
  set.add(fn);
106
- return () => set.delete(fn);
145
+ // videoLimited costs a getStats every 2 s, so it runs only while heard.
146
+ if (event === 'videoLimited')
147
+ this.syncLimitWatch();
148
+ return () => {
149
+ set.delete(fn);
150
+ if (event === 'videoLimited')
151
+ this.syncLimitWatch();
152
+ };
153
+ }
154
+ listenerCount(event) {
155
+ return this.listeners.get(event)?.size ?? 0;
107
156
  }
108
157
  emit(event, ...args) {
109
158
  for (const fn of this.listeners.get(event) ?? []) {
@@ -117,12 +166,36 @@ class StreamClient {
117
166
  }
118
167
  // ------------------------------------------------------------- connection
119
168
  async connect() {
120
- await this.openSocket();
121
- const result = (await this.rpc('join', { token: this.token }));
122
- this.joinResult = result;
123
- this.device = new mediasoup_client_1.Device();
124
- await this.device.load({ routerRtpCapabilities: result.routerRtpCapabilities });
169
+ let result;
170
+ try {
171
+ await this.openSocket();
172
+ result = (await this.rpc('join', { token: this.token }));
173
+ this.joinResult = result;
174
+ this.device = this.createDevice();
175
+ await this.device.load({ routerRtpCapabilities: result.routerRtpCapabilities });
176
+ }
177
+ catch (err) {
178
+ // A session that never got going: the app has the error. Left open,
179
+ // the socket would be closed by the server's join deadline instead, and
180
+ // that close would start reconnecting a session that never began.
181
+ if (!this.joinedOnce)
182
+ this.detachSocket();
183
+ throw err;
184
+ }
185
+ // Joined and set up: the next drop gets every reconnect attempt again.
186
+ // Not when the socket opens -- a join refused with a code worth retrying
187
+ // (no-capacity) never used them up, and every client of a full cluster
188
+ // retried about twice a second, forever.
189
+ this.reconnectAttempt = 0;
190
+ this.joinedOnce = true;
125
191
  this.emit('joined', result);
192
+ // A video that ended while this client was away sends it no
193
+ // producerClosed: forget the layers asked for it.
194
+ const present = new Set(result.producers.map((p) => p.producerId));
195
+ for (const producerId of this.preferredLayers.keys()) {
196
+ if (!present.has(producerId))
197
+ this.preferredLayers.delete(producerId);
198
+ }
126
199
  // Subscribe to everything already publishing before we announce readiness.
127
200
  for (const p of result.producers) {
128
201
  await this.consume(p).catch((err) => this.emit('error', err));
@@ -135,6 +208,10 @@ class StreamClient {
135
208
  }
136
209
  return result;
137
210
  }
211
+ /** A new mediasoup Device for each join. Its own method so tests, which have no WebRTC, can stand one in. */
212
+ createDevice() {
213
+ return new mediasoup_client_1.Device();
214
+ }
138
215
  openSocket() {
139
216
  // Abandon any previous socket first. Its close handler checks identity
140
217
  // against `this.ws`, so once it is detached it can no longer trigger a
@@ -146,7 +223,6 @@ class StreamClient {
146
223
  const ws = new WebSocket(url.toString());
147
224
  this.ws = ws;
148
225
  const onOpen = () => {
149
- this.reconnectAttempt = 0;
150
226
  this.emit('connected');
151
227
  resolve();
152
228
  };
@@ -172,6 +248,9 @@ class StreamClient {
172
248
  // displace it, and it would displace us right back, forever.
173
249
  this.emit('error', new Error('this identity connected from somewhere else'));
174
250
  this.closedByUs = true;
251
+ // The producers outlive this close until the app calls close(); the
252
+ // videoLimited sampling must not, or it reports after `closed`.
253
+ this.limitWatch.reset();
175
254
  this.emit('closed', 'replaced');
176
255
  return;
177
256
  }
@@ -179,9 +258,14 @@ class StreamClient {
179
258
  // Normally roomClosed has already ended the session; this covers a
180
259
  // close that arrived without it.
181
260
  this.closedByUs = true;
261
+ this.limitWatch.reset();
182
262
  this.emit('closed', 'room-closed');
183
263
  return;
184
264
  }
265
+ // Before a first successful join the app is still waiting on
266
+ // connect(), which reports the failure; there is nothing to restore.
267
+ if (!this.joinedOnce)
268
+ return;
185
269
  void this.scheduleReconnect();
186
270
  });
187
271
  });
@@ -225,8 +309,13 @@ class StreamClient {
225
309
  return;
226
310
  try {
227
311
  this.teardownMedia();
228
- await this.connect();
229
- return; // connect() resets the attempt counter once the socket opens
312
+ await this.connect(); // which resets the attempt counter once joined
313
+ // The socket can drop again while the session is being restored,
314
+ // and its close handler then finds this loop still running and
315
+ // leaves it to us. Returning would leave the client disconnected
316
+ // for good, with no `closed`.
317
+ if (this.ws)
318
+ return;
230
319
  }
231
320
  catch (err) {
232
321
  this.emit('error', err);
@@ -252,6 +341,9 @@ class StreamClient {
252
341
  this.closedByUs = true;
253
342
  this.wasPublishing = false;
254
343
  this.teardownMedia();
344
+ this.preferredLayers.clear();
345
+ // Nothing is emitted after closing, so not even a clearing videoLimited.
346
+ this.limitWatch.reset();
255
347
  // Producers no longer stop their tracks, so releasing the camera and
256
348
  // microphone is ours to do -- otherwise the device light stays on.
257
349
  this.localStream?.getTracks().forEach((t) => t.stop());
@@ -270,6 +362,10 @@ class StreamClient {
270
362
  p.close();
271
363
  this.producers.clear();
272
364
  this.musicProducerId = null;
365
+ this.cameraEncodings = null;
366
+ // Paused, not reset: a reconnect's republish starts it again, and a
367
+ // limitation already reported is not reported twice.
368
+ this.syncLimitWatch();
273
369
  this.sendTransport?.close();
274
370
  this.recvTransport?.close();
275
371
  this.sendTransport = null;
@@ -338,6 +434,7 @@ class StreamClient {
338
434
  break;
339
435
  case 'producerClosed': {
340
436
  const { producerId, identity } = data;
437
+ this.preferredLayers.delete(producerId);
341
438
  const consumer = this.consumers.get(producerId);
342
439
  if (!consumer)
343
440
  break; // never consumed it, or already cleaned up
@@ -514,13 +611,26 @@ class StreamClient {
514
611
  *
515
612
  * Pass the constraints used to obtain the stream and they will be reused to
516
613
  * reacquire the devices if a reconnect finds the tracks have ended.
614
+ *
615
+ * `options.quality` picks 1080p, 720p or 480p (see `videoPolicy` for what
616
+ * the project allows). Capture at least that size for the full picture.
617
+ * `options.layers`, `codec` and `degradationPreference` tune how the video
618
+ * is encoded; the defaults suit both phones and computers. The options are
619
+ * kept for a reconnect's republish.
517
620
  */
518
- async publish(stream, constraints) {
621
+ async publish(stream, constraints, options) {
622
+ const generation = this.publishGeneration;
519
623
  const transport = await this.ensureSendTransport();
624
+ // unpublish() ran first: this publish is over before it began, and must
625
+ // leave nothing behind that a reconnect would publish again.
626
+ if (generation !== this.publishGeneration)
627
+ throw new Error(unpublishedWhile('stream'));
520
628
  this.localStream = stream;
521
629
  this.wasPublishing = true;
522
630
  if (constraints)
523
631
  this.publishConstraints = constraints;
632
+ if (options)
633
+ this.publishOptions = { ...options };
524
634
  const audio = stream.getAudioTracks()[0];
525
635
  if (audio) {
526
636
  const producer = await transport.produce({
@@ -531,25 +641,380 @@ class StreamClient {
531
641
  codecOptions: { opusStereo: true, opusDtx: true, opusFec: true },
532
642
  appData: { source: 'mic' },
533
643
  });
534
- this.producers.set(producer.id, producer);
644
+ await this.keepProducer(producer, generation, 'microphone');
645
+ // A microphone handed over switched off, or muted before a reconnect.
646
+ await this.pauseOnServer(producer);
535
647
  }
536
648
  const video = stream.getVideoTracks()[0];
537
649
  if (video) {
538
- // Simulcast from day one: retrofitting it later is genuinely annoying,
539
- // and it is what lets one node serve far more viewers.
540
- const producer = await transport.produce({
541
- track: video,
542
- stopTracks: false,
543
- encodings: [
544
- { rid: 'q', scaleResolutionDownBy: 4, maxBitrate: 200_000, scalabilityMode: 'L1T3' },
545
- { rid: 'h', scaleResolutionDownBy: 2, maxBitrate: 600_000, scalabilityMode: 'L1T3' },
546
- { rid: 'f', scaleResolutionDownBy: 1, maxBitrate: 1_800_000, scalabilityMode: 'L1T3' },
547
- ],
548
- codecOptions: { videoGoogleStartBitrate: 1000 },
549
- appData: { source: 'camera' },
650
+ // The track is read when the camera's turn comes: one handed to
651
+ // replaceVideoTrack while this publish was under way is the one to send.
652
+ await this.cameraTask(() => this.produceCamera(transport, this.localStream?.getVideoTracks()[0] ?? video, generation));
653
+ }
654
+ }
655
+ /** What this project may publish. Known once joined; 720p before that. */
656
+ get videoPolicy() {
657
+ return this.joinResult?.video ?? quality_js_1.DEFAULT_VIDEO_POLICY;
658
+ }
659
+ /** The quality video is being sent at, after the project's limit. */
660
+ get videoQuality() {
661
+ return (0, quality_js_1.allowedQuality)(this.publishOptions.quality, this.videoPolicy);
662
+ }
663
+ /**
664
+ * Change the video quality while publishing. The camera's producer is
665
+ * replaced, so viewers see a brief pause while the new one starts. Resolves
666
+ * to the quality actually used, after the project's limit. Capture at least
667
+ * that size (for example with `track.applyConstraints`) first.
668
+ */
669
+ async setVideoQuality(quality) {
670
+ this.publishOptions = { ...this.publishOptions, quality };
671
+ await this.cameraTask(async () => {
672
+ const current = this.cameraProducer();
673
+ const track = current?.track;
674
+ if (!current || !track || track.readyState !== 'live')
675
+ return;
676
+ await this.reproduceCamera(current, track);
677
+ });
678
+ return this.videoQuality;
679
+ }
680
+ /**
681
+ * Send a different video track from now on: a canvas with effects drawn on
682
+ * it, the raw camera again, another camera. When the new track needs the
683
+ * same layers as the current one (the same size, or another size the
684
+ * project's quality scales alike), viewers see no pause, and the layers
685
+ * take the new picture's bitrates. Otherwise the camera producer is
686
+ * replaced, as `setVideoQuality` does. Either way a paused camera stays
687
+ * paused.
688
+ *
689
+ * Your own stream is left as it is: the client keeps a stream of its own
690
+ * (`local`, announced by `localStreamChanged`) with the new track and your
691
+ * microphone, and a reconnect republishes that. The track you replaced is
692
+ * not stopped; it is yours again, switched back on if pausing the camera
693
+ * had switched it off. Rejects when no camera is being published.
694
+ */
695
+ async replaceVideoTrack(track) {
696
+ if (!track || track.kind !== 'video')
697
+ throw new TypeError('replaceVideoTrack needs a video track');
698
+ if (track.readyState === 'ended')
699
+ throw new Error('replaceVideoTrack: that track has ended');
700
+ await this.cameraTask(async () => {
701
+ const current = this.cameraProducer();
702
+ // Between a dropped connection and the republish there is no producer,
703
+ // but the camera is still being published: the republish sends this.
704
+ const reconnecting = !current && this.wasPublishing && (this.localStream?.getVideoTracks().length ?? 0) > 0;
705
+ if (!current && !reconnecting)
706
+ throw new Error('replaceVideoTrack: no camera is being published');
707
+ if (!current) {
708
+ // The republish reads the camera's state off the track it sends, and
709
+ // the track being replaced is switched off exactly when the camera is
710
+ // paused. Hand that state on, or a camera that was off comes back on;
711
+ // and the old track back switched on, as below.
712
+ const replaced = this.localStream?.getVideoTracks()[0];
713
+ if (replaced && replaced !== track) {
714
+ track.enabled = replaced.enabled;
715
+ replaced.enabled = true;
716
+ }
717
+ }
718
+ this.adoptVideoTrack(track);
719
+ if (!current || current.track === track)
720
+ return;
721
+ const previous = current.track;
722
+ const paused = current.paused;
723
+ try {
724
+ const { width = 0, height = 0 } = track.getSettings();
725
+ const next = (0, quality_js_1.videoEncodings)(width, height, this.videoQuality, this.layerCount());
726
+ if (this.cameraEncodings && (0, quality_js_1.sameLayout)(this.cameraEncodings, next)) {
727
+ await current.replaceTrack({ track });
728
+ await this.applyLayerBitrates(current, next);
729
+ return;
730
+ }
731
+ await this.reproduceCamera(current, track);
732
+ }
733
+ finally {
734
+ // Pausing switched the old track off (mediasoup-client pauses that
735
+ // way), and nothing switches it back on once it is replaced. It is the
736
+ // app's again, often the camera its canvas draws from: handed back
737
+ // off, that canvas stays black after the camera is resumed.
738
+ if (paused && previous && previous !== this.cameraProducer()?.track)
739
+ previous.enabled = true;
740
+ }
741
+ });
742
+ }
743
+ /**
744
+ * Give a camera producer whose track was swapped in place the bitrates of
745
+ * its new picture. The layout matched, but a bigger picture at the same
746
+ * scales (a 1280×720 canvas for a 960×540 camera) would otherwise stay
747
+ * capped at the smaller one's. Best effort: a browser that refuses keeps
748
+ * the old caps.
749
+ */
750
+ async applyLayerBitrates(producer, encodings) {
751
+ const before = this.cameraEncodings;
752
+ if (before && before.every((encoding, i) => encoding.maxBitrate === encodings[i]?.maxBitrate)) {
753
+ this.cameraEncodings = encodings;
754
+ return;
755
+ }
756
+ const sender = producer.rtpSender;
757
+ if (!sender)
758
+ return;
759
+ try {
760
+ const parameters = sender.getParameters();
761
+ // One entry per layer, smallest first, as they were produced. A browser
762
+ // that reports them some other way is left with its own.
763
+ if (parameters.encodings.length !== encodings.length)
764
+ return;
765
+ parameters.encodings.forEach((encoding, i) => {
766
+ encoding.maxBitrate = encodings[i].maxBitrate;
550
767
  });
551
- this.producers.set(producer.id, producer);
768
+ await sender.setParameters(parameters);
769
+ this.cameraEncodings = encodings;
770
+ }
771
+ catch {
772
+ // Unsupported here; the layers keep the caps they were produced with.
773
+ }
774
+ }
775
+ /**
776
+ * What your camera video is sending now, layer by layer, or null when no
777
+ * camera is being published. The top-level fields describe the biggest
778
+ * layer going out. `qualityLimitation` says why it is less than asked:
779
+ * `cpu` is this device, `bandwidth` the upload, `none` neither (then check
780
+ * the camera and the light). Bitrates are averaged since the previous call,
781
+ * so call it every second or two; the first call reports 0.
782
+ */
783
+ async getVideoStats() {
784
+ return this.collectVideoStats(this.sendStatsBaseline);
785
+ }
786
+ /**
787
+ * What this client receives of a remote video (by its `producerId`, from
788
+ * the `track` event), or null when it is not receiving that video. Bitrates
789
+ * are averaged since the previous call for it; the first call reports 0.
790
+ */
791
+ async getReceiveStats(producerId) {
792
+ const consumer = this.consumers.get(producerId);
793
+ if (!consumer || consumer.kind !== 'video')
794
+ return null;
795
+ let report;
796
+ try {
797
+ report = await consumer.getStats();
798
+ }
799
+ catch (err) {
800
+ if (consumer.closed)
801
+ return null;
802
+ throw err;
803
+ }
804
+ const live = new Set([...this.consumers.values()].map((c) => c.id));
805
+ (0, stats_js_1.pruneBaseline)(this.recvStatsBaseline, (key) => live.has(key));
806
+ return (0, stats_js_1.parseReceiveStats)(report.values(), { producerId, baseline: this.recvStatsBaseline, key: consumer.id });
807
+ }
808
+ /**
809
+ * Receive a smaller copy of someone's video (by its `producerId`, from the
810
+ * `track` event), for a tile that does not need the full picture. Spatial
811
+ * layer 0 is the smallest copy the publisher sends: a computer sends three
812
+ * (at 720p 320×180, 640×360 and 1280×720), a phone two (640×360 and
813
+ * 1280×720), and a layer above the biggest means the biggest. Leave
814
+ * `temporalLayer` out to keep the full frame rate. It is the most this
815
+ * client is sent: a connection that cannot carry it still gets less. The
816
+ * choice is kept across reconnects and forgotten when the track ends.
817
+ * Rejects for a producer whose video this client is not receiving.
818
+ */
819
+ async setPreferredLayers(producerId, spatialLayer, temporalLayer) {
820
+ if (!isLayer(spatialLayer) || (temporalLayer !== undefined && !isLayer(temporalLayer))) {
821
+ throw new RangeError('setPreferredLayers: layers are whole numbers from 0');
822
+ }
823
+ const consumer = this.consumers.get(producerId);
824
+ if (!consumer || consumer.kind !== 'video') {
825
+ throw new Error(`setPreferredLayers: this client is not receiving a video with producerId ${producerId}`);
552
826
  }
827
+ const layers = temporalLayer === undefined ? { spatialLayer } : { spatialLayer, temporalLayer };
828
+ // Kept first: a reconnect that interrupts the request still applies it.
829
+ this.preferredLayers.set(producerId, layers);
830
+ await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers });
831
+ }
832
+ async collectVideoStats(baseline) {
833
+ const producer = this.cameraProducer();
834
+ if (!producer)
835
+ return null;
836
+ let report;
837
+ try {
838
+ report = await producer.getStats();
839
+ }
840
+ catch (err) {
841
+ if (producer.closed)
842
+ return null;
843
+ throw err;
844
+ }
845
+ const prefix = `${producer.id}:`;
846
+ (0, stats_js_1.pruneBaseline)(baseline, (key) => key.startsWith(prefix));
847
+ return (0, stats_js_1.parseSendStats)(report.values(), { quality: this.videoQuality, baseline, keyPrefix: prefix });
848
+ }
849
+ cameraProducer() {
850
+ for (const p of this.producers.values()) {
851
+ if (p.kind === 'video' && p.appData.source === 'camera')
852
+ return p;
853
+ }
854
+ return undefined;
855
+ }
856
+ cameraTask(task) {
857
+ const run = this.cameraQueue.then(task, task);
858
+ this.cameraQueue = run.catch(() => undefined);
859
+ return run;
860
+ }
861
+ /** Swap the video in the client's own stream, never the app's. */
862
+ adoptVideoTrack(track) {
863
+ const previous = this.localStream;
864
+ if (previous && previous.getVideoTracks()[0] === track)
865
+ return;
866
+ const others = previous ? previous.getTracks().filter((t) => t.kind !== 'video') : [];
867
+ this.localStream = new MediaStream([...others, track]);
868
+ this.emit('localStreamChanged', this.localStream);
869
+ }
870
+ /** Close the camera producer and produce `track` in its place, paused on both ends if it was. */
871
+ async reproduceCamera(current, track) {
872
+ const generation = this.publishGeneration;
873
+ const paused = current.paused;
874
+ current.close();
875
+ this.producers.delete(current.id);
876
+ this.cameraEncodings = null;
877
+ this.syncLimitWatch();
878
+ await this.rpc('closeProducer', { producerId: current.id }).catch(() => undefined);
879
+ return this.produceCamera(await this.ensureSendTransport(), track, generation, paused);
880
+ }
881
+ /** `options.layers` when valid, else 2 on phones and tablets and 3 elsewhere. */
882
+ layerCount() {
883
+ const asked = this.publishOptions.layers;
884
+ return asked === 1 || asked === 2 || asked === 3 ? asked : (0, device_js_1.defaultLayerCount)();
885
+ }
886
+ /**
887
+ * Produce the camera from `track`, paused on both ends when `paused`. By
888
+ * default the track says: one handed over switched off is a camera that is
889
+ * off, and so is one a pause switched off before a reconnect.
890
+ */
891
+ async produceCamera(transport, track, generation, paused = !track.enabled) {
892
+ if (generation !== this.publishGeneration)
893
+ throw new Error(unpublishedWhile('camera'));
894
+ // mediasoup-client starts a producer paused exactly when its track is
895
+ // off. Setting the track to the camera's state first makes the producer
896
+ // start in it, and a camera that is off never sends a frame of the new
897
+ // track; the server is told below.
898
+ track.enabled = !paused;
899
+ // Simulcast from day one: it is what lets one node serve far more viewers.
900
+ // The layers follow the picture's real size, the project's quality and
901
+ // the device, so the browser sends every layer asked for (see
902
+ // videoEncodings).
903
+ const { width = 0, height = 0 } = track.getSettings();
904
+ const quality = this.videoQuality;
905
+ const encodings = (0, quality_js_1.videoEncodings)(width, height, quality, this.layerCount());
906
+ let codecs;
907
+ try {
908
+ codecs = this.device?.sendRtpCapabilities.codecs;
909
+ }
910
+ catch {
911
+ codecs = undefined;
912
+ }
913
+ const chosen = await this.cameraCodec(codecs, encodings, width, height, quality);
914
+ const produce = (codec) => transport.produce({
915
+ track,
916
+ stopTracks: false,
917
+ encodings,
918
+ codecOptions: { videoGoogleStartBitrate: VIDEO_START_BITRATE_KBPS },
919
+ ...(codec ? { codec } : {}),
920
+ appData: { source: 'camera' },
921
+ });
922
+ let producer;
923
+ try {
924
+ producer = await produce(chosen.capability);
925
+ }
926
+ catch (err) {
927
+ // Never fail a publish over the codec: H.264 refused is VP8 instead.
928
+ // A closed transport (a reconnect under way) fails either way.
929
+ if (chosen.codec !== 'h264' || transport.closed)
930
+ throw err;
931
+ this.reportCodecFallback(`H.264 was refused (${err.message}): publishing VP8 instead`);
932
+ producer = await produce((0, device_js_1.findCodec)(codecs, 'vp8'));
933
+ }
934
+ // unpublish() may have run while this was being made (a publish, a
935
+ // quality change or a track swap under way).
936
+ await this.keepProducer(producer, generation, 'camera');
937
+ this.cameraEncodings = encodings;
938
+ await this.pauseOnServer(producer);
939
+ await this.applyDegradationPreference(producer);
940
+ this.syncLimitWatch();
941
+ return producer;
942
+ }
943
+ /**
944
+ * Keep a producer just made -- unless unpublish() ran while it was being
945
+ * made. Kept, it would publish again what was just unpublished, so it is
946
+ * closed instead, on both ends.
947
+ */
948
+ async keepProducer(producer, generation, what) {
949
+ if (generation !== this.publishGeneration) {
950
+ producer.close();
951
+ await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
952
+ throw new Error(unpublishedWhile(what));
953
+ }
954
+ this.producers.set(producer.id, producer);
955
+ }
956
+ /**
957
+ * mediasoup-client makes a producer paused when its track is off, and the
958
+ * server makes every producer live. Unless the server is told, viewers get
959
+ * black video or silence from a camera or microphone they are told is on.
960
+ */
961
+ async pauseOnServer(producer) {
962
+ if (producer.paused)
963
+ await this.rpc('pauseProducer', { producerId: producer.id, paused: true });
964
+ }
965
+ /**
966
+ * H.264 or VP8 for the camera (see chooseCodec), with the device's own
967
+ * capability entry for it: produce() must only ever get one of those.
968
+ */
969
+ async cameraCodec(codecs, encodings, width, height, quality) {
970
+ const requested = (0, device_js_1.normalizeCodec)(this.publishOptions.codec);
971
+ const env = (0, device_js_1.currentEnv)();
972
+ const phoneOrTablet = (0, device_js_1.isPhoneOrTablet)(env);
973
+ const h264 = (0, device_js_1.findCodec)(codecs, 'h264');
974
+ let h264Hardware = null;
975
+ if (requested === 'auto' && phoneOrTablet && h264) {
976
+ const top = (0, quality_js_1.topLayerSize)(width, height, quality);
977
+ h264Hardware = await (0, device_js_1.h264HardwareEncode)({ width: top.width, height: top.height, bitrate: encodings[encodings.length - 1].maxBitrate });
978
+ }
979
+ const choice = (0, device_js_1.chooseCodec)({ requested, phoneOrTablet, appleMobile: (0, device_js_1.isAppleMobile)(env), h264Available: Boolean(h264), h264Hardware });
980
+ if (choice.warning)
981
+ this.reportCodecFallback(choice.warning);
982
+ else if (requested === 'auto' && phoneOrTablet && !h264)
983
+ this.reportCodecFallback('this browser cannot send H.264 to this server: publishing VP8');
984
+ if (choice.codec === 'h264' && h264)
985
+ return { codec: 'h264', capability: h264 };
986
+ return { codec: 'vp8', capability: (0, device_js_1.findCodec)(codecs, 'vp8') };
987
+ }
988
+ /** A forced H.264 that could not be used is the app's to hear about; 'auto' just notes it, once. */
989
+ reportCodecFallback(message) {
990
+ if ((0, device_js_1.normalizeCodec)(this.publishOptions.codec) === 'h264') {
991
+ this.emit('error', new Error(message));
992
+ }
993
+ else if (!this.codecFallbackLogged) {
994
+ this.codecFallbackLogged = true;
995
+ console.warn(`[voltkit-rtc] ${message}`);
996
+ }
997
+ }
998
+ /** Best effort: a browser that ignores or refuses it keeps its own default. */
999
+ async applyDegradationPreference(producer) {
1000
+ const preference = this.publishOptions.degradationPreference;
1001
+ const sender = producer.rtpSender;
1002
+ if (!sender)
1003
+ return;
1004
+ if (preference !== 'maintain-resolution' && preference !== 'maintain-framerate' && preference !== 'balanced')
1005
+ return;
1006
+ try {
1007
+ const parameters = sender.getParameters();
1008
+ parameters.degradationPreference = preference;
1009
+ await sender.setParameters(parameters);
1010
+ }
1011
+ catch {
1012
+ // Unsupported here; the encoder keeps the browser's own trade-off.
1013
+ }
1014
+ }
1015
+ /** videoLimited sampling runs while a camera is published and someone listens. */
1016
+ syncLimitWatch() {
1017
+ this.limitWatch.set(!this.closedByUs && this.cameraProducer() !== undefined && this.listenerCount('videoLimited') > 0);
553
1018
  }
554
1019
  /**
555
1020
  * Re-establish this client's broadcast after a reconnect.
@@ -559,10 +1024,15 @@ class StreamClient {
559
1024
  * when the tracks have genuinely ended.
560
1025
  */
561
1026
  async republish() {
1027
+ // unpublish() can run while this is under way, and wins: nothing it
1028
+ // stopped is published again.
1029
+ const generation = this.publishGeneration;
562
1030
  // Music is not a device and cannot be "gone": publish it again whatever
563
1031
  // happens to the camera and microphone below.
564
1032
  if (this.music)
565
- await this.produceMusic(this.music).catch((err) => this.emit('error', err));
1033
+ await this.produceMusic(this.music, generation).catch((err) => this.emit('error', err));
1034
+ if (generation !== this.publishGeneration)
1035
+ return;
566
1036
  let stream = this.localStream;
567
1037
  if (!stream && !this.publishConstraints)
568
1038
  return; // music was all that was being published
@@ -572,7 +1042,20 @@ class StreamClient {
572
1042
  this.emit('error', new Error('cannot restore the broadcast: the devices are gone'));
573
1043
  return;
574
1044
  }
1045
+ const ended = stream;
575
1046
  stream = await navigator.mediaDevices.getUserMedia(this.publishConstraints);
1047
+ if (generation !== this.publishGeneration) {
1048
+ for (const track of stream.getTracks())
1049
+ track.stop();
1050
+ return;
1051
+ }
1052
+ // New devices come back switched on. A camera or microphone the user
1053
+ // had switched off stays off: a dropped connection is no reason to
1054
+ // switch it back on.
1055
+ for (const track of stream.getTracks()) {
1056
+ if (ended?.getTracks().some((t) => t.kind === track.kind && !t.enabled))
1057
+ track.enabled = false;
1058
+ }
576
1059
  this.localStream = stream;
577
1060
  this.emit('localStreamChanged', stream);
578
1061
  }
@@ -592,11 +1075,17 @@ class StreamClient {
592
1075
  * the user has not interacted with.
593
1076
  */
594
1077
  async publishMusic(input, options) {
1078
+ const generation = this.publishGeneration;
595
1079
  if (this.music)
596
1080
  await this.stopMusic();
597
1081
  const music = new music_js_1.MusicSource(input, options);
598
1082
  try {
599
- await this.produceMusic(music);
1083
+ await this.produceMusic(music, generation);
1084
+ // Checked again here: an unpublish() after the producer was kept has
1085
+ // already closed it, and the music must not be remembered for a
1086
+ // reconnect to publish again.
1087
+ if (generation !== this.publishGeneration)
1088
+ throw new Error(unpublishedWhile('music'));
600
1089
  }
601
1090
  catch (err) {
602
1091
  music.close();
@@ -606,8 +1095,10 @@ class StreamClient {
606
1095
  this.wasPublishing = true;
607
1096
  this.emitLocalMedia();
608
1097
  }
609
- async produceMusic(music) {
1098
+ async produceMusic(music, generation) {
610
1099
  const transport = await this.ensureSendTransport();
1100
+ if (generation !== this.publishGeneration)
1101
+ throw new Error(unpublishedWhile('music'));
611
1102
  const producer = await transport.produce({
612
1103
  track: music.track,
613
1104
  stopTracks: false,
@@ -616,8 +1107,10 @@ class StreamClient {
616
1107
  codecOptions: { opusStereo: true, opusDtx: false, opusFec: true, opusMaxAverageBitrate: 128_000 },
617
1108
  appData: { source: 'music' },
618
1109
  });
619
- this.producers.set(producer.id, producer);
1110
+ await this.keepProducer(producer, generation, 'music');
620
1111
  this.musicProducerId = producer.id;
1112
+ // Music switched off before a reconnect comes back paused here.
1113
+ await this.pauseOnServer(producer);
621
1114
  }
622
1115
  /** True while music is published, whether or not it is switched on. */
623
1116
  get musicPublishing() {
@@ -680,6 +1173,13 @@ class StreamClient {
680
1173
  }));
681
1174
  const consumer = await transport.consume(params);
682
1175
  this.consumers.set(remote.producerId, consumer);
1176
+ // Layers asked for before a reconnect: the new consumer gets them before
1177
+ // its first frame instead of starting at the full picture. Not getting
1178
+ // them costs data, not the track, so a refusal is only reported.
1179
+ const layers = this.preferredLayers.get(remote.producerId);
1180
+ if (layers) {
1181
+ await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers }).catch((err) => this.emit('error', err));
1182
+ }
683
1183
  // The server created it paused. Resume only now that our end exists,
684
1184
  // otherwise the first frames arrive before there is anything to decode them.
685
1185
  await this.rpc('resumeConsumer', { consumerId: consumer.id });
@@ -739,21 +1239,35 @@ class StreamClient {
739
1239
  }
740
1240
  /** Stop publishing entirely and release the local devices. */
741
1241
  async unpublish() {
1242
+ // All of it takes effect now; only telling the server is waited for. So
1243
+ // publish() and unpublish() act in the order they are called: a publish
1244
+ // called while this still waits is not undone by it, and one already
1245
+ // under way stops at its next step (keepProducer).
1246
+ this.publishGeneration += 1;
742
1247
  const ids = [...this.producers.keys()];
743
1248
  for (const producer of this.producers.values())
744
1249
  producer.close();
745
1250
  this.producers.clear();
746
- for (const id of ids) {
747
- await this.rpc('closeProducer', { producerId: id }).catch(() => undefined);
748
- }
1251
+ this.cameraEncodings = null;
1252
+ // Start over for the next publish, and clear a limitation still showing:
1253
+ // nothing is being held back once nothing is being sent.
1254
+ const cleared = this.limitWatch.reset();
1255
+ if (cleared)
1256
+ this.emit('videoLimited', cleared);
749
1257
  this.localStream?.getTracks().forEach((t) => t.stop());
750
1258
  this.localStream = null;
1259
+ // Forgotten with the stream: kept, a reconnect would ask for the camera
1260
+ // and microphone again and publish them, after the app unpublished them.
1261
+ this.publishConstraints = null;
751
1262
  this.music?.close();
752
1263
  this.music = null;
753
1264
  this.musicProducerId = null;
754
1265
  this.wasPublishing = false;
755
1266
  this.emit('localStreamChanged', null);
756
1267
  this.emit('localMediaChanged', { micEnabled: false, camEnabled: false, musicEnabled: false });
1268
+ for (const id of ids) {
1269
+ await this.rpc('closeProducer', { producerId: id }).catch(() => undefined);
1270
+ }
757
1271
  }
758
1272
  get local() {
759
1273
  return this.localStream;