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/dist/react.js CHANGED
@@ -1,7 +1,7 @@
1
1
  "use client";
2
- import { useRef, useState, useEffect, useCallback, useMemo, createElement, Fragment } from 'react';
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?.code;
32
- if (code === "UNSUPPORTED_METHOD" || code === "RECIPIENT_NOT_FOUND" || code === 1401 || code === 1402) {
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 Error(
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)) throw 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 Error(
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
- setState({ status: "registering", registered: [], rejected: [] });
180
- void attachAvatarTools(room, tools, {
181
- signal: abort.signal,
182
- onResult: (event) => {
183
- if (!abort.signal.aborted) callback.current?.(event);
184
- }
185
- }).then((result) => {
186
- if (abort.signal.aborted) {
187
- result.dispose();
188
- return;
189
- }
190
- setState({ status: "ready", registered: result.accepted, rejected: result.rejected });
191
- }).catch((error) => {
192
- if (!abort.signal.aborted) setState({
193
- status: "error",
194
- registered: [],
195
- rejected: [],
196
- error: error instanceof Error ? error.message : "Tool registration failed"
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
- return () => abort.abort();
200
- }, [room, tools, connection]);
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?.stt_mode === "server",
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 fitClass = fit === "cover" ? AVATAR_VIDEO_FIT_COVER : AVATAR_VIDEO_FIT_CONTAIN;
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 backFitClass = fitClass;
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
- className: joinClass(AVATAR_VIDEO_LAYER, backFitClass),
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
- className: joinClass(AVATAR_VIDEO_LAYER, backFitClass),
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
- transitionDuration: "0ms"
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
- className: joinClass(AVATAR_VIDEO_LAYER, fitClass),
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", className: AVATAR_VIDEO_BADGE },
1812
- createElement("span", { key: "dot", className: AVATAR_VIDEO_BADGE_DOT, "aria-hidden": true }),
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: joinClass(AVATAR_VIDEO_BOX, 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
- children
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
- function joinClass(...parts) {
2142
- return parts.filter(Boolean).join(" ");
2143
- }
2144
- var AVATAR_VIDEO_BOX = "relative size-full overflow-hidden";
2145
- var AVATAR_VIDEO_LAYER = "absolute inset-0 size-full transform-gpu [image-rendering:auto] [object-position:center_22%]";
2146
- var AVATAR_VIDEO_FIT_CONTAIN = "object-contain";
2147
- var AVATAR_VIDEO_FIT_COVER = "object-cover";
2148
- var AVATAR_VIDEO_LIVE_WRAP = "absolute inset-0 z-20 size-full transition-opacity ease-out";
2149
- var AVATAR_VIDEO_BADGE = "absolute top-2 right-2 z-30 inline-flex items-center gap-1.5 rounded-md bg-black/55 px-1.5 py-0.5 font-mono text-[10px] text-white/85 backdrop-blur-sm";
2150
- var AVATAR_VIDEO_BADGE_DOT = "size-1.5 rounded-full bg-emerald-400";
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 (!active) reset();
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 || manualReconnectPendingRef.current || recovery.kind === "in-place-reconnecting") return;
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.reset();
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
- audio: props.listen !== false,
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
- props.children ? props.children(call) : null
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 beacon = (path, body) => {
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
- if (!send) return false;
3953
- return send(`${base}${path}`, new Blob([JSON.stringify(body)], { type: "application/json" }));
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 response = await post(
3958
- "/connect",
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 response = await post("/end", { session_id: sessionId, reason }, requestOptions);
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 beacon("/end", { session_id: sessionId, reason: reason ?? "page_hide" });
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 response = await post("/end", { queue_ticket_id: queueTicketId, reason }, requestOptions);
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 beacon("/end", { queue_ticket_id: queueTicketId, reason: reason ?? "page_hide" });
4449
+ return pageHide({ queue_ticket_id: queueTicketId, reason: reason ?? "page_hide" });
4001
4450
  }
4002
4451
  };
4003
4452
  }