@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/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { CameraRecording, type RecordingOptions, type RecordingFile } from './recording.js';
1
2
  /**
2
3
  * Thin client SDK over the orchestrator's signaling protocol.
3
4
  *
@@ -34,6 +35,12 @@ import {
34
35
  allowedQuality,
35
36
  DEFAULT_VIDEO_POLICY,
36
37
  sameLayout,
38
+ describeVideoLayers,
39
+ receiveSpatialLayer,
40
+ screenEncodings,
41
+ type ScreenMode,
42
+ type ReceiveQuality,
43
+ type VideoLayer,
37
44
  topLayerSize,
38
45
  videoEncodings,
39
46
  type LayerCount,
@@ -52,13 +59,15 @@ import {
52
59
  type VideoSendStats,
53
60
  } from './stats.js';
54
61
 
62
+ export { CameraRecording, RecordingUploader, RecordingError } from './recording.js';
63
+ export type { RecordingOptions, RecordingEvent, RecordingState, RecordingSession, RecordingFile } from './recording.js';
55
64
  export { BroadcastPublisher, WhipError, whipEnd, whipOffer } from './broadcast.js';
56
65
  export { AudioMixer, MusicSource } from './music.js';
57
66
  export type { MusicInput, MusicOptions, TrackSource } from './music.js';
58
67
  export type { BroadcastEvents, BroadcastStartOptions, BroadcastState } from './broadcast.js';
59
68
  export { ChatClient, ChatRpcError } from './chat.js';
60
69
  export { allowedQuality, layerBitrate, videoEncodings, VIDEO_QUALITIES } from './quality.js';
61
- export type { LayerCount, VideoEncoding, VideoPolicy, VideoQuality } from './quality.js';
70
+ export type { LayerCount, VideoEncoding, VideoPolicy, VideoQuality, VideoLayer, ReceiveQuality, ScreenMode } from './quality.js';
62
71
  export type { CodecPreference } from './device.js';
63
72
  export type { VideoLayerStats, VideoLimitedEvent, VideoLimitReason, VideoReceiveStats, VideoSendStats } from './stats.js';
64
73
  export type {
@@ -98,12 +107,14 @@ export interface JoinResult {
98
107
  blockedSlots?: string[];
99
108
  /**
100
109
  * 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.
110
+ * on a publisher's incoming bitrate. Absent from older servers: 1080p.
102
111
  */
103
112
  video?: VideoPolicy;
104
113
  }
105
114
 
106
115
  export interface PublishOptions {
116
+ /** 30 by default. Only the top layer uses 60; capture at 60 too. */
117
+ frameRate?: 30 | 60;
107
118
  /**
108
119
  * The video quality to send: 1080p, 720p or 480p. Defaults to the highest
109
120
  * the project allows (`videoPolicy.maxQuality`), and is lowered to it if
@@ -140,6 +151,13 @@ export interface PublishOptions {
140
151
  degradationPreference?: 'maintain-resolution' | 'maintain-framerate' | 'balanced';
141
152
  }
142
153
 
154
+ export interface ScreenPublishOptions {
155
+ mode?: ScreenMode;
156
+ audio?: boolean;
157
+ /** An existing screen stream; otherwise capture is requested from this user gesture. */
158
+ stream?: MediaStream;
159
+ }
160
+
143
161
  export interface SlotClaim {
144
162
  key: string;
145
163
  identity: string;
@@ -153,7 +171,9 @@ export interface ParticipantInfo {
153
171
  identity: string;
154
172
  role: Role;
155
173
  handRaised: boolean;
174
+ handMetadata?: Record<string, unknown>;
156
175
  muted: boolean;
176
+ videoBlocked?: boolean;
157
177
  metadata?: unknown;
158
178
  }
159
179
 
@@ -164,6 +184,8 @@ export interface RemoteProducer {
164
184
  paused: boolean;
165
185
  /** What the track carries. A host playing music publishes two audio tracks: `mic` and `music`. */
166
186
  source?: TrackSource;
187
+ videoLayers?: VideoLayer[];
188
+ appData?: { videoLayers?: VideoLayer[] };
167
189
  }
168
190
 
169
191
  const defaultSource = (kind: 'audio' | 'video'): TrackSource => (kind === 'audio' ? 'mic' : 'camera');
@@ -185,8 +207,9 @@ export interface ClientEvents extends ChatEvents {
185
207
  trackMuted: (info: { identity: string; kind: 'audio' | 'video'; source: TrackSource; producerId: string; paused: boolean }) => void;
186
208
  participantJoined: (p: ParticipantInfo) => void;
187
209
  participantLeft: (p: { identity: string; reason?: string }) => void;
210
+ participantMuted: (e: { identity: string; kind?: 'audio' | 'video' | ''; paused: boolean }) => void;
188
211
  roleChanged: (e: { identity: string; role: Role }) => void;
189
- handRaised: (e: { identity: string; handRaised: boolean }) => void;
212
+ handRaised: (e: { identity: string; handRaised: boolean; metadata?: Record<string, unknown>; reason?: string }) => void;
190
213
  /**
191
214
  * A moderator switched your microphone (`audio`), camera (`video`) or both
192
215
  * (no kind) off, or released it (`paused: false`). While paused you cannot
@@ -196,6 +219,8 @@ export interface ClientEvents extends ChatEvents {
196
219
  mustReconnect: (e: { reason: string; token?: string; retry: boolean }) => void;
197
220
  roomClosed: (e: { reason: string }) => void;
198
221
  kicked: (e: { by: string; reason?: string }) => void;
222
+ screenShareStarted: (e: { stream: MediaStream; mode: ScreenMode }) => void;
223
+ screenShareEnded: (e: { reason: 'stopped' | 'track-ended' }) => void;
199
224
  error: (err: Error) => void;
200
225
  localMediaChanged: (state: { micEnabled: boolean; camEnabled: boolean; musicEnabled: boolean }) => void;
201
226
  /** The local stream was replaced, typically after reacquiring devices on a reconnect. */
@@ -278,7 +303,7 @@ const isLayer = (value: unknown): value is number => Number.isInteger(value) &&
278
303
  * Where a new camera stream's bandwidth estimate starts, in kbps. Chrome
279
304
  * otherwise starts low and takes many seconds to climb to the top layer.
280
305
  */
281
- const VIDEO_START_BITRATE_KBPS = 1500;
306
+ const VIDEO_START_BITRATE_KBPS = 2000;
282
307
 
283
308
  /** Why a publish gave up: unpublish() ran while it was under way, and unpublish wins. */
284
309
  const unpublishedWhile = (what: string) => `the ${what} was unpublished while it was being published`;
@@ -302,6 +327,7 @@ export class StreamClient {
302
327
  private token: string;
303
328
  private joinResult: JoinResult | null = null;
304
329
  private localStream: MediaStream | null = null;
330
+ private recording: CameraRecording | null = null;
305
331
  // A reconnect tears down every transport and producer. Without remembering
306
332
  // that this client was publishing -- and what it was publishing with -- a
307
333
  // broadcaster that reconnects rejoins silently, which is worse than being
@@ -312,6 +338,20 @@ export class StreamClient {
312
338
  /** In-app music being published, kept across reconnects so it can be published again. */
313
339
  private music: MusicSource | null = null;
314
340
  private musicProducerId: string | null = null;
341
+ private screenStream: MediaStream | null = null;
342
+ private screenMode: ScreenMode = 'detail';
343
+ private screenAudioEnabled = true;
344
+ private screenGeneration = 0;
345
+ private screenPending = false;
346
+ private screenStarted = false;
347
+ private screenEndedListener: (() => void) | null = null;
348
+ private readonly screenProducerIds = new Set<string>();
349
+ private readonly remoteVideoLayers = new Map<string, VideoLayer[]>();
350
+ private readonly receiveQualities = new Map<string, ReceiveQuality>();
351
+ private cameraFrameRate: 30 | 60 = 30;
352
+ private frameRateFallback = false;
353
+ private frameRateLowSamples = 0;
354
+ private frameRateWarmupSamples = 2;
315
355
  /** What each remote producer carries, so a pause can say "music" rather than just "audio". */
316
356
  private readonly remoteSources = new Map<string, TrackSource>();
317
357
  /**
@@ -339,7 +379,7 @@ export class StreamClient {
339
379
  private readonly sendStatsBaseline: StatsBaseline = new Map();
340
380
  private readonly recvStatsBaseline: StatsBaseline = new Map();
341
381
  private readonly limitWatch = new VideoLimitWatch(
342
- (baseline) => this.collectVideoStats(baseline),
382
+ (baseline) => this.sampleCamera(baseline),
343
383
  (event) => this.emit('videoLimited', event),
344
384
  );
345
385
 
@@ -401,15 +441,29 @@ export class StreamClient {
401
441
  this.reconnectAttempt = 0;
402
442
  this.joinedOnce = true;
403
443
 
444
+ // A role or mute can change while the socket is away. The fresh roster
445
+ // decides what may return; reconnecting never turns a blocked device on.
446
+ if (result.role !== 'host' && result.role !== 'cohost' && (this.wasPublishing || this.screenPending)) {
447
+ await this.unpublish();
448
+ }
449
+ const own = result.participants.find((p) => p.identity === result.identity);
450
+ if (own?.muted) this.applyLocalModeration('audio');
451
+ if (own?.videoBlocked) this.applyLocalModeration('video');
404
452
  this.emit('joined', result);
405
453
 
406
454
  // A video that ended while this client was away sends it no
407
455
  // producerClosed: forget the layers asked for it.
408
456
  const present = new Set(result.producers.map((p) => p.producerId));
409
457
  for (const producerId of this.preferredLayers.keys()) {
410
- if (!present.has(producerId)) this.preferredLayers.delete(producerId);
458
+ if (!present.has(producerId)) {
459
+ this.preferredLayers.delete(producerId);
460
+ this.receiveQualities.delete(producerId);
461
+ this.remoteVideoLayers.delete(producerId);
462
+ }
411
463
  }
412
464
 
465
+ for (const producerId of this.remoteVideoLayers.keys()) if (!present.has(producerId)) this.remoteVideoLayers.delete(producerId);
466
+
413
467
  // Subscribe to everything already publishing before we announce readiness.
414
468
  for (const p of result.producers) {
415
469
  await this.consume(p).catch((err) => this.emit('error', err as Error));
@@ -549,13 +603,20 @@ export class StreamClient {
549
603
  }
550
604
 
551
605
  close(): void {
606
+ void this.stopRecording().catch(() => undefined);
552
607
  // Only an app closing a live session is 'left'; kicked, room-closed and the
553
608
  // others have already reported themselves and set closedByUs.
554
609
  const wasOpen = !this.closedByUs;
555
610
  this.closedByUs = true;
611
+ this.publishGeneration += 1;
556
612
  this.wasPublishing = false;
557
613
  this.teardownMedia();
558
614
  this.preferredLayers.clear();
615
+ this.receiveQualities.clear();
616
+ this.remoteVideoLayers.clear();
617
+ this.releaseScreen();
618
+ this.music?.close();
619
+ this.music = null;
559
620
  // Nothing is emitted after closing, so not even a clearing videoLimited.
560
621
  this.limitWatch.reset();
561
622
  // Producers no longer stop their tracks, so releasing the camera and
@@ -574,6 +635,7 @@ export class StreamClient {
574
635
  for (const p of this.producers.values()) p.close();
575
636
  this.producers.clear();
576
637
  this.musicProducerId = null;
638
+ this.screenProducerIds.clear();
577
639
  this.cameraEncodings = null;
578
640
  // Paused, not reset: a reconnect's republish starts it again, and a
579
641
  // limitation already reported is not reported twice.
@@ -651,6 +713,8 @@ export class StreamClient {
651
713
  case 'producerClosed': {
652
714
  const { producerId, identity } = data as { producerId: string; identity: string };
653
715
  this.preferredLayers.delete(producerId);
716
+ this.receiveQualities.delete(producerId);
717
+ this.remoteVideoLayers.delete(producerId);
654
718
  const consumer = this.consumers.get(producerId);
655
719
  if (!consumer) break; // never consumed it, or already cleaned up
656
720
  consumer.close();
@@ -673,16 +737,28 @@ export class StreamClient {
673
737
  case 'participantJoined':
674
738
  this.emit('participantJoined', data as ParticipantInfo);
675
739
  break;
740
+ case 'participantMuted': {
741
+ const wire = data as { identity: string; kind?: 'audio' | 'video' | ''; muted?: boolean; paused?: boolean };
742
+ const e = { identity: wire.identity, kind: wire.kind, paused: wire.paused ?? wire.muted ?? false };
743
+ if (this.joinResult) this.joinResult.participants = this.joinResult.participants.map((p) =>
744
+ p.identity !== e.identity ? p : { ...p,
745
+ ...(!e.kind || e.kind === 'audio' ? { muted: e.paused } : {}),
746
+ ...(!e.kind || e.kind === 'video' ? { videoBlocked: e.paused } : {}),
747
+ });
748
+ this.emit('participantMuted', e);
749
+ break;
750
+ }
676
751
  case 'participantLeft':
677
752
  this.emit('participantLeft', data as { identity: string; reason?: string });
678
753
  break;
679
754
  case 'roleChanged': {
680
755
  const e = data as { identity: string; role: Role };
756
+ if (e.identity === this.joinResult?.identity) this.joinResult.role = e.role;
681
757
  this.emit('roleChanged', e);
682
758
  // Demoted: the server has already closed our producers. Release the
683
759
  // camera and microphone too, instead of capturing for nobody.
684
760
  const canPublish = e.role === 'host' || e.role === 'cohost';
685
- if (e.identity === this.joinResult?.identity && !canPublish && this.producers.size > 0) {
761
+ if (e.identity === this.joinResult?.identity && !canPublish && (this.wasPublishing || this.screenPending)) {
686
762
  void this.unpublish().catch(() => undefined);
687
763
  }
688
764
  break;
@@ -697,9 +773,7 @@ export class StreamClient {
697
773
  // changes nothing here -- switching back on is the user's choice.
698
774
  const e = data as { by: string; kind?: 'audio' | 'video' | ''; paused: boolean };
699
775
  if (e.paused) {
700
- for (const p of this.producers.values()) {
701
- if (!e.kind || p.kind === e.kind) p.pause();
702
- }
776
+ this.applyLocalModeration(e.kind);
703
777
  this.emitLocalMedia();
704
778
  }
705
779
  this.emit('forceMuted', e);
@@ -787,7 +861,7 @@ export class StreamClient {
787
861
  .catch(fail);
788
862
  });
789
863
  transport.on('produce', ({ kind, rtpParameters, appData }, ok, fail) => {
790
- this.rpc('produce', { transportId: transport.id, kind, rtpParameters, appData, source: appData.source })
864
+ this.rpc('produce', { transportId: transport.id, kind, rtpParameters, appData, source: appData.source, videoLayers: appData.videoLayers })
791
865
  .then((res) => ok({ id: (res as { producerId: string }).producerId }))
792
866
  .catch(fail);
793
867
  });
@@ -844,7 +918,11 @@ export class StreamClient {
844
918
  this.localStream = stream;
845
919
  this.wasPublishing = true;
846
920
  if (constraints) this.publishConstraints = constraints;
847
- if (options) this.publishOptions = { ...options };
921
+ if (options) {
922
+ this.publishOptions = { ...options };
923
+ this.frameRateFallback = false;
924
+ this.frameRateLowSamples = 0;
925
+ }
848
926
 
849
927
  const audio = stream.getAudioTracks()[0];
850
928
  if (audio) {
@@ -869,7 +947,7 @@ export class StreamClient {
869
947
  }
870
948
  }
871
949
 
872
- /** What this project may publish. Known once joined; 720p before that. */
950
+ /** What this project may publish. Known once joined; 1080p before that. */
873
951
  get videoPolicy(): VideoPolicy {
874
952
  return this.joinResult?.video ?? DEFAULT_VIDEO_POLICY;
875
953
  }
@@ -896,11 +974,27 @@ export class StreamClient {
896
974
  return this.videoQuality;
897
975
  }
898
976
 
977
+ /** Change the camera frame rate without stopping screen, microphone or music. */
978
+ async setVideoFrameRate(frameRate: 30 | 60): Promise<30 | 60> {
979
+ if (frameRate !== 30 && frameRate !== 60) throw new RangeError('setVideoFrameRate: expected 30 or 60');
980
+ this.publishOptions = { ...this.publishOptions, frameRate };
981
+ this.frameRateFallback = false;
982
+ this.frameRateLowSamples = 0;
983
+ await this.cameraTask(async () => {
984
+ const current = this.cameraProducer();
985
+ const track = current?.track;
986
+ if (!current || !track || track.readyState !== 'live') return;
987
+ try { await track.applyConstraints?.({ frameRate: { ideal: frameRate, max: frameRate } }); }
988
+ catch { /* Existing capture settings decide whether 60 fps is possible. */ }
989
+ await this.reproduceCamera(current, track);
990
+ });
991
+ return this.cameraProducer() ? this.cameraFrameRate : this.videoPolicy.maxFrameRate === 30 ? 30 : frameRate;
992
+ }
993
+
899
994
  /**
900
995
  * Send a different video track from now on: a canvas with effects drawn on
901
996
  * 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
997
+ * same dimensions and layers as the current one, viewers see no pause, and the layers
904
998
  * take the new picture's bitrates. Otherwise the camera producer is
905
999
  * replaced, as `setVideoQuality` does. Either way a paused camera stays
906
1000
  * paused.
@@ -937,8 +1031,10 @@ export class StreamClient {
937
1031
  const paused = current.paused;
938
1032
  try {
939
1033
  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)) {
1034
+ const next = videoEncodings(width, height, this.videoQuality, this.layerCount(), this.frameRateFor(track));
1035
+ const previousSize = current.track?.getSettings();
1036
+ if (this.cameraEncodings && sameLayout(this.cameraEncodings, next) &&
1037
+ previousSize?.width === width && previousSize?.height === height) {
942
1038
  await current.replaceTrack({ track });
943
1039
  await this.applyLayerBitrates(current, next);
944
1040
  return;
@@ -1036,6 +1132,7 @@ export class StreamClient {
1036
1132
  throw new Error(`setPreferredLayers: this client is not receiving a video with producerId ${producerId}`);
1037
1133
  }
1038
1134
  const layers: PreferredLayers = temporalLayer === undefined ? { spatialLayer } : { spatialLayer, temporalLayer };
1135
+ this.receiveQualities.delete(producerId);
1039
1136
  // Kept first: a reconnect that interrupts the request still applies it.
1040
1137
  this.preferredLayers.set(producerId, layers);
1041
1138
  await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers });
@@ -1056,6 +1153,22 @@ export class StreamClient {
1056
1153
  return parseSendStats(report.values(), { quality: this.videoQuality, baseline, keyPrefix: prefix });
1057
1154
  }
1058
1155
 
1156
+ /** Friendly receive ceiling using this producer's real layer sizes; auto restores adaptation. */
1157
+ async setReceiveQuality(producerId: string, quality: ReceiveQuality): Promise<void> {
1158
+ if (!['auto', '1080p', '720p', '480p'].includes(quality)) throw new RangeError('setReceiveQuality: unsupported quality');
1159
+ const consumer = this.consumers.get(producerId);
1160
+ if (!consumer || consumer.kind !== 'video') throw new Error('setReceiveQuality: this client is not receiving that video');
1161
+ let layers: PreferredLayers;
1162
+ if (quality === 'auto') layers = { spatialLayer: -1, temporalLayer: -1 };
1163
+ else {
1164
+ try { layers = { spatialLayer: receiveSpatialLayer(this.remoteVideoLayers.get(producerId) ?? [], quality) }; }
1165
+ catch { throw new RpcError('quality-unavailable', 'this publisher does not describe its video layers; use Auto'); }
1166
+ }
1167
+ this.receiveQualities.set(producerId, quality);
1168
+ this.preferredLayers.set(producerId, layers);
1169
+ await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers });
1170
+ }
1171
+
1059
1172
  private cameraProducer(): Producer | undefined {
1060
1173
  for (const p of this.producers.values()) {
1061
1174
  if (p.kind === 'video' && (p.appData as { source?: string }).source === 'camera') return p;
@@ -1119,7 +1232,8 @@ export class StreamClient {
1119
1232
  // videoEncodings).
1120
1233
  const { width = 0, height = 0 } = track.getSettings();
1121
1234
  const quality = this.videoQuality;
1122
- const encodings = videoEncodings(width, height, quality, this.layerCount());
1235
+ this.cameraFrameRate = this.frameRateFor(track);
1236
+ const encodings = videoEncodings(width, height, quality, this.layerCount(), this.cameraFrameRate);
1123
1237
  let codecs: RtpCodecCapability[] | undefined;
1124
1238
  try {
1125
1239
  codecs = this.device?.sendRtpCapabilities.codecs;
@@ -1134,7 +1248,7 @@ export class StreamClient {
1134
1248
  encodings,
1135
1249
  codecOptions: { videoGoogleStartBitrate: VIDEO_START_BITRATE_KBPS },
1136
1250
  ...(codec ? { codec } : {}),
1137
- appData: { source: 'camera' },
1251
+ appData: { source: 'camera', videoLayers: describeVideoLayers(width, height, encodings, quality) },
1138
1252
  });
1139
1253
  let producer: Producer;
1140
1254
  try {
@@ -1150,6 +1264,8 @@ export class StreamClient {
1150
1264
  // quality change or a track swap under way).
1151
1265
  await this.keepProducer(producer, generation, 'camera');
1152
1266
  this.cameraEncodings = encodings;
1267
+ this.frameRateLowSamples = 0;
1268
+ this.frameRateWarmupSamples = 2;
1153
1269
  await this.pauseOnServer(producer);
1154
1270
  await this.applyDegradationPreference(producer);
1155
1271
  this.syncLimitWatch();
@@ -1233,7 +1349,7 @@ export class StreamClient {
1233
1349
 
1234
1350
  /** videoLimited sampling runs while a camera is published and someone listens. */
1235
1351
  private syncLimitWatch(): void {
1236
- this.limitWatch.set(!this.closedByUs && this.cameraProducer() !== undefined && this.listenerCount('videoLimited') > 0);
1352
+ this.limitWatch.set(!this.closedByUs && this.cameraProducer() !== undefined && (this.listenerCount('videoLimited') > 0 || this.cameraFrameRate === 60));
1237
1353
  }
1238
1354
 
1239
1355
  /**
@@ -1249,6 +1365,11 @@ export class StreamClient {
1249
1365
  const generation = this.publishGeneration;
1250
1366
  // Music is not a device and cannot be "gone": publish it again whatever
1251
1367
  // happens to the camera and microphone below.
1368
+ const screen = this.screenStream;
1369
+ if (screen) await this.restoreScreen().catch(async (err) => {
1370
+ if (this.screenStream === screen) await this.stopScreen('stopped');
1371
+ this.emit('error', err as Error);
1372
+ });
1252
1373
  if (this.music) await this.produceMusic(this.music, generation).catch((err) => this.emit('error', err as Error));
1253
1374
  if (generation !== this.publishGeneration) return;
1254
1375
 
@@ -1281,6 +1402,150 @@ export class StreamClient {
1281
1402
  await this.publish(stream);
1282
1403
  }
1283
1404
 
1405
+ // ------------------------------------------------------------- screen sharing
1406
+
1407
+ /** Publish a screen beside the camera. Call from a click so capture permission can open. */
1408
+ async publishScreen(options: ScreenPublishOptions = {}): Promise<MediaStream> {
1409
+ if (this.joinResult?.role !== 'host' && this.joinResult?.role !== 'cohost') throw new RpcError('forbidden', 'only hosts and cohosts can share a screen');
1410
+ if (this.screenStream || this.screenPending) throw new RpcError('screen-share-active', 'a screen share is already active');
1411
+ if (options.mode !== undefined && options.mode !== 'detail' && options.mode !== 'motion') throw new TypeError('publishScreen: mode must be detail or motion');
1412
+ const mode = options.mode ?? 'detail';
1413
+ if (!options.stream && (typeof navigator === 'undefined' || !navigator.mediaDevices?.getDisplayMedia)) {
1414
+ throw new RpcError('screen-share-unsupported', 'screen capture is not supported by this browser; use a supported desktop browser');
1415
+ }
1416
+ const generation = this.screenGeneration;
1417
+ this.screenPending = true;
1418
+ let stream: MediaStream | undefined;
1419
+ try {
1420
+ stream = options.stream ?? await navigator.mediaDevices.getDisplayMedia({
1421
+ video: { width: { ideal: 1920, max: 1920 }, height: { ideal: 1080, max: 1080 }, frameRate: { ideal: mode === 'motion' ? 30 : 15, max: mode === 'motion' ? 30 : 15 } },
1422
+ audio: options.audio ?? false,
1423
+ });
1424
+ const video = stream.getVideoTracks()[0];
1425
+ if (!video || video.readyState !== 'live') throw new RpcError('screen-share-no-video', 'screen capture did not provide a live video track');
1426
+ if (generation !== this.screenGeneration || this.closedByUs) throw new Error(unpublishedWhile('screen'));
1427
+ this.screenStream = stream;
1428
+ this.screenMode = mode;
1429
+ this.screenAudioEnabled = options.audio !== false;
1430
+ video.contentHint = mode;
1431
+ const onEnded = () => { void this.stopScreen('track-ended').catch((err) => this.emit('error', err as Error)); };
1432
+ this.screenEndedListener = onEnded;
1433
+ video.addEventListener('ended', onEnded, { once: true });
1434
+ await this.restoreScreen();
1435
+ if (generation !== this.screenGeneration || video.readyState !== 'live') throw new Error(unpublishedWhile('screen'));
1436
+ this.screenStarted = true;
1437
+ this.wasPublishing = true;
1438
+ this.emit('screenShareStarted', { stream, mode });
1439
+ return stream;
1440
+ } catch (error) {
1441
+ if (generation === this.screenGeneration) await this.stopScreen('stopped');
1442
+ stream?.getTracks().forEach((track) => track.stop());
1443
+ throw error;
1444
+ } finally {
1445
+ if (generation === this.screenGeneration) this.screenPending = false;
1446
+ }
1447
+ }
1448
+
1449
+ /** Stop only the screen and its tab/system audio; keep camera, microphone and music. */
1450
+ async unpublishScreen(): Promise<void> {
1451
+ await this.stopScreen('stopped');
1452
+ }
1453
+
1454
+ private releaseScreen(): void {
1455
+ this.screenGeneration += 1;
1456
+ this.screenPending = false;
1457
+ const stream = this.screenStream;
1458
+ if (this.screenEndedListener) stream?.getVideoTracks()[0]?.removeEventListener('ended', this.screenEndedListener);
1459
+ this.screenEndedListener = null;
1460
+ this.screenStream = null;
1461
+ this.screenStarted = false;
1462
+ stream?.getTracks().forEach((track) => track.stop());
1463
+ }
1464
+
1465
+ private async stopScreen(reason: 'stopped' | 'track-ended'): Promise<void> {
1466
+ const started = this.screenStarted;
1467
+ const ids = [...this.screenProducerIds];
1468
+ this.screenProducerIds.clear();
1469
+ this.releaseScreen();
1470
+ for (const id of ids) { this.producers.get(id)?.close(); this.producers.delete(id); }
1471
+ if (!this.localStream && !this.music) this.wasPublishing = false;
1472
+ if (started && !this.closedByUs) this.emit('screenShareEnded', { reason });
1473
+ await Promise.all(ids.map((producerId) => this.rpc('closeProducer', { producerId }).catch(() => undefined)));
1474
+ }
1475
+
1476
+ private async restoreScreen(): Promise<void> {
1477
+ const stream = this.screenStream;
1478
+ const video = stream?.getVideoTracks()[0];
1479
+ if (!stream || !video) return;
1480
+ if (video.readyState !== 'live') { await this.stopScreen('track-ended'); return; }
1481
+ const generation = this.screenGeneration;
1482
+ const publishing = this.publishGeneration;
1483
+ const transport = await this.ensureSendTransport();
1484
+ if (generation !== this.screenGeneration) return;
1485
+ const { width = 0, height = 0 } = video.getSettings();
1486
+ const encodings = screenEncodings(width, height, this.screenMode);
1487
+ // A project's explicit safety restriction also applies to screen video.
1488
+ const cap = topLayerSize(width, height, this.videoPolicy.maxQuality);
1489
+ const screenScale = encodings.at(-1)!.scaleResolutionDownBy;
1490
+ if (cap.scale > screenScale) encodings.forEach((e) => { e.scaleResolutionDownBy *= cap.scale / screenScale; });
1491
+ const keep = async (producer: Producer) => {
1492
+ if (generation !== this.screenGeneration || publishing !== this.publishGeneration || video.readyState !== 'live') {
1493
+ producer.close();
1494
+ await this.rpc('closeProducer', { producerId: producer.id }).catch(() => undefined);
1495
+ throw new Error(unpublishedWhile('screen'));
1496
+ }
1497
+ this.producers.set(producer.id, producer);
1498
+ this.screenProducerIds.add(producer.id);
1499
+ await this.pauseOnServer(producer);
1500
+ };
1501
+ await keep(await transport.produce({
1502
+ track: video, stopTracks: false, encodings,
1503
+ codecOptions: { videoGoogleStartBitrate: VIDEO_START_BITRATE_KBPS },
1504
+ appData: { source: 'screen', videoLayers: describeVideoLayers(width, height, encodings) },
1505
+ }));
1506
+ const audio = stream.getAudioTracks().find((track) => track.readyState === 'live');
1507
+ if (audio && this.screenAudioEnabled && generation === this.screenGeneration) await keep(await transport.produce({
1508
+ track: audio, stopTracks: false,
1509
+ codecOptions: { opusStereo: true, opusDtx: false, opusFec: true, opusMaxAverageBitrate: 128000 },
1510
+ appData: { source: 'screen-audio' },
1511
+ }));
1512
+ }
1513
+
1514
+ private frameRateFor(track: MediaStreamTrack): 30 | 60 {
1515
+ if (this.publishOptions.frameRate !== 60 || this.frameRateFallback) return 30;
1516
+ const captured = track.getSettings().frameRate;
1517
+ if (this.videoPolicy.maxFrameRate === 30 || (typeof captured === 'number' && captured < 50)) {
1518
+ this.frameRateFallback = true;
1519
+ this.emit('error', new RpcError('frame-rate-fallback', '60 fps is unavailable from this camera or project; publishing at 30 fps'));
1520
+ return 30;
1521
+ }
1522
+ return 60;
1523
+ }
1524
+
1525
+ private async sampleCamera(baseline: StatsBaseline): Promise<VideoSendStats | null> {
1526
+ const stats = await this.collectVideoStats(baseline);
1527
+ if (this.cameraFrameRate !== 60 || this.frameRateFallback || !stats) return stats;
1528
+ if (this.cameraProducer()?.paused) return stats;
1529
+ // The lower simulcast copies intentionally run at 30 fps. During the
1530
+ // initial bandwidth ramp they may be the only active copies, which does
1531
+ // not establish that the camera cannot sustain 60. Match native warm-up:
1532
+ // two 2-second samples, then require three consecutive limited samples.
1533
+ if (this.frameRateWarmupSamples-- > 0) return stats;
1534
+ const limited = stats.layers.some((layer) => layer.active && (layer.qualityLimitation === 'cpu' || layer.qualityLimitation === 'bandwidth'));
1535
+ this.frameRateLowSamples = limited && stats.fps > 0 && stats.fps < 50 ? this.frameRateLowSamples + 1 : 0;
1536
+ if (this.frameRateLowSamples >= 3) {
1537
+ this.frameRateFallback = true;
1538
+ void this.cameraTask(async () => {
1539
+ const producer = this.cameraProducer();
1540
+ if (!producer?.track || producer.closed) return;
1541
+ await producer.track.applyConstraints?.({ frameRate: { ideal: 30, max: 30 } }).catch(() => undefined);
1542
+ await this.reproduceCamera(producer, producer.track);
1543
+ this.emit('error', new RpcError('frame-rate-fallback', 'this device or upload cannot sustain 60 fps; publishing at 30 fps'));
1544
+ }).catch((error) => this.emit('error', error as Error));
1545
+ }
1546
+ return stats;
1547
+ }
1548
+
1284
1549
  // ------------------------------------------------------------------ music
1285
1550
 
1286
1551
  /**
@@ -1392,11 +1657,17 @@ export class StreamClient {
1392
1657
 
1393
1658
  const consumer = await transport.consume(params);
1394
1659
  this.consumers.set(remote.producerId, consumer);
1660
+ this.remoteVideoLayers.set(remote.producerId, remote.videoLayers ?? remote.appData?.videoLayers ?? (params as { videoLayers?: VideoLayer[] }).videoLayers ?? []);
1395
1661
 
1396
1662
  // Layers asked for before a reconnect: the new consumer gets them before
1397
1663
  // its first frame instead of starting at the full picture. Not getting
1398
1664
  // them costs data, not the track, so a refusal is only reported.
1399
- const layers = this.preferredLayers.get(remote.producerId);
1665
+ let layers = this.preferredLayers.get(remote.producerId);
1666
+ const receiveQuality = this.receiveQualities.get(remote.producerId);
1667
+ if (receiveQuality && receiveQuality !== 'auto') {
1668
+ try { layers = { spatialLayer: receiveSpatialLayer(this.remoteVideoLayers.get(remote.producerId) ?? [], receiveQuality) }; }
1669
+ catch { layers = undefined; this.emit('error', new RpcError('quality-unavailable', 'this publisher no longer describes its video layers; using Auto')); }
1670
+ }
1400
1671
  if (layers) {
1401
1672
  await this.rpc('setPreferredLayers', { consumerId: consumer.id, ...layers }).catch((err) => this.emit('error', err as Error));
1402
1673
  }
@@ -1436,7 +1707,7 @@ export class StreamClient {
1436
1707
 
1437
1708
  private trackEnabled(kind: 'audio' | 'video'): boolean {
1438
1709
  for (const p of this.producers.values()) {
1439
- if (p.kind === kind && p.id !== this.musicProducerId) return !p.paused;
1710
+ if (p.kind === kind && p.appData.source === defaultSource(kind)) return !p.paused;
1440
1711
  }
1441
1712
  return false;
1442
1713
  }
@@ -1454,24 +1725,40 @@ export class StreamClient {
1454
1725
  await this.setKindEnabled('video', enabled);
1455
1726
  }
1456
1727
 
1728
+ private applyLocalModeration(kind?: 'audio' | 'video' | ''): void {
1729
+ for (const producer of this.producers.values()) {
1730
+ if (!kind || producer.kind === kind) producer.pause();
1731
+ }
1732
+ // During reconnect there are no producers, but the retained tracks still
1733
+ // capture. Keep their muted state for republish as well.
1734
+ for (const stream of [this.localStream, this.screenStream]) {
1735
+ for (const track of stream?.getTracks() ?? []) if (!kind || track.kind === kind) track.enabled = false;
1736
+ }
1737
+ if (!kind || kind === 'audio') this.music?.setEnabled(false);
1738
+ }
1739
+
1457
1740
  private async setKindEnabled(kind: 'audio' | 'video', enabled: boolean): Promise<void> {
1458
1741
  for (const producer of this.producers.values()) {
1459
1742
  // 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();
1743
+ if (producer.kind !== kind || producer.appData.source !== defaultSource(kind)) continue;
1744
+ if (!enabled) producer.pause();
1463
1745
  await this.rpc('pauseProducer', { producerId: producer.id, paused: !enabled });
1746
+ if (enabled) producer.resume();
1464
1747
  }
1465
1748
  this.emitLocalMedia();
1466
1749
  }
1467
1750
 
1468
1751
  /** Stop publishing entirely and release the local devices. */
1469
1752
  async unpublish(): Promise<void> {
1753
+ void this.stopRecording().catch(error => this.emit('error', error as Error));
1470
1754
  // All of it takes effect now; only telling the server is waited for. So
1471
1755
  // publish() and unpublish() act in the order they are called: a publish
1472
1756
  // called while this still waits is not undone by it, and one already
1473
1757
  // under way stops at its next step (keepProducer).
1474
1758
  this.publishGeneration += 1;
1759
+ const screenWasStarted = this.screenStarted;
1760
+ this.releaseScreen();
1761
+ if (screenWasStarted) this.emit('screenShareEnded', { reason: 'stopped' });
1475
1762
  const ids = [...this.producers.keys()];
1476
1763
  for (const producer of this.producers.values()) producer.close();
1477
1764
  this.producers.clear();
@@ -1496,14 +1783,27 @@ export class StreamClient {
1496
1783
  }
1497
1784
  }
1498
1785
 
1786
+ /** Paid camera recording; project opt-in, quotas and quality are checked by the backend. */
1787
+ async startRecording(options: RecordingOptions): Promise<void> {
1788
+ if (this.recording) throw new Error('Recording is already active');
1789
+ if (!this.localStream || !this.joinResult || !['host', 'cohost'].includes(this.joinResult.role)) throw new Error('Publish your camera before recording');
1790
+ const recording = new CameraRecording(() => this.camEnabled ? this.localStream : null, () => this.token, options);
1791
+ this.recording = recording;
1792
+ try { await recording.start(); } catch (error) { if (this.recording === recording) this.recording = null; throw error; }
1793
+ }
1794
+ async stopRecording(): Promise<RecordingFile[]> {
1795
+ const recording = this.recording; this.recording = null;
1796
+ return recording ? recording.stop() : [];
1797
+ }
1798
+
1499
1799
  get local(): MediaStream | null {
1500
1800
  return this.localStream;
1501
1801
  }
1502
1802
 
1503
1803
  // ------------------------------------------------------------- moderation
1504
1804
 
1505
- raiseHand(): Promise<unknown> { return this.rpc('raiseHand'); }
1506
- lowerHand(): Promise<unknown> { return this.rpc('lowerHand'); }
1805
+ raiseHand(metadata?: Record<string, unknown>): Promise<unknown> { return this.rpc('raiseHand', metadata ? { metadata } : {}); }
1806
+ lowerHand(identity?: string): Promise<unknown> { return this.rpc('lowerHand', identity ? { identity } : {}); }
1507
1807
  muteParticipant(identity: string): Promise<unknown> { return this.rpc('requestMute', { identity }); }
1508
1808
  /**
1509
1809
  * 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. */