@pexip/media 20.2.0 → 20.3.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/CHANGELOG.md CHANGED
@@ -1,5 +1,48 @@
1
1
  # @pexip/media
2
2
 
3
+ ## 20.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 69d6be4: Refactor `MediaTrack` to decouple system/hardware mute from mute
8
+
9
+ - Add `stopped` attributes to `MediaTrack` to indicate the track `live` or
10
+ `ended`
11
+ - Add `suspended` attributes to
12
+ `MediaTrack to indicate the track is being system/hardware muted`
13
+ - Add signal supports to `MediaTrack` to be able to subscribe track related
14
+ signals
15
+ - Implement Two-hand control to secure mute consistency
16
+
17
+ When there is `muted` event from `MediaStreamTrack`, the track will follow
18
+ the event to set the `enabled` attribute to `false` to avoid any
19
+ possibilities of leaking the local media to remote parties. When the
20
+ `unmuted` event is triggered, it depends on whether the `enabled` attribute
21
+ is set before the `muted` event or not to unmute the track.
22
+
23
+ Breaking changes:
24
+
25
+ - Replace `MediaStreamTrack` related signal with the new `MediaTrack` signals
26
+
27
+ - `onStreamTrackEnded` -> `onMediaTrackStopped`
28
+ - `onStreamTrackMuted` -> `onMediaTrackSuspended`
29
+ - `onStreamTrackUnmuted` -> `onMediaTrackResumed`
30
+ - `onStreamTrackEnabled` -> `onMediaTrackMuted`
31
+ - `onAudioMuteStateChanged` -> `onMediaTrackMuted`
32
+ - `onVideoMuteStateChanged` -> `onMediaTrackMuted`
33
+
34
+ ### Patch Changes
35
+
36
+ - f8a8159: Fix Subsequant mute/unmute events handling
37
+ - e347770: Handle suspended state fro preview controller
38
+ - 0a375c7: Delay stopping video track to ensure last frame being black
39
+ - Updated dependencies [69d6be4]
40
+ - Updated dependencies [adc1a50]
41
+ - @pexip/media-control@20.3.0
42
+ - @pexip/utils@17.0.1
43
+ - @pexip/media-processor@20.3.0
44
+ - @pexip/signal@16.9.0
45
+
3
46
  ## 20.2.0
4
47
 
5
48
  ### Minor Changes
@@ -1,6 +1,6 @@
1
1
  import type { MediaDeviceRequest } from '@pexip/media-control';
2
2
  import type { AudioGraph, AudioNodeInit } from '@pexip/media-processor';
3
- import type { TrackProcessor } from './types';
3
+ import type { MediaSignals, TrackProcessor } from './types';
4
4
  interface AudioStreamProcessorProps {
5
5
  mixWithAdditionalMedia?: boolean;
6
6
  merger?: AudioNodeInit<ChannelMergerNode, ChannelMergerNode>;
@@ -12,7 +12,7 @@ type FeaturePropKeys = (typeof FEATURE_KEYS)[number];
12
12
  type FeatureProps = Pick<AudioStreamProcessorProps, FeaturePropKeys>;
13
13
  export declare const updateFeatureProps: (constraints: MediaDeviceRequest["audio"], props: FeatureProps) => FeatureProps;
14
14
  /**
15
- * Create a Audio Mixing Processor and will own the stream passed-in
15
+ * Create an Audio Mixing Processor and will own the stream passed-in
16
16
  */
17
- export declare const createAudioMixingProcess: (getCurrentMedia: () => MediaStream | undefined, label?: "AudioMixingProcessor") => TrackProcessor;
17
+ export declare const createAudioMixingProcess: (getCurrentMedia: () => MediaStream | undefined, signals?: MediaSignals, label?: "AudioMixingProcessor") => TrackProcessor;
18
18
  export {};
@@ -2,7 +2,7 @@ import { extractConstraintsWithKeys } from '@pexip/media-control';
2
2
  import { isEmpty, assert } from '@pexip/utils';
3
3
  import { createAudioGraph, createAudioGraphProxy, createChannelMergerGraphNode, createStreamDestinationGraphNode, createStreamSourceGraphNode, resumeAudioOnUnmute, } from '@pexip/media-processor';
4
4
  import { logger } from './logger';
5
- import { createMediaTrack, isTrackMuted } from './utils';
5
+ import { createMediaTrack } from './utils';
6
6
  import { PROCESSOR_LABELS } from './constants';
7
7
  const FEATURE_KEYS = ['mixWithAdditionalMedia'];
8
8
  const getAudioConstraints = extractConstraintsWithKeys(FEATURE_KEYS);
@@ -19,9 +19,9 @@ export const updateFeatureProps = (constraints, props) => {
19
19
  }, {});
20
20
  };
21
21
  /**
22
- * Create a Audio Mixing Processor and will own the stream passed-in
22
+ * Create an Audio Mixing Processor and will own the stream passed-in
23
23
  */
24
- export const createAudioMixingProcess = (getCurrentMedia, label = PROCESSOR_LABELS.AudioMixingProcessor) => {
24
+ export const createAudioMixingProcess = (getCurrentMedia, signals, label = PROCESSOR_LABELS.AudioMixingProcessor) => {
25
25
  const props = {};
26
26
  return async (prevMediaTrack) => {
27
27
  updateFeatureProps(prevMediaTrack.getConstraints(), props);
@@ -154,12 +154,12 @@ export const createAudioMixingProcess = (getCurrentMedia, label = PROCESSOR_LABE
154
154
  expectedInput: prevMediaTrack.expectedInput,
155
155
  track,
156
156
  overrideMute: true,
157
- mute: toMute => {
158
- prevMediaTrack.mute(toMute);
157
+ mute: (toMute, _self, soft) => {
159
158
  for (const track of mainSource.node?.mediaStream.getAudioTracks() ??
160
159
  []) {
161
160
  track.enabled = !toMute;
162
161
  }
162
+ prevMediaTrack.mute(toMute, soft);
163
163
  },
164
164
  get muted() {
165
165
  return (
@@ -168,11 +168,12 @@ export const createAudioMixingProcess = (getCurrentMedia, label = PROCESSOR_LABE
168
168
  !!prevMediaTrack.muted ||
169
169
  mainSource.node?.mediaStream
170
170
  .getAudioTracks()
171
- .some(isTrackMuted));
171
+ .some(track => track.enabled === false));
172
172
  },
173
173
  constraints: prevMediaTrack.getConstraints(),
174
174
  applyConstraints,
175
175
  release,
176
+ signals,
176
177
  getSettings: () => {
177
178
  const mixWithAdditionalMedia = !!props.mixWithAdditionalMedia;
178
179
  const contentHint = track.contentHint ?? '';
@@ -1,6 +1,6 @@
1
- import type { AudioGraphOptions, AudioNodeInit, ThrottleOptions, DenoiseWorkletNodeInit, AnalyzerNodeInit, AudioGraph } from '@pexip/media-processor';
1
+ import type { AnalyzerNodeInit, AudioGraph, AudioGraphOptions, AudioNodeInit, DenoiseWorkletNodeInit, ThrottleOptions } from '@pexip/media-processor';
2
2
  import type { MediaDeviceRequest } from '@pexip/media-control';
3
- import type { TrackProcessor, DenoiseParams, AudioContentHint } from './types';
3
+ import type { AudioContentHint, DenoiseParams, MediaSignals, TrackProcessor } from './types';
4
4
  type AudioNodeInits = AudioNodeInit[];
5
5
  /**
6
6
  * A function to be called to create the AudioNodes needed for the graph
@@ -60,6 +60,7 @@ interface AudioProcessOptions {
60
60
  */
61
61
  silentThreshold?: number;
62
62
  label?: string;
63
+ signals?: MediaSignals;
63
64
  }
64
65
  interface AudioStreamProcessorProps {
65
66
  audioGraphOptions?: AudioGraphOptions;
@@ -80,7 +81,7 @@ type FeaturePropKeys = (typeof FEATURE_KEYS)[number];
80
81
  type FeatureProps = Pick<AudioStreamProcessorProps, FeaturePropKeys>;
81
82
  export declare const updateFeatureProps: (constraints: MediaDeviceRequest["audio"], props: FeatureProps) => FeatureProps;
82
83
  /**
83
- * Create a Audio Stream Processor and will own the stream passed-in
84
+ * Create an Audio Stream Processor and will own the stream passed-in
84
85
  */
85
- export declare const createAudioStreamProcess: ({ analyzerUpdateFrequency, audioGraphOptions, audioSignalDetectionDuration, clock, createNodes, denoiseParams, fftSize, onAudioSignalDetected, onVoiceActivityDetected, shouldEnable, silentThreshold, throttleMs, label, }: AudioProcessOptions & ThrottleOptions) => TrackProcessor;
86
+ export declare const createAudioStreamProcess: ({ analyzerUpdateFrequency, audioGraphOptions, audioSignalDetectionDuration, clock, createNodes, denoiseParams, fftSize, onAudioSignalDetected, onVoiceActivityDetected, shouldEnable, signals, silentThreshold, throttleMs, label, }: AudioProcessOptions & ThrottleOptions) => TrackProcessor;
86
87
  export {};
@@ -1,6 +1,6 @@
1
1
  import { extractConstraintsWithKeys } from '@pexip/media-control';
2
2
  import { createQueue, isEmpty, assert } from '@pexip/utils';
3
- import { createAudioGraph, createAudioGraphProxy, createStreamSourceGraphNode, createStreamDestinationGraphNode, createAnalyzerSubscribableGraphNode, createDenoiseWorkletGraphNode, createAudioSignalDetector, createVADetector, createVoiceDetectorFromTimeData, createVoiceDetectorFromProbability, avg, } from '@pexip/media-processor';
3
+ import { avg, createAnalyzerSubscribableGraphNode, createAudioGraph, createAudioGraphProxy, createAudioSignalDetector, createDenoiseWorkletGraphNode, createStreamDestinationGraphNode, createStreamSourceGraphNode, createVADetector, createVoiceDetectorFromProbability, createVoiceDetectorFromTimeData, } from '@pexip/media-processor';
4
4
  import { PROCESSOR_LABELS } from './constants';
5
5
  import { logger } from './logger';
6
6
  import { isAudioContentHint } from './typeGuard';
@@ -54,12 +54,12 @@ export const updateFeatureProps = (constraints, props) => {
54
54
  }, {});
55
55
  };
56
56
  /**
57
- * Create a Audio Stream Processor and will own the stream passed-in
57
+ * Create an Audio Stream Processor and will own the stream passed-in
58
58
  */
59
59
  export const createAudioStreamProcess = ({ analyzerUpdateFrequency = 0.5, // 0.5 Hz
60
60
  audioGraphOptions, audioSignalDetectionDuration = 4.0, // 4 seconds
61
61
  clock, createNodes, denoiseParams, fftSize = 2048, // FFT size
62
- onAudioSignalDetected, onVoiceActivityDetected, shouldEnable, silentThreshold = 10.0 / 32767, // At least one LSB 16-bit data (compare is on absolute value).
62
+ onAudioSignalDetected, onVoiceActivityDetected, shouldEnable, signals, silentThreshold = 10.0 / 32767, // At least one LSB 16-bit data (compare is on absolute value).
63
63
  throttleMs = 3000, // 3 seconds
64
64
  label = PROCESSOR_LABELS.AudioProcessor, }) => {
65
65
  const props = {
@@ -198,11 +198,6 @@ label = PROCESSOR_LABELS.AudioProcessor, }) => {
198
198
  props.analyzer = undefined;
199
199
  props.audioGraph = undefined;
200
200
  };
201
- const mute = (mute) => {
202
- if (track) {
203
- track.enabled = !mute;
204
- }
205
- };
206
201
  return createMediaTrack({
207
202
  label,
208
203
  kind: 'audioinput',
@@ -210,9 +205,18 @@ label = PROCESSOR_LABELS.AudioProcessor, }) => {
210
205
  previousMediaTrack: prevMediaTrack,
211
206
  input: prevMediaTrack.input,
212
207
  expectedInput: prevMediaTrack.expectedInput,
213
- mute,
208
+ overrideMute: true,
209
+ mute(toMute) {
210
+ if (track) {
211
+ track.enabled = !toMute;
212
+ }
213
+ },
214
+ get muted() {
215
+ return !track.enabled;
216
+ },
214
217
  track,
215
218
  release,
219
+ signals,
216
220
  applyConstraints: async (constraints) => {
217
221
  if (isEmpty(constraints)) {
218
222
  return;
package/dist/media.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createIndexedDevices, createStreamTrackEventSubscriptions, getDevices, getInputDevicePermissionState, isIndexedDevices, isRequestedResolution, mergeConstraints, } from '@pexip/media-control';
1
+ import { createIndexedDevices, getDevices, getInputDevicePermissionState, isIndexedDevices, isRequestedResolution, mergeConstraints, } from '@pexip/media-control';
2
2
  import { createAsyncQueue, isEmpty, assert } from '@pexip/utils';
3
3
  import { internalSignals } from './signals';
4
4
  import { UserMediaStatus } from './types';
@@ -283,28 +283,6 @@ export const createMedia = ({ getMuteState, signals, audioProcessors, videoProce
283
283
  props.devices = createIndexedDevices(await getDevices());
284
284
  return props.devices;
285
285
  };
286
- const syncMuteState = (tracks) => {
287
- const inputMuted = getMuteState();
288
- for (const track of tracks) {
289
- switch (track.kind) {
290
- case 'audioinput': {
291
- track.mute(inputMuted.audio);
292
- // FIXME: this can fire too often, but we want to make sure we signal initial state as well.
293
- // We should only fire this when needed.
294
- signals.onAudioMuteStateChanged?.emit(track.muted);
295
- break;
296
- }
297
- case 'videoinput':
298
- track.mute(inputMuted.video);
299
- // FIXME: this can fire too often, but we want to make sure we signal initial state as well.
300
- // We should only fire this when needed.
301
- signals.onVideoMuteStateChanged?.emit(track.muted);
302
- break;
303
- default:
304
- break;
305
- }
306
- }
307
- };
308
286
  const processMedia = createMediaProcessor({
309
287
  audioProcessors,
310
288
  videoProcessors,
@@ -332,13 +310,14 @@ export const createMedia = ({ getMuteState, signals, audioProcessors, videoProce
332
310
  });
333
311
  const processAndUpdateMedia = async (media, tracks) => {
334
312
  const processedTracks = await processMedia(tracks);
335
- // Sync mute state to processed tracks
336
- syncMuteState(processedTracks);
337
313
  for (const [idx, track] of processedTracks.entries()) {
338
314
  const originTrack = tracks.at(idx);
339
315
  assert(originTrack, 'Processed track should always has the original track in the same order');
340
316
  // Only replace track when they are not the same
341
317
  if (originTrack.id !== track.id) {
318
+ if (originTrack.muted !== undefined) {
319
+ track.mute(originTrack.muted);
320
+ }
342
321
  media.removeTrack(originTrack);
343
322
  media.addTrack(track);
344
323
  }
@@ -351,19 +330,12 @@ export const createMedia = ({ getMuteState, signals, audioProcessors, videoProce
351
330
  shouldDiscardMedia: () => props.discardMedia,
352
331
  getDefaultConstraints,
353
332
  onMediaTracksChanged: (media, tracks) => {
354
- // Sync mute state to input tracks
355
- syncMuteState(tracks);
333
+ const inputMuted = getMuteState();
356
334
  for (const track of tracks) {
357
- if (track.track) {
358
- const unsubscribe = createStreamTrackEventSubscriptions(track.track, {
359
- ended: track => {
360
- signals.onStreamTrackEnded?.emit(track);
361
- unsubscribe();
362
- },
363
- mute: signals.onStreamTrackMuted?.emit,
364
- unmute: signals.onStreamTrackUnmuted?.emit,
365
- });
366
- }
335
+ // Sync mute state to input tracks
336
+ track.mute(track.kind === 'audioinput'
337
+ ? inputMuted.audio
338
+ : inputMuted.video);
367
339
  }
368
340
  logger.debug({ media, tracks }, 'Update media tracks');
369
341
  props.media = media;
@@ -12,11 +12,13 @@ export interface PreviewEventHandler {
12
12
  audioInputError?: EventErrorCallback;
13
13
  applyChangesError?: EventErrorCallback;
14
14
  revertChangesError?: EventErrorCallback;
15
+ audioMuted?: EventCallback<boolean | undefined>;
16
+ audioSuspended?: EventCallback<boolean | undefined>;
17
+ updatingMain?: EventCallback<boolean>;
15
18
  updatingPreviewAudio?: EventCallback<boolean>;
16
19
  updatingPreviewVideo?: EventCallback<boolean>;
17
- audioMuted?: EventCallback<boolean | undefined>;
18
20
  videoMuted?: EventCallback<boolean | undefined>;
19
- updatingMain?: EventCallback<boolean>;
21
+ videoSuspended?: EventCallback<boolean | undefined>;
20
22
  unsubscribeMain?: Unsubscribe;
21
23
  }
22
24
  export interface PreviewStreamParams {
@@ -24,6 +26,7 @@ export interface PreviewStreamParams {
24
26
  getCurrentMedia: () => Media | undefined;
25
27
  updateMainStream: (request: MediaDeviceRequest) => Promise<void>;
26
28
  mainMediaSignal: MediaSignals['onMediaChanged'];
29
+ signals: MediaSignals;
27
30
  onEnded?: () => void;
28
31
  fftSize?: number;
29
32
  queueOptions?: Partial<AsyncQueueOptions>;
@@ -76,8 +79,10 @@ export interface PreviewStreamController {
76
79
  onApplyChangesError(callback: EventErrorCallback): Unsubscribe;
77
80
  onRevertChangesError(callback: EventErrorCallback): Unsubscribe;
78
81
  onAudioMuted(callback: EventCallback<boolean | undefined>): Unsubscribe;
82
+ onAudioSuspended(callback: EventCallback<boolean | undefined>): Unsubscribe;
79
83
  onVideoMuted(callback: EventCallback<boolean | undefined>): Unsubscribe;
84
+ onVideoSuspended(callback: EventCallback<boolean | undefined>): Unsubscribe;
80
85
  }
81
- export declare const createPreviewStreamController: ({ getCurrentDevices, getCurrentMedia, updateMainStream, onEnded, mainMediaSignal, queueOptions, audioProcessors, videoProcessors, getDefaultConstraints, }: PreviewStreamParams) => PreviewStreamController;
86
+ export declare const createPreviewStreamController: ({ getCurrentDevices, getCurrentMedia, updateMainStream, onEnded, mainMediaSignal, queueOptions, audioProcessors, videoProcessors, getDefaultConstraints, signals, }: PreviewStreamParams) => PreviewStreamController;
82
87
  export type CreatePreviewStreamController = typeof createPreviewStreamController;
83
88
  export {};
@@ -4,7 +4,6 @@ import { AUDIO_SETTINGS_KEYS, VIDEO_SETTINGS_KEYS, createMediaProcessor, hasSett
4
4
  import { isMedia } from './typeGuard';
5
5
  import { createModuleLogger } from './logger';
6
6
  import { createGetUserMediaProcess, requestUserMediaWithRetry, } from './userMedia';
7
- import { createMediaSignals } from './signals';
8
7
  import { createMediaUpdater } from './media';
9
8
  const DEFAULT_QUEUE_DELAY_MS = 100;
10
9
  const DEFAULT_QUEUE_DROP_LAST = false;
@@ -52,7 +51,7 @@ export const createPreviewStreamController = ({ getCurrentDevices, getCurrentMed
52
51
  throttleInMS: DEFAULT_QUEUE_THROTTLE_MS,
53
52
  delayInMS: DEFAULT_QUEUE_DELAY_MS,
54
53
  dropLast: DEFAULT_QUEUE_DROP_LAST,
55
- }, audioProcessors, videoProcessors, getDefaultConstraints = () => ({}), }) => {
54
+ }, audioProcessors, videoProcessors, getDefaultConstraints = () => ({}), signals, }) => {
56
55
  const queue = createAsyncQueue(queueOptions);
57
56
  const eventHandlers = {};
58
57
  const internalProps = {
@@ -62,15 +61,7 @@ export const createPreviewStreamController = ({ getCurrentDevices, getCurrentMed
62
61
  updatingMain: false,
63
62
  discardMedia: false,
64
63
  initialized: false,
65
- signals: createMediaSignals([
66
- 'onAddTrack',
67
- 'onAudioMuteStateChanged',
68
- 'onRemoveTrack',
69
- 'onStatusChanged',
70
- 'onUpdatingAudio',
71
- 'onUpdatingVideo',
72
- 'onVideoMuteStateChanged',
73
- ], 'PreviewStreamController'),
64
+ signals,
74
65
  };
75
66
  const logger = createModuleLogger({
76
67
  module: 'PreviewStreamController',
@@ -129,11 +120,29 @@ export const createPreviewStreamController = ({ getCurrentDevices, getCurrentMed
129
120
  },
130
121
  });
131
122
  const subscriptions = [
132
- props.signals.onAudioMuteStateChanged?.add(muted => {
133
- eventHandlers.audioMuted?.(muted);
123
+ props.signals.onMediaTrackMuted?.add(track => {
124
+ if (track.kind === 'audioinput') {
125
+ eventHandlers.audioMuted?.(track.muted);
126
+ }
127
+ else {
128
+ eventHandlers.videoMuted?.(track.muted);
129
+ }
130
+ }),
131
+ props.signals.onMediaTrackSuspended?.add(track => {
132
+ if (track.kind === 'audioinput') {
133
+ eventHandlers.audioSuspended?.(track.suspended);
134
+ }
135
+ else {
136
+ eventHandlers.videoSuspended?.(track.suspended);
137
+ }
134
138
  }),
135
- props.signals.onVideoMuteStateChanged?.add(muted => {
136
- eventHandlers.videoMuted?.(muted);
139
+ props.signals.onMediaTrackResumed?.add(track => {
140
+ if (track.kind === 'audioinput') {
141
+ eventHandlers.audioSuspended?.(track.suspended);
142
+ }
143
+ else {
144
+ eventHandlers.videoSuspended?.(track.suspended);
145
+ }
137
146
  }),
138
147
  props.signals.onAddTrack?.add(track => {
139
148
  switch (track.kind) {
@@ -228,7 +237,7 @@ export const createPreviewStreamController = ({ getCurrentDevices, getCurrentMed
228
237
  return;
229
238
  }
230
239
  try {
231
- const clonedMedia = mainMedia.clone(props.signals);
240
+ const clonedMedia = mainMedia.clone(props.signals, 'Preview');
232
241
  props.media = clonedMedia;
233
242
  queue.enqueue(async () => {
234
243
  await processAndUpdateMedia(clonedMedia, clonedMedia.getTracks(), true);
@@ -484,6 +493,8 @@ export const createPreviewStreamController = ({ getCurrentDevices, getCurrentMed
484
493
  onUpdatingMain: toEvenHandler('updatingMain'),
485
494
  onAudioMuted: toEvenHandler('audioMuted'),
486
495
  onVideoMuted: toEvenHandler('videoMuted'),
496
+ onAudioSuspended: toEvenHandler('audioSuspended'),
497
+ onVideoSuspended: toEvenHandler('videoSuspended'),
487
498
  applyChanges,
488
499
  revertChanges,
489
500
  cleanup,
package/dist/types.d.ts CHANGED
@@ -127,8 +127,9 @@ export interface Media extends MediaAttributes {
127
127
  getAudioTracks(): MediaTrack[];
128
128
  getVideoTracks(): MediaTrack[];
129
129
  addTrack(track: MediaTrack): void;
130
+ isCurrentTrack(track: MediaTrack): boolean;
130
131
  removeTrack(track: MediaTrack): void;
131
- clone(signals?: MediaSignals): Media;
132
+ clone(signals?: MediaSignals, label?: string): Media;
132
133
  }
133
134
  export interface MediaInit {
134
135
  constraints: MediaDeviceRequest;
@@ -149,26 +150,35 @@ export interface MediaTrack {
149
150
  * `track` is available otherwise it uses the `id` from the construction or the
150
151
  * `kind`. @see MediaTrackInit
151
152
  */
152
- id: string;
153
- stopped: boolean;
154
- kind: 'audioinput' | 'videoinput';
155
- label?: string;
156
- previousMediaTrack?: MediaTrack;
153
+ readonly id: string;
154
+ /**
155
+ * It maps to MediaStreamTrack['readyState']
156
+ * When `true` the track's `readyState` is `ended`, `false` means `live` otherwise the track is not available
157
+ */
158
+ readonly stopped?: boolean;
159
+ /**
160
+ * Indicate that the media has been suspended by the User Agent, and it could happen at any time.
161
+ * `undefined` means the track is not available
162
+ */
163
+ readonly suspended?: boolean;
164
+ readonly kind: 'audioinput' | 'videoinput';
165
+ readonly label?: string;
166
+ readonly previousMediaTrack?: MediaTrack;
157
167
  /**
158
168
  * The MediaStreamTrack object representing the track
159
169
  * @see https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack
160
170
  */
161
- track?: MediaStreamTrack;
171
+ readonly track?: MediaStreamTrack;
162
172
  /**
163
173
  * The `MediaDeviceInfo` object representing the device that the track is
164
174
  * connected to. @see https://developer.mozilla.org/en-US/docs/Web/API/MediaDeviceInfo
165
175
  */
166
- input?: MediaDeviceInfoLike;
176
+ readonly input?: MediaDeviceInfoLike;
167
177
  /**
168
178
  * The `MediaDeviceInfo` object representing the device that the track is
169
179
  * expected to be connected to. @see https://developer.mozilla.org/en-US/docs/Web/API/MediaDeviceInfo
170
180
  */
171
- expectedInput?: MediaDeviceInfoLike;
181
+ readonly expectedInput?: MediaDeviceInfoLike;
172
182
  /**
173
183
  * The current mute state of the track. `undefined` means the track is not available
174
184
  */
@@ -176,13 +186,20 @@ export interface MediaTrack {
176
186
  /**
177
187
  * The source track that it is originated from
178
188
  */
179
- source: MediaTrack;
180
- mute(toMute: boolean): void;
189
+ readonly source: MediaTrack;
190
+ /**
191
+ * Mute the track
192
+ *
193
+ * @param toMute - `true` to mute the track, `false` to unmute
194
+ * @param soft - `true` to indicate a soft mute i.e. setting the track to NOT `enabled`
195
+ * `false` or `undefined` for default mute
196
+ */
197
+ mute(toMute: boolean, soft?: boolean): void;
181
198
  getSettings(): ExtendedMediaTrackSettings;
182
199
  getConstraints(): InputDeviceConstraint;
183
200
  applyConstraints(constraints: InputDeviceConstraint): Promise<void>;
184
201
  release(): Promise<void>;
185
- clone(signals?: MediaSignals): MediaTrack;
202
+ clone(signals?: MediaSignals, label?: string): MediaTrack;
186
203
  toJSON(): unknown;
187
204
  }
188
205
  export interface MediaTrackInit extends Partial<Omit<MediaTrack, 'mute'>> {
@@ -192,7 +209,15 @@ export interface MediaTrackInit extends Partial<Omit<MediaTrack, 'mute'>> {
192
209
  constraints: InputDeviceConstraint;
193
210
  overrideMute?: boolean;
194
211
  signals?: MediaSignals;
195
- mute?: (toMute: boolean, self: MediaTrack) => void;
212
+ /**
213
+ * Overriding Mute the track
214
+ *
215
+ * @param toMute - `true` to mute the track, `false` to unmute
216
+ * @param self - The media track itself
217
+ * @param soft - `true` to indicate a soft mute i.e. setting the track to NOT `enabled`
218
+ * `false` or `undefined` for default mute
219
+ */
220
+ mute?: (toMute: boolean, self: MediaTrack, soft?: boolean) => void;
196
221
  }
197
222
  export type Process<T> = (a: T) => Promise<MediaTrack>;
198
223
  export type TrackProcessor = Process<MediaTrack>;
@@ -521,44 +546,16 @@ export type VideoRenderParams = Omit<RendererOptions, 'effects'> & {
521
546
  tilt?: boolean;
522
547
  zoom?: boolean;
523
548
  };
524
- export interface StreamTrackSignals {
525
- /**
526
- * MediaStreamTrack events: mute
527
- * https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack#events
528
- */
529
- onStreamTrackMuted: Signal<MediaStreamTrack>;
530
- /**
531
- * MediaStreamTrack events: unmute
532
- * https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack#events
533
- */
534
- onStreamTrackUnmuted: Signal<MediaStreamTrack>;
535
- /**
536
- * MediaStreamTrack events: ended
537
- * https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack#events
538
- */
539
- onStreamTrackEnded: Signal<MediaStreamTrack>;
540
- /**
541
- * Emit `MediaStreamTrack` whenever a call to `Media['muteAudio']` or
542
- * `Media['muteVideo']`
543
- */
544
- onStreamTrackEnabled: Signal<MediaStreamTrack>;
545
- }
546
- /**
547
- * Track state signals that used for the final track that the media pipeline
548
- * produced
549
- */
550
- export type StreamTrackSignalFinals = {
551
- [key in keyof StreamTrackSignals as `${key}Final`]: Signal<MediaStreamTrack>;
552
- };
553
549
  export interface MediaChangesSignals {
554
550
  onAddTrack: Signal<MediaStreamTrack>;
555
- onAudioMuteStateChanged: Signal<boolean | undefined>;
556
- onTrackReleased: Signal<MediaStreamTrack>;
557
551
  onDevicesChanged: Signal<IndexedDevices>;
558
552
  onMediaChanged: Signal<Media | undefined>;
553
+ onMediaTrackMuted: Signal<MediaTrack>;
554
+ onMediaTrackResumed: Signal<MediaTrack>;
555
+ onMediaTrackStopped: Signal<MediaTrack>;
556
+ onMediaTrackSuspended: Signal<MediaTrack>;
559
557
  onRemoveTrack: Signal<MediaStreamTrack>;
560
558
  onStatusChanged: Signal<UserMediaStatus>;
561
- onVideoMuteStateChanged: Signal<boolean | undefined>;
562
559
  onUpdatingAudio: Signal<boolean>;
563
560
  onUpdatingVideo: Signal<boolean>;
564
561
  }
@@ -566,7 +563,7 @@ export interface AudioDetectionSignals {
566
563
  onVAD: Signal<undefined>;
567
564
  onSilentDetected: Signal<boolean>;
568
565
  }
569
- export type MediaSignalsOptional = Omit<Partial<MediaChangesSignals>, 'onMediaChanged'> & Partial<StreamTrackSignals> & Partial<StreamTrackSignalFinals>;
566
+ export type MediaSignalsOptional = Omit<Partial<MediaChangesSignals>, 'onMediaChanged'>;
570
567
  export type MediaSignalsRequired = Pick<MediaChangesSignals, 'onMediaChanged'> & AudioDetectionSignals;
571
568
  export type MediaSignals = MediaSignalsRequired & MediaSignalsOptional;
572
569
  export declare enum DeniedDevices {
package/dist/userMedia.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { MediaDeviceFailure, extractConstraintsWithKeys, findMediaInputFromMediaStreamTrack, getUserMedia, isExactDeviceConstraint, isStreamingRequestedDevices, relaxInputConstraint, } from '@pexip/media-control';
2
2
  import { assert } from '@pexip/utils';
3
3
  import { UserMediaStatus } from './types';
4
- import { buildMedia, createMediaTrack, findExpectedInput, isTrackMuted, makeDeriveDeviceStatus, } from './utils';
4
+ import { buildMedia, createMediaTrack, findExpectedInput, makeDeriveDeviceStatus, } from './utils';
5
5
  import { logger } from './logger';
6
6
  import { isUnknownError } from './status';
7
7
  import { PROCESSOR_LABELS } from './constants';
@@ -556,20 +556,33 @@ export const createGetUserMediaProcess = ({ getCurrentDevices, getUserMedia, sco
556
556
  let audioMediaTrack;
557
557
  let videoMediaTrack;
558
558
  const extractContentHint = extractConstraintsWithKeys(['contentHint']);
559
+ let trackReleaseTimeoutID = 0;
559
560
  /**
560
561
  * Release the track as mute
561
562
  */
562
- const releaseTrackAsMute = async (track, toMute) => {
563
+ const releaseTrackAsMute = async (track, toMute, soft) => {
563
564
  if (!track?.track || track.muted === toMute) {
564
565
  return;
565
566
  }
566
567
  track.track.enabled = !toMute;
568
+ if (soft) {
569
+ return;
570
+ }
567
571
  if (toMute) {
568
572
  if (!track.stopped) {
569
- await track.release();
573
+ // Track cannot be stopped immediately as we need to ensure
574
+ // the last frame is black to avoid frozen frame in a gateway call
575
+ trackReleaseTimeoutID = window.setTimeout(() => {
576
+ track.release();
577
+ trackReleaseTimeoutID = 0;
578
+ }, 500);
570
579
  }
571
580
  }
572
581
  else {
582
+ if (trackReleaseTimeoutID) {
583
+ window.clearTimeout(trackReleaseTimeoutID);
584
+ trackReleaseTimeoutID = 0;
585
+ }
573
586
  if (track.stopped) {
574
587
  await updateMedia({
575
588
  [track.kind === 'audioinput' ? 'audio' : 'video']: track.getConstraints(),
@@ -586,7 +599,7 @@ export const createGetUserMediaProcess = ({ getCurrentDevices, getUserMedia, sco
586
599
  audioTrack.contentHint = contentHint ?? '';
587
600
  }
588
601
  audioMediaTrack = createMediaTrack({
589
- label: PROCESSOR_LABELS.GetUserMedia,
602
+ label: [scope, PROCESSOR_LABELS.GetUserMedia].join('|'),
590
603
  kind: 'audioinput',
591
604
  track: audioTrack,
592
605
  input: audioInput,
@@ -605,17 +618,19 @@ export const createGetUserMediaProcess = ({ getCurrentDevices, getUserMedia, sco
605
618
  videoTrack.contentHint = contentHint ?? '';
606
619
  }
607
620
  videoMediaTrack = createMediaTrack({
608
- label: PROCESSOR_LABELS.GetUserMedia,
621
+ label: [scope, PROCESSOR_LABELS.GetUserMedia].join('|'),
609
622
  kind: 'videoinput',
610
623
  track: videoTrack,
611
624
  input: videoInput,
612
625
  expectedInput: findExpectedInput(devices, constraints.video, videoInput, 'videoinput'),
613
- overrideMute: stopVideoTrackAsMute(),
626
+ get overrideMute() {
627
+ return stopVideoTrackAsMute();
628
+ },
614
629
  get muted() {
615
- return isTrackMuted(videoTrack);
630
+ return !videoTrack?.enabled;
616
631
  },
617
- mute(toMute, self) {
618
- releaseTrackAsMute(self, toMute);
632
+ mute(toMute, self, soft) {
633
+ releaseTrackAsMute(self, toMute, soft);
619
634
  },
620
635
  constraints: constraints.video,
621
636
  signals,
package/dist/utils.js CHANGED
@@ -19,10 +19,10 @@ export const createMediaTrack = (trackInit) => {
19
19
  (trackInit.kind === trackInit.input?.kind &&
20
20
  trackInit.kind === toMediaDeviceInputKind(trackInit.track));
21
21
  assert(trackConsistency, `Inconsistent track kind: ${trackInit.input?.kind} ${trackInit.track && toMediaDeviceInputKind(trackInit.track)} vs ${trackInit.kind}`);
22
- assert(!trackInit.overrideMute ||
23
- (trackInit.overrideMute &&
24
- Object.hasOwn(trackInit, 'mute') &&
25
- Object.hasOwn(trackInit, 'muted')), 'Inconsistent overrideMute');
22
+ assert(!hasOwn(trackInit, 'mute') ||
23
+ (hasOwn(trackInit, 'mute') && hasOwn(trackInit, 'overrideMute')), 'Inconsistent overrideMute mute()');
24
+ assert(!hasOwn(trackInit, 'muted') ||
25
+ (hasOwn(trackInit, 'muted') && hasOwn(trackInit, 'overrideMute')), 'Inconsistent overrideMute muted');
26
26
  const currentConstraints = typeof trackInit.constraints === 'boolean' ? {} : trackInit.constraints;
27
27
  // Find the original source track through the linked list
28
28
  let sourceMediaTrack = trackInit.previousMediaTrack;
@@ -45,6 +45,25 @@ export const createMediaTrack = (trackInit) => {
45
45
  ...(trackInit.getSettings?.() ?? {}),
46
46
  };
47
47
  };
48
+ // Suscribe track events
49
+ let trackUnsubscribe;
50
+ if (trackInit.track) {
51
+ trackUnsubscribe = createStreamTrackEventSubscriptions(trackInit.track, {
52
+ ended(track) {
53
+ assert(track.id === trackInit.track?.id, 'Same Track');
54
+ void mediaTrack.release();
55
+ trackInit.signals?.onMediaTrackStopped?.emit(mediaTrack);
56
+ },
57
+ mute(track) {
58
+ assert(track.id === trackInit.track?.id, 'Same Track');
59
+ trackInit.signals?.onMediaTrackSuspended?.emit(mediaTrack);
60
+ },
61
+ unmute(track) {
62
+ assert(track.id === trackInit.track?.id, 'Same Track');
63
+ trackInit.signals?.onMediaTrackResumed?.emit(mediaTrack);
64
+ },
65
+ });
66
+ }
48
67
  const mediaTrack = {
49
68
  get kind() {
50
69
  return trackInit.kind;
@@ -74,36 +93,50 @@ export const createMediaTrack = (trackInit) => {
74
93
  if (trackInit.overrideMute) {
75
94
  return trackInit.muted;
76
95
  }
77
- // The source is itself, just access the track's state
78
- if (this.source === this) {
79
- return isTrackMuted(trackInit.track);
96
+ if (trackInit.track) {
97
+ return !trackInit.track.enabled;
80
98
  }
81
- return this.source.muted;
99
+ return undefined;
82
100
  },
83
101
  get stopped() {
84
- return trackInit.track ? isTrackEnded(trackInit.track) : true;
102
+ return trackInit.track && isTrackEnded(trackInit.track);
103
+ },
104
+ get suspended() {
105
+ // Any track that is suspended in the chain will cause this track to be suspended
106
+ return (trackInit.previousMediaTrack?.suspended ||
107
+ trackInit.track?.muted);
85
108
  },
86
109
  get source() {
87
- return sourceMediaTrack ?? mediaTrack;
110
+ return sourceMediaTrack ?? this;
88
111
  },
89
- mute(toMute) {
112
+ mute(toMute, soft) {
113
+ if (this.muted === toMute) {
114
+ return;
115
+ }
90
116
  if (trackInit.overrideMute) {
91
- return trackInit.mute?.(toMute, this);
117
+ trackInit.mute?.(toMute, this, soft);
92
118
  }
93
- if (trackInit.track) {
94
- trackInit.track.enabled = !toMute;
119
+ else {
120
+ if (trackInit.track) {
121
+ trackInit.track.enabled = !toMute;
122
+ }
123
+ trackInit.previousMediaTrack?.mute(toMute, soft);
95
124
  }
96
- trackInit.previousMediaTrack?.mute(toMute);
125
+ trackInit.signals?.onMediaTrackMuted?.emit(this);
97
126
  },
98
127
  getSettings,
99
128
  getConstraints() {
100
129
  return getConstraints();
101
130
  },
102
- clone(signals) {
131
+ clone(signals, label) {
103
132
  return createMediaTrack({
104
- ...trackInit,
133
+ kind: this.kind,
134
+ label: [label, this.label]
135
+ .flatMap(a => (a ? [a] : []))
136
+ .join('|'),
137
+ input: this.input,
105
138
  constraints: getConstraints(),
106
- track: trackInit.track?.clone(),
139
+ track: this.track?.clone(),
107
140
  // When Cloning a MediaTrack, overrideMute should be turned off since
108
141
  // it might corrupt the logic of its original media pipeline
109
142
  overrideMute: false,
@@ -146,23 +179,20 @@ export const createMediaTrack = (trackInit) => {
146
179
  Object.assign(currentConstraints, resolvedConstraints);
147
180
  },
148
181
  async release() {
182
+ trackUnsubscribe?.();
149
183
  trackInit.track?.stop();
150
- if (trackInit.track) {
151
- trackInit.signals?.onTrackReleased?.emit(trackInit.track);
152
- trackInit.signals?.[trackInit.track.kind === 'audio'
153
- ? 'onAudioMuteStateChanged'
154
- : 'onVideoMuteStateChanged']?.emit(undefined);
155
- }
156
- await trackInit.previousMediaTrack?.release();
157
184
  await trackInit.release?.();
185
+ await trackInit.previousMediaTrack?.release();
186
+ trackInit.signals?.onMediaTrackStopped?.emit(this);
158
187
  },
159
188
  toJSON() {
160
189
  return {
161
- label: trackInit.label,
162
- track: trackInit.track,
163
- previousMediaTrack: trackInit.previousMediaTrack,
164
- constraints: getConstraints(),
165
- settings: getSettings(),
190
+ label: this.label,
191
+ track: this.track,
192
+ input: this.input,
193
+ previousMediaTrack: this.previousMediaTrack,
194
+ constraints: this.getConstraints(),
195
+ settings: this.getSettings(),
166
196
  };
167
197
  },
168
198
  };
@@ -231,6 +261,10 @@ export const buildMedia = (mediaInit, onDevicesChanged = internalSignals.onDevic
231
261
  status: mediaInit.status,
232
262
  devices: mediaInit.devices,
233
263
  stream: mediaInit.stream ?? new MediaStream(),
264
+ // `undefined` is used to avoid double state overriding,
265
+ // because 2 subsequent `susppend` events could invalidate the previous assignment
266
+ audioAlreadyMuted: undefined,
267
+ videoAlreadyMuted: undefined,
234
268
  audioTrack: mediaInit.tracks
235
269
  .flatMap(track => {
236
270
  return track.kind === 'audioinput' ? [track] : [];
@@ -240,73 +274,76 @@ export const buildMedia = (mediaInit, onDevicesChanged = internalSignals.onDevic
240
274
  .flatMap(track => (track.kind === 'videoinput' ? [track] : []))
241
275
  .at(0),
242
276
  };
243
- const subscribeTrackEvents = (track) => createStreamTrackEventSubscriptions(track, {
244
- ended: track => {
245
- mediaInit.signals?.[track.kind === 'audio'
246
- ? 'onAudioMuteStateChanged'
247
- : 'onVideoMuteStateChanged']?.emit(undefined);
248
- mediaInit.signals?.onStreamTrackEndedFinal?.emit(track);
249
- },
250
- mute: track => {
251
- mediaInit.signals?.[track.kind === 'audio'
252
- ? 'onAudioMuteStateChanged'
253
- : 'onVideoMuteStateChanged']?.emit(true);
254
- mediaInit.signals?.onStreamTrackMuted?.emit(track);
255
- },
256
- unmute: track => {
257
- mediaInit.signals?.[track.kind === 'audio'
258
- ? 'onAudioMuteStateChanged'
259
- : 'onVideoMuteStateChanged']?.emit(!track.enabled);
260
- mediaInit.signals?.onStreamTrackUnmuted?.emit(track);
261
- },
262
- });
263
- const subscribeTrackAndSourceTrackEvents = (track) => {
264
- let trackUnsubscribe;
265
- let sourceTrackUnsubscribe;
266
- if (track.track) {
267
- trackUnsubscribe = subscribeTrackEvents(track.track);
268
- if (track.source.track && track.source.track !== track.track) {
269
- sourceTrackUnsubscribe = subscribeTrackEvents(track.source.track);
270
- }
271
- }
272
- return () => {
273
- trackUnsubscribe?.();
274
- sourceTrackUnsubscribe?.();
275
- };
276
- };
277
- const trackSubscriptions = new Map(mediaInit.tracks.flatMap(track => {
278
- if (track.track) {
279
- return [
280
- [track.track, subscribeTrackAndSourceTrackEvents(track)],
281
- ];
282
- }
283
- return [];
284
- }));
285
277
  const getConstraints = () => {
286
278
  const audio = props.audioTrack?.getConstraints() ?? mediaInit.constraints.audio;
287
279
  const video = props.videoTrack?.getConstraints() ?? mediaInit.constraints.video;
288
280
  return { audio: audio, video: video };
289
281
  };
290
- let unsubscribe = onDevicesChanged.add(devices => {
282
+ const updateDevices = (devices) => {
291
283
  props.devices = devices;
292
- });
293
- const muteTrack = (track, mute) => {
294
- const previousMuteState = track.muted;
295
- const previousEnabledState = track.track?.enabled;
296
- track.mute(mute);
297
- const currentMuteState = track.muted;
298
- const currentEnabledState = track.track?.enabled;
299
- if (track.track &&
300
- previousEnabledState !== undefined &&
301
- currentEnabledState !== undefined &&
302
- previousEnabledState !== currentEnabledState) {
303
- mediaInit.signals?.onStreamTrackEnabled?.emit(track.track);
284
+ };
285
+ // Two-hand control to avoid leaking of the media
286
+ // When the track is suspended by the system, we also mute it if it is not already muted.
287
+ // As it is possible that the track could still flow data even if it is system muted
288
+ const handleSuspended = (track) => {
289
+ switch (track.kind) {
290
+ case 'audioinput': {
291
+ if (props.audioAlreadyMuted === undefined) {
292
+ props.audioAlreadyMuted = props.audioTrack?.muted === true;
293
+ }
294
+ if (!props.audioAlreadyMuted) {
295
+ props.audioTrack?.mute(true, true);
296
+ }
297
+ break;
298
+ }
299
+ case 'videoinput': {
300
+ if (props.videoAlreadyMuted === undefined) {
301
+ props.videoAlreadyMuted = props.videoTrack?.muted === true;
302
+ }
303
+ if (!props.videoAlreadyMuted) {
304
+ props.videoTrack?.mute(true, true);
305
+ }
306
+ break;
307
+ }
308
+ }
309
+ };
310
+ // When the track is resumed by the system, we only unmute the track
311
+ // if an only if it is muted by the suspended event
312
+ const handleResumed = (track) => {
313
+ switch (track.kind) {
314
+ case 'audioinput': {
315
+ assert(props.audioAlreadyMuted !== undefined, 'Audio Resume should not be triggerred before Suspended');
316
+ if (props.audioTrack?.suspended === false) {
317
+ if (!props.audioAlreadyMuted) {
318
+ props.audioTrack.mute(false);
319
+ }
320
+ props.audioAlreadyMuted = undefined;
321
+ }
322
+ break;
323
+ }
324
+ case 'videoinput': {
325
+ assert(props.videoAlreadyMuted !== undefined, 'Video Resume should not be triggerred before Suspended');
326
+ if (props.videoTrack?.suspended === false) {
327
+ if (!props.videoAlreadyMuted) {
328
+ props.videoTrack.mute(false);
329
+ }
330
+ props.videoAlreadyMuted = undefined;
331
+ }
332
+ break;
333
+ }
304
334
  }
305
- if (previousMuteState !== currentMuteState) {
306
- mediaInit.signals?.[track.kind === 'audioinput'
307
- ? 'onAudioMuteStateChanged'
308
- : 'onVideoMuteStateChanged']?.emit(currentMuteState);
335
+ };
336
+ const subscribe = () => [
337
+ onDevicesChanged.add(updateDevices),
338
+ mediaInit.signals?.onMediaTrackSuspended?.add(handleSuspended),
339
+ mediaInit.signals?.onMediaTrackResumed?.add(handleResumed),
340
+ ];
341
+ let subscriptions = subscribe();
342
+ const unsubscribe = () => {
343
+ for (const unsubscribe of subscriptions) {
344
+ unsubscribe?.();
309
345
  }
346
+ subscriptions.length = 0;
310
347
  };
311
348
  return {
312
349
  get id() {
@@ -372,16 +409,16 @@ export const buildMedia = (mediaInit, onDevicesChanged = internalSignals.onDevic
372
409
  props.devices.size('videoinput') > 0));
373
410
  },
374
411
  muteAudio(mute) {
375
- if (!props.audioTrack || props.audioTrack.muted === mute) {
412
+ if (!props.audioTrack || props.audioTrack.suspended) {
376
413
  return;
377
414
  }
378
- muteTrack(props.audioTrack, mute);
415
+ props.audioTrack.mute(mute);
379
416
  },
380
417
  muteVideo(mute) {
381
- if (!props.videoTrack || props.videoTrack.muted === mute) {
418
+ if (!props.videoTrack || props.videoTrack.suspended) {
382
419
  return;
383
420
  }
384
- muteTrack(props.videoTrack, mute);
421
+ props.videoTrack?.mute(mute);
385
422
  },
386
423
  applyConstraints: async (constraints) => {
387
424
  await Promise.all([
@@ -394,12 +431,7 @@ export const buildMedia = (mediaInit, onDevicesChanged = internalSignals.onDevic
394
431
  ]);
395
432
  },
396
433
  async release() {
397
- unsubscribe?.();
398
- unsubscribe = undefined;
399
- for (const unsubscribe of trackSubscriptions.values()) {
400
- unsubscribe();
401
- }
402
- trackSubscriptions.clear();
434
+ unsubscribe();
403
435
  await Promise.all([
404
436
  props.audioTrack?.release(),
405
437
  props.videoTrack?.release(),
@@ -409,13 +441,13 @@ export const buildMedia = (mediaInit, onDevicesChanged = internalSignals.onDevic
409
441
  audio: props.audioTrack?.getSettings(),
410
442
  video: props.videoTrack?.getSettings(),
411
443
  }),
412
- clone(signals) {
444
+ clone(signals, label) {
413
445
  const tracks = [props.audioTrack, props.videoTrack].flatMap(track => {
414
446
  if (!track?.source) {
415
447
  return [];
416
448
  }
417
- const cloned = track.source.clone(signals);
418
- return cloned;
449
+ const cloned = track.source.clone(signals, label);
450
+ return [cloned];
419
451
  });
420
452
  const stream = this.stream &&
421
453
  new MediaStream(tracks.flatMap(track => (track.track ? [track.track] : [])));
@@ -440,6 +472,9 @@ export const buildMedia = (mediaInit, onDevicesChanged = internalSignals.onDevic
440
472
  getVideoTracks() {
441
473
  return props.videoTrack ? [props.videoTrack] : [];
442
474
  },
475
+ isCurrentTrack(track) {
476
+ return track === props.audioTrack || track === props.videoTrack;
477
+ },
443
478
  addTrack(track) {
444
479
  switch (track.kind) {
445
480
  case 'audioinput':
@@ -457,13 +492,22 @@ export const buildMedia = (mediaInit, onDevicesChanged = internalSignals.onDevic
457
492
  default:
458
493
  break;
459
494
  }
495
+ if (subscriptions.length === 0) {
496
+ subscriptions = subscribe();
497
+ }
460
498
  if (track.track) {
461
499
  props.stream.addTrack(track.track);
462
- if (!trackSubscriptions.has(track.track)) {
463
- trackSubscriptions.set(track.track, subscribeTrackAndSourceTrackEvents(track));
464
- }
465
500
  mediaInit.signals?.onAddTrack?.emit(track.track);
466
501
  props.stream.dispatchEvent(new MediaStreamTrackEvent('addtrack', { track: track.track }));
502
+ if (track.muted) {
503
+ mediaInit.signals?.onMediaTrackMuted?.emit(track);
504
+ }
505
+ if (track.suspended) {
506
+ mediaInit.signals?.onMediaTrackSuspended?.emit(track);
507
+ }
508
+ if (track.stopped) {
509
+ mediaInit.signals?.onMediaTrackStopped?.emit(track);
510
+ }
467
511
  }
468
512
  },
469
513
  removeTrack(track) {
@@ -478,13 +522,14 @@ export const buildMedia = (mediaInit, onDevicesChanged = internalSignals.onDevic
478
522
  }
479
523
  if (removed && track.track) {
480
524
  props.stream.removeTrack(track.track);
481
- trackSubscriptions.get(track.track)?.();
482
- trackSubscriptions.delete(track.track);
483
525
  mediaInit.signals?.onRemoveTrack?.emit(track.track);
484
526
  props.stream.dispatchEvent(new MediaStreamTrackEvent('removetrack', {
485
527
  track: track.track,
486
528
  }));
487
529
  }
530
+ if (subscriptions.length) {
531
+ unsubscribe();
532
+ }
488
533
  },
489
534
  toJSON() {
490
535
  return {
@@ -1,10 +1,10 @@
1
- import type { VideoProcessor, SegmentationTransform, SegmentationModel, RenderBackend } from '@pexip/media-processor';
1
+ import type { RenderBackend, SegmentationModel, SegmentationTransform, VideoProcessor } from '@pexip/media-processor';
2
2
  import type { MediaDeviceRequest } from '@pexip/media-control';
3
- import type { TrackProcessor, VideoRenderParams, Segmenters, VideoStreamTrackProcessorAPIs, VideoContentHint } from './types';
3
+ import type { MediaSignals, Segmenters, TrackProcessor, VideoContentHint, VideoRenderParams, VideoStreamTrackProcessorAPIs } from './types';
4
4
  interface ProcessorDeps {
5
- videoProcessor?: () => VideoProcessor;
6
- transformer?: SegmentationTransform;
7
5
  segmenters: Partial<Segmenters>;
6
+ transformer?: SegmentationTransform;
7
+ videoProcessor?: () => VideoProcessor;
8
8
  videoSegmentationModel?: SegmentationModel;
9
9
  }
10
10
  interface VideoStreamProcessOptions extends Partial<VideoRenderParams>, Omit<ProcessorDeps, 'videoProcessor'> {
@@ -28,6 +28,7 @@ interface VideoStreamProcessOptions extends Partial<VideoRenderParams>, Omit<Pro
28
28
  stopAsMute?: () => boolean;
29
29
  dynamicProcessingDimensions?: () => boolean;
30
30
  gpuAPI?: () => RenderBackend;
31
+ signals?: MediaSignals;
31
32
  }
32
33
  interface VideoStreamProcessProps extends Partial<VideoRenderParams>, Required<ProcessorDeps> {
33
34
  hasInitialized: boolean;
@@ -37,5 +38,5 @@ declare const FEATURE_KEYS: readonly ["backgroundBlurAmount", "backgroundImageUr
37
38
  type FeaturePropKeys = (typeof FEATURE_KEYS)[number];
38
39
  type FeatureProps = Pick<Partial<VideoStreamProcessProps>, FeaturePropKeys>;
39
40
  export declare const updateFeatureProps: (constraints: MediaDeviceRequest["video"], props: FeatureProps) => FeatureProps;
40
- export declare const createVideoStreamProcess: ({ backgroundImageUrl, dynamicProcessingDimensions, edgeBlurAmount, foregroundThreshold, frameRate, gpuAPI, label, lowestProcessingHeight, maskCombineRatio, processingHeight, processingWidth, shouldEnable, stopAsMute, trackProcessorAPI, videoSegmentation, ...options }: VideoStreamProcessOptions) => TrackProcessor;
41
+ export declare const createVideoStreamProcess: ({ backgroundImageUrl, dynamicProcessingDimensions, edgeBlurAmount, foregroundThreshold, frameRate, gpuAPI, label, lowestProcessingHeight, maskCombineRatio, processingHeight, processingWidth, shouldEnable, stopAsMute, trackProcessorAPI, videoSegmentation, signals, ...options }: VideoStreamProcessOptions) => TrackProcessor;
41
42
  export {};
@@ -1,5 +1,5 @@
1
- import { createVideoProcessor, createCanvasTransform, createVideoTrackProcessor, createVideoTrackProcessorWithFallback, isRenderEffects, isSegmentationModel, } from '@pexip/media-processor';
2
- import { muteStreamTrack, extractConstraintsWithKeys, getValueFromConstrainNumber, } from '@pexip/media-control';
1
+ import { createCanvasTransform, createVideoProcessor, createVideoTrackProcessor, createVideoTrackProcessorWithFallback, isRenderEffects, isSegmentationModel, } from '@pexip/media-processor';
2
+ import { extractConstraintsWithKeys, getValueFromConstrainNumber, muteStreamTrack, } from '@pexip/media-control';
3
3
  import { isEmpty, assert } from '@pexip/utils';
4
4
  import { createMediaTrack, getBlurKernelSize } from './utils';
5
5
  import { logger, proxyWithLog } from './logger';
@@ -142,7 +142,7 @@ const getTrackProcessor = (shouldUseStreamTrackProcessor, ...params) => {
142
142
  return createVideoTrackProcessorWithFallback(...params);
143
143
  };
144
144
  export const createVideoStreamProcess = ({ backgroundImageUrl, dynamicProcessingDimensions = () => false, edgeBlurAmount, foregroundThreshold, frameRate, gpuAPI = () => 'webgl', // dynamically changing the config will not be picked up, not a priority for now
145
- label = PROCESSOR_LABELS.VideoProcessor, lowestProcessingHeight, maskCombineRatio, processingHeight, processingWidth, shouldEnable, stopAsMute = () => false, trackProcessorAPI = () => 'stream', videoSegmentation, ...options }) => {
145
+ label = PROCESSOR_LABELS.VideoProcessor, lowestProcessingHeight, maskCombineRatio, processingHeight, processingWidth, shouldEnable, stopAsMute = () => false, trackProcessorAPI = () => 'stream', videoSegmentation, signals, ...options }) => {
146
146
  const videoSegmentationModel = options.videoSegmentationModel ?? 'selfie';
147
147
  const segmenter = options.segmenters[videoSegmentationModel];
148
148
  if (!segmenter) {
@@ -253,36 +253,61 @@ label = PROCESSOR_LABELS.VideoProcessor, lowestProcessingHeight, maskCombineRati
253
253
  assert(track, 'Video track should be there');
254
254
  // Inherit contentHint from previous track
255
255
  track.contentHint = prevMediaTrack.track.contentHint;
256
- const release = async () => {
257
- props.videoProcessor().close();
258
- await prevMediaTrack.release();
259
- _videoProcessor = undefined;
260
- props.hasInitialized = false;
261
- };
262
- const mute = (mute) => {
263
- props.transformer.effects = mute
264
- ? 'none'
265
- : props.videoSegmentation ?? 'none';
266
- muteStreamTrack(stream)(mute, 'video');
267
- if (mute && stopAsMute()) {
268
- for (const track of stream.getVideoTracks()) {
269
- track.stop();
270
- }
271
- release();
272
- }
273
- prevMediaTrack.mute(mute);
274
- };
256
+ let trackReleaseTimeoutID = 0;
275
257
  return createMediaTrack({
276
258
  label,
277
259
  kind: 'videoinput',
278
- constraints: prevMediaTrack.getConstraints(),
279
- previousMediaTrack: prevMediaTrack,
280
- input: prevMediaTrack.input,
281
- expectedInput: prevMediaTrack.expectedInput,
260
+ get constraints() {
261
+ return this.previousMediaTrack?.getConstraints();
262
+ },
263
+ get previousMediaTrack() {
264
+ return prevMediaTrack;
265
+ },
266
+ get input() {
267
+ return this.previousMediaTrack?.input;
268
+ },
269
+ get expectedInput() {
270
+ return this.previousMediaTrack?.expectedInput;
271
+ },
282
272
  track,
283
- mute,
284
- release,
285
- applyConstraints: async (constraints) => {
273
+ overrideMute: true,
274
+ get muted() {
275
+ return this.previousMediaTrack?.muted;
276
+ },
277
+ mute(mute, _self, soft) {
278
+ props.transformer.effects = mute
279
+ ? 'none'
280
+ : props.videoSegmentation ?? 'none';
281
+ muteStreamTrack(stream)(mute, 'video');
282
+ if (stopAsMute() && !soft) {
283
+ if (mute) {
284
+ // Track cannot be stopped immediately as we need to ensure
285
+ // the last frame is black to avoid frozen frame in a gateway call
286
+ trackReleaseTimeoutID = window.setTimeout(() => {
287
+ for (const track of stream.getVideoTracks()) {
288
+ track.stop();
289
+ }
290
+ void this?.release?.();
291
+ trackReleaseTimeoutID = 0;
292
+ }, 500);
293
+ }
294
+ else {
295
+ if (trackReleaseTimeoutID) {
296
+ window.clearTimeout(trackReleaseTimeoutID);
297
+ trackReleaseTimeoutID = 0;
298
+ }
299
+ }
300
+ }
301
+ this.previousMediaTrack?.mute(mute, soft);
302
+ },
303
+ signals,
304
+ async release() {
305
+ props.videoProcessor().close();
306
+ await this.previousMediaTrack?.release();
307
+ _videoProcessor = undefined;
308
+ props.hasInitialized = false;
309
+ },
310
+ async applyConstraints(constraints) {
286
311
  if (isEmpty(constraints)) {
287
312
  return;
288
313
  }
@@ -293,8 +318,8 @@ label = PROCESSOR_LABELS.VideoProcessor, lowestProcessingHeight, maskCombineRati
293
318
  }
294
319
  applyFeatures(props.transformer, features, lowestProcessingHeight);
295
320
  if (dynamicProcessingDimensions()) {
296
- const acceptedSettings = prevMediaTrack.source.getSettings();
297
- if (acceptedSettings.width && acceptedSettings.height) {
321
+ const acceptedSettings = this.previousMediaTrack?.source.getSettings();
322
+ if (acceptedSettings?.width && acceptedSettings?.height) {
298
323
  props.transformer.update({
299
324
  processingHeight: acceptedSettings.height,
300
325
  processingWidth: acceptedSettings.width,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pexip/media",
3
- "version": "20.2.0",
3
+ "version": "20.3.0",
4
4
  "description": "Home for media related stuff",
5
5
  "homepage": "https://gitlab.com/pexip/zoo",
6
6
  "bugs": "https://gitlab.com/pexip/zoo/issues",
@@ -44,14 +44,14 @@
44
44
  "typecheck": "yarn tsc --noEmit -p ."
45
45
  },
46
46
  "dependencies": {
47
- "@pexip/media-control": "20.2.0",
48
- "@pexip/media-processor": "20.2.0",
47
+ "@pexip/media-control": "20.3.0",
48
+ "@pexip/media-processor": "20.3.0",
49
49
  "@pexip/signal": "16.9.0",
50
- "@pexip/utils": "17.0.0"
50
+ "@pexip/utils": "17.0.1"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@jest/globals": "^29.7.0",
54
- "@pexip/bundler": "18.1.1",
54
+ "@pexip/bundler": "18.1.2",
55
55
  "@swc/core": "^1.10.12",
56
56
  "@swc/jest": "^0.2.37",
57
57
  "jest": "^29.5.12",