@unboundcx/video-sdk-client 2.0.6 → 2.0.8

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.
@@ -7,6 +7,11 @@ import { RemoteMediaManager } from "./managers/RemoteMediaManager.js";
7
7
  import { QualityMonitor } from "./managers/QualityMonitor.js";
8
8
  import { ConnectionHealthMonitor } from "./managers/ConnectionHealthMonitor.js";
9
9
  import { StateError, RoomError } from "./utils/errors.js";
10
+ import {
11
+ getVideoQualityProfile,
12
+ sendConfigChanged,
13
+ DEFAULT_VIDEO_QUALITY_LEVEL,
14
+ } from "./videoQualityProfiles.js";
10
15
 
11
16
  /**
12
17
  * Main SDK client for video meeting functionality
@@ -43,6 +48,7 @@ export class VideoMeetingClient extends EventEmitter {
43
48
  this.state = "disconnected"; // disconnected, connecting, connected, waiting-room, in-meeting
44
49
  this.currentRoomId = null;
45
50
  this.joinData = null; // Store video.join() response data
51
+ this.serverInfo = null; // Handling pod identity (serverId/externalIp/geoLocation)
46
52
  this.debug = options.debug;
47
53
  this.isGuest = false;
48
54
  this.inWaitingRoom = false;
@@ -62,6 +68,11 @@ export class VideoMeetingClient extends EventEmitter {
62
68
  this.localMedia = null;
63
69
  this.remoteMedia = null;
64
70
 
71
+ // Current video quality level (data-saver). 'auto' by default; the host
72
+ // app maps its "Data saver" toggle to 'medium'/'auto'. See
73
+ // videoQualityProfiles.js + setVideoQuality().
74
+ this._videoQualityLevel = DEFAULT_VIDEO_QUALITY_LEVEL;
75
+
65
76
  // If serverUrl provided, initialize managers now (old behavior)
66
77
  if (options.serverUrl) {
67
78
  this._initializeManagers(options.serverUrl);
@@ -597,6 +608,13 @@ export class VideoMeetingClient extends EventEmitter {
597
608
  // Listen for media.routerCapabilities to know when room is ready
598
609
  this.connection.onServerEvent("media.routerCapabilities", (data) => {
599
610
  this.logger.info("Room is ready - received media.routerCapabilities");
611
+ // The handling pod stamps its identity (serverId / externalIp /
612
+ // geoLocation) onto this message — surface it so the app can show
613
+ // which server is serving this participant.
614
+ if (data?.serverInfo) {
615
+ this.serverInfo = data.serverInfo;
616
+ this.emit("server:info", data.serverInfo);
617
+ }
600
618
  this.emit("waitingRoom:ready", {
601
619
  roomId: videoRoom.id,
602
620
  participant,
@@ -757,7 +775,87 @@ export class VideoMeetingClient extends EventEmitter {
757
775
  */
758
776
  async publishCamera(options = {}) {
759
777
  this._ensureInRoom();
760
- return await this.localMedia.publishCamera(options);
778
+ // Honor the active quality level so a camera (re)published after the user
779
+ // set data-saver — e.g. unmuting mid-call — comes up already capped.
780
+ const profile = getVideoQualityProfile(this._videoQualityLevel);
781
+ const merged = {
782
+ resolution: profile.sendResolution || options.resolution,
783
+ maxResolution: profile.sendResolution || options.maxResolution,
784
+ frameRate: profile.sendFrameRate || options.frameRate,
785
+ ...options,
786
+ };
787
+ // options passed by the caller still win for explicit overrides, except
788
+ // we always apply the quality-level send cap on top.
789
+ if (profile.sendResolution) {
790
+ merged.resolution = profile.sendResolution;
791
+ merged.maxResolution = profile.sendResolution;
792
+ merged.frameRate = profile.sendFrameRate;
793
+ }
794
+ return await this.localMedia.publishCamera(merged);
795
+ }
796
+
797
+ /**
798
+ * Set the video quality level (data-saver / quality tiers). One call fans
799
+ * out to both directions:
800
+ * - receive: sets a shared layer ceiling on the mediasoup + remote media
801
+ * managers so we never pull above the cap from any peer (coordinates
802
+ * with QualityMonitor — the lower of {auto-selected, cap} always wins).
803
+ * - send: re-caps the live camera's capture resolution/frame-rate +
804
+ * simulcast top layer, without a full republish (low flicker). Skipped
805
+ * when the send config is unchanged (e.g. auto → high).
806
+ *
807
+ * Screen share is intentionally untouched in both directions.
808
+ *
809
+ * @param {'auto'|'high'|'medium'|'low'} level
810
+ * @returns {Promise<void>}
811
+ */
812
+ async setVideoQuality(level) {
813
+ const profile = getVideoQualityProfile(level);
814
+ const prevLevel = this._videoQualityLevel;
815
+ this._videoQualityLevel = level;
816
+ this.logger.info("VideoMeetingClient :: setVideoQuality ::", {
817
+ level,
818
+ profile,
819
+ });
820
+
821
+ // ---- receive ceiling (applies immediately to existing consumers) ----
822
+ if (this.mediasoup) {
823
+ this.mediasoup.setLayerCeiling(
824
+ profile.maxSpatialLayer,
825
+ profile.maxTemporalLayer,
826
+ );
827
+ }
828
+ if (this.remoteMedia) {
829
+ this.remoteMedia.setLayerCeiling(
830
+ profile.maxSpatialLayer,
831
+ profile.maxTemporalLayer,
832
+ );
833
+ }
834
+
835
+ // ---- send cap (only if the capture config actually changed) ----
836
+ if (
837
+ sendConfigChanged(prevLevel, level) &&
838
+ this.localMedia?.isCameraActive
839
+ ) {
840
+ try {
841
+ await this.localMedia.setSendQuality(profile);
842
+ } catch (err) {
843
+ this.logger.error(
844
+ "VideoMeetingClient :: setVideoQuality :: send cap failed",
845
+ err,
846
+ );
847
+ }
848
+ }
849
+
850
+ this.emit("quality:level", { level });
851
+ }
852
+
853
+ /**
854
+ * Current video quality level.
855
+ * @returns {'auto'|'high'|'medium'|'low'}
856
+ */
857
+ getVideoQualityLevel() {
858
+ return this._videoQualityLevel;
761
859
  }
