@runwayml/avatars-react 0.10.0-beta.0 → 0.11.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.
package/README.md CHANGED
@@ -46,17 +46,20 @@ import '@runwayml/avatars-react/styles.css';
46
46
  The styles use CSS custom properties for easy customization:
47
47
 
48
48
  ```css
49
- :root {
50
- --avatar-bg: #a78bfa; /* Video background color */
51
- --avatar-radius: 16px; /* Container border radius */
52
- --avatar-control-size: 48px; /* Control button size */
53
- --avatar-end-call-bg: #ef4444; /* End call button color */
49
+ [data-avatar-call] {
50
+ --avatar-bg-connecting: #8b5cf6; /* Video background color */
51
+ --avatar-radius: 16px; /* Container border radius */
52
+ --avatar-control-size: 40px; /* Control button size */
53
+ --avatar-end-call-bg: #ff552f; /* End call button color */
54
+ --avatar-screen-share-active-bg: #fff; /* Active share button background */
54
55
  }
55
56
  ```
56
57
 
57
58
  See [`examples/`](./examples) for complete working examples:
58
59
  - [`nextjs`](./examples/nextjs) - Next.js App Router
59
60
  - [`nextjs-client-events`](./examples/nextjs-client-events) - Client event tools (trivia game)
61
+ - [`nextjs-rpc`](./examples/nextjs-rpc) - Backend RPC + client events (trivia with server-side questions)
62
+ - [`nextjs-rpc-weather`](./examples/nextjs-rpc-weather) - Backend RPC only (weather assistant)
60
63
  - [`nextjs-server-actions`](./examples/nextjs-server-actions) - Next.js with Server Actions
61
64
  - [`react-router`](./examples/react-router) - React Router v7 framework mode
62
65
  - [`express`](./examples/express) - Express + Vite
@@ -151,38 +154,41 @@ import { AvatarCall, AvatarVideo, ControlBar, UserVideo } from '@runwayml/avatar
151
154
 
152
155
  ### Render Props
153
156
 
154
- All components support render props for complete control:
157
+ All display components support render props for complete control. `AvatarVideo` receives a discriminated union with `status`:
155
158
 
156
159
  ```tsx
157
160
  <AvatarVideo>
158
- {({ hasVideo, isConnecting, trackRef }) => (
159
- <div>
160
- {isConnecting && <Spinner />}
161
- {hasVideo && <VideoTrack trackRef={trackRef} />}
162
- </div>
163
- )}
161
+ {(avatar) => {
162
+ switch (avatar.status) {
163
+ case 'connecting': return <Spinner />;
164
+ case 'waiting': return <Placeholder />;
165
+ case 'ready': return <VideoTrack trackRef={avatar.videoTrackRef} />;
166
+ }
167
+ }}
164
168
  </AvatarVideo>
165
169
  ```
166
170
 
167
171
  ### CSS Styling with Data Attributes
168
172
 
169
- Style connection states with CSS:
173
+ Style components with the namespaced `data-avatar-*` attributes:
170
174
 
171
175
  ```tsx
172
176
  <AvatarCall avatarId="music-superstar" connectUrl="/api/avatar/connect" className="my-avatar" />
173
177
  ```
174
178
 
175
179
  ```css
176
- .my-avatar[data-state="connecting"] {
180
+ /* Style avatar video by connection status */
181
+ [data-avatar-video][data-avatar-status="connecting"] {
177
182
  opacity: 0.5;
178
183
  }
179
184
 
180
- .my-avatar[data-state="error"] {
181
- border: 2px solid red;
185
+ [data-avatar-video][data-avatar-status="ready"] {
186
+ opacity: 1;
182
187
  }
183
188
 
184
- .my-avatar[data-state="connected"] {
185
- border: 2px solid green;
189
+ /* Style control buttons */
190
+ [data-avatar-control][data-avatar-enabled="false"] {
191
+ opacity: 0.5;
186
192
  }
187
193
  ```
