@forinda/video-sdk-vue
v1.0.0
Published
Vue 3 composables and components for the Forinda RTC SDK
Maintainers
Readme
@forinda/video-sdk-vue
Vue 3 composables and a VideoView component for the Forinda RTC SDK. Idiomatic wrappers around the framework-agnostic Publisher / Viewer from @forinda/video-sdk-core.
Install
pnpm add @forinda/video-sdk-vue @forinda/video-sdk-core @forinda/video-sdk-signaling-ws vuePeer deps:
@forinda/video-sdk-core,vue@>=3.4. Composition API only — Options API is not supported (the reactive boundary doesn't fit).@forinda/video-sdk-signaling-wsis the WebSocket transport used in the quick start — swap or omit if you bring your own.
Quick start
// main.ts
import { createApp } from "vue";
import { VideoSdkPlugin } from "@forinda/video-sdk-vue";
import { defineWebSocketSignaling } from "@forinda/video-sdk-signaling-ws";
import App from "./App.vue";
createApp(App)
.use(VideoSdkPlugin, {
signaling: () => defineWebSocketSignaling({ url: "wss://signal.example.com" }),
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
})
.mount("#app");<!-- App.vue -->
<script setup lang="ts">
import { useUserMedia } from "@forinda/video-sdk-vue";
import Broadcast from "./Broadcast.vue";
// Camera resolves asynchronously; mount Broadcast only when stream is ready.
const { stream } = useUserMedia({ audio: true, video: true });
</script>
<template>
<Broadcast v-if="stream" :stream="stream" />
</template><!-- Broadcast.vue -->
<script setup lang="ts">
import { usePublisher, VideoView } from "@forinda/video-sdk-vue";
const props = defineProps<{ stream: MediaStream }>();
const { state, viewers } = usePublisher({ room: "demo", stream: props.stream });
</script>
<template>
<VideoView :stream="props.stream" muted autoplay playsinline mirror />
<p>state: {{ state }}, viewers: {{ viewers.length }}</p>
</template>
usePublisherreadsstreamonce at construction. Mounting viav-if="stream"(or attaching to aRoom) keeps the wiring straightforward — swapping the camera mid-call is done withreplaceVideoTrack(...), not by re-rendering the composable.
Composables
Every composable returns Vue refs. Functions returned alongside (start, stop, setAttribute, …) are stable across re-renders — safe to use as event handlers.
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 scope dispose. Uses AbortController so an in-flight request that finishes after dispose still tears down its stream. SSR-safe (returns inert refs on the server).
useDisplayMedia(opts?)
Request a screen-share MediaStream via getDisplayMedia. Defaults to manual start (most apps want a button click); pass { autoStart: true } to open the picker on creation.
const { stream, state, start, stop } = useDisplayMedia();
// state: "idle" | "requesting" | "granted" | "denied" | "ended" | "error"
await start(); // opens browser pickerThe "ended" state fires when the user clicks the browser's own "Stop sharing" pill — wire it to swap back to the camera or to tear down a screen-share publisher.
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 refs.
const {
publisher,
state,
viewers,
stats,
error,
start,
stop,
replaceVideoTrack,
replaceAudioTrack,
} = usePublisher({
room: "demo",
stream, // a MediaStream (use v-if to defer construction until ready)
autoStart: true, // default
stats: { interval: 1000 }, // optional
});If signaling / iceServers / retry aren't supplied, the composable falls back to the VideoSdkPlugin config.
useViewer(opts)
Symmetric to usePublisher.
const { viewer, state, stream, stats, error, start, stop } = useViewer({
room: "demo",
publisherId: "alice",
});stream is null until the first track event fires; pass it to <VideoView>.
useConnectionStats(publisherOrViewer, { interval? })
Standalone composable for consumers managing their own Publisher/Viewer who want stats.
const stats = useConnectionStats(publisher); // Ref<ConnectionStats[]>
const oneViewer = useConnectionStats(viewer); // Ref<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 the transport without the duplicate-join footgun.
const { room, state, error } = useRoom({ room: "demo", peerId: "alice" });
const { publisher } = usePublisher({ attach: room.value!, stream });
const { channel } = useRoomChannel({ attach: room.value! });
const { messages } = useChat(channel);| Option | Default | Description |
| ----------- | --------------------- | ------------------------------- |
| room | — | Required. Room id. |
| peerId | crypto.randomUUID() | Self id. |
| signaling | from plugin | Pre-built SignalingTransport. |
When attached, the child composables ignore their own room / peerId / signaling options (taken from the Room).
useRoomChannel(opts)
Construct a RoomChannel (presence + chat) for the lifetime of the calling component. Falls back to the plugin's signaling factory when opts.signaling is omitted.
const { channel, state, error } = useRoomChannel({ room: "demo", peerId: "alice" });
// state: "idle" | "connecting" | "connected" | "reconnecting" | "closed"| Option | Default | Description |
| ------------------ | --------------------- | --------------------------------------------------------------------------------- |
| room | — | Required. |
| peerId | crypto.randomUUID() | Self id. |
| signaling | from plugin | 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 creation. |
usePresence(channel)
Live snapshot of every peer's attributes plus stable write callbacks. Re-fires on presence, presence-snapshot, and peer-left events.
const { peers, setAttribute, removeAttribute, clearAttributes } = usePresence(channel);
// peers: Ref<Record<peerId, Record<string, JsonValue>>>
await setAttribute("status", "🎬");Accepts a Ref<RoomChannel | null>, a getter, or a plain value. Typically the channel ref returned by useRoomChannel — it'll re-subscribe automatically when the channel finishes constructing.
useChat(channel)
Live chat history plus a send callback. Omit to for a broadcast; pass a peerId for a DM. send() resolves with the entry's id.
const { messages, send } = useChat(channel);
const id = await send("hello room");
await send("psst", { to: "bob" });
// Status updates: subscribe via the underlying channel.
watch(channel, (ch, _prev, onCleanup) => {
if (!ch) return;
const off = ch.on("chat-status", ({ id, status }) => {
// status: "pending" | "confirmed" | "failed"
});
onCleanup(off);
});messages.value[i].status is "pending" until the server echoes the message; the chatAckTimeoutMs (default 10s) flips 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);raised is a computed ref.
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 scope dispose.
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: Ref<string | null>
// Lazy URL.createObjectURL(blob), revoked automatically on next blob /
// scope dispose. Bind directly to <a download :href="downloadUrl">...When stream is null (e.g. before useUserMedia resolves) the composable returns inert refs and start() is a no-op.
useUploader(recorder, uploader)
Wire a Recorder to an Uploader for the lifetime of the active scope. Returns reactive { state, pendingBytes, error, retry }.
import { defineRecorder, defineUploader } from "@forinda/video-sdk-core";
import { useUploader, useUserMedia } from "@forinda/video-sdk-vue";
const { stream } = useUserMedia({ audio: true, video: true });
// Construct recorder/uploader once stream is ready (use v-if pattern).
const recorder = defineRecorder(stream.value!, { timesliceMs: 1000 });
const uploader = defineUploader({ url: "/api/uploads" });
const { state, pendingBytes, retry } = useUploader(recorder, uploader);When the uploader transitions to "failed", the recorder pauses; retry() resumes it.
Components
<VideoView :stream="..." />
<video> wrapper that handles srcObject (which can't go through <video :src>) and exposes the underlying element via defineExpose({ video }). Optional mirror prop applies transform: scaleX(-1) for selfie preview.
<VideoView :stream="stream" muted autoplay playsinline mirror />Forwards every other <video> attribute (controls, poster, class, style, listeners…). Get a ref to the underlying element via defineExpose:
<script setup lang="ts">
import { ref } from "vue";
import { VideoView } from "@forinda/video-sdk-vue";
const view = ref<{ video: HTMLVideoElement | null } | null>(null);
function fullscreen() {
view.value?.video?.requestFullscreen();
}
</script>
<template>
<VideoView ref="view" :stream="stream" />
<button @click="fullscreen">Fullscreen</button>
</template>Plugin
app.use(VideoSdkPlugin, config)
Optional — supplies default config to every composable in the tree via Vue's provide / inject. Per-composable overrides win.
signaling is a factory (not an instance) so each usePublisher / useViewer gets its own transport. Multiple components in one tree don't fight over one socket.
app.use(VideoSdkPlugin, {
signaling: () => defineWebSocketSignaling({ url: "wss://..." }),
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
retry: { maxAttempts: 5 },
});The plugin is optional — composables that receive opts.signaling directly bypass it entirely.
Behavior
Reactivity
- All returned values are refs (
Ref<T>). Read with.valuein<script setup>or directly in templates. - The composables internally use
shallowReffor foreign objects (MediaStream, Publisher, Recorder…). Identity comparisons in templates and watchers work as expected. - Functions returned (
start,stop,send, …) are stable — they read live state via captured closures, so calling them after a re-render gets the current value.
SSR (Nuxt-friendly)
useUserMedia,useDevices, anduseDisplayMediaearly-return inert refs on the server (typeof window === "undefined").usePublisher,useViewer,useRoom,useRoomChannel, anduseRecorderskip construction on the server.<VideoView>renders an empty<video>element.
No hydration mismatch warnings expected.
Cleanup ordering
When the active scope disposes (component unmount, manual effectScope().stop()):
- Event listeners detach.
- The underlying
Publisher/Viewer/RoomChannel/Room.stop()/.close()is called (void-ed; dispose hooks are sync). - Refs reset to initial values.
For useUserMedia, MediaStreamTrack.stop() is called on every track in the active stream.
Pitfalls
usePublisher({ stream })readsstreamonce. Pass a stream that's already resolved (viav-if) or attach to a room. Reactive prop changes are not picked up — that would tear down + rebuild the publisher on every camera change.<VideoView>autoplay requiresmutedin modern browsers (Chromium/Safari). If you want unmuted autoplay, ensure a user gesture has fired first.VideoSdkPlugin.signalingis a factory, not an instance. Passing an instance would mean every component in the tree shares one transport — usually wrong. The signature enforces the factory shape.- No Options API. Composables only work in
setup()/<script setup>. - Vue 2 not supported.
License
MIT — © 2026 Felix Orinda.
