@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.
- package/VideoMeetingClient.js +23 -0
- package/managers/LocalMediaManager.js +167 -6
- package/package.json +1 -1
package/VideoMeetingClient.js
CHANGED
|
@@ -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
|
-
//
|
|
233
|
-
track.
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
*/
|