@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/esm/index.js CHANGED
@@ -6,10 +6,14 @@
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';
11
+ import { allowedQuality, DEFAULT_VIDEO_POLICY, sameLayout, topLayerSize, videoEncodings, } from './quality.js';
12
+ import { parseReceiveStats, parseSendStats, pruneBaseline, VideoLimitWatch, } from './stats.js';
10
13
  export { BroadcastPublisher, WhipError, whipEnd, whipOffer } from './broadcast.js';
11
14
  export { AudioMixer, MusicSource } from './music.js';
12
15
  export { ChatClient, ChatRpcError } from './chat.js';
16
+ export { allowedQuality, layerBitrate, videoEncodings, VIDEO_QUALITIES } from './quality.js';
13
17
  const defaultSource = (kind) => (kind === 'audio' ? 'mic' : 'camera');
14
18
  /** An error returned by the server, carrying its machine-readable code. */
15
19
  export class RpcError extends Error {
@@ -46,6 +50,14 @@ const CLOSE_REPLACED = 4001;
46
50
  /** The server ended the session on purpose and said why first: do not reconnect. */
47
51
  const CLOSE_ENDED = 4003;
48
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 = 1500;
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`;
49
61
  export class StreamClient {
50
62
  wsUrl;
51
63
  ws = null;
@@ -72,14 +84,37 @@ export class StreamClient {
72
84
  // disconnected: the room looks healthy and shows nothing.
73
85
  wasPublishing = false;
74
86
  publishConstraints = null;
87
+ publishOptions = {};
75
88
  /** In-app music being published, kept across reconnects so it can be published again. */
76
89
  music = null;
77
90
  musicProducerId = null;
78
91
  /** What each remote producer carries, so a pause can say "music" rather than just "audio". */
79
92
  remoteSources = new Map();
93
+ /**
94
+ * The layers asked for each remote video, by producerId. A reconnect
95
+ * consumes everything again, and the new consumer would otherwise start at
96
+ * the full picture.
97
+ */
98
+ preferredLayers = new Map();
80
99
  closedByUs = false;
81
100
  reconnecting = false;
82
101
  reconnectAttempt = 0;
102
+ /** Set once a connect() has joined and set up. Only such a session is reconnected. */
103
+ joinedOnce = false;
104
+ /** The camera producer's layers, to tell whether a new track fits them. */
105
+ cameraEncodings = null;
106
+ // Changes to the camera producer run one at a time. Two overlapping swaps
107
+ // (an effect toggled on and off quickly) would otherwise both close the
108
+ // same producer and both produce a new one.
109
+ cameraQueue = Promise.resolve();
110
+ // Bumped by unpublish(). A producer still being made when it runs (camera,
111
+ // microphone or music) belongs to the publish it ended: it is closed, not
112
+ // kept.
113
+ publishGeneration = 0;
114
+ codecFallbackLogged = false;
115
+ sendStatsBaseline = new Map();
116
+ recvStatsBaseline = new Map();
117
+ limitWatch = new VideoLimitWatch((baseline) => this.collectVideoStats(baseline), (event) => this.emit('videoLimited', event));
83
118
  constructor(wsUrl, token) {
84
119
  this.wsUrl = wsUrl;
85
120
  this.token = token;
@@ -91,7 +126,17 @@ export class StreamClient {
91
126
  this.listeners.set(event, set);
92
127
  }
93
128
  set.add(fn);
94
- return () => set.delete(fn);
129
+ // videoLimited costs a getStats every 2 s, so it runs only while heard.
130
+ if (event === 'videoLimited')
131
+ this.syncLimitWatch();
132
+ return () => {
133
+ set.delete(fn);
134
+ if (event === 'videoLimited')
135
+ this.syncLimitWatch();
136
+ };
137
+ }
138
+ listenerCount(event) {
139
+ return this.listeners.get(event)?.size ?? 0;
95
140
  }
96
141
  emit(event, ...args) {
97
142
  for (const fn of this.listeners.get(event) ?? []) {
@@ -105,12 +150,36 @@ export class StreamClient {
105
150
  }
106
151
  // ------------------------------------------------------------- connection
107
152
  async connect() {
108
- await this.openSocket();
109
- const result = (await this.rpc('join', { token: this.token }));
110
- this.joinResult = result;
111
- this.device = new Device();
112
- await this.device.load({ routerRtpCapabilities: result.routerRtpCapabilities });
153
+ let result;
154
+ try {
155
+ await this.openSocket();
156
+ result = (await this.rpc('join', { token: this.token }));
157
+ this.joinResult = result;
158
+ this.device = this.createDevice();
159
+ await this.device.load({ routerRtpCapabilities: result.routerRtpCapabilities });
160
+ }
161
+ catch (err) {
162
+ // A session that never got going: the app has the error. Left open,
163
+ // the socket would be closed by the server's join deadline instead, and
164
+ // that close would start reconnecting a session that never began.
165
+ if (!this.joinedOnce)
166
+ this.detachSocket();
167
+ throw err;
168
+ }
169
+ // Joined and set up: the next drop gets every reconnect attempt again.
170
+ // Not when the socket opens -- a join refused with a code worth retrying
171
+ // (no-capacity) never used them up, and every client of a full cluster
172
+ // retried about twice a second, forever.
173
+ this.reconnectAttempt = 0;
174
+ this.joinedOnce = true;
113
175
  this.emit('joined', result);
176
+ // A video that ended while this client was away sends it no
177
+ // producerClosed: forget the layers asked for it.
178
+ const present = new Set(result.producers.map((p) => p.producerId));
179
+ for (const producerId of this.preferredLayers.keys()) {
180
+ if (!present.has(producerId))
181
+ this.preferredLayers.delete(producerId);
182
+ }
114
183
  // Subscribe to everything already publishing before we announce readiness.
115
184
  for (const p of result.producers) {
116
185
  await this.consume(p).catch((err) => this.emit('error', err));
@@ -123,6 +192,10 @@ export class StreamClient {
123
192
  }
124
193
  return result;
125
194
  }
195
+ /** A new mediasoup Device for each join. Its own method so tests, which have no WebRTC, can stand one in. */
196
+ createDevice() {
197
+ return new Device();
198
+ }
126
199
  openSocket() {
127
200
  // Abandon any previous socket first. Its close handler checks identity
128
201
  // against `this.ws`, so once it is detached it can no longer trigger a
@@ -134,7 +207,6 @@ export class StreamClient {
134
207
  const ws = new WebSocket(url.toString());
135
208
  this.ws = ws;
136
209
  const onOpen = () => {
137
- this.reconnectAttempt = 0;
138
210
  this.emit('connected');
139
211
  resolve();
140
212
  };
@@ -160,6 +232,9 @@ export class StreamClient {
160
232
  // displace it, and it would displace us right back, forever.
161
233
  this.emit('error', new Error('this identity connected from somewhere else'));
162
234
  this.closedByUs = true;
235
+ // The producers outlive this close until the app calls close(); the
236
+ // videoLimited sampling must not, or it reports after `closed`.
237
+ this.limitWatch.reset();
163
238
  this.emit('closed', 'replaced');
164
239
  return;
165
240
  }
@@ -167,9 +242,14 @@ export class StreamClient {
167
242
  // Normally roomClosed has already ended the session; this covers a
168
243
  // close that arrived without it.
169
244
  this.closedByUs = true;
245
+ this.limitWatch.reset();
170
246
  this.emit('closed', 'room-closed');
171
247
  return;
172
248
  }
249
+ // Before a first successful join the app is still waiting on
250
+ // connect(), which reports the failure; there is nothing to restore.
251
+ if (!this.joinedOnce)
252
+ return;
173
253
  void this.scheduleReconnect();
174
254
  });
175
255
  });
@@ -213,8 +293,13 @@ export class StreamClient {
213
293
  return;
214
294
  try {
215
295
  this.teardownMedia();
216
- await this.connect();
217
- return; // connect() resets the attempt counter once the socket opens
296
+ await this.connect(); // which resets the attempt counter once joined
297
+ // The socket can drop again while the session is being restored,
298
+ // and its close handler then finds this loop still running and
299
+ // leaves it to us. Returning would leave the client disconnected
300
+ // for good, with no `closed`.
301
+ if (this.ws)
302
+ return;
218
303
  }
219
304
  catch (err) {
220
305
  this.emit('error', err);
@@ -240,6 +325,9 @@ export class StreamClient {
240
325
  this.closedByUs = true;
241
326
  this.wasPublishing = false;
242
327
  this.teardownMedia();
328
+ this.preferredLayers.clear();
329
+ // Nothing is emitted after closing, so not even a clearing videoLimited.
330
+ this.limitWatch.reset();
243
331
  // Producers no longer stop their tracks, so releasing the camera and
244
332
  // microphone is ours to do -- otherwise the device light stays on.
245
333
  this.localStream?.getTracks().forEach((t) => t.stop());
@@ -258,6 +346,10 @@ export class StreamClient {
258
346
  p.close();
259
347
  this.producers.clear();
260
348
  this.musicProducerId = null;
349
+ this.cameraEncodings = null;
350
+ // Paused, not reset: a reconnect's republish starts it again, and a
351
+ // limitation already reported is not reported twice.
352
+ this.syncLimitWatch();
261
353
  this.sendTransport?.close();
262
354
  this.recvTransport?.close();
263
355
  this.sendTransport = null;
@@ -326,6 +418,7 @@ export class StreamClient {
326
418
  break;
327
419
  case 'producerClosed': {
328
420
  const { producerId, identity } = data;
421
+ this.preferredLayers.delete(producerId);
329
422
  const consumer = this.consumers.get(producerId);
330
423
  if (!consumer)
331
424
  break; // never consumed it, or already cleaned up
@@ -502,13 +595,26 @@ export class StreamClient {
502
595
  *
503
596
  * Pass the constraints used to obtain the stream and they will be reused to
504
597
  * reacquire the devices if a reconnect finds the tracks have ended.
598
+ *
599
+ * `options.quality` picks 1080p, 720p or 480p (see `videoPolicy` for what
600
+ * the project allows). Capture at least that size for the full picture.
601
+ * `options.layers`, `codec` and `degradationPreference` tune how the video
602
+ * is encoded; the defaults suit both phones and computers. The options are
603
+ * kept for a reconnect's republish.
505
604
  */
506
- async publish(stream, constraints) {
605
+ async publish(stream, constraints, options) {
606
+ const generation = this.publishGeneration;
507
607
  const transport = await this.ensureSendTransport();
608
+ // unpublish() ran first: this publish is over before it began, and must
609
+ // leave nothing behind that a reconnect would publish again.
610
+ if (generation !== this.publishGeneration)
611
+ throw new Error(unpublishedWhile('stream'));
508
612
  this.localStream = stream;
509
613
  this.wasPublishing = true;
510
614
  if (constraints)
511
615
  this.publishConstraints = constraints;
616
+ if (options)
617
+ this.publishOptions = { ...options };
512
618
  const audio = stream.getAudioTracks()[0];
513
619
  if (audio) {
514
620
  const producer = await transport.produce({
@@ -519,25 +625,380 @@ export class StreamClient {
519
625
  codecOptions: { opusStereo: true, opusDtx: true, opusFec: true },
520
626
  appData: { source: 'mic' },
521
627
  });
522
- this.producers.set(producer.id, producer);
628
+ await this.keepProducer(producer, generation, 'microphone');
629
+ // A microphone handed over switched off, or muted before a reconnect.
630
+ await this.pauseOnServer(producer);
523
631
  }
524
632
  const video = stream.getVideoTracks()[0];
525
633
  if (video) {
526
- // Simulcast from day one: retrofitting it later is genuinely annoying,
527
- // and it is what lets one node serve far more viewers.
528
- const producer = await transport.produce({
529
- track: video,
530
- stopTracks: false,
531
- encodings: [
532
- { rid: 'q', scaleResolutionDownBy: 4, maxBitrate: 200_000, scalabilityMode: 'L1T3' },
533
- { rid: 'h', scaleResolutionDownBy: 2, maxBitrate: 600_000, scalabilityMode: 'L1T3' },
534
- { rid: 'f', scaleResolutionDownBy: 1, maxBitrate: 1_800_000, scalabilityMode: 'L1T3' },
535
- ],
536
- codecOptions: { videoGoogleStartBitrate: 1000 },
537
- appData: { source: 'camera' },
634
+ // The track is read when the camera's turn comes: one handed to
635
+ // replaceVideoTrack while this publish was under way is the one to send.
636
+ await this.cameraTask(() => this.produceCamera(transport, this.localStream?.getVideoTracks()[0] ?? video, generation));
637
+ }
638
+ }
639
+ /** What this project may publish. Known once joined; 720p before that. */
640
+ get videoPolicy() {
641
+ return this.joinResult?.video ?? DEFAULT_VIDEO_POLICY;
642
+ }
643
+ /** The quality video is being sent at, after the project's limit. */
644
+ get videoQuality() {
645
+ return allowedQuality(this.publishOptions.quality, this.videoPolicy);
646
+ }
647
+ /**
648
+ * Change the video quality while publishing. The camera's producer is
649
+ * replaced, so viewers see a brief pause while the new one starts. Resolves
650
+ * to the quality actually used, after the project's limit. Capture at least
651
+ * that size (for example with `track.applyConstraints`) first.
652
+ */
653
+ async setVideoQuality(quality) {
654
+ this.publishOptions = { ...this.publishOptions, quality };
655
+ await this.cameraTask(async () => {
656
+ const current = this.cameraProducer();
657
+ const track = current?.track;
658
+ if (!current || !track || track.readyState !== 'live')
659
+ return;
660
+ await this.reproduceCamera(current, track);
661
+ });
662
+ return this.videoQuality;
663
+ }
664
+ /**
665
+ * Send a different video track from now on: a canvas with effects drawn on
666
+ * it, the raw camera again, another camera. When the new track needs the
667
+ * same layers as the current one (the same size, or another size the
668
+ * project's quality scales alike), viewers see no pause, and the layers
669
+ * take the new picture's bitrates. Otherwise the camera producer is
670
+ * replaced, as `setVideoQuality` does. Either way a paused camera stays
671
+ * paused.
672
+ *
673
+ * Your own stream is left as it is: the client keeps a stream of its own
674
+ * (`local`, announced by `localStreamChanged`) with the new track and your
675
+ * microphone, and a reconnect republishes that. The track you replaced is
676
+ * not stopped; it is yours again, switched back on if pausing the camera
677
+ * had switched it off. Rejects when no camera is being published.
678
+ */
679
+ async replaceVideoTrack(track) {
680
+ if (!track || track.kind !== 'video')
681
+ throw new TypeError('replaceVideoTrack needs a video track');
682
+ if (track.readyState === 'ended')
683
+ throw new Error('replaceVideoTrack: that track has ended');
684
+ await this.cameraTask(async () => {
685
+ const current = this.cameraProducer();
686
+ // Between a dropped connection and the republish there is no producer,
687
+ // but the camera is still being published: the republish sends this.
688
+ const reconnecting = !current && this.wasPublishing && (this.localStream?.getVideoTracks().length ?? 0) > 0;
689
+ if (!current && !reconnecting)
690
+ throw new Error('replaceVideoTrack: no camera is being published');
691
+ if (!current) {
692
+ // The republish reads the camera's state off the track it sends, and
693
+ // the track being replaced is switched off exactly when the camera is
694
+ // paused. Hand that state on, or a camera that was off comes back on;
695
+ // and the old track back switched on, as below.
696
+ const replaced = this.localStream?.getVideoTracks()[0];
697
+ if (replaced && replaced !== track) {
698
+ track.enabled = replaced.enabled;
699
+ replaced.enabled = true;
700
+ }
701
+ }
702
+ this.adoptVideoTrack(track);
703
+ if (!current || current.track === track)
704
+ return;
705
+ const previous = current.track;
706
+ const paused = current.paused;
707
+ try {
708
+ const { width = 0, height = 0 } = track.getSettings();
709
+ const next = videoEncodings(width, height, this.videoQuality, this.layerCount());
710
+ if (this.cameraEncodings && sameLayout(this.cameraEncodings, next)) {
711
+ await current.replaceTrack({ track });
712
+ await this.applyLayerBitrates(current, next);
713
+ return;
714
+ }
715
+ await this.reproduceCamera(current, track);
716
+ }
717
+ finally {
718
+ // Pausing switched the old track off (mediasoup-client pauses that
719
+ // way), and nothing switches it back on once it is replaced. It is the
720
+ // app's again, often the camera its canvas draws from: handed back
721
+ // off, that canvas stays black after the camera is resumed.
722
+ if (paused && previous && previous !== this.cameraProducer()?.track)
723
+ previous.enabled = true;
724
+ }
725
+ });
726
+ }
727
+ /**
728
+ * Give a camera producer whose track was swapped in place the bitrates of
729
+ * its new picture. The layout matched, but a bigger picture at the same
730
+ * scales (a 1280×720 canvas for a 960×540 camera) would otherwise stay
731
+ * capped at the smaller one's. Best effort: a browser that refuses keeps
732
+ * the old caps.
733
+ */
734
+ async applyLayerBitrates(producer, encodings) {
735
+ const before = this.cameraEncodings;
736
+ if (before && before.every((encoding, i) => encoding.maxBitrate === encodings[i]?.maxBitrate)) {
737
+ this.cameraEncodings = encodings;
738
+ return;
739
+ }
740
+ const sender = producer.rtpSender;
741
+ if (!sender)
742
+ return;
743
+ try {
744
+ const parameters = sender.getParameters();
745
+ // One entry per layer, smallest first, as they were produced. A browser
746
+ // that reports them some other way is left with its own.
747
+ if (parameters.encodings.length !== encodings.length)
748
+ return;
749
+ parameters.encodings.forEach((encoding, i) => {
750
+ encoding.maxBitrate = encodings[i].maxBitrate;
538
751
  });
539
- this.producers.set(producer.id, producer);
752
+ await sender.setParameters(parameters);
753
+ this.cameraEncodings = encodings;
754
+ }
755
+ catch {
756
+ // Unsupported here; the layers keep the caps they were produced with.
757
+ }
758
+ }
759
+ /**
760
+ * What your camera video is sending now, layer by layer, or null when no
761
+ * camera is being published. The top-level fields describe the biggest
762
+ * layer going out. `qualityLimitation` says why it is less than asked:
763
+ * `cpu` is this device, `bandwidth` the upload, `none` neither (then check
764
+ * the camera and the light). Bitrates are averaged since the previous call,
765
+ * so call it every second or two; the first call reports 0.
766
+ */
767
+ async getVideoStats() {
768
+ return this.collectVideoStats(this.sendStatsBaseline);
769
+ }
770
+ /**
771
+ * What this client receives of a remote video (by its `producerId`, from
772
+ * the `track` event), or null when it is not receiving that video. Bitrates
773
+ * are averaged since the previous call for it; the first call reports 0.
774
+ */
775
+ async getReceiveStats(producerId) {
776
+ const consumer = this.consumers.get(producerId);
777
+ if (!consumer || consumer.kind !== 'video')
778
+ return null;
779
+ let report;
780
+ try {
781
+ report = await consumer.getStats();
782
+ }
783
+ catch (err) {
784
+ if (consumer.closed)
785
+ return null;
786
+ throw err;
787
+ }
788
+ const live = new Set([...this.consumers.values()].map((c) => c.id));
789
+ pruneBaseline(this.recvStatsBaseline, (key) => live.has(key));
790
+ return parseReceiveStats(report.values(), { producerId, baseline: this.recvStatsBaseline, key: consumer.id });
791
+ }
792
+ /**
793
+ * Receive a smaller copy of someone's video (by its `producerId`, from the
794
+ * `track` event), for a tile that does not need the full picture. Spatial
795
+ * layer 0 is the smallest copy the publisher sends: a computer sends three
796
+ * (at 720p 320×180, 640×360 and 1280×720), a phone two (640×360 and
797
+ * 1280×720), and a layer above the biggest means the biggest. Leave
798
+ * `temporalLayer` out to keep the full frame rate. It is the most this
799
+ * client is sent: a connection that cannot carry it still gets less. The
800
+ * choice is kept across reconnects and forgotten when the track ends.
801
+ * Rejects for a producer whose video this client is not receiving.
802
+ */
803
+ async setPreferredLayers(producerId, spatialLayer, temporalLayer) {
804
+ if (!isLayer(spatialLayer) || (temporalLayer !== undefined && !isLayer(temporalLayer))) {
805
+ throw new RangeError('setPreferredLayers: layers are whole numbers from 0');
806
+ }
807
+ const consumer = this.consumers.get(producerId);
808
+ if (!consumer || consumer.kind !== 'video') {
809
+ throw new Error(`setPreferredLayers: this client is not receiving a video with producerId ${producerId}`);
540
810
  }
811
+ const layers = temporalLayer === undefined ? { spatialLayer } : { spatialLayer, temporalLayer };
812
+ // Kept first: a reconnect that interrupts the request still applies it.
813
+ this.preferredLayers.set(producerId, layers);
814
+ await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers });
815
+ }
816
+ async collectVideoStats(baseline) {
817
+ const producer = this.cameraProducer();
818
+ if (!producer)
819
+ return null;
820
+ let report;
821
+ try {
822
+ report = await producer.getStats();
823
+ }
824
+ catch (err) {
825
+ if (producer.closed)
826
+ return null;
827
+ throw err;
828
+ }
829
+ const prefix = `${producer.id}:`;
830
+ pruneBaseline(baseline, (key) => key.startsWith(prefix));
831
+ return parseSendStats(report.values(), { quality: this.videoQuality, baseline, keyPrefix: prefix });
832
+ }
833
+ cameraProducer() {
834
+ for (const p of this.producers.values()) {
835
+ if (p.kind === 'video' && p.appData.source === 'camera')
836
+ return p;
837
+ }
838
+ return undefined;
839
+ }
840
+ cameraTask(task) {
841
+ const run = this.cameraQueue.then(task, task);
842
+ this.cameraQueue = run.catch(() => undefined);
843
+ return run;
844
+ }
845
+ /** Swap the video in the client's own stream, never the app's. */
846
+ adoptVideoTrack(track) {
847
+ const previous = this.localStream;
848
+ if (previous && previous.getVideoTracks()[0] === track)
849
+ return;
850
+ const others = previous ? previous.getTracks().filter((t) => t.kind !== 'video') : [];
851
+ this.localStream = new MediaStream([...others, track]);
852
+ this.emit('localStreamChanged', this.localStream);
853
+ }
854
+ /** Close the camera producer and produce `track` in its place, paused on both ends if it was. */
855
+ async reproduceCamera(current, track) {
856
+ const generation = this.publishGeneration;
857
+ const paused = current.paused;
858
+ current.close();
859
+ this.producers.delete(current.id);
860
+ this.cameraEncodings = null;
861
+ this.syncLimitWatch();
862
+ await this.rpc('closeProducer', { producerId: current.id }).catch(() => undefined);
863
+ return this.produceCamera(await this.ensureSendTransport(), track, generation, paused);
864
+ }
865
+ /** `options.layers` when valid, else 2 on phones and tablets and 3 elsewhere. */
866
+ layerCount() {
867
+ const asked = this.publishOptions.layers;
868
+ return asked === 1 || asked === 2 || asked === 3 ? asked : defaultLayerCount();
869
+ }
870
+ /**
871
+ * Produce the camera from `track`, paused on both ends when `paused`. By
872
+ * default the track says: one handed over switched off is a camera that is
873
+ * off, and so is one a pause switched off before a reconnect.
874
+ */
875
+ async produceCamera(transport, track, generation, paused = !track.enabled) {
876
+ if (generation !== this.publishGeneration)
877
+ throw new Error(unpublishedWhile('camera'));
878
+ // mediasoup-client starts a producer paused exactly when its track is
879
+ // off. Setting the track to the camera's state first makes the producer
880
+ // start in it, and a camera that is off never sends a frame of the new
881
+ // track; the server is told below.
882
+ track.enabled = !paused;
883
+ // Simulcast from day one: it is what lets one node serve far more viewers.
884
+ // The layers follow the picture's real size, the project's quality and
885
+ // the device, so the browser sends every layer asked for (see
886
+ // videoEncodings).
887
+ const { width = 0, height = 0 } = track.getSettings();
888
+ const quality = this.videoQuality;
889
+ const encodings = videoEncodings(width, height, quality, this.layerCount());
890
+ let codecs;
891
+ try {
892
+ codecs = this.device?.sendRtpCapabilities.codecs;
893
+ }
894
+ catch {
895
+ codecs = undefined;
896
+ }
897
+ const chosen = await this.cameraCodec(codecs, encodings, width, height, quality);
898
+ const produce = (codec) => transport.produce({
899
+ track,
900
+ stopTracks: false,
901
+ encodings,
902
+ codecOptions: { videoGoogleStartBitrate: VIDEO_START_BITRATE_KBPS },
903
+ ...(codec ? { codec } : {}),
904
+ appData: { source: 'camera' },
905
+ });
906
+ let producer;
907
+ try {
908
+ producer = await produce(chosen.capability);
909
+ }
910
+ catch (err) {
911
+ // Never fail a publish over the codec: H.264 refused is VP8 instead.
912
+ // A closed transport (a reconnect under way) fails either way.
913
+ if (chosen.codec !== 'h264' || transport.closed)
914
+ throw err;
915
+ this.reportCodecFallback(`H.264 was refused (${err.message}): publishing VP8 instead`);
916
+ producer = await produce(findCodec(codecs, 'vp8'));
917
+ }
918
+ // unpublish() may have run while this was being made (a publish, a
919
+ // quality change or a track swap under way).
920
+ await this.keepProducer(producer, generation, 'camera');
921
+ this.cameraEncodings = encodings;
922
+ await this.pauseOnServer(producer);
923
+ await this.applyDegradationPreference(producer);
924
+ this.syncLimitWatch();
925
+ return producer;
926
+ }
927
+ /**
928
+ * Keep a producer just made -- unless unpublish() ran while it was being
929
+ * made. Kept, it would publish again what was just unpublished, so it is
930
+ * closed instead, on both ends.
931
+ */
932
+ async keepProducer(producer, generation, what) {
933
+ if (generation !== this.publishGeneration) {
934
+ producer.close();
935
+ await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
936
+ throw new Error(unpublishedWhile(what));
937
+ }
938
+ this.producers.set(producer.id, producer);
939
+ }
940
+ /**
941
+ * mediasoup-client makes a producer paused when its track is off, and the
942
+ * server makes every producer live. Unless the server is told, viewers get
943
+ * black video or silence from a camera or microphone they are told is on.
944
+ */
945
+ async pauseOnServer(producer) {
946
+ if (producer.paused)
947
+ await this.rpc('pauseProducer', { producerId: producer.id, paused: true });
948
+ }
949
+ /**
950
+ * H.264 or VP8 for the camera (see chooseCodec), with the device's own
951
+ * capability entry for it: produce() must only ever get one of those.
952
+ */
953
+ async cameraCodec(codecs, encodings, width, height, quality) {
954
+ const requested = normalizeCodec(this.publishOptions.codec);
955
+ const env = currentEnv();
956
+ const phoneOrTablet = isPhoneOrTablet(env);
957
+ const h264 = findCodec(codecs, 'h264');
958
+ let h264Hardware = null;
959
+ if (requested === 'auto' && phoneOrTablet && h264) {
960
+ const top = topLayerSize(width, height, quality);
961
+ h264Hardware = await h264HardwareEncode({ width: top.width, height: top.height, bitrate: encodings[encodings.length - 1].maxBitrate });
962
+ }
963
+ const choice = chooseCodec({ requested, phoneOrTablet, appleMobile: isAppleMobile(env), h264Available: Boolean(h264), h264Hardware });
964
+ if (choice.warning)
965
+ this.reportCodecFallback(choice.warning);
966
+ else if (requested === 'auto' && phoneOrTablet && !h264)
967
+ this.reportCodecFallback('this browser cannot send H.264 to this server: publishing VP8');
968
+ if (choice.codec === 'h264' && h264)
969
+ return { codec: 'h264', capability: h264 };
970
+ return { codec: 'vp8', capability: findCodec(codecs, 'vp8') };
971
+ }
972
+ /** A forced H.264 that could not be used is the app's to hear about; 'auto' just notes it, once. */
973
+ reportCodecFallback(message) {
974
+ if (normalizeCodec(this.publishOptions.codec) === 'h264') {
975
+ this.emit('error', new Error(message));
976
+ }
977
+ else if (!this.codecFallbackLogged) {
978
+ this.codecFallbackLogged = true;
979
+ console.warn(`[voltkit-rtc] ${message}`);
980
+ }
981
+ }
982
+ /** Best effort: a browser that ignores or refuses it keeps its own default. */
983
+ async applyDegradationPreference(producer) {
984
+ const preference = this.publishOptions.degradationPreference;
985
+ const sender = producer.rtpSender;
986
+ if (!sender)
987
+ return;
988
+ if (preference !== 'maintain-resolution' && preference !== 'maintain-framerate' && preference !== 'balanced')
989
+ return;
990
+ try {
991
+ const parameters = sender.getParameters();
992
+ parameters.degradationPreference = preference;
993
+ await sender.setParameters(parameters);
994
+ }
995
+ catch {
996
+ // Unsupported here; the encoder keeps the browser's own trade-off.
997
+ }
998
+ }
999
+ /** videoLimited sampling runs while a camera is published and someone listens. */
1000
+ syncLimitWatch() {
1001
+ this.limitWatch.set(!this.closedByUs && this.cameraProducer() !== undefined && this.listenerCount('videoLimited') > 0);
541
1002
  }
542
1003
  /**
543
1004
  * Re-establish this client's broadcast after a reconnect.
@@ -547,10 +1008,15 @@ export class StreamClient {
547
1008
  * when the tracks have genuinely ended.
548
1009
  */
549
1010
  async republish() {
1011
+ // unpublish() can run while this is under way, and wins: nothing it
1012
+ // stopped is published again.
1013
+ const generation = this.publishGeneration;
550
1014
  // Music is not a device and cannot be "gone": publish it again whatever
551
1015
  // happens to the camera and microphone below.
552
1016
  if (this.music)
553
- await this.produceMusic(this.music).catch((err) => this.emit('error', err));
1017
+ await this.produceMusic(this.music, generation).catch((err) => this.emit('error', err));
1018
+ if (generation !== this.publishGeneration)
1019
+ return;
554
1020
  let stream = this.localStream;
555
1021
  if (!stream && !this.publishConstraints)
556
1022
  return; // music was all that was being published
@@ -560,7 +1026,20 @@ export class StreamClient {
560
1026
  this.emit('error', new Error('cannot restore the broadcast: the devices are gone'));
561
1027
  return;
562
1028
  }
1029
+ const ended = stream;
563
1030
  stream = await navigator.mediaDevices.getUserMedia(this.publishConstraints);
1031
+ if (generation !== this.publishGeneration) {
1032
+ for (const track of stream.getTracks())
1033
+ track.stop();
1034
+ return;
1035
+ }
1036
+ // New devices come back switched on. A camera or microphone the user
1037
+ // had switched off stays off: a dropped connection is no reason to
1038
+ // switch it back on.
1039
+ for (const track of stream.getTracks()) {
1040
+ if (ended?.getTracks().some((t) => t.kind === track.kind && !t.enabled))
1041
+ track.enabled = false;
1042
+ }
564
1043
  this.localStream = stream;
565
1044
  this.emit('localStreamChanged', stream);
566
1045
  }
@@ -580,11 +1059,17 @@ export class StreamClient {
580
1059
  * the user has not interacted with.
581
1060
  */
582
1061
  async publishMusic(input, options) {
1062
+ const generation = this.publishGeneration;
583
1063
  if (this.music)
584
1064
  await this.stopMusic();
585
1065
  const music = new MusicSource(input, options);
586
1066
  try {
587
- await this.produceMusic(music);
1067
+ await this.produceMusic(music, generation);
1068
+ // Checked again here: an unpublish() after the producer was kept has
1069
+ // already closed it, and the music must not be remembered for a
1070
+ // reconnect to publish again.
1071
+ if (generation !== this.publishGeneration)
1072
+ throw new Error(unpublishedWhile('music'));
588
1073
  }
589
1074
  catch (err) {
590
1075
  music.close();
@@ -594,8 +1079,10 @@ export class StreamClient {
594
1079
  this.wasPublishing = true;
595
1080
  this.emitLocalMedia();
596
1081
  }
597
- async produceMusic(music) {
1082
+ async produceMusic(music, generation) {
598
1083
  const transport = await this.ensureSendTransport();
1084
+ if (generation !== this.publishGeneration)
1085
+ throw new Error(unpublishedWhile('music'));
599
1086
  const producer = await transport.produce({
600
1087
  track: music.track,
601
1088
  stopTracks: false,
@@ -604,8 +1091,10 @@ export class StreamClient {
604
1091
  codecOptions: { opusStereo: true, opusDtx: false, opusFec: true, opusMaxAverageBitrate: 128_000 },
605
1092
  appData: { source: 'music' },
606
1093
  });
607
- this.producers.set(producer.id, producer);
1094
+ await this.keepProducer(producer, generation, 'music');
608
1095
  this.musicProducerId = producer.id;
1096
+ // Music switched off before a reconnect comes back paused here.
1097
+ await this.pauseOnServer(producer);
609
1098
  }
610
1099
  /** True while music is published, whether or not it is switched on. */
611
1100
  get musicPublishing() {
@@ -668,6 +1157,13 @@ export class StreamClient {
668
1157
  }));
669
1158
  const consumer = await transport.consume(params);
670
1159
  this.consumers.set(remote.producerId, consumer);
1160
+ // Layers asked for before a reconnect: the new consumer gets them before
1161
+ // its first frame instead of starting at the full picture. Not getting
1162
+ // them costs data, not the track, so a refusal is only reported.
1163
+ const layers = this.preferredLayers.get(remote.producerId);
1164
+ if (layers) {
1165
+ await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers }).catch((err) => this.emit('error', err));
1166
+ }
671
1167
  // The server created it paused. Resume only now that our end exists,
672
1168
  // otherwise the first frames arrive before there is anything to decode them.
673
1169
  await this.rpc('resumeConsumer', { consumerId: consumer.id });
@@ -727,21 +1223,35 @@ export class StreamClient {
727
1223
  }
728
1224
  /** Stop publishing entirely and release the local devices. */
729
1225
  async unpublish() {
1226
+ // All of it takes effect now; only telling the server is waited for. So
1227
+ // publish() and unpublish() act in the order they are called: a publish
1228
+ // called while this still waits is not undone by it, and one already
1229
+ // under way stops at its next step (keepProducer).
1230
+ this.publishGeneration += 1;
730
1231
  const ids = [...this.producers.keys()];
731
1232
  for (const producer of this.producers.values())
732
1233
  producer.close();
733
1234
  this.producers.clear();
734
- for (const id of ids) {
735
- await this.rpc('closeProducer', { producerId: id }).catch(() => undefined);
736
- }
1235
+ this.cameraEncodings = null;
1236
+ // Start over for the next publish, and clear a limitation still showing:
1237
+ // nothing is being held back once nothing is being sent.
1238
+ const cleared = this.limitWatch.reset();
1239
+ if (cleared)
1240
+ this.emit('videoLimited', cleared);
737
1241
  this.localStream?.getTracks().forEach((t) => t.stop());
738
1242
  this.localStream = null;
1243
+ // Forgotten with the stream: kept, a reconnect would ask for the camera
1244
+ // and microphone again and publish them, after the app unpublished them.
1245
+ this.publishConstraints = null;
739
1246
  this.music?.close();
740
1247
  this.music = null;
741
1248
  this.musicProducerId = null;
742
1249
  this.wasPublishing = false;
743
1250
  this.emit('localStreamChanged', null);
744
1251
  this.emit('localMediaChanged', { micEnabled: false, camEnabled: false, musicEnabled: false });
1252
+ for (const id of ids) {
1253
+ await this.rpc('closeProducer', { producerId: id }).catch(() => undefined);
1254
+ }
745
1255
  }
746
1256
  get local() {
747
1257
  return this.localStream;