npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@forinda/video-sdk-react

v1.0.0

Published

React hooks and components for the Forinda video SDK

Readme

@forinda/video-sdk-react

React 18+ hooks and components for the Forinda RTC SDK. Provides ergonomic wrappers around the framework-agnostic Publisher / Viewer from @forinda/video-sdk-core.

Install

pnpm add @forinda/video-sdk-react @forinda/video-sdk-core @forinda/video-sdk-signaling-ws react react-dom

Peer deps: @forinda/video-sdk-core, react@>=18. The hooks rely on useSyncExternalStore, so React 17 isn't supported. @forinda/video-sdk-signaling-ws is the WebSocket transport used in the quick-start — swap or omit if you bring your own.

Quick start

import { VideoSdkProvider, usePublisher, useUserMedia, VideoView } from "@forinda/video-sdk-react";
import { defineWebSocketSignaling } from "@forinda/video-sdk-signaling-ws";

function App() {
  return (
    <VideoSdkProvider
      signaling={() => defineWebSocketSignaling({ url: "wss://signal.example.com" })}
      iceServers={[{ urls: "stun:stun.l.google.com:19302" }]}
    >
      <Broadcast />
    </VideoSdkProvider>
  );
}

function Broadcast() {
  const { stream } = useUserMedia({ audio: true, video: true });
  const { state, viewers } = usePublisher({ room: "demo", stream });

  return (
    <>
      <VideoView stream={stream} muted autoPlay playsInline mirror />
      <p>
        state: {state}, viewers: {viewers.length}
      </p>
    </>
  );
}

Hooks

useUserMedia(opts)

Request a MediaStream, manage tracks, expose state.

const { stream, error, state, refresh, stop } = useUserMedia({ audio: true, video: true });
// state: "idle" | "requesting" | "granted" | "denied" | "error"

Cleans up tracks on unmount; uses AbortController so StrictMode double-mount in dev is idempotent. SSR-safe (returns { stream: null, state: "idle" } on the server).

useDisplayMedia(opts?)

Request a screen-share MediaStream via getDisplayMedia. Defaults to manual start (most apps want a button click), with { autoStart: true } to open the picker on mount.

const { stream, state, start, stop } = useDisplayMedia();
// state: "idle" | "requesting" | "granted" | "denied" | "ended" | "error"

await start(); // opens browser picker
// later:
publisher?.replaceVideoTrack(stream!.getVideoTracks()[0]); // swap into existing publisher

The "ended" state fires when the user clicks the browser's own "Stop sharing" pill — wire it to swap back to the camera or unmount.

useDevices()

Subscribe to camera/mic/speaker enumeration with hotplug auto-refresh.

const { cameras, microphones, speakers, refresh } = useDevices();

usePublisher(opts)

Construct + manage a Publisher. Subscribes to all the events you care about and exposes them as a snapshot.

const {
  publisher,
  state,
  viewers,
  stats,
  error,
  start,
  stop,
  replaceVideoTrack,
  replaceAudioTrack,
} = usePublisher({
  room: "demo",
  stream, // from useUserMedia
  autoStart: true, // default
  stats: { interval: 1000 }, // optional
});

If signaling / iceServers / retry aren't supplied, the hook falls back to the nearest VideoSdkProvider.

useViewer(opts)

Symmetric to usePublisher for the viewer side.

const { viewer, state, stream, stats, error, start, stop } = useViewer({
  room: "demo",
  publisherId: "alice",
  autoStart: true,
});

stream is null until the first track event fires; pass it to <VideoView>.

useConnectionStats(publisherOrViewer, { interval? })

Standalone hook for consumers managing their own Publisher/Viewer who want stats.

const stats = useConnectionStats(publisher); // ConnectionStats[]
const oneViewer = useConnectionStats(viewer); // ConnectionStats | null

useRoom(opts)

