@oxvo/ai-live-assist 7.3.4 → 7.4.15
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/cjs/AiLiveAssist.d.ts +506 -6
- package/cjs/AiLiveAssist.js +12280 -1267
- package/cjs/client.d.ts +51 -10
- package/cjs/client.js +193 -58
- package/cjs/context.d.ts +15 -5
- package/cjs/context.js +568 -73
- package/cjs/control.d.ts +72 -7
- package/cjs/control.js +598 -71
- package/cjs/dialogFocusScope.d.ts +37 -0
- package/cjs/dialogFocusScope.js +227 -0
- package/cjs/experienceState.js +1 -1
- package/cjs/index.d.ts +1 -1
- package/cjs/index.js +2 -2
- package/cjs/media.d.ts +13 -3
- package/cjs/media.js +125 -19
- package/cjs/messages.js +22 -3
- package/cjs/messengerLoader.d.ts +3 -0
- package/cjs/messengerLoader.js +156 -0
- package/cjs/pageAssistancePreference.d.ts +78 -0
- package/cjs/pageAssistancePreference.js +488 -0
- package/cjs/preview.d.ts +33 -0
- package/cjs/preview.js +567 -0
- package/cjs/tabLock.d.ts +54 -3
- package/cjs/tabLock.js +752 -72
- package/cjs/types.d.ts +101 -3
- package/cjs/ui.d.ts +192 -10
- package/cjs/ui.js +6683 -543
- package/cjs/version.d.ts +1 -1
- package/cjs/version.js +1 -1
- package/cjs/voicePresenceUi.d.ts +42 -9
- package/cjs/voicePresenceUi.js +757 -125
- package/lib/AiLiveAssist.d.ts +506 -6
- package/lib/AiLiveAssist.js +12285 -1275
- package/lib/client.d.ts +51 -10
- package/lib/client.js +190 -57
- package/lib/context.d.ts +15 -5
- package/lib/context.js +566 -72
- package/lib/control.d.ts +72 -7
- package/lib/control.js +598 -71
- package/lib/dialogFocusScope.d.ts +37 -0
- package/lib/dialogFocusScope.js +223 -0
- package/lib/experienceState.js +1 -1
- package/lib/index.d.ts +1 -1
- package/lib/index.js +2 -2
- package/lib/media.d.ts +13 -3
- package/lib/media.js +125 -19
- package/lib/messages.js +22 -3
- package/lib/messengerLoader.d.ts +3 -0
- package/lib/messengerLoader.js +151 -0
- package/lib/pageAssistancePreference.d.ts +78 -0
- package/lib/pageAssistancePreference.js +482 -0
- package/lib/preview.d.ts +33 -0
- package/lib/preview.js +561 -0
- package/lib/tabLock.d.ts +54 -3
- package/lib/tabLock.js +752 -72
- package/lib/types.d.ts +101 -3
- package/lib/ui.d.ts +192 -10
- package/lib/ui.js +6684 -544
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/lib/voicePresenceUi.d.ts +42 -9
- package/lib/voicePresenceUi.js +757 -125
- package/package.json +8 -3
- package/cjs/recordingPolicy.d.ts +0 -2
- package/cjs/recordingPolicy.js +0 -12
- package/lib/recordingPolicy.d.ts +0 -2
- 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;
|
package/cjs/experienceState.js
CHANGED
|
@@ -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.
|
|
22
|
-
console.warn('OXVO AI Guide requires @oxvo/browser version 7.
|
|
21
|
+
if (!app.checkRequiredVersion?.('7.4.15')) {
|
|
22
|
+
console.warn('OXVO AI Guide requires @oxvo/browser version 7.4.15 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,9 +18,14 @@ export declare class RealtimeMedia {
|
|
|
16
18
|
private pushActive;
|
|
17
19
|
private muted;
|
|
18
20
|
private replacingDevice;
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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;
|
|
23
30
|
setManualTurn(manual: boolean): void;
|
|
24
31
|
beginPushToTalk(): void;
|
|
@@ -30,5 +37,8 @@ export declare class RealtimeMedia {
|
|
|
30
37
|
private onDeviceChange;
|
|
31
38
|
private replaceUnavailableDevice;
|
|
32
39
|
private waitForIce;
|
|
40
|
+
private active;
|
|
41
|
+
private assertActive;
|
|
42
|
+
private assertReplacementActive;
|
|
33
43
|
}
|
|
34
44
|
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
|
-
|
|
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,21 +86,40 @@ 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 =
|
|
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) {
|
|
@@ -91,23 +142,28 @@ class RealtimeMedia {
|
|
|
91
142
|
this.applyTrackState();
|
|
92
143
|
}
|
|
93
144
|
switchToTextOnly() {
|
|
145
|
+
this.deviceGeneration += 1;
|
|
94
146
|
for (const sender of this.connection?.getSenders() ?? []) {
|
|
95
147
|
if (sender.track?.kind === 'audio') {
|
|
96
148
|
sender.track.stop();
|
|
97
149
|
void sender.replaceTrack(null);
|
|
98
150
|
}
|
|
99
151
|
}
|
|
100
|
-
|
|
101
|
-
track.stop();
|
|
152
|
+
stopStream(this.localStream);
|
|
102
153
|
this.localStream = null;
|
|
103
154
|
this.callbacks.onLocalStream(null);
|
|
104
155
|
navigator.mediaDevices?.removeEventListener?.('devicechange', this.onDeviceChange);
|
|
105
156
|
}
|
|
106
157
|
close() {
|
|
158
|
+
if (!this.closed) {
|
|
159
|
+
this.closed = true;
|
|
160
|
+
this.generation += 1;
|
|
161
|
+
this.deviceGeneration += 1;
|
|
162
|
+
this.lifetimeAbort.abort();
|
|
163
|
+
}
|
|
107
164
|
this.dataChannel?.close();
|
|
108
165
|
this.dataChannel = null;
|
|
109
|
-
|
|
110
|
-
track.stop();
|
|
166
|
+
stopStream(this.localStream);
|
|
111
167
|
this.localStream = null;
|
|
112
168
|
this.callbacks.onLocalStream(null);
|
|
113
169
|
navigator.mediaDevices?.removeEventListener?.('devicechange', this.onDeviceChange);
|
|
@@ -126,8 +182,16 @@ class RealtimeMedia {
|
|
|
126
182
|
}
|
|
127
183
|
}
|
|
128
184
|
async replaceUnavailableDevice() {
|
|
129
|
-
|
|
185
|
+
const signal = this.signal;
|
|
186
|
+
const generation = this.generation;
|
|
187
|
+
const deviceGeneration = this.deviceGeneration;
|
|
188
|
+
if (!signal ||
|
|
189
|
+
!this.active(signal, generation) ||
|
|
190
|
+
this.replacingDevice ||
|
|
191
|
+
!this.connection ||
|
|
192
|
+
!this.localStream) {
|
|
130
193
|
return;
|
|
194
|
+
}
|
|
131
195
|
const current = this.localStream.getAudioTracks()[0];
|
|
132
196
|
if (!current) {
|
|
133
197
|
this.callbacks.onMicrophoneUnavailable();
|
|
@@ -136,6 +200,7 @@ class RealtimeMedia {
|
|
|
136
200
|
if (current.readyState === 'live') {
|
|
137
201
|
try {
|
|
138
202
|
const devices = await navigator.mediaDevices.enumerateDevices();
|
|
203
|
+
this.assertReplacementActive(signal, generation, deviceGeneration);
|
|
139
204
|
const currentId = current.getSettings().deviceId;
|
|
140
205
|
if (!currentId ||
|
|
141
206
|
devices.some((device) => device.kind === 'audioinput' && device.deviceId === currentId)) {
|
|
@@ -147,8 +212,11 @@ class RealtimeMedia {
|
|
|
147
212
|
}
|
|
148
213
|
}
|
|
149
214
|
this.replacingDevice = true;
|
|
215
|
+
let replacement = null;
|
|
150
216
|
try {
|
|
151
|
-
const
|
|
217
|
+
const connection = this.connection;
|
|
218
|
+
const priorStream = this.localStream;
|
|
219
|
+
replacement = await navigator.mediaDevices.getUserMedia({
|
|
152
220
|
audio: {
|
|
153
221
|
autoGainControl: true,
|
|
154
222
|
echoCancellation: true,
|
|
@@ -156,28 +224,41 @@ class RealtimeMedia {
|
|
|
156
224
|
},
|
|
157
225
|
video: false,
|
|
158
226
|
});
|
|
227
|
+
this.assertReplacementActive(signal, generation, deviceGeneration);
|
|
159
228
|
const track = replacement.getAudioTracks()[0];
|
|
160
|
-
const sender =
|
|
229
|
+
const sender = connection
|
|
161
230
|
.getSenders()
|
|
162
231
|
.find((candidate) => candidate.track?.kind === 'audio');
|
|
163
232
|
if (!track || !sender)
|
|
164
233
|
throw new Error('No replacement microphone track.');
|
|
165
234
|
await sender.replaceTrack(track);
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
235
|
+
this.assertReplacementActive(signal, generation, deviceGeneration);
|
|
236
|
+
if (this.connection !== connection || this.localStream !== priorStream) {
|
|
237
|
+
throw abortError();
|
|
238
|
+
}
|
|
239
|
+
stopStream(priorStream);
|
|
240
|
+
const activeReplacement = replacement;
|
|
241
|
+
this.localStream = activeReplacement;
|
|
242
|
+
replacement = null;
|
|
169
243
|
this.bindTrackEnd();
|
|
170
244
|
this.applyTrackState();
|
|
171
|
-
this.callbacks.onLocalStream(
|
|
245
|
+
this.callbacks.onLocalStream(activeReplacement);
|
|
172
246
|
}
|
|
173
|
-
catch {
|
|
174
|
-
|
|
247
|
+
catch (error) {
|
|
248
|
+
stopStream(replacement);
|
|
249
|
+
if (!(error instanceof DOMException && error.name === 'AbortError') &&
|
|
250
|
+
this.active(signal, generation)) {
|
|
251
|
+
this.callbacks.onMicrophoneUnavailable();
|
|
252
|
+
}
|
|
175
253
|
}
|
|
176
254
|
finally {
|
|
177
255
|
this.replacingDevice = false;
|
|
178
256
|
}
|
|
179
257
|
}
|
|
180
258
|
waitForIce(connection, signal) {
|
|
259
|
+
if (signal.aborted || this.lifetimeAbort.signal.aborted) {
|
|
260
|
+
return Promise.reject(abortError());
|
|
261
|
+
}
|
|
181
262
|
if (connection.iceGatheringState === 'complete')
|
|
182
263
|
return Promise.resolve();
|
|
183
264
|
return new Promise((resolve, reject) => {
|
|
@@ -188,12 +269,13 @@ class RealtimeMedia {
|
|
|
188
269
|
};
|
|
189
270
|
const onAbort = () => {
|
|
190
271
|
cleanup();
|
|
191
|
-
reject(
|
|
272
|
+
reject(abortError());
|
|
192
273
|
};
|
|
193
274
|
const cleanup = () => {
|
|
194
275
|
clearTimeout(timeout);
|
|
195
276
|
connection.removeEventListener('icegatheringstatechange', onState);
|
|
196
277
|
signal.removeEventListener('abort', onAbort);
|
|
278
|
+
this.lifetimeAbort.signal.removeEventListener('abort', onAbort);
|
|
197
279
|
};
|
|
198
280
|
function finish() {
|
|
199
281
|
cleanup();
|
|
@@ -201,7 +283,31 @@ class RealtimeMedia {
|
|
|
201
283
|
}
|
|
202
284
|
connection.addEventListener('icegatheringstatechange', onState);
|
|
203
285
|
signal.addEventListener('abort', onAbort, { once: true });
|
|
286
|
+
this.lifetimeAbort.signal.addEventListener('abort', onAbort, { once: true });
|
|
204
287
|
});
|
|
205
288
|
}
|
|
289
|
+
active(signal, generation) {
|
|
290
|
+
if (signal.aborted ||
|
|
291
|
+
this.lifetimeAbort.signal.aborted ||
|
|
292
|
+
this.closed ||
|
|
293
|
+
generation !== this.generation) {
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
try {
|
|
297
|
+
return this.isCurrent();
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
assertActive(signal, generation) {
|
|
304
|
+
if (!this.active(signal, generation))
|
|
305
|
+
throw abortError();
|
|
306
|
+
}
|
|
307
|
+
assertReplacementActive(signal, generation, deviceGeneration) {
|
|
308
|
+
this.assertActive(signal, generation);
|
|
309
|
+
if (deviceGeneration !== this.deviceGeneration)
|
|
310
|
+
throw abortError();
|
|
311
|
+
}
|
|
206
312
|
}
|
|
207
313
|
exports.RealtimeMedia = RealtimeMedia;
|