@unboundcx/video-sdk-client 2.0.3 → 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>}
@@ -229,12 +229,11 @@ export class LocalMediaManager extends EventEmitter {
229
229
  // Store stream
230
230
  this.streams.camera = stream;
231
231
 
232
- // Setup track ended handler
233
- track.addEventListener('ended', () => {
234
- this.logger.warn('Camera track ended');
235
- this.emit('track:ended', { type: 'camera' });
236
- this.streams.camera = null;
237
- });
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);
238
237
 
239
238
  // Publish to mediasoup
240
239
  const producer = await this.mediasoup.produce(track, {
@@ -757,6 +756,7 @@ export class LocalMediaManager extends EventEmitter {
757
756
 
758
757
  // Replace track in producer
759
758
  await this.producers.video.replaceTrack({ track: newTrack });
759
+ this._watchCameraTrack(newTrack);
760
760
 
761
761
  // If producer was paused (camera muted), resume it
762
762
  // When user manually changes camera, they likely want to use it
@@ -867,6 +867,7 @@ export class LocalMediaManager extends EventEmitter {
867
867
 
868
868
  const newTrack = newStream.getVideoTracks()[0];
869
869
  await this.producers.video.replaceTrack({ track: newTrack });
870
+ this._watchCameraTrack(newTrack);
870
871
 
871
872
  // Commit new state, then tear down old.
872
873
  this.streams.camera = newStream;
@@ -929,6 +930,7 @@ export class LocalMediaManager extends EventEmitter {
929
930
  const newTrack = processedStream.getVideoTracks()[0];
930
931
 
931
932
  await this.producers.video.replaceTrack({ track: newTrack });
933
+ this._watchCameraTrack(newTrack);
932
934
 
933
935
  // Commit new state.
934
936
  this.currentBackgroundOptions = options;
@@ -1166,6 +1168,165 @@ export class LocalMediaManager extends EventEmitter {
1166
1168
  return this.muteState.microphone;
1167
1169
  }
1168
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
+
1169
1330
  /**
1170
1331
  * Clean up all local streams
1171
1332
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unboundcx/video-sdk-client",
3
- "version": "2.0.3",
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",