realtime-avatar 0.25.0 → 0.26.1
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/README.md +78 -0
- package/dist/browser.d.ts +2 -87
- package/dist/browser.js +6 -1
- package/dist/{chunk-DNHHQ5GZ.js → chunk-MSPXJU5P.js} +28 -10
- package/dist/{chunk-GMYGHDY2.js → chunk-V3PQ7RNX.js} +22 -4
- package/dist/express.js +2 -2
- package/dist/hono.js +2 -2
- package/dist/index.d.ts +12 -0
- package/dist/index.js +1 -1
- package/dist/nextjs.js +2 -2
- package/dist/playout-delay-CtRZdtsj.d.ts +168 -0
- package/dist/{proxy-client-O-Mu9JKt.d.ts → proxy-client-C8M_G_Kk.d.ts} +163 -17
- package/dist/react-native.d.ts +3 -3
- package/dist/react-native.js +341 -26
- package/dist/react.d.ts +75 -14
- package/dist/react.js +532 -83
- package/dist/server.js +1 -1
- package/dist/tanstack-start.js +2 -2
- package/dist/tools.d.ts +17 -1
- package/dist/tools.js +28 -8
- package/package.json +1 -1
- package/dist/playout-delay-DWOc8iCl.d.ts +0 -65
package/dist/react.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { useRef, useState, useEffect, useCallback,
|
|
3
|
-
import { useRoomContext, useConnectionState, useLocalParticipant, useTranscriptions, RoomAudioRenderer, LiveKitRoom, useMaybeRoomContext, useVoiceAssistant, VideoTrack, useChat } from '@livekit/components-react';
|
|
4
|
-
import { ConnectionState, isRemoteTrack, VideoQuality, ConnectionQuality, RoomEvent, TrackEvent, Track, ConnectionError, ConnectionErrorReason, DisconnectReason, createLocalVideoTrack, Room } from 'livekit-client';
|
|
2
|
+
import { useRef, useMemo, useState, useEffect, useCallback, createElement, Fragment } from 'react';
|
|
3
|
+
import { useRoomContext, useConnectionState, useLocalParticipant, useTranscriptions, RoomAudioRenderer, LiveKitRoom, useMaybeRoomContext, useVoiceAssistant, VideoTrack, useChat, useAudioPlayback } from '@livekit/components-react';
|
|
4
|
+
import { ConnectionState, isRemoteTrack, VideoQuality, ConnectionQuality, RoomEvent, TrackEvent, Track, ConnectionError, ConnectionErrorReason, ParticipantEvent, DisconnectReason, createLocalVideoTrack, Room } from 'livekit-client';
|
|
5
5
|
import { z } from 'zod';
|
|
6
6
|
import { jsxs, jsx } from 'react/jsx-runtime';
|
|
7
7
|
|
|
@@ -27,14 +27,26 @@ function candidateIdentities(room) {
|
|
|
27
27
|
const agents = all.filter((id) => /^agent[-_]/i.test(id));
|
|
28
28
|
return [...agents, ...all.filter((id) => !agents.includes(id))];
|
|
29
29
|
}
|
|
30
|
+
function rpcCode(error) {
|
|
31
|
+
return typeof error === "object" && error !== null && "code" in error ? error.code : void 0;
|
|
32
|
+
}
|
|
30
33
|
function methodMissing(error) {
|
|
31
|
-
const code = error
|
|
32
|
-
if (code === "UNSUPPORTED_METHOD" || code === "RECIPIENT_NOT_FOUND" || code ===
|
|
34
|
+
const code = rpcCode(error);
|
|
35
|
+
if (code === "UNSUPPORTED_METHOD" || code === "RECIPIENT_NOT_FOUND" || code === 1400 || code === 1401) {
|
|
33
36
|
return true;
|
|
34
37
|
}
|
|
35
38
|
const message = error instanceof Error ? error.message : String(error ?? "");
|
|
36
39
|
return /not supported|unsupported_method|recipient_not_found|recipient not found/i.test(message);
|
|
37
40
|
}
|
|
41
|
+
var TRANSIENT_RPC_CODES = /* @__PURE__ */ new Set([1501, 1502, 1503, 1505]);
|
|
42
|
+
var ToolRegistrationError = class extends Error {
|
|
43
|
+
name = "ToolRegistrationError";
|
|
44
|
+
retryable;
|
|
45
|
+
constructor(message, options) {
|
|
46
|
+
super(message, options.cause === void 0 ? void 0 : { cause: options.cause });
|
|
47
|
+
this.retryable = options.retryable;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
38
50
|
var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
39
51
|
function buildManifest(tools) {
|
|
40
52
|
const descriptors = [];
|
|
@@ -64,8 +76,9 @@ async function attachAvatarTools(room, tools, options = {}) {
|
|
|
64
76
|
const { descriptors, rejected } = buildManifest(tools);
|
|
65
77
|
const payload = JSON.stringify({ tools: descriptors });
|
|
66
78
|
if (payload.length > MAX_MANIFEST_BYTES) {
|
|
67
|
-
throw new
|
|
68
|
-
`tool manifest is ${payload.length} bytes, over the ${MAX_MANIFEST_BYTES} limit \u2014 shorten descriptions or register fewer tools
|
|
79
|
+
throw new ToolRegistrationError(
|
|
80
|
+
`tool manifest is ${payload.length} bytes, over the ${MAX_MANIFEST_BYTES} limit \u2014 shorten descriptions or register fewer tools`,
|
|
81
|
+
{ retryable: false }
|
|
69
82
|
);
|
|
70
83
|
}
|
|
71
84
|
options.signal?.throwIfAborted();
|
|
@@ -148,14 +161,21 @@ async function attachAvatarTools(room, tools, options = {}) {
|
|
|
148
161
|
} catch (error) {
|
|
149
162
|
activeCaller = void 0;
|
|
150
163
|
lastError = error;
|
|
151
|
-
if (!methodMissing(error))
|
|
164
|
+
if (!methodMissing(error)) {
|
|
165
|
+
if (options.signal?.aborted) throw error;
|
|
166
|
+
throw new ToolRegistrationError(
|
|
167
|
+
`tool registration failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
168
|
+
{ retryable: TRANSIENT_RPC_CODES.has(rpcCode(error)), cause: error }
|
|
169
|
+
);
|
|
170
|
+
}
|
|
152
171
|
}
|
|
153
172
|
}
|
|
154
173
|
if (Date.now() >= deadline) break;
|
|
155
174
|
await sleep(200);
|
|
156
175
|
}
|
|
157
|
-
throw new
|
|
158
|
-
sawCandidate ? `no participant accepted the tool manifest \u2014 either this session was not minted with the \`client_tools\` capability (a server-side grant), or the agent never armed registration within ${options.timeoutMs ?? 8e3}ms` + (lastError instanceof Error ? ` (last error: ${lastError.message})` : "") : "no remote participant joined the room before the timeout \u2014 nothing to register with"
|
|
176
|
+
throw new ToolRegistrationError(
|
|
177
|
+
sawCandidate ? `no participant accepted the tool manifest \u2014 either this session was not minted with the \`client_tools\` capability (a server-side grant), or the agent never armed registration within ${options.timeoutMs ?? 8e3}ms` + (lastError instanceof Error ? ` (last error: ${lastError.message})` : "") : "no remote participant joined the room before the timeout \u2014 nothing to register with",
|
|
178
|
+
{ retryable: true, cause: lastError ?? void 0 }
|
|
159
179
|
);
|
|
160
180
|
} catch (error) {
|
|
161
181
|
dispose();
|
|
@@ -164,11 +184,31 @@ async function attachAvatarTools(room, tools, options = {}) {
|
|
|
164
184
|
}
|
|
165
185
|
|
|
166
186
|
// ../client/src/react/use-character-tools.ts
|
|
187
|
+
var CHARACTER_TOOLS_RETRY_DELAYS_MS = [1e3, 3e3];
|
|
188
|
+
function manifestKey(tools) {
|
|
189
|
+
return JSON.stringify(Object.entries(tools).map(([name, tool]) => [name, tool.description, tool.parameters ?? null]));
|
|
190
|
+
}
|
|
167
191
|
function useCharacterTools(tools, options = {}) {
|
|
168
192
|
const room = useRoomContext();
|
|
169
193
|
const connection = useConnectionState();
|
|
170
194
|
const callback = useRef(options.onResult);
|
|
171
195
|
callback.current = options.onResult;
|
|
196
|
+
const latest = useRef(tools);
|
|
197
|
+
latest.current = tools;
|
|
198
|
+
const key = manifestKey(tools);
|
|
199
|
+
const stable = useMemo(() => Object.fromEntries(
|
|
200
|
+
Object.entries(latest.current).map(([name, tool]) => [
|
|
201
|
+
name,
|
|
202
|
+
// A tool without a callable `execute` is passed as it is, so the manifest still rejects it.
|
|
203
|
+
typeof tool.execute !== "function" ? tool : {
|
|
204
|
+
description: tool.description,
|
|
205
|
+
parameters: tool.parameters,
|
|
206
|
+
execute: (args, context) => latest.current[name]?.execute(args, context)
|
|
207
|
+
}
|
|
208
|
+
])
|
|
209
|
+
// `key` IS the dependency: it changes exactly when the manifest does.
|
|
210
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
211
|
+
), [key]);
|
|
172
212
|
const [state, setState] = useState({ status: "idle", registered: [], rejected: [] });
|
|
173
213
|
useEffect(() => {
|
|
174
214
|
if (connection !== ConnectionState.Connected) {
|
|
@@ -176,28 +216,42 @@ function useCharacterTools(tools, options = {}) {
|
|
|
176
216
|
return;
|
|
177
217
|
}
|
|
178
218
|
const abort = new AbortController();
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
219
|
+
let timer;
|
|
220
|
+
const register = (attempt) => {
|
|
221
|
+
setState({ status: "registering", registered: [], rejected: [], attempt });
|
|
222
|
+
void attachAvatarTools(room, stable, {
|
|
223
|
+
signal: abort.signal,
|
|
224
|
+
onResult: (event) => {
|
|
225
|
+
if (!abort.signal.aborted) callback.current?.(event);
|
|
226
|
+
}
|
|
227
|
+
}).then((result) => {
|
|
228
|
+
if (abort.signal.aborted) {
|
|
229
|
+
result.dispose();
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
setState({ status: "ready", registered: result.accepted, rejected: result.rejected, attempt });
|
|
233
|
+
}).catch((error) => {
|
|
234
|
+
if (abort.signal.aborted) return;
|
|
235
|
+
const delay = CHARACTER_TOOLS_RETRY_DELAYS_MS[attempt - 1];
|
|
236
|
+
if (error instanceof ToolRegistrationError && error.retryable && delay !== void 0) {
|
|
237
|
+
timer = setTimeout(() => register(attempt + 1), delay);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
setState({
|
|
241
|
+
status: "error",
|
|
242
|
+
registered: [],
|
|
243
|
+
rejected: [],
|
|
244
|
+
attempt,
|
|
245
|
+
error: error instanceof Error ? error.message : "Tool registration failed"
|
|
246
|
+
});
|
|
197
247
|
});
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
|
|
248
|
+
};
|
|
249
|
+
register(1);
|
|
250
|
+
return () => {
|
|
251
|
+
abort.abort();
|
|
252
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
253
|
+
};
|
|
254
|
+
}, [room, stable, connection]);
|
|
201
255
|
return state;
|
|
202
256
|
}
|
|
203
257
|
|
|
@@ -852,6 +906,11 @@ function useLiveKitAvatarGrant(input) {
|
|
|
852
906
|
});
|
|
853
907
|
const stateRef = useRef(state);
|
|
854
908
|
stateRef.current = state;
|
|
909
|
+
const wantedRef = useRef(active);
|
|
910
|
+
wantedRef.current = active;
|
|
911
|
+
useEffect(() => () => {
|
|
912
|
+
wantedRef.current = false;
|
|
913
|
+
}, []);
|
|
855
914
|
const [tabVisible, setTabVisible] = useState(
|
|
856
915
|
() => typeof document === "undefined" || document.visibilityState === "visible"
|
|
857
916
|
);
|
|
@@ -892,6 +951,8 @@ function useLiveKitAvatarGrant(input) {
|
|
|
892
951
|
if (cancelled) {
|
|
893
952
|
if (result.status !== "busy") {
|
|
894
953
|
void client.releaseLiveKitSession(result.grant.session_id, "superseded");
|
|
954
|
+
} else if (!wantedRef.current && result.busy.queue_ticket_id) {
|
|
955
|
+
void client.releaseLiveKitQueueTicket(result.busy.queue_ticket_id, "unmount");
|
|
895
956
|
}
|
|
896
957
|
return;
|
|
897
958
|
}
|
|
@@ -1016,13 +1077,16 @@ function RealtimeAvatarLiveKitRoom(props) {
|
|
|
1016
1077
|
serverUrl: grant?.livekit_url,
|
|
1017
1078
|
token: grant?.participant_token,
|
|
1018
1079
|
connect: shouldConnect,
|
|
1019
|
-
audio: audio ?? grant
|
|
1080
|
+
audio: audio ?? publishesMicrophoneByDefault(grant),
|
|
1020
1081
|
video: cameraPublishOptions(grant, video),
|
|
1021
1082
|
options: roomOptions
|
|
1022
1083
|
},
|
|
1023
1084
|
createElement(Fragment, null, cushion, children, roomAudio)
|
|
1024
1085
|
);
|
|
1025
1086
|
}
|
|
1087
|
+
function publishesMicrophoneByDefault(grant) {
|
|
1088
|
+
return grant?.stt_mode === "server";
|
|
1089
|
+
}
|
|
1026
1090
|
var capacityErrorFromBusy = (busy) => {
|
|
1027
1091
|
return new RealtimeAvatarCapacityError(busy.message, busy);
|
|
1028
1092
|
};
|
|
@@ -1754,16 +1818,16 @@ function AvatarVideoSurface(props) {
|
|
|
1754
1818
|
!live || connectionState === ConnectionState.Disconnected
|
|
1755
1819
|
);
|
|
1756
1820
|
useLiveResumeOnProducing(liveWrapRef, trackProducing);
|
|
1757
|
-
const
|
|
1821
|
+
const fitStyle = fit === "cover" ? AVATAR_VIDEO_FIT_COVER : AVATAR_VIDEO_FIT_CONTAIN;
|
|
1758
1822
|
const liveLabel = showLive && liveDims && liveDims.width > 0 && liveDims.height > 0 ? `${liveDims.width}\xD7${liveDims.height}` : null;
|
|
1759
1823
|
const layers = resolveSurfaceLayers({ idleVideoUrl, poster });
|
|
1760
|
-
const
|
|
1824
|
+
const backFitStyle = fitStyle;
|
|
1761
1825
|
const posterLayer = layers.showPoster ? createElement("img", {
|
|
1762
1826
|
key: "poster",
|
|
1763
1827
|
src: poster,
|
|
1764
1828
|
alt: "",
|
|
1765
1829
|
"aria-hidden": true,
|
|
1766
|
-
|
|
1830
|
+
style: backFitStyle,
|
|
1767
1831
|
"data-testid": "avatar-poster"
|
|
1768
1832
|
}) : null;
|
|
1769
1833
|
const idleLayer = layers.showIdleVideo ? createElement("video", {
|
|
@@ -1781,7 +1845,7 @@ function AvatarVideoSurface(props) {
|
|
|
1781
1845
|
playsInline: true,
|
|
1782
1846
|
autoPlay: true,
|
|
1783
1847
|
preload: "auto",
|
|
1784
|
-
|
|
1848
|
+
style: backFitStyle,
|
|
1785
1849
|
"data-testid": "avatar-idle-video"
|
|
1786
1850
|
}) : null;
|
|
1787
1851
|
const frontLayer = createElement(
|
|
@@ -1789,17 +1853,17 @@ function AvatarVideoSurface(props) {
|
|
|
1789
1853
|
{
|
|
1790
1854
|
key: "live",
|
|
1791
1855
|
ref: liveWrapRef,
|
|
1792
|
-
className: joinClass(AVATAR_VIDEO_LIVE_WRAP, showLive ? void 0 : "pointer-events-none"),
|
|
1793
1856
|
style: {
|
|
1857
|
+
...AVATAR_VIDEO_LIVE_WRAP,
|
|
1794
1858
|
opacity: showLive ? 1 : 0,
|
|
1795
|
-
|
|
1859
|
+
pointerEvents: showLive ? void 0 : "none"
|
|
1796
1860
|
},
|
|
1797
1861
|
"data-testid": "avatar-live-layer",
|
|
1798
1862
|
"aria-hidden": !showLive
|
|
1799
1863
|
},
|
|
1800
1864
|
videoTrack ? createElement(VideoTrack, {
|
|
1801
1865
|
trackRef: videoTrack,
|
|
1802
|
-
|
|
1866
|
+
style: fitStyle,
|
|
1803
1867
|
playsInline: true,
|
|
1804
1868
|
autoPlay: true,
|
|
1805
1869
|
muted: true,
|
|
@@ -1808,16 +1872,16 @@ function AvatarVideoSurface(props) {
|
|
|
1808
1872
|
);
|
|
1809
1873
|
const badge = showLiveBadge && showLive ? createElement(
|
|
1810
1874
|
"span",
|
|
1811
|
-
{ key: "badge",
|
|
1812
|
-
createElement("span", { key: "dot",
|
|
1875
|
+
{ key: "badge", style: AVATAR_VIDEO_BADGE },
|
|
1876
|
+
createElement("span", { key: "dot", style: AVATAR_VIDEO_BADGE_DOT, "aria-hidden": true }),
|
|
1813
1877
|
liveLabel ? `live \xB7 ${liveLabel}` : "live"
|
|
1814
1878
|
) : null;
|
|
1815
1879
|
return createElement(
|
|
1816
1880
|
"div",
|
|
1817
1881
|
{
|
|
1818
|
-
className
|
|
1819
|
-
style: boxAspect ? { aspectRatio: boxAspect, ...style } : style,
|
|
1820
|
-
"data-testid": testId
|
|
1882
|
+
className,
|
|
1883
|
+
style: boxAspect ? { ...AVATAR_VIDEO_BOX, aspectRatio: boxAspect, ...style } : { ...AVATAR_VIDEO_BOX, ...style },
|
|
1884
|
+
"data-testid": testId ?? "avatar-video-surface"
|
|
1821
1885
|
},
|
|
1822
1886
|
// Deepest → shallowest: poster floor (the always-present face), the idle clip
|
|
1823
1887
|
// over it, then the live video. The poster guarantees the surface is never a
|
|
@@ -1826,7 +1890,9 @@ function AvatarVideoSurface(props) {
|
|
|
1826
1890
|
idleLayer,
|
|
1827
1891
|
frontLayer,
|
|
1828
1892
|
badge,
|
|
1829
|
-
|
|
1893
|
+
// Above BOTH media layers and the badge. Appended bare, a consumer's positioned child with
|
|
1894
|
+
// no z-index painted under the z-20 live layer, which then took its clicks once live.
|
|
1895
|
+
children == null || children === false ? null : createElement("div", { key: "overlay", style: AVATAR_VIDEO_OVERLAY, "data-testid": "avatar-overlay" }, children)
|
|
1830
1896
|
);
|
|
1831
1897
|
}
|
|
1832
1898
|
function resolveSurfaceLayers(input) {
|
|
@@ -2138,16 +2204,45 @@ function useLiveResumeOnProducing(wrapRef, producing) {
|
|
|
2138
2204
|
};
|
|
2139
2205
|
}, [wrapRef, producing]);
|
|
2140
2206
|
}
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2207
|
+
var AVATAR_VIDEO_BOX = { position: "relative", width: "100%", height: "100%", overflow: "hidden", isolation: "isolate" };
|
|
2208
|
+
var AVATAR_VIDEO_LAYER = {
|
|
2209
|
+
position: "absolute",
|
|
2210
|
+
inset: 0,
|
|
2211
|
+
width: "100%",
|
|
2212
|
+
height: "100%",
|
|
2213
|
+
transform: "translateZ(0)",
|
|
2214
|
+
imageRendering: "auto",
|
|
2215
|
+
objectPosition: "center 22%"
|
|
2216
|
+
};
|
|
2217
|
+
var AVATAR_VIDEO_FIT_CONTAIN = { ...AVATAR_VIDEO_LAYER, objectFit: "contain" };
|
|
2218
|
+
var AVATAR_VIDEO_FIT_COVER = { ...AVATAR_VIDEO_LAYER, objectFit: "cover" };
|
|
2219
|
+
var AVATAR_VIDEO_LIVE_WRAP = {
|
|
2220
|
+
position: "absolute",
|
|
2221
|
+
inset: 0,
|
|
2222
|
+
zIndex: 20,
|
|
2223
|
+
width: "100%",
|
|
2224
|
+
height: "100%",
|
|
2225
|
+
transitionProperty: "opacity",
|
|
2226
|
+
transitionDuration: "0ms"
|
|
2227
|
+
};
|
|
2228
|
+
var AVATAR_VIDEO_BADGE = {
|
|
2229
|
+
position: "absolute",
|
|
2230
|
+
top: 8,
|
|
2231
|
+
right: 8,
|
|
2232
|
+
zIndex: 30,
|
|
2233
|
+
display: "inline-flex",
|
|
2234
|
+
alignItems: "center",
|
|
2235
|
+
gap: 6,
|
|
2236
|
+
borderRadius: 6,
|
|
2237
|
+
background: "rgb(0 0 0 / 0.55)",
|
|
2238
|
+
padding: "2px 6px",
|
|
2239
|
+
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
|
2240
|
+
fontSize: 10,
|
|
2241
|
+
color: "rgb(255 255 255 / 0.85)",
|
|
2242
|
+
backdropFilter: "blur(8px)"
|
|
2243
|
+
};
|
|
2244
|
+
var AVATAR_VIDEO_BADGE_DOT = { width: 6, height: 6, borderRadius: 9999, background: "#34d399" };
|
|
2245
|
+
var AVATAR_VIDEO_OVERLAY = { position: "absolute", inset: 0, zIndex: 40 };
|
|
2151
2246
|
var RECORDED_MEDIA_MODES = ["audio", "video", "audio_video"];
|
|
2152
2247
|
var recordingModeSchema = z.enum(["off", ...RECORDED_MEDIA_MODES, "participants"]).describe("Server-owned recording policy; omitted means off. Legacy audio includes user and avatar audio. participants saves one continuous audio/video file per participant, without mixing their voices.");
|
|
2153
2248
|
var RECORDING_STATUSES = ["pending", "recording", "processing", "ready", "failed", "expired"];
|
|
@@ -2681,6 +2776,169 @@ function createConnectionHistoryCollector(input) {
|
|
|
2681
2776
|
return { enqueue, flush, dispose };
|
|
2682
2777
|
}
|
|
2683
2778
|
|
|
2779
|
+
// ../browser/src/microphone.ts
|
|
2780
|
+
var HINTS = {
|
|
2781
|
+
"insecure-origin": "The browser only exposes microphones on a secure origin. Serve the page over https, or open it on http://localhost \u2014 a LAN address like http://192.168.1.5 will not do.",
|
|
2782
|
+
"no-answer": "The permission prompt is probably still open, or was dismissed without an answer. Look for it in the address bar, answer it, and try again.",
|
|
2783
|
+
"denied-by-browser": "This site is blocked from using the microphone. Click the icon at the left of the address bar, set Microphone to Allow, and reload.",
|
|
2784
|
+
"denied-by-os": "The operating system is blocking the browser itself, so no site can record. On macOS: System Settings > Privacy & Security > Microphone, enable your browser, then RESTART it \u2014 the change does not apply to a running browser.",
|
|
2785
|
+
"no-device": "No microphone is available. Connect one, and if you are on Bluetooth headphones check they are in a mode that has a microphone.",
|
|
2786
|
+
"device-in-use": "Another application or browser tab is holding the microphone. Close it \u2014 including any call from this page you did not hang up \u2014 and try again.",
|
|
2787
|
+
"device-lost": "The microphone stopped during the call: it was unplugged, or access to it was turned off. Reconnect it or allow access again, then turn the microphone back on.",
|
|
2788
|
+
unknown: "Try again, and if it persists include the message above in your report."
|
|
2789
|
+
};
|
|
2790
|
+
function isOperatingSystemDenial(message) {
|
|
2791
|
+
return /\bby system\b|system permission/i.test(message);
|
|
2792
|
+
}
|
|
2793
|
+
function classify(error) {
|
|
2794
|
+
if (error instanceof TypeError && /mediaDevices|undefined/i.test(error.message)) {
|
|
2795
|
+
return { reason: "insecure-origin", message: error.message };
|
|
2796
|
+
}
|
|
2797
|
+
const name = typeof error === "object" && error !== null && "name" in error ? String(error.name) : "";
|
|
2798
|
+
const message = typeof error === "object" && error !== null && "message" in error && error.message ? String(error.message) : String(error);
|
|
2799
|
+
switch (name) {
|
|
2800
|
+
case "NotAllowedError":
|
|
2801
|
+
case "PermissionDeniedError":
|
|
2802
|
+
return {
|
|
2803
|
+
reason: isOperatingSystemDenial(message) ? "denied-by-os" : "denied-by-browser",
|
|
2804
|
+
message
|
|
2805
|
+
};
|
|
2806
|
+
case "NotFoundError":
|
|
2807
|
+
case "DevicesNotFoundError":
|
|
2808
|
+
case "OverconstrainedError":
|
|
2809
|
+
case "ConstraintNotSatisfiedError":
|
|
2810
|
+
return { reason: "no-device", message };
|
|
2811
|
+
case "NotReadableError":
|
|
2812
|
+
case "TrackStartError":
|
|
2813
|
+
case "AbortError":
|
|
2814
|
+
return { reason: "device-in-use", message };
|
|
2815
|
+
case "SecurityError":
|
|
2816
|
+
return { reason: "insecure-origin", message };
|
|
2817
|
+
default:
|
|
2818
|
+
return { reason: "unknown", message };
|
|
2819
|
+
}
|
|
2820
|
+
}
|
|
2821
|
+
function describeMicrophoneFailure(error) {
|
|
2822
|
+
const { reason, message } = classify(error);
|
|
2823
|
+
return { reason, message, hint: HINTS[reason] };
|
|
2824
|
+
}
|
|
2825
|
+
function microphoneLost(message) {
|
|
2826
|
+
return { reason: "device-lost", message, hint: HINTS["device-lost"] };
|
|
2827
|
+
}
|
|
2828
|
+
|
|
2829
|
+
// ../client/src/react/call-media.ts
|
|
2830
|
+
var DEVICE_LOST_MESSAGE = "The microphone track ended during the call.";
|
|
2831
|
+
function callMicrophoneFrom(facts, intent) {
|
|
2832
|
+
if (!facts.wanted) return { status: "off" };
|
|
2833
|
+
const { publication } = facts;
|
|
2834
|
+
if (publication) {
|
|
2835
|
+
if (publication.ended) {
|
|
2836
|
+
return publication.muted ? { status: "unavailable", ...microphoneLost(DEVICE_LOST_MESSAGE) } : { status: "pending" };
|
|
2837
|
+
}
|
|
2838
|
+
return publication.muted ? { status: "muted" } : { status: "on" };
|
|
2839
|
+
}
|
|
2840
|
+
if (intent.muted) return { status: "muted" };
|
|
2841
|
+
if (facts.enabling) return { status: "pending" };
|
|
2842
|
+
const error = facts.deviceError;
|
|
2843
|
+
if (error) {
|
|
2844
|
+
const problem = describeMicrophoneFailure(error);
|
|
2845
|
+
const blocked = problem.reason === "denied-by-browser" || problem.reason === "denied-by-os" || problem.reason === "insecure-origin";
|
|
2846
|
+
return blocked ? { status: "blocked", ...problem } : { status: "unavailable", ...problem };
|
|
2847
|
+
}
|
|
2848
|
+
return facts.connected ? { status: "pending" } : { status: "off" };
|
|
2849
|
+
}
|
|
2850
|
+
function microphoneFacts(room, wanted, connected, enabling, staleError) {
|
|
2851
|
+
const publication = room.localParticipant.getTrackPublication(Track.Source.Microphone);
|
|
2852
|
+
const track = publication?.track;
|
|
2853
|
+
return {
|
|
2854
|
+
wanted,
|
|
2855
|
+
connected,
|
|
2856
|
+
enabling,
|
|
2857
|
+
publication: track ? { muted: publication.isMuted, ended: track.mediaStreamTrack?.readyState === "ended" } : null,
|
|
2858
|
+
deviceError: room.localParticipant.lastMicrophoneError !== staleError && room.localParticipant.lastMicrophoneError || null
|
|
2859
|
+
};
|
|
2860
|
+
}
|
|
2861
|
+
function useCallMedia(room, options) {
|
|
2862
|
+
const { setMicrophoneFacts, setAudioPlayback, registerMediaControls, microphoneWanted, connectionState, callKey } = options;
|
|
2863
|
+
const connected = connectionState === ConnectionState.Connected;
|
|
2864
|
+
const scope = useMemo(
|
|
2865
|
+
() => ({ room, callKey, stale: room.localParticipant.lastMicrophoneError }),
|
|
2866
|
+
[room, callKey]
|
|
2867
|
+
);
|
|
2868
|
+
const [enabling, setEnabling] = useState(false);
|
|
2869
|
+
const [version, setVersion] = useState(0);
|
|
2870
|
+
const sinksRef = useRef({ setMicrophoneFacts, setAudioPlayback });
|
|
2871
|
+
sinksRef.current = { setMicrophoneFacts, setAudioPlayback };
|
|
2872
|
+
useEffect(() => {
|
|
2873
|
+
if (!setMicrophoneFacts) return;
|
|
2874
|
+
const bump = () => setVersion((value) => value + 1);
|
|
2875
|
+
const participant = room.localParticipant;
|
|
2876
|
+
let watched = null;
|
|
2877
|
+
const watchTrack = () => {
|
|
2878
|
+
watched?.off();
|
|
2879
|
+
const track = participant.getTrackPublication(Track.Source.Microphone)?.track;
|
|
2880
|
+
if (!track) {
|
|
2881
|
+
watched = null;
|
|
2882
|
+
return;
|
|
2883
|
+
}
|
|
2884
|
+
track.on(TrackEvent.Ended, bump).on(TrackEvent.Restarted, bump).on(TrackEvent.Muted, bump).on(TrackEvent.Unmuted, bump);
|
|
2885
|
+
watched = { off: () => {
|
|
2886
|
+
track.off(TrackEvent.Ended, bump).off(TrackEvent.Restarted, bump).off(TrackEvent.Muted, bump).off(TrackEvent.Unmuted, bump);
|
|
2887
|
+
} };
|
|
2888
|
+
};
|
|
2889
|
+
const onPublications = () => {
|
|
2890
|
+
watchTrack();
|
|
2891
|
+
bump();
|
|
2892
|
+
};
|
|
2893
|
+
room.on(RoomEvent.MediaDevicesError, bump).on(RoomEvent.LocalTrackPublished, onPublications).on(RoomEvent.LocalTrackUnpublished, onPublications);
|
|
2894
|
+
participant.on(ParticipantEvent.TrackMuted, bump).on(ParticipantEvent.TrackUnmuted, bump);
|
|
2895
|
+
watchTrack();
|
|
2896
|
+
return () => {
|
|
2897
|
+
watched?.off();
|
|
2898
|
+
room.off(RoomEvent.MediaDevicesError, bump).off(RoomEvent.LocalTrackPublished, onPublications).off(RoomEvent.LocalTrackUnpublished, onPublications);
|
|
2899
|
+
participant.off(ParticipantEvent.TrackMuted, bump).off(ParticipantEvent.TrackUnmuted, bump);
|
|
2900
|
+
};
|
|
2901
|
+
}, [room, setMicrophoneFacts]);
|
|
2902
|
+
useEffect(() => {
|
|
2903
|
+
sinksRef.current.setMicrophoneFacts?.(microphoneFacts(room, microphoneWanted, connected, enabling, scope.stale));
|
|
2904
|
+
}, [room, microphoneWanted, connected, enabling, version, scope]);
|
|
2905
|
+
const { canPlayAudio } = useAudioPlayback(room);
|
|
2906
|
+
useEffect(() => {
|
|
2907
|
+
sinksRef.current.setAudioPlayback?.(!canPlayAudio ? "blocked" : connected ? "allowed" : "unknown");
|
|
2908
|
+
}, [canPlayAudio, connected]);
|
|
2909
|
+
const unlockTriedRef = useRef(null);
|
|
2910
|
+
const connecting = connectionState === ConnectionState.Connecting || connected;
|
|
2911
|
+
useEffect(() => {
|
|
2912
|
+
if (!setAudioPlayback || !connecting || unlockTriedRef.current === room) return;
|
|
2913
|
+
unlockTriedRef.current = room;
|
|
2914
|
+
void room.startAudio().catch(() => void 0);
|
|
2915
|
+
}, [room, setAudioPlayback, connecting]);
|
|
2916
|
+
const controls = useMemo(() => ({
|
|
2917
|
+
startAudio: async () => {
|
|
2918
|
+
try {
|
|
2919
|
+
await room.startAudio();
|
|
2920
|
+
} catch {
|
|
2921
|
+
}
|
|
2922
|
+
return room.canPlaybackAudio;
|
|
2923
|
+
},
|
|
2924
|
+
setMicrophoneEnabled: async (enabled) => {
|
|
2925
|
+
if (enabled) setEnabling(true);
|
|
2926
|
+
try {
|
|
2927
|
+
await room.localParticipant.setMicrophoneEnabled(enabled);
|
|
2928
|
+
} catch {
|
|
2929
|
+
} finally {
|
|
2930
|
+
if (enabled) setEnabling(false);
|
|
2931
|
+
setVersion((value) => value + 1);
|
|
2932
|
+
}
|
|
2933
|
+
}
|
|
2934
|
+
}), [room]);
|
|
2935
|
+
useEffect(() => {
|
|
2936
|
+
if (!registerMediaControls) return;
|
|
2937
|
+
registerMediaControls(controls);
|
|
2938
|
+
return () => registerMediaControls(null);
|
|
2939
|
+
}, [controls, registerMediaControls]);
|
|
2940
|
+
}
|
|
2941
|
+
|
|
2684
2942
|
// ../client/src/react/session-lifecycle.ts
|
|
2685
2943
|
function phaseForReason(reason) {
|
|
2686
2944
|
switch (reason) {
|
|
@@ -2846,10 +3104,13 @@ function useSessionLifecycle(input) {
|
|
|
2846
3104
|
const reconnectPolicyRef = useRef(reconnectPolicy);
|
|
2847
3105
|
reconnectPolicyRef.current = reconnectPolicy;
|
|
2848
3106
|
const connectWatchdogMs = typeof connectWatchdogSeconds === "number" && Number.isFinite(connectWatchdogSeconds) ? Math.max(0, Math.floor(connectWatchdogSeconds)) * 1e3 : DEFAULT_CONNECT_WATCHDOG_SECONDS * 1e3;
|
|
3107
|
+
const [userEnded, setUserEnded] = useState(false);
|
|
3108
|
+
const userEndedRef = useRef(false);
|
|
3109
|
+
const phaseRef = useRef({ kind: "idle" });
|
|
2849
3110
|
const grantState = useLiveKitAvatarGrant({
|
|
2850
3111
|
client,
|
|
2851
3112
|
session,
|
|
2852
|
-
active,
|
|
3113
|
+
active: active && !userEnded,
|
|
2853
3114
|
autoRetryBusy,
|
|
2854
3115
|
requestOptions
|
|
2855
3116
|
});
|
|
@@ -2866,6 +3127,8 @@ function useSessionLifecycle(input) {
|
|
|
2866
3127
|
refreshRef.current = grantState.refresh;
|
|
2867
3128
|
const releaseRef = useRef(grantState.release);
|
|
2868
3129
|
releaseRef.current = grantState.release;
|
|
3130
|
+
const clearRef = useRef(grantState.clear);
|
|
3131
|
+
clearRef.current = grantState.clear;
|
|
2869
3132
|
const capacityRef = useRef(capacity);
|
|
2870
3133
|
capacityRef.current = capacity;
|
|
2871
3134
|
const refreshBaselineCapacityRef = useRef(null);
|
|
@@ -2892,13 +3155,16 @@ function useSessionLifecycle(input) {
|
|
|
2892
3155
|
refreshBaselineCapacityRef.current = null;
|
|
2893
3156
|
attemptRef.current = 0;
|
|
2894
3157
|
setAttempt(0);
|
|
2895
|
-
setRecovery({ kind: "connected" });
|
|
3158
|
+
setRecovery(userEndedRef.current ? { kind: "ended", reason: "user" } : { kind: "connected" });
|
|
2896
3159
|
setConnected(false);
|
|
2897
3160
|
setAgentPresentState(false);
|
|
2898
3161
|
lastActivityRef.current = Date.now();
|
|
2899
3162
|
}, [clearTimer]);
|
|
2900
3163
|
useEffect(() => {
|
|
2901
|
-
if (
|
|
3164
|
+
if (active) return;
|
|
3165
|
+
userEndedRef.current = false;
|
|
3166
|
+
setUserEnded(false);
|
|
3167
|
+
reset();
|
|
2902
3168
|
}, [active, reset]);
|
|
2903
3169
|
const markActivity = useCallback(() => {
|
|
2904
3170
|
lastActivityRef.current = Date.now();
|
|
@@ -2929,6 +3195,7 @@ function useSessionLifecycle(input) {
|
|
|
2929
3195
|
leaveRoomRef.current?.();
|
|
2930
3196
|
}, [clearTimer]);
|
|
2931
3197
|
const onConnected = useCallback(() => {
|
|
3198
|
+
if (userEndedRef.current) return;
|
|
2932
3199
|
clearTimer();
|
|
2933
3200
|
connectedRef.current = true;
|
|
2934
3201
|
manualReconnectPendingRef.current = false;
|
|
@@ -2941,7 +3208,7 @@ function useSessionLifecycle(input) {
|
|
|
2941
3208
|
}, [clearTimer]);
|
|
2942
3209
|
const onDisconnected = useCallback(
|
|
2943
3210
|
(reason) => {
|
|
2944
|
-
if (!active) return;
|
|
3211
|
+
if (!active || userEndedRef.current) return;
|
|
2945
3212
|
const action = disconnectAction(reason, connectedRef.current);
|
|
2946
3213
|
if (action === "noop") return;
|
|
2947
3214
|
if (action === "reset") {
|
|
@@ -2966,7 +3233,7 @@ function useSessionLifecycle(input) {
|
|
|
2966
3233
|
);
|
|
2967
3234
|
const onConnectionError = useCallback(
|
|
2968
3235
|
(error) => {
|
|
2969
|
-
if (!active || !isRecoverableConnectionError(error)) return;
|
|
3236
|
+
if (!active || userEndedRef.current || !isRecoverableConnectionError(error)) return;
|
|
2970
3237
|
connectedRef.current = false;
|
|
2971
3238
|
setConnected(false);
|
|
2972
3239
|
setAgentPresentState(false);
|
|
@@ -2979,6 +3246,9 @@ function useSessionLifecycle(input) {
|
|
|
2979
3246
|
},
|
|
2980
3247
|
[active]
|
|
2981
3248
|
);
|
|
3249
|
+
useEffect(() => {
|
|
3250
|
+
if (capacity.kind === "error" && recovery.kind === "connected") manualReconnectPendingRef.current = false;
|
|
3251
|
+
}, [capacity, recovery]);
|
|
2982
3252
|
useEffect(() => {
|
|
2983
3253
|
if (recovery.kind !== "refreshing" || capacity.kind !== "error") return;
|
|
2984
3254
|
const baseline = refreshBaselineCapacityRef.current;
|
|
@@ -2988,7 +3258,7 @@ function useSessionLifecycle(input) {
|
|
|
2988
3258
|
}, [capacity, recovery]);
|
|
2989
3259
|
const onConnectionStateChange = useCallback(
|
|
2990
3260
|
(state) => {
|
|
2991
|
-
if (!active) return;
|
|
3261
|
+
if (!active || userEndedRef.current) return;
|
|
2992
3262
|
if (state === "connected") {
|
|
2993
3263
|
onConnected();
|
|
2994
3264
|
return;
|
|
@@ -3036,8 +3306,30 @@ function useSessionLifecycle(input) {
|
|
|
3036
3306
|
}, step2.delayMs);
|
|
3037
3307
|
return clearTimer;
|
|
3038
3308
|
}, [recovery.kind, active, attempt, clearTimer]);
|
|
3309
|
+
const end = useCallback(() => {
|
|
3310
|
+
if (!active || userEndedRef.current || phaseRef.current.kind === "ended") return;
|
|
3311
|
+
userEndedRef.current = true;
|
|
3312
|
+
clearTimer();
|
|
3313
|
+
connectedRef.current = false;
|
|
3314
|
+
manualReconnectPendingRef.current = false;
|
|
3315
|
+
refreshBaselineCapacityRef.current = null;
|
|
3316
|
+
setUserEnded(true);
|
|
3317
|
+
setConnected(false);
|
|
3318
|
+
setAgentPresentState(false);
|
|
3319
|
+
setRecovery({ kind: "ended", reason: "user" });
|
|
3320
|
+
clearRef.current();
|
|
3321
|
+
leaveRoomRef.current?.();
|
|
3322
|
+
}, [active, clearTimer]);
|
|
3039
3323
|
const reconnect = useCallback(() => {
|
|
3040
|
-
if (!active
|
|
3324
|
+
if (!active) return;
|
|
3325
|
+
if (userEndedRef.current) {
|
|
3326
|
+
userEndedRef.current = false;
|
|
3327
|
+
setUserEnded(false);
|
|
3328
|
+
reset();
|
|
3329
|
+
manualReconnectPendingRef.current = true;
|
|
3330
|
+
return;
|
|
3331
|
+
}
|
|
3332
|
+
if (manualReconnectPendingRef.current || recovery.kind === "in-place-reconnecting") return;
|
|
3041
3333
|
manualReconnectPendingRef.current = true;
|
|
3042
3334
|
clearTimer();
|
|
3043
3335
|
attemptRef.current = 0;
|
|
@@ -3046,7 +3338,7 @@ function useSessionLifecycle(input) {
|
|
|
3046
3338
|
releaseRef.current("superseded");
|
|
3047
3339
|
setRecovery({ kind: "refreshing", attempt: 0 });
|
|
3048
3340
|
refreshRef.current();
|
|
3049
|
-
}, [active, clearTimer, recovery.kind]);
|
|
3341
|
+
}, [active, clearTimer, recovery.kind, reset]);
|
|
3050
3342
|
const endIdleRef = useRef(endIdle);
|
|
3051
3343
|
endIdleRef.current = endIdle;
|
|
3052
3344
|
useEffect(() => {
|
|
@@ -3078,6 +3370,7 @@ function useSessionLifecycle(input) {
|
|
|
3078
3370
|
() => lifecyclePhaseFrom({ capacity, recovery, idle, connected, agentPresent }),
|
|
3079
3371
|
[capacity, recovery, idle, connected, agentPresent]
|
|
3080
3372
|
);
|
|
3373
|
+
phaseRef.current = phase;
|
|
3081
3374
|
const onBehaviorChangeRef = useRef(onBehaviorChange);
|
|
3082
3375
|
onBehaviorChangeRef.current = onBehaviorChange;
|
|
3083
3376
|
const lastBehaviorRef = useRef(null);
|
|
@@ -3103,6 +3396,7 @@ function useSessionLifecycle(input) {
|
|
|
3103
3396
|
stayConnected,
|
|
3104
3397
|
markActivity,
|
|
3105
3398
|
reconnect,
|
|
3399
|
+
end,
|
|
3106
3400
|
onConnected,
|
|
3107
3401
|
onDisconnected,
|
|
3108
3402
|
onConnectionError,
|
|
@@ -3121,6 +3415,7 @@ function useSessionLifecycle(input) {
|
|
|
3121
3415
|
stayConnected,
|
|
3122
3416
|
markActivity,
|
|
3123
3417
|
reconnect,
|
|
3418
|
+
end,
|
|
3124
3419
|
onConnected,
|
|
3125
3420
|
onDisconnected,
|
|
3126
3421
|
onConnectionError,
|
|
@@ -3135,7 +3430,8 @@ function useSessionLifecycle(input) {
|
|
|
3135
3430
|
function SessionLifecycleRoomBridge({
|
|
3136
3431
|
lifecycle,
|
|
3137
3432
|
onConnectionDetailsChange,
|
|
3138
|
-
onMediaModeChange
|
|
3433
|
+
onMediaModeChange,
|
|
3434
|
+
microphone
|
|
3139
3435
|
}) {
|
|
3140
3436
|
const {
|
|
3141
3437
|
onConnectionStateChange,
|
|
@@ -3146,12 +3442,23 @@ function SessionLifecycleRoomBridge({
|
|
|
3146
3442
|
registerDataPublisher,
|
|
3147
3443
|
registerTurnSender,
|
|
3148
3444
|
setTurnState,
|
|
3149
|
-
setMedia
|
|
3445
|
+
setMedia,
|
|
3446
|
+
setMicrophoneFacts,
|
|
3447
|
+
setAudioPlayback,
|
|
3448
|
+
registerMediaControls
|
|
3150
3449
|
} = lifecycle;
|
|
3151
3450
|
const connectionState = useConnectionState();
|
|
3152
3451
|
const assistant = useVoiceAssistant();
|
|
3153
3452
|
const transcriptions = useTranscriptions();
|
|
3154
3453
|
const room = useRoomContext();
|
|
3454
|
+
useCallMedia(room, {
|
|
3455
|
+
setMicrophoneFacts,
|
|
3456
|
+
setAudioPlayback,
|
|
3457
|
+
registerMediaControls,
|
|
3458
|
+
microphoneWanted: microphone ?? publishesMicrophoneByDefault(lifecycle.grant),
|
|
3459
|
+
connectionState,
|
|
3460
|
+
callKey: lifecycle.grant?.session_id
|
|
3461
|
+
});
|
|
3155
3462
|
const mediaModeCallbackRef = useRef(onMediaModeChange);
|
|
3156
3463
|
mediaModeCallbackRef.current = onMediaModeChange;
|
|
3157
3464
|
const mediaModeEnabled = onMediaModeChange !== void 0 && lifecycle.grant !== null;
|
|
@@ -3425,6 +3732,8 @@ function resolveEndReason(workerLabel, innerReason) {
|
|
|
3425
3732
|
return "idle";
|
|
3426
3733
|
case "error":
|
|
3427
3734
|
return "failed";
|
|
3735
|
+
case "user":
|
|
3736
|
+
return "user_ended";
|
|
3428
3737
|
default:
|
|
3429
3738
|
return "disconnected";
|
|
3430
3739
|
}
|
|
@@ -3480,6 +3789,16 @@ function useRealtimeSession(input) {
|
|
|
3480
3789
|
const [turn, setTurn] = useState("quiet");
|
|
3481
3790
|
const [media, setMediaState] = useState({ video: "connecting", audio: "silent" });
|
|
3482
3791
|
const [behavior, setBehavior] = useState(null);
|
|
3792
|
+
const [microphoneFacts2, setMicrophoneFactsState] = useState({
|
|
3793
|
+
wanted: false,
|
|
3794
|
+
connected: false,
|
|
3795
|
+
enabling: false,
|
|
3796
|
+
publication: null,
|
|
3797
|
+
deviceError: null
|
|
3798
|
+
});
|
|
3799
|
+
const [microphoneMuted, setMicrophoneMuted] = useState(false);
|
|
3800
|
+
const [audioPlayback, setAudioPlaybackState] = useState("unknown");
|
|
3801
|
+
const mediaControlsRef = useRef(null);
|
|
3483
3802
|
const lastBehaviorRef = useRef(null);
|
|
3484
3803
|
const lastLabeledEndReasonRef = useRef(null);
|
|
3485
3804
|
const pendingClipsRef = useRef(/* @__PURE__ */ new Map());
|
|
@@ -3586,6 +3905,31 @@ function useRealtimeSession(input) {
|
|
|
3586
3905
|
const setMedia = useCallback((next) => {
|
|
3587
3906
|
setMediaState((prev) => prev.video === next.video && prev.audio === next.audio ? prev : next);
|
|
3588
3907
|
}, []);
|
|
3908
|
+
const microphoneFactsRef = useRef(microphoneFacts2);
|
|
3909
|
+
microphoneFactsRef.current = microphoneFacts2;
|
|
3910
|
+
const setMicrophoneFacts = useCallback((facts) => setMicrophoneFactsState(facts), []);
|
|
3911
|
+
const setAudioPlayback = useCallback((playback) => setAudioPlaybackState(playback), []);
|
|
3912
|
+
const registerMediaControls = useCallback((controls) => {
|
|
3913
|
+
mediaControlsRef.current = controls;
|
|
3914
|
+
}, []);
|
|
3915
|
+
const startAudio = useCallback(async () => {
|
|
3916
|
+
const controls = mediaControlsRef.current;
|
|
3917
|
+
return controls ? controls.startAudio() : false;
|
|
3918
|
+
}, []);
|
|
3919
|
+
const callEnded = lifecycle.phase.kind === "ended";
|
|
3920
|
+
const callEndedRef = useRef(callEnded);
|
|
3921
|
+
callEndedRef.current = callEnded;
|
|
3922
|
+
const setMicrophoneEnabled = useCallback(async (enabled) => {
|
|
3923
|
+
if (callEndedRef.current) return;
|
|
3924
|
+
setMicrophoneMuted(!enabled);
|
|
3925
|
+
if (!microphoneFactsRef.current.connected) return;
|
|
3926
|
+
await mediaControlsRef.current?.setMicrophoneEnabled(enabled);
|
|
3927
|
+
}, []);
|
|
3928
|
+
const microphone = useMemo(
|
|
3929
|
+
() => callEnded ? { status: "off" } : callMicrophoneFrom(microphoneFacts2, { muted: microphoneMuted }),
|
|
3930
|
+
[callEnded, microphoneFacts2, microphoneMuted]
|
|
3931
|
+
);
|
|
3932
|
+
const callAudioPlayback = callEnded ? "unknown" : audioPlayback;
|
|
3589
3933
|
useEffect(() => {
|
|
3590
3934
|
if (phaseKind === "idle" || phaseKind === "ended") return;
|
|
3591
3935
|
const timer = window.setInterval(() => {
|
|
@@ -3730,11 +4074,23 @@ function useRealtimeSession(input) {
|
|
|
3730
4074
|
void sendResolvedTurn(last.text, last.attributes, last.id).catch(() => void 0);
|
|
3731
4075
|
}, [sendResolvedTurn]);
|
|
3732
4076
|
const end = useCallback((reason) => {
|
|
4077
|
+
if (phaseKind === "ended") return;
|
|
3733
4078
|
if (reason) lastLabeledEndReasonRef.current = reason;
|
|
3734
4079
|
lastTurnRef.current = null;
|
|
3735
4080
|
requestGracefulClose();
|
|
3736
|
-
lifecycle.
|
|
3737
|
-
}, [lifecycle, requestGracefulClose]);
|
|
4081
|
+
lifecycle.end();
|
|
4082
|
+
}, [lifecycle, phaseKind, requestGracefulClose]);
|
|
4083
|
+
const active = inner.active ?? true;
|
|
4084
|
+
useEffect(() => {
|
|
4085
|
+
if (active) return;
|
|
4086
|
+
lastLabeledEndReasonRef.current = null;
|
|
4087
|
+
setMicrophoneMuted(false);
|
|
4088
|
+
}, [active]);
|
|
4089
|
+
const innerReconnect = lifecycle.reconnect;
|
|
4090
|
+
const reconnect = useCallback(() => {
|
|
4091
|
+
if (phaseKind === "ended") lastLabeledEndReasonRef.current = null;
|
|
4092
|
+
innerReconnect();
|
|
4093
|
+
}, [innerReconnect, phaseKind]);
|
|
3738
4094
|
const performAction = useCallback(
|
|
3739
4095
|
(actionId, opts) => {
|
|
3740
4096
|
const requestId = newTurnId();
|
|
@@ -3780,6 +4136,7 @@ function useRealtimeSession(input) {
|
|
|
3780
4136
|
approachingFiredRef.current = false;
|
|
3781
4137
|
creditsLowFiredRef.current = false;
|
|
3782
4138
|
lastTurnRef.current = null;
|
|
4139
|
+
setMicrophoneMuted(false);
|
|
3783
4140
|
lifecycle.reset();
|
|
3784
4141
|
}, [lifecycle]);
|
|
3785
4142
|
return useMemo(
|
|
@@ -3793,6 +4150,12 @@ function useRealtimeSession(input) {
|
|
|
3793
4150
|
endsAt,
|
|
3794
4151
|
graceWindow,
|
|
3795
4152
|
media,
|
|
4153
|
+
microphone,
|
|
4154
|
+
audioPlayback: callAudioPlayback,
|
|
4155
|
+
startAudio,
|
|
4156
|
+
setMicrophoneEnabled,
|
|
4157
|
+
microphoneMuted,
|
|
4158
|
+
reconnect,
|
|
3796
4159
|
lifecycle,
|
|
3797
4160
|
sendClosingTurn,
|
|
3798
4161
|
requestGracefulClose,
|
|
@@ -3808,6 +4171,9 @@ function useRealtimeSession(input) {
|
|
|
3808
4171
|
registerTurnSender,
|
|
3809
4172
|
setTurnState,
|
|
3810
4173
|
setMedia,
|
|
4174
|
+
setMicrophoneFacts,
|
|
4175
|
+
setAudioPlayback,
|
|
4176
|
+
registerMediaControls,
|
|
3811
4177
|
reset
|
|
3812
4178
|
}),
|
|
3813
4179
|
[
|
|
@@ -3817,6 +4183,12 @@ function useRealtimeSession(input) {
|
|
|
3817
4183
|
endsAt,
|
|
3818
4184
|
graceWindow,
|
|
3819
4185
|
media,
|
|
4186
|
+
microphone,
|
|
4187
|
+
callAudioPlayback,
|
|
4188
|
+
startAudio,
|
|
4189
|
+
setMicrophoneEnabled,
|
|
4190
|
+
microphoneMuted,
|
|
4191
|
+
reconnect,
|
|
3820
4192
|
sendClosingTurn,
|
|
3821
4193
|
requestGracefulClose,
|
|
3822
4194
|
extend,
|
|
@@ -3831,6 +4203,9 @@ function useRealtimeSession(input) {
|
|
|
3831
4203
|
registerTurnSender,
|
|
3832
4204
|
setTurnState,
|
|
3833
4205
|
setMedia,
|
|
4206
|
+
setMicrophoneFacts,
|
|
4207
|
+
setAudioPlayback,
|
|
4208
|
+
registerMediaControls,
|
|
3834
4209
|
reset
|
|
3835
4210
|
]
|
|
3836
4211
|
);
|
|
@@ -3863,9 +4238,31 @@ function handleFor(session) {
|
|
|
3863
4238
|
session.sendClosingTurn(text);
|
|
3864
4239
|
},
|
|
3865
4240
|
keepAlive: session.stayConnected,
|
|
3866
|
-
end: () => session.end("user_ended")
|
|
4241
|
+
end: () => session.end("user_ended"),
|
|
4242
|
+
microphone: session.microphone,
|
|
4243
|
+
audio: session.audioPlayback,
|
|
4244
|
+
startAudio: session.startAudio,
|
|
4245
|
+
setMicrophoneEnabled: session.setMicrophoneEnabled,
|
|
4246
|
+
retryMicrophone: () => session.setMicrophoneEnabled(true)
|
|
3867
4247
|
};
|
|
3868
4248
|
}
|
|
4249
|
+
var UNLOCK_REGION = {
|
|
4250
|
+
position: "absolute",
|
|
4251
|
+
top: 16,
|
|
4252
|
+
left: "50%",
|
|
4253
|
+
transform: "translateX(-50%)",
|
|
4254
|
+
zIndex: 1
|
|
4255
|
+
};
|
|
4256
|
+
var UNLOCK_BUTTON = {
|
|
4257
|
+
padding: "10px 16px",
|
|
4258
|
+
border: "none",
|
|
4259
|
+
borderRadius: 9999,
|
|
4260
|
+
background: "rgb(0 0 0 / 0.7)",
|
|
4261
|
+
color: "white",
|
|
4262
|
+
font: "inherit",
|
|
4263
|
+
fontSize: 14,
|
|
4264
|
+
cursor: "pointer"
|
|
4265
|
+
};
|
|
3869
4266
|
function useAvatarCall(props) {
|
|
3870
4267
|
const sessionRef = useRef(null);
|
|
3871
4268
|
const session = useRealtimeSession({
|
|
@@ -3889,11 +4286,36 @@ function useAvatarCall(props) {
|
|
|
3889
4286
|
lastStatusRef.current = call.status;
|
|
3890
4287
|
onStatusChange?.(call.status);
|
|
3891
4288
|
}, [call.status, onStatusChange]);
|
|
4289
|
+
const microphone = call.microphone;
|
|
4290
|
+
const problemKey = microphone.status === "blocked" || microphone.status === "unavailable" ? `${microphone.status}:${microphone.reason}:${microphone.message}` : null;
|
|
4291
|
+
const lastProblemRef = useRef(null);
|
|
4292
|
+
const onMicrophoneProblemRef = useRef(props.onMicrophoneProblem);
|
|
4293
|
+
onMicrophoneProblemRef.current = props.onMicrophoneProblem;
|
|
4294
|
+
useEffect(() => {
|
|
4295
|
+
if (problemKey === lastProblemRef.current) return;
|
|
4296
|
+
lastProblemRef.current = problemKey;
|
|
4297
|
+
if (microphone.status === "blocked" || microphone.status === "unavailable") onMicrophoneProblemRef.current?.(microphone);
|
|
4298
|
+
}, [problemKey]);
|
|
4299
|
+
const prompt = props.audioUnlockPrompt ?? true;
|
|
4300
|
+
const unlock = prompt === false ? null : createElement(
|
|
4301
|
+
"div",
|
|
4302
|
+
{ key: "audio-unlock-region", role: "status", "aria-live": "polite", style: UNLOCK_REGION, "data-testid": "avatar-audio-unlock-region" },
|
|
4303
|
+
call.audio !== "blocked" ? null : typeof prompt === "function" ? prompt(call) : createElement(
|
|
4304
|
+
"button",
|
|
4305
|
+
{ type: "button", style: UNLOCK_BUTTON, onClick: () => {
|
|
4306
|
+
void call.startAudio();
|
|
4307
|
+
}, "data-testid": "avatar-audio-unlock" },
|
|
4308
|
+
"Tap to turn on sound"
|
|
4309
|
+
)
|
|
4310
|
+
);
|
|
4311
|
+
const overlay = props.children ? props.children(call) : null;
|
|
3892
4312
|
const view = createElement(
|
|
3893
4313
|
RealtimeAvatarLiveKitRoom,
|
|
3894
4314
|
{
|
|
3895
4315
|
grant: session.grant,
|
|
3896
|
-
|
|
4316
|
+
// LiveKit captures the microphone on connect from this value, so a mute chosen while
|
|
4317
|
+
// waiting is honoured rather than overridden when the call goes live.
|
|
4318
|
+
audio: props.listen !== false && !session.microphoneMuted,
|
|
3897
4319
|
onConnected: session.onConnected,
|
|
3898
4320
|
onDisconnected: session.onDisconnected,
|
|
3899
4321
|
onError: session.onConnectionError
|
|
@@ -3901,6 +4323,7 @@ function useAvatarCall(props) {
|
|
|
3901
4323
|
createElement(SessionLifecycleRoomBridge, {
|
|
3902
4324
|
key: "bridge",
|
|
3903
4325
|
lifecycle: session,
|
|
4326
|
+
microphone: props.listen !== false,
|
|
3904
4327
|
onConnectionDetailsChange: props.onConnectionDetailsChange
|
|
3905
4328
|
}),
|
|
3906
4329
|
createElement(
|
|
@@ -3910,9 +4333,10 @@ function useAvatarCall(props) {
|
|
|
3910
4333
|
idleVideoUrl: props.idleVideoUrl ?? null,
|
|
3911
4334
|
poster: props.poster ?? null,
|
|
3912
4335
|
fit: props.fit ?? "cover",
|
|
3913
|
-
className: props.className
|
|
4336
|
+
className: props.className,
|
|
4337
|
+
style: props.style
|
|
3914
4338
|
},
|
|
3915
|
-
|
|
4339
|
+
overlay === null && unlock === null ? null : createElement(Fragment, null, overlay, unlock)
|
|
3916
4340
|
)
|
|
3917
4341
|
);
|
|
3918
4342
|
return { call, view };
|
|
@@ -3931,6 +4355,8 @@ function createProxyClient(options) {
|
|
|
3931
4355
|
const base = normalize(options.proxyUrl);
|
|
3932
4356
|
const doFetch = options.fetch ?? globalThis.fetch?.bind(globalThis);
|
|
3933
4357
|
const timeoutMs = options.timeoutMs ?? PROXY_CLIENT_TIMEOUT_MS;
|
|
4358
|
+
const credentials = options.credentials;
|
|
4359
|
+
const withCredentials = credentials === void 0 ? {} : { credentials };
|
|
3934
4360
|
const post = async (path, body, request) => {
|
|
3935
4361
|
if (!doFetch) throw new Error("realtime-avatar: no fetch available \u2014 pass one via `fetch`.");
|
|
3936
4362
|
const caller = request?.signal;
|
|
@@ -3938,6 +4364,7 @@ function createProxyClient(options) {
|
|
|
3938
4364
|
try {
|
|
3939
4365
|
return await doFetch(`${base}${path}`, {
|
|
3940
4366
|
method: "POST",
|
|
4367
|
+
...withCredentials,
|
|
3941
4368
|
headers: { "content-type": "application/json", ...request?.headers ?? {} },
|
|
3942
4369
|
body: JSON.stringify(body),
|
|
3943
4370
|
signal: timer && caller ? AbortSignal.any([caller, timer]) : timer ?? caller
|
|
@@ -3947,18 +4374,38 @@ function createProxyClient(options) {
|
|
|
3947
4374
|
throw cause;
|
|
3948
4375
|
}
|
|
3949
4376
|
};
|
|
3950
|
-
const
|
|
4377
|
+
const pageHide = (body) => {
|
|
4378
|
+
const page = globalThis.location?.href;
|
|
4379
|
+
let target;
|
|
4380
|
+
try {
|
|
4381
|
+
target = new URL(`${base}/end`, page);
|
|
4382
|
+
} catch {
|
|
4383
|
+
return false;
|
|
4384
|
+
}
|
|
4385
|
+
const payload = JSON.stringify(body);
|
|
3951
4386
|
const send = globalThis.navigator?.sendBeacon?.bind(globalThis.navigator);
|
|
3952
|
-
|
|
3953
|
-
|
|
4387
|
+
const sameOrigin = page !== void 0 && target.origin === new URL(page).origin;
|
|
4388
|
+
if (send && (sameOrigin || credentials === "include")) {
|
|
4389
|
+
if (send(target.href, new Blob([payload], { type: "application/json" }))) return true;
|
|
4390
|
+
}
|
|
4391
|
+
if (!doFetch) return false;
|
|
4392
|
+
try {
|
|
4393
|
+
void doFetch(target.href, {
|
|
4394
|
+
method: "POST",
|
|
4395
|
+
keepalive: true,
|
|
4396
|
+
...withCredentials,
|
|
4397
|
+
headers: { "content-type": "application/json" },
|
|
4398
|
+
body: payload
|
|
4399
|
+
}).catch(() => void 0);
|
|
4400
|
+
return true;
|
|
4401
|
+
} catch {
|
|
4402
|
+
return false;
|
|
4403
|
+
}
|
|
3954
4404
|
};
|
|
3955
4405
|
return {
|
|
3956
4406
|
async createLiveKitSessionOrBusy(input, requestOptions) {
|
|
3957
|
-
const
|
|
3958
|
-
|
|
3959
|
-
{ avatarId: input.avatarId, mode: input.mode },
|
|
3960
|
-
requestOptions
|
|
3961
|
-
);
|
|
4407
|
+
const connect = { avatarId: input.avatarId, mode: input.mode };
|
|
4408
|
+
const response = await post("/connect", connect, requestOptions);
|
|
3962
4409
|
if (response.status === 429) {
|
|
3963
4410
|
const value = await response.clone().json().catch(() => null);
|
|
3964
4411
|
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
@@ -3976,7 +4423,8 @@ function createProxyClient(options) {
|
|
|
3976
4423
|
async releaseLiveKitSession(sessionId, reason, requestOptions) {
|
|
3977
4424
|
if (!sessionId) return false;
|
|
3978
4425
|
try {
|
|
3979
|
-
const
|
|
4426
|
+
const body = { session_id: sessionId, reason };
|
|
4427
|
+
const response = await post("/end", body, requestOptions);
|
|
3980
4428
|
return response.ok;
|
|
3981
4429
|
} catch {
|
|
3982
4430
|
return false;
|
|
@@ -3984,12 +4432,13 @@ function createProxyClient(options) {
|
|
|
3984
4432
|
},
|
|
3985
4433
|
releaseLiveKitSessionBeacon(sessionId, reason) {
|
|
3986
4434
|
if (!sessionId) return false;
|
|
3987
|
-
return
|
|
4435
|
+
return pageHide({ session_id: sessionId, reason: reason ?? "page_hide" });
|
|
3988
4436
|
},
|
|
3989
4437
|
async releaseLiveKitQueueTicket(queueTicketId, reason, requestOptions) {
|
|
3990
4438
|
if (!queueTicketId) return false;
|
|
3991
4439
|
try {
|
|
3992
|
-
const
|
|
4440
|
+
const body = { queue_ticket_id: queueTicketId, reason };
|
|
4441
|
+
const response = await post("/end", body, requestOptions);
|
|
3993
4442
|
return response.ok;
|
|
3994
4443
|
} catch {
|
|
3995
4444
|
return false;
|
|
@@ -3997,7 +4446,7 @@ function createProxyClient(options) {
|
|
|
3997
4446
|
},
|
|
3998
4447
|
releaseLiveKitQueueTicketBeacon(queueTicketId, reason) {
|
|
3999
4448
|
if (!queueTicketId) return false;
|
|
4000
|
-
return
|
|
4449
|
+
return pageHide({ queue_ticket_id: queueTicketId, reason: reason ?? "page_hide" });
|
|
4001
4450
|
}
|
|
4002
4451
|
};
|
|
4003
4452
|
}
|