realtime-avatar 0.24.1 → 0.26.0

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.
@@ -1,9 +1,9 @@
1
1
  import { AudioSession, LiveKitRoom, VideoTrack } from '@livekit/react-native';
2
2
  export { AudioSession, VideoTrack, registerGlobals } from '@livekit/react-native';
3
3
  import { useState, useRef, useEffect, useCallback, createElement, useMemo } from 'react';
4
- import { useLocalParticipant, useConnectionState, useTranscriptions, useMaybeRoomContext, useVoiceAssistant, isTrackReference, useRoomContext, useChat } from '@livekit/components-react';
4
+ import { useLocalParticipant, useConnectionState, useTranscriptions, useMaybeRoomContext, useVoiceAssistant, isTrackReference, useRoomContext, useChat, useAudioPlayback } from '@livekit/components-react';
5
5
  export { useChat, useConnectionState, useLocalParticipant, useRoomContext, useTrackToggle, useTranscriptions, useVoiceAssistant } from '@livekit/components-react';
6
- import { ConnectionState, isRemoteTrack, VideoQuality, ConnectionQuality, RoomEvent, TrackEvent, Track, ConnectionError, ConnectionErrorReason, DisconnectReason, createLocalVideoTrack, Room } from 'livekit-client';
6
+ import { ConnectionState, isRemoteTrack, VideoQuality, ConnectionQuality, RoomEvent, TrackEvent, Track, ConnectionError, ConnectionErrorReason, ParticipantEvent, DisconnectReason, createLocalVideoTrack, Room } from 'livekit-client';
7
7
  export { DisconnectReason, Room, RoomEvent, Track } from 'livekit-client';
8
8
  import { Platform, StyleSheet, Animated, Image, View, Text } from 'react-native';
9
9
  import { z } from 'zod';
@@ -414,7 +414,13 @@ function normalizeError(input, overrides) {
414
414
  };
415
415
  }
416
416
 
417
+ // ../http-client/src/errors.ts
418
+ function isRequestId(value) {
419
+ return typeof value === "string" && /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(value);
420
+ }
421
+
417
422
  // ../client/src/errors.ts
