@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.
- package/cjs/AiLiveAssist.d.ts +588 -6
- package/cjs/AiLiveAssist.js +14892 -1240
- package/cjs/client.d.ts +71 -10
- package/cjs/client.js +316 -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 +594 -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 +14 -3
- package/cjs/media.js +128 -19
- package/cjs/messages.js +26 -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 +152 -4
- package/cjs/ui.d.ts +200 -10
- package/cjs/ui.js +6946 -523
- package/cjs/version.d.ts +1 -1
- package/cjs/version.js +1 -1
- package/cjs/voicePresenceUi.d.ts +48 -9
- package/cjs/voicePresenceUi.js +863 -125
- package/lib/AiLiveAssist.d.ts +588 -6
- package/lib/AiLiveAssist.js +14897 -1248
- package/lib/client.d.ts +71 -10
- package/lib/client.js +313 -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 +594 -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 +14 -3
- package/lib/media.js +128 -19
- package/lib/messages.js +26 -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 +152 -4
- package/lib/ui.d.ts +200 -10
- package/lib/ui.js +6947 -524
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/lib/voicePresenceUi.d.ts +48 -9
- package/lib/voicePresenceUi.js +863 -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.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
|
-
|
|
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;
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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(
|
|
248
|
+
this.callbacks.onLocalStream(activeReplacement);
|
|
172
249
|
}
|
|
173
|
-
catch {
|
|
174
|
-
|
|
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(
|
|
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;
|