188
194
 
@@ -233,6 +239,8 @@ Enable the screen share button by passing `showScreenShare` to `ControlBar`, and
233
239
  </AvatarCall>
234
240
  ```
235
241
 
242
+ While sharing is active, the default `ControlBar` UI shows a sharing banner with a quick `Stop` action.
243
+
236
244
  You can also start screen sharing automatically by passing a pre-captured stream via `initialScreenStream`. This is useful when you want to prompt the user for screen share permission before the session connects:
237
245
 
238
246
  ```tsx
@@ -346,6 +354,8 @@ function MediaControls() {
346
354
 
347
355
  ## Client Events
348
356
 
357
+ > **Compatibility:** Client events (tool calling) are supported on avatars that use a **preset voice**. Custom voice avatars do not currently support client events.
358
+
349
359
  Avatars can trigger UI events via tool calls sent over the data channel. Define tools, pass them when creating a session, and subscribe on the client:
350
360
 
351
361
  ```ts
package/dist/index.cjs CHANGED
@@ -170,53 +170,6 @@ function parseClientEvent(payload) {
170
170
  return null;
171
171
  }
172
172
  }
173
- async function hasMediaDevice(kind, timeoutMs = 1e3) {
174
- try {
175
- const timeoutPromise = new Promise(
176
- (resolve) => setTimeout(() => resolve(false), timeoutMs)
177
- );
178
- const checkPromise = navigator.mediaDevices.enumerateDevices().then((devices) => devices.some((device) => device.kind === kind));
179
- return await Promise.race([checkPromise, timeoutPromise]);
180
- } catch {
181
- return false;
182
- }
183
- }
184
- function useDeviceAvailability(requestAudio, requestVideo) {
185
- const [state, setState] = react.useState({
186
- audio: requestAudio,
187
- // Optimistically assume devices exist
188
- video: requestVideo
189
- });
190
- react.useEffect(() => {
191
- let cancelled = false;
192
- async function checkDevices() {
193
- const [hasAudio, hasVideo] = await Promise.all([
194
- requestAudio ? hasMediaDevice("audioinput") : Promise.resolve(false),
195
- requestVideo ? hasMediaDevice("videoinput") : Promise.resolve(false)
196
- ]);
197
- if (!cancelled) {
198
- setState({
199
- audio: requestAudio && hasAudio,
200
- video: requestVideo && hasVideo
201
- });
202
- }
203
- }
204
- checkDevices();
205
- return () => {
206
- cancelled = true;
207
- };
208
- }, [requestAudio, requestVideo]);
209
- return state;
210
- }
211
- var MEDIA_DEVICE_ERROR_NAMES = /* @__PURE__ */ new Set([
212
- "NotAllowedError",
213
- "NotFoundError",
214
- "NotReadableError",
215
- "OverconstrainedError"
216
- ]);
217
- function isMediaDeviceError(error) {
218
- return MEDIA_DEVICE_ERROR_NAMES.has(error.name);
219
- }
220
173
  var DEFAULT_ROOM_OPTIONS = {
221
174
  adaptiveStream: false,
222
175
  dynacast: false
@@ -238,6 +191,7 @@ function mapConnectionState(connectionState) {
238
191
  var AvatarSessionContext = react.createContext(
239
192
  null
240
193
  );
194
+ var MediaDeviceErrorContext = react.createContext(null);
241
195
  function AvatarSession({
242
196
  credentials,
243
197
  children,
@@ -250,12 +204,9 @@ function AvatarSession({
250
204
  __unstable_roomOptions
251
205
  }) {
252
206
  const errorRef = react.useRef(null);
253
- const deviceAvailability = useDeviceAvailability(requestAudio, requestVideo);
254
207
  const handleError = (error) => {
255
208
  onError?.(error);
256
- if (!isMediaDeviceError(error)) {
257
- errorRef.current = error;
258
- }
209
+ errorRef.current = error;
259
210
  };
260
211
  const roomOptions = {
261
212
  ...DEFAULT_ROOM_OPTIONS,
@@ -267,8 +218,8 @@ function AvatarSession({
267
218
  serverUrl: credentials.serverUrl,
268
219
  token: credentials.token,
269
220
  connect: true,
270
- audio: deviceAvailability.audio,
271
- video: deviceAvailability.video,
221
+ audio: false,
222
+ video: false,
272
223
  onDisconnected: () => onEnd?.(),
273
224
  onError: handleError,
274
225
  options: roomOptions,
@@ -280,6 +231,8 @@ function AvatarSession({
280
231
  AvatarSessionContextInner,
281
232
  {
282
233
  sessionId: credentials.sessionId,
234
+ requestAudio,
235
+ requestVideo,
283
236
  onEnd,
284
237
  onClientEvent,
285
238
  errorRef,
@@ -294,6 +247,8 @@ function AvatarSession({
294
247
  }
295
248
  function AvatarSessionContextInner({
296
249
  sessionId,
250
+ requestAudio,
251
+ requestVideo,
297
252
  onEnd,
298
253
  onClientEvent,
299
254
  errorRef,
@@ -329,6 +284,64 @@ function AvatarSessionContextInner({
329
284
  });
330
285
  };
331
286
  }, [connectionState, initialScreenStream, room]);
287
+ const [micError, setMicError] = react.useState(null);
288
+ const [cameraError, setCameraError] = react.useState(null);
289
+ const mediaEnabledRef = react.useRef(false);
290
+ react.useEffect(() => {
291
+ if (connectionState !== livekitClient.ConnectionState.Connected) return;
292
+ if (mediaEnabledRef.current) return;
293
+ mediaEnabledRef.current = true;
294
+ async function enableMedia() {
295
+ if (requestAudio) {
296
+ try {
297
+ await room.localParticipant.setMicrophoneEnabled(true);
298
+ } catch (err) {
299
+ if (err instanceof Error) setMicError(err);
300
+ }
301
+ }
302
+ if (requestVideo) {
303
+ try {
304
+ await room.localParticipant.setCameraEnabled(true);
305
+ } catch (err) {
306
+ if (err instanceof Error) setCameraError(err);
307
+ }
308
+ }
309
+ }
310
+ enableMedia();
311
+ }, [connectionState, room, requestAudio, requestVideo]);
312
+ react.useEffect(() => {
313
+ function handleMediaDevicesError(error, kind) {
314
+ if (kind === "audioinput") {
315
+ setMicError(error);
316
+ } else if (kind === "videoinput") {
317
+ setCameraError(error);
318
+ }
319
+ }
320
+ room.on(livekitClient.RoomEvent.MediaDevicesError, handleMediaDevicesError);
321
+ return () => {
322
+ room.off(livekitClient.RoomEvent.MediaDevicesError, handleMediaDevicesError);
323
+ };
324
+ }, [room]);
325
+ const retryMic = react.useCallback(async () => {
326
+ try {
327
+ await room.localParticipant.setMicrophoneEnabled(true);
328
+ setMicError(null);
329
+ } catch (err) {
330
+ if (err instanceof Error) setMicError(err);
331
+ }
332
+ }, [room]);
333
+ const retryCamera = react.useCallback(async () => {
334
+ try {
335
+ await room.localParticipant.setCameraEnabled(true);
336
+ setCameraError(null);
337
+ } catch (err) {
338
+ if (err instanceof Error) setCameraError(err);
339
+ }
340
+ }, [room]);
341
+ const mediaDeviceErrors = react.useMemo(
342
+ () => ({ micError, cameraError, retryMic, retryCamera }),
343
+ [micError, cameraError, retryMic, retryCamera]
344
+ );
332
345
  react.useEffect(() => {
333
346
  function handleDataReceived(payload) {
334
347
  const event = parseClientEvent(payload);
@@ -357,7 +370,7 @@ function AvatarSessionContextInner({
357
370
  error: errorRef.current,
358
371
  end
359
372
  };
360
- return /* @__PURE__ */ jsxRuntime.jsx(AvatarSessionContext.Provider, { value: contextValue, children });
373
+ return /* @__PURE__ */ jsxRuntime.jsx(AvatarSessionContext.Provider, { value: contextValue, children: /* @__PURE__ */ jsxRuntime.jsx(MediaDeviceErrorContext.Provider, { value: mediaDeviceErrors, children }) });
361
374
  }
362
375
  function useAvatarSessionContext() {
363
376
  const context = react.useContext(AvatarSessionContext);
@@ -368,6 +381,9 @@ function useAvatarSessionContext() {
368
381
  }
369
382
  return context;
370
383
  }
384
+ function useMediaDeviceErrorContext() {
385
+ return react.useContext(MediaDeviceErrorContext);
386
+ }
371
387
  function useAvatar() {
372
388
  const remoteParticipants = componentsReact.useRemoteParticipants();
373
389
  const avatarParticipant = remoteParticipants[0] ?? null;
@@ -433,8 +449,24 @@ function AvatarVideo({ children, ...props }) {
433
449
  }
434
450
  );
435
451
  }
452
+ var NOOP_ASYNC = async () => {
453
+ };
454
+ function createCaptureController() {
455
+ if (typeof window === "undefined" || !("CaptureController" in window)) {
456
+ return void 0;
457
+ }
458
+ const controller = new window.CaptureController();
459
+ controller.setFocusBehavior("no-focus-change");
460
+ return controller;
461
+ }
436
462
  function useLocalMedia() {
437
463
  const { localParticipant } = componentsReact.useLocalParticipant();
464
+ const {
465
+ micError = null,
466
+ cameraError = null,
467
+ retryMic = NOOP_ASYNC,
468
+ retryCamera = NOOP_ASYNC
469
+ } = useMediaDeviceErrorContext() ?? {};
438
470
  const audioDevices = componentsReact.useMediaDevices({ kind: "audioinput" });
439
471
  const videoDevices = componentsReact.useMediaDevices({ kind: "videoinput" });
440
472
  const hasMic = audioDevices?.length > 0;
@@ -458,7 +490,16 @@ function useLocalMedia() {
458
490
  }
459
491
  }, [localParticipant]);
460
492
  const toggleScreenShare = react.useCallback(() => {
461
- localParticipant?.setScreenShareEnabled(!isScreenShareEnabledRef.current);
493
+ const next = !isScreenShareEnabledRef.current;
494
+ if (next) {
495
+ const controller = createCaptureController();
496
+ localParticipant?.setScreenShareEnabled(true, {
497
+ controller,
498
+ surfaceSwitching: "include"
499
+ });
500
+ } else {
501
+ localParticipant?.setScreenShareEnabled(false);
502
+ }
462
503
  }, [localParticipant]);
463
504
  const tracks = componentsReact.useTracks(
464
505
  [{ source: livekitClient.Track.Source.Camera, withPlaceholder: true }],
@@ -480,7 +521,11 @@ function useLocalMedia() {
480
521
  toggleMic,
481
522
  toggleCamera,
482
523
  toggleScreenShare,
483
- localVideoTrackRef
524
+ localVideoTrackRef,
525
+ micError,
526
+ cameraError,
527
+ retryMic,
528
+ retryCamera
484
529
  };
485
530
  }
486
531
  function ControlBar({
@@ -498,9 +543,17 @@ function ControlBar({
498
543
  isScreenShareEnabled,
499
544
  toggleMic,
500
545
  toggleCamera,
501
- toggleScreenShare
546
+ toggleScreenShare,
547
+ micError,
548
+ cameraError,
549
+ retryMic,
550
+ retryCamera
502
551
  } = useLocalMedia();
503
552
  const isActive = session.state === "active";
553
+ const handleStopScreenShare = react.useCallback(() => {
554
+ if (!isScreenShareEnabled) return;
555
+ toggleScreenShare();
556
+ }, [isScreenShareEnabled, toggleScreenShare]);
504
557
  const state = {
505
558
  isMicEnabled,
506
559
  isCameraEnabled,
@@ -509,7 +562,11 @@ function ControlBar({
509
562
  toggleCamera,
510
563
  toggleScreenShare,
511
564
  endCall: session.end,
512
- isActive
565
+ isActive,
566
+ micError,
567
+ cameraError,
568
+ retryMic,
569
+ retryCamera
513
570
  };
514
571
  if (children) {
515
572
  return /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: children(state) });
@@ -518,57 +575,71 @@ function ControlBar({
518
575
  return null;
519
576
  }
520
577
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { ...props, "data-avatar-control-bar": "", "data-avatar-active": isActive, children: [
521
- showMicrophone && /* @__PURE__ */ jsxRuntime.jsx(
522
- "button",
523
- {
524
- type: "button",
525
- onClick: toggleMic,
526
- "data-avatar-control": "microphone",
527
- "data-avatar-enabled": isMicEnabled,
528
- "aria-label": isMicEnabled ? "Mute microphone" : "Unmute microphone",
529
- children: microphoneIcon
530
- }
531
- ),
532
- showCamera && /* @__PURE__ */ jsxRuntime.jsx(
533
- "button",
534
- {
535
- type: "button",
536
- onClick: toggleCamera,
537
- "data-avatar-control": "camera",
538
- "data-avatar-enabled": isCameraEnabled,
539
- "aria-label": isCameraEnabled ? "Turn off camera" : "Turn on camera",
540
- children: cameraIcon
541
- }
542
- ),
543
- showScreenShare && /* @__PURE__ */ jsxRuntime.jsx(
544
- componentsReact.TrackToggle,
545
- {
546
- source: livekitClient.Track.Source.ScreenShare,
547
- showIcon: false,
548
- "data-avatar-control": "screen-share",
549
- "data-avatar-enabled": isScreenShareEnabled,
550
- "aria-label": "Toggle screen share",
551
- children: screenShareIcon
552
- }
553
- ),
554
- showEndCall && /* @__PURE__ */ jsxRuntime.jsx(
555
- "button",
556
- {
557
- type: "button",
558
- onClick: session.end,
559
- "data-avatar-control": "end-call",
560
- "data-avatar-enabled": true,
561
- "aria-label": "End call",
562
- children: phoneIcon
563
- }
564
- )
578
+ showScreenShare && isScreenShareEnabled && /* @__PURE__ */ jsxRuntime.jsxs("div", { "data-avatar-share-indicator": "", "aria-live": "polite", children: [
579
+ /* @__PURE__ */ jsxRuntime.jsx("span", { "data-avatar-share-label": "", children: "You're sharing your screen" }),
580
+ /* @__PURE__ */ jsxRuntime.jsx("div", { "data-avatar-share-actions": "", children: /* @__PURE__ */ jsxRuntime.jsx(
581
+ "button",
582
+ {
583
+ type: "button",
584
+ onClick: handleStopScreenShare,
585
+ "data-avatar-share-action": "stop",
586
+ children: "Stop"
587
+ }
588
+ ) })
589
+ ] }),
590
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { "data-avatar-controls": "", children: [
591
+ showMicrophone && /* @__PURE__ */ jsxRuntime.jsx(
592
+ "button",
593
+ {
594
+ type: "button",
595
+ onClick: toggleMic,
596
+ "data-avatar-control": "microphone",
597
+ "data-avatar-enabled": isMicEnabled,
598
+ "aria-label": isMicEnabled ? "Mute microphone" : "Unmute microphone",
599
+ children: microphoneIcon
600
+ }
601
+ ),
602
+ showCamera && /* @__PURE__ */ jsxRuntime.jsx(
603
+ "button",
604
+ {
605
+ type: "button",
606
+ onClick: toggleCamera,
607
+ "data-avatar-control": "camera",
608
+ "data-avatar-enabled": isCameraEnabled,
609
+ "aria-label": isCameraEnabled ? "Turn off camera" : "Turn on camera",
610
+ children: cameraIcon
611
+ }
612
+ ),
613
+ showScreenShare && /* @__PURE__ */ jsxRuntime.jsx(
614
+ "button",
615
+ {
616
+ type: "button",
617
+ onClick: toggleScreenShare,
618
+ "data-avatar-control": "screen-share",
619
+ "data-avatar-enabled": isScreenShareEnabled,
620
+ "aria-label": isScreenShareEnabled ? "Stop sharing screen" : "Share screen",
621
+ children: screenShareIcon
622
+ }
623
+ ),
624
+ showEndCall && /* @__PURE__ */ jsxRuntime.jsx(
625
+ "button",
626
+ {
627
+ type: "button",
628
+ onClick: session.end,
629
+ "data-avatar-control": "end-call",
630
+ "data-avatar-enabled": true,
631
+ "aria-label": "End call",
632
+ children: phoneIcon
633
+ }
634
+ )
635
+ ] })
565
636
  ] });
566
637
  }
567
638
  var microphoneIcon = /* @__PURE__ */ jsxRuntime.jsxs(
568
639
  "svg",
569
640
  {
570
- width: "20",
571
- height: "20",
641
+ width: "16",
642
+ height: "16",
572
643
  viewBox: "0 0 24 24",
573
644
  fill: "none",
574
645
  stroke: "currentColor",
@@ -586,8 +657,8 @@ var microphoneIcon = /* @__PURE__ */ jsxRuntime.jsxs(
586
657
  var cameraIcon = /* @__PURE__ */ jsxRuntime.jsxs(
587
658
  "svg",
588
659
  {
589
- width: "20",
590
- height: "20",
660
+ width: "16",
661
+ height: "16",
591
662
  viewBox: "0 0 24 24",
592
663
  fill: "none",
593
664
  stroke: "currentColor",
@@ -604,8 +675,8 @@ var cameraIcon = /* @__PURE__ */ jsxRuntime.jsxs(
604
675
  var screenShareIcon = /* @__PURE__ */ jsxRuntime.jsxs(
605
676
  "svg",
606
677
  {
607
- width: "20",
608
- height: "20",
678
+ width: "16",
679
+ height: "16",
609
680
  viewBox: "0 0 24 24",
610
681
  fill: "none",
611
682
  stroke: "currentColor",
@@ -614,17 +685,19 @@ var screenShareIcon = /* @__PURE__ */ jsxRuntime.jsxs(
614
685
  strokeLinejoin: "round",
615
686
  "aria-hidden": "true",
616
687
  children: [
617
- /* @__PURE__ */ jsxRuntime.jsx("rect", { width: "20", height: "14", x: "2", y: "3", rx: "2" }),
618
- /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "8", x2: "16", y1: "21", y2: "21" }),
619
- /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "12", x2: "12", y1: "17", y2: "21" })
688
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M13 3H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-3" }),
689
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M8 21h8" }),
690
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M12 17v4" }),
691
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "m17 8 5-5" }),
692
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M17 3h5v5" })
620
693
  ]
621
694
  }
622
695
  );
623
696
  var phoneIcon = /* @__PURE__ */ jsxRuntime.jsx(
624
697
  "svg",
625
698
  {
626
- width: "20",
627
- height: "20",
699
+ width: "16",
700
+ height: "16",
628
701
  viewBox: "8 14 24 12",
629
702
  fill: "currentColor",
630
703
  "aria-hidden": "true",