423
+ var TIMEOUT_CODE = "upstream_timeout";
418
424
  var RealtimeAvatarApiError = class _RealtimeAvatarApiError extends Error {
419
425
  constructor(message, status, body, response, metadata = {}) {
420
426
  super(message);
@@ -426,6 +432,7 @@ var RealtimeAvatarApiError = class _RealtimeAvatarApiError extends Error {
426
432
  this.retryable = metadata.retryable;
427
433
  this.billingUrl = metadata.billingUrl;
428
434
  this.rawMessage = metadata.rawMessage ?? null;
435
+ this.requestId = metadata.requestId;
429
436
  }
430
437
  status;
431
438
  body;
@@ -435,6 +442,8 @@ var RealtimeAvatarApiError = class _RealtimeAvatarApiError extends Error {
435
442
  billingUrl;
436
443
  /** Debug detail returned by the API. Do not render directly in user-facing UI. */
437
444
  rawMessage;
445
+ /** The platform's correlation ID for this attempt. Quote it to support. */
446
+ requestId;
438
447
  get isBillingRequired() {
439
448
  return this.status === 402 || this.code === "insufficient_credits" || this.code === "spend_limit_exceeded";
440
449
  }
@@ -443,14 +452,31 @@ var RealtimeAvatarApiError = class _RealtimeAvatarApiError extends Error {
443
452
  const body = contentType.includes("application/json") ? await response.clone().json().catch(() => null) : await response.clone().text().catch(() => "");
444
453
  const rawMessage = (extractErrorMessage(body) ?? response.statusText) || null;
445
454
  const metadata = extractErrorMetadata(body, response.status, rawMessage);
455
+ const bodyRequestId = readRecord(body)?.requestId;
456
+ const headerRequestId = response.headers.get("x-request-id");
446
457
  return new _RealtimeAvatarApiError(
447
458
  metadata.message,
448
459
  response.status,
449
460
  body,
450
461
  response,
451
- metadata
462
+ {
463
+ ...metadata,
464
+ requestId: isRequestId(bodyRequestId) ? bodyRequestId : isRequestId(headerRequestId) ? headerRequestId : void 0
465
+ }
452
466
  );
453
467
  }
468
+ /**
469
+ * A request that got no answer before its deadline. The status is the one the vocabulary pairs
470
+ * with the code, so `normalizeRealtimeAvatarError` reads it back as the same failure.
471
+ */
472
+ static timeout(timeoutMs) {
473
+ const { message, retryable, statuses: [status] } = ERROR_SEMANTICS[TIMEOUT_CODE];
474
+ return new _RealtimeAvatarApiError(message, status, null, null, {
475
+ code: TIMEOUT_CODE,
476
+ retryable,
477
+ rawMessage: `No response within ${timeoutMs}ms.`
478
+ });
479
+ }
454
480
  };
455
481
  var RealtimeAvatarCapacityError = class extends Error {
456
482
  constructor(message, busy) {
@@ -672,6 +698,11 @@ function useLiveKitAvatarGrant(input) {
672
698
  });
673
699
  const stateRef = useRef(state);
674
700
  stateRef.current = state;
701
+ const wantedRef = useRef(active);
702
+ wantedRef.current = active;
703
+ useEffect(() => () => {
704
+ wantedRef.current = false;
705
+ }, []);
675
706
  const [tabVisible, setTabVisible] = useState(
676
707
  () => typeof document === "undefined" || document.visibilityState === "visible"
677
708
  );
@@ -712,6 +743,8 @@ function useLiveKitAvatarGrant(input) {
712
743
  if (cancelled) {
713
744
  if (result.status !== "busy") {
714
745
  void client.releaseLiveKitSession(result.grant.session_id, "superseded");
746
+ } else if (!wantedRef.current && result.busy.queue_ticket_id) {
747
+ void client.releaseLiveKitQueueTicket(result.busy.queue_ticket_id, "unmount");
715
748
  }
716
749
  return;
717
750
  }
@@ -795,6 +828,9 @@ function sameCapacityState(a, b) {
795
828
  return true;
796
829
  }
797
830
  }
831
+ function publishesMicrophoneByDefault(grant) {
832
+ return grant?.stt_mode === "server";
833
+ }
798
834
  var capacityErrorFromBusy = (busy) => {
799
835
  return new RealtimeAvatarCapacityError(busy.message, busy);
800
836
  };
@@ -2120,6 +2156,169 @@ function createConnectionHistoryCollector(input) {
2120
2156
  return { enqueue, flush, dispose };
2121
2157
  }
2122
2158
 
2159
+ // ../browser/src/microphone.ts
2160
+ var HINTS = {
2161
+ "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.",
2162
+ "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.",
2163
+ "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.",
2164
+ "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.",
2165
+ "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.",
2166
+ "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.",
2167
+ "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.",
2168
+ unknown: "Try again, and if it persists include the message above in your report."
2169
+ };
2170
+ function isOperatingSystemDenial(message) {
2171
+ return /\bby system\b|system permission/i.test(message);
2172
+ }
2173
+ function classify(error) {
2174
+ if (error instanceof TypeError && /mediaDevices|undefined/i.test(error.message)) {
2175
+ return { reason: "insecure-origin", message: error.message };
2176
+ }
2177
+ const name = typeof error === "object" && error !== null && "name" in error ? String(error.name) : "";
2178
+ const message = typeof error === "object" && error !== null && "message" in error && error.message ? String(error.message) : String(error);
2179
+ switch (name) {
2180
+ case "NotAllowedError":
2181
+ case "PermissionDeniedError":
2182
+ return {
2183
+ reason: isOperatingSystemDenial(message) ? "denied-by-os" : "denied-by-browser",
2184
+ message
2185
+ };
2186
+ case "NotFoundError":
2187
+ case "DevicesNotFoundError":
2188
+ case "OverconstrainedError":
2189
+ case "ConstraintNotSatisfiedError":
2190
+ return { reason: "no-device", message };
2191
+ case "NotReadableError":
2192
+ case "TrackStartError":
2193
+ case "AbortError":
2194
+ return { reason: "device-in-use", message };
2195
+ case "SecurityError":
2196
+ return { reason: "insecure-origin", message };
2197
+ default:
2198
+ return { reason: "unknown", message };
2199
+ }
2200
+ }
2201
+ function describeMicrophoneFailure(error) {
2202
+ const { reason, message } = classify(error);
2203
+ return { reason, message, hint: HINTS[reason] };
2204
+ }
2205
+ function microphoneLost(message) {
2206
+ return { reason: "device-lost", message, hint: HINTS["device-lost"] };
2207
+ }
2208
+
2209
+ // ../client/src/react/call-media.ts
2210
+ var DEVICE_LOST_MESSAGE = "The microphone track ended during the call.";
2211
+ function callMicrophoneFrom(facts, intent) {
2212
+ if (!facts.wanted) return { status: "off" };
2213
+ const { publication } = facts;
2214
+ if (publication) {
2215
+ if (publication.ended) {
2216
+ return publication.muted ? { status: "unavailable", ...microphoneLost(DEVICE_LOST_MESSAGE) } : { status: "pending" };
2217
+ }
2218
+ return publication.muted ? { status: "muted" } : { status: "on" };
2219
+ }
2220
+ if (intent.muted) return { status: "muted" };
2221
+ if (facts.enabling) return { status: "pending" };
2222
+ const error = facts.deviceError;
2223
+ if (error) {
2224
+ const problem = describeMicrophoneFailure(error);
2225
+ const blocked = problem.reason === "denied-by-browser" || problem.reason === "denied-by-os" || problem.reason === "insecure-origin";
2226
+ return blocked ? { status: "blocked", ...problem } : { status: "unavailable", ...problem };
2227
+ }
2228
+ return facts.connected ? { status: "pending" } : { status: "off" };
2229
+ }
2230
+ function microphoneFacts(room, wanted, connected, enabling, staleError) {
2231
+ const publication = room.localParticipant.getTrackPublication(Track.Source.Microphone);
2232
+ const track = publication?.track;
2233
+ return {
2234
+ wanted,
2235
+ connected,
2236
+ enabling,
2237
+ publication: track ? { muted: publication.isMuted, ended: track.mediaStreamTrack?.readyState === "ended" } : null,
2238
+ deviceError: room.localParticipant.lastMicrophoneError !== staleError && room.localParticipant.lastMicrophoneError || null
2239
+ };
2240
+ }
2241
+ function useCallMedia(room, options) {
2242
+ const { setMicrophoneFacts, setAudioPlayback, registerMediaControls, microphoneWanted, connectionState, callKey } = options;
2243
+ const connected = connectionState === ConnectionState.Connected;
2244
+ const scope = useMemo(
2245
+ () => ({ room, callKey, stale: room.localParticipant.lastMicrophoneError }),
2246
+ [room, callKey]
2247
+ );
2248
+ const [enabling, setEnabling] = useState(false);
2249
+ const [version, setVersion] = useState(0);
2250
+ const sinksRef = useRef({ setMicrophoneFacts, setAudioPlayback });
2251
+ sinksRef.current = { setMicrophoneFacts, setAudioPlayback };
2252
+ useEffect(() => {
2253
+ if (!setMicrophoneFacts) return;
2254
+ const bump = () => setVersion((value) => value + 1);
2255
+ const participant = room.localParticipant;
2256
+ let watched = null;
2257
+ const watchTrack = () => {
2258
+ watched?.off();
2259
+ const track = participant.getTrackPublication(Track.Source.Microphone)?.track;
2260
+ if (!track) {
2261
+ watched = null;
2262
+ return;
2263
+ }
2264
+ track.on(TrackEvent.Ended, bump).on(TrackEvent.Restarted, bump).on(TrackEvent.Muted, bump).on(TrackEvent.Unmuted, bump);
2265
+ watched = { off: () => {
2266
+ track.off(TrackEvent.Ended, bump).off(TrackEvent.Restarted, bump).off(TrackEvent.Muted, bump).off(TrackEvent.Unmuted, bump);
2267
+ } };
2268
+ };
2269
+ const onPublications = () => {
2270
+ watchTrack();
2271
+ bump();
2272
+ };
2273
+ room.on(RoomEvent.MediaDevicesError, bump).on(RoomEvent.LocalTrackPublished, onPublications).on(RoomEvent.LocalTrackUnpublished, onPublications);
2274
+ participant.on(ParticipantEvent.TrackMuted, bump).on(ParticipantEvent.TrackUnmuted, bump);
2275
+ watchTrack();
2276
+ return () => {
2277
+ watched?.off();
2278
+ room.off(RoomEvent.MediaDevicesError, bump).off(RoomEvent.LocalTrackPublished, onPublications).off(RoomEvent.LocalTrackUnpublished, onPublications);
2279
+ participant.off(ParticipantEvent.TrackMuted, bump).off(ParticipantEvent.TrackUnmuted, bump);
2280
+ };
2281
+ }, [room, setMicrophoneFacts]);
2282
+ useEffect(() => {
2283
+ sinksRef.current.setMicrophoneFacts?.(microphoneFacts(room, microphoneWanted, connected, enabling, scope.stale));
2284
+ }, [room, microphoneWanted, connected, enabling, version, scope]);
2285
+ const { canPlayAudio } = useAudioPlayback(room);
2286
+ useEffect(() => {
2287
+ sinksRef.current.setAudioPlayback?.(!canPlayAudio ? "blocked" : connected ? "allowed" : "unknown");
2288
+ }, [canPlayAudio, connected]);
2289
+ const unlockTriedRef = useRef(null);
2290
+ const connecting = connectionState === ConnectionState.Connecting || connected;
2291
+ useEffect(() => {
2292
+ if (!setAudioPlayback || !connecting || unlockTriedRef.current === room) return;
2293
+ unlockTriedRef.current = room;
2294
+ void room.startAudio().catch(() => void 0);
2295
+ }, [room, setAudioPlayback, connecting]);
2296
+ const controls = useMemo(() => ({
2297
+ startAudio: async () => {
2298
+ try {
2299
+ await room.startAudio();
2300
+ } catch {
2301
+ }
2302
+ return room.canPlaybackAudio;
2303
+ },
2304
+ setMicrophoneEnabled: async (enabled) => {
2305
+ if (enabled) setEnabling(true);
2306
+ try {
2307
+ await room.localParticipant.setMicrophoneEnabled(enabled);
2308
+ } catch {
2309
+ } finally {
2310
+ if (enabled) setEnabling(false);
2311
+ setVersion((value) => value + 1);
2312
+ }
2313
+ }
2314
+ }), [room]);
2315
+ useEffect(() => {
2316
+ if (!registerMediaControls) return;
2317
+ registerMediaControls(controls);
2318
+ return () => registerMediaControls(null);
2319
+ }, [controls, registerMediaControls]);
2320
+ }
2321
+
2123
2322
  // ../client/src/react/session-lifecycle.ts
2124
2323
  function phaseForReason(reason) {
2125
2324
  switch (reason) {
@@ -2280,10 +2479,13 @@ function useSessionLifecycle(input) {
2280
2479
  const reconnectPolicyRef = useRef(reconnectPolicy);
2281
2480
  reconnectPolicyRef.current = reconnectPolicy;
2282
2481
  const connectWatchdogMs = typeof connectWatchdogSeconds === "number" && Number.isFinite(connectWatchdogSeconds) ? Math.max(0, Math.floor(connectWatchdogSeconds)) * 1e3 : DEFAULT_CONNECT_WATCHDOG_SECONDS * 1e3;
2482
+ const [userEnded, setUserEnded] = useState(false);
2483
+ const userEndedRef = useRef(false);
2484
+ const phaseRef = useRef({ kind: "idle" });
2283
2485
  const grantState = useLiveKitAvatarGrant({
2284
2486
  client,
2285
2487
  session,
2286
- active,
2488
+ active: active && !userEnded,
2287
2489
  autoRetryBusy,
2288
2490
  requestOptions
2289
2491
  });
@@ -2300,6 +2502,8 @@ function useSessionLifecycle(input) {
2300
2502
  refreshRef.current = grantState.refresh;
2301
2503
  const releaseRef = useRef(grantState.release);
2302
2504
  releaseRef.current = grantState.release;
2505
+ const clearRef = useRef(grantState.clear);
2506
+ clearRef.current = grantState.clear;
2303
2507
  const capacityRef = useRef(capacity);
2304
2508
  capacityRef.current = capacity;
2305
2509
  const refreshBaselineCapacityRef = useRef(null);
@@ -2326,13 +2530,16 @@ function useSessionLifecycle(input) {
2326
2530
  refreshBaselineCapacityRef.current = null;
2327
2531
  attemptRef.current = 0;
2328
2532
  setAttempt(0);
2329
- setRecovery({ kind: "connected" });
2533
+ setRecovery(userEndedRef.current ? { kind: "ended", reason: "user" } : { kind: "connected" });
2330
2534
  setConnected(false);
2331
2535
  setAgentPresentState(false);
2332
2536
  lastActivityRef.current = Date.now();
2333
2537
  }, [clearTimer]);
2334
2538
  useEffect(() => {
2335
- if (!active) reset();
2539
+ if (active) return;
2540
+ userEndedRef.current = false;
2541
+ setUserEnded(false);
2542
+ reset();
2336
2543
  }, [active, reset]);
2337
2544
  const markActivity = useCallback(() => {
2338
2545
  lastActivityRef.current = Date.now();
@@ -2363,6 +2570,7 @@ function useSessionLifecycle(input) {
2363
2570
  leaveRoomRef.current?.();
2364
2571
  }, [clearTimer]);
2365
2572
  const onConnected = useCallback(() => {
2573
+ if (userEndedRef.current) return;
2366
2574
  clearTimer();
2367
2575
  connectedRef.current = true;
2368
2576
  manualReconnectPendingRef.current = false;
@@ -2375,7 +2583,7 @@ function useSessionLifecycle(input) {
2375
2583
  }, [clearTimer]);
2376
2584
  const onDisconnected = useCallback(
2377
2585
  (reason) => {
2378
- if (!active) return;
2586
+ if (!active || userEndedRef.current) return;
2379
2587
  const action = disconnectAction(reason, connectedRef.current);
2380
2588
  if (action === "noop") return;
2381
2589
  if (action === "reset") {
@@ -2400,7 +2608,7 @@ function useSessionLifecycle(input) {
2400
2608
  );
2401
2609
  const onConnectionError = useCallback(
2402
2610
  (error) => {
2403
- if (!active || !isRecoverableConnectionError(error)) return;
2611
+ if (!active || userEndedRef.current || !isRecoverableConnectionError(error)) return;
2404
2612
  connectedRef.current = false;
2405
2613
  setConnected(false);
2406
2614
  setAgentPresentState(false);
@@ -2413,6 +2621,9 @@ function useSessionLifecycle(input) {
2413
2621
  },
2414
2622
  [active]
2415
2623
  );
2624
+ useEffect(() => {
2625
+ if (capacity.kind === "error" && recovery.kind === "connected") manualReconnectPendingRef.current = false;
2626
+ }, [capacity, recovery]);
2416
2627
  useEffect(() => {
2417
2628
  if (recovery.kind !== "refreshing" || capacity.kind !== "error") return;
2418
2629
  const baseline = refreshBaselineCapacityRef.current;
@@ -2422,7 +2633,7 @@ function useSessionLifecycle(input) {
2422
2633
  }, [capacity, recovery]);
2423
2634
  const onConnectionStateChange = useCallback(
2424
2635
  (state) => {
2425
- if (!active) return;
2636
+ if (!active || userEndedRef.current) return;
2426
2637
  if (state === "connected") {
2427
2638
  onConnected();
2428
2639
  return;
@@ -2470,8 +2681,30 @@ function useSessionLifecycle(input) {
2470
2681
  }, step2.delayMs);
2471
2682
  return clearTimer;
2472
2683
  }, [recovery.kind, active, attempt, clearTimer]);
2684
+ const end = useCallback(() => {
2685
+ if (!active || userEndedRef.current || phaseRef.current.kind === "ended") return;
2686
+ userEndedRef.current = true;
2687
+ clearTimer();
2688
+ connectedRef.current = false;
2689
+ manualReconnectPendingRef.current = false;
2690
+ refreshBaselineCapacityRef.current = null;
2691
+ setUserEnded(true);
2692
+ setConnected(false);
2693
+ setAgentPresentState(false);
2694
+ setRecovery({ kind: "ended", reason: "user" });
2695
+ clearRef.current();
2696
+ leaveRoomRef.current?.();
2697
+ }, [active, clearTimer]);
2473
2698
  const reconnect = useCallback(() => {
2474
- if (!active || manualReconnectPendingRef.current || recovery.kind === "in-place-reconnecting") return;
2699
+ if (!active) return;
2700
+ if (userEndedRef.current) {
2701
+ userEndedRef.current = false;
2702
+ setUserEnded(false);
2703
+ reset();
2704
+ manualReconnectPendingRef.current = true;
2705
+ return;
2706
+ }
2707
+ if (manualReconnectPendingRef.current || recovery.kind === "in-place-reconnecting") return;
2475
2708
  manualReconnectPendingRef.current = true;
2476
2709
  clearTimer();
2477
2710
  attemptRef.current = 0;
@@ -2480,7 +2713,7 @@ function useSessionLifecycle(input) {
2480
2713
  releaseRef.current("superseded");
2481
2714
  setRecovery({ kind: "refreshing", attempt: 0 });
2482
2715
  refreshRef.current();
2483
- }, [active, clearTimer, recovery.kind]);
2716
+ }, [active, clearTimer, recovery.kind, reset]);
2484
2717
  const endIdleRef = useRef(endIdle);
2485
2718
  endIdleRef.current = endIdle;
2486
2719
  useEffect(() => {
@@ -2512,6 +2745,7 @@ function useSessionLifecycle(input) {
2512
2745
  () => lifecyclePhaseFrom({ capacity, recovery, idle, connected, agentPresent }),
2513
2746
  [capacity, recovery, idle, connected, agentPresent]
2514
2747
  );
2748
+ phaseRef.current = phase;
2515
2749
  const onBehaviorChangeRef = useRef(onBehaviorChange);
2516
2750
  onBehaviorChangeRef.current = onBehaviorChange;
2517
2751
  const lastBehaviorRef = useRef(null);
@@ -2537,6 +2771,7 @@ function useSessionLifecycle(input) {
2537
2771
  stayConnected,
2538
2772
  markActivity,
2539
2773
  reconnect,
2774
+ end,
2540
2775
  onConnected,
2541
2776
  onDisconnected,
2542
2777
  onConnectionError,
@@ -2555,6 +2790,7 @@ function useSessionLifecycle(input) {
2555
2790
  stayConnected,
2556
2791
  markActivity,
2557
2792
  reconnect,
2793
+ end,
2558
2794
  onConnected,
2559
2795
  onDisconnected,
2560
2796
  onConnectionError,
@@ -2569,7 +2805,8 @@ function useSessionLifecycle(input) {
2569
2805
  function SessionLifecycleRoomBridge({
2570
2806
  lifecycle,
2571
2807
  onConnectionDetailsChange,
2572
- onMediaModeChange
2808
+ onMediaModeChange,
2809
+ microphone
2573
2810
  }) {
2574
2811
  const {
2575
2812
  onConnectionStateChange,
@@ -2580,12 +2817,23 @@ function SessionLifecycleRoomBridge({
2580
2817
  registerDataPublisher,
2581
2818
  registerTurnSender,
2582
2819
  setTurnState,
2583
- setMedia
2820
+ setMedia,
2821
+ setMicrophoneFacts,
2822
+ setAudioPlayback,
2823
+ registerMediaControls
2584
2824
  } = lifecycle;
2585
2825
  const connectionState = useConnectionState();
2586
2826
  const assistant = useVoiceAssistant();
2587
2827
  const transcriptions = useTranscriptions();
2588
2828
  const room = useRoomContext();
2829
+ useCallMedia(room, {
2830
+ setMicrophoneFacts,
2831
+ setAudioPlayback,
2832
+ registerMediaControls,
2833
+ microphoneWanted: microphone ?? publishesMicrophoneByDefault(lifecycle.grant),
2834
+ connectionState,
2835
+ callKey: lifecycle.grant?.session_id
2836
+ });
2589
2837
  const mediaModeCallbackRef = useRef(onMediaModeChange);
2590
2838
  mediaModeCallbackRef.current = onMediaModeChange;
2591
2839
  const mediaModeEnabled = onMediaModeChange !== void 0 && lifecycle.grant !== null;
@@ -2859,6 +3107,8 @@ function resolveEndReason(workerLabel, innerReason) {
2859
3107
  return "idle";
2860
3108
  case "error":
2861
3109
  return "failed";
3110
+ case "user":
3111
+ return "user_ended";
2862
3112
  default:
2863
3113
  return "disconnected";
2864
3114
  }
@@ -2914,6 +3164,16 @@ function useRealtimeSession(input) {
2914
3164
  const [turn, setTurn] = useState("quiet");
2915
3165
  const [media, setMediaState] = useState({ video: "connecting", audio: "silent" });
2916
3166
  const [behavior, setBehavior] = useState(null);
3167
+ const [microphoneFacts2, setMicrophoneFactsState] = useState({
3168
+ wanted: false,
3169
+ connected: false,
3170
+ enabling: false,
3171
+ publication: null,
3172
+ deviceError: null
3173
+ });
3174
+ const [microphoneMuted, setMicrophoneMuted] = useState(false);
3175
+ const [audioPlayback, setAudioPlaybackState] = useState("unknown");
3176
+ const mediaControlsRef = useRef(null);
2917
3177
  const lastBehaviorRef = useRef(null);
2918
3178
  const lastLabeledEndReasonRef = useRef(null);
2919
3179
  const pendingClipsRef = useRef(/* @__PURE__ */ new Map());
@@ -3020,6 +3280,31 @@ function useRealtimeSession(input) {
3020
3280
  const setMedia = useCallback((next) => {
3021
3281
  setMediaState((prev) => prev.video === next.video && prev.audio === next.audio ? prev : next);
3022
3282
  }, []);
3283
+ const microphoneFactsRef = useRef(microphoneFacts2);
3284
+ microphoneFactsRef.current = microphoneFacts2;
3285
+ const setMicrophoneFacts = useCallback((facts) => setMicrophoneFactsState(facts), []);
3286
+ const setAudioPlayback = useCallback((playback) => setAudioPlaybackState(playback), []);
3287
+ const registerMediaControls = useCallback((controls) => {
3288
+ mediaControlsRef.current = controls;
3289
+ }, []);
3290
+ const startAudio = useCallback(async () => {
3291
+ const controls = mediaControlsRef.current;
3292
+ return controls ? controls.startAudio() : false;
3293
+ }, []);
3294
+ const callEnded = lifecycle.phase.kind === "ended";
3295
+ const callEndedRef = useRef(callEnded);
3296
+ callEndedRef.current = callEnded;
3297
+ const setMicrophoneEnabled = useCallback(async (enabled) => {
3298
+ if (callEndedRef.current) return;
3299
+ setMicrophoneMuted(!enabled);
3300
+ if (!microphoneFactsRef.current.connected) return;
3301
+ await mediaControlsRef.current?.setMicrophoneEnabled(enabled);
3302
+ }, []);
3303
+ const microphone = useMemo(
3304
+ () => callEnded ? { status: "off" } : callMicrophoneFrom(microphoneFacts2, { muted: microphoneMuted }),
3305
+ [callEnded, microphoneFacts2, microphoneMuted]
3306
+ );
3307
+ const callAudioPlayback = callEnded ? "unknown" : audioPlayback;
3023
3308
  useEffect(() => {
3024
3309
  if (phaseKind === "idle" || phaseKind === "ended") return;
3025
3310
  const timer = window.setInterval(() => {
@@ -3164,11 +3449,23 @@ function useRealtimeSession(input) {
3164
3449
  void sendResolvedTurn(last.text, last.attributes, last.id).catch(() => void 0);
3165
3450
  }, [sendResolvedTurn]);
3166
3451
  const end = useCallback((reason) => {
3452
+ if (phaseKind === "ended") return;
3167
3453
  if (reason) lastLabeledEndReasonRef.current = reason;
3168
3454
  lastTurnRef.current = null;
3169
3455
  requestGracefulClose();
3170
- lifecycle.reset();
3171
- }, [lifecycle, requestGracefulClose]);
3456
+ lifecycle.end();
3457
+ }, [lifecycle, phaseKind, requestGracefulClose]);
3458
+ const active = inner.active ?? true;
3459
+ useEffect(() => {
3460
+ if (active) return;
3461
+ lastLabeledEndReasonRef.current = null;
3462
+ setMicrophoneMuted(false);
3463
+ }, [active]);
3464
+ const innerReconnect = lifecycle.reconnect;
3465
+ const reconnect = useCallback(() => {
3466
+ if (phaseKind === "ended") lastLabeledEndReasonRef.current = null;
3467
+ innerReconnect();
3468
+ }, [innerReconnect, phaseKind]);
3172
3469
  const performAction = useCallback(
3173
3470
  (actionId, opts) => {
3174
3471
  const requestId = newTurnId();
@@ -3214,6 +3511,7 @@ function useRealtimeSession(input) {
3214
3511
  approachingFiredRef.current = false;
3215
3512
  creditsLowFiredRef.current = false;
3216
3513
  lastTurnRef.current = null;
3514
+ setMicrophoneMuted(false);
3217
3515
  lifecycle.reset();
3218
3516
  }, [lifecycle]);
3219
3517
  return useMemo(
@@ -3227,6 +3525,12 @@ function useRealtimeSession(input) {
3227
3525
  endsAt,
3228
3526
  graceWindow,
3229
3527
  media,
3528
+ microphone,
3529
+ audioPlayback: callAudioPlayback,
3530
+ startAudio,
3531
+ setMicrophoneEnabled,
3532
+ microphoneMuted,
3533
+ reconnect,
3230
3534
  lifecycle,
3231
3535
  sendClosingTurn,
3232
3536
  requestGracefulClose,
@@ -3242,6 +3546,9 @@ function useRealtimeSession(input) {
3242
3546
  registerTurnSender,
3243
3547
  setTurnState,
3244
3548
  setMedia,
3549
+ setMicrophoneFacts,
3550
+ setAudioPlayback,
3551
+ registerMediaControls,
3245
3552
  reset
3246
3553
  }),
3247
3554
  [
@@ -3251,6 +3558,12 @@ function useRealtimeSession(input) {
3251
3558
  endsAt,
3252
3559
  graceWindow,
3253
3560
  media,
3561
+ microphone,
3562
+ callAudioPlayback,
3563
+ startAudio,
3564
+ setMicrophoneEnabled,
3565
+ microphoneMuted,
3566
+ reconnect,
3254
3567
  sendClosingTurn,
3255
3568
  requestGracefulClose,
3256
3569
  extend,
@@ -3265,44 +3578,74 @@ function useRealtimeSession(input) {
3265
3578
  registerTurnSender,
3266
3579
  setTurnState,
3267
3580
  setMedia,
3581
+ setMicrophoneFacts,
3582
+ setAudioPlayback,
3583
+ registerMediaControls,
3268
3584
  reset
3269
3585
  ]
3270
3586
  );
3271
3587
  }
3272
3588
 
3589
+ // ../http-client/src/retry.ts
3590
+ var PROXY_CLIENT_TIMEOUT_MS = 6e4;
3591
+
3273
3592
  // ../client/src/proxy-client.ts
3274
- var DEFAULT_TIMEOUT_MS = 6e4;
3275
3593
  var normalize = (url) => url.replace(/\/+$/, "");
3276
3594
  function createProxyClient(options) {
3277
3595
  const base = normalize(options.proxyUrl);
3278
3596
  const doFetch = options.fetch ?? globalThis.fetch?.bind(globalThis);
3279
- const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
3280
- const deadline = (caller) => {
3281
- if (!timeoutMs) return caller;
3282
- const timer = AbortSignal.timeout(timeoutMs);
3283
- return caller ? AbortSignal.any([caller, timer]) : timer;
3284
- };
3597
+ const timeoutMs = options.timeoutMs ?? PROXY_CLIENT_TIMEOUT_MS;
3598
+ const credentials = options.credentials;
3599
+ const withCredentials = credentials === void 0 ? {} : { credentials };
3285
3600
  const post = async (path, body, request) => {
3286
3601
  if (!doFetch) throw new Error("realtime-avatar: no fetch available \u2014 pass one via `fetch`.");
3287
- return doFetch(`${base}${path}`, {
3288
- method: "POST",
3289
- headers: { "content-type": "application/json", ...request?.headers ?? {} },
3290
- body: JSON.stringify(body),
3291
- signal: deadline(request?.signal)
3292
- });
3602
+ const caller = request?.signal;
3603
+ const timer = timeoutMs ? AbortSignal.timeout(timeoutMs) : void 0;
3604
+ try {
3605
+ return await doFetch(`${base}${path}`, {
3606
+ method: "POST",
3607
+ ...withCredentials,
3608
+ headers: { "content-type": "application/json", ...request?.headers ?? {} },
3609
+ body: JSON.stringify(body),
3610
+ signal: timer && caller ? AbortSignal.any([caller, timer]) : timer ?? caller
3611
+ });
3612
+ } catch (cause) {
3613
+ if (timer?.aborted && !caller?.aborted) throw RealtimeAvatarApiError.timeout(timeoutMs);
3614
+ throw cause;
3615
+ }
3293
3616
  };
3294
- const beacon = (path, body) => {
3617
+ const pageHide = (body) => {
3618
+ const page = globalThis.location?.href;
3619
+ let target;
3620
+ try {
3621
+ target = new URL(`${base}/end`, page);
3622
+ } catch {
3623
+ return false;
3624
+ }
3625
+ const payload = JSON.stringify(body);
3295
3626
  const send = globalThis.navigator?.sendBeacon?.bind(globalThis.navigator);
3296
- if (!send) return false;
3297
- return send(`${base}${path}`, new Blob([JSON.stringify(body)], { type: "application/json" }));
3627
+ const sameOrigin = page !== void 0 && target.origin === new URL(page).origin;
3628
+ if (send && (sameOrigin || credentials === "include")) {
3629
+ if (send(target.href, new Blob([payload], { type: "application/json" }))) return true;
3630
+ }
3631
+ if (!doFetch) return false;
3632
+ try {
3633
+ void doFetch(target.href, {
3634
+ method: "POST",
3635
+ keepalive: true,
3636
+ ...withCredentials,
3637
+ headers: { "content-type": "application/json" },
3638
+ body: payload
3639
+ }).catch(() => void 0);
3640
+ return true;
3641
+ } catch {
3642
+ return false;
3643
+ }
3298
3644
  };
3299
3645
  return {
3300
3646
  async createLiveKitSessionOrBusy(input, requestOptions) {
3301
- const response = await post(
3302
- "/connect",
3303
- { avatarId: input.avatarId, mode: input.mode },
3304
- requestOptions
3305
- );
3647
+ const connect = { avatarId: input.avatarId, mode: input.mode };
3648
+ const response = await post("/connect", connect, requestOptions);
3306
3649
  if (response.status === 429) {
3307
3650
  const value = await response.clone().json().catch(() => null);
3308
3651
  if (value && typeof value === "object" && !Array.isArray(value)) {
@@ -3320,7 +3663,8 @@ function createProxyClient(options) {
3320
3663
  async releaseLiveKitSession(sessionId, reason, requestOptions) {
3321
3664
  if (!sessionId) return false;
3322
3665
  try {
3323
- const response = await post("/end", { session_id: sessionId, reason }, requestOptions);
3666
+ const body = { session_id: sessionId, reason };
3667
+ const response = await post("/end", body, requestOptions);
3324
3668
  return response.ok;
3325
3669
  } catch {
3326
3670
  return false;
@@ -3328,12 +3672,13 @@ function createProxyClient(options) {
3328
3672
  },
3329
3673
  releaseLiveKitSessionBeacon(sessionId, reason) {
3330
3674
  if (!sessionId) return false;
3331
- return beacon("/end", { session_id: sessionId, reason: reason ?? "page_hide" });
3675
+ return pageHide({ session_id: sessionId, reason: reason ?? "page_hide" });
3332
3676
  },
3333
3677
  async releaseLiveKitQueueTicket(queueTicketId, reason, requestOptions) {
3334
3678
  if (!queueTicketId) return false;
3335
3679
  try {
3336
- const response = await post("/end", { queue_ticket_id: queueTicketId, reason }, requestOptions);
3680
+ const body = { queue_ticket_id: queueTicketId, reason };
3681
+ const response = await post("/end", body, requestOptions);
3337
3682
  return response.ok;
3338
3683
  } catch {
3339
3684
  return false;
@@ -3341,7 +3686,7 @@ function createProxyClient(options) {
3341
3686
  },
3342
3687
  releaseLiveKitQueueTicketBeacon(queueTicketId, reason) {
3343
3688
  if (!queueTicketId) return false;
3344
- return beacon("/end", { queue_ticket_id: queueTicketId, reason: reason ?? "page_hide" });
3689
+ return pageHide({ queue_ticket_id: queueTicketId, reason: reason ?? "page_hide" });
3345
3690
  }
3346
3691
  };
3347
3692
  }