@unboundcx/video-sdk-client 2.0.2 → 2.0.4

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.
@@ -206,6 +206,14 @@ export class VideoMeetingClient extends EventEmitter {
206
206
  this.emit("device:changed", data);
207
207
  });
208
208
 
209
+ this.localMedia.on("camera:degraded", (data) => {
210
+ this.emit("camera:degraded", data);
211
+ });
212
+
213
+ this.localMedia.on("camera:restarted", (data) => {
214
+ this.emit("camera:restarted", data);
215
+ });
216
+
209
217
  // Remote media events
210
218
  this.remoteMedia.on("participant:added", (data) => {
211
219
  this.emit("participant:joined", data);
@@ -882,6 +890,21 @@ export class VideoMeetingClient extends EventEmitter {
882
890
  return await this.localMedia.updateCameraBackground(options);
883
891
  }
884
892
 
893
+ /**
894
+ * Restart the local camera in place. Use after a 'camera:degraded'
895
+ * event, or as a user-triggered recovery from a frozen video state.
896
+ * Re-acquires the camera, reapplies any active background, and
897
+ * replaces the track on the existing producer (no renegotiation).
898
+ *
899
+ * @param {Object} [options] - Optional deviceId/facingMode override.
900
+ * If omitted, reuses the last-known camera source.
901
+ * @returns {Promise<MediaStream>}
902
+ */
903
+ async restartCamera(options) {
904
+ this._ensureInRoom();
905
+ return await this.localMedia.restartCamera(options);
906
+ }
907
+
885
908
  /**
886
909
  * Mute camera
887
910
  * @returns {Promise<void>}
@@ -57,6 +57,15 @@ export class LocalMediaManager extends EventEmitter {
57
57
  // Video processor for virtual backgrounds
58
58
  this.videoProcessor = new VideoProcessor({ debug: options.debug });
59
59
  this.currentBackgroundOptions = null;
60
+
61
+ // When a virtual background is active, `streams.camera` is the
62
+ // canvas-captured (processed) stream — its track has no deviceId
63
+ // or facingMode. We keep the *raw* camera stream and the original
64
+ // source descriptor around so subsequent operations (background
65
+ // swaps, camera changes) can re-acquire or reuse the real camera
66
+ // rather than reading constraints off the canvas track.
67
+ this.rawCameraStream = null;
68
+ this.cameraSource = null; // { deviceId } | { facingMode } | null
60
69
  }
61
70
 
62
71
  /**
@@ -186,6 +195,19 @@ export class LocalMediaManager extends EventEmitter {
186
195
  }
187
196
  }
188
197
 
198
+ // Remember the raw camera stream + source descriptor BEFORE any
199
+ // background processing wraps it. Used by updateCameraBackground
200
+ // and changeCamera so they don't read constraints off a canvas
201
+ // track (which has no deviceId / facingMode).
202
+ this.rawCameraStream = stream;
203
+ if (options.facingMode) {
204
+ this.cameraSource = { facingMode: options.facingMode };
205
+ } else {
206
+ const trackDeviceId =
207
+ originalTrack.getSettings().deviceId || options.deviceId || null;
208
+ this.cameraSource = trackDeviceId ? { deviceId: trackDeviceId } : null;
209
+ }
210
+
189
211
  // Apply background effect if specified
190
212
  if (options.background && options.background.type !== 'none') {
191
213
  this.logger.info('Applying background effect:', options.background);
@@ -207,12 +229,11 @@ export class LocalMediaManager extends EventEmitter {
207
229
  // Store stream
208
230
  this.streams.camera = stream;
209
231
 
210
- // Setup track ended handler
211
- track.addEventListener('ended', () => {
212
- this.logger.warn('Camera track ended');
213
- this.emit('track:ended', { type: 'camera' });
214
- this.streams.camera = null;
215
- });
232
+ // Attach health watchers (ended / muted) on the producer's
233
+ // current track. _watchCameraTrack handles both ended and
234
+ // browser-side mute (which is distinct from user mute and
235
+ // indicates the OS took the camera, USB unplug, etc.).
236
+ this._watchCameraTrack(track);
216
237
 
217
238
  // Publish to mediasoup
218
239
  const producer = await this.mediasoup.produce(track, {
@@ -442,6 +463,22 @@ export class LocalMediaManager extends EventEmitter {
442
463
  // Stop all tracks
443
464
  this.streams.camera.getTracks().forEach((track) => track.stop());
444
465
 
466
+ // Also stop the raw camera stream if it's distinct (background was
467
+ // active — processed/canvas stream wraps the raw camera stream).
468
+ if (this.rawCameraStream && this.rawCameraStream !== this.streams.camera) {
469
+ this.rawCameraStream.getTracks().forEach((t) => t.stop());
470
+ }
471
+
472
+ // Tear down processor if a background was active.
473
+ if (this.currentBackgroundOptions) {
474
+ try {
475
+ await this.videoProcessor.cleanup();
476
+ } catch (cleanupErr) {
477
+ this.logger.warn('Processor cleanup during stopCamera failed:', cleanupErr);
478
+ }
479
+ this.currentBackgroundOptions = null;
480
+ }
481
+
445
482
  // Close producer
446
483
  if (this.producers.video) {
447
484
  await this.mediasoup.closeProducer('video');
@@ -449,6 +486,8 @@ export class LocalMediaManager extends EventEmitter {
449
486
  }
450
487
 
451
488
  this.streams.camera = null;
489
+ this.rawCameraStream = null;
490
+ this.cameraSource = null;
452
491
 
453
492
  this.emit('stream:removed', { type: 'camera' });
454
493
  }
@@ -687,18 +726,28 @@ export class LocalMediaManager extends EventEmitter {
687
726
  throw new Error('No active camera to change');
688
727
  }
689
728
 
729
+ const oldProcessedStream = this.streams.camera;
730
+ const oldRawStream = this.rawCameraStream;
731
+
690
732
  try {
691
- // Get new stream
733
+ // Get new stream (always fresh — camera device is changing).
692
734
  const constraints = this._getVideoConstraints(options);
693
- let stream = await navigator.mediaDevices.getUserMedia({
735
+ const rawStream = await navigator.mediaDevices.getUserMedia({
694
736
  video: constraints,
695
737
  });
696
738
 
739
+ let stream = rawStream;
740
+
697
741
  // Reapply background effect if it was active
698
742
  if (this.currentBackgroundOptions) {
699
743
  this.logger.info('Reapplying background effect after camera change');
744
+ try {
745
+ await this.videoProcessor.cleanup();
746
+ } catch (cleanupErr) {
747
+ this.logger.warn('Processor cleanup during camera change failed:', cleanupErr);
748
+ }
700
749
  stream = await this.videoProcessor.applyBackground(
701
- stream,
750
+ rawStream,
702
751
  this.currentBackgroundOptions,
703
752
  );
704
753
  }
@@ -707,6 +756,7 @@ export class LocalMediaManager extends EventEmitter {
707
756
 
708
757
  // Replace track in producer
709
758
  await this.producers.video.replaceTrack({ track: newTrack });
759
+ this._watchCameraTrack(newTrack);
710
760
 
711
761
  // If producer was paused (camera muted), resume it
712
762
  // When user manually changes camera, they likely want to use it
@@ -716,13 +766,32 @@ export class LocalMediaManager extends EventEmitter {
716
766
  this.muteState.camera = false;
717
767
  }
718
768
 
719
- // Stop old stream
720
- if (this.streams.camera) {
721
- this.streams.camera.getTracks().forEach((track) => track.stop());
769
+ // Stop the OLD raw camera stream (the actual device). The old
770
+ // processed/canvas stream's track is no longer attached to the
771
+ // producer; stop it too so the canvas doesn't keep pumping.
772
+ if (oldRawStream && oldRawStream !== rawStream) {
773
+ oldRawStream.getTracks().forEach((t) => t.stop());
774
+ }
775
+ if (
776
+ oldProcessedStream &&
777
+ oldProcessedStream !== oldRawStream &&
778
+ oldProcessedStream !== stream
779
+ ) {
780
+ oldProcessedStream.getTracks().forEach((t) => t.stop());
722
781
  }
723
782
 
724
- // Store new stream
783
+ // Store new streams + source descriptor.
725
784
  this.streams.camera = stream;
785
+ this.rawCameraStream = rawStream;
786
+ if (options.facingMode) {
787
+ this.cameraSource = { facingMode: options.facingMode };
788
+ } else {
789
+ const trackDeviceId =
790
+ rawStream.getVideoTracks()[0]?.getSettings().deviceId ||
791
+ options.deviceId ||
792
+ null;
793
+ this.cameraSource = trackDeviceId ? { deviceId: trackDeviceId } : null;
794
+ }
726
795
 
727
796
  this.logger.info('Camera changed successfully');
728
797
 
@@ -766,66 +835,129 @@ export class LocalMediaManager extends EventEmitter {
766
835
  throw new Error('No active video producer');
767
836
  }
768
837
 
769
- try {
770
- // Clean up old processor if exists
771
- if (this.currentBackgroundOptions) {
772
- await this.videoProcessor.cleanup();
773
- }
774
-
775
- // Get current device ID
776
- const currentTrack = this.streams.camera.getVideoTracks()[0];
777
- const deviceId = currentTrack.getSettings().deviceId;
838
+ // Remember everything we'd need to restore on failure. We do NOT
839
+ // stop the existing canvas / camera or tear down the processor
840
+ // until the NEW track has been swapped in successfully — otherwise
841
+ // a failure mid-swap leaves the producer holding a stopped track
842
+ // and the user's video freezes locally and on remotes.
843
+ const hadBackground = !!this.currentBackgroundOptions;
844
+ const oldProcessedStream = this.streams.camera;
845
+ const oldRawStream = this.rawCameraStream;
778
846
 
779
- // If removing background, restart with original stream
847
+ try {
848
+ // REMOVE BACKGROUND
780
849
  if (options.type === 'none') {
781
850
  this.logger.info('Removing background effect');
782
- this.currentBackgroundOptions = null;
783
851
 
784
- const constraints = this._getVideoConstraints({ deviceId });
785
- const newStream = await navigator.mediaDevices.getUserMedia({
786
- video: constraints,
787
- });
852
+ // Prefer the raw camera stream we already have — its track is
853
+ // the real device track. If it's somehow gone, fall back to
854
+ // getUserMedia using the recorded source.
855
+ let newStream = null;
856
+ const rawTrack = oldRawStream?.getVideoTracks?.()[0];
857
+ if (rawTrack && rawTrack.readyState === 'live') {
858
+ newStream = oldRawStream;
859
+ } else {
860
+ const constraints = this._getVideoConstraints(
861
+ this.cameraSource || {},
862
+ );
863
+ newStream = await navigator.mediaDevices.getUserMedia({
864
+ video: constraints,
865
+ });
866
+ }
788
867
 
789
868
  const newTrack = newStream.getVideoTracks()[0];
790
-
791
869
  await this.producers.video.replaceTrack({ track: newTrack });
870
+ this._watchCameraTrack(newTrack);
792
871
 
793
- this.streams.camera.getTracks().forEach((t) => t.stop());
872
+ // Commit new state, then tear down old.
794
873
  this.streams.camera = newStream;
874
+ this.rawCameraStream = newStream;
875
+ this.currentBackgroundOptions = null;
876
+
877
+ if (hadBackground) {
878
+ try {
879
+ await this.videoProcessor.cleanup();
880
+ } catch (cleanupErr) {
881
+ this.logger.warn('Processor cleanup after remove failed:', cleanupErr);
882
+ }
883
+ // Stop the processed (canvas) stream's track if it's
884
+ // distinct from the raw stream we just promoted.
885
+ if (oldProcessedStream && oldProcessedStream !== newStream) {
886
+ oldProcessedStream.getTracks().forEach((t) => t.stop());
887
+ }
888
+ }
795
889
 
796
890
  this.logger.info('Background effect removed');
797
891
  this.emit('background:changed', { type: 'none' });
798
-
799
892
  return newStream;
800
893
  }
801
894
 
802
- // Apply new background effect
803
- this.currentBackgroundOptions = options;
804
-
805
- // Get fresh stream
806
- const constraints = this._getVideoConstraints({ deviceId });
807
- let stream = await navigator.mediaDevices.getUserMedia({
808
- video: constraints,
809
- });
895
+ // APPLY / SWAP BACKGROUND
896
+ //
897
+ // Reuse the raw camera stream if it's still live — that's the
898
+ // common case for switching between backgrounds without doing
899
+ // a second getUserMedia (which is slow and can hit
900
+ // OverconstrainedError because the canvas track has no
901
+ // deviceId).
902
+ let rawStream = oldRawStream;
903
+ const rawTrack = rawStream?.getVideoTracks?.()[0];
904
+ const rawAlive = !!rawTrack && rawTrack.readyState === 'live';
905
+
906
+ if (!rawAlive) {
907
+ const constraints = this._getVideoConstraints(
908
+ this.cameraSource || {},
909
+ );
910
+ rawStream = await navigator.mediaDevices.getUserMedia({
911
+ video: constraints,
912
+ });
913
+ }
810
914
 
811
- // Apply background
812
- stream = await this.videoProcessor.applyBackground(stream, options);
915
+ // If swapping (background already active) we must tear down the
916
+ // processor before starting a new one — its canvas / offscreen
917
+ // video are single-instance state.
918
+ if (hadBackground) {
919
+ try {
920
+ await this.videoProcessor.cleanup();
921
+ } catch (cleanupErr) {
922
+ this.logger.warn('Processor cleanup before swap failed:', cleanupErr);
923
+ }
924
+ }
813
925
 
814
- const newTrack = stream.getVideoTracks()[0];
926
+ const processedStream = await this.videoProcessor.applyBackground(
927
+ rawStream,
928
+ options,
929
+ );
930
+ const newTrack = processedStream.getVideoTracks()[0];
815
931
 
816
- // Replace track in producer
817
932
  await this.producers.video.replaceTrack({ track: newTrack });
933
+ this._watchCameraTrack(newTrack);
818
934
 
819
- // Stop old stream
820
- this.streams.camera.getTracks().forEach((t) => t.stop());
821
- this.streams.camera = stream;
935
+ // Commit new state.
936
+ this.currentBackgroundOptions = options;
937
+ this.streams.camera = processedStream;
938
+ this.rawCameraStream = rawStream;
939
+
940
+ // Stop the OLD processed canvas stream's track (it was wrapping
941
+ // the processor we just cleaned up). The raw stream is
942
+ // intentionally preserved — it's still feeding the new processor.
943
+ if (
944
+ hadBackground &&
945
+ oldProcessedStream &&
946
+ oldProcessedStream !== processedStream &&
947
+ oldProcessedStream !== rawStream
948
+ ) {
949
+ oldProcessedStream.getTracks().forEach((t) => t.stop());
950
+ }
822
951
 
823
952
  this.logger.info('Background effect updated successfully');
824
953
  this.emit('background:changed', options);
825
954
 
826
- return stream;
955
+ return processedStream;
827
956
  } catch (error) {
828
957
  this.logger.error('Failed to update camera background:', error);
958
+ // We did not mutate streams.camera or stop the old track until
959
+ // after the swap succeeded, so the producer is still holding a
960
+ // live track and the user's video keeps flowing.
829
961
  throw wrapError(error, 'updateCameraBackground');
830
962
  }
831
963
  }
@@ -1036,6 +1168,165 @@ export class LocalMediaManager extends EventEmitter {
1036
1168
  return this.muteState.microphone;
1037
1169
  }
1038
1170
 
1171
+ /**
1172
+ * Attach health watchers to the producer's current video track.
1173
+ *
1174
+ * Emits:
1175
+ * - 'track:ended' { type: 'camera' } — kept for back-compat
1176
+ * - 'camera:degraded' { reason, track } — track is no longer
1177
+ * delivering frames (ended, OS-level muted, etc.). Remotes
1178
+ * will see a frozen frame until the producer is paused or the
1179
+ * track is replaced via restartCamera().
1180
+ *
1181
+ * The 'mute' event here is the browser's own muting (camera taken
1182
+ * over by another app, USB unplug, OS privacy switch) — distinct
1183
+ * from the user pressing our mute button.
1184
+ */
1185
+ _watchCameraTrack(track) {
1186
+ if (!track) return;
1187
+ // Stash so subsequent watchers can replace listeners cleanly.
1188
+ if (this._watchedCameraTrack === track) return;
1189
+ this._watchedCameraTrack = track;
1190
+
1191
+ const onEnded = () => {
1192
+ this.logger.warn('Camera track ended');
1193
+ this.emit('track:ended', { type: 'camera' });
1194
+ if (this.streams.camera) this.streams.camera = null;
1195
+ this.emit('camera:degraded', { reason: 'ended', track });
1196
+ };
1197
+ const onMute = () => {
1198
+ this.logger.warn('Camera track muted (browser-side)');
1199
+ this.emit('camera:degraded', { reason: 'muted', track });
1200
+ };
1201
+
1202
+ track.addEventListener('ended', onEnded);
1203
+ track.addEventListener('mute', onMute);
1204
+ }
1205
+
1206
+ /**
1207
+ * Restart the local camera in place.
1208
+ *
1209
+ * Re-acquires the camera via `cameraSource` (or any caller-supplied
1210
+ * deviceId/facingMode), reapplies the current background if one was
1211
+ * active, and `replaceTrack`s the live producer. Use this when
1212
+ * 'camera:degraded' has fired or the user manually clicks "Restart
1213
+ * camera" after a freeze.
1214
+ *
1215
+ * Does NOT change isCameraMuted. If the producer was paused on
1216
+ * degradation, the caller is responsible for resuming it once the
1217
+ * new track is in place (we resume here automatically for ergonomics).
1218
+ *
1219
+ * @param {Object} [options] - Optional deviceId/facingMode override.
1220
+ * @returns {Promise<MediaStream>} New camera stream.
1221
+ */
1222
+ async restartCamera(options = {}) {
1223
+ this.logger.info('Restarting camera', options);
1224
+
1225
+ if (!this.producers.video) {
1226
+ throw new Error('No active camera producer to restart');
1227
+ }
1228
+
1229
+ const source =
1230
+ Object.keys(options).length > 0 ? options : this.cameraSource || {};
1231
+
1232
+ const oldProcessedStream = this.streams.camera;
1233
+ const oldRawStream = this.rawCameraStream;
1234
+
1235
+ try {
1236
+ const constraints = this._getVideoConstraints(source);
1237
+ let rawStream;
1238
+ try {
1239
+ rawStream = await navigator.mediaDevices.getUserMedia({
1240
+ video: constraints,
1241
+ });
1242
+ } catch (err) {
1243
+ if (
1244
+ err.name === 'OverconstrainedError' ||
1245
+ err.name === 'ConstraintNotSatisfiedError'
1246
+ ) {
1247
+ this.logger.warn(
1248
+ 'Restart hit constraints, retrying with ideal-only:',
1249
+ err.constraint,
1250
+ );
1251
+ const fallback = {
1252
+ ...(source.deviceId ? { deviceId: { exact: source.deviceId } } : {}),
1253
+ ...(source.facingMode
1254
+ ? { facingMode: { exact: source.facingMode } }
1255
+ : {}),
1256
+ width: { ideal: constraints.width?.ideal },
1257
+ height: { ideal: constraints.height?.ideal },
1258
+ frameRate: constraints.frameRate,
1259
+ };
1260
+ rawStream = await navigator.mediaDevices.getUserMedia({
1261
+ video: fallback,
1262
+ });
1263
+ } else {
1264
+ throw err;
1265
+ }
1266
+ }
1267
+
1268
+ let stream = rawStream;
1269
+ if (this.currentBackgroundOptions) {
1270
+ try {
1271
+ await this.videoProcessor.cleanup();
1272
+ } catch (cleanupErr) {
1273
+ this.logger.warn('Processor cleanup during restart failed:', cleanupErr);
1274
+ }
1275
+ stream = await this.videoProcessor.applyBackground(
1276
+ rawStream,
1277
+ this.currentBackgroundOptions,
1278
+ );
1279
+ }
1280
+
1281
+ const newTrack = stream.getVideoTracks()[0];
1282
+ await this.producers.video.replaceTrack({ track: newTrack });
1283
+
1284
+ // If we paused the producer when degradation was detected,
1285
+ // resume now that a healthy track is attached. Don't touch
1286
+ // the user's mute state.
1287
+ if (this.producers.video.paused && !this.muteState.camera) {
1288
+ try {
1289
+ this.producers.video.resume();
1290
+ } catch (resumeErr) {
1291
+ this.logger.warn('Producer resume after restart failed:', resumeErr);
1292
+ }
1293
+ }
1294
+
1295
+ // Commit + stop old streams.
1296
+ this.streams.camera = stream;
1297
+ this.rawCameraStream = rawStream;
1298
+ if (source.facingMode) {
1299
+ this.cameraSource = { facingMode: source.facingMode };
1300
+ } else {
1301
+ const trackDeviceId =
1302
+ rawStream.getVideoTracks()[0]?.getSettings().deviceId ||
1303
+ source.deviceId ||
1304
+ null;
1305
+ this.cameraSource = trackDeviceId ? { deviceId: trackDeviceId } : null;
1306
+ }
1307
+
1308
+ if (oldRawStream && oldRawStream !== rawStream) {
1309
+ oldRawStream.getTracks().forEach((t) => t.stop());
1310
+ }
1311
+ if (
1312
+ oldProcessedStream &&
1313
+ oldProcessedStream !== oldRawStream &&
1314
+ oldProcessedStream !== stream
1315
+ ) {
1316
+ oldProcessedStream.getTracks().forEach((t) => t.stop());
1317
+ }
1318
+
1319
+ this._watchCameraTrack(newTrack);
1320
+ this.emit('camera:restarted', { stream });
1321
+ this.logger.info('Camera restarted successfully');
1322
+
1323
+ return stream;
1324
+ } catch (error) {
1325
+ this.logger.error('Failed to restart camera:', error);
1326
+ throw wrapError(error, 'restartCamera');
1327
+ }
1328
+ }
1329
+
1039
1330
  /**
1040
1331
  * Clean up all local streams
1041
1332
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unboundcx/video-sdk-client",
3
- "version": "2.0.2",
3
+ "version": "2.0.4",
4
4
  "description": "Framework-agnostic WebRTC video meeting SDK powered by mediasoup",
5
5
  "type": "module",
6
6
  "main": "index.js",