762
860
 
763
861
  /**
@@ -1222,6 +1320,13 @@ export class VideoMeetingClient extends EventEmitter {
1222
1320
  const oldState = this.state;
1223
1321
  this.state = newState;
1224
1322
  this.logger.info(`State changed: ${oldState} -> ${newState}`);
1323
+ // Tell the health monitor whether media should be flowing. Media silence
1324
+ // only signals a problem once we're actually in the meeting; before that
1325
+ // (connecting / waiting-room) there's no media by design, so silence must
1326
+ // not escalate to "Connection lost". Socket/transport failures still do.
1327
+ if (this.connectionHealth?.setMediaExpected) {
1328
+ this.connectionHealth.setMediaExpected(newState === "in-meeting");
1329
+ }
1225
1330
  this.emit("state:changed", { from: oldState, to: newState });
1226
1331
  }
1227
1332
 
package/index.js CHANGED
@@ -18,6 +18,14 @@
18
18
  export { VideoMeetingClient } from './VideoMeetingClient.js';
19
19
  export { VideoProcessor } from './VideoProcessor.js';
20
20
 
21
+ // Video quality tiers (data-saver / quality level)
22
+ export {
23
+ VIDEO_QUALITY_LEVELS,
24
+ VIDEO_QUALITY_PROFILES,
25
+ DEFAULT_VIDEO_QUALITY_LEVEL,
26
+ getVideoQualityProfile,
27
+ } from './videoQualityProfiles.js';
28
+
21
29
  // Managers (for advanced usage)
22
30
  export { ConnectionManager } from './managers/ConnectionManager.js';
23
31
  export { MediasoupManager } from './managers/MediasoupManager.js';
@@ -74,6 +74,11 @@ export class ConnectionHealthMonitor {
74
74
  this._timer = null;
75
75
  this._started = false;
76
76
  this._unsubs = [];
77
+ // Whether media should be flowing. Until we're in the meeting (set by
78
+ // VideoMeetingClient via setMediaExpected), media silence is expected
79
+ // and must not escalate the state — the pre-join waiting room is quiet
80
+ // by design. Socket/transport failures still escalate regardless.
81
+ this._mediaExpected = false;
77
82
 
78
83
  // Track transport-level state independently of socket-level state so
79
84
  // we can report the right reason in the event payload.
@@ -107,6 +112,13 @@ export class ConnectionHealthMonitor {
107
112
  // (any socket message, any stats sample). The wiring below already
108
113
  // does this for the common cases — exposed for hosts that want to
109
114
  // mark explicit liveness pings.
115
+ // Public: toggled by VideoMeetingClient when entering/leaving the meeting.
116
+ // Resets the silence clock on enable so the timer starts fresh at join.
117
+ setMediaExpected(expected) {
118
+ this._mediaExpected = !!expected;
119
+ if (this._mediaExpected) this._lastActivityAt = Date.now();
120
+ }
121
+
110
122
  markActivity() {
111
123
  this._lastActivityAt = Date.now();
112
124
  // Hearing from the server is sufficient evidence the connection is
@@ -226,12 +238,19 @@ export class ConnectionHealthMonitor {
226
238
  // has run long past Socket.IO's own ping timeout (the engine itself
227
239
  // is quiet, which is real evidence). This prevents an idle-but-fine
228
240
  // session (e.g. host alone in the room) from cascading to "failed".
241
+ // Silence only counts as a problem once media should be flowing. Before
242
+ // that (waiting room / pre-join) it's expected, so don't escalate on it.
243
+ const silenceCounts = this._mediaExpected;
229
244
  const hasCorroboration =
230
245
  !this._socketAlive ||
231
246
  this._transportFailed() ||
232
- silence >= this.opts.silenceReconnectingMs;
247
+ (silenceCounts && silence >= this.opts.silenceReconnectingMs);
233
248
 
234
- if (this._state === 'connected' && silence >= this.opts.silenceUnstableMs) {
249
+ if (
250
+ this._state === 'connected' &&
251
+ silenceCounts &&
252
+ silence >= this.opts.silenceUnstableMs
253
+ ) {
235
254
  this._transition('unstable', `silence:${silence}ms`);
236
255
  } else if (this._state === 'unstable' && hasCorroboration) {
237
256
  this._transition(
@@ -75,10 +75,46 @@ export class LocalMediaManager extends EventEmitter {
75
75
  async getDevices() {
76
76
  this.logger.log('Getting available devices');
77
77
 
78
+ // Request permission so enumerateDevices() returns device labels. Ask
79
+ // for audio and video INDEPENDENTLY — a single denied/missing device
80
+ // (e.g. camera blocked but microphone allowed) must not throw away the
81
+ // whole list. These temporary tracks exist only to unlock labels, so we
82
+ // stop them immediately; the published tracks are separate instances.
83
+ const stopTracks = (stream) => {
84
+ try {
85
+ stream?.getTracks().forEach((t) => t.stop());
86
+ } catch (e) {
87
+ /* ignore */
88
+ }
89
+ };
78
90
  try {
79
- // Request permissions first to get device labels
80
- await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
91
+ stopTracks(
92
+ await navigator.mediaDevices.getUserMedia({ audio: true, video: true }),
93
+ );
94
+ } catch (combinedError) {
95
+ this.logger.log(
96
+ 'getDevices :: combined permission request failed, trying audio and video separately ::',
97
+ combinedError?.name,
98
+ );
99
+ try {
100
+ stopTracks(await navigator.mediaDevices.getUserMedia({ audio: true }));
101
+ } catch (audioError) {
102
+ this.logger.log(
103
+ 'getDevices :: microphone unavailable ::',
104
+ audioError?.name,
105
+ );
106
+ }
107
+ try {
108
+ stopTracks(await navigator.mediaDevices.getUserMedia({ video: true }));
109
+ } catch (videoError) {
110
+ this.logger.log(
111
+ 'getDevices :: camera unavailable ::',
112
+ videoError?.name,
113
+ );
114
+ }
115
+ }
81
116
 
117
+ try {
82
118
  const devices = await navigator.mediaDevices.enumerateDevices();
83
119
 
84
120
  const result = {
@@ -95,7 +131,7 @@ export class LocalMediaManager extends EventEmitter {
95
131
 
96
132
  return result;
97
133
  } catch (error) {
98
- this.logger.error('Failed to get devices:', error);
134
+ this.logger.error('Failed to enumerate devices:', error);
99
135
  throw wrapError(error, 'getDevices');
100
136
  }
101
137
  }
@@ -824,6 +860,162 @@ export class LocalMediaManager extends EventEmitter {
824
860
  }
825
861
  }
826
862
 
863
+ /**
864
+ * Apply a send-side quality cap (data-saver / quality level) to the live
865
+ * camera WITHOUT a full republish/renegotiation. Two coordinated moves:
866
+ *
867
+ * 1. Re-acquire the camera at the capped capture resolution + frame
868
+ * rate and replaceTrack() it into the existing producer. Lowering
869
+ * actual capture is what saves CPU, battery and capture-side
870
+ * bandwidth — disabling a simulcast layer alone keeps encoding the
871
+ * full-res frame.
872
+ * 2. Cap the simulcast top layer via setParameters (deactivate
873
+ * encodings whose source res now exceeds the cap), so we don't pay
874
+ * 1080p-tier bits to ship a 540p picture.
875
+ *
876
+ * Same device, same background, no transport renegotiation → far less
877
+ * visible than tearing the producer down and re-publishing. No-op if the
878
+ * camera isn't currently active.
879
+ *
880
+ * @param {Object} profile - from videoQualityProfiles.js
881
+ * @param {string|null} profile.sendResolution - '360p'|'540p'|'720p'|'1080p'|null
882
+ * @param {number} profile.sendFrameRate
883
+ * @returns {Promise<void>}
884
+ */
885
+ async setSendQuality(profile = {}) {
886
+ if (!this.producers.video || !this.streams.camera) {
887
+ this.logger.info(
888
+ 'LocalMediaManager :: setSendQuality :: no active camera, skipping'
889
+ );
890
+ return;
891
+ }
892
+
893
+ const oldProcessedStream = this.streams.camera;
894
+ const oldRawStream = this.rawCameraStream;
895
+
896
+ try {
897
+ // Re-acquire at the capped capture constraints, reusing the current
898
+ // device/facingMode (cameraSource) so we don't switch cameras.
899
+ const reacquireOptions = {
900
+ ...(this.cameraSource || {}),
901
+ resolution: profile.sendResolution || undefined,
902
+ frameRate: profile.sendFrameRate || undefined,
903
+ };
904
+ const constraints = this._getVideoConstraints(reacquireOptions);
905
+ this.logger.info(
906
+ 'LocalMediaManager :: setSendQuality :: re-acquiring camera',
907
+ { sendResolution: profile.sendResolution, frameRate: profile.sendFrameRate }
908
+ );
909
+
910
+ const rawStream = await navigator.mediaDevices.getUserMedia({
911
+ video: constraints,
912
+ });
913
+
914
+ let stream = rawStream;
915
+ if (this.currentBackgroundOptions) {
916
+ try {
917
+ await this.videoProcessor.cleanup();
918
+ } catch (cleanupErr) {
919
+ this.logger.warn(
920
+ 'LocalMediaManager :: setSendQuality :: processor cleanup failed',
921
+ cleanupErr
922
+ );
923
+ }
924
+ stream = await this.videoProcessor.applyBackground(
925
+ rawStream,
926
+ this.currentBackgroundOptions
927
+ );
928
+ }
929
+
930
+ const newTrack = stream.getVideoTracks()[0];
931
+ await this.producers.video.replaceTrack({ track: newTrack });
932
+ this._watchCameraTrack(newTrack);
933
+
934
+ // Stop the old streams now that the new track is live.
935
+ if (oldRawStream && oldRawStream !== rawStream) {
936
+ oldRawStream.getTracks().forEach((t) => t.stop());
937
+ }
938
+ if (
939
+ oldProcessedStream &&
940
+ oldProcessedStream !== oldRawStream &&
941
+ oldProcessedStream !== stream
942
+ ) {
943
+ oldProcessedStream.getTracks().forEach((t) => t.stop());
944
+ }
945
+
946
+ this.streams.camera = stream;
947
+ this.rawCameraStream = rawStream;
948
+
949
+ // Cap the simulcast top layer to match the new capture resolution.
950
+ this._capSendSimulcastToResolution(profile.sendResolution);
951
+
952
+ this.logger.info('LocalMediaManager :: setSendQuality :: applied', {
953
+ resolution: `${newTrack.getSettings().width}x${newTrack.getSettings().height}`,
954
+ frameRate: newTrack.getSettings().frameRate,
955
+ });
956
+
957
+ this.emit('stream:added', {
958
+ type: 'camera',
959
+ stream,
960
+ producer: this.producers.video,
961
+ });
962
+ } catch (error) {
963
+ this.logger.error(
964
+ 'LocalMediaManager :: setSendQuality :: failed',
965
+ error
966
+ );
967
+ throw wrapError(error, 'setSendQuality');
968
+ }
969
+ }
970
+
971
+ /**
972
+ * Deactivate simulcast encodings whose source resolution exceeds the cap,
973
+ * via RTCRtpSender.setParameters (no renegotiation). null/'1080p' = no cap
974
+ * (reactivate everything). Mirrors QualityMonitor's outbound capping but
975
+ * keyed off a user-chosen resolution rather than a 'mid'|'low' target.
976
+ * @private
977
+ * @param {string|null} sendResolution
978
+ */
979
+ _capSendSimulcastToResolution(sendResolution) {
980
+ const sender = this.producers.video?.rtpSender;
981
+ if (!sender || typeof sender.getParameters !== 'function') return;
982
+
983
+ // rid → approx source width at 1080p capture (l=1/4, m=1/2, h=full).
984
+ // Keep a layer active only if its width is within the cap.
985
+ const capWidth =
986
+ { '360p': 640, '540p': 960, '720p': 1280 }[sendResolution] || Infinity;
987
+
988
+ try {
989
+ const params = sender.getParameters();
990
+ if (!params.encodings || params.encodings.length < 2) return;
991
+ let changed = false;
992
+ for (const enc of params.encodings) {
993
+ // scaleResolutionDownBy tells us this layer's output width
994
+ // relative to the captured frame; below the cap stays active.
995
+ const settings = this.streams.camera?.getVideoTracks()[0]?.getSettings();
996
+ const captureWidth = settings?.width || 1920;
997
+ const layerWidth = captureWidth / (enc.scaleResolutionDownBy || 1);
998
+ const wantActive = layerWidth <= capWidth + 1; // +1 for rounding
999
+ if (enc.active !== wantActive) {
1000
+ enc.active = wantActive;
1001
+ changed = true;
1002
+ }
1003
+ }
1004
+ if (changed) {
1005
+ sender.setParameters(params);
1006
+ this.logger.info(
1007
+ 'LocalMediaManager :: _capSendSimulcastToResolution ::',
1008
+ { sendResolution, capWidth }
1009
+ );
1010
+ }
1011
+ } catch (err) {
1012
+ this.logger.warn(
1013
+ 'LocalMediaManager :: _capSendSimulcastToResolution failed',
1014
+ err
1015
+ );
1016
+ }
1017
+ }
1018
+
827
1019
  /**
828
1020
  * Update background effect on active camera
829
1021
  * @param {Object} options - Background options
@@ -1090,7 +1282,9 @@ export class LocalMediaManager extends EventEmitter {
1090
1282
  */
1091
1283
  _getVideoConstraints(options = {}) {
1092
1284
  const resolutions = {
1285
+ '360p': { width: 640, height: 360 },
1093
1286
  '480p': { width: 640, height: 480 },
1287
+ '540p': { width: 960, height: 540 },
1094
1288
  '720p': { width: 1280, height: 720 },
1095
1289
  '1080p': { width: 1920, height: 1080 },
1096
1290
  };
@@ -33,6 +33,11 @@ export class MediasoupManager extends EventEmitter {
33
33
  this.producers = new Map(); // Map<type, Producer>
34
34
  this.consumers = new Map(); // Map<consumerId, Consumer>
35
35
 
36
+ // Receive-side quality ceiling (data-saver / quality level). Enforced in
37
+ // setPeerPreferredLayer so every inbound-layer request is bounded. null =
38
+ // no cap. See videoQualityProfiles.js + RemoteMediaManager.setLayerCeiling.
39
+ this.layerCeiling = { maxSpatialLayer: null, maxTemporalLayer: null };
40
+
36
41
  // Initialize stats collector
37
42
  this.statsCollector = new StatsCollector(this.logger);
38
43
  this.virtualBackgroundStore = null; // Will be set externally
@@ -580,19 +585,32 @@ export class MediasoupManager extends EventEmitter {
580
585
  // No `return` — we still want to call transport.produce().
581
586
  } else {
582
587
 
583
- // Check if user has set a max resolution preference
588
+ // Check if user has set a max resolution preference (data-saver /
589
+ // quality level). The top simulcast layer is capped to this; lower
590
+ // layers scale from it. Bitrate budget tracks the cap so we don't
591
+ // pay 1080p bits to send a 540p top layer.
584
592
  const maxResolution = options.maxResolution || "1080p"; // Default to 1080p
585
593
 
594
+ // Resolution caps → { width, bitrate } for the top (h) layer. Only
595
+ // applied when the captured track is actually larger than the cap.
596
+ const RES_CAPS = {
597
+ "360p": { width: 640, bitrate: 400000 },
598
+ "540p": { width: 960, bitrate: 1000000 },
599
+ "720p": { width: 1280, bitrate: 2000000 },
600
+ "1080p": { width: 1920, bitrate: 3500000 },
601
+ };
602
+
586
603
  // Calculate target resolution based on user preference
587
604
  let targetWidth = baseWidth;
588
605
  let targetHeight = baseHeight;
589
606
  let targetBitrate = 3500000;
590
607
 
591
- if (maxResolution === "720p" && baseWidth > 1280) {
592
- // User prefers 720p max - scale down from 1080p
593
- targetWidth = 1280;
594
- targetHeight = 720;
595
- targetBitrate = 2000000;
608
+ const cap = RES_CAPS[maxResolution];
609
+ if (cap && baseWidth > cap.width) {
610
+ // Scale the top layer down to the cap, preserving aspect ratio.
611
+ targetHeight = Math.round((baseHeight * cap.width) / baseWidth);
612
+ targetWidth = cap.width;
613
+ targetBitrate = cap.bitrate;
596
614
  }
597
615
 
598
616
  // Adaptive simulcast configuration based on target resolution
@@ -855,8 +873,13 @@ export class MediasoupManager extends EventEmitter {
855
873
  producer.close();
856
874
  this.producers.delete(type);
857
875
 
858
- // Notify server with correct event name and format
859
- await this.connection.request("media.produce.close", {
876
+ // Notify server. Event name is `media.produceClose` (camelCase
877
+ // suffix) — must match the handler registration in app1-socket's
878
+ // /video routes and app1-video-server's NATS handler map. A dotted
879
+ // variant ("media.produce.close") gets silently dropped server-side
880
+ // and the SDK then times out after 5s with the producer still
881
+ // active on the SFU — remote consumers see a frozen frame.
882
+ await this.connection.request("media.produceClose", {
860
883
  producer: {
861
884
  id: producer.id,
862
885
  producerType: type,
@@ -1055,6 +1078,20 @@ export class MediasoupManager extends EventEmitter {
1055
1078
  */
1056
1079
  setPeerPreferredLayer(peerParticipantId, spatialLayer) {
1057
1080
  if (!this.connection?.socket) return false;
1081
+ // Apply the user's quality ceiling (data-saver). This is the single
1082
+ // chokepoint every layer request flows through — the resize
1083
+ // auto-selector AND QualityMonitor's inbound uncap (which asks for
1084
+ // spatialLayer 2) both land here — so clamping once keeps every caller
1085
+ // bounded. The lower of {requested, ceiling} always wins.
1086
+ const spatial =
1087
+ this.layerCeiling.maxSpatialLayer == null
1088
+ ? spatialLayer
1089
+ : Math.min(spatialLayer, this.layerCeiling.maxSpatialLayer);
1090
+ const temporal =
1091
+ this.layerCeiling.maxTemporalLayer == null
1092
+ ? 2
1093
+ : Math.min(2, this.layerCeiling.maxTemporalLayer);
1094
+
1058
1095
  // Find every consumer whose producer belongs to this peer. We may
1059
1096
  // have a video + a screenshare consumer for the same peer; we cap
1060
1097
  // only kind='video' to avoid clobbering screen share quality.
@@ -1064,13 +1101,23 @@ export class MediasoupManager extends EventEmitter {
1064
1101
  if (consumer.appData?.participantId !== peerParticipantId) continue;
1065
1102
  this.connection.socket.emit("media.consumer.setPreferredLayers", {
1066
1103
  consumer: { id: consumer.id },
1067
- preferredLayers: { spatialLayer, temporalLayer: 2 },
1104
+ preferredLayers: { spatialLayer: spatial, temporalLayer: temporal },
1068
1105
  });
1069
1106
  sent = true;
1070
1107
  }
1071
1108
  return sent;
1072
1109
  }
1073
1110
 
1111
+ /**
1112
+ * Set the receive-side quality ceiling enforced in setPeerPreferredLayer.
1113
+ * null for either value lifts that ceiling. Data-saver / quality level.
1114
+ * @param {number|null} maxSpatialLayer
1115
+ * @param {number|null} maxTemporalLayer
1116
+ */
1117
+ setLayerCeiling(maxSpatialLayer, maxTemporalLayer) {
1118
+ this.layerCeiling = { maxSpatialLayer, maxTemporalLayer };
1119
+ }
1120
+
1074
1121
  /**
1075
1122
  * Pause/resume the local consumer AND ask the SFU to stop/start
1076
1123
  * forwarding bytes for this peer's video. Local pause alone would
@@ -30,11 +30,15 @@
30
30
  // quality-monitor-thresholds.test.js for the user-visible reaction
31
31
  // times these collectively produce.
32
32
  const DEFAULTS = {
33
- // SignalState gates — outer gate per level transition. Tuned to be
34
- // patient: a blip that clears within ~20s isn't worth telling the
35
- // user about; below that we let the SDK adapt invisibly.
36
- warnSustainedMs: 20000,
37
- critSustainedMs: 35000,
33
+ // SignalState gates — outer gate per level transition. Kept in line with
34
+ // the inner encoder gates (encoderConstrainedMs / encoderSevereConstrainedMs)
35
+ // and the user-visible reaction times asserted in
36
+ // quality-monitor-thresholds.test.js: a silent cap / warning within ~10s of
37
+ // sustained badness, critical within ~25s. Short enough to feel Meet/Zoom-
38
+ // fast, long enough that momentary BWE blips don't toast — the recovery hold
39
+ // + per-level toast cooldown below absorb flapping.
40
+ warnSustainedMs: 8000,
41
+ critSustainedMs: 20000,
38
42
  // network thresholds (round trip)
39
43
  rttWarnMs: 300,
40
44
  rttCritMs: 500,
@@ -412,10 +416,18 @@ export class QualityMonitor {
412
416
  maxLoss >= this.thresholds.lossWarnRatio
413
417
  ? 1
414
418
  : 0;
419
+ // Bandwidth-pressure signals (the available-outgoing-bitrate floor and
420
+ // the encoder bandwidth-limited ratio) are only meaningful when we're
421
+ // actually sending video. With the camera off/denied, Chrome's BWE has
422
+ // nothing to probe and availableOutgoingBitrate reads artificially low —
423
+ // counting that as a bad network wrongly marks audio-only participants
424
+ // "poor" and triggers a disable-camera suggestion for a camera that's
425
+ // already off. RTT + loss remain valid either way.
415
426
  // Only count availMbps once we've seen a real reading (>0); some
416
427
  // browsers report 0 transiently at session start.
417
- const bandwidthSeverity =
418
- availMbps > 0 && availMbps <= this.thresholds.availMbpsCrit
428
+ const bandwidthSeverity = !this._isSendingVideo()
429
+ ? 0
430
+ : availMbps > 0 && availMbps <= this.thresholds.availMbpsCrit
419
431
  ? 2
420
432
  : bwLimitedRatio >= this.thresholds.encoderBwLimitedCritRatio
421
433
  ? 2
@@ -585,6 +597,7 @@ export class QualityMonitor {
585
597
  // pressure (availMbps + encoder-bw-limited ratio), so throttled
586
598
  // uplinks correctly drive network → critical alongside outbound.
587
599
  const triggerCritical =
600
+ this._isSendingVideo() &&
588
601
  this.outbound.level === "critical" &&
589
602
  this.network.level === "critical";
590
603
  const t = now();
@@ -763,6 +776,17 @@ export class QualityMonitor {
763
776
  return null;
764
777
  }
765
778
 
779
+ // Are we actively sending video right now (producer exists and isn't
780
+ // paused/closed)? Used to suppress encoder/bandwidth-pressure signals and
781
+ // the disable-camera suggestion when the camera is off or denied — Chrome's
782
+ // available-outgoing-bitrate reads artificially low with nothing to send,
783
+ // which would otherwise flag an audio-only participant's network as "poor"
784
+ // and even suggest disabling a camera that's already off.
785
+ _isSendingVideo() {
786
+ const p = this._findVideoProducer();
787
+ return !!(p && !p.closed && !p.paused);
788
+ }
789
+
766
790
  _emit(evt) {
767
791
  // Always log into the debug ring, even if onQualityEvent throws.
768
792
  try {
@@ -39,12 +39,28 @@ export class RemoteMediaManager extends EventEmitter {
39
39
  // Queue for producers that arrived before transports were ready
40
40
  this.pendingProducers = [];
41
41
 
42
+ // Producer ids currently being consumed (in-flight). A producer can be
43
+ // announced via both the pending queue and a re-sent media.producer.created
44
+ // after room.join; consuming it twice yields two consumers with the same
45
+ // msid, which makes setRemoteDescription fail ("Duplicate a=msid lines").
46
+ this.consumingProducers = new Set();
47
+
42
48
  // Server event listeners will be set up after socket connects
43
49
  this.listenersSetup = false;
44
50
 
45
51
  // Map of video element observers: participantId -> { element, observer, lastWidth, lastHeight }
46
52
  this.videoElementTracking = new Map();
47
53
 
54
+ // User-chosen quality ceiling (data-saver / quality level). These cap
55
+ // what we'll accept from EVERY remote peer, on top of the size-based
56
+ // auto-selection and QualityMonitor's bad-network capping. null = no
57
+ // ceiling (default). Whoever wants the LOWER layer wins, so a ceiling
58
+ // here can never raise quality — it only ever lowers it. Set via
59
+ // setLayerCeiling(); both the resize auto-selector and QualityMonitor
60
+ // clamp through clampSpatialLayer()/clampTemporalLayer().
61
+ this.maxSpatialLayer = null;
62
+ this.maxTemporalLayer = null;
63
+
48
64
  // Map of local mute states for remote streams: `${participantId}:${type}` -> boolean
49
65
  this.localMuteStates = new Map();
50
66
 
@@ -237,10 +253,25 @@ export class RemoteMediaManager extends EventEmitter {
237
253
  // Wait for device and transports to be ready before consuming
238
254
  // Transports are created when media.transports event arrives from server
239
255
  if (!this.mediasoup.device.loaded || !this.mediasoup.recvTransport) {
240
- this.logger.warn('Device or transport not ready yet, queuing producer:', producer.id);
241
- this.pendingProducers.push({ producer, participant });
256
+ if (!this.pendingProducers.some((p) => p.producer.id === producer.id)) {
257
+ this.logger.warn('Device or transport not ready yet, queuing producer:', producer.id);
258
+ this.pendingProducers.push({ producer, participant });
259
+ }
260
+ return;
261
+ }
262
+
263
+ // Skip if we're already consuming this producer or already have a
264
+ // consumer for it. Without this, a producer announced via both the
265
+ // pending queue and a re-sent media.producer.created gets consumed
266
+ // twice → duplicate msid → setRemoteDescription parse failure.
267
+ if (
268
+ this.consumingProducers.has(producer.id) ||
269
+ this._isProducerConsumed(producer.id)
270
+ ) {
271
+ this.logger.info('Skipping duplicate consume for producer:', producer.id);
242
272
  return;
243
273
  }
274
+ this.consumingProducers.add(producer.id);
244
275
 
245
276
  // Emit event to let UI know we're attempting to consume a stream
246
277
  const streamType = producer.producerType || producer.type || 'unknown';
@@ -324,7 +355,22 @@ export class RemoteMediaManager extends EventEmitter {
324
355
  error: error.message,
325
356
  canRetryManually: true
326
357
  });
358
+ } finally {
359
+ // Clear the in-flight marker. On success the consumers map now tracks
360
+ // it (see _isProducerConsumed); on failure it's free to retry.
361
+ this.consumingProducers.delete(producer.id);
362
+ }
363
+ }
364
+
365
+ /**
366
+ * Whether we already hold a consumer for this producer id.
367
+ * @private
368
+ */
369
+ _isProducerConsumed(producerId) {
370
+ for (const data of this.consumers.values()) {
371
+ if (data.producerId === producerId) return true;
327
372
  }
373
+ return false;
328
374
  }
329
375
 
330
376
  /**
@@ -488,9 +534,66 @@ export class RemoteMediaManager extends EventEmitter {
488
534
  (typeof window !== 'undefined' && window.devicePixelRatio) || 1;
489
535
  const maxPx = Math.max(width || 0, height || 0) * dpr;
490
536
 
491
- if (maxPx <= 480) return 0; // ≤480 px → 270p source suffices
492
- if (maxPx <= 960) return 1; // ≤960 px → 540p source suffices
493
- return 2; // largerneed 1080p source
537
+ let layer;
538
+ if (maxPx <= 480) layer = 0; // ≤480 px → 270p source suffices
539
+ else if (maxPx <= 960) layer = 1; // ≤960 px 540p source suffices
540
+ else layer = 2; // larger → need 1080p source
541
+
542
+ // Apply the user's quality ceiling (data-saver). A large tile on a
543
+ // metered connection should still be served the capped layer.
544
+ return this.clampSpatialLayer(layer);
545
+ }
546
+
547
+ /**
548
+ * Clamp a desired spatial layer to the user's ceiling. The lower of the
549
+ * two always wins, so callers (resize auto-selector, QualityMonitor) can
550
+ * request whatever they think is optimal and never exceed the cap.
551
+ * @param {number} layer - Desired spatial layer (0-2)
552
+ * @returns {number}
553
+ */
554
+ clampSpatialLayer(layer) {
555
+ if (this.maxSpatialLayer == null) return layer;
556
+ return Math.min(layer, this.maxSpatialLayer);
557
+ }
558
+
559
+ /**
560
+ * Clamp a desired temporal layer to the user's ceiling. See clampSpatialLayer.
561
+ * @param {number} layer - Desired temporal layer (0-2)
562
+ * @returns {number}
563
+ */
564
+ clampTemporalLayer(layer) {
565
+ if (this.maxTemporalLayer == null) return layer;
566
+ return Math.min(layer, this.maxTemporalLayer);
567
+ }
568
+
569
+ /**
570
+ * Set the user-chosen quality ceiling (data-saver / quality level) and
571
+ * immediately re-apply it to every consumer we're currently tracking, so
572
+ * a mid-call toggle takes effect right away. Pass null for either value to
573
+ * lift that ceiling.
574
+ * @param {number|null} maxSpatialLayer - 0-2 or null for no cap
575
+ * @param {number|null} maxTemporalLayer - 0-2 or null for no cap
576
+ */
577
+ setLayerCeiling(maxSpatialLayer, maxTemporalLayer) {
578
+ this.maxSpatialLayer = maxSpatialLayer;
579
+ this.maxTemporalLayer = maxTemporalLayer;
580
+ this.logger.info('RemoteMediaManager :: Set Layer Ceiling ::', {
581
+ maxSpatialLayer,
582
+ maxTemporalLayer,
583
+ });
584
+
585
+ // Re-evaluate every tracked element against the new ceiling. We
586
+ // recompute from the element's current size so lifting the cap also
587
+ // restores the size-appropriate layer, not just lowers it.
588
+ for (const [participantId, tracking] of this.videoElementTracking) {
589
+ const consumer = this._getVideoConsumerForParticipant(participantId);
590
+ if (!consumer) continue;
591
+ const optimalLayer = this._calculateOptimalSpatialLayer(
592
+ tracking.element.clientWidth,
593
+ tracking.element.clientHeight
594
+ );
595
+ this._updateConsumerLayers(consumer, optimalLayer);
596
+ }
494
597
  }
495
598
 
496
599
  /**
@@ -624,17 +727,21 @@ export class RemoteMediaManager extends EventEmitter {
624
727
  }
625
728
 
626
729
  try {
627
- // Set preferred layers: spatialLayer and temporalLayer
628
- // temporalLayer 2 = highest frame rate for the spatial layer
730
+ // spatialLayer is already clamped by callers via
731
+ // _calculateOptimalSpatialLayer/clampSpatialLayer. temporalLayer 2
732
+ // is the highest frame rate for the spatial layer; clamp it to the
733
+ // user's ceiling (data-saver low → ≈half frame rate).
734
+ const spatial = this.clampSpatialLayer(spatialLayer);
735
+ const temporal = this.clampTemporalLayer(2);
629
736
  await consumer.setPreferredLayers({
630
- spatialLayer: spatialLayer,
631
- temporalLayer: 2,
737
+ spatialLayer: spatial,
738
+ temporalLayer: temporal,
632
739
  });
633
740
 
634
741
  this.logger.log('RemoteMediaManager :: Updated Consumer Layers ::', {
635
742
  consumerId: consumer.id,
636
- spatialLayer,
637
- temporalLayer: 2,
743
+ spatialLayer: spatial,
744
+ temporalLayer: temporal,
638
745
  });
639
746
  } catch (error) {
640
747
  this.logger.error('RemoteMediaManager :: Failed to Update Consumer Layers ::', error);
@@ -355,6 +355,10 @@ export class StatsCollector {
355
355
  network.local.type = localCandidate.networkType;
356
356
  network.local.port = localCandidate.port;
357
357
  network.local.protocol = localCandidate.protocol;
358
+ // host | srflx | prflx | relay — 'relay' means traffic is going
359
+ // through a TURN server, a strong "why is it slow" signal.
360
+ network.local.candidateType = localCandidate.candidateType;
361
+ network.local.relayProtocol = localCandidate.relayProtocol;
358
362
  }
359
363
 
360
364
  const remoteCandidate = stats.get(candidate.remoteCandidateId);
@@ -362,7 +366,13 @@ export class StatsCollector {
362
366
  network.remote.wanIp = remoteCandidate.ip;
363
367
  network.remote.port = remoteCandidate.port;
364
368
  network.remote.protocol = remoteCandidate.protocol;
369
+ network.remote.candidateType = remoteCandidate.candidateType;
365
370
  }
371
+
372
+ // Relay in use if either end of the nominated pair is a TURN relay.
373
+ network.relay =
374
+ localCandidate?.candidateType === 'relay' ||
375
+ remoteCandidate?.candidateType === 'relay';
366
376
  }
367
377
  }
368
378
 
@@ -395,6 +405,7 @@ export class StatsCollector {
395
405
  audioStats.totalPacketSendDelay = this.validateNumber(
396
406
  report.totalPacketSendDelay,
397
407
  );
408
+ audioStats.nackCount = this.validateNumber(report.nackCount);
398
409
  } else if (
399
410
  report.type === 'remote-inbound-rtp' ||
400
411
  report.type === 'inbound-rtp'
@@ -432,6 +443,12 @@ export class StatsCollector {
432
443
  videoStats.qLdOther = report?.qualityLimitationDurations?.other;
433
444
  videoStats.qlResolutionChanges =
434
445
  report?.qualityLimitationResolutionChanges;
446
+ videoStats.qualityLimitationReason =
447
+ report?.qualityLimitationReason;
448
+ // Retransmits / keyframe requests — congestion + loss indicators.
449
+ videoStats.nackCount = this.validateNumber(report.nackCount);
450
+ videoStats.pliCount = this.validateNumber(report.pliCount);
451
+ videoStats.firCount = this.validateNumber(report.firCount);
435
452
  } else if (report.type === 'remote-inbound-rtp') {
436
453
  videoStats.jitter = this.validateNumber(report.jitter);
437
454
  videoStats.roundTripTime = this.validateNumber(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unboundcx/video-sdk-client",
3
- "version": "2.0.6",
3
+ "version": "2.0.8",
4
4
  "description": "Framework-agnostic WebRTC video meeting SDK powered by mediasoup",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -43,6 +43,7 @@
43
43
  "files": [
44
44
  "index.js",
45
45
  "VideoMeetingClient.js",
46
+ "videoQualityProfiles.js",
46
47
  "VideoProcessor.js",
47
48
  "AudioMixer.js",
48
49
  "managers/",
@@ -0,0 +1,100 @@
1
+ // Video quality tiers — single source of truth for the data-saver / quality
2
+ // selection feature. The UI surfaces a simple "Data saver" toggle, but the SDK
3
+ // is built around a richer set of named levels so we can expose finer control
4
+ // (an Auto/Low/Medium/High dropdown) later without touching the managers.
5
+ //
6
+ // A profile describes BOTH directions:
7
+ //
8
+ // send — how we publish our own camera:
9
+ // sendResolution capture/encode resolution cap (getUserMedia +
10
+ // simulcast top layer). null = no cap (use device
11
+ // default / 1080p).
12
+ // sendFrameRate capture frame-rate cap.
13
+ //
14
+ // receive — the ceiling on what we pull from every remote peer:
15
+ // maxSpatialLayer highest simulcast spatial layer we'll accept
16
+ // (0=≈270p, 1=≈540p, 2=≈1080p). null = no ceiling
17
+ // (size-based auto-selection, today's behavior).
18
+ // maxTemporalLayer highest temporal (frame-rate) layer we'll
19
+ // accept (2=full, 1=≈half, 0=≈quarter). null =
20
+ // no ceiling.
21
+ //
22
+ // Screen share is intentionally NOT governed by these profiles — shared text
23
+ // must stay legible, so the screen-share producer/consumers always run at their
24
+ // own high-quality single-layer profile regardless of the selected level.
25
+
26
+ /** @typedef {'auto'|'high'|'medium'|'low'} VideoQualityLevel */
27
+
28
+ export const VIDEO_QUALITY_LEVELS = ['auto', 'high', 'medium', 'low'];
29
+
30
+ export const DEFAULT_VIDEO_QUALITY_LEVEL = 'auto';
31
+
32
+ // resolution presets reference the same keys LocalMediaManager._getVideoConstraints
33
+ // understands ('360p' | '540p' | '720p' | '1080p').
34
+ export const VIDEO_QUALITY_PROFILES = Object.freeze({
35
+ // Size-based auto-selection on receive; full quality on send. This is the
36
+ // historical default behaviour — no ceilings imposed.
37
+ auto: {
38
+ label: 'Auto',
39
+ sendResolution: null, // device default (≈1080p)
40
+ sendFrameRate: 30,
41
+ maxSpatialLayer: null, // no ceiling — tile-size auto-selection wins
42
+ maxTemporalLayer: null,
43
+ },
44
+ // Explicit "best" — same caps as auto but pins send to 1080p rather than
45
+ // whatever the device defaults to. Useful as the OFF target if we ever want
46
+ // to force-max instead of auto.
47
+ high: {
48
+ label: 'High',
49
+ sendResolution: '1080p',
50
+ sendFrameRate: 30,
51
+ maxSpatialLayer: 2,
52
+ maxTemporalLayer: 2,
53
+ },
54
+ // The "Data saver" target. Meaningful savings, still very usable face video.
55
+ medium: {
56
+ label: 'Medium',
57
+ sendResolution: '540p',
58
+ sendFrameRate: 30,
59
+ maxSpatialLayer: 1, // never accept the 1080p layer from anyone
60
+ maxTemporalLayer: 2,
61
+ },
62
+ // Aggressive savings for genuinely tight / CPU-constrained connections.
63
+ low: {
64
+ label: 'Low',
65
+ sendResolution: '360p',
66
+ sendFrameRate: 15,
67
+ maxSpatialLayer: 0, // lowest layer only
68
+ maxTemporalLayer: 1, // ≈half frame-rate on receive
69
+ },
70
+ });
71
+
72
+ /**
73
+ * Resolve a level name to its profile, falling back to the default for unknown
74
+ * input so callers never have to null-check.
75
+ * @param {VideoQualityLevel} level
76
+ * @returns {typeof VIDEO_QUALITY_PROFILES.auto}
77
+ */
78
+ export function getVideoQualityProfile(level) {
79
+ return (
80
+ VIDEO_QUALITY_PROFILES[level] ||
81
+ VIDEO_QUALITY_PROFILES[DEFAULT_VIDEO_QUALITY_LEVEL]
82
+ );
83
+ }
84
+
85
+ /**
86
+ * True when the two levels would produce a different *send* configuration
87
+ * (capture resolution or frame-rate). Used to skip a needless camera republish
88
+ * when only the receive ceiling changed (e.g. auto → high).
89
+ * @param {VideoQualityLevel} a
90
+ * @param {VideoQualityLevel} b
91
+ * @returns {boolean}
92
+ */
93
+ export function sendConfigChanged(a, b) {
94
+ const pa = getVideoQualityProfile(a);
95
+ const pb = getVideoQualityProfile(b);
96
+ return (
97
+ pa.sendResolution !== pb.sendResolution ||
98
+ pa.sendFrameRate !== pb.sendFrameRate
99
+ );
100
+ }