@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/src/index.ts CHANGED
@@ -34,6 +34,12 @@ import {
34
34
  allowedQuality,
35
35
  DEFAULT_VIDEO_POLICY,
36
36
  sameLayout,
37
+ describeVideoLayers,
38
+ receiveSpatialLayer,
39
+ screenEncodings,
40
+ type ScreenMode,
41
+ type ReceiveQuality,
42
+ type VideoLayer,
37
43
  topLayerSize,
38
44
  videoEncodings,
39
45
  type LayerCount,
@@ -58,7 +64,7 @@ export type { MusicInput, MusicOptions, TrackSource } from './music.js';
58
64
  export type { BroadcastEvents, BroadcastStartOptions, BroadcastState } from './broadcast.js';
59
65
  export { ChatClient, ChatRpcError } from './chat.js';
60
66
  export { allowedQuality, layerBitrate, videoEncodings, VIDEO_QUALITIES } from './quality.js';
61
- export type { LayerCount, VideoEncoding, VideoPolicy, VideoQuality } from './quality.js';
67
+ export type { LayerCount, VideoEncoding, VideoPolicy, VideoQuality, VideoLayer, ReceiveQuality, ScreenMode } from './quality.js';
62
68
  export type { CodecPreference } from './device.js';
63
69
  export type { VideoLayerStats, VideoLimitedEvent, VideoLimitReason, VideoReceiveStats, VideoSendStats } from './stats.js';
64
70
  export type {
@@ -98,12 +104,14 @@ export interface JoinResult {
98
104
  blockedSlots?: string[];
99
105
  /**
100
106
  * The highest video quality this project may publish, and the server's cap
101
- * on a publisher's incoming bitrate. Absent from older servers: 720p.
107
+ * on a publisher's incoming bitrate. Absent from older servers: 1080p.
102
108
  */
103
109
  video?: VideoPolicy;
104
110
  }
105
111
 
106
112
  export interface PublishOptions {
113
+ /** 30 by default. Only the top layer uses 60; capture at 60 too. */
114
+ frameRate?: 30 | 60;
107
115
  /**
108
116
  * The video quality to send: 1080p, 720p or 480p. Defaults to the highest
109
117
  * the project allows (`videoPolicy.maxQuality`), and is lowered to it if
@@ -140,6 +148,13 @@ export interface PublishOptions {
140
148
  degradationPreference?: 'maintain-resolution' | 'maintain-framerate' | 'balanced';
141
149
  }
142
150
 
151
+ export interface ScreenPublishOptions {
152
+ mode?: ScreenMode;
153
+ audio?: boolean;
154
+ /** An existing screen stream; otherwise capture is requested from this user gesture. */
155
+ stream?: MediaStream;
156
+ }
157
+
143
158
  export interface SlotClaim {
144
159
  key: string;
145
160
  identity: string;
@@ -153,7 +168,9 @@ export interface ParticipantInfo {
153
168
  identity: string;
154
169
  role: Role;
155
170
  handRaised: boolean;
171
+ handMetadata?: Record<string, unknown>;
156
172
  muted: boolean;
173
+ videoBlocked?: boolean;
157
174
  metadata?: unknown;
158
175
  }
159
176
 
@@ -164,6 +181,8 @@ export interface RemoteProducer {
164
181
  paused: boolean;
165
182
  /** What the track carries. A host playing music publishes two audio tracks: `mic` and `music`. */
166
183
  source?: TrackSource;
184
+ videoLayers?: VideoLayer[];
185
+ appData?: { videoLayers?: VideoLayer[] };
167
186
  }
168
187
 
169
188
  const defaultSource = (kind: 'audio' | 'video'): TrackSource => (kind === 'audio' ? 'mic' : 'camera');
@@ -185,8 +204,9 @@ export interface ClientEvents extends ChatEvents {
185
204
  trackMuted: (info: { identity: string; kind: 'audio' | 'video'; source: TrackSource; producerId: string; paused: boolean }) => void;
186
205
  participantJoined: (p: ParticipantInfo) => void;
187
206
  participantLeft: (p: { identity: string; reason?: string }) => void;
207
+ participantMuted: (e: { identity: string; kind?: 'audio' | 'video' | ''; paused: boolean }) => void;
188
208
  roleChanged: (e: { identity: string; role: Role }) => void;
189
- handRaised: (e: { identity: string; handRaised: boolean }) => void;
209
+ handRaised: (e: { identity: string; handRaised: boolean; metadata?: Record<string, unknown>; reason?: string }) => void;
190
210
  /**
191
211
  * A moderator switched your microphone (`audio`), camera (`video`) or both
192
212
  * (no kind) off, or released it (`paused: false`). While paused you cannot
@@ -196,6 +216,8 @@ export interface ClientEvents extends ChatEvents {
196
216
  mustReconnect: (e: { reason: string; token?: string; retry: boolean }) => void;
197
217
  roomClosed: (e: { reason: string }) => void;
198
218
  kicked: (e: { by: string; reason?: string }) => void;
219
+ screenShareStarted: (e: { stream: MediaStream; mode: ScreenMode }) => void;
220
+ screenShareEnded: (e: { reason: 'stopped' | 'track-ended' }) => void;
199
221
  error: (err: Error) => void;
200
222
  localMediaChanged: (state: { micEnabled: boolean; camEnabled: boolean; musicEnabled: boolean }) => void;
201
223
  /** The local stream was replaced, typically after reacquiring devices on a reconnect. */
@@ -278,7 +300,7 @@ const isLayer = (value: unknown): value is number => Number.isInteger(value) &&
278
300
  * Where a new camera stream's bandwidth estimate starts, in kbps. Chrome
279
301
  * otherwise starts low and takes many seconds to climb to the top layer.
280
302
  */
281
- const VIDEO_START_BITRATE_KBPS = 1500;
303
+ const VIDEO_START_BITRATE_KBPS = 2000;
282
304
 
283
305
  /** Why a publish gave up: unpublish() ran while it was under way, and unpublish wins. */
284
306
  const unpublishedWhile = (what: string) => `the ${what} was unpublished while it was being published`;
@@ -312,6 +334,20 @@ export class StreamClient {
312
334
  /** In-app music being published, kept across reconnects so it can be published again. */
313
335
  private music: MusicSource | null = null;
314
336
  private musicProducerId: string | null = null;
337
+ private screenStream: MediaStream | null = null;
338
+ private screenMode: ScreenMode = 'detail';
339
+ private screenAudioEnabled = true;
340
+ private screenGeneration = 0;
341
+ private screenPending = false;
342
+ private screenStarted = false;
343
+ private screenEndedListener: (() => void) | null = null;
344
+ private readonly screenProducerIds = new Set<string>();
345
+ private readonly remoteVideoLayers = new Map<string, VideoLayer[]>();
346
+ private readonly receiveQualities = new Map<string, ReceiveQuality>();
347
+ private cameraFrameRate: 30 | 60 = 30;
348
+ private frameRateFallback = false;
349
+ private frameRateLowSamples = 0;
350
+ private frameRateWarmupSamples = 2;
315
351
  /** What each remote producer carries, so a pause can say "music" rather than just "audio". */
316
352
  private readonly remoteSources = new Map<string, TrackSource>();
317
353
  /**
@@ -339,7 +375,7 @@ export class StreamClient {
339
375
  private readonly sendStatsBaseline: StatsBaseline = new Map();
340
376
  private readonly recvStatsBaseline: StatsBaseline = new Map();
341
377
  private readonly limitWatch = new VideoLimitWatch(
342
- (baseline) => this.collectVideoStats(baseline),
378
+ (baseline) => this.sampleCamera(baseline),
343
379
  (event) => this.emit('videoLimited', event),
344
380
  );
345
381
 
@@ -401,15 +437,29 @@ export class StreamClient {
401
437
  this.reconnectAttempt = 0;
402
438
  this.joinedOnce = true;
403
439
 
440
+ // A role or mute can change while the socket is away. The fresh roster
441
+ // decides what may return; reconnecting never turns a blocked device on.
442
+ if (result.role !== 'host' && result.role !== 'cohost' && (this.wasPublishing || this.screenPending)) {
443
+ await this.unpublish();
444
+ }
445
+ const own = result.participants.find((p) => p.identity === result.identity);
446
+ if (own?.muted) this.applyLocalModeration('audio');
447
+ if (own?.videoBlocked) this.applyLocalModeration('video');
404
448
  this.emit('joined', result);
405
449
 
406
450
  // A video that ended while this client was away sends it no
407
451
  // producerClosed: forget the layers asked for it.
408
452
  const present = new Set(result.producers.map((p) => p.producerId));
409
453
  for (const producerId of this.preferredLayers.keys()) {
410
- if (!present.has(producerId)) this.preferredLayers.delete(producerId);
454
+ if (!present.has(producerId)) {
455
+ this.preferredLayers.delete(producerId);
456
+ this.receiveQualities.delete(producerId);
457
+ this.remoteVideoLayers.delete(producerId);
458
+ }
411
459
  }
412
460
 
461
+ for (const producerId of this.remoteVideoLayers.keys()) if (!present.has(producerId)) this.remoteVideoLayers.delete(producerId);
462
+
413
463
  // Subscribe to everything already publishing before we announce readiness.
414
464
  for (const p of result.producers) {
415
465
  await this.consume(p).catch((err) => this.emit('error', err as Error));
@@ -553,9 +603,15 @@ export class StreamClient {
553
603
  // others have already reported themselves and set closedByUs.
554
604
  const wasOpen = !this.closedByUs;
555
605
  this.closedByUs = true;
606
+ this.publishGeneration += 1;
556
607
  this.wasPublishing = false;
557
608
  this.teardownMedia();
558
609
  this.preferredLayers.clear();
610
+ this.receiveQualities.clear();
611
+ this.remoteVideoLayers.clear();
612
+ this.releaseScreen();
613
+ this.music?.close();
614
+ this.music = null;
559
615
  // Nothing is emitted after closing, so not even a clearing videoLimited.
560
616
  this.limitWatch.reset();
561
617
  // Producers no longer stop their tracks, so releasing the camera and
@@ -574,6 +630,7 @@ export class StreamClient {
574
630
  for (const p of this.producers.values()) p.close();
575
631
  this.producers.clear();
576
632
  this.musicProducerId = null;
633
+ this.screenProducerIds.clear();
577
634
  this.cameraEncodings = null;
578
635
  // Paused, not reset: a reconnect's republish starts it again, and a
579
636
  // limitation already reported is not reported twice.
@@ -651,6 +708,8 @@ export class StreamClient {
651
708
  case 'producerClosed': {
652
709
  const { producerId, identity } = data as { producerId: string; identity: string };
653
710
  this.preferredLayers.delete(producerId);
711
+ this.receiveQualities.delete(producerId);
712
+ this.remoteVideoLayers.delete(producerId);
654
713
  const consumer = this.consumers.get(producerId);
655
714
  if (!consumer) break; // never consumed it, or already cleaned up
656
715
  consumer.close();
@@ -673,16 +732,28 @@ export class StreamClient {
673
732
  case 'participantJoined':
674
733
  this.emit('participantJoined', data as ParticipantInfo);
675
734
  break;
735
+ case 'participantMuted': {
736
+ const wire = data as { identity: string; kind?: 'audio' | 'video' | ''; muted?: boolean; paused?: boolean };
737
+ const e = { identity: wire.identity, kind: wire.kind, paused: wire.paused ?? wire.muted ?? false };
738
+ if (this.joinResult) this.joinResult.participants = this.joinResult.participants.map((p) =>
739
+ p.identity !== e.identity ? p : { ...p,
740
+ ...(!e.kind || e.kind === 'audio' ? { muted: e.paused } : {}),
741
+ ...(!e.kind || e.kind === 'video' ? { videoBlocked: e.paused } : {}),
742
+ });
743
+ this.emit('participantMuted', e);
744
+ break;
745
+ }
676
746
  case 'participantLeft':
677
747
  this.emit('participantLeft', data as { identity: string; reason?: string });
678
748
  break;
679
749
  case 'roleChanged': {
680
750
  const e = data as { identity: string; role: Role };
751
+ if (e.identity === this.joinResult?.identity) this.joinResult.role = e.role;
681
752
  this.emit('roleChanged', e);
682
753
  // Demoted: the server has already closed our producers. Release the
683
754
  // camera and microphone too, instead of capturing for nobody.
684
755
  const canPublish = e.role === 'host' || e.role === 'cohost';
685
- if (e.identity === this.joinResult?.identity && !canPublish && this.producers.size > 0) {
756
+ if (e.identity === this.joinResult?.identity && !canPublish && (this.wasPublishing || this.screenPending)) {
686
757
  void this.unpublish().catch(() => undefined);
687
758
  }
688
759
  break;
@@ -697,9 +768,7 @@ export class StreamClient {
697
768
  // changes nothing here -- switching back on is the user's choice.
698
769
  const e = data as { by: string; kind?: 'audio' | 'video' | ''; paused: boolean };
699
770
  if (e.paused) {
700
- for (const p of this.producers.values()) {
701
- if (!e.kind || p.kind === e.kind) p.pause();
702
- }
771
+ this.applyLocalModeration(e.kind);
703
772
  this.emitLocalMedia();
704
773
  }
705
774
  this.emit('forceMuted', e);
@@ -787,7 +856,7 @@ export class StreamClient {
787
856
  .catch(fail);
788
857
  });
789
858
  transport.on('produce', ({ kind, rtpParameters, appData }, ok, fail) => {
790
- this.rpc('produce', { transportId: transport.id, kind, rtpParameters, appData, source: appData.source })
859
+ this.rpc('produce', { transportId: transport.id, kind, rtpParameters, appData, source: appData.source, videoLayers: appData.videoLayers })
791
860
  .then((res) => ok({ id: (res as { producerId: string }).producerId }))
792
861
  .catch(fail);
793
862
  });
@@ -844,7 +913,11 @@ export class StreamClient {
844
913
  this.localStream = stream;
845
914
  this.wasPublishing = true;
846
915
  if (constraints) this.publishConstraints = constraints;
847
- if (options) this.publishOptions = { ...options };
916
+ if (options) {
917
+ this.publishOptions = { ...options };
918
+ this.frameRateFallback = false;
919
+ this.frameRateLowSamples = 0;
920
+ }
848
921
 
849
922
  const audio = stream.getAudioTracks()[0];
850
923
  if (audio) {
@@ -869,7 +942,7 @@ export class StreamClient {
869
942
  }
870
943
  }
871
944
 
872
- /** What this project may publish. Known once joined; 720p before that. */
945
+ /** What this project may publish. Known once joined; 1080p before that. */
873
946
  get videoPolicy(): VideoPolicy {
874
947
  return this.joinResult?.video ?? DEFAULT_VIDEO_POLICY;
875
948
  }
@@ -896,11 +969,27 @@ export class StreamClient {
896
969
  return this.videoQuality;
897
970
  }
898
971
 
972
+ /** Change the camera frame rate without stopping screen, microphone or music. */
973
+ async setVideoFrameRate(frameRate: 30 | 60): Promise<30 | 60> {
974
+ if (frameRate !== 30 && frameRate !== 60) throw new RangeError('setVideoFrameRate: expected 30 or 60');
975
+ this.publishOptions = { ...this.publishOptions, frameRate };
976
+ this.frameRateFallback = false;
977
+ this.frameRateLowSamples = 0;
978
+ await this.cameraTask(async () => {
979
+ const current = this.cameraProducer();
980
+ const track = current?.track;
981
+ if (!current || !track || track.readyState !== 'live') return;
982
+ try { await track.applyConstraints?.({ frameRate: { ideal: frameRate, max: frameRate } }); }
983
+ catch { /* Existing capture settings decide whether 60 fps is possible. */ }
984
+ await this.reproduceCamera(current, track);
985
+ });
986
+ return this.cameraProducer() ? this.cameraFrameRate : this.videoPolicy.maxFrameRate === 30 ? 30 : frameRate;
987
+ }
988
+
899
989
  /**
900
990
  * Send a different video track from now on: a canvas with effects drawn on
901
991
  * it, the raw camera again, another camera. When the new track needs the
902
- * same layers as the current one (the same size, or another size the
903
- * project's quality scales alike), viewers see no pause, and the layers
992
+ * same dimensions and layers as the current one, viewers see no pause, and the layers
904
993
  * take the new picture's bitrates. Otherwise the camera producer is
905
994
  * replaced, as `setVideoQuality` does. Either way a paused camera stays
906
995
  * paused.
@@ -937,8 +1026,10 @@ export class StreamClient {
937
1026
  const paused = current.paused;
938
1027
  try {
939
1028
  const { width = 0, height = 0 } = track.getSettings();
940
- const next = videoEncodings(width, height, this.videoQuality, this.layerCount());
941
- if (this.cameraEncodings && sameLayout(this.cameraEncodings, next)) {
1029
+ const next = videoEncodings(width, height, this.videoQuality, this.layerCount(), this.frameRateFor(track));
1030
+ const previousSize = current.track?.getSettings();
1031
+ if (this.cameraEncodings && sameLayout(this.cameraEncodings, next) &&
1032
+ previousSize?.width === width && previousSize?.height === height) {
942
1033
  await current.replaceTrack({ track });
943
1034
  await this.applyLayerBitrates(current, next);
944
1035
  return;
@@ -1036,6 +1127,7 @@ export class StreamClient {
1036
1127
  throw new Error(`setPreferredLayers: this client is not receiving a video with producerId ${producerId}`);
1037
1128
  }
1038
1129
  const layers: PreferredLayers = temporalLayer === undefined ? { spatialLayer } : { spatialLayer, temporalLayer };
1130
+ this.receiveQualities.delete(producerId);
1039
1131
  // Kept first: a reconnect that interrupts the request still applies it.
1040
1132
  this.preferredLayers.set(producerId, layers);
1041
1133
  await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers });
@@ -1056,6 +1148,22 @@ export class StreamClient {
1056
1148
  return parseSendStats(report.values(), { quality: this.videoQuality, baseline, keyPrefix: prefix });
1057
1149
  }
1058
1150
 
1151
+ /** Friendly receive ceiling using this producer's real layer sizes; auto restores adaptation. */
1152
+ async setReceiveQuality(producerId: string, quality: ReceiveQuality): Promise<void> {
1153
+ if (!['auto', '1080p', '720p', '480p'].includes(quality)) throw new RangeError('setReceiveQuality: unsupported quality');
1154
+ const consumer = this.consumers.get(producerId);
1155
+ if (!consumer || consumer.kind !== 'video') throw new Error('setReceiveQuality: this client is not receiving that video');
1156
+ let layers: PreferredLayers;
1157
+ if (quality === 'auto') layers = { spatialLayer: -1, temporalLayer: -1 };
1158
+ else {
1159
+ try { layers = { spatialLayer: receiveSpatialLayer(this.remoteVideoLayers.get(producerId) ?? [], quality) }; }
1160
+ catch { throw new RpcError('quality-unavailable', 'this publisher does not describe its video layers; use Auto'); }
1161
+ }
1162
+ this.receiveQualities.set(producerId, quality);
1163
+ this.preferredLayers.set(producerId, layers);
1164
+ await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers });
1165
+ }
1166
+
1059
1167
  private cameraProducer(): Producer | undefined {
1060
1168
  for (const p of this.producers.values()) {
1061
1169
  if (p.kind === 'video' && (p.appData as { source?: string }).source === 'camera') return p;
@@ -1119,7 +1227,8 @@ export class StreamClient {
1119
1227
  // videoEncodings).
1120
1228
  const { width = 0, height = 0 } = track.getSettings();
1121
1229
  const quality = this.videoQuality;
1122
- const encodings = videoEncodings(width, height, quality, this.layerCount());
1230
+ this.cameraFrameRate = this.frameRateFor(track);
1231
+ const encodings = videoEncodings(width, height, quality, this.layerCount(), this.cameraFrameRate);
1123
1232
  let codecs: RtpCodecCapability[] | undefined;
1124
1233
  try {
1125
1234
  codecs = this.device?.sendRtpCapabilities.codecs;
@@ -1134,7 +1243,7 @@ export class StreamClient {
1134
1243
  encodings,
1135
1244
  codecOptions: { videoGoogleStartBitrate: VIDEO_START_BITRATE_KBPS },
1136
1245
  ...(codec ? { codec } : {}),
1137
- appData: { source: 'camera' },
1246
+ appData: { source: 'camera', videoLayers: describeVideoLayers(width, height, encodings, quality) },
1138
1247
  });
1139
1248
  let producer: Producer;
1140
1249
  try {
@@ -1150,6 +1259,8 @@ export class StreamClient {
1150
1259
  // quality change or a track swap under way).
1151
1260
  await this.keepProducer(producer, generation, 'camera');
1152
1261
  this.cameraEncodings = encodings;
1262
+ this.frameRateLowSamples = 0;
1263
+ this.frameRateWarmupSamples = 2;
1153
1264
  await this.pauseOnServer(producer);
1154
1265
  await this.applyDegradationPreference(producer);
1155
1266
  this.syncLimitWatch();
@@ -1233,7 +1344,7 @@ export class StreamClient {
1233
1344
 
1234
1345
  /** videoLimited sampling runs while a camera is published and someone listens. */
1235
1346
  private syncLimitWatch(): void {
1236
- this.limitWatch.set(!this.closedByUs && this.cameraProducer() !== undefined && this.listenerCount('videoLimited') > 0);
1347
+ this.limitWatch.set(!this.closedByUs && this.cameraProducer() !== undefined && (this.listenerCount('videoLimited') > 0 || this.cameraFrameRate === 60));
1237
1348
  }
1238
1349
 
1239
1350
  /**
@@ -1249,6 +1360,11 @@ export class StreamClient {
1249
1360
  const generation = this.publishGeneration;
1250
1361
  // Music is not a device and cannot be "gone": publish it again whatever
1251
1362
  // happens to the camera and microphone below.
1363
+ const screen = this.screenStream;
1364
+ if (screen) await this.restoreScreen().catch(async (err) => {
1365
+ if (this.screenStream === screen) await this.stopScreen('stopped');
1366
+ this.emit('error', err as Error);
1367
+ });
1252
1368
  if (this.music) await this.produceMusic(this.music, generation).catch((err) => this.emit('error', err as Error));
1253
1369
  if (generation !== this.publishGeneration) return;
1254
1370
 
@@ -1281,6 +1397,150 @@ export class StreamClient {
1281
1397
  await this.publish(stream);
1282
1398
  }
1283
1399
 
1400
+ // ------------------------------------------------------------- screen sharing
1401
+
1402
+ /** Publish a screen beside the camera. Call from a click so capture permission can open. */
1403
+ async publishScreen(options: ScreenPublishOptions = {}): Promise<MediaStream> {
1404
+ if (this.joinResult?.role !== 'host' && this.joinResult?.role !== 'cohost') throw new RpcError('forbidden', 'only hosts and cohosts can share a screen');
1405
+ if (this.screenStream || this.screenPending) throw new RpcError('screen-share-active', 'a screen share is already active');
1406
+ if (options.mode !== undefined && options.mode !== 'detail' && options.mode !== 'motion') throw new TypeError('publishScreen: mode must be detail or motion');
1407
+ const mode = options.mode ?? 'detail';
1408
+ if (!options.stream && (typeof navigator === 'undefined' || !navigator.mediaDevices?.getDisplayMedia)) {
1409
+ throw new RpcError('screen-share-unsupported', 'screen capture is not supported by this browser; use a supported desktop browser');
1410
+ }
1411
+ const generation = this.screenGeneration;
1412
+ this.screenPending = true;
1413
+ let stream: MediaStream | undefined;
1414
+ try {
1415
+ stream = options.stream ?? await navigator.mediaDevices.getDisplayMedia({
1416
+ video: { width: { ideal: 1920, max: 1920 }, height: { ideal: 1080, max: 1080 }, frameRate: { ideal: mode === 'motion' ? 30 : 15, max: mode === 'motion' ? 30 : 15 } },
1417
+ audio: options.audio ?? false,
1418
+ });
1419
+ const video = stream.getVideoTracks()[0];
1420
+ if (!video || video.readyState !== 'live') throw new RpcError('screen-share-no-video', 'screen capture did not provide a live video track');
1421
+ if (generation !== this.screenGeneration || this.closedByUs) throw new Error(unpublishedWhile('screen'));
1422
+ this.screenStream = stream;
1423
+ this.screenMode = mode;
1424
+ this.screenAudioEnabled = options.audio !== false;
1425
+ video.contentHint = mode;
1426
+ const onEnded = () => { void this.stopScreen('track-ended').catch((err) => this.emit('error', err as Error)); };
1427
+ this.screenEndedListener = onEnded;
1428
+ video.addEventListener('ended', onEnded, { once: true });
1429
+ await this.restoreScreen();
1430
+ if (generation !== this.screenGeneration || video.readyState !== 'live') throw new Error(unpublishedWhile('screen'));
1431
+ this.screenStarted = true;
1432
+ this.wasPublishing = true;
1433
+ this.emit('screenShareStarted', { stream, mode });
1434
+ return stream;
1435
+ } catch (error) {
1436
+ if (generation === this.screenGeneration) await this.stopScreen('stopped');
1437
+ stream?.getTracks().forEach((track) => track.stop());
1438
+ throw error;
1439
+ } finally {
1440
+ if (generation === this.screenGeneration) this.screenPending = false;
1441
+ }
1442
+ }
1443
+
1444
+ /** Stop only the screen and its tab/system audio; keep camera, microphone and music. */
1445
+ async unpublishScreen(): Promise<void> {
1446
+ await this.stopScreen('stopped');
1447
+ }
1448
+
1449
+ private releaseScreen(): void {
1450
+ this.screenGeneration += 1;
1451
+ this.screenPending = false;
1452
+ const stream = this.screenStream;
1453
+ if (this.screenEndedListener) stream?.getVideoTracks()[0]?.removeEventListener('ended', this.screenEndedListener);
1454
+ this.screenEndedListener = null;
1455
+ this.screenStream = null;
1456
+ this.screenStarted = false;
1457
+ stream?.getTracks().forEach((track) => track.stop());
1458
+ }
1459
+
1460
+ private async stopScreen(reason: 'stopped' | 'track-ended'): Promise<void> {
1461
+ const started = this.screenStarted;
1462
+ const ids = [...this.screenProducerIds];
1463
+ this.screenProducerIds.clear();
1464
+ this.releaseScreen();
1465
+ for (const id of ids) { this.producers.get(id)?.close(); this.producers.delete(id); }
1466
+ if (!this.localStream && !this.music) this.wasPublishing = false;
1467
+ if (started && !this.closedByUs) this.emit('screenShareEnded', { reason });
1468
+ await Promise.all(ids.map((producerId) => this.rpc('closeProducer', { producerId }).catch(() => undefined)));
1469
+ }
1470
+
1471
+ private async restoreScreen(): Promise<void> {
1472
+ const stream = this.screenStream;
1473
+ const video = stream?.getVideoTracks()[0];
1474
+ if (!stream || !video) return;
1475
+ if (video.readyState !== 'live') { await this.stopScreen('track-ended'); return; }
1476
+ const generation = this.screenGeneration;
1477
+ const publishing = this.publishGeneration;
1478
+ const transport = await this.ensureSendTransport();
1479
+ if (generation !== this.screenGeneration) return;
1480
+ const { width = 0, height = 0 } = video.getSettings();
1481
+ const encodings = screenEncodings(width, height, this.screenMode);
1482
+ // A project's explicit safety restriction also applies to screen video.
1483
+ const cap = topLayerSize(width, height, this.videoPolicy.maxQuality);
1484
+ const screenScale = encodings.at(-1)!.scaleResolutionDownBy;
1485
+ if (cap.scale > screenScale) encodings.forEach((e) => { e.scaleResolutionDownBy *= cap.scale / screenScale; });
1486
+ const keep = async (producer: Producer) => {
1487
+ if (generation !== this.screenGeneration || publishing !== this.publishGeneration || video.readyState !== 'live') {
1488
+ producer.close();
1489
+ await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
1490
+ throw new Error(unpublishedWhile('screen'));
1491
+ }
1492
+ this.producers.set(producer.id, producer);
1493
+ this.screenProducerIds.add(producer.id);
1494
+ await this.pauseOnServer(producer);
1495
+ };
1496
+ await keep(await transport.produce({
1497
+ track: video, stopTracks: false, encodings,
1498
+ codecOptions: { videoGoogleStartBitrate: VIDEO_START_BITRATE_KBPS },
1499
+ appData: { source: 'screen', videoLayers: describeVideoLayers(width, height, encodings) },
1500
+ }));
1501
+ const audio = stream.getAudioTracks().find((track) => track.readyState === 'live');
1502
+ if (audio && this.screenAudioEnabled && generation === this.screenGeneration) await keep(await transport.produce({
1503
+ track: audio, stopTracks: false,
1504
+ codecOptions: { opusStereo: true, opusDtx: false, opusFec: true, opusMaxAverageBitrate: 128000 },
1505
+ appData: { source: 'screen-audio' },
1506
+ }));
1507
+ }
1508
+
1509
+ private frameRateFor(track: MediaStreamTrack): 30 | 60 {
1510
+ if (this.publishOptions.frameRate !== 60 || this.frameRateFallback) return 30;
1511
+ const captured = track.getSettings().frameRate;
1512
+ if (this.videoPolicy.maxFrameRate === 30 || (typeof captured === 'number' && captured < 50)) {
1513
+ this.frameRateFallback = true;
1514
+ this.emit('error', new RpcError('frame-rate-fallback', '60 fps is unavailable from this camera or project; publishing at 30 fps'));
1515
+ return 30;
1516
+ }
1517
+ return 60;
1518
+ }
1519
+
1520
+ private async sampleCamera(baseline: StatsBaseline): Promise<VideoSendStats | null> {
1521
+ const stats = await this.collectVideoStats(baseline);
1522
+ if (this.cameraFrameRate !== 60 || this.frameRateFallback || !stats) return stats;
1523
+ if (this.cameraProducer()?.paused) return stats;
1524
+ // The lower simulcast copies intentionally run at 30 fps. During the
1525
+ // initial bandwidth ramp they may be the only active copies, which does
1526
+ // not establish that the camera cannot sustain 60. Match native warm-up:
1527
+ // two 2-second samples, then require three consecutive limited samples.
1528
+ if (this.frameRateWarmupSamples-- > 0) return stats;
1529
+ const limited = stats.layers.some((layer) => layer.active && (layer.qualityLimitation === 'cpu' || layer.qualityLimitation === 'bandwidth'));
1530
+ this.frameRateLowSamples = limited && stats.fps > 0 && stats.fps < 50 ? this.frameRateLowSamples + 1 : 0;
1531
+ if (this.frameRateLowSamples >= 3) {
1532
+ this.frameRateFallback = true;
1533
+ void this.cameraTask(async () => {
1534
+ const producer = this.cameraProducer();
1535
+ if (!producer?.track || producer.closed) return;
1536
+ await producer.track.applyConstraints?.({ frameRate: { ideal: 30, max: 30 } }).catch(() => undefined);
1537
+ await this.reproduceCamera(producer, producer.track);
1538
+ this.emit('error', new RpcError('frame-rate-fallback', 'this device or upload cannot sustain 60 fps; publishing at 30 fps'));
1539
+ }).catch((error) => this.emit('error', error as Error));
1540
+ }
1541
+ return stats;
1542
+ }
1543
+
1284
1544
  // ------------------------------------------------------------------ music
1285
1545
 
1286
1546
  /**
@@ -1392,11 +1652,17 @@ export class StreamClient {
1392
1652
 
1393
1653
  const consumer = await transport.consume(params);
1394
1654
  this.consumers.set(remote.producerId, consumer);
1655
+ this.remoteVideoLayers.set(remote.producerId, remote.videoLayers ?? remote.appData?.videoLayers ?? (params as { videoLayers?: VideoLayer[] }).videoLayers ?? []);
1395
1656
 
1396
1657
  // Layers asked for before a reconnect: the new consumer gets them before
1397
1658
  // its first frame instead of starting at the full picture. Not getting
1398
1659
  // them costs data, not the track, so a refusal is only reported.
1399
- const layers = this.preferredLayers.get(remote.producerId);
1660
+ let layers = this.preferredLayers.get(remote.producerId);
1661
+ const receiveQuality = this.receiveQualities.get(remote.producerId);
1662
+ if (receiveQuality && receiveQuality !== 'auto') {
1663
+ try { layers = { spatialLayer: receiveSpatialLayer(this.remoteVideoLayers.get(remote.producerId) ?? [], receiveQuality) }; }
1664
+ catch { layers = undefined; this.emit('error', new RpcError('quality-unavailable', 'this publisher no longer describes its video layers; using Auto')); }
1665
+ }
1400
1666
  if (layers) {
1401
1667
  await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers }).catch((err) => this.emit('error', err as Error));
1402
1668
  }
@@ -1436,7 +1702,7 @@ export class StreamClient {
1436
1702
 
1437
1703
  private trackEnabled(kind: 'audio' | 'video'): boolean {
1438
1704
  for (const p of this.producers.values()) {
1439
- if (p.kind === kind && p.id !== this.musicProducerId) return !p.paused;
1705
+ if (p.kind === kind && p.appData.source === defaultSource(kind)) return !p.paused;
1440
1706
  }
1441
1707
  return false;
1442
1708
  }
@@ -1454,13 +1720,25 @@ export class StreamClient {
1454
1720
  await this.setKindEnabled('video', enabled);
1455
1721
  }
1456
1722
 
1723
+ private applyLocalModeration(kind?: 'audio' | 'video' | ''): void {
1724
+ for (const producer of this.producers.values()) {
1725
+ if (!kind || producer.kind === kind) producer.pause();
1726
+ }
1727
+ // During reconnect there are no producers, but the retained tracks still
1728
+ // capture. Keep their muted state for republish as well.
1729
+ for (const stream of [this.localStream, this.screenStream]) {
1730
+ for (const track of stream?.getTracks() ?? []) if (!kind || track.kind === kind) track.enabled = false;
1731
+ }
1732
+ if (!kind || kind === 'audio') this.music?.setEnabled(false);
1733
+ }
1734
+
1457
1735
  private async setKindEnabled(kind: 'audio' | 'video', enabled: boolean): Promise<void> {
1458
1736
  for (const producer of this.producers.values()) {
1459
1737
  // The music has its own switch. Muting the microphone must not stop the song.
1460
- if (producer.kind !== kind || producer.id === this.musicProducerId) continue;
1461
- if (enabled) producer.resume();
1462
- else producer.pause();
1738
+ if (producer.kind !== kind || producer.appData.source !== defaultSource(kind)) continue;
1739
+ if (!enabled) producer.pause();
1463
1740
  await this.rpc('pauseProducer', { producerId: producer.id, paused: !enabled });
1741
+ if (enabled) producer.resume();
1464
1742
  }
1465
1743
  this.emitLocalMedia();
1466
1744
  }
@@ -1472,6 +1750,9 @@ export class StreamClient {
1472
1750
  // called while this still waits is not undone by it, and one already
1473
1751
  // under way stops at its next step (keepProducer).
1474
1752
  this.publishGeneration += 1;
1753
+ const screenWasStarted = this.screenStarted;
1754
+ this.releaseScreen();
1755
+ if (screenWasStarted) this.emit('screenShareEnded', { reason: 'stopped' });
1475
1756
  const ids = [...this.producers.keys()];
1476
1757
  for (const producer of this.producers.values()) producer.close();
1477
1758
  this.producers.clear();
@@ -1502,8 +1783,8 @@ export class StreamClient {
1502
1783
 
1503
1784
  // ------------------------------------------------------------- moderation
1504
1785
 
1505
- raiseHand(): Promise<unknown> { return this.rpc('raiseHand'); }
1506
- lowerHand(): Promise<unknown> { return this.rpc('lowerHand'); }
1786
+ raiseHand(metadata?: Record<string, unknown>): Promise<unknown> { return this.rpc('raiseHand', metadata ? { metadata } : {}); }
1787
+ lowerHand(identity?: string): Promise<unknown> { return this.rpc('lowerHand', identity ? { identity } : {}); }
1507
1788
  muteParticipant(identity: string): Promise<unknown> { return this.rpc('requestMute', { identity }); }
1508
1789
  /**
1509
1790
  * Moderating roles: switch someone's microphone (`audio`), camera (`video`)
package/src/music.ts CHANGED
@@ -22,7 +22,7 @@ export interface MusicOptions {
22
22
  monitor?: boolean;
23
23
  }
24
24
 
25
- export type TrackSource = 'mic' | 'music' | 'camera' | 'screen';
25
+ export type TrackSource = 'mic' | 'music' | 'camera' | 'screen' | 'screen-audio';
26
26
 
27
27
  let shared: AudioContext | null = null;
28
28
  /** A media element can be wired to one audio graph, once, for its whole life. */