Construct a Room for the calling component's lifetime. The Room owns one signaling transport and one join; pair it with usePublisher({ attach: room }), useViewer({ attach: room }), useRoomChannel({ attach: room }) to share that transport without the duplicate-join footgun.

const { room, state, error } = useRoom({ room: "demo", peerId: "alice" });
const { publisher } = usePublisher({ attach: room, stream });
const { channel } = useRoomChannel({ attach: room });
const { messages } = useChat(channel);

| Option | Default | Description | | ----------- | --------------------- | ------------------------------- | | room | — | Required. Room id. | | peerId | crypto.randomUUID() | Self id. | | signaling | from provider | Pre-built SignalingTransport. |

When attached, the child hooks ignore their own room / peerId / signaling options (taken from the Room). Standalone usage is unchanged.

Director / moderation surface (EPIC-12)

useRoom() also returns role, directors, and sendCommand for moderation UIs:

const { room, role, directors, sendCommand } = useRoom({
  room: "demo",
  peerId: "alice",
});

// Claim director after the Room is constructed:
useEffect(() => {
  if (!room) return;
  void room.ensureConnected().then(() => room.ensureJoined("director"));
}, [room]);

const isDirector = role === "director" || directors.includes("alice");

return (
  <>
    {isDirector && (
      <button onClick={() => sendCommand({ type: "mute", target: "bob", kind: "audio" })}>
        Mute Bob
      </button>
    )}
  </>
);

The available command shapes: mute / unmute ({ target, kind }), kick ({ target, reason? }), promote / demote ({ target }), set-bitrate ({ target, bitsPerSec }).

When the engine has enforceModerationCommands: true, non-director senders are rejected with SignalingPermissionError. Default honor mode just relays the command and lets the target's client decide whether to obey.

useRoomChannel(opts)

Construct a RoomChannel (presence + chat) for the lifetime of the calling component. Falls back to VideoSdkProvider's signaling factory when opts.signaling is omitted. Pass attach: room to share a Room's transport.

const { channel, state, error } = useRoomChannel({ room: "demo", peerId: "alice" });
// state: "idle" | "connecting" | "connected" | "reconnecting" | "closed"
//   — passthrough of the underlying transport state, useful for "Connecting…" UI

| Option | Default | Description | | ------------------ | --------------------- | --------------------------------------------------------------------------------- | | room | — | Required. | | peerId | crypto.randomUUID() | Self id. | | signaling | from provider | Pre-built SignalingTransport. | | manageJoin | true | Issue join + leave. Set false when sharing a transport with a Publisher/Viewer. | | chatHistoryLimit | 200 | Rolling chat-buffer cap. | | autoStart | true | Call channel.start() on mount. |

usePresence(channel)

Live snapshot of every peer's attributes plus stable write callbacks. Re-renders on presence, presence-snapshot, and peer-left events.

const { peers, setAttribute, removeAttribute, clearAttributes } = usePresence(channel);
// peers: Record<peerId, Record<string, JsonValue>>
await setAttribute("status", "🎬");

useChat(channel)

Live chat history plus a send callback. Omit to for a room-wide broadcast; pass a peerId for a DM. send() resolves with the entry's id so you can correlate with chat-status events on the underlying channel.

const { messages, send } = useChat(channel);
const id = await send("hello room");
await send("psst", { to: "bob" });

// Subscribe to status changes via the underlying channel:
useEffect(() => {
  if (!channel) return;
  return channel.on("chat-status", ({ id, status }) => {
    // status: "pending" | "confirmed" | "failed"
  });
}, [channel]);

messages[i].status is "pending" until the server echoes the message back, then "confirmed". A chatAckTimeoutMs lapse (default 10s) flips it to "failed". Each entry's id matches the chat-status event payload.

useRaiseHand(channel)

Sugar over usePresence for the most common interaction pattern. Reads the channel peer's own "hand-raised" attribute.

const { raised, raise, lower, toggle } = useRaiseHand(channel);

useRecorder(stream, opts?)

Record any MediaStream to a Blob. Wraps core's defineRecorder; constructs the MediaRecorder lazily on the first start() call and auto-stops on unmount.

const { state, blob, downloadUrl, chunks, error, start, stop, pause, resume } = useRecorder(
  stream,
  {
    mimeType: "video/webm;codecs=vp9,opus",
    videoBitsPerSecond: 2_500_000,
    timesliceMs: 1_000,
    maxBufferedBytes: 200 * 1024 * 1024, // 200 MB cap
  },
);
// state: "idle" | "recording" | "paused" | "stopped" | "error"
//
// downloadUrl: string | null
//   Lazy URL.createObjectURL(blob), revoked automatically on next blob / unmount.
//   Bind directly to <a download href={downloadUrl}>...

When stream is null (e.g. before useUserMedia resolves) the hook returns inert state and start() is a no-op.

useUploader(recorder, uploader)

Wire a Recorder to an Uploader for the lifetime of the calling component. Returns { state, pendingBytes, error, retry } for "Uploading…" / "Failed (retry?)" UI.

import { defineRecorder, defineUploader } from "@forinda/video-sdk-core";
import { useMemo } from "react";

const { stream } = useUserMedia({ audio: true, video: true });
const recorder = useMemo(() => stream && defineRecorder(stream, { timesliceMs: 1000 }), [stream]);
const uploader = useMemo(() => defineUploader({ url: "/api/uploads" }), []);
const { state, pendingBytes, retry } = useUploader(recorder, uploader);

return (
  <>
    <p>
      Upload: {state} ({pendingBytes} bytes pending)
    </p>
    {state === "failed" && <button onClick={retry}>Retry</button>}
  </>
);

When the uploader transitions to "failed" the underlying recorder is paused automatically; retry() resumes it.

Components

<VideoView stream={...} />

<video> wrapper that handles srcObject (which doesn't fit React's prop model) and forwards refs. Optional mirror prop applies transform: scaleX(-1) for selfie preview.

<VideoView stream={stream} muted autoPlay playsInline mirror />

Forwards every other <video> attribute (controls, poster, className, style, …).

Provider

<VideoSdkProvider signaling={...} iceServers={...} retry={...}>

Optional context — supplies default config to all hooks. Per-hook overrides win.

signaling is a factory (not an instance) so each usePublisher / useViewer gets its own transport. Multiple instances in one tree don't fight over one socket.

<VideoSdkProvider
  signaling={() => defineWebSocketSignaling({ url: "wss://..." })}
  iceServers={[{ urls: "stun:stun.l.google.com:19302" }]}
  retry={{ maxAttempts: 5 }}
>
  <App />
</VideoSdkProvider>

Behavior

StrictMode

Every effect uses AbortController cleanup. The dev-only mount→unmount→remount pattern is idempotent: torn-down media streams are stopped, and the remount fires a fresh request.

SSR

  • useUserMedia and useDevices early-return inert state on the server (typeof window === "undefined").
  • usePublisher and useViewer skip the construction effect on the server.
  • <VideoView> renders an empty <video> element.

No hydration mismatch warnings expected.

Cleanup ordering

When a hook unmounts:

  1. The AbortController aborts.
  2. All event listeners detach.
  3. The underlying Publisher / Viewer .stop() is called (void-ed; the cleanup function is sync).
  4. State setters reset to initial values.

For useUserMedia, MediaStreamTrack.stop() is called on every track in the active stream.

Pitfalls

  • useSyncExternalStore requires React 18+. No fallback shim.
  • <VideoView> autoplay requires muted in modern browsers (Chromium/Safari). If you want unmuted autoplay, ensure a user gesture has occurred first.
  • VideoSdkProvider.signaling is a factory, not an instance. Passing an instance means every hook shares one transport — usually wrong. The signature enforces the factory shape.
  • opts.stream === null skips usePublisher's construction. This is intentional — Publisher needs a stream. Wrap the publisher render in a guard or pass a stream.

License

MIT — © 2026 Felix Orinda.