@oxvo/ai-live-assist 7.3.4 → 7.4.23

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.
Files changed (67) hide show
  1. package/cjs/AiLiveAssist.d.ts +588 -6
  2. package/cjs/AiLiveAssist.js +14892 -1240
  3. package/cjs/client.d.ts +71 -10
  4. package/cjs/client.js +316 -58
  5. package/cjs/context.d.ts +15 -5
  6. package/cjs/context.js +568 -73
  7. package/cjs/control.d.ts +72 -7
  8. package/cjs/control.js +594 -71
  9. package/cjs/dialogFocusScope.d.ts +37 -0
  10. package/cjs/dialogFocusScope.js +227 -0
  11. package/cjs/experienceState.js +1 -1
  12. package/cjs/index.d.ts +1 -1
  13. package/cjs/index.js +2 -2
  14. package/cjs/media.d.ts +14 -3
  15. package/cjs/media.js +128 -19
  16. package/cjs/messages.js +26 -3
  17. package/cjs/messengerLoader.d.ts +3 -0
  18. package/cjs/messengerLoader.js +156 -0
  19. package/cjs/pageAssistancePreference.d.ts +78 -0
  20. package/cjs/pageAssistancePreference.js +488 -0
  21. package/cjs/preview.d.ts +33 -0
  22. package/cjs/preview.js +567 -0
  23. package/cjs/tabLock.d.ts +54 -3
  24. package/cjs/tabLock.js +752 -72
  25. package/cjs/types.d.ts +152 -4
  26. package/cjs/ui.d.ts +200 -10
  27. package/cjs/ui.js +6946 -523
  28. package/cjs/version.d.ts +1 -1
  29. package/cjs/version.js +1 -1
  30. package/cjs/voicePresenceUi.d.ts +48 -9
  31. package/cjs/voicePresenceUi.js +863 -125
  32. package/lib/AiLiveAssist.d.ts +588 -6
  33. package/lib/AiLiveAssist.js +14897 -1248
  34. package/lib/client.d.ts +71 -10
  35. package/lib/client.js +313 -57
  36. package/lib/context.d.ts +15 -5
  37. package/lib/context.js +566 -72
  38. package/lib/control.d.ts +72 -7
  39. package/lib/control.js +594 -71
  40. package/lib/dialogFocusScope.d.ts +37 -0
  41. package/lib/dialogFocusScope.js +223 -0
  42. package/lib/experienceState.js +1 -1
  43. package/lib/index.d.ts +1 -1
  44. package/lib/index.js +2 -2
  45. package/lib/media.d.ts +14 -3
  46. package/lib/media.js +128 -19
  47. package/lib/messages.js +26 -3
  48. package/lib/messengerLoader.d.ts +3 -0
  49. package/lib/messengerLoader.js +151 -0
  50. package/lib/pageAssistancePreference.d.ts +78 -0
  51. package/lib/pageAssistancePreference.js +482 -0
  52. package/lib/preview.d.ts +33 -0
  53. package/lib/preview.js +561 -0
  54. package/lib/tabLock.d.ts +54 -3
  55. package/lib/tabLock.js +752 -72
  56. package/lib/types.d.ts +152 -4
  57. package/lib/ui.d.ts +200 -10
  58. package/lib/ui.js +6947 -524
  59. package/lib/version.d.ts +1 -1
  60. package/lib/version.js +1 -1
  61. package/lib/voicePresenceUi.d.ts +48 -9
  62. package/lib/voicePresenceUi.js +863 -125
  63. package/package.json +8 -3
  64. package/cjs/recordingPolicy.d.ts +0 -2
  65. package/cjs/recordingPolicy.js +0 -12
  66. package/lib/recordingPolicy.d.ts +0 -2
  67. package/lib/recordingPolicy.js +0 -8
@@ -0,0 +1,37 @@
1
+ type DialogPresentation = {
2
+ surface: HTMLElement;
3
+ initialFocus: () => HTMLElement | null;
4
+ fallbackFocus: () => HTMLElement | null;
5
+ onEscape: () => void;
6
+ };
7
+ /**
8
+ * Owns keyboard focus for one safety presentation at a time. Presentation
9
+ * replacement is generation-guarded so stale microtasks cannot focus or
10
+ * restore an earlier prompt.
11
+ */
12
+ export declare class DialogFocusScope {
13
+ private readonly root;
14
+ private readonly host;
15
+ private current;
16
+ private restoreTarget;
17
+ private generation;
18
+ private redirectGeneration;
19
+ private containmentBound;
20
+ private destroyed;
21
+ constructor(root: ShadowRoot, host: HTMLElement);
22
+ present(value: DialogPresentation): void;
23
+ dismiss(surface: HTMLElement, options?: {
24
+ restore?: boolean;
25
+ }): void;
26
+ clear(options?: {
27
+ restore?: boolean;
28
+ }): void;
29
+ destroy(): void;
30
+ private bindContainment;
31
+ private unbindContainment;
32
+ private focusPresentation;
33
+ private onFocusIn;
34
+ private activeFocusTarget;
35
+ private onKeyDown;
36
+ }
37
+ export {};
@@ -0,0 +1,227 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DialogFocusScope = void 0;
4
+ const focusableSelector = [
5
+ "button:not([disabled])",
6
+ "input:not([disabled])",
7
+ "textarea:not([disabled])",
8
+ "select:not([disabled])",
9
+ "summary",
10
+ "a[href]",
11
+ '[tabindex]:not([tabindex="-1"])',
12
+ ].join(",");
13
+ const isVisibleFocusTarget = (element) => {
14
+ if (!element?.isConnected ||
15
+ element.hidden ||
16
+ element.getAttribute("aria-hidden") === "true" ||
17
+ element.closest("[hidden],[inert]") ||
18
+ ("disabled" in element && element.disabled === true)) {
19
+ return false;
20
+ }
21
+ try {
22
+ const computed = getComputedStyle(element);
23
+ return computed.display !== "none" && computed.visibility !== "hidden";
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+ };
29
+ /**
30
+ * Owns keyboard focus for one safety presentation at a time. Presentation
31
+ * replacement is generation-guarded so stale microtasks cannot focus or
32
+ * restore an earlier prompt.
33
+ */
34
+ class DialogFocusScope {
35
+ constructor(root, host) {
36
+ this.root = root;
37
+ this.host = host;
38
+ this.current = null;
39
+ this.restoreTarget = null;
40
+ this.generation = 0;
41
+ this.redirectGeneration = null;
42
+ this.containmentBound = false;
43
+ this.destroyed = false;
44
+ this.onFocusIn = (event) => {
45
+ const presentation = this.current;
46
+ if (!presentation || this.destroyed)
47
+ return;
48
+ const eventPath = event.composedPath?.() ?? [];
49
+ const eventTarget = event.target;
50
+ if (eventPath.includes(presentation.surface) ||
51
+ (eventTarget instanceof Node &&
52
+ presentation.surface.contains(eventTarget))) {
53
+ return;
54
+ }
55
+ const generation = this.generation;
56
+ if (this.redirectGeneration === generation)
57
+ return;
58
+ this.redirectGeneration = generation;
59
+ queueMicrotask(() => {
60
+ if (this.redirectGeneration === generation) {
61
+ this.redirectGeneration = null;
62
+ }
63
+ if (this.destroyed ||
64
+ generation !== this.generation ||
65
+ this.current !== presentation ||
66
+ presentation.surface.contains(this.root.activeElement)) {
67
+ return;
68
+ }
69
+ this.focusPresentation(presentation);
70
+ });
71
+ };
72
+ this.onKeyDown = (event) => {
73
+ const presentation = this.current;
74
+ if (!presentation || event.currentTarget !== presentation.surface)
75
+ return;
76
+ if (event.key === "Escape") {
77
+ event.preventDefault();
78
+ event.stopPropagation();
79
+ presentation.onEscape();
80
+ return;
81
+ }
82
+ if (event.key !== "Tab")
83
+ return;
84
+ const focusable = [
85
+ ...presentation.surface.querySelectorAll(focusableSelector),
86
+ ].filter(isVisibleFocusTarget);
87
+ const first = focusable[0];
88
+ const last = focusable.at(-1);
89
+ if (!first || !last)
90
+ return;
91
+ const active = this.root.activeElement;
92
+ if (!presentation.surface.contains(active)) {
93
+ event.preventDefault();
94
+ (event.shiftKey ? last : first).focus({ preventScroll: true });
95
+ return;
96
+ }
97
+ if (event.shiftKey && active === first) {
98
+ event.preventDefault();
99
+ last.focus({ preventScroll: true });
100
+ }
101
+ else if (!event.shiftKey && active === last) {
102
+ event.preventDefault();
103
+ first.focus({ preventScroll: true });
104
+ }
105
+ };
106
+ }
107
+ present(value) {
108
+ if (this.destroyed)
109
+ return;
110
+ if (!this.current && !isVisibleFocusTarget(this.restoreTarget)) {
111
+ this.restoreTarget = this.activeFocusTarget(value);
112
+ }
113
+ this.current?.surface.removeEventListener("keydown", this.onKeyDown);
114
+ this.current = value;
115
+ this.generation += 1;
116
+ this.redirectGeneration = null;
117
+ const generation = this.generation;
118
+ value.surface.addEventListener("keydown", this.onKeyDown);
119
+ this.bindContainment();
120
+ const immediateTarget = value.initialFocus();
121
+ if (isVisibleFocusTarget(immediateTarget)) {
122
+ immediateTarget.focus({ preventScroll: true });
123
+ }
124
+ queueMicrotask(() => {
125
+ if (this.destroyed ||
126
+ generation !== this.generation ||
127
+ this.current !== value) {
128
+ return;
129
+ }
130
+ if (value.surface.contains(this.root.activeElement))
131
+ return;
132
+ const target = value.initialFocus();
133
+ if (isVisibleFocusTarget(target))
134
+ target.focus({ preventScroll: true });
135
+ });
136
+ }
137
+ dismiss(surface, options = {}) {
138
+ if (this.current?.surface !== surface)
139
+ return;
140
+ const presentation = this.current;
141
+ presentation.surface.removeEventListener("keydown", this.onKeyDown);
142
+ this.current = null;
143
+ this.generation += 1;
144
+ this.redirectGeneration = null;
145
+ this.unbindContainment();
146
+ const generation = this.generation;
147
+ if (options.restore === false) {
148
+ this.restoreTarget = null;
149
+ return;
150
+ }
151
+ const restoreTarget = this.restoreTarget;
152
+ queueMicrotask(() => {
153
+ if (this.destroyed ||
154
+ generation !== this.generation ||
155
+ this.current) {
156
+ return;
157
+ }
158
+ const fallback = presentation.fallbackFocus();
159
+ const target = isVisibleFocusTarget(restoreTarget)
160
+ ? restoreTarget
161
+ : isVisibleFocusTarget(fallback)
162
+ ? fallback
163
+ : null;
164
+ this.restoreTarget = null;
165
+ target?.focus({ preventScroll: true });
166
+ });
167
+ }
168
+ clear(options = {}) {
169
+ const surface = this.current?.surface;
170
+ if (surface) {
171
+ this.dismiss(surface, options);
172
+ return;
173
+ }
174
+ this.generation += 1;
175
+ this.restoreTarget = null;
176
+ }
177
+ destroy() {
178
+ if (this.destroyed)
179
+ return;
180
+ this.destroyed = true;
181
+ this.current?.surface.removeEventListener("keydown", this.onKeyDown);
182
+ this.unbindContainment();
183
+ this.current = null;
184
+ this.restoreTarget = null;
185
+ this.redirectGeneration = null;
186
+ this.generation += 1;
187
+ }
188
+ bindContainment() {
189
+ if (this.containmentBound)
190
+ return;
191
+ this.containmentBound = true;
192
+ this.root.addEventListener("focusin", this.onFocusIn, true);
193
+ document.addEventListener("focusin", this.onFocusIn, true);
194
+ }
195
+ unbindContainment() {
196
+ if (!this.containmentBound)
197
+ return;
198
+ this.containmentBound = false;
199
+ this.root.removeEventListener("focusin", this.onFocusIn, true);
200
+ document.removeEventListener("focusin", this.onFocusIn, true);
201
+ }
202
+ focusPresentation(presentation) {
203
+ const preferred = presentation.initialFocus();
204
+ if (isVisibleFocusTarget(preferred)) {
205
+ preferred.focus({ preventScroll: true });
206
+ return;
207
+ }
208
+ const first = [
209
+ ...presentation.surface.querySelectorAll(focusableSelector),
210
+ ].find(isVisibleFocusTarget);
211
+ first?.focus({ preventScroll: true });
212
+ }
213
+ activeFocusTarget(value) {
214
+ const shadowActive = this.root.activeElement;
215
+ if (shadowActive instanceof HTMLElement)
216
+ return shadowActive;
217
+ const documentActive = document.activeElement;
218
+ if (documentActive instanceof HTMLElement &&
219
+ documentActive !== document.body &&
220
+ documentActive !== document.documentElement &&
221
+ documentActive !== this.host) {
222
+ return documentActive;
223
+ }
224
+ return value.fallbackFocus();
225
+ }
226
+ }
227
+ exports.DialogFocusScope = DialogFocusScope;
@@ -19,7 +19,7 @@ const transitions = new Map([
19
19
  ["connecting", new Set(["active.ready", "active.reconnecting", "recoverable_error", "terminal_error", "ending"])],
20
20
  ["active.reconnecting", new Set(["connecting", "active.ready", "recoverable_error", "terminal_error", "ending"])],
21
21
  ["recoverable_error", new Set(["launcher", "preflight", "requesting_microphone", "connecting", "active.reconnecting", "ending", "terminal_error"])],
22
- ["terminal_error", new Set(["launcher", "ended"])],
22
+ ["terminal_error", new Set(["launcher", "ended", "ending"])],
23
23
  ["ending", new Set(["ended", "terminal_error"])],
24
24
  ["ended", new Set(["launcher"])],
25
25
  ]);
package/cjs/index.d.ts CHANGED
@@ -2,7 +2,7 @@ import type { App, Options as BrowserOptions } from '@oxvo/browser';
2
2
  import AiLiveAssist from './AiLiveAssist.js';
3
3
  import type { PluginOptions } from './types.js';
4
4
  import { VERSION } from './version.js';
5
- export type { PluginOptions } from './types.js';
5
+ export type { AiGuideControllerState, AiGuideOpenInput, MessengerCompanionConfig, PluginOptions, } from './types.js';
6
6
  export { AiLiveAssist, VERSION };
7
7
  export default function aiLiveAssist(options?: PluginOptions): (app: App | null) => AiLiveAssist | undefined;
8
8
  export declare function installAiLiveAssist(input: {
package/cjs/index.js CHANGED
@@ -18,8 +18,8 @@ function aiLiveAssist(options = {}) {
18
18
  if (document.querySelector('[data-oxvo-ai-live-assist-root]')) {
19
19
  return undefined;
20
20
  }
21
- if (!app.checkRequiredVersion?.('7.3.0')) {
22
- console.warn('OXVO AI Guide requires @oxvo/browser version 7.3.0 or newer.');
21
+ if (!app.checkRequiredVersion?.('7.4.23')) {
22
+ console.warn('OXVO AI Guide requires @oxvo/browser version 7.4.23 or newer.');
23
23
  return undefined;
24
24
  }
25
25
  const instance = new AiLiveAssist_js_1.default(app, options);
package/cjs/media.d.ts CHANGED
@@ -6,9 +6,11 @@ type MediaCallbacks = {
6
6
  onConnectionState: (state: RTCPeerConnectionState) => void;
7
7
  onMicrophoneUnavailable: () => void;
8
8
  };
9
+ type MediaLifecycleCheck = () => boolean;
9
10
  export declare class RealtimeMedia {
10
11
  private readonly client;
11
12
  private readonly callbacks;
13
+ private readonly isCurrent;
12
14
  private connection;
13
15
  private localStream;
14
16
  private dataChannel;
@@ -16,10 +18,16 @@ export declare class RealtimeMedia {
16
18
  private pushActive;
17
19
  private muted;
18
20
  private replacingDevice;
19
- constructor(client: RuntimeClient, callbacks: MediaCallbacks);
20
- prepare(mode: AssistMode): Promise<void>;
21
- connect(result: LaunchResult, mode: AssistMode, signal: AbortSignal): Promise<number>;
21
+ private closed;
22
+ private generation;
23
+ private deviceGeneration;
24
+ private signal;
25
+ private readonly lifetimeAbort;
26
+ constructor(client: RuntimeClient, callbacks: MediaCallbacks, isCurrent?: MediaLifecycleCheck);
27
+ prepare(mode: AssistMode, signal: AbortSignal): Promise<void>;
28
+ connect(result: LaunchResult, mode: AssistMode, signal: AbortSignal, offerIdempotencyKey?: string, expectedSessionState?: 'active_text' | 'active_voice'): Promise<number>;
22
29
  setMuted(muted: boolean): void;
30
+ get isMuted(): boolean;
23
31
  setManualTurn(manual: boolean): void;
24
32
  beginPushToTalk(): void;
25
33
  endPushToTalk(): void;
@@ -30,5 +38,8 @@ export declare class RealtimeMedia {
30
38
  private onDeviceChange;
31
39
  private replaceUnavailableDevice;
32
40
  private waitForIce;
41
+ private active;
42
+ private assertActive;
43
+ private assertReplacementActive;
33
44
  }
34
45
  export {};
package/cjs/media.js CHANGED
@@ -1,10 +1,16 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.RealtimeMedia = void 0;
4
+ const abortError = () => new DOMException('The operation was aborted.', 'AbortError');
5
+ const stopStream = (stream) => {
6
+ for (const track of stream?.getTracks() ?? [])
7
+ track.stop();
8
+ };
4
9
  class RealtimeMedia {
5
- constructor(client, callbacks) {
10
+ constructor(client, callbacks, isCurrent = () => true) {
6
11
  this.client = client;
7
12
  this.callbacks = callbacks;
13
+ this.isCurrent = isCurrent;
8
14
  this.connection = null;
9
15
  this.localStream = null;
10
16
  this.dataChannel = null;
@@ -12,17 +18,32 @@ class RealtimeMedia {
12
18
  this.pushActive = false;
13
19
  this.muted = false;
14
20
  this.replacingDevice = false;
21
+ this.closed = false;
22
+ this.generation = 0;
23
+ this.deviceGeneration = 0;
24
+ this.signal = null;
25
+ this.lifetimeAbort = new AbortController();
15
26
  this.onDeviceChange = () => {
27
+ if (!this.connection && this.localStream) {
28
+ const track = this.localStream.getAudioTracks()[0];
29
+ if (!track || track.readyState !== 'live') {
30
+ this.callbacks.onMicrophoneUnavailable();
31
+ }
32
+ return;
33
+ }
16
34
  void this.replaceUnavailableDevice();
17
35
  };
18
36
  }
19
- async prepare(mode) {
37
+ async prepare(mode, signal) {
38
+ this.signal = signal;
39
+ const generation = this.generation;
40
+ this.assertActive(signal, generation);
20
41
  if (mode !== 'voice')
21
42
  return;
22
43
  if (!navigator.mediaDevices?.getUserMedia) {
23
44
  throw new Error('Microphone access is not available in this browser.');
24
45
  }
25
- this.localStream = await navigator.mediaDevices.getUserMedia({
46
+ const stream = await navigator.mediaDevices.getUserMedia({
26
47
  audio: {
27
48
  autoGainControl: true,
28
49
  echoCancellation: true,
@@ -30,12 +51,23 @@ class RealtimeMedia {
30
51
  },
31
52
  video: false,
32
53
  });
54
+ try {
55
+ this.assertActive(signal, generation);
56
+ }
57
+ catch (error) {
58
+ stopStream(stream);
59
+ throw error;
60
+ }
61
+ this.localStream = stream;
33
62
  navigator.mediaDevices.addEventListener?.('devicechange', this.onDeviceChange);
34
63
  this.bindTrackEnd();
35
64
  this.applyTrackState();
36
65
  this.callbacks.onLocalStream(this.localStream);
37
66
  }
38
- async connect(result, mode, signal) {
67
+ async connect(result, mode, signal, offerIdempotencyKey, expectedSessionState) {
68
+ this.signal = signal;
69
+ const generation = this.generation;
70
+ this.assertActive(signal, generation);
39
71
  const connection = new RTCPeerConnection({ bundlePolicy: 'max-bundle' });
40
72
  this.connection = connection;
41
73
  this.dataChannel = connection.createDataChannel('oai-events', {
@@ -54,27 +86,49 @@ class RealtimeMedia {
54
86
  connection.addTransceiver('audio', { direction: 'recvonly' });
55
87
  }
56
88
  connection.addEventListener('track', (event) => {
89
+ if (!this.active(signal, generation) ||
90
+ this.connection !== connection) {
91
+ return;
92
+ }
57
93
  const stream = event.streams[0];
58
94
  if (stream)
59
95
  this.callbacks.onRemoteStream(stream);
60
96
  });
61
97
  connection.addEventListener('connectionstatechange', () => {
98
+ if (!this.active(signal, generation) ||
99
+ this.connection !== connection) {
100
+ return;
101
+ }
62
102
  this.callbacks.onConnectionState(connection.connectionState);
63
103
  });
64
104
  const offer = await connection.createOffer();
105
+ this.assertActive(signal, generation);
65
106
  await connection.setLocalDescription(offer);
107
+ this.assertActive(signal, generation);
66
108
  await this.waitForIce(connection, signal);
109
+ this.assertActive(signal, generation);
67
110
  const localSdp = connection.localDescription?.sdp;
68
111
  if (!localSdp)
69
112
  throw new Error('The browser could not create a realtime offer.');
70
- const response = await this.client.offer(result, localSdp, signal);
113
+ const response = offerIdempotencyKey
114
+ ? await this.client.offer(result, localSdp, signal, offerIdempotencyKey)
115
+ : await this.client.offer(result, localSdp, signal);
116
+ this.assertActive(signal, generation);
117
+ if (expectedSessionState &&
118
+ response.sessionState !== expectedSessionState) {
119
+ throw new Error('The realtime session activated in an unexpected mode.');
120
+ }
71
121
  await connection.setRemoteDescription({ type: 'answer', sdp: response.answerSdp });
122
+ this.assertActive(signal, generation);
72
123
  return response.fencingToken;
73
124
  }
74
125
  setMuted(muted) {
75
126
  this.muted = muted;
76
127
  this.applyTrackState();
77
128
  }
129
+ get isMuted() {
130
+ return this.muted;
131
+ }
78
132
  setManualTurn(manual) {
79
133
  this.manualTurn = manual;
80
134
  this.pushActive = false;
@@ -91,23 +145,28 @@ class RealtimeMedia {
91
145
  this.applyTrackState();
92
146
  }
93
147
  switchToTextOnly() {
148
+ this.deviceGeneration += 1;
94
149
  for (const sender of this.connection?.getSenders() ?? []) {
95
150
  if (sender.track?.kind === 'audio') {
96
151
  sender.track.stop();
97
152
  void sender.replaceTrack(null);
98
153
  }
99
154
  }
100
- for (const track of this.localStream?.getTracks() ?? [])
101
- track.stop();
155
+ stopStream(this.localStream);
102
156
  this.localStream = null;
103
157
  this.callbacks.onLocalStream(null);
104
158
  navigator.mediaDevices?.removeEventListener?.('devicechange', this.onDeviceChange);
105
159
  }
106
160
  close() {
161
+ if (!this.closed) {
162
+ this.closed = true;
163
+ this.generation += 1;
164
+ this.deviceGeneration += 1;
165
+ this.lifetimeAbort.abort();
166
+ }
107
167
  this.dataChannel?.close();
108
168
  this.dataChannel = null;
109
- for (const track of this.localStream?.getTracks() ?? [])
110
- track.stop();
169
+ stopStream(this.localStream);
111
170
  this.localStream = null;
112
171
  this.callbacks.onLocalStream(null);
113
172
  navigator.mediaDevices?.removeEventListener?.('devicechange', this.onDeviceChange);
@@ -126,8 +185,16 @@ class RealtimeMedia {
126
185
  }
127
186
  }
128
187
  async replaceUnavailableDevice() {
129
- if (this.replacingDevice || !this.connection || !this.localStream)
188
+ const signal = this.signal;
189
+ const generation = this.generation;
190
+ const deviceGeneration = this.deviceGeneration;
191
+ if (!signal ||
192
+ !this.active(signal, generation) ||
193
+ this.replacingDevice ||
194
+ !this.connection ||
195
+ !this.localStream) {
130
196
  return;
197
+ }
131
198
  const current = this.localStream.getAudioTracks()[0];
132
199
  if (!current) {
133
200
  this.callbacks.onMicrophoneUnavailable();
@@ -136,6 +203,7 @@ class RealtimeMedia {
136
203
  if (current.readyState === 'live') {
137
204
  try {
138
205
  const devices = await navigator.mediaDevices.enumerateDevices();
206
+ this.assertReplacementActive(signal, generation, deviceGeneration);
139
207
  const currentId = current.getSettings().deviceId;
140
208
  if (!currentId ||
141
209
  devices.some((device) => device.kind === 'audioinput' && device.deviceId === currentId)) {
@@ -147,8 +215,11 @@ class RealtimeMedia {
147
215
  }
148
216
  }
149
217
  this.replacingDevice = true;
218
+ let replacement = null;
150
219
  try {
151
- const replacement = await navigator.mediaDevices.getUserMedia({
220
+ const connection = this.connection;
221
+ const priorStream = this.localStream;
222
+ replacement = await navigator.mediaDevices.getUserMedia({
152
223
  audio: {
153
224
  autoGainControl: true,
154
225
  echoCancellation: true,
@@ -156,28 +227,41 @@ class RealtimeMedia {
156
227
  },
157
228
  video: false,
158
229
  });
230
+ this.assertReplacementActive(signal, generation, deviceGeneration);
159
231
  const track = replacement.getAudioTracks()[0];
160
- const sender = this.connection
232
+ const sender = connection
161
233
  .getSenders()
162
234
  .find((candidate) => candidate.track?.kind === 'audio');
163
235
  if (!track || !sender)
164
236
  throw new Error('No replacement microphone track.');
165
237
  await sender.replaceTrack(track);
166
- for (const prior of this.localStream.getTracks())
167
- prior.stop();
168
- this.localStream = replacement;
238
+ this.assertReplacementActive(signal, generation, deviceGeneration);
239
+ if (this.connection !== connection || this.localStream !== priorStream) {
240
+ throw abortError();
241
+ }
242
+ stopStream(priorStream);
243
+ const activeReplacement = replacement;
244
+ this.localStream = activeReplacement;
245
+ replacement = null;
169
246
  this.bindTrackEnd();
170
247
  this.applyTrackState();
171
- this.callbacks.onLocalStream(replacement);
248
+ this.callbacks.onLocalStream(activeReplacement);
172
249
  }
173
- catch {
174
- this.callbacks.onMicrophoneUnavailable();
250
+ catch (error) {
251
+ stopStream(replacement);
252
+ if (!(error instanceof DOMException && error.name === 'AbortError') &&
253
+ this.active(signal, generation)) {
254
+ this.callbacks.onMicrophoneUnavailable();
255
+ }
175
256
  }
176
257
  finally {
177
258
  this.replacingDevice = false;
178
259
  }
179
260
  }
180
261
  waitForIce(connection, signal) {
262
+ if (signal.aborted || this.lifetimeAbort.signal.aborted) {
263
+ return Promise.reject(abortError());
264
+ }
181
265
  if (connection.iceGatheringState === 'complete')
182
266
  return Promise.resolve();
183
267
  return new Promise((resolve, reject) => {
@@ -188,12 +272,13 @@ class RealtimeMedia {
188
272
  };
189
273
  const onAbort = () => {
190
274
  cleanup();
191
- reject(new DOMException('The operation was aborted.', 'AbortError'));
275
+ reject(abortError());
192
276
  };
193
277
  const cleanup = () => {
194
278
  clearTimeout(timeout);
195
279
  connection.removeEventListener('icegatheringstatechange', onState);
196
280
  signal.removeEventListener('abort', onAbort);
281
+ this.lifetimeAbort.signal.removeEventListener('abort', onAbort);
197
282
  };
198
283
  function finish() {
199
284
  cleanup();
@@ -201,7 +286,31 @@ class RealtimeMedia {
201
286
  }
202
287
  connection.addEventListener('icegatheringstatechange', onState);
203
288
  signal.addEventListener('abort', onAbort, { once: true });
289
+ this.lifetimeAbort.signal.addEventListener('abort', onAbort, { once: true });
204
290
  });
205
291
  }
292
+ active(signal, generation) {
293
+ if (signal.aborted ||
294
+ this.lifetimeAbort.signal.aborted ||
295
+ this.closed ||
296
+ generation !== this.generation) {
297
+ return false;
298
+ }
299
+ try {
300
+ return this.isCurrent();
301
+ }
302
+ catch {
303
+ return false;
304
+ }
305
+ }
306
+ assertActive(signal, generation) {
307
+ if (!this.active(signal, generation))
308
+ throw abortError();
309
+ }
310
+ assertReplacementActive(signal, generation, deviceGeneration) {
311
+ this.assertActive(signal, generation);
312
+ if (deviceGeneration !== this.deviceGeneration)
313
+ throw abortError();
314
+ }
206
315
  }
207
316
  exports.RealtimeMedia = RealtimeMedia;