@pexip/media 22.1.0 → 22.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,67 @@
1
1
  # @pexip/media
2
2
 
3
+ ## 22.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 5d1bb40: Add `createMediaSession` to present an ongoing call to the platform's
8
+ media controls via the Media Session API, and to keep audio capture alive
9
+ while the page is in the background on mobile. The supported actions are
10
+ extended with the conferencing specific `hangup`, `togglecamera`,
11
+ `togglemicrophone`, `togglescreenshare` and `voiceactivity`, along with
12
+ `enterpictureinpicture`.
13
+
14
+ The controller also exposes `setPositionState` and `setScreenshareActive`, and
15
+ `destroy` is now terminal, so a slower owner cannot resurrect the platform
16
+ controls after the call has ended.
17
+
18
+ The capture state is only reported to platforms that treat it as such. WebKit
19
+ implements `setMicrophoneActive`, `setCameraActive` and `setScreenshareActive`
20
+ as commands mutating the real capture state, and rejects activating one
21
+ outside a user gesture, which would otherwise leave the microphone muted with
22
+ no way back. Pass `applyImperativeCaptureState` to opt in when every call is
23
+ made from a user gesture handler.
24
+
25
+ ### Patch Changes
26
+
27
+ - Updated dependencies [b666166]
28
+ - Updated dependencies [53af4ef]
29
+ - Updated dependencies [2f3c6b7]
30
+ - @pexip/media-processor@22.3.0
31
+ - @pexip/media-control@22.3.0
32
+
33
+ ## 22.2.0
34
+
35
+ ### Patch Changes
36
+
37
+ - 72f8dde: Temporarily remove updating the backend in the pipeline
38
+ - 746f6b6: Add Canvas2d rendering backend support: alpha mask converter,
39
+ Canvas2d mask renderer, canvas dimension-aware rendering, and proper backend
40
+ propagation through the video processor pipeline.
41
+ - 265c898: Migrate test runner from Jest to Vitest.
42
+ - a5e8026: Fix "We can't find your microphone" error when changing the
43
+ microphone while sharing content without audio. The audio mixing processor no
44
+ longer tries to create an audio source node from a presentation stream that
45
+ has no audio track, and audio nodes belonging to a stale `AudioContext` are
46
+ recreated instead of being reused.
47
+ - 0878b3b: Fix race conditions in the video processing pipeline so effect
48
+ changes made during processor startup are correctly applied and worker option
49
+ diffs are computed against the previous state.
50
+ - Updated dependencies [5a17991]
51
+ - Updated dependencies [f4af45a]
52
+ - Updated dependencies [746f6b6]
53
+ - Updated dependencies [c101d02]
54
+ - Updated dependencies [476acbf]
55
+ - Updated dependencies [7c99e29]
56
+ - Updated dependencies [1b82fd5]
57
+ - Updated dependencies [265c898]
58
+ - Updated dependencies [a5e8026]
59
+ - Updated dependencies [0878b3b]
60
+ - @pexip/utils@17.4.1
61
+ - @pexip/media-processor@22.2.0
62
+ - @pexip/media-control@22.2.0
63
+ - @pexip/signal@16.9.6
64
+
3
65
  ## 22.1.0
4
66
 
5
67
  ### Minor Changes
@@ -282,6 +282,11 @@ await mediaController.media.applyConstraints({
282
282
 
283
283
  | Interface | Description |
284
284
  | ------ | ------ |
285
+ | [MediaSessionEnterPictureInPictureActionDetails](interfaces/MediaSessionEnterPictureInPictureActionDetails.mdx) | Details passed to the `enterpictureinpicture` action handler, which are not part of the `MediaSessionActionDetails` type shipped with TypeScript's DOM library. |
286
+ | [MediaSessionLike](interfaces/MediaSessionLike.mdx) | The subset of the `navigator.mediaSession` API used by [createMediaSession](functions/createMediaSession.mdx), so that an equivalent implementation can be injected, e.g. in tests. |
287
+ | [MediaSessionMetadataInit](interfaces/MediaSessionMetadataInit.mdx) | Descriptive information presented by the platform's media controls, e.g. the lock screen or the notification shade. |
288
+ | [MediaSessionInit](interfaces/MediaSessionInit.mdx) | Options to create a media session. |
289
+ | [MediaSessionController](interfaces/MediaSessionController.mdx) | Controls an active media session. |
285
290
  | [PreviewEventHandler](interfaces/PreviewEventHandler.mdx) | - |
286
291
  | [PreviewStreamParams](interfaces/PreviewStreamParams.mdx) | - |
287
292
  | [PreviewControllerProps](interfaces/PreviewControllerProps.mdx) | - |
@@ -307,6 +312,8 @@ await mediaController.media.applyConstraints({
307
312
  | Type Alias | Description |
308
313
  | ------ | ------ |
309
314
  | [GetDisplayMedia](type-aliases/GetDisplayMedia.mdx) | - |
315
+ | [ExtendedMediaSessionAction](type-aliases/ExtendedMediaSessionAction.mdx) | Media session actions, including the conferencing specific ones which are not part of the `MediaSessionAction` type shipped with TypeScript's DOM library. |
316
+ | [MediaSessionEnterPictureInPictureReason](type-aliases/MediaSessionEnterPictureInPictureReason.mdx) | Why the user agent asked the page to enter picture in picture. |
310
317
  | [PreviewInput](type-aliases/PreviewInput.mdx) | - |
311
318
  | [CreatePreviewStreamController](type-aliases/CreatePreviewStreamController.mdx) | - |
312
319
  | [GeneratedMediaSignals](type-aliases/GeneratedMediaSignals.mdx) | - |
@@ -373,6 +380,7 @@ await mediaController.media.applyConstraints({
373
380
  | [createGetDisplayMedia](functions/createGetDisplayMedia.mdx) | - |
374
381
  | [setLogger](functions/setLogger.mdx) | - |
375
382
  | [createMedia](functions/createMedia.mdx) | Create an object to interact with the media scream, which is usually used for our main stream. |
383
+ | [createMediaSession](functions/createMediaSession.mdx) | Create a media session, presenting the ongoing call to the platform's media controls, e.g. the lock screen, the notification shade or headset buttons. |
376
384
  | [createPreviewStreamController](functions/createPreviewStreamController.mdx) | - |
377
385
  | [createMediaSignal](functions/createMediaSignal.mdx) | Create a general signal with consistent scoped name |
378
386
  | [createMediaSignals](functions/createMediaSignals.mdx) | Create and return all required and optional (if specified with `more`), signals for media to work |
@@ -0,0 +1,37 @@
1
+ ```ts
2
+ function createMediaSession(__namedParameters): MediaSessionController;
3
+ ```
4
+
5
+ Create a media session, presenting the ongoing call to the platform's media
6
+ controls, e.g. the lock screen, the notification shade or headset buttons.
7
+
8
+ Declaring a media session also hints the browser that the page is playing
9
+ media, which mobile browsers use to relax the throttling applied to
10
+ backgrounded pages and to keep the audio capture alive.
11
+
12
+ Every method is a no-op when the Media Session API is unavailable, so the
13
+ caller does not need to feature detect it.
14
+
15
+ ## Parameters
16
+
17
+ | Parameter | Type |
18
+ | ------ | ------ |
19
+ | `__namedParameters` | [`MediaSessionInit`](../interfaces/MediaSessionInit.mdx) |
20
+
21
+ ## Returns
22
+
23
+ [`MediaSessionController`](../interfaces/MediaSessionController.mdx)
24
+
25
+ ## Example
26
+
27
+ ```ts
28
+ const session = createMediaSession({
29
+ metadata: {title: 'Weekly sync', artist: 'Pexip'},
30
+ handlers: {
31
+ hangup: () => leaveMeeting(),
32
+ togglemicrophone: () => toggleAudioMuted(),
33
+ },
34
+ });
35
+ await session.setMicrophoneActive(true);
36
+ session.destroy();
37
+ ```
@@ -0,0 +1,50 @@
1
+ Controls an active media session.
2
+
3
+ ## Properties
4
+
5
+ | Property | Modifier | Type | Description |
6
+ | ------ | ------ | ------ | ------ |
7
+ | <a id="issupported"></a> `isSupported` | `readonly` | `boolean` | Whether the media session API is available in the current environment. Every other method is a no-op when it is `false`. |
8
+ | <a id="setmetadata"></a> `setMetadata` | `public` | `(metadata?) => void` | Update, or clear when omitted, the presented metadata. |
9
+ | <a id="setplaybackstate"></a> `setPlaybackState` | `public` | `(playbackState) => void` | Update the presented playback state. |
10
+ | <a id="setmicrophoneactive"></a> `setMicrophoneActive` | `public` | `(active) => Promise<void>` | Update whether the platform should present the microphone as active. A no-op on platforms that implement this as a command mutating the real tracks, unless `applyImperativeCaptureState` was set. |
11
+ | <a id="setcameraactive"></a> `setCameraActive` | `public` | `(active) => Promise<void>` | Update whether the platform should present the camera as active, with the same caveat as [MediaSessionController.setMicrophoneActive](#setmicrophoneactive). |
12
+ | <a id="setscreenshareactive"></a> `setScreenshareActive` | `public` | `(active) => Promise<void>` | Update whether the platform should present the screen as shared, with the same caveat as [MediaSessionController.setMicrophoneActive](#setmicrophoneactive). Only implemented by WebKit so far. |
13
+ | <a id="setpositionstate"></a> `setPositionState` | `public` | `(state?) => void` | Update, or reset when omitted, the presented playback position. Pass `duration: Infinity` for a live stream such as a call, which makes the platform present an elapsed clock rather than a seekable scrub bar. States the user agent rejects are logged and otherwise ignored. |
14
+ | <a id="destroy"></a> `destroy` | `public` | `() => void` | Unregister every handler registered by this session and reset the metadata, the playback state and the position state. Terminal and idempotent: every other method becomes a no-op afterwards, so a slower owner cannot resurrect the platform's media controls. |
15
+
16
+ ## Methods
17
+
18
+ ### registerActions()
19
+
20
+ ```ts
21
+ registerActions(handlers): void;
22
+ ```
23
+
24
+ #### Parameters
25
+
26
+ | Parameter | Type |
27
+ | ------ | ------ |
28
+ | `handlers` | `Partial`\<`Record`\<[`ExtendedMediaSessionAction`](../type-aliases/ExtendedMediaSessionAction.mdx), `MediaSessionActionHandler`\>\> |
29
+
30
+ #### Returns
31
+
32
+ `void`
33
+
34
+ ***
35
+
36
+ ### deregisterActions()
37
+
38
+ ```ts
39
+ deregisterActions(actions): void;
40
+ ```
41
+
42
+ #### Parameters
43
+
44
+ | Parameter | Type |
45
+ | ------ | ------ |
46
+ | `actions` | [`ExtendedMediaSessionAction`](../type-aliases/ExtendedMediaSessionAction.mdx)[] |
47
+
48
+ #### Returns
49
+
50
+ `void`
@@ -0,0 +1,17 @@
1
+ Details passed to the `enterpictureinpicture` action handler, which are not
2
+ part of the `MediaSessionActionDetails` type shipped with TypeScript's DOM
3
+ library.
4
+
5
+ ## Extends
6
+
7
+ - `MediaSessionActionDetails`
8
+
9
+ ## Properties
10
+
11
+ | Property | Type | Inherited from |
12
+ | ------ | ------ | ------ |
13
+ | <a id="enterpictureinpicturereason"></a> `enterPictureInPictureReason?` | [`MediaSessionEnterPictureInPictureReason`](../type-aliases/MediaSessionEnterPictureInPictureReason.mdx) | - |
14
+ | <a id="action"></a> `action` | `MediaSessionAction` | `MediaSessionActionDetails.action` |
15
+ | <a id="fastseek"></a> `fastSeek?` | `boolean` | `MediaSessionActionDetails.fastSeek` |
16
+ | <a id="seekoffset"></a> `seekOffset?` | `number` | `MediaSessionActionDetails.seekOffset` |
17
+ | <a id="seektime"></a> `seekTime?` | `number` | `MediaSessionActionDetails.seekTime` |
@@ -0,0 +1,13 @@
1
+ Options to create a media session.
2
+
3
+ ## Properties
4
+
5
+ | Property | Type | Description |
6
+ | ------ | ------ | ------ |
7
+ | <a id="handlers"></a> `handlers?` | `Partial`\<`Record`\<[`ExtendedMediaSessionAction`](../type-aliases/ExtendedMediaSessionAction.mdx), `MediaSessionActionHandler`\>\> | Handlers to register, keyed by the action they handle. Actions without a handler are left untouched, allowing the platform to fall back to its default behavior. |
8
+ | <a id="metadata"></a> `metadata?` | [`MediaSessionMetadataInit`](MediaSessionMetadataInit.mdx) | Initial metadata to present. |
9
+ | <a id="playbackstate"></a> `playbackState?` | `MediaSessionPlaybackState` | Initial playback state, defaults to `playing`. |
10
+ | <a id="positionstate"></a> `positionState?` | `MediaPositionState` | Initial playback position to present. |
11
+ | <a id="applyimperativecapturestate"></a> `applyImperativeCaptureState?` | `boolean` | Apply the capture state even when the platform implements it as a command that mutes the real tracks, defaults to `false`. Only enable this when every call is made from a user gesture handler, otherwise unmuting is rejected and the capture stays muted. |
12
+ | <a id="mediasession"></a> `mediaSession?` | [`MediaSessionLike`](MediaSessionLike.mdx) | The media session implementation to use, defaults to `navigator.mediaSession` when available. |
13
+ | <a id="createmetadata"></a> `createMetadata?` | `(init) => MediaMetadata` | Factory building the metadata object, defaults to the `MediaMetadata` constructor when available. |
@@ -0,0 +1,122 @@
1
+ The subset of the `navigator.mediaSession` API used by
2
+ [createMediaSession](../functions/createMediaSession.mdx), so that an equivalent implementation can be
3
+ injected, e.g. in tests.
4
+
5
+ ## Properties
6
+
7
+ | Property | Type | Description |
8
+ | ------ | ------ | ------ |
9
+ | <a id="metadata"></a> `metadata` | `MediaMetadata \| null` | Descriptive information about the currently played media. |
10
+ | <a id="playbackstate"></a> `playbackState` | `MediaSessionPlaybackState` | Whether the media session is currently playing or paused. |
11
+
12
+ ## Methods
13
+
14
+ ### setActionHandler()
15
+
16
+ ```ts
17
+ setActionHandler(action, handler): void;
18
+ ```
19
+
20
+ Register, or unregister when the handler is `null`, a handler for the
21
+ given media session action.
22
+
23
+ #### Parameters
24
+
25
+ | Parameter | Type |
26
+ | ------ | ------ |
27
+ | `action` | [`ExtendedMediaSessionAction`](../type-aliases/ExtendedMediaSessionAction.mdx) |
28
+ | `handler` | `MediaSessionActionHandler \| null` |
29
+
30
+ #### Returns
31
+
32
+ `void`
33
+
34
+ ***
35
+
36
+ ### setCameraActive()?
37
+
38
+ ```ts
39
+ optional setCameraActive(active): Promise<void>;
40
+ ```
41
+
42
+ Tell the user agent whether the camera is considered to be active.
43
+
44
+ P.S. Safari's impl. is an imperative action to change the track's
45
+ muted stated whereas Chromium-based browsers indicate to the user
46
+ agent the microphone capture state desired by the page
47
+
48
+ #### Parameters
49
+
50
+ | Parameter | Type |
51
+ | ------ | ------ |
52
+ | `active` | `boolean` |
53
+
54
+ #### Returns
55
+
56
+ `Promise<void>`
57
+
58
+ ***
59
+
60
+ ### setMicrophoneActive()?
61
+
62
+ ```ts
63
+ optional setMicrophoneActive(active): Promise<void>;
64
+ ```
65
+
66
+ Tell the user agent whether the microphone is considered to be active.
67
+
68
+ P.S. Safari's impl. is an imperative action to change the track's
69
+ muted stated whereas Chromium-based browsers indicate to the user
70
+ agent the microphone capture state desired by the page
71
+
72
+ #### Parameters
73
+
74
+ | Parameter | Type |
75
+ | ------ | ------ |
76
+ | `active` | `boolean` |
77
+
78
+ #### Returns
79
+
80
+ `Promise<void>`
81
+
82
+ ***
83
+
84
+ ### setScreenshareActive()?
85
+
86
+ ```ts
87
+ optional setScreenshareActive(active): Promise<void>;
88
+ ```
89
+
90
+ Tell the user agent whether the screen is considered to be shared. Only
91
+ specified so far, no browser implements it yet.
92
+
93
+ #### Parameters
94
+
95
+ | Parameter | Type |
96
+ | ------ | ------ |
97
+ | `active` | `boolean` |
98
+
99
+ #### Returns
100
+
101
+ `Promise<void>`
102
+
103
+ ***
104
+
105
+ ### setPositionState()?
106
+
107
+ ```ts
108
+ optional setPositionState(state?): void;
109
+ ```
110
+
111
+ Present the playback position, or reset it when called without a state.
112
+ Throws a `TypeError` for a state the user agent considers invalid.
113
+
114
+ #### Parameters
115
+
116
+ | Parameter | Type |
117
+ | ------ | ------ |
118
+ | `state?` | `MediaPositionState` |
119
+
120
+ #### Returns
121
+
122
+ `void`
@@ -0,0 +1,11 @@
1
+ Descriptive information presented by the platform's media controls, e.g. the
2
+ lock screen or the notification shade.
3
+
4
+ ## Properties
5
+
6
+ | Property | Type | Description |
7
+ | ------ | ------ | ------ |
8
+ | <a id="title"></a> `title` | `string` | Main label, e.g. the name of the meeting. |
9
+ | <a id="artist"></a> `artist?` | `string` | Secondary label, e.g. the name of the brand. |
10
+ | <a id="album"></a> `album?` | `string` | Tertiary label. |
11
+ | <a id="artwork"></a> `artwork?` | `MediaImage[]` | Images associated with the session, e.g. the brand logo. |
@@ -0,0 +1,28 @@
1
+ ```ts
2
+ type ExtendedMediaSessionAction =
3
+ | MediaSessionAction
4
+ | "enterpictureinpicture"
5
+ | "hangup"
6
+ | "togglecamera"
7
+ | "togglemicrophone"
8
+ | "togglescreenshare"
9
+ | "voiceactivity";
10
+ ```
11
+
12
+ Media session actions, including the conferencing specific ones which are
13
+ not part of the `MediaSessionAction` type shipped with TypeScript's DOM
14
+ library.
15
+
16
+ `hangup`, `togglecamera`, `togglemicrophone` and `enterpictureinpicture` are
17
+ implemented by Chromium based browsers. `togglescreenshare` and
18
+ `voiceactivity` are only specified so far, and are registered as a no-op by
19
+ every current browser, which [createMediaSession](../functions/createMediaSession.mdx) handles gracefully.
20
+
21
+ Note that `voiceactivity` runs the other way around to the rest: the user
22
+ agent invokes the handler when it detects voice on a live microphone track,
23
+ e.g. to let a page nudge a muted user who is talking.
24
+
25
+ ## See
26
+
27
+ - [https://w3c.github.io/mediasession/#enumdef-mediasessionaction](https://w3c.github.io/mediasession/#enumdef-mediasessionaction)
28
+ - [https://developer.mozilla.org/en-US/docs/Web/API/MediaSession/setActionHandler](https://developer.mozilla.org/en-US/docs/Web/API/MediaSession/setActionHandler)
@@ -0,0 +1,9 @@
1
+ ```ts
2
+ type MediaSessionEnterPictureInPictureReason = "other" | "useraction" | "contentoccluded";
3
+ ```
4
+
5
+ Why the user agent asked the page to enter picture in picture.
6
+
7
+ ## See
8
+
9
+ [https://developer.mozilla.org/en-US/docs/Web/API/MediaSession/setActionHandler](https://developer.mozilla.org/en-US/docs/Web/API/MediaSession/setActionHandler)
@@ -5,6 +5,7 @@ interface AudioStreamProcessorProps {
5
5
  mixWithAdditionalMedia?: boolean;
6
6
  merger?: AudioNodeInit<ChannelMergerNode, ChannelMergerNode>;
7
7
  displaySource?: AudioNodeInit<MediaStreamAudioSourceNode, MediaStreamAudioSourceNode>;
8
+ displayStream?: MediaStream;
8
9
  audioGraph?: AudioGraph;
9
10
  }
10
11
  declare const FEATURE_KEYS: {
@@ -13,6 +14,20 @@ declare const FEATURE_KEYS: {
13
14
  type FeaturePropKeys = keyof typeof FEATURE_KEYS;
14
15
  type FeatureProps = Pick<AudioStreamProcessorProps, FeaturePropKeys>;
15
16
  export declare const updateFeatureProps: (constraints: MediaDeviceRequest["audio"], props: FeatureProps) => FeatureProps;
17
+ /**
18
+ * Extract a `MediaStream` containing only the audio tracks of the provided
19
+ * stream, or `undefined` when there is no audio track to mix, e.g. sharing a
20
+ * screen without audio.
21
+ *
22
+ * @param stream - The stream to extract the audio tracks from
23
+ *
24
+ * @example
25
+ *
26
+ * ```typescript
27
+ * const audioOnly = toAudioOnlyStream(displayStream);
28
+ * ```
29
+ */
30
+ export declare const toAudioOnlyStream: (stream?: MediaStream) => MediaStream | undefined;
16
31
  /**
17
32
  * Create an Audio Mixing Processor and will own the stream passed-in
18
33
  */
@@ -28,6 +28,26 @@ export const updateFeatureProps = (constraints, props) => {
28
28
  }
29
29
  return features;
30
30
  };
31
+ /**
32
+ * Extract a `MediaStream` containing only the audio tracks of the provided
33
+ * stream, or `undefined` when there is no audio track to mix, e.g. sharing a
34
+ * screen without audio.
35
+ *
36
+ * @param stream - The stream to extract the audio tracks from
37
+ *
38
+ * @example
39
+ *
40
+ * ```typescript
41
+ * const audioOnly = toAudioOnlyStream(displayStream);
42
+ * ```
43
+ */
44
+ export const toAudioOnlyStream = (stream) => {
45
+ const audioTracks = stream?.getAudioTracks?.() ?? [];
46
+ if (audioTracks.length === 0) {
47
+ return undefined;
48
+ }
49
+ return new MediaStream(audioTracks);
50
+ };
31
51
  /**
32
52
  * Create an Audio Mixing Processor and will own the stream passed-in
33
53
  */
@@ -36,7 +56,8 @@ export const createAudioMixingProcess = (getCurrentMedia, signals, label = PROCE
36
56
  return async (prevMediaTrack) => {
37
57
  updateFeatureProps(prevMediaTrack.getConstraints(), props);
38
58
  const displayStream = getCurrentMedia();
39
- const displayTrack = displayStream?.getAudioTracks?.().at(0);
59
+ const displayAudioStream = toAudioOnlyStream(displayStream);
60
+ const displayTrack = displayAudioStream?.getAudioTracks().at(0);
40
61
  const applyConstraints = async (constraints) => {
41
62
  if (isEmpty(constraints)) {
42
63
  return;
@@ -50,8 +71,8 @@ export const createAudioMixingProcess = (getCurrentMedia, signals, label = PROCE
50
71
  }
51
72
  if (features.mixWithAdditionalMedia) {
52
73
  const newStream = getCurrentMedia();
53
- const [newTrack] = getCurrentMedia()?.getAudioTracks() ?? [];
54
- if (!newTrack) {
74
+ const newAudioStream = toAudioOnlyStream(newStream);
75
+ if (!newAudioStream) {
55
76
  return;
56
77
  }
57
78
  if (!props.audioGraph || !props.merger) {
@@ -59,8 +80,7 @@ export const createAudioMixingProcess = (getCurrentMedia, signals, label = PROCE
59
80
  return;
60
81
  }
61
82
  if (props.displaySource && props.merger) {
62
- if (newStream ===
63
- props.displaySource.audioNode?.mediaStream) {
83
+ if (newStream === props.displayStream) {
64
84
  return;
65
85
  }
66
86
  props.audioGraph.disconnect([
@@ -68,14 +88,13 @@ export const createAudioMixingProcess = (getCurrentMedia, signals, label = PROCE
68
88
  props.merger,
69
89
  ]);
70
90
  }
71
- if (newStream) {
72
- props.displaySource =
73
- createStreamSourceGraphNode(newStream);
74
- props.audioGraph.connect([
75
- props.displaySource,
76
- props.merger,
77
- ]);
78
- }
91
+ props.displayStream = newStream;
92
+ props.displaySource =
93
+ createStreamSourceGraphNode(newAudioStream);
94
+ props.audioGraph.connect([
95
+ props.displaySource,
96
+ props.merger,
97
+ ]);
79
98
  }
80
99
  else if (features.mixWithAdditionalMedia === false) {
81
100
  // Unmixing
@@ -83,6 +102,7 @@ export const createAudioMixingProcess = (getCurrentMedia, signals, label = PROCE
83
102
  props.displaySource.release();
84
103
  props.audioGraph?.disconnect([props.displaySource]);
85
104
  props.displaySource = undefined;
105
+ props.displayStream = undefined;
86
106
  }
87
107
  }
88
108
  return Promise.resolve();
@@ -118,8 +138,10 @@ export const createAudioMixingProcess = (getCurrentMedia, signals, label = PROCE
118
138
  }
119
139
  assert(prevMediaTrack.track, 'Main track should be available');
120
140
  const mainSource = createStreamSourceGraphNode(new MediaStream([prevMediaTrack.track]));
121
- props.displaySource =
122
- displayStream && createStreamSourceGraphNode(displayStream);
141
+ props.displaySource = displayAudioStream
142
+ ? createStreamSourceGraphNode(displayAudioStream)
143
+ : undefined;
144
+ props.displayStream = displayAudioStream ? displayStream : undefined;
123
145
  props.merger = createChannelMergerGraphNode();
124
146
  const destination = createStreamDestinationGraphNode({
125
147
  channelCount: 1,
@@ -151,10 +173,13 @@ export const createAudioMixingProcess = (getCurrentMedia, signals, label = PROCE
151
173
  logger.debug({ label }, 'Release Media');
152
174
  unsubscribe();
153
175
  // Release Props
154
- await props.audioGraph?.release();
155
- props.audioGraph = undefined;
156
- props.merger = undefined;
157
- props.displaySource = undefined;
176
+ await audioGraph.release();
177
+ if (props.audioGraph === audioGraph) {
178
+ props.audioGraph = undefined;
179
+ props.merger = undefined;
180
+ props.displaySource = undefined;
181
+ props.displayStream = undefined;
182
+ }
158
183
  };
159
184
  return Promise.resolve(createMediaTrack({
160
185
  label,
@@ -173,6 +173,14 @@ label = PROCESSOR_LABELS.AudioProcessor, }) => {
173
173
  [source, analyzer],
174
174
  ];
175
175
  logger.debug({ initialAudioNodeConnection, label }, 'Initial AudioNodeConnection');
176
+ if (props.denoiseNode) {
177
+ props.denoiseNode.release();
178
+ props.denoiseNode = undefined;
179
+ }
180
+ if (props.analyzer) {
181
+ props.analyzer.release();
182
+ props.analyzer = undefined;
183
+ }
176
184
  const audioGraph = createAudioGraphProxy(createAudioGraph(initialAudioNodeConnection, props.audioGraphOptions), {
177
185
  connect: (target, args) => {
178
186
  logger.debug({ label, target, args }, 'connect nodes');
@@ -199,9 +207,11 @@ label = PROCESSOR_LABELS.AudioProcessor, }) => {
199
207
  track.stop();
200
208
  // Release Props
201
209
  await audioGraph.release();
202
- props.denoiseNode = undefined;
203
- props.analyzer = undefined;
204
- props.audioGraph = undefined;
210
+ if (props.audioGraph === audioGraph) {
211
+ props.denoiseNode = undefined;
212
+ props.analyzer = undefined;
213
+ props.audioGraph = undefined;
214
+ }
205
215
  };
206
216
  return createMediaTrack({
207
217
  label,
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './displayMedia';
2
+ export * from './mediaSession';
2
3
  export * from './previewController';
3
4
  export * from './signals';
4
5
  export * from './status';
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './displayMedia';
2
+ export * from './mediaSession';
2
3
  export * from './previewController';
3
4
  export * from './signals';
4
5
  export * from './status';
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Media session actions, including the conferencing specific ones which are
3
+ * not part of the `MediaSessionAction` type shipped with TypeScript's DOM
4
+ * library.
5
+ *
6
+ * `hangup`, `togglecamera`, `togglemicrophone` and `enterpictureinpicture` are
7
+ * implemented by Chromium based browsers. `togglescreenshare` and
8
+ * `voiceactivity` are only specified so far, and are registered as a no-op by
9
+ * every current browser, which {@link createMediaSession} handles gracefully.
10
+ *
11
+ * Note that `voiceactivity` runs the other way around to the rest: the user
12
+ * agent invokes the handler when it detects voice on a live microphone track,
13
+ * e.g. to let a page nudge a muted user who is talking.
14
+ *
15
+ * @see {@link https://w3c.github.io/mediasession/#enumdef-mediasessionaction}
16
+ * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/MediaSession/setActionHandler}
17
+ */
18
+ export type ExtendedMediaSessionAction = MediaSessionAction | 'enterpictureinpicture' | 'hangup' | 'togglecamera' | 'togglemicrophone' | 'togglescreenshare' | 'voiceactivity';
19
+ /**
20
+ * Why the user agent asked the page to enter picture in picture.
21
+ *
22
+ * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/MediaSession/setActionHandler}
23
+ */
24
+ export type MediaSessionEnterPictureInPictureReason = 'other' | 'useraction' | 'contentoccluded';
25
+ /**
26
+ * Details passed to the `enterpictureinpicture` action handler, which are not
27
+ * part of the `MediaSessionActionDetails` type shipped with TypeScript's DOM
28
+ * library.
29
+ */
30
+ export interface MediaSessionEnterPictureInPictureActionDetails extends MediaSessionActionDetails {
31
+ enterPictureInPictureReason?: MediaSessionEnterPictureInPictureReason;
32
+ }
33
+ /**
34
+ * The subset of the `navigator.mediaSession` API used by
35
+ * {@link createMediaSession}, so that an equivalent implementation can be
36
+ * injected, e.g. in tests.
37
+ */
38
+ export interface MediaSessionLike {
39
+ /**
40
+ * Descriptive information about the currently played media.
41
+ */
42
+ metadata: MediaMetadata | null;
43
+ /**
44
+ * Whether the media session is currently playing or paused.
45
+ */
46
+ playbackState: MediaSessionPlaybackState;
47
+ /**
48
+ * Register, or unregister when the handler is `null`, a handler for the
49
+ * given media session action.
50
+ */
51
+ setActionHandler(action: ExtendedMediaSessionAction, handler: MediaSessionActionHandler | null): void;
52
+ /**
53
+ * Tell the user agent whether the camera is considered to be active.
54
+ *
55
+ * P.S. Safari's impl. is an imperative action to change the track's
56
+ * muted stated whereas Chromium-based browsers indicate to the user
57
+ * agent the microphone capture state desired by the page
58
+ */
59
+ setCameraActive?(active: boolean): Promise<void>;
60
+ /**
61
+ * Tell the user agent whether the microphone is considered to be active.
62
+ *
63
+ * P.S. Safari's impl. is an imperative action to change the track's
64
+ * muted stated whereas Chromium-based browsers indicate to the user
65
+ * agent the microphone capture state desired by the page
66
+ */
67
+ setMicrophoneActive?(active: boolean): Promise<void>;
68
+ /**
69
+ * Tell the user agent whether the screen is considered to be shared. Only
70
+ * specified so far, no browser implements it yet.
71
+ */
72
+ setScreenshareActive?(active: boolean): Promise<void>;
73
+ /**
74
+ * Present the playback position, or reset it when called without a state.
75
+ * Throws a `TypeError` for a state the user agent considers invalid.
76
+ */
77
+ setPositionState?(state?: MediaPositionState): void;
78
+ }
79
+ /**
80
+ * Descriptive information presented by the platform's media controls, e.g. the
81
+ * lock screen or the notification shade.
82
+ */
83
+ export interface MediaSessionMetadataInit {
84
+ /**
85
+ * Main label, e.g. the name of the meeting.
86
+ */
87
+ title: string;
88
+ /**
89
+ * Secondary label, e.g. the name of the brand.
90
+ */
91
+ artist?: string;
92
+ /**
93
+ * Tertiary label.
94
+ */
95
+ album?: string;
96
+ /**
97
+ * Images associated with the session, e.g. the brand logo.
98
+ */
99
+ artwork?: MediaImage[];
100
+ }
101
+ /**
102
+ * Options to create a media session.
103
+ */
104
+ export interface MediaSessionInit {
105
+ /**
106
+ * Handlers to register, keyed by the action they handle. Actions without a
107
+ * handler are left untouched, allowing the platform to fall back to its
108
+ * default behavior.
109
+ */
110
+ handlers?: Partial<Record<ExtendedMediaSessionAction, MediaSessionActionHandler>>;
111
+ /**
112
+ * Initial metadata to present.
113
+ */
114
+ metadata?: MediaSessionMetadataInit;
115
+ /**
116
+ * Initial playback state, defaults to `playing`.
117
+ */
118
+ playbackState?: MediaSessionPlaybackState;
119
+ /**
120
+ * Initial playback position to present.
121
+ */
122
+ positionState?: MediaPositionState;
123
+ /**
124
+ * Apply the capture state even when the platform implements it as a
125
+ * command that mutes the real tracks, defaults to `false`.
126
+ *
127
+ * Only enable this when every call is made from a user gesture handler,
128
+ * otherwise unmuting is rejected and the capture stays muted.
129
+ */
130
+ applyImperativeCaptureState?: boolean;
131
+ /**
132
+ * The media session implementation to use, defaults to
133
+ * `navigator.mediaSession` when available.
134
+ */
135
+ mediaSession?: MediaSessionLike;
136
+ /**
137
+ * Factory building the metadata object, defaults to the `MediaMetadata`
138
+ * constructor when available.
139
+ */
140
+ createMetadata?: (init: MediaSessionMetadataInit) => MediaMetadata;
141
+ }
142
+ /**
143
+ * Controls an active media session.
144
+ */
145
+ export interface MediaSessionController {
146
+ /**
147
+ * Whether the media session API is available in the current environment.
148
+ * Every other method is a no-op when it is `false`.
149
+ */
150
+ readonly isSupported: boolean;
151
+ /**
152
+ * Update, or clear when omitted, the presented metadata.
153
+ */
154
+ setMetadata: (metadata?: MediaSessionMetadataInit) => void;
155
+ /**
156
+ * Update the presented playback state.
157
+ */
158
+ setPlaybackState: (playbackState: MediaSessionPlaybackState) => void;
159
+ /**
160
+ * Update whether the platform should present the microphone as active.
161
+ *
162
+ * A no-op on platforms that implement this as a command mutating the real
163
+ * tracks, unless `applyImperativeCaptureState` was set.
164
+ */
165
+ setMicrophoneActive: (active: boolean) => Promise<void>;
166
+ /**
167
+ * Update whether the platform should present the camera as active, with
168
+ * the same caveat as {@link MediaSessionController.setMicrophoneActive}.
169
+ */
170
+ setCameraActive: (active: boolean) => Promise<void>;
171
+ /**
172
+ * Update whether the platform should present the screen as shared, with
173
+ * the same caveat as {@link MediaSessionController.setMicrophoneActive}.
174
+ *
175
+ * Only implemented by WebKit so far.
176
+ */
177
+ setScreenshareActive: (active: boolean) => Promise<void>;
178
+ /**
179
+ * Update, or reset when omitted, the presented playback position.
180
+ *
181
+ * Pass `duration: Infinity` for a live stream such as a call, which makes
182
+ * the platform present an elapsed clock rather than a seekable scrub bar.
183
+ * States the user agent rejects are logged and otherwise ignored.
184
+ */
185
+ setPositionState: (state?: MediaPositionState) => void;
186
+ registerActions(handlers: Partial<Record<ExtendedMediaSessionAction, MediaSessionActionHandler>>): void;
187
+ deregisterActions(actions: ExtendedMediaSessionAction[]): void;
188
+ /**
189
+ * Unregister every handler registered by this session and reset the
190
+ * metadata, the playback state and the position state.
191
+ *
192
+ * Terminal and idempotent: every other method becomes a no-op afterwards,
193
+ * so a slower owner cannot resurrect the platform's media controls.
194
+ */
195
+ destroy: () => void;
196
+ }
197
+ /**
198
+ * Create a media session, presenting the ongoing call to the platform's media
199
+ * controls, e.g. the lock screen, the notification shade or headset buttons.
200
+ *
201
+ * Declaring a media session also hints the browser that the page is playing
202
+ * media, which mobile browsers use to relax the throttling applied to
203
+ * backgrounded pages and to keep the audio capture alive.
204
+ *
205
+ * Every method is a no-op when the Media Session API is unavailable, so the
206
+ * caller does not need to feature detect it.
207
+ *
208
+ * @example
209
+ * ```ts
210
+ * const session = createMediaSession({
211
+ * metadata: {title: 'Weekly sync', artist: 'Pexip'},
212
+ * handlers: {
213
+ * hangup: () => leaveMeeting(),
214
+ * togglemicrophone: () => toggleAudioMuted(),
215
+ * },
216
+ * });
217
+ * await session.setMicrophoneActive(true);
218
+ * session.destroy();
219
+ * ```
220
+ */
221
+ export declare const createMediaSession: ({ handlers, metadata, playbackState, positionState, applyImperativeCaptureState, mediaSession, createMetadata, }?: MediaSessionInit) => MediaSessionController;
@@ -0,0 +1,213 @@
1
+ import { logger } from './logger';
2
+ const getDefaultMediaSession = () => typeof navigator !== 'undefined' && 'mediaSession' in navigator
3
+ ? navigator.mediaSession
4
+ : undefined;
5
+ const getDefaultCreateMetadata = () => typeof MediaMetadata === 'function'
6
+ ? init => new MediaMetadata(init)
7
+ : undefined;
8
+ /**
9
+ * Whether the platform treats the capture state setters as a command rather
10
+ * than as a report.
11
+ *
12
+ * Chromium's `setMicrophoneActive`/`setCameraActive` are purely declarative:
13
+ * they only choose which glyph the media control draws. WebKit's are
14
+ * imperative, and route through `UserMediaController::updateCaptureState` to
15
+ * the page level capture muted state, which mutes the real tracks. Activating
16
+ * capture there additionally requires a visible document and a user gesture,
17
+ * and can raise a permission prompt.
18
+ *
19
+ * That asymmetry is a trap for a state driven caller: muting always succeeds,
20
+ * while the matching unmute is rejected outside a gesture, leaving the
21
+ * microphone muted at the page level with no way back from a signal handler.
22
+ *
23
+ * `setScreenshareActive` distinguishes the two, as only WebKit implements it.
24
+ * WebKit exposes all three under a single `MediaSessionCaptureToggleAPIEnabled`
25
+ * setting, so they appear and disappear together, which makes the probe
26
+ * reliable: when the setting is off none of them exist and there is nothing to
27
+ * call, and Chromium only ever exposes the microphone and the camera ones.
28
+ */
29
+ const hasImperativeCaptureState = (mediaSession) => typeof mediaSession.setScreenshareActive === 'function';
30
+ const createUnsupportedMediaSession = () => ({
31
+ isSupported: false,
32
+ setMetadata: () => undefined,
33
+ setPlaybackState: () => undefined,
34
+ setMicrophoneActive: () => Promise.resolve(),
35
+ setCameraActive: () => Promise.resolve(),
36
+ setScreenshareActive: () => Promise.resolve(),
37
+ setPositionState: () => undefined,
38
+ registerActions: () => undefined,
39
+ deregisterActions: () => undefined,
40
+ destroy: () => undefined,
41
+ });
42
+ /**
43
+ * Create a media session, presenting the ongoing call to the platform's media
44
+ * controls, e.g. the lock screen, the notification shade or headset buttons.
45
+ *
46
+ * Declaring a media session also hints the browser that the page is playing
47
+ * media, which mobile browsers use to relax the throttling applied to
48
+ * backgrounded pages and to keep the audio capture alive.
49
+ *
50
+ * Every method is a no-op when the Media Session API is unavailable, so the
51
+ * caller does not need to feature detect it.
52
+ *
53
+ * @example
54
+ * ```ts
55
+ * const session = createMediaSession({
56
+ * metadata: {title: 'Weekly sync', artist: 'Pexip'},
57
+ * handlers: {
58
+ * hangup: () => leaveMeeting(),
59
+ * togglemicrophone: () => toggleAudioMuted(),
60
+ * },
61
+ * });
62
+ * await session.setMicrophoneActive(true);
63
+ * session.destroy();
64
+ * ```
65
+ */
66
+ export const createMediaSession = ({ handlers = {}, metadata, playbackState = 'playing', positionState, applyImperativeCaptureState = false, mediaSession = getDefaultMediaSession(), createMetadata = getDefaultCreateMetadata(), } = {}) => {
67
+ if (!mediaSession) {
68
+ logger.debug({ context: 'MediaSession' }, 'Media Session API is not supported');
69
+ return createUnsupportedMediaSession();
70
+ }
71
+ const registeredActions = new Set();
72
+ let destroyed = false;
73
+ const skipCaptureState = !applyImperativeCaptureState && hasImperativeCaptureState(mediaSession);
74
+ if (skipCaptureState) {
75
+ logger.debug({ context: 'MediaSession' }, 'Skipping the media session capture state, the platform applies it to the real tracks');
76
+ }
77
+ const setActionHandler = (action, handler) => {
78
+ try {
79
+ mediaSession.setActionHandler(action, handler);
80
+ return true;
81
+ }
82
+ catch (error) {
83
+ logger.debug({ context: 'MediaSession', action, error }, 'Media session action is not supported');
84
+ return false;
85
+ }
86
+ };
87
+ const setMetadata = (updatedMetadata) => {
88
+ if (destroyed) {
89
+ return;
90
+ }
91
+ if (!updatedMetadata || !createMetadata) {
92
+ mediaSession.metadata = null;
93
+ return;
94
+ }
95
+ mediaSession.metadata = createMetadata(updatedMetadata);
96
+ };
97
+ const setPlaybackState = (updatedPlaybackState) => {
98
+ if (destroyed) {
99
+ return;
100
+ }
101
+ mediaSession.playbackState = updatedPlaybackState;
102
+ };
103
+ const setMicrophoneActive = async (active) => {
104
+ if (destroyed ||
105
+ skipCaptureState ||
106
+ !mediaSession.setMicrophoneActive) {
107
+ return;
108
+ }
109
+ try {
110
+ await mediaSession.setMicrophoneActive(active);
111
+ }
112
+ catch (error) {
113
+ logger.debug({ context: 'MediaSession', active, error }, 'Failed to update the media session microphone state');
114
+ }
115
+ };
116
+ const setCameraActive = async (active) => {
117
+ if (destroyed || skipCaptureState || !mediaSession.setCameraActive) {
118
+ return;
119
+ }
120
+ try {
121
+ await mediaSession.setCameraActive(active);
122
+ }
123
+ catch (error) {
124
+ logger.debug({ context: 'MediaSession', active, error }, 'Failed to update the media session camera state');
125
+ }
126
+ };
127
+ const setScreenshareActive = async (active) => {
128
+ if (destroyed ||
129
+ skipCaptureState ||
130
+ !mediaSession.setScreenshareActive) {
131
+ return;
132
+ }
133
+ try {
134
+ await mediaSession.setScreenshareActive(active);
135
+ }
136
+ catch (error) {
137
+ logger.debug({ context: 'MediaSession', active, error }, 'Failed to update the media session screenshare state');
138
+ }
139
+ };
140
+ const setPositionState = (state) => {
141
+ if (destroyed || !mediaSession.setPositionState) {
142
+ return;
143
+ }
144
+ try {
145
+ // Blink rejects a missing, `NaN` or negative duration, a negative
146
+ // position, a position past the duration and a zero playback rate.
147
+ mediaSession.setPositionState(state);
148
+ }
149
+ catch (error) {
150
+ logger.debug({ context: 'MediaSession', state, error }, 'Failed to update the media session position state');
151
+ }
152
+ };
153
+ const destroy = () => {
154
+ if (destroyed) {
155
+ return;
156
+ }
157
+ for (const action of registeredActions) {
158
+ setActionHandler(action, null);
159
+ }
160
+ registeredActions.clear();
161
+ setMetadata();
162
+ setPlaybackState('none');
163
+ setPositionState();
164
+ // The session is a view onto the global `navigator.mediaSession`, which
165
+ // outlives it, so every mutator has to stop writing to it once the
166
+ // session is gone. Owners tear down on different clocks, e.g. the view
167
+ // layer only unbinds its media element listeners on the next React
168
+ // passive cleanup, well after the service has ended the call.
169
+ destroyed = true;
170
+ };
171
+ const registerActions = handlers => {
172
+ if (destroyed) {
173
+ return;
174
+ }
175
+ for (const [action, handler] of Object.entries(handlers)) {
176
+ if (!handler) {
177
+ continue;
178
+ }
179
+ if (setActionHandler(action, handler)) {
180
+ registeredActions.add(action);
181
+ }
182
+ }
183
+ };
184
+ const deregisterActions = actions => {
185
+ if (destroyed) {
186
+ return;
187
+ }
188
+ for (const action of actions) {
189
+ if (registeredActions.delete(action)) {
190
+ setActionHandler(action, null);
191
+ }
192
+ }
193
+ };
194
+ // Register initial actions
195
+ registerActions(handlers);
196
+ setMetadata(metadata);
197
+ mediaSession.playbackState = playbackState;
198
+ if (positionState) {
199
+ setPositionState(positionState);
200
+ }
201
+ return {
202
+ isSupported: true,
203
+ setMetadata,
204
+ setPlaybackState,
205
+ setMicrophoneActive,
206
+ setCameraActive,
207
+ setScreenshareActive,
208
+ setPositionState,
209
+ registerActions,
210
+ deregisterActions,
211
+ destroy,
212
+ };
213
+ };
@@ -11,10 +11,7 @@ export declare const toSameDeviceStatus: ({ audio, video, }: {
11
11
  audio: boolean;
12
12
  video: boolean;
13
13
  }) => UserMediaStatus.PermissionsGranted | UserMediaStatus.PermissionsGrantedFallback | UserMediaStatus.PermissionsGrantedFallbackAudioinput | UserMediaStatus.PermissionsGrantedFallbackVideoinput;
14
- export declare const toOnlyDeviceStatus: (kind: "audioinput" | "videoinput", streamedRequestedDevices: {
15
- audio: boolean;
16
- video: boolean;
17
- }, devices: IndexedDevices) => UserMediaStatus.PermissionsRejectedAudioInput | UserMediaStatus.PermissionsRejectedVideoInput | UserMediaStatus.PermissionsOnlyAudioinput | UserMediaStatus.PermissionsOnlyAudioinputNoVideoDevices | UserMediaStatus.PermissionsOnlyAudioinputFallback | UserMediaStatus.PermissionsOnlyAudioinputFallbackNoVideoDevices | UserMediaStatus.PermissionsOnlyVideoinput | UserMediaStatus.PermissionsOnlyVideoinputNoAudioDevices | UserMediaStatus.PermissionsOnlyVideoinputFallback | UserMediaStatus.PermissionsOnlyVideoinputFallbackNoAudioDevices;
14
+ export declare const toOnlyDeviceStatus: (kind: "audioinput" | "videoinput", matched: boolean, devices: IndexedDevices) => UserMediaStatus.PermissionsRejectedAudioInput | UserMediaStatus.PermissionsRejectedVideoInput | UserMediaStatus.PermissionsOnlyAudioinput | UserMediaStatus.PermissionsOnlyAudioinputNoVideoDevices | UserMediaStatus.PermissionsOnlyAudioinputFallback | UserMediaStatus.PermissionsOnlyAudioinputFallbackNoVideoDevices | UserMediaStatus.PermissionsOnlyVideoinput | UserMediaStatus.PermissionsOnlyVideoinputNoAudioDevices | UserMediaStatus.PermissionsOnlyVideoinputFallback | UserMediaStatus.PermissionsOnlyVideoinputFallbackNoAudioDevices;
18
15
  /**
19
16
  * Check if there is any track match with the `kind`
20
17
  */
package/dist/userMedia.js CHANGED
@@ -71,16 +71,13 @@ export const toSameDeviceStatus = ({ audio, video, }) => {
71
71
  }
72
72
  return UserMediaStatus.PermissionsGrantedFallback;
73
73
  };
74
- export const toOnlyDeviceStatus = (kind, streamedRequestedDevices, devices) => {
75
- const [self, other] = kind === 'audioinput' ? ['audio', 'video'] : ['video', 'audio'];
74
+ export const toOnlyDeviceStatus = (kind, matched, devices) => {
76
75
  const otherKind = kind === 'audioinput' ? 'videoinput' : 'audioinput';
77
- const matched = streamedRequestedDevices[self];
78
- const anotherDeviceRequested = streamedRequestedDevices[other];
79
76
  const hasAnotherTypeOfAuthorizedDevice = devices.anyAuthorizedDevice(otherKind);
80
77
  const hasAnotherTypeOfDevice = devices.size(otherKind) > 0;
81
78
  if (matched) {
82
79
  if (hasAnotherTypeOfDevice) {
83
- if (hasAnotherTypeOfAuthorizedDevice || !anotherDeviceRequested) {
80
+ if (hasAnotherTypeOfAuthorizedDevice) {
84
81
  return kind === 'audioinput'
85
82
  ? UserMediaStatus.PermissionsOnlyAudioinput
86
83
  : UserMediaStatus.PermissionsOnlyVideoinput;
@@ -94,7 +91,7 @@ export const toOnlyDeviceStatus = (kind, streamedRequestedDevices, devices) => {
94
91
  : UserMediaStatus.PermissionsOnlyVideoinputNoAudioDevices;
95
92
  }
96
93
  if (hasAnotherTypeOfDevice) {
97
- if (hasAnotherTypeOfAuthorizedDevice || !anotherDeviceRequested) {
94
+ if (hasAnotherTypeOfAuthorizedDevice) {
98
95
  return kind === 'audioinput'
99
96
  ? UserMediaStatus.PermissionsOnlyAudioinputFallback
100
97
  : UserMediaStatus.PermissionsOnlyVideoinputFallback;
@@ -259,6 +256,7 @@ export const mergeStatus = (prevStatus, nextStatus, prevTracks = [], nextTracks
259
256
  return nextStatus;
260
257
  }
261
258
  }
259
+ return nextStatus;
262
260
  }
263
261
  // Failed to get a new track
264
262
  if (prevValidAudio) {
@@ -426,8 +424,8 @@ export const requestUserMedia = (getCurrentDevices, getMedia = getUserMedia) =>
426
424
  : false,
427
425
  });
428
426
  const streamedRequestedDevices = isStreamingRequestedDevices(constraints, stream, devices);
429
- const onlyAudioStatus = toOnlyDeviceStatus('audioinput', streamedRequestedDevices, devices);
430
- const onlyVideoStatus = toOnlyDeviceStatus('videoinput', streamedRequestedDevices, devices);
427
+ const onlyAudioStatus = toOnlyDeviceStatus('audioinput', streamedRequestedDevices.audio, devices);
428
+ const onlyVideoStatus = toOnlyDeviceStatus('videoinput', streamedRequestedDevices.video, devices);
431
429
  const status = deriveDeviceStatus(onlyAudioStatus, onlyVideoStatus, toSameDeviceStatus(streamedRequestedDevices));
432
430
  return [stream, status];
433
431
  }
@@ -22,6 +22,7 @@ interface VideoStreamProcessProps extends Partial<VideoRenderParams> {
22
22
  contentHint?: VideoContentHint;
23
23
  }
24
24
  declare const FEATURE_KEYS: {
25
+ readonly backend: "string";
25
26
  readonly backgroundBlurAmount: "number";
26
27
  readonly backgroundImageUrl: "string";
27
28
  readonly backgroundThreshold: "number";
@@ -6,6 +6,7 @@ import { logger } from './logger';
6
6
  import { isVideoContentHint } from './typeGuard';
7
7
  import { PROCESSOR_LABELS } from './constants';
8
8
  const FEATURE_KEYS = {
9
+ backend: 'string',
9
10
  backgroundBlurAmount: 'number',
10
11
  backgroundImageUrl: 'string',
11
12
  backgroundThreshold: 'number',
@@ -217,21 +218,26 @@ const applyFeatures = async (processor, features, referenceProcessingHeight) =>
217
218
  case 'downSampleFactor':
218
219
  case 'backgroundThreshold': {
219
220
  const value = features[k];
220
- if (value !== undefined) {
221
+ if (value !== undefined &&
222
+ value !== processor.renderOptions[k]) {
221
223
  renderOptions[k] = value;
222
224
  }
223
225
  break;
224
226
  }
225
227
  case 'excludeBystanders': {
226
228
  const value = features[k];
227
- if (value !== undefined) {
229
+ if (value !== undefined &&
230
+ value !== processor.renderOptions[k]) {
228
231
  renderOptions[k] = value;
229
232
  }
230
233
  break;
231
234
  }
232
235
  case 'personCenterX': {
233
236
  const value = features[k];
234
- if (value !== undefined) {
237
+ if (value !== undefined &&
238
+ value !== processor.renderOptions.personCenter[0]) {
239
+ // Keep the other coordinate from the local draft or existing
240
+ // processor state so X/Y single-field updates stay paired.
235
241
  renderOptions.personCenter = [
236
242
  value,
237
243
  renderOptions.personCenter?.[1] ??
@@ -242,7 +248,10 @@ const applyFeatures = async (processor, features, referenceProcessingHeight) =>
242
248
  }
243
249
  case 'personCenterY': {
244
250
  const value = features[k];
245
- if (value !== undefined) {
251
+ if (value !== undefined &&
252
+ value !== processor.renderOptions.personCenter[1]) {
253
+ // Keep the other coordinate from the local draft or existing
254
+ // processor state so X/Y single-field updates stay paired.
246
255
  renderOptions.personCenter = [
247
256
  renderOptions.personCenter?.[0] ??
248
257
  processor.renderOptions.personCenter[0],
@@ -255,14 +264,20 @@ const applyFeatures = async (processor, features, referenceProcessingHeight) =>
255
264
  case 'backgroundBlurAmount': {
256
265
  const value = features[k];
257
266
  if (value !== undefined) {
258
- renderOptions[k] = getBlurKernelSize(value, referenceProcessingHeight ?? processor.processingHeight, processor.processingHeight);
267
+ const actualValue = getBlurKernelSize(value, referenceProcessingHeight ?? processor.processingHeight, processor.processingHeight);
268
+ if (actualValue !== processor.renderOptions[k]) {
269
+ renderOptions[k] = actualValue;
270
+ }
259
271
  }
260
272
  break;
261
273
  }
262
274
  case 'edgeBlurAmount': {
263
275
  const value = features[k];
264
276
  if (value !== undefined) {
265
- renderOptions[k] = getTentBlurSize(value, referenceProcessingHeight ?? processor.processingHeight, processor.processingHeight);
277
+ const actualValue = getTentBlurSize(value, referenceProcessingHeight ?? processor.processingHeight, processor.processingHeight);
278
+ if (actualValue !== processor.renderOptions[k]) {
279
+ renderOptions[k] = actualValue;
280
+ }
266
281
  }
267
282
  break;
268
283
  }
@@ -273,6 +288,13 @@ const applyFeatures = async (processor, features, referenceProcessingHeight) =>
273
288
  }
274
289
  break;
275
290
  }
291
+ case 'backend': {
292
+ const value = features[k];
293
+ if (value && value !== processor.renderOptions.backend) {
294
+ renderOptions.backend = value;
295
+ }
296
+ break;
297
+ }
276
298
  case 'backgroundImageUrl': {
277
299
  const value = features[k];
278
300
  if (value &&
@@ -356,9 +378,23 @@ label = PROCESSOR_LABELS.VideoProcessor, referenceProcessingHeight, maskCombineR
356
378
  });
357
379
  }
358
380
  else {
359
- await applyFeatures(videoProcessor, { ...features, videoSegmentation }, referenceProcessingHeight);
381
+ await applyFeatures(videoProcessor, {
382
+ backend: gpuAPI(),
383
+ ...renderOptions,
384
+ videoSegmentation,
385
+ }, referenceProcessingHeight);
360
386
  }
361
387
  const track = await videoProcessor.process(prevMediaTrack.track);
388
+ // The source track constraints may have changed while `process()` was
389
+ // awaiting the segmentation model to load (e.g. the user toggled blur
390
+ // in pre-flight before the processor was ready). The features computed
391
+ // above are already baked into the freshly opened processor, so
392
+ // re-apply anything that changed in the meantime to avoid silently
393
+ // dropping the effect.
394
+ const latestFeatures = updateFeatureProps(prevMediaTrack.getConstraints(), props);
395
+ if (!isEmpty(latestFeatures)) {
396
+ await applyFeatures(videoProcessor, latestFeatures, referenceProcessingHeight);
397
+ }
362
398
  // Inherit contentHint from previous track
363
399
  track.contentHint = prevMediaTrack.track.contentHint;
364
400
  let trackReleaseTimeoutID = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pexip/media",
3
- "version": "22.1.0",
3
+ "version": "22.3.0",
4
4
  "description": "Home for media related stuff",
5
5
  "homepage": "https://developer.pexip.com",
6
6
  "bugs": "https://gitlab.com/pexip/zoo/issues",
@@ -39,7 +39,7 @@
39
39
  "clean": "rm -fr dist api-docs",
40
40
  "dev": "pexip-bundler dev",
41
41
  "prepack": "yarn clean && yarn build && yarn docs",
42
- "test": "jest",
42
+ "test": "pexip-bundler test",
43
43
  "check-format": "prettier --ignore-path=../../.prettierignore --check .",
44
44
  "format": "prettier --ignore-path=../../.prettierignore --write .",
45
45
  "lint": "yarn run -T biome lint",
@@ -47,20 +47,15 @@
47
47
  "docs": "typedoc"
48
48
  },
49
49
  "dependencies": {
50
- "@pexip/media-control": "22.1.0",
51
- "@pexip/media-processor": "22.1.0",
52
- "@pexip/signal": "16.9.5",
53
- "@pexip/utils": "17.4.0"
50
+ "@pexip/media-control": "22.3.0",
51
+ "@pexip/media-processor": "22.3.0",
52
+ "@pexip/signal": "16.9.6",
53
+ "@pexip/utils": "17.4.1"
54
54
  },
55
55
  "devDependencies": {
56
- "@jest/globals": "^29.5.12",
57
- "@pexip/bundler": "18.2.1",
58
- "@swc/core": "^1.15.33",
59
- "@swc/jest": "^0.2.39",
60
- "jest": "^29.5.12",
61
- "jest-junit": "^16.0.0",
56
+ "@pexip/bundler": "18.3.0",
62
57
  "prettier": "^3.2.5",
63
- "typedoc": "^0.28.18",
58
+ "typedoc": "^0.28.20",
64
59
  "typedoc-plugin-markdown": "^4.11.0",
65
60
  "typescript": "~6.0.2"
66
61
  },