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