@voltkit-rtc/client 0.4.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.
package/dist/cjs/index.js CHANGED
@@ -71,7 +71,7 @@ const isLayer = (value) => Number.isInteger(value) && value >= 0;
71
71
  * Where a new camera stream's bandwidth estimate starts, in kbps. Chrome
72
72
  * otherwise starts low and takes many seconds to climb to the top layer.
73
73
  */
74
- const VIDEO_START_BITRATE_KBPS = 1500;
74
+ const VIDEO_START_BITRATE_KBPS = 2000;
75
75
  /** Why a publish gave up: unpublish() ran while it was under way, and unpublish wins. */
76
76
  const unpublishedWhile = (what) => `the ${what} was unpublished while it was being published`;
77
77
  class StreamClient {
@@ -104,6 +104,20 @@ class StreamClient {
104
104
  /** In-app music being published, kept across reconnects so it can be published again. */
105
105
  music = null;
106
106
  musicProducerId = null;
107
+ screenStream = null;
108
+ screenMode = 'detail';
109
+ screenAudioEnabled = true;
110
+ screenGeneration = 0;
111
+ screenPending = false;
112
+ screenStarted = false;
113
+ screenEndedListener = null;
114
+ screenProducerIds = new Set();
115
+ remoteVideoLayers = new Map();
116
+ receiveQualities = new Map();
117
+ cameraFrameRate = 30;
118
+ frameRateFallback = false;
119
+ frameRateLowSamples = 0;
120
+ frameRateWarmupSamples = 2;
107
121
  /** What each remote producer carries, so a pause can say "music" rather than just "audio". */
108
122
  remoteSources = new Map();
109
123
  /**
@@ -130,7 +144,7 @@ class StreamClient {
130
144
  codecFallbackLogged = false;
131
145
  sendStatsBaseline = new Map();
132
146
  recvStatsBaseline = new Map();
133
- limitWatch = new stats_js_1.VideoLimitWatch((baseline) => this.collectVideoStats(baseline), (event) => this.emit('videoLimited', event));
147
+ limitWatch = new stats_js_1.VideoLimitWatch((baseline) => this.sampleCamera(baseline), (event) => this.emit('videoLimited', event));
134
148
  constructor(wsUrl, token) {
135
149
  this.wsUrl = wsUrl;
136
150
  this.token = token;
@@ -188,14 +202,30 @@ class StreamClient {
188
202
  // retried about twice a second, forever.
189
203
  this.reconnectAttempt = 0;
190
204
  this.joinedOnce = true;
205
+ // A role or mute can change while the socket is away. The fresh roster
206
+ // decides what may return; reconnecting never turns a blocked device on.
207
+ if (result.role !== 'host' && result.role !== 'cohost' && (this.wasPublishing || this.screenPending)) {
208
+ await this.unpublish();
209
+ }
210
+ const own = result.participants.find((p) => p.identity === result.identity);
211
+ if (own?.muted)
212
+ this.applyLocalModeration('audio');
213
+ if (own?.videoBlocked)
214
+ this.applyLocalModeration('video');
191
215
  this.emit('joined', result);
192
216
  // A video that ended while this client was away sends it no
193
217
  // producerClosed: forget the layers asked for it.
194
218
  const present = new Set(result.producers.map((p) => p.producerId));
195
219
  for (const producerId of this.preferredLayers.keys()) {
196
- if (!present.has(producerId))
220
+ if (!present.has(producerId)) {
197
221
  this.preferredLayers.delete(producerId);
222
+ this.receiveQualities.delete(producerId);
223
+ this.remoteVideoLayers.delete(producerId);
224
+ }
198
225
  }
226
+ for (const producerId of this.remoteVideoLayers.keys())
227
+ if (!present.has(producerId))
228
+ this.remoteVideoLayers.delete(producerId);
199
229
  // Subscribe to everything already publishing before we announce readiness.
200
230
  for (const p of result.producers) {
201
231
  await this.consume(p).catch((err) => this.emit('error', err));
@@ -339,9 +369,15 @@ class StreamClient {
339
369
  // others have already reported themselves and set closedByUs.
340
370
  const wasOpen = !this.closedByUs;
341
371
  this.closedByUs = true;
372
+ this.publishGeneration += 1;
342
373
  this.wasPublishing = false;
343
374
  this.teardownMedia();
344
375
  this.preferredLayers.clear();
376
+ this.receiveQualities.clear();
377
+ this.remoteVideoLayers.clear();
378
+ this.releaseScreen();
379
+ this.music?.close();
380
+ this.music = null;
345
381
  // Nothing is emitted after closing, so not even a clearing videoLimited.
346
382
  this.limitWatch.reset();
347
383
  // Producers no longer stop their tracks, so releasing the camera and
@@ -362,6 +398,7 @@ class StreamClient {
362
398
  p.close();
363
399
  this.producers.clear();
364
400
  this.musicProducerId = null;
401
+ this.screenProducerIds.clear();
365
402
  this.cameraEncodings = null;
366
403
  // Paused, not reset: a reconnect's republish starts it again, and a
367
404
  // limitation already reported is not reported twice.
@@ -435,6 +472,8 @@ class StreamClient {
435
472
  case 'producerClosed': {
436
473
  const { producerId, identity } = data;
437
474
  this.preferredLayers.delete(producerId);
475
+ this.receiveQualities.delete(producerId);
476
+ this.remoteVideoLayers.delete(producerId);
438
477
  const consumer = this.consumers.get(producerId);
439
478
  if (!consumer)
440
479
  break; // never consumed it, or already cleaned up
@@ -458,16 +497,29 @@ class StreamClient {
458
497
  case 'participantJoined':
459
498
  this.emit('participantJoined', data);
460
499
  break;
500
+ case 'participantMuted': {
501
+ const wire = data;
502
+ const e = { identity: wire.identity, kind: wire.kind, paused: wire.paused ?? wire.muted ?? false };
503
+ if (this.joinResult)
504
+ this.joinResult.participants = this.joinResult.participants.map((p) => p.identity !== e.identity ? p : { ...p,
505
+ ...(!e.kind || e.kind === 'audio' ? { muted: e.paused } : {}),
506
+ ...(!e.kind || e.kind === 'video' ? { videoBlocked: e.paused } : {}),
507
+ });
508
+ this.emit('participantMuted', e);
509
+ break;
510
+ }
461
511
  case 'participantLeft':
462
512
  this.emit('participantLeft', data);
463
513
  break;
464
514
  case 'roleChanged': {
465
515
  const e = data;
516
+ if (e.identity === this.joinResult?.identity)
517
+ this.joinResult.role = e.role;
466
518
  this.emit('roleChanged', e);
467
519
  // Demoted: the server has already closed our producers. Release the
468
520
  // camera and microphone too, instead of capturing for nobody.
469
521
  const canPublish = e.role === 'host' || e.role === 'cohost';
470
- if (e.identity === this.joinResult?.identity && !canPublish && this.producers.size > 0) {
522
+ if (e.identity === this.joinResult?.identity && !canPublish && (this.wasPublishing || this.screenPending)) {
471
523
  void this.unpublish().catch(() => undefined);
472
524
  }
473
525
  break;
@@ -482,10 +534,7 @@ class StreamClient {
482
534
  // changes nothing here -- switching back on is the user's choice.
483
535
  const e = data;
484
536
  if (e.paused) {
485
- for (const p of this.producers.values()) {
486
- if (!e.kind || p.kind === e.kind)
487
- p.pause();
488
- }
537
+ this.applyLocalModeration(e.kind);
489
538
  this.emitLocalMedia();
490
539
  }
491
540
  this.emit('forceMuted', e);
@@ -573,7 +622,7 @@ class StreamClient {
573
622
  .catch(fail);
574
623
  });
575
624
  transport.on('produce', ({ kind, rtpParameters, appData }, ok, fail) => {
576
- this.rpc('produce', { transportId: transport.id, kind, rtpParameters, appData, source: appData.source })
625
+ this.rpc('produce', { transportId: transport.id, kind, rtpParameters, appData, source: appData.source, videoLayers: appData.videoLayers })
577
626
  .then((res) => ok({ id: res.producerId }))
578
627
  .catch(fail);
579
628
  });
@@ -629,8 +678,11 @@ class StreamClient {
629
678
  this.wasPublishing = true;
630
679
  if (constraints)
631
680
  this.publishConstraints = constraints;
632
- if (options)
681
+ if (options) {
633
682
  this.publishOptions = { ...options };
683
+ this.frameRateFallback = false;
684
+ this.frameRateLowSamples = 0;
685
+ }
634
686
  const audio = stream.getAudioTracks()[0];
635
687
  if (audio) {
636
688
  const producer = await transport.produce({
@@ -652,7 +704,7 @@ class StreamClient {
652
704
  await this.cameraTask(() => this.produceCamera(transport, this.localStream?.getVideoTracks()[0] ?? video, generation));
653
705
  }
654
706
  }
655
- /** What this project may publish. Known once joined; 720p before that. */
707
+ /** What this project may publish. Known once joined; 1080p before that. */
656
708
  get videoPolicy() {
657
709
  return this.joinResult?.video ?? quality_js_1.DEFAULT_VIDEO_POLICY;
658
710
  }
@@ -677,11 +729,30 @@ class StreamClient {
677
729
  });
678
730
  return this.videoQuality;
679
731
  }
732
+ /** Change the camera frame rate without stopping screen, microphone or music. */
733
+ async setVideoFrameRate(frameRate) {
734
+ if (frameRate !== 30 && frameRate !== 60)
735
+ throw new RangeError('setVideoFrameRate: expected 30 or 60');
736
+ this.publishOptions = { ...this.publishOptions, frameRate };
737
+ this.frameRateFallback = false;
738
+ this.frameRateLowSamples = 0;
739
+ await this.cameraTask(async () => {
740
+ const current = this.cameraProducer();
741
+ const track = current?.track;
742
+ if (!current || !track || track.readyState !== 'live')
743
+ return;
744
+ try {
745
+ await track.applyConstraints?.({ frameRate: { ideal: frameRate, max: frameRate } });
746
+ }
747
+ catch { /* Existing capture settings decide whether 60 fps is possible. */ }
748
+ await this.reproduceCamera(current, track);
749
+ });
750
+ return this.cameraProducer() ? this.cameraFrameRate : this.videoPolicy.maxFrameRate === 30 ? 30 : frameRate;
751
+ }
680
752
  /**
681
753
  * Send a different video track from now on: a canvas with effects drawn on
682
754
  * 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
755
+ * same dimensions and layers as the current one, viewers see no pause, and the layers
685
756
  * take the new picture's bitrates. Otherwise the camera producer is
686
757
  * replaced, as `setVideoQuality` does. Either way a paused camera stays
687
758
  * paused.
@@ -722,8 +793,10 @@ class StreamClient {
722
793
  const paused = current.paused;
723
794
  try {
724
795
  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)) {
796
+ const next = (0, quality_js_1.videoEncodings)(width, height, this.videoQuality, this.layerCount(), this.frameRateFor(track));
797
+ const previousSize = current.track?.getSettings();
798
+ if (this.cameraEncodings && (0, quality_js_1.sameLayout)(this.cameraEncodings, next) &&
799
+ previousSize?.width === width && previousSize?.height === height) {
727
800
  await current.replaceTrack({ track });
728
801
  await this.applyLayerBitrates(current, next);
729
802
  return;
@@ -825,6 +898,7 @@ class StreamClient {
825
898
  throw new Error(`setPreferredLayers: this client is not receiving a video with producerId ${producerId}`);
826
899
  }
827
900
  const layers = temporalLayer === undefined ? { spatialLayer } : { spatialLayer, temporalLayer };
901
+ this.receiveQualities.delete(producerId);
828
902
  // Kept first: a reconnect that interrupts the request still applies it.
829
903
  this.preferredLayers.set(producerId, layers);
830
904
  await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers });
@@ -846,6 +920,28 @@ class StreamClient {
846
920
  (0, stats_js_1.pruneBaseline)(baseline, (key) => key.startsWith(prefix));
847
921
  return (0, stats_js_1.parseSendStats)(report.values(), { quality: this.videoQuality, baseline, keyPrefix: prefix });
848
922
  }
923
+ /** Friendly receive ceiling using this producer's real layer sizes; auto restores adaptation. */
924
+ async setReceiveQuality(producerId, quality) {
925
+ if (!['auto', '1080p', '720p', '480p'].includes(quality))
926
+ throw new RangeError('setReceiveQuality: unsupported quality');
927
+ const consumer = this.consumers.get(producerId);
928
+ if (!consumer || consumer.kind !== 'video')
929
+ throw new Error('setReceiveQuality: this client is not receiving that video');
930
+ let layers;
931
+ if (quality === 'auto')
932
+ layers = { spatialLayer: -1, temporalLayer: -1 };
933
+ else {
934
+ try {
935
+ layers = { spatialLayer: (0, quality_js_1.receiveSpatialLayer)(this.remoteVideoLayers.get(producerId) ?? [], quality) };
936
+ }
937
+ catch {
938
+ throw new RpcError('quality-unavailable', 'this publisher does not describe its video layers; use Auto');
939
+ }
940
+ }
941
+ this.receiveQualities.set(producerId, quality);
942
+ this.preferredLayers.set(producerId, layers);
943
+ await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers });
944
+ }
849
945
  cameraProducer() {
850
946
  for (const p of this.producers.values()) {
851
947
  if (p.kind === 'video' && p.appData.source === 'camera')
@@ -902,7 +998,8 @@ class StreamClient {
902
998
  // videoEncodings).
903
999
  const { width = 0, height = 0 } = track.getSettings();
904
1000
  const quality = this.videoQuality;
905
- const encodings = (0, quality_js_1.videoEncodings)(width, height, quality, this.layerCount());
1001
+ this.cameraFrameRate = this.frameRateFor(track);
1002
+ const encodings = (0, quality_js_1.videoEncodings)(width, height, quality, this.layerCount(), this.cameraFrameRate);
906
1003
  let codecs;
907
1004
  try {
908
1005
  codecs = this.device?.sendRtpCapabilities.codecs;
@@ -917,7 +1014,7 @@ class StreamClient {
917
1014
  encodings,
918
1015
  codecOptions: { videoGoogleStartBitrate: VIDEO_START_BITRATE_KBPS },
919
1016
  ...(codec ? { codec } : {}),
920
- appData: { source: 'camera' },
1017
+ appData: { source: 'camera', videoLayers: (0, quality_js_1.describeVideoLayers)(width, height, encodings, quality) },
921
1018
  });
922
1019
  let producer;
923
1020
  try {
@@ -935,6 +1032,8 @@ class StreamClient {
935
1032
  // quality change or a track swap under way).
936
1033
  await this.keepProducer(producer, generation, 'camera');
937
1034
  this.cameraEncodings = encodings;
1035
+ this.frameRateLowSamples = 0;
1036
+ this.frameRateWarmupSamples = 2;
938
1037
  await this.pauseOnServer(producer);
939
1038
  await this.applyDegradationPreference(producer);
940
1039
  this.syncLimitWatch();
@@ -1014,7 +1113,7 @@ class StreamClient {
1014
1113
  }
1015
1114
  /** videoLimited sampling runs while a camera is published and someone listens. */
1016
1115
  syncLimitWatch() {
1017
- this.limitWatch.set(!this.closedByUs && this.cameraProducer() !== undefined && this.listenerCount('videoLimited') > 0);
1116
+ this.limitWatch.set(!this.closedByUs && this.cameraProducer() !== undefined && (this.listenerCount('videoLimited') > 0 || this.cameraFrameRate === 60));
1018
1117
  }
1019
1118
  /**
1020
1119
  * Re-establish this client's broadcast after a reconnect.
@@ -1029,6 +1128,13 @@ class StreamClient {
1029
1128
  const generation = this.publishGeneration;
1030
1129
  // Music is not a device and cannot be "gone": publish it again whatever
1031
1130
  // happens to the camera and microphone below.
1131
+ const screen = this.screenStream;
1132
+ if (screen)
1133
+ await this.restoreScreen().catch(async (err) => {
1134
+ if (this.screenStream === screen)
1135
+ await this.stopScreen('stopped');
1136
+ this.emit('error', err);
1137
+ });
1032
1138
  if (this.music)
1033
1139
  await this.produceMusic(this.music, generation).catch((err) => this.emit('error', err));
1034
1140
  if (generation !== this.publishGeneration)
@@ -1063,6 +1169,170 @@ class StreamClient {
1063
1169
  return;
1064
1170
  await this.publish(stream);
1065
1171
  }
1172
+ // ------------------------------------------------------------- screen sharing
1173
+ /** Publish a screen beside the camera. Call from a click so capture permission can open. */
1174
+ async publishScreen(options = {}) {
1175
+ if (this.joinResult?.role !== 'host' && this.joinResult?.role !== 'cohost')
1176
+ throw new RpcError('forbidden', 'only hosts and cohosts can share a screen');
1177
+ if (this.screenStream || this.screenPending)
1178
+ throw new RpcError('screen-share-active', 'a screen share is already active');
1179
+ if (options.mode !== undefined && options.mode !== 'detail' && options.mode !== 'motion')
1180
+ throw new TypeError('publishScreen: mode must be detail or motion');
1181
+ const mode = options.mode ?? 'detail';
1182
+ if (!options.stream && (typeof navigator === 'undefined' || !navigator.mediaDevices?.getDisplayMedia)) {
1183
+ throw new RpcError('screen-share-unsupported', 'screen capture is not supported by this browser; use a supported desktop browser');
1184
+ }
1185
+ const generation = this.screenGeneration;
1186
+ this.screenPending = true;
1187
+ let stream;
1188
+ try {
1189
+ stream = options.stream ?? await navigator.mediaDevices.getDisplayMedia({
1190
+ video: { width: { ideal: 1920, max: 1920 }, height: { ideal: 1080, max: 1080 }, frameRate: { ideal: mode === 'motion' ? 30 : 15, max: mode === 'motion' ? 30 : 15 } },
1191
+ audio: options.audio ?? false,
1192
+ });
1193
+ const video = stream.getVideoTracks()[0];
1194
+ if (!video || video.readyState !== 'live')
1195
+ throw new RpcError('screen-share-no-video', 'screen capture did not provide a live video track');
1196
+ if (generation !== this.screenGeneration || this.closedByUs)
1197
+ throw new Error(unpublishedWhile('screen'));
1198
+ this.screenStream = stream;
1199
+ this.screenMode = mode;
1200
+ this.screenAudioEnabled = options.audio !== false;
1201
+ video.contentHint = mode;
1202
+ const onEnded = () => { void this.stopScreen('track-ended').catch((err) => this.emit('error', err)); };
1203
+ this.screenEndedListener = onEnded;
1204
+ video.addEventListener('ended', onEnded, { once: true });
1205
+ await this.restoreScreen();
1206
+ if (generation !== this.screenGeneration || video.readyState !== 'live')
1207
+ throw new Error(unpublishedWhile('screen'));
1208
+ this.screenStarted = true;
1209
+ this.wasPublishing = true;
1210
+ this.emit('screenShareStarted', { stream, mode });
1211
+ return stream;
1212
+ }
1213
+ catch (error) {
1214
+ if (generation === this.screenGeneration)
1215
+ await this.stopScreen('stopped');
1216
+ stream?.getTracks().forEach((track) => track.stop());
1217
+ throw error;
1218
+ }
1219
+ finally {
1220
+ if (generation === this.screenGeneration)
1221
+ this.screenPending = false;
1222
+ }
1223
+ }
1224
+ /** Stop only the screen and its tab/system audio; keep camera, microphone and music. */
1225
+ async unpublishScreen() {
1226
+ await this.stopScreen('stopped');
1227
+ }
1228
+ releaseScreen() {
1229
+ this.screenGeneration += 1;
1230
+ this.screenPending = false;
1231
+ const stream = this.screenStream;
1232
+ if (this.screenEndedListener)
1233
+ stream?.getVideoTracks()[0]?.removeEventListener('ended', this.screenEndedListener);
1234
+ this.screenEndedListener = null;
1235
+ this.screenStream = null;
1236
+ this.screenStarted = false;
1237
+ stream?.getTracks().forEach((track) => track.stop());
1238
+ }
1239
+ async stopScreen(reason) {
1240
+ const started = this.screenStarted;
1241
+ const ids = [...this.screenProducerIds];
1242
+ this.screenProducerIds.clear();
1243
+ this.releaseScreen();
1244
+ for (const id of ids) {
1245
+ this.producers.get(id)?.close();
1246
+ this.producers.delete(id);
1247
+ }
1248
+ if (!this.localStream && !this.music)
1249
+ this.wasPublishing = false;
1250
+ if (started && !this.closedByUs)
1251
+ this.emit('screenShareEnded', { reason });
1252
+ await Promise.all(ids.map((producerId) => this.rpc('closeProducer', { producerId }).catch(() => undefined)));
1253
+ }
1254
+ async restoreScreen() {
1255
+ const stream = this.screenStream;
1256
+ const video = stream?.getVideoTracks()[0];
1257
+ if (!stream || !video)
1258
+ return;
1259
+ if (video.readyState !== 'live') {
1260
+ await this.stopScreen('track-ended');
1261
+ return;
1262
+ }
1263
+ const generation = this.screenGeneration;
1264
+ const publishing = this.publishGeneration;
1265
+ const transport = await this.ensureSendTransport();
1266
+ if (generation !== this.screenGeneration)
1267
+ return;
1268
+ const { width = 0, height = 0 } = video.getSettings();
1269
+ const encodings = (0, quality_js_1.screenEncodings)(width, height, this.screenMode);
1270
+ // A project's explicit safety restriction also applies to screen video.
1271
+ const cap = (0, quality_js_1.topLayerSize)(width, height, this.videoPolicy.maxQuality);
1272
+ const screenScale = encodings.at(-1).scaleResolutionDownBy;
1273
+ if (cap.scale > screenScale)
1274
+ encodings.forEach((e) => { e.scaleResolutionDownBy *= cap.scale / screenScale; });
1275
+ const keep = async (producer) => {
1276
+ if (generation !== this.screenGeneration || publishing !== this.publishGeneration || video.readyState !== 'live') {
1277
+ producer.close();
1278
+ await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
1279
+ throw new Error(unpublishedWhile('screen'));
1280
+ }
1281
+ this.producers.set(producer.id, producer);
1282
+ this.screenProducerIds.add(producer.id);
1283
+ await this.pauseOnServer(producer);
1284
+ };
1285
+ await keep(await transport.produce({
1286
+ track: video, stopTracks: false, encodings,
1287
+ codecOptions: { videoGoogleStartBitrate: VIDEO_START_BITRATE_KBPS },
1288
+ appData: { source: 'screen', videoLayers: (0, quality_js_1.describeVideoLayers)(width, height, encodings) },
1289
+ }));
1290
+ const audio = stream.getAudioTracks().find((track) => track.readyState === 'live');
1291
+ if (audio && this.screenAudioEnabled && generation === this.screenGeneration)
1292
+ await keep(await transport.produce({
1293
+ track: audio, stopTracks: false,
1294
+ codecOptions: { opusStereo: true, opusDtx: false, opusFec: true, opusMaxAverageBitrate: 128000 },
1295
+ appData: { source: 'screen-audio' },
1296
+ }));
1297
+ }
1298
+ frameRateFor(track) {
1299
+ if (this.publishOptions.frameRate !== 60 || this.frameRateFallback)
1300
+ return 30;
1301
+ const captured = track.getSettings().frameRate;
1302
+ if (this.videoPolicy.maxFrameRate === 30 || (typeof captured === 'number' && captured < 50)) {
1303
+ this.frameRateFallback = true;
1304
+ this.emit('error', new RpcError('frame-rate-fallback', '60 fps is unavailable from this camera or project; publishing at 30 fps'));
1305
+ return 30;
1306
+ }
1307
+ return 60;
1308
+ }
1309
+ async sampleCamera(baseline) {
1310
+ const stats = await this.collectVideoStats(baseline);
1311
+ if (this.cameraFrameRate !== 60 || this.frameRateFallback || !stats)
1312
+ return stats;
1313
+ if (this.cameraProducer()?.paused)
1314
+ return stats;
1315
+ // The lower simulcast copies intentionally run at 30 fps. During the
1316
+ // initial bandwidth ramp they may be the only active copies, which does
1317
+ // not establish that the camera cannot sustain 60. Match native warm-up:
1318
+ // two 2-second samples, then require three consecutive limited samples.
1319
+ if (this.frameRateWarmupSamples-- > 0)
1320
+ return stats;
1321
+ const limited = stats.layers.some((layer) => layer.active && (layer.qualityLimitation === 'cpu' || layer.qualityLimitation === 'bandwidth'));
1322
+ this.frameRateLowSamples = limited && stats.fps > 0 && stats.fps < 50 ? this.frameRateLowSamples + 1 : 0;
1323
+ if (this.frameRateLowSamples >= 3) {
1324
+ this.frameRateFallback = true;
1325
+ void this.cameraTask(async () => {
1326
+ const producer = this.cameraProducer();
1327
+ if (!producer?.track || producer.closed)
1328
+ return;
1329
+ await producer.track.applyConstraints?.({ frameRate: { ideal: 30, max: 30 } }).catch(() => undefined);
1330
+ await this.reproduceCamera(producer, producer.track);
1331
+ this.emit('error', new RpcError('frame-rate-fallback', 'this device or upload cannot sustain 60 fps; publishing at 30 fps'));
1332
+ }).catch((error) => this.emit('error', error));
1333
+ }
1334
+ return stats;
1335
+ }
1066
1336
  // ------------------------------------------------------------------ music
1067
1337
  /**
1068
1338
  * Publish music playing inside the page, as its own track.
@@ -1173,10 +1443,21 @@ class StreamClient {
1173
1443
  }));
1174
1444
  const consumer = await transport.consume(params);
1175
1445
  this.consumers.set(remote.producerId, consumer);
1446
+ this.remoteVideoLayers.set(remote.producerId, remote.videoLayers ?? remote.appData?.videoLayers ?? params.videoLayers ?? []);
1176
1447
  // Layers asked for before a reconnect: the new consumer gets them before
1177
1448
  // its first frame instead of starting at the full picture. Not getting
1178
1449
  // them costs data, not the track, so a refusal is only reported.
1179
- const layers = this.preferredLayers.get(remote.producerId);
1450
+ let layers = this.preferredLayers.get(remote.producerId);
1451
+ const receiveQuality = this.receiveQualities.get(remote.producerId);
1452
+ if (receiveQuality && receiveQuality !== 'auto') {
1453
+ try {
1454
+ layers = { spatialLayer: (0, quality_js_1.receiveSpatialLayer)(this.remoteVideoLayers.get(remote.producerId) ?? [], receiveQuality) };
1455
+ }
1456
+ catch {
1457
+ layers = undefined;
1458
+ this.emit('error', new RpcError('quality-unavailable', 'this publisher no longer describes its video layers; using Auto'));
1459
+ }
1460
+ }
1180
1461
  if (layers) {
1181
1462
  await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers }).catch((err) => this.emit('error', err));
1182
1463
  }
@@ -1208,7 +1489,7 @@ class StreamClient {
1208
1489
  }
1209
1490
  trackEnabled(kind) {
1210
1491
  for (const p of this.producers.values()) {
1211
- if (p.kind === kind && p.id !== this.musicProducerId)
1492
+ if (p.kind === kind && p.appData.source === defaultSource(kind))
1212
1493
  return !p.paused;
1213
1494
  }
1214
1495
  return false;
@@ -1224,16 +1505,31 @@ class StreamClient {
1224
1505
  async setCamEnabled(enabled) {
1225
1506
  await this.setKindEnabled('video', enabled);
1226
1507
  }
1508
+ applyLocalModeration(kind) {
1509
+ for (const producer of this.producers.values()) {
1510
+ if (!kind || producer.kind === kind)
1511
+ producer.pause();
1512
+ }
1513
+ // During reconnect there are no producers, but the retained tracks still
1514
+ // capture. Keep their muted state for republish as well.
1515
+ for (const stream of [this.localStream, this.screenStream]) {
1516
+ for (const track of stream?.getTracks() ?? [])
1517
+ if (!kind || track.kind === kind)
1518
+ track.enabled = false;
1519
+ }
1520
+ if (!kind || kind === 'audio')
1521
+ this.music?.setEnabled(false);
1522
+ }
1227
1523
  async setKindEnabled(kind, enabled) {
1228
1524
  for (const producer of this.producers.values()) {
1229
1525
  // The music has its own switch. Muting the microphone must not stop the song.
1230
- if (producer.kind !== kind || producer.id === this.musicProducerId)
1526
+ if (producer.kind !== kind || producer.appData.source !== defaultSource(kind))
1231
1527
  continue;
1232
- if (enabled)
1233
- producer.resume();
1234
- else
1528
+ if (!enabled)
1235
1529
  producer.pause();
1236
1530
  await this.rpc('pauseProducer', { producerId: producer.id, paused: !enabled });
1531
+ if (enabled)
1532
+ producer.resume();
1237
1533
  }
1238
1534
  this.emitLocalMedia();
1239
1535
  }
@@ -1244,6 +1540,10 @@ class StreamClient {
1244
1540
  // called while this still waits is not undone by it, and one already
1245
1541
  // under way stops at its next step (keepProducer).
1246
1542
  this.publishGeneration += 1;
1543
+ const screenWasStarted = this.screenStarted;
1544
+ this.releaseScreen();
1545
+ if (screenWasStarted)
1546
+ this.emit('screenShareEnded', { reason: 'stopped' });
1247
1547
  const ids = [...this.producers.keys()];
1248
1548
  for (const producer of this.producers.values())
1249
1549
  producer.close();
@@ -1273,8 +1573,8 @@ class StreamClient {
1273
1573
  return this.localStream;
1274
1574
  }
1275
1575
  // ------------------------------------------------------------- moderation
1276
- raiseHand() { return this.rpc('raiseHand'); }
1277
- lowerHand() { return this.rpc('lowerHand'); }
1576
+ raiseHand(metadata) { return this.rpc('raiseHand', metadata ? { metadata } : {}); }
1577
+ lowerHand(identity) { return this.rpc('lowerHand', identity ? { identity } : {}); }
1278
1578
  muteParticipant(identity) { return this.rpc('requestMute', { identity }); }
1279
1579
  /**
1280
1580
  * Moderating roles: switch someone's microphone (`audio`), camera (`video`)