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