@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/esm/index.js CHANGED
@@ -8,7 +8,7 @@
8
8
  import { Device } from 'mediasoup-client';
9
9
  import { chooseCodec, currentEnv, defaultLayerCount, findCodec, h264HardwareEncode, isAppleMobile, isPhoneOrTablet, normalizeCodec, } from './device.js';
10
10
  import { MusicSource } from './music.js';
11
- import { allowedQuality, DEFAULT_VIDEO_POLICY, sameLayout, topLayerSize, videoEncodings, } from './quality.js';
11
+ import { allowedQuality, DEFAULT_VIDEO_POLICY, sameLayout, describeVideoLayers, receiveSpatialLayer, screenEncodings, topLayerSize, videoEncodings, } from './quality.js';
12
12
  import { parseReceiveStats, parseSendStats, pruneBaseline, VideoLimitWatch, } from './stats.js';
13
13
  export { BroadcastPublisher, WhipError, whipEnd, whipOffer } from './broadcast.js';
14
14
  export { AudioMixer, MusicSource } from './music.js';
@@ -55,7 +55,7 @@ const isLayer = (value) => Number.isInteger(value) && value >= 0;
55
55
  * Where a new camera stream's bandwidth estimate starts, in kbps. Chrome
56
56
  * otherwise starts low and takes many seconds to climb to the top layer.
57
57
  */
58
- const VIDEO_START_BITRATE_KBPS = 1500;
58
+ const VIDEO_START_BITRATE_KBPS = 2000;
59
59
  /** Why a publish gave up: unpublish() ran while it was under way, and unpublish wins. */
60
60
  const unpublishedWhile = (what) => `the ${what} was unpublished while it was being published`;
61
61
  export class StreamClient {
@@ -88,6 +88,20 @@ export class StreamClient {
88
88
  /** In-app music being published, kept across reconnects so it can be published again. */
89
89
  music = null;
90
90
  musicProducerId = null;
91
+ screenStream = null;
92
+ screenMode = 'detail';
93
+ screenAudioEnabled = true;
94
+ screenGeneration = 0;
95
+ screenPending = false;
96
+ screenStarted = false;
97
+ screenEndedListener = null;
98
+ screenProducerIds = new Set();
99
+ remoteVideoLayers = new Map();
100
+ receiveQualities = new Map();
101
+ cameraFrameRate = 30;
102
+ frameRateFallback = false;
103
+ frameRateLowSamples = 0;
104
+ frameRateWarmupSamples = 2;
91
105
  /** What each remote producer carries, so a pause can say "music" rather than just "audio". */
92
106
  remoteSources = new Map();
93
107
  /**
@@ -114,7 +128,7 @@ export class StreamClient {
114
128
  codecFallbackLogged = false;
115
129
  sendStatsBaseline = new Map();
116
130
  recvStatsBaseline = new Map();
117
- limitWatch = new VideoLimitWatch((baseline) => this.collectVideoStats(baseline), (event) => this.emit('videoLimited', event));
131
+ limitWatch = new VideoLimitWatch((baseline) => this.sampleCamera(baseline), (event) => this.emit('videoLimited', event));
118
132
  constructor(wsUrl, token) {
119
133
  this.wsUrl = wsUrl;
120
134
  this.token = token;
@@ -172,14 +186,30 @@ export class StreamClient {
172
186
  // retried about twice a second, forever.
173
187
  this.reconnectAttempt = 0;
174
188
  this.joinedOnce = true;
189
+ // A role or mute can change while the socket is away. The fresh roster
190
+ // decides what may return; reconnecting never turns a blocked device on.
191
+ if (result.role !== 'host' && result.role !== 'cohost' && (this.wasPublishing || this.screenPending)) {
192
+ await this.unpublish();
193
+ }
194
+ const own = result.participants.find((p) => p.identity === result.identity);
195
+ if (own?.muted)
196
+ this.applyLocalModeration('audio');
197
+ if (own?.videoBlocked)
198
+ this.applyLocalModeration('video');
175
199
  this.emit('joined', result);
176
200
  // A video that ended while this client was away sends it no
177
201
  // producerClosed: forget the layers asked for it.
178
202
  const present = new Set(result.producers.map((p) => p.producerId));
179
203
  for (const producerId of this.preferredLayers.keys()) {
180
- if (!present.has(producerId))
204
+ if (!present.has(producerId)) {
181
205
  this.preferredLayers.delete(producerId);
206
+ this.receiveQualities.delete(producerId);
207
+ this.remoteVideoLayers.delete(producerId);
208
+ }
182
209
  }
210
+ for (const producerId of this.remoteVideoLayers.keys())
211
+ if (!present.has(producerId))
212
+ this.remoteVideoLayers.delete(producerId);
183
213
  // Subscribe to everything already publishing before we announce readiness.
184
214
  for (const p of result.producers) {
185
215
  await this.consume(p).catch((err) => this.emit('error', err));
@@ -323,9 +353,15 @@ export class StreamClient {
323
353
  // others have already reported themselves and set closedByUs.
324
354
  const wasOpen = !this.closedByUs;
325
355
  this.closedByUs = true;
356
+ this.publishGeneration += 1;
326
357
  this.wasPublishing = false;
327
358
  this.teardownMedia();
328
359
  this.preferredLayers.clear();
360
+ this.receiveQualities.clear();
361
+ this.remoteVideoLayers.clear();
362
+ this.releaseScreen();
363
+ this.music?.close();
364
+ this.music = null;
329
365
  // Nothing is emitted after closing, so not even a clearing videoLimited.
330
366
  this.limitWatch.reset();
331
367
  // Producers no longer stop their tracks, so releasing the camera and
@@ -346,6 +382,7 @@ export class StreamClient {
346
382
  p.close();
347
383
  this.producers.clear();
348
384
  this.musicProducerId = null;
385
+ this.screenProducerIds.clear();
349
386
  this.cameraEncodings = null;
350
387
  // Paused, not reset: a reconnect's republish starts it again, and a
351
388
  // limitation already reported is not reported twice.
@@ -419,6 +456,8 @@ export class StreamClient {
419
456
  case 'producerClosed': {
420
457
  const { producerId, identity } = data;
421
458
  this.preferredLayers.delete(producerId);
459
+ this.receiveQualities.delete(producerId);
460
+ this.remoteVideoLayers.delete(producerId);
422
461
  const consumer = this.consumers.get(producerId);
423
462
  if (!consumer)
424
463
  break; // never consumed it, or already cleaned up
@@ -442,16 +481,29 @@ export class StreamClient {
442
481
  case 'participantJoined':
443
482
  this.emit('participantJoined', data);
444
483
  break;
484
+ case 'participantMuted': {
485
+ const wire = data;
486
+ const e = { identity: wire.identity, kind: wire.kind, paused: wire.paused ?? wire.muted ?? false };
487
+ if (this.joinResult)
488
+ this.joinResult.participants = this.joinResult.participants.map((p) => p.identity !== e.identity ? p : { ...p,
489
+ ...(!e.kind || e.kind === 'audio' ? { muted: e.paused } : {}),
490
+ ...(!e.kind || e.kind === 'video' ? { videoBlocked: e.paused } : {}),
491
+ });
492
+ this.emit('participantMuted', e);
493
+ break;
494
+ }
445
495
  case 'participantLeft':
446
496
  this.emit('participantLeft', data);
447
497
  break;
448
498
  case 'roleChanged': {
449
499
  const e = data;
500
+ if (e.identity === this.joinResult?.identity)
501
+ this.joinResult.role = e.role;
450
502
  this.emit('roleChanged', e);
451
503
  // Demoted: the server has already closed our producers. Release the
452
504
  // camera and microphone too, instead of capturing for nobody.
453
505
  const canPublish = e.role === 'host' || e.role === 'cohost';
454
- if (e.identity === this.joinResult?.identity && !canPublish && this.producers.size > 0) {
506
+ if (e.identity === this.joinResult?.identity && !canPublish && (this.wasPublishing || this.screenPending)) {
455
507
  void this.unpublish().catch(() => undefined);
456
508
  }
457
509
  break;
@@ -466,10 +518,7 @@ export class StreamClient {
466
518
  // changes nothing here -- switching back on is the user's choice.
467
519
  const e = data;
468
520
  if (e.paused) {
469
- for (const p of this.producers.values()) {
470
- if (!e.kind || p.kind === e.kind)
471
- p.pause();
472
- }
521
+ this.applyLocalModeration(e.kind);
473
522
  this.emitLocalMedia();
474
523
  }
475
524
  this.emit('forceMuted', e);
@@ -557,7 +606,7 @@ export class StreamClient {
557
606
  .catch(fail);
558
607
  });
559
608
  transport.on('produce', ({ kind, rtpParameters, appData }, ok, fail) => {
560
- this.rpc('produce', { transportId: transport.id, kind, rtpParameters, appData, source: appData.source })
609
+ this.rpc('produce', { transportId: transport.id, kind, rtpParameters, appData, source: appData.source, videoLayers: appData.videoLayers })
561
610
  .then((res) => ok({ id: res.producerId }))
562
611
  .catch(fail);
563
612
  });
@@ -613,8 +662,11 @@ export class StreamClient {
613
662
  this.wasPublishing = true;
614
663
  if (constraints)
615
664
  this.publishConstraints = constraints;
616
- if (options)
665
+ if (options) {
617
666
  this.publishOptions = { ...options };
667
+ this.frameRateFallback = false;
668
+ this.frameRateLowSamples = 0;
669
+ }
618
670
  const audio = stream.getAudioTracks()[0];
619
671
  if (audio) {
620
672
  const producer = await transport.produce({
@@ -636,7 +688,7 @@ export class StreamClient {
636
688
  await this.cameraTask(() => this.produceCamera(transport, this.localStream?.getVideoTracks()[0] ?? video, generation));
637
689
  }
638
690
  }
639
- /** What this project may publish. Known once joined; 720p before that. */
691
+ /** What this project may publish. Known once joined; 1080p before that. */
640
692
  get videoPolicy() {
641
693
  return this.joinResult?.video ?? DEFAULT_VIDEO_POLICY;
642
694
  }
@@ -661,11 +713,30 @@ export class StreamClient {
661
713
  });
662
714
  return this.videoQuality;
663
715
  }
716
+ /** Change the camera frame rate without stopping screen, microphone or music. */
717
+ async setVideoFrameRate(frameRate) {
718
+ if (frameRate !== 30 && frameRate !== 60)
719
+ throw new RangeError('setVideoFrameRate: expected 30 or 60');
720
+ this.publishOptions = { ...this.publishOptions, frameRate };
721
+ this.frameRateFallback = false;
722
+ this.frameRateLowSamples = 0;
723
+ await this.cameraTask(async () => {
724
+ const current = this.cameraProducer();
725
+ const track = current?.track;
726
+ if (!current || !track || track.readyState !== 'live')
727
+ return;
728
+ try {
729
+ await track.applyConstraints?.({ frameRate: { ideal: frameRate, max: frameRate } });
730
+ }
731
+ catch { /* Existing capture settings decide whether 60 fps is possible. */ }
732
+ await this.reproduceCamera(current, track);
733
+ });
734
+ return this.cameraProducer() ? this.cameraFrameRate : this.videoPolicy.maxFrameRate === 30 ? 30 : frameRate;
735
+ }
664
736
  /**
665
737
  * Send a different video track from now on: a canvas with effects drawn on
666
738
  * 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
739
+ * same dimensions and layers as the current one, viewers see no pause, and the layers
669
740
  * take the new picture's bitrates. Otherwise the camera producer is
670
741
  * replaced, as `setVideoQuality` does. Either way a paused camera stays
671
742
  * paused.
@@ -706,8 +777,10 @@ export class StreamClient {
706
777
  const paused = current.paused;
707
778
  try {
708
779
  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)) {
780
+ const next = videoEncodings(width, height, this.videoQuality, this.layerCount(), this.frameRateFor(track));
781
+ const previousSize = current.track?.getSettings();
782
+ if (this.cameraEncodings && sameLayout(this.cameraEncodings, next) &&
783
+ previousSize?.width === width && previousSize?.height === height) {
711
784
  await current.replaceTrack({ track });
712
785
  await this.applyLayerBitrates(current, next);
713
786
  return;
@@ -809,6 +882,7 @@ export class StreamClient {
809
882
  throw new Error(`setPreferredLayers: this client is not receiving a video with producerId ${producerId}`);
810
883
  }
811
884
  const layers = temporalLayer === undefined ? { spatialLayer } : { spatialLayer, temporalLayer };
885
+ this.receiveQualities.delete(producerId);
812
886
  // Kept first: a reconnect that interrupts the request still applies it.
813
887
  this.preferredLayers.set(producerId, layers);
814
888
  await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers });
@@ -830,6 +904,28 @@ export class StreamClient {
830
904
  pruneBaseline(baseline, (key) => key.startsWith(prefix));
831
905
  return parseSendStats(report.values(), { quality: this.videoQuality, baseline, keyPrefix: prefix });
832
906
  }
907
+ /** Friendly receive ceiling using this producer's real layer sizes; auto restores adaptation. */
908
+ async setReceiveQuality(producerId, quality) {
909
+ if (!['auto', '1080p', '720p', '480p'].includes(quality))
910
+ throw new RangeError('setReceiveQuality: unsupported quality');
911
+ const consumer = this.consumers.get(producerId);
912
+ if (!consumer || consumer.kind !== 'video')
913
+ throw new Error('setReceiveQuality: this client is not receiving that video');
914
+ let layers;
915
+ if (quality === 'auto')
916
+ layers = { spatialLayer: -1, temporalLayer: -1 };
917
+ else {
918
+ try {
919
+ layers = { spatialLayer: receiveSpatialLayer(this.remoteVideoLayers.get(producerId) ?? [], quality) };
920
+ }
921
+ catch {
922
+ throw new RpcError('quality-unavailable', 'this publisher does not describe its video layers; use Auto');
923
+ }
924
+ }
925
+ this.receiveQualities.set(producerId, quality);
926
+ this.preferredLayers.set(producerId, layers);
927
+ await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers });
928
+ }
833
929
  cameraProducer() {
834
930
  for (const p of this.producers.values()) {
835
931
  if (p.kind === 'video' && p.appData.source === 'camera')
@@ -886,7 +982,8 @@ export class StreamClient {
886
982
  // videoEncodings).
887
983
  const { width = 0, height = 0 } = track.getSettings();
888
984
  const quality = this.videoQuality;
889
- const encodings = videoEncodings(width, height, quality, this.layerCount());
985
+ this.cameraFrameRate = this.frameRateFor(track);
986
+ const encodings = videoEncodings(width, height, quality, this.layerCount(), this.cameraFrameRate);
890
987
  let codecs;
891
988
  try {
892
989
  codecs = this.device?.sendRtpCapabilities.codecs;
@@ -901,7 +998,7 @@ export class StreamClient {
901
998
  encodings,
902
999
  codecOptions: { videoGoogleStartBitrate: VIDEO_START_BITRATE_KBPS },
903
1000
  ...(codec ? { codec } : {}),
904
- appData: { source: 'camera' },
1001
+ appData: { source: 'camera', videoLayers: describeVideoLayers(width, height, encodings, quality) },
905
1002
  });
906
1003
  let producer;
907
1004
  try {
@@ -919,6 +1016,8 @@ export class StreamClient {
919
1016
  // quality change or a track swap under way).
920
1017
  await this.keepProducer(producer, generation, 'camera');
921
1018
  this.cameraEncodings = encodings;
1019
+ this.frameRateLowSamples = 0;
1020
+ this.frameRateWarmupSamples = 2;
922
1021
  await this.pauseOnServer(producer);
923
1022
  await this.applyDegradationPreference(producer);
924
1023
  this.syncLimitWatch();
@@ -998,7 +1097,7 @@ export class StreamClient {
998
1097
  }
999
1098
  /** videoLimited sampling runs while a camera is published and someone listens. */
1000
1099
  syncLimitWatch() {
1001
- this.limitWatch.set(!this.closedByUs && this.cameraProducer() !== undefined && this.listenerCount('videoLimited') > 0);
1100
+ this.limitWatch.set(!this.closedByUs && this.cameraProducer() !== undefined && (this.listenerCount('videoLimited') > 0 || this.cameraFrameRate === 60));
1002
1101
  }
1003
1102
  /**
1004
1103
  * Re-establish this client's broadcast after a reconnect.
@@ -1013,6 +1112,13 @@ export class StreamClient {
1013
1112
  const generation = this.publishGeneration;
1014
1113
  // Music is not a device and cannot be "gone": publish it again whatever
1015
1114
  // happens to the camera and microphone below.
1115
+ const screen = this.screenStream;
1116
+ if (screen)
1117
+ await this.restoreScreen().catch(async (err) => {
1118
+ if (this.screenStream === screen)
1119
+ await this.stopScreen('stopped');
1120
+ this.emit('error', err);
1121
+ });
1016
1122
  if (this.music)
1017
1123
  await this.produceMusic(this.music, generation).catch((err) => this.emit('error', err));
1018
1124
  if (generation !== this.publishGeneration)
@@ -1047,6 +1153,170 @@ export class StreamClient {
1047
1153
  return;
1048
1154
  await this.publish(stream);
1049
1155
  }
1156
+ // ------------------------------------------------------------- screen sharing
1157
+ /** Publish a screen beside the camera. Call from a click so capture permission can open. */
1158
+ async publishScreen(options = {}) {
1159
+ if (this.joinResult?.role !== 'host' && this.joinResult?.role !== 'cohost')
1160
+ throw new RpcError('forbidden', 'only hosts and cohosts can share a screen');
1161
+ if (this.screenStream || this.screenPending)
1162
+ throw new RpcError('screen-share-active', 'a screen share is already active');
1163
+ if (options.mode !== undefined && options.mode !== 'detail' && options.mode !== 'motion')
1164
+ throw new TypeError('publishScreen: mode must be detail or motion');
1165
+ const mode = options.mode ?? 'detail';
1166
+ if (!options.stream && (typeof navigator === 'undefined' || !navigator.mediaDevices?.getDisplayMedia)) {
1167
+ throw new RpcError('screen-share-unsupported', 'screen capture is not supported by this browser; use a supported desktop browser');
1168
+ }
1169
+ const generation = this.screenGeneration;
1170
+ this.screenPending = true;
1171
+ let stream;
1172
+ try {
1173
+ stream = options.stream ?? await navigator.mediaDevices.getDisplayMedia({
1174
+ video: { width: { ideal: 1920, max: 1920 }, height: { ideal: 1080, max: 1080 }, frameRate: { ideal: mode === 'motion' ? 30 : 15, max: mode === 'motion' ? 30 : 15 } },
1175
+ audio: options.audio ?? false,
1176
+ });
1177
+ const video = stream.getVideoTracks()[0];
1178
+ if (!video || video.readyState !== 'live')
1179
+ throw new RpcError('screen-share-no-video', 'screen capture did not provide a live video track');
1180
+ if (generation !== this.screenGeneration || this.closedByUs)
1181
+ throw new Error(unpublishedWhile('screen'));
1182
+ this.screenStream = stream;
1183
+ this.screenMode = mode;
1184
+ this.screenAudioEnabled = options.audio !== false;
1185
+ video.contentHint = mode;
1186
+ const onEnded = () => { void this.stopScreen('track-ended').catch((err) => this.emit('error', err)); };
1187
+ this.screenEndedListener = onEnded;
1188
+ video.addEventListener('ended', onEnded, { once: true });
1189
+ await this.restoreScreen();
1190
+ if (generation !== this.screenGeneration || video.readyState !== 'live')
1191
+ throw new Error(unpublishedWhile('screen'));
1192
+ this.screenStarted = true;
1193
+ this.wasPublishing = true;
1194
+ this.emit('screenShareStarted', { stream, mode });
1195
+ return stream;
1196
+ }
1197
+ catch (error) {
1198
+ if (generation === this.screenGeneration)
1199
+ await this.stopScreen('stopped');
1200
+ stream?.getTracks().forEach((track) => track.stop());
1201
+ throw error;
1202
+ }
1203
+ finally {
1204
+ if (generation === this.screenGeneration)
1205
+ this.screenPending = false;
1206
+ }
1207
+ }
1208
+ /** Stop only the screen and its tab/system audio; keep camera, microphone and music. */
1209
+ async unpublishScreen() {
1210
+ await this.stopScreen('stopped');
1211
+ }
1212
+ releaseScreen() {
1213
+ this.screenGeneration += 1;
1214
+ this.screenPending = false;
1215
+ const stream = this.screenStream;
1216
+ if (this.screenEndedListener)
1217
+ stream?.getVideoTracks()[0]?.removeEventListener('ended', this.screenEndedListener);
1218
+ this.screenEndedListener = null;
1219
+ this.screenStream = null;
1220
+ this.screenStarted = false;
1221
+ stream?.getTracks().forEach((track) => track.stop());
1222
+ }
1223
+ async stopScreen(reason) {
1224
+ const started = this.screenStarted;
1225
+ const ids = [...this.screenProducerIds];
1226
+ this.screenProducerIds.clear();
1227
+ this.releaseScreen();
1228
+ for (const id of ids) {
1229
+ this.producers.get(id)?.close();
1230
+ this.producers.delete(id);
1231
+ }
1232
+ if (!this.localStream && !this.music)
1233
+ this.wasPublishing = false;
1234
+ if (started && !this.closedByUs)
1235
+ this.emit('screenShareEnded', { reason });
1236
+ await Promise.all(ids.map((producerId) => this.rpc('closeProducer', { producerId }).catch(() => undefined)));
1237
+ }
1238
+ async restoreScreen() {
1239
+ const stream = this.screenStream;
1240
+ const video = stream?.getVideoTracks()[0];
1241
+ if (!stream || !video)
1242
+ return;
1243
+ if (video.readyState !== 'live') {
1244
+ await this.stopScreen('track-ended');
1245
+ return;
1246
+ }
1247
+ const generation = this.screenGeneration;
1248
+ const publishing = this.publishGeneration;
1249
+ const transport = await this.ensureSendTransport();
1250
+ if (generation !== this.screenGeneration)
1251
+ return;
1252
+ const { width = 0, height = 0 } = video.getSettings();
1253
+ const encodings = screenEncodings(width, height, this.screenMode);
1254
+ // A project's explicit safety restriction also applies to screen video.
1255
+ const cap = topLayerSize(width, height, this.videoPolicy.maxQuality);
1256
+ const screenScale = encodings.at(-1).scaleResolutionDownBy;
1257
+ if (cap.scale > screenScale)
1258
+ encodings.forEach((e) => { e.scaleResolutionDownBy *= cap.scale / screenScale; });
1259
+ const keep = async (producer) => {
1260
+ if (generation !== this.screenGeneration || publishing !== this.publishGeneration || video.readyState !== 'live') {
1261
+ producer.close();
1262
+ await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
1263
+ throw new Error(unpublishedWhile('screen'));
1264
+ }
1265
+ this.producers.set(producer.id, producer);
1266
+ this.screenProducerIds.add(producer.id);
1267
+ await this.pauseOnServer(producer);
1268
+ };
1269
+ await keep(await transport.produce({
1270
+ track: video, stopTracks: false, encodings,
1271
+ codecOptions: { videoGoogleStartBitrate: VIDEO_START_BITRATE_KBPS },
1272
+ appData: { source: 'screen', videoLayers: describeVideoLayers(width, height, encodings) },
1273
+ }));
1274
+ const audio = stream.getAudioTracks().find((track) => track.readyState === 'live');
1275
+ if (audio && this.screenAudioEnabled && generation === this.screenGeneration)
1276
+ await keep(await transport.produce({
1277
+ track: audio, stopTracks: false,
1278
+ codecOptions: { opusStereo: true, opusDtx: false, opusFec: true, opusMaxAverageBitrate: 128000 },
1279
+ appData: { source: 'screen-audio' },
1280
+ }));
1281
+ }
1282
+ frameRateFor(track) {
1283
+ if (this.publishOptions.frameRate !== 60 || this.frameRateFallback)
1284
+ return 30;
1285
+ const captured = track.getSettings().frameRate;
1286
+ if (this.videoPolicy.maxFrameRate === 30 || (typeof captured === 'number' && captured < 50)) {
1287
+ this.frameRateFallback = true;
1288
+ this.emit('error', new RpcError('frame-rate-fallback', '60 fps is unavailable from this camera or project; publishing at 30 fps'));
1289
+ return 30;
1290
+ }
1291
+ return 60;
1292
+ }
1293
+ async sampleCamera(baseline) {
1294
+ const stats = await this.collectVideoStats(baseline);
1295
+ if (this.cameraFrameRate !== 60 || this.frameRateFallback || !stats)
1296
+ return stats;
1297
+ if (this.cameraProducer()?.paused)
1298
+ return stats;
1299
+ // The lower simulcast copies intentionally run at 30 fps. During the
1300
+ // initial bandwidth ramp they may be the only active copies, which does
1301
+ // not establish that the camera cannot sustain 60. Match native warm-up:
1302
+ // two 2-second samples, then require three consecutive limited samples.
1303
+ if (this.frameRateWarmupSamples-- > 0)
1304
+ return stats;
1305
+ const limited = stats.layers.some((layer) => layer.active && (layer.qualityLimitation === 'cpu' || layer.qualityLimitation === 'bandwidth'));
1306
+ this.frameRateLowSamples = limited && stats.fps > 0 && stats.fps < 50 ? this.frameRateLowSamples + 1 : 0;
1307
+ if (this.frameRateLowSamples >= 3) {
1308
+ this.frameRateFallback = true;
1309
+ void this.cameraTask(async () => {
1310
+ const producer = this.cameraProducer();
1311
+ if (!producer?.track || producer.closed)
1312
+ return;
1313
+ await producer.track.applyConstraints?.({ frameRate: { ideal: 30, max: 30 } }).catch(() => undefined);
1314
+ await this.reproduceCamera(producer, producer.track);
1315
+ this.emit('error', new RpcError('frame-rate-fallback', 'this device or upload cannot sustain 60 fps; publishing at 30 fps'));
1316
+ }).catch((error) => this.emit('error', error));
1317
+ }
1318
+ return stats;
1319
+ }
1050
1320
  // ------------------------------------------------------------------ music
1051
1321
  /**
1052
1322
  * Publish music playing inside the page, as its own track.
@@ -1157,10 +1427,21 @@ export class StreamClient {
1157
1427
  }));
1158
1428
  const consumer = await transport.consume(params);
1159
1429
  this.consumers.set(remote.producerId, consumer);
1430
+ this.remoteVideoLayers.set(remote.producerId, remote.videoLayers ?? remote.appData?.videoLayers ?? params.videoLayers ?? []);
1160
1431
  // Layers asked for before a reconnect: the new consumer gets them before
1161
1432
  // its first frame instead of starting at the full picture. Not getting
1162
1433
  // them costs data, not the track, so a refusal is only reported.
1163
- const layers = this.preferredLayers.get(remote.producerId);
1434
+ let layers = this.preferredLayers.get(remote.producerId);
1435
+ const receiveQuality = this.receiveQualities.get(remote.producerId);
1436
+ if (receiveQuality && receiveQuality !== 'auto') {
1437
+ try {
1438
+ layers = { spatialLayer: receiveSpatialLayer(this.remoteVideoLayers.get(remote.producerId) ?? [], receiveQuality) };
1439
+ }
1440
+ catch {
1441
+ layers = undefined;
1442
+ this.emit('error', new RpcError('quality-unavailable', 'this publisher no longer describes its video layers; using Auto'));
1443
+ }
1444
+ }
1164
1445
  if (layers) {
1165
1446
  await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers }).catch((err) => this.emit('error', err));
1166
1447
  }
@@ -1192,7 +1473,7 @@ export class StreamClient {
1192
1473
  }
1193
1474
  trackEnabled(kind) {
1194
1475
  for (const p of this.producers.values()) {
1195
- if (p.kind === kind && p.id !== this.musicProducerId)
1476
+ if (p.kind === kind && p.appData.source === defaultSource(kind))
1196
1477
  return !p.paused;
1197
1478
  }
1198
1479
  return false;
@@ -1208,16 +1489,31 @@ export class StreamClient {
1208
1489
  async setCamEnabled(enabled) {
1209
1490
  await this.setKindEnabled('video', enabled);
1210
1491
  }
1492
+ applyLocalModeration(kind) {
1493
+ for (const producer of this.producers.values()) {
1494
+ if (!kind || producer.kind === kind)
1495
+ producer.pause();
1496
+ }
1497
+ // During reconnect there are no producers, but the retained tracks still
1498
+ // capture. Keep their muted state for republish as well.
1499
+ for (const stream of [this.localStream, this.screenStream]) {
1500
+ for (const track of stream?.getTracks() ?? [])
1501
+ if (!kind || track.kind === kind)
1502
+ track.enabled = false;
1503
+ }
1504
+ if (!kind || kind === 'audio')
1505
+ this.music?.setEnabled(false);
1506
+ }
1211
1507
  async setKindEnabled(kind, enabled) {
1212
1508
  for (const producer of this.producers.values()) {
1213
1509
  // The music has its own switch. Muting the microphone must not stop the song.
1214
- if (producer.kind !== kind || producer.id === this.musicProducerId)
1510
+ if (producer.kind !== kind || producer.appData.source !== defaultSource(kind))
1215
1511
  continue;
1216
- if (enabled)
1217
- producer.resume();
1218
- else
1512
+ if (!enabled)
1219
1513
  producer.pause();
1220
1514
  await this.rpc('pauseProducer', { producerId: producer.id, paused: !enabled });
1515
+ if (enabled)
1516
+ producer.resume();
1221
1517
  }
1222
1518
  this.emitLocalMedia();
1223
1519
  }
@@ -1228,6 +1524,10 @@ export class StreamClient {
1228
1524
  // called while this still waits is not undone by it, and one already
1229
1525
  // under way stops at its next step (keepProducer).
1230
1526
  this.publishGeneration += 1;
1527
+ const screenWasStarted = this.screenStarted;
1528
+ this.releaseScreen();
1529
+ if (screenWasStarted)
1530
+ this.emit('screenShareEnded', { reason: 'stopped' });
1231
1531
  const ids = [...this.producers.keys()];
1232
1532
  for (const producer of this.producers.values())
1233
1533
  producer.close();
@@ -1257,8 +1557,8 @@ export class StreamClient {
1257
1557
  return this.localStream;
1258
1558
  }
1259
1559
  // ------------------------------------------------------------- moderation
1260
- raiseHand() { return this.rpc('raiseHand'); }
1261
- lowerHand() { return this.rpc('lowerHand'); }
1560
+ raiseHand(metadata) { return this.rpc('raiseHand', metadata ? { metadata } : {}); }
1561
+ lowerHand(identity) { return this.rpc('lowerHand', identity ? { identity } : {}); }
1262
1562
  muteParticipant(identity) { return this.rpc('requestMute', { identity }); }
1263
1563
  /**
1264
1564
  * Moderating roles: switch someone's microphone (`audio`), camera (`video`)