@rhombussystems/react
v2.3.1
Published
React components for Rhombus video, audio playback, and two-way talkback
Keywords
Readme
Rhombus React SDK — @rhombussystems/react
React + TypeScript components for embedding Rhombus video and audio in your own app. The SDK supports MPEG-DASH video, low-latency H.264, live and historical A100/DR40 audio, synchronized wall-clock controls, and browser-microphone talkback.
Your Rhombus API key never ships to the browser. Everything is built around short-lived federated session tokens minted by your backend (see Authentication).
Version: this guide tracks
@rhombussystems/react2.2.0. React 18+.
Contents
- Install
- Quick start
- Audio full-stack quick start
- Choosing a component
RhombusMediaPlayer— complete video, audio, and talkbackRhombusPlayer— the unified playerRhombusAudioPlayer— A100 and DR40 audio- Audio sources and device UUIDs
- Standalone live audio
- Live and historical transports
- Audio props
- Built-in, selective, and custom controls
- Imperative and controlled playback
- Shared video/audio playback
- DR40 audio ownership
- Callbacks, state, and recovery
- Authentication and network modes
- Styling and browser behavior
RhombusTalkback— A100 and DR40 two-way audioRhombusBufferedPlayer— DASH live & VODRhombusRealtimePlayer— low-latency liveTimeline— standalone scrubber- Authentication & tokens
- WAN vs LAN
- Stream quality
- Auto-recovery / reconnect
- Backend contract
- Exported API surface
- Browser support
- Troubleshooting
- Migrating from 2.1 → 2.2
- Migrating from 2.0 → 2.1
- Migrating from 1.x → 2.0
- License
Install
npm install @rhombussystems/react
# or: yarn add @rhombussystems/react / pnpm add @rhombussystems/reactreactandreact-dom(>= 18) are peer dependencies — install them in your app.dashjsand the worker-backed Opus decoder are bundled — you do not install them separately.- The realtime/canvas path uses the browser WebCodecs
VideoDecoder(Chrome, Edge, Safari 16.4+; Firefox H.264 is still limited) — no extra dependency. - This is a browser media package. In an SSR framework, load the SDK from a client-only module; Dash.js evaluates browser globals when the package is imported.
Quick start
A complete live/VOD player with controls, a timeline, zoom, snapshot, and clip export —
from a single cameraUuid:
import { RhombusPlayer } from "@rhombussystems/react";
export function CameraView() {
return (
<RhombusPlayer
cameraUuid="YOUR_CAMERA_UUID"
apiOverrideBaseUrl="https://your-api.example.com" // proxy mode (recommended)
style={{ height: 480 }}
/>
);
}⚠️ Server setup is required. The SDK calls your origin for a token. Your server must expose
POST /api/federated-token(the default path) or setpaths.federatedTokento your route. Built-in Save Clip, proxy-mode audio, and talkback capability policy additionally need application-owned proxy routes. See the Backend contract.
Prefer to compose your own layout? Drop down to the individual building blocks — each has a deep-dive section further down the page:
RhombusBufferedPlayer— MPEG-DASH live & VOD on a real<video>element; native pause/seek, widest browser support.RhombusRealtimePlayer— sub-second live H.264 over WebSocket, decoded with WebCodecs onto a<canvas>(live only).RhombusAudioPlayer— A100/DR40 live Opus and historical audio, standalone or synchronized with video.RhombusTalkback— send the browser microphone to an A100 or DR40, with Console-aligned RBAC/license/config policy.
Audio full-stack quick start
This is the recommended starting point when a page needs the complete audio feature set:
- live and historical A100 or DR40 listening;
- video and audio driven by one epoch-ms timeline;
- browser-microphone talkback;
- automatic A100/DR40 click-to-talk versus hold-to-talk behavior;
- optional talkback blocking while the operator is viewing history;
- matching-source echo suppression while the operator is speaking; and
- automatic DR40 ownership handoff so embedded video audio is not played twice.
import {
RhombusMediaPlayer,
type RhombusAudioSource,
} from "@rhombussystems/react";
type AudioStationProps = {
audioSource: RhombusAudioSource;
/** Omit for an audio-only page. */
cameraUuid?: string;
};
export function AudioStation({ audioSource, cameraUuid }: AudioStationProps) {
return (
<RhombusMediaPlayer
cameraUuid={cameraUuid}
audioSource={audioSource}
apiOverrideBaseUrl="/"
/>
);
}Use it with an A100 audio gateway:
<AudioStation
cameraUuid="CAMERA_UUID"
audioSource={{ type: "audio-gateway", uuid: "A100_AUDIO_GATEWAY_UUID" }}
/>Use it with a DR40:
<AudioStation
cameraUuid="DR40_DEVICE_UUID"
audioSource={{ type: "dr40", uuid: "DR40_DEVICE_UUID" }}
/>For a DR40, the video cameraUuid and audioSource.uuid must be the same device UUID for
automatic audio ownership handoff — RhombusMediaPlayer then also infers
deviceType: "doorbell" for the video participant, so its media resolves through
/doorbellcamera/getMediaUris. (When composing RhombusPlayer yourself, pass
deviceType="doorbell" explicitly.) An A100 uses its audio gateway UUID, which is normally
different from the camera UUID. RhombusMediaPlayer creates and shares the controller
automatically: the video timeline seeks both streams, the talkback control knows whether the
page is live or historical, and matching incoming far-audio is suppressed while speaking.
Use the lower-level composition recipe when your layout needs independent placement of those
participants.
Your application backend must expose these routes (defaults shown):
| Browser route | Purpose | Rhombus upstream |
| --- | --- | --- |
| POST /api/federated-token | Mint a short-lived browser token. | /org/generateFederatedSessionToken |
| POST /api/audio-media-uris | Resolve A100 or DR40 live/VOD media URIs. | /audiogateway/getMediaUris or /doorbellcamera/getMediaUris |
| POST /api/audio-talkback-capabilities | Normalize device-scope authorization, Enterprise licensing, speaker configuration, connectivity, and interaction mode. | Accessible-device inventory, getConfig, and /license/getDeviceLicenses |
The backend keeps the API key secret. The realtime audio WebSocket separately authenticates the federated token and must authorize its permission group for the requested device; the capability route is a user-interface policy check, not the WebSocket security boundary. Copyable route contracts are in Backend contract.
Before testing, confirm:
- The server-side API key can see the selected device through its assigned permission group.
- The A100/DR40 has an Enterprise device license, its speaker is enabled, and it is online.
- The federated token is minted for the browser's deployed domain.
- The page is served over HTTPS (or localhost), microphone access is allowed by the browser, operating system, and iframe policy, and the media/worker hosts are allowed by CSP.
- The user explicitly unmutes listening audio and starts talkback from a click, tap, or keyboard gesture so browser media activation succeeds.
Talkback is allowed while viewing historical footage by default. Set
disableTalkbackInVod when the operator must return to live before speaking. Talkback always
reaches the physical device now; it is never scheduled at the historical playhead.
Choosing a component
| Component | Transport | Live latency | Live | Past (VOD) | Controls |
| --------------------------- | --------------------------------------------------------------------- | --------------- | ---- | ---------- | ---------------------- |
| RhombusMediaPlayer | unified video + A100/DR40 audio + talkback | sub-second live | ✅ | ✅ | ✅ complete experience |
| RhombusPlayer | both — realtime canvas for live, DASH for VOD, switched automatically | sub-second live | ✅ | ✅ | ✅ full bar + ref API |
| RhombusAudioPlayer | live Opus + historical DASH/decoded Opus | sub-second live | ✅ | ✅ | ✅ full bar + ref API |
| RhombusTalkback | browser microphone → PCM16/WebSocket → A100/DR40 speaker | sub-second | ✅ | n/a | ✅ mic control + ref API |
| RhombusBufferedPlayer | MPEG-DASH (Dash.js) on a <video> | ~few seconds | ✅ | ✅ | native <video> |
| RhombusRealtimePlayer | H.264 / WebSocket → WebCodecs → <canvas> | sub-second | ✅ | ❌ | none (always live) |
| Timeline | none — a canvas scrubber you pair with any media | — | — | — | seek UI only |
Rule of thumb: start with RhombusMediaPlayer for the complete experience. Reach for
RhombusPlayer, RhombusAudioPlayer, and RhombusTalkback separately when the application
needs custom participant placement or lifecycle. Give separate participants the same shared
controller when playback time, VOD talk policy, and two-way-audio echo handling must
coordinate.
RhombusMediaPlayer — complete video, audio, and talkback
RhombusMediaPlayer is the high-level facade for the most common integration. It composes
the existing video, audio, talkback, and controller implementations; it does not introduce a
second transport stack. The required audioSource name is deliberately explicit because
cameraUuid identifies a different, optional video participant.
Identity props: why audioSource is required
The two identity props name two different participants:
| Prop | Identifies | When required |
| --- | --- | --- |
| audioSource | The A100 audio gateway or DR40 used for incoming audio and talkback. | Always |
| cameraUuid | The optional camera that supplies video and the shared timeline. | Only for video + audio |
Therefore omitting cameraUuid means audio-only; it does not make audioSource generic.
For a DR40 A/V station, use the same DR40 UUID for both props. For an A100 station,
audioSource.uuid is the A100 gateway UUID and cameraUuid is the separately chosen camera.
Copy-paste defaults
The minimum audio-only experience is:
import { RhombusMediaPlayer } from "@rhombussystems/react";
<RhombusMediaPlayer
audioSource={{ type: "audio-gateway", uuid: "A100_AUDIO_GATEWAY_UUID" }}
apiOverrideBaseUrl="/"
/>;Add cameraUuid for synchronized video:
<RhombusMediaPlayer
cameraUuid="CAMERA_UUID"
audioSource={{ type: "audio-gateway", uuid: "A100_AUDIO_GATEWAY_UUID" }}
apiOverrideBaseUrl="/"
/>;Those examples intentionally pass no control configuration, participant overrides, custom styles, callbacks, or external controller. Out of the box, the facade supplies:
- the complete audio toolbar and audio timeline on an audio-only page;
- the video toolbar and its single shared timeline when
cameraUuidis present; - only volume control on the separate audio row when video owns the timeline;
- listening audio, initially muted for browser autoplay compatibility;
- talkback with the device-resolved click-to-talk or hold-to-talk interaction;
- live and historical playback with synchronized timeline seeks;
- automatic matching-DR40 audio ownership handoff; and
- a private shared playback controller that requires no application wiring.
The sibling rhombus-react-example project exposes /media-player as a dedicated
out-of-the-box test page. Its selectors are only test-fixture UI; the rendered
RhombusMediaPlayer receives exactly the props shown above. Use its /audio page for the
advanced customization and diagnostics lab.
Defaults:
- talkback is rendered and follows the server-resolved hold/toggle policy;
- talkback remains available in VOD and always targets the live physical device;
- one private
RhombusPlaybackControllercoordinates every participant; - without video, audio renders its complete controls and wall-clock timeline;
- with video, the video owns the timeline and audio renders volume controls only;
- matching DR40 video/audio automatically hands buffered/VOD audio ownership to video;
- listening starts muted, volume
1, playing, live, and at rate1; and - the facade uses the same token, endpoint, network, recovery, and error props for every participant.
Set talkback={false} for playback only. Set disableTalkbackInVod when operators must
return to the live edge before speaking.
RhombusMediaPlayer props
| Prop | Type | Default | Purpose |
| --- | --- | --- | --- |
| audioSource | RhombusAudioSource | required | A100 audio gateway or DR40 used for listening and talkback. |
| cameraUuid | string | — | Optional synchronized video participant. Omit for audio-only. |
| deviceType | "camera" | "doorbell" | inferred | Video device family. Defaults to "doorbell" when cameraUuid matches a "dr40" audioSource, else "camera". |
| apiOverrideBaseUrl and shared media props | RhombusMediaBaseProps | SDK defaults | Applied consistently to video, audio, and talkback. |
| playbackController | RhombusPlaybackController | private controller | Join an existing playback group instead of creating one. |
| playbackOptions | RhombusPlaybackControllerOptions | controller defaults | Seed the private controller's mode, time, play state, rate, volume, mute, and timeline behavior. |
| talkback | boolean | true | Render or omit microphone talkback. |
| disableTalkbackInVod | boolean | false | Block and immediately stop TX while viewing history. |
| videoProps | RhombusMediaPlayerVideoProps | — | Video-specific controls, quality, fit, callbacks, and styling. |
| audioProps | RhombusMediaPlayerAudioProps | contextual controls | Audio-specific controls, VOD window, callbacks, and styling. |
| talkbackProps | RhombusMediaPlayerTalkbackProps | — | Talkback interaction, microphone, capability, callback, and styling overrides. |
| className / style | React root styling | — | Customize the facade root. |
| classNames / styles | facade slot maps | — | Customize root, video, audio, and talkback slots. Inline values override defaults. |
| onError / onRecoveryAttempt | shared callbacks | — | Receive failures/recovery from every participant. |
The nested prop types intentionally omit participant identity, shared authentication,
controller-owned playback state, and the shared controller itself. This prevents a nested
override from silently splitting the group. Use playbackOptions to seed internal state:
<RhombusMediaPlayer
cameraUuid={cameraUuid}
audioSource={audioSource}
apiOverrideBaseUrl="/"
disableTalkbackInVod
playbackOptions={{
initialMuted: true,
defaultRewindSec: 30,
}}
videoProps={{
videoFit: "contain",
timeline: { fetchSeekPoints: true },
}}
audioProps={{
vodWindowSec: 30 * 60,
}}
talkbackProps={{
microphoneGain: 5,
}}
/>;When cameraUuid is present, audioProps.controls defaults to ["volume"]. Pass an
explicit list to change it. Without a camera, an omitted control list renders the full audio
toolbar and timeline.
Styling and imperative access
The facade uses zero-specificity defaults on:
.rhombus-media-player.rhombus-media-player-video.rhombus-media-player-audio.rhombus-media-player-talkback
Normal CSS overrides those classes. classNames adds design-system classes, while styles
sets inline styles on the corresponding slots. The root also exposes
data-rhombus-media-has-video, data-rhombus-media-audio-source, and
data-rhombus-media-talkback for state-aware selectors:
<RhombusMediaPlayer
audioSource={audioSource}
className="security-station"
classNames={{ talkback: "security-station-microphone" }}
styles={{
root: { gap: 16 },
talkback: { borderColor: "var(--brand-border)" },
}}
/>;The RhombusMediaPlayerHandle exposes the shared playbackController plus
getVideoPlayer(), getAudioPlayer(), and getTalkback(). The getters return each existing
participant handle or null when that participant is disabled:
import { useRef } from "react";
import {
RhombusMediaPlayer,
type RhombusMediaPlayerHandle,
} from "@rhombussystems/react";
const media = useRef<RhombusMediaPlayerHandle>(null);
<RhombusMediaPlayer ref={media} audioSource={audioSource} />;
<button onClick={() => media.current?.playbackController.goLive()}>
Go live
</button>Use the lower-level components when participants must render in unrelated parts of the DOM, when separate controllers are intentional, or when application-specific layout exceeds the facade's video → audio → talkback ordering.
RhombusPlayer — the unified player
RhombusPlayer composes RhombusRealtimePlayer and RhombusBufferedPlayer behind one
interface and adds player-level controls: play/pause, go-live, rewind, playback speed,
digital zoom + pan, snapshot, an event-aware timeline, and save clip. It automatically
switches between Live and VOD as the user interacts with the timeline and Go-Live button.
import { RhombusPlayer } from "@rhombussystems/react";
<RhombusPlayer
cameraUuid="YOUR_CAMERA_UUID"
apiOverrideBaseUrl="https://your-api.example.com"
showLiveTypeSwitcher // optional Console-style Realtime/Buffered + quality menu
saveClip={{ defaultTitle: "Door cam" }}
timeline={{ fetchSeekPoints: true }} // 24h day window by default, ±12h chevrons
onModeChange={(mode, atMs) => console.log(mode, new Date(atMs))}
/>How Live ⇄ VOD switching works
Switching is a pure function of time vs. now:
- Live uses the realtime transport by default (
RhombusRealtimePlayer, WebCodecs canvas, sub-second). It auto-falls back to buffered DASH when WebCodecs is unavailable. - Pause, rewind, change speed, or seek into the past drops the player into VOD
(
RhombusBufferedPlayeranchored on a manifest window containing the target time). - Go Live (or seeking within
liveEdgeToleranceSecof now) returns to the live edge.
Only one transport is mounted at a time, so a switch costs one brief reconnect (no double
bandwidth). Seeking within the loaded VOD window is instant (native <video> seek);
seeking outside it loads a fresh manifest window. A seek preserves the play/pause
state: if playback was paused, it stays paused at the new time; if playing, it keeps
playing (seeking to the live edge always resumes, since realtime live cannot be paused).
RhombusPlayer props
Every prop RhombusPlayer accepts. Only cameraUuid is required; everything else is
optional. (The auth / endpoint / resilience props are the shared base props
common to all players.)
| Prop | Type | Required | Default | Notes |
| ---------------------------- | ----------------------------------------------------- | -------- | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| cameraUuid | string | ✅ | — | Camera UUID from Rhombus. Safe in the browser. For a DR40 this is the doorbell's device UUID. |
| deviceType | "camera" | "doorbell" | — | "camera" | Set "doorbell" for DR40 video intercoms so media/seekpoint/availability requests target the /doorbellcamera/* endpoints (direct mode) or tag proxy bodies for routing. |
| connectionMode | "wan" | "lan" | — | "wan" | Which getMediaUris URIs to use. See WAN vs LAN. |
| apiOverrideBaseUrl | string | — | — | Base for the token and media requests (proxy mode). Required for built-in Save Clip. When omitted, media is fetched directly from Rhombus. |
| rhombusApiBaseUrl | string | — | https://api2.rhombussystems.com/api | Rhombus REST base when apiOverrideBaseUrl is omitted. |
| paths | RhombusPlayerPaths | — | see backend | Override video, audio, token, seekpoint, and availability routes. |
| federatedSessionToken | string | — | — | Supply & rotate your own token; the SDK skips its token endpoint. |
| tokenDurationSec | number | — | 86400 | Requested token TTL (SDK-managed mode). |
| headers | HeadersInit | — | — | Static headers for the token request (+ media when apiOverrideBaseUrl set). |
| getRequestHeaders | () => HeadersInit | Promise<…> | — | — | Async headers merged after headers. |
| maxRetryIntervalMs | number | — | 30000 | Auto-recovery backoff ceiling. 0 disables. |
| stallTimeoutMs | number | — | 12000 | Stall watchdog. 0 disables. |
| playbackController | RhombusPlaybackController | — | private controller | Join one video and one audio participant. Controller playhead/rate/mute/volume state takes precedence over equivalent player props. |
| liveTransport | "realtime" | "buffered" | — | "realtime" | Live transport. Controllable. Auto-falls back to buffered without WebCodecs. |
| videoFit | "contain" | "cover" | "fill" | "auto" | — | "auto" | How the video fills its area. Controllable; built-in "videoFit" control changes it. See Video display / fit. |
| onVideoFitChange | (fit) => void | — | — | Fired when the video-display fit changes. |
| playing | boolean | — | — | Controlled play/pause. Omit = uncontrolled (starts playing). Pair with onPlayingChange. See Controlled vs. imperative. |
| playbackRate | number | — | — | Controlled VOD speed (no-op while live). Pair with onPlaybackRateChange. |
| zoom | number (1–4) | — | — | Controlled digital zoom. Pair with onZoomChange. |
| positionMs | number (epoch ms) | — | — | Controlled playhead — seeks when its value changes; mode is derived (near now ⇒ live). Mirror onProgress/onSeek for two-way binding. |
| showLiveTypeSwitcher | boolean | — | false | Render the Console-style Realtime/Buffered + quality menu in the bar. |
| realtimeStreamQuality | "HD" | "SD" | — | "HD" | Live quality when the resolved transport is realtime. |
| bufferedStreamQuality | "HIGH" | "MEDIUM" | "LOW" | — | "HIGH" | DASH quality for buffered live + VOD. |
| applyBufferedStreamQuality | boolean | — | true | Set false to omit the _ds downscale. |
| initialMode | "live" | "vod" | — | "live" | Start live or jump straight into the past. |
| initialStartTimeMs | number (epoch ms) | — | — | Anchor used when initialMode="vod". |
| vodWindowSec | number | — | 7200 | Length of the VOD manifest window the SDK requests. |
| defaultRewindSec | number | — | 15 | Step used by the Rewind button / rewind(). |
| liveEdgeToleranceSec | number | — | 5 | A seek within this many seconds of now counts as live. |
| autoGoLiveAtEdge | boolean | — | false | Auto-return to live when VOD playback catches up to the edge. |
| controls | RhombusPlayerControl[] | — | undefined | Which built-in controls to render. Leaving it undefined renders every control; [] = headless. There is no "all" value. See below. |
| classNames | RhombusPlayerClassNames | — | — | Per-slot class names for the bar. See Styling. |
| renderControls | (api, state) => ReactNode | — | — | Replace the bar entirely (timeline still renders). |
| saveClip | RhombusSaveClipConfig | — | — | Built-in clip export config. See Save Clip. |
| timeline | RhombusPlayerTimelineConfig | — | — | Timeline/scrubber config. See Timeline. |
| className / style | string / CSSProperties | — | — | Applied to the player's root element. |
| onReady | () => void | — | — | First underlying transport became ready. |
| onError | (error: Error) => void | — | — | Token / media / setup failure. |
| onRecoveryAttempt | (attempt, error) => void | — | — | Fires on each auto-recovery retry. |
| onModeChange | (mode, atWallClockMs) => void | — | — | Fired on every Live ⇄ VOD transition. |
| onTransportChange | (transport) => void | — | — | Resolved live transport changed (incl. WebCodecs fallback). |
| onSeek | (wallClockMs, mode) => void | — | — | A seek happened. |
| onProgress | (wallClockMs, mode) => void | — | — | Throttled playback progress (~4Hz VOD / ~1Hz live). Use to mirror a controlled positionMs. |
| onPlayingChange | (playing) => void | — | — | Play/pause state changed. |
| onPlaybackRateChange | (rate) => void | — | — | Playback speed changed. |
| onSnapshot | (RhombusSnapshotResult) => void | — | — | A snapshot was captured. |
| onZoomChange | (zoom, panX, panY) => void | — | — | Zoom/pan changed. |
| onClipRangeSelect | (RhombusClipRange) => void | — | — | User selected a clip range (fires regardless of built-in export). |
| onClipExport | (RhombusClipExportStatus) => void | — | — | Built-in clip export progress/result. |
Controlled, uncontrolled & imperative
You don't have to choose one approach. Props, the ref, and the built-in control bar all
read and write the same internal state, so they work together and stay in sync — drive some
aspects declaratively and others imperatively, or let users click the built-in bar; every path
fires the matching on*Change callback so your state can follow. (The only caveat is the standard
React one: see "Notes" below.)
- Controlled value props — drive a steady-state value declaratively. Each is optional: omit
it and the player owns it internally (uncontrolled, seeded by
initial*/defaults); provide it (and update it from the matchingon*Change) and it becomes the source of truth. The built-in controls and therefstill work — in controlled mode they fireon*Changeso your state updates.
| Prop | Callback |
| --------------- | ---------------------- |
| playing | onPlayingChange |
| playbackRate | onPlaybackRateChange |
| zoom | onZoomChange |
| liveTransport | onTransportChange |
| videoFit | onVideoFitChange |
- Controlled playhead —
positionMs(epoch ms). It seeks when its value changes (the player derives live vs. VOD: withinliveEdgeToleranceSecof now ⇒ live in the current transport, else VOD — there is nomodeprop). The player still advances on its own; for a two-way binding, mirroronProgress(throttled) and/oronSeekback intopositionMs: - Imperative actions — one-shot commands on the
ref. Some are just sugar over a declarative prop (use whichever you prefer); two are strictly imperative because they return a value / run an async side-effect and have no meaningful "state" to bind:
| ref method | Declarative equivalent |
| ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| play() / pause() | playing |
| setPlaybackRate(r) | playbackRate |
| zoomIn() / zoomOut() / setZoom() / resetZoom() | zoom |
| setLiveTransport(t) | liveTransport |
| seekTo(ms) / rewind(s) / goLive() | positionMs (set to the time / now − s / now) |
| **snapshot()** | none — strictly imperative (returns the captured frame). |
| **startClipExport(range?, opts?)** | none — strictly imperative (clip capture; runs the async render, returns status). Clip range selection is the built-in UI / onClipRangeSelect, but the export itself is a command. |
So: everything that has a steady-state value is available as a controlled prop; the only things
that are ref-only are **snapshot()** and **startClipExport()** (and you'd typically also
reach for getState() imperatively).
Notes (controlled semantics): when you provide a controlled prop, you own it — if you ignore its
on*Change, the prop and the player can diverge until the next prop change (standard React
controlled behavior; e.g. the built-in Pause button fires onPlayingChange(false), and if you don't
update your playing state the player re-asserts your prop). getState() always returns the
effective values regardless of how you drive it, and the ref works in controlled or
uncontrolled mode.
Imperative handle (ref)
Pass a ref to drive the player programmatically. The built-in control bar uses this exact
API internally, so anything the buttons do, you can do too.
import { useRef } from "react";
import { RhombusPlayer, type RhombusPlayerHandle } from "@rhombussystems/react";
function Controlled() {
const player = useRef<RhombusPlayerHandle>(null);
return (
<>
<RhombusPlayer ref={player} cameraUuid="…" apiOverrideBaseUrl="https://api.example.com" />
<button onClick={() => player.current?.pause()}>Pause</button>
<button onClick={() => player.current?.goLive()}>Go live</button>
<button onClick={() => player.current?.rewind(30)}>« 30s</button>
<button onClick={() => player.current?.seekTo(Date.now() - 3_600_000)}>1h ago</button>
<button onClick={async () => {
const shot = await player.current?.snapshot();
if (shot) downloadDataUrl(shot.dataUrl, "frame.png");
}}>Snapshot</button>
</>
);
}| Method | Description |
| --------------------------------------------- | ---------------------------------------------------------------- |
| play() / pause() | Play / pause. Pausing live drops into a frozen VOD frame. |
| goLive() | Return to the live edge (restores the live transport). |
| seekTo(wallClockMs) | Seek to an absolute time (epoch ms); auto-switches Live ⇄ VOD. |
| rewind(seconds?) | Jump back seconds (default defaultRewindSec). |
| setPlaybackRate(rate) | VOD only; ignored while live. |
| zoomIn(step?) / zoomOut(step?) | Digital zoom (1×–4×). |
| setZoom(zoom, panX?, panY?) / resetZoom() | Set zoom + pan directly / reset to 1×. |
| snapshot() | Promise<RhombusSnapshotResult> — capture the current frame. |
| setLiveTransport("realtime" | "buffered") | Switch transport (clamps to buffered without WebCodecs). |
| startClipExport(range?, options?) | Promise<RhombusClipExportStatus> — export a clip (proxy mode). |
| getState() | Current RhombusPlayerState snapshot. |
Observable state
renderControls(api, state) receives — and getState() returns — a RhombusPlayerState:
type RhombusPlayerState = {
cameraUuid: string;
mode: "live" | "vod";
liveTransport: "realtime" | "buffered"; // resolved (may have fallen back)
playing: boolean;
playbackRate: number;
currentWallClockMs: number | null; // ≈ Date.now() while live
zoom: number;
isAtLiveEdge: boolean;
canSaveClip: boolean; // built-in export available (proxy mode)
clipSelection: { startMs: number; endMs: number } | null; // current clip selection, or null
clipExport?: RhombusClipExportStatus; // in-progress / finished export
};Choosing which controls render
controls is a list of RhombusPlayerControl. It's exported both as a string union and
as a runtime constant (RhombusPlayerControl.Play, etc.), so use plain strings or named
members — whichever you prefer:
"play" | "goLive" | "rewind" | "speed" | "zoom" | "snapshot" | "saveClip" | "timeline" | "liveType" | "videoFit" | "goToDate"import { RhombusPlayer, RhombusPlayerControl } from "@rhombussystems/react";
<>
{/* All controls — omit the prop entirely: */}
<RhombusPlayer cameraUuid="…" />
{/* A subset — plain strings: */}
<RhombusPlayer cameraUuid="…" controls={["play", "timeline"]} />
{/* …or the named constant (autocompletes, refactor-safe): */}
<RhombusPlayer
cameraUuid="…"
controls={[RhombusPlayerControl.Play, RhombusPlayerControl.Timeline]}
/>
{/* Headless — no built-in UI at all; drive everything through the ref: */}
<RhombusPlayer ref={player} cameraUuid="…" controls={[]} />
</>Go to date ("goToDate" control / RhombusDateTimePicker)
The toolbar includes a date/time jump picker (the "goToDate" control, on by default):
a calendar + time-of-day popover that seeks the player to any moment — the SDK counterpart of
the Rhombus Console's toolbar date picker. When footage availability
is enabled, days with no recorded footage are struck through and disabled (one
getPresenceWindows fetch per viewed month, cached; failures just leave days enabled).
The component is also exported standalone for custom layouts — it interops with any player via
seekTo:
import { RhombusDateTimePicker } from "@rhombussystems/react";
<RhombusDateTimePicker
value={positionMs} // epoch ms (or null)
onChange={(ms) => playerRef.current?.seekTo(ms)}
cameraUuid="…" // optional: enables no-footage day disabling
apiOverrideBaseUrl="https://your-api.example.com"
minTimeMs={retentionFloorMs} // optional: disable pre-retention days
direction="down" // "up" for bottom toolbars (player default)
/>All calendar math is in the viewer's local time zone (consistent with the Timeline). Style it
via the rhombus-datepicker-* classes (zero-specificity defaults, like the control bar) or the
classNames={{ anchor, popover }} prop.
Inside RhombusPlayer, a picker jump that lands outside the visible timeline window also
re-centers the timeline on the target (in-window seeks — i.e. timeline clicks — never move the
window). In custom layouts composing the standalone picker with a standalone Timeline, you
own the window: update your rangeStartMs/rangeEndMs in the same onChange that calls
seekTo.
Video display / fit
Cameras are usually 16:9; when the player box isn't, you get letter/pillar-boxing. The
videoFit prop controls how the footage fills its area, mirroring the Rhombus Console
video-wall "Video Display" options:
| videoFit | Console label | Behavior |
| -------------------- | -------------------- | ------------------------------------------------------------------------- |
| "auto" (default) | Auto-Size | The player box takes the video's aspect ratio — no bars, no cropping. |
| "contain" | Default Aspect Ratio | Full frame, letter/pillar-boxed (object-fit: contain). |
| "cover" | Full View Cropped | Fills the box, crops overflow (object-fit: cover). |
| "fill" | Stretch to Fit | Distorts to fill, no cropping (object-fit: fill). |
There's a built-in video-display control in the bar (the "videoFit" control) so users can
switch between these live; it fires onVideoFitChange. You can also drive it as a controlled
prop:
<RhombusPlayer cameraUuid="…" videoFit="cover" onVideoFitChange={setFit} />
**"auto"sizes by width:** the player measures the video's intrinsic aspect ratio and sets the stage'saspect-ratio(height is derived), so give the player a width and don't impose a fixed height in that mode. The other three modes fill whatever box you give it.
For the low-level RhombusBufferedPlayer / RhombusRealtimePlayer, set object-fit yourself via
videoProps.style / canvasProps.style.
Styling the controls
Three options, least → most custom:
1. Plain CSS overrides. The bar uses stable class names, and the SDK ships its defaults
as a zero-specificity :where() stylesheet injected once at runtime. Because every
default selector sits inside :where() (specificity 0,0,0), your CSS always wins — no
!important, no import, regardless of load order:
| Element | class |
| ------------------ | ---------------------------------------------------------------------------- |
| the bar | rhombus-player-controls |
| every button | rhombus-player-btn (active: [data-active="true"]; disabled: :disabled) |
| speed <select> | rhombus-player-speed |
| quality <select> | rhombus-player-quality |
| live-type group | rhombus-player-livetype |
| clip group | rhombus-player-clip |
| clip status text | rhombus-player-clip-status |
| timeline wrapper | rhombus-player-timeline |
.rhombus-player-controls { background: #fff; color: #111; gap: 12px; }
.rhombus-player-btn { background: #0a7; border-color: #0a7; border-radius: 999px; }
.rhombus-player-btn[data-active="true"] { outline: 2px solid #0a7; }2. classNames prop — attach your own class per slot (Tailwind, CSS-modules, design
systems). Appended to the SDK's class on that element:
<RhombusPlayer
cameraUuid="…"
classNames={{ controls: "flex gap-3 p-2 bg-white", button: "btn btn-sm", clip: "ml-auto" }}
/>3. renderControls — replace the bar entirely (the timeline still renders above it) and
build your own buttons against the imperative api:
<RhombusPlayer
cameraUuid="…"
renderControls={(api, s) => (
<div className="my-bar">
<button onClick={() => (s.playing ? api.pause() : api.play())}>
{s.playing ? "Pause" : "Play"}
</button>
{s.mode === "vod" && <button onClick={() => api.goLive()}>Go live</button>}
<button onClick={() => api.rewind()}>« 15s</button>
<button disabled={s.mode === "live"} onClick={() => api.setPlaybackRate(2)}>2×</button>
<button onClick={() => api.zoomIn()}>+</button>
<button onClick={() => void api.snapshot()}>Snapshot</button>
</div>
)}
/>renderControls is fully optional — omit it to keep the built-in bar. For total control,
combine controls={[]} (no bar) with the ref handle and your own layout.
Snapshots
The Snapshot tool captures the current frame and hands the image data back to you — it does
not auto-download and there is no target container/ref to render into. It works in both
modes (the realtime canvas and the MSE-fed DASH <video> are both untainted, so toDataURL /
toBlob succeed) and returns a RhombusSnapshotResult:
type RhombusSnapshotResult = {
dataUrl: string; // PNG data: URL
blob: Blob | null; // PNG blob (null only if toBlob is unavailable)
wallClockMs: number;
mode: "live" | "vod";
width: number;
height: number;
};You receive it two ways — both deliver the same result, including for the built-in Snapshot button:
// 1) Callback — fires for the built-in button AND for api.snapshot()
<RhombusPlayer cameraUuid="…" onSnapshot={(shot) => setPreview(shot.dataUrl)} />
// 2) Imperative — capture on demand and use the returned result
const shot = await playerRef.current!.snapshot();The SDK never downloads or displays the image itself — render it (<img src={shot.dataUrl} />),
upload shot.blob, or trigger a download yourself:
const shot = await playerRef.current!.snapshot();
const a = document.createElement("a");
a.href = shot.dataUrl; // or URL.createObjectURL(shot.blob!)
a.download = `snapshot-${shot.wallClockMs}.png`;
a.click();A common pattern is to store
onSnapshot'sdataUrlin state and render a thumbnail (<img src={dataUrl} />). For lower-level use,snapshotCanvasElement/snapshotVideoElementare exported too.
Save Clip
The clip flow is drag-to-select on the timeline, then export:
**✂ Create clip** in the bar enters clip mode — it seeds a selection at the playhead and zooms the timeline so it's easy to adjust.- On the timeline you get a shaded region with draggable start/end handles, a draggable body (move the whole range), and a live duration label. The selection clamps to a minimum (default 5s), a maximum (default 60 min — the server cap), and never includes the future.
**Save clip** opens a small title / description / visibility form (skippable — setsaveClip.showOptionsForm: false), then runs the export:/video/spliceV3→ progress polling → a download URL.
onClipRangeSelect({ startMs, endMs, cameraUuid }) fires as the selection changes, and
onClipExport(status) reports progress/result.
Proxy mode required for export. The clip endpoints are API-key / session authed, not federated-token compatible, so the request must go through your backend (which attaches the API key) — exactly like the media-URI proxy. Built-in export is only available when
apiOverrideBaseUrlis set; selection +onClipRangeSelectwork regardless. See the Backend contract.
<RhombusPlayer
cameraUuid="…"
apiOverrideBaseUrl="https://your-api.example.com"
saveClip={{ defaultDurationSec: 30, defaultVisibility: "PRIVATE", showOptionsForm: true }}
onClipExport={(s) => {
if (s.phase === "rendering") setProgress(s.percentComplete);
if (s.phase === "complete") window.location.assign(s.downloadUrl!);
}}
/>type RhombusSaveClipConfig = {
enabled?: boolean; // default true when apiOverrideBaseUrl is set
paths?: { splice?: string; progress?: string; download?: string };
defaultTitle?: string;
defaultDurationSec?: number; // seeded selection width. Default 60
minDurationSec?: number; // drag clamp. Default 5
maxDurationSec?: number; // drag clamp. Default 3600 (server caps at 60 min)
progressTimeoutMs?: number; // give up polling a stuck render. Default 300000 (5 min); 0 = never
defaultVisibility?: RhombusClipVisibility; // "ORG_WIDE" (default) | "PRIVATE" | "ROLE_RESTRICTED"
showOptionsForm?: boolean; // show the title/description/visibility form. Default true
requireFootage?: "any" | "full" | "off"; // footage pre-check policy. Default "any" (see below)
};
type RhombusClipExportOptions = {
title?: string;
description?: string;
visibility?: RhombusClipVisibility;
saveToConsole?: boolean; // default true
audioIncluded?: boolean; // also splices the camera's .a0 audio facet
};
type RhombusClipExportStatus = {
phase: "selecting" | "submitting" | "rendering" | "complete" | "error" | "canceled";
clipUuid?: string;
percentComplete?: number; // 0–100 while rendering
currentOperation?: string;
downloadUrl?: string; // set when complete
error?: string;
errorCode?: "no-footage" | "partial-footage"; // set when the footage pre-check blocked the export
coverage?: RhombusRangeCoverage; // footage coverage of the range, when the check ran
};Footage pre-check (requireFootage)
Rhombus renders time ranges with no recorded footage (camera offline during the window, or
footage past retention) as "VIDEO NOT AVAILABLE" placeholder frames — and /video/spliceV3
happily renders a clip over such a range, returning a "successful" clip with no real video in
it. Before submitting an export, the player therefore fetches
/camera/getPresenceWindows for the selected range and applies
requireFootage:
"any"(default) — block only when the range has zero recorded footage."full"— block when the range has any confirmed gap."off"— no pre-check (legacy behavior).
A blocked export emits phase: "error" with errorCode ("no-footage" / "partial-footage")
and coverage — key both your custom UI messages off errorCode, not the human-readable
error string. Exports that proceed carry coverage on every subsequent status so you can
warn about partial footage. The check fails open: if availability can't be fetched (missing
proxy route, timeout), the export proceeds ungated and coverage is absent.
Build your own clip UI instead of the built-in form: read the live selection from
onClipRangeSelect (or getState().clipSelection) and call the imperative handle with your own
options:
await player.current!.startClipExport(
{ startMs, endMs, cameraUuid },
{ title: "Front door", visibility: "PRIVATE", audioIncluded: true }
);Timeline configuration
RhombusPlayer renders a Timeline when controls
includes "timeline" (the default). Configure it with the timeline prop:
type RhombusPlayerTimelineConfig = {
windowSec?: number; // span of the scrubber, seconds. Default 86400 (a full day)
fetchSeekPoints?: boolean; // fetch event markers from /camera/getFootageSeekpointsV2. Default true
includeAnyMotion?: boolean;
fetchAvailability?: boolean; // fetch footage coverage from /camera/getPresenceWindows and draw
// no-footage gaps on the availability bar. Default: true in proxy
// mode (apiOverrideBaseUrl set), false in direct mode.
onAvailabilityLoaded?: (availability: RhombusFootageAvailability) => void;
marks?: TimelineMark[]; // extra static event bands / gaps
colors?: TimelineColors; // recolor seekpoints, bars, playhead, buttons (see below)
height?: number; // px, default 56
onSeekPointsLoaded?: (points: RhombusFootageSeekPoint[]) => void; // diagnostics
};Footage availability
With fetchAvailability on, the availability bar stops pretending all past time is recorded:
ranges with confirmed no footage render in colors.availabilityGap (default a muted red) —
the same ranges the Rhombus stream would play as "VIDEO NOT AVAILABLE" placeholder frames. Gaps
are only drawn where the answer is actually known (inside the fetched range, in the past, and
older than a ~2-minute live-edge grace window for presence-ingest lag); everything else keeps
the legacy look. The in-clip-mode toolbar also shows a warning (and disables Save, per
requireFootage) when the selection overlaps a gap.
The raw client + coverage math are exported for custom UIs: fetchPresenceWindows,
mergeFootageWindows, computeFootageGaps, computeRangeCoverage, and the
RhombusFootageWindow / RhombusFootageAvailability / RhombusRangeCoverage types. Windows
carry source: "cloud" | "local" — local windows live on the camera's SD card and are only
retrievable while the camera is online; cloud windows are always retrievable.
By default the window is a 24h span aligned to local midnight (Console-style). RhombusPlayer
renders ‹/› chevrons that pan by half a span (±12h at the day view), and −/+ zoom buttons +
mouse-wheel zoom that step through 24h → 8h → 3h → 1h → 20m → 5m (centered on the cursor or
playhead, with an animated transition) so you can pinpoint a moment when seekpoints bunch up.
It auto-follows the current day/playhead until you navigate; Go Live resets to the day view.
The player keeps the window stable while you scrub and only scrolls it once playback leaves the visible range, so the playhead always lands exactly where you click.
RhombusPlayer recipes
Open straight into an event (past footage):
<RhombusPlayer
cameraUuid="…"
apiOverrideBaseUrl="https://api.example.com"
initialMode="vod"
initialStartTimeMs={new Date("2025-04-15T09:30:00Z").getTime()}
/>Auto-return to live when caught up, wider rewind step:
<RhombusPlayer cameraUuid="…" autoGoLiveAtEdge defaultRewindSec={30} />Force broadest browser support (buffered live everywhere):
<RhombusPlayer cameraUuid="…" liveTransport="buffered" />Headless — your UI, our engine:
function MyPlayer() {
const ref = useRef<RhombusPlayerHandle>(null);
const [state, setState] = useState<RhombusPlayerState>();
return (
<>
<RhombusPlayer ref={ref} cameraUuid="…" controls={[]} onModeChange={() => setState(ref.current?.getState())} />
{/* render your own toolbar from `state` and `ref.current` */}
</>
);
}RhombusAudioPlayer — A100 and DR40 audio
RhombusAudioPlayer is the drop-in audio equivalent of RhombusPlayer. It plays an A100
audio gateway or DR40 by absolute wall-clock time, switches between live and historical
audio, and can either own its controls or participate in the same controller and timeline as
video.
Use it for:
- standalone live A100 or DR40 listening;
- standalone historical audio with rewind, pause, speed, and epoch-ms seeking;
- synchronized camera video plus independent A100 audio;
- synchronized DR40 video/audio without downloading or playing the DR40 audio twice;
- a headless audio engine controlled by application UI;
- one external timeline controlling both a video and audio participant.
Use RhombusTalkback alongside this
component for full two-way audio. DSP/equalizer controls and multiple simultaneous audible
tracks on one shared controller remain outside the public API.
Audio sources and device UUIDs
Sources are explicit so the SDK can select the correct Rhombus endpoint and request body:
import type { RhombusAudioSource } from "@rhombussystems/react";
const a100: RhombusAudioSource = {
type: "audio-gateway",
uuid: "A100_AUDIO_GATEWAY_UUID",
};
const dr40: RhombusAudioSource = {
type: "dr40",
uuid: "DR40_DEVICE_UUID",
};"audio-gateway"calls/audiogateway/getMediaUriswith{ gatewayUuid: source.uuid }."dr40"calls/doorbellcamera/getMediaUriswith{ deviceUuid: source.uuid }.- An A100 UUID is the audio gateway UUID, not an associated camera UUID.
- A source must be visible to the organization represented by the API key or federated token. An unknown or cross-organization UUID produces no usable media URIs.
The SDK intentionally does not include a device-list UI. Most applications select devices
from their own inventory. A backend-powered picker can use Rhombus
/audiogateway/getMinimalAudioGatewayStateList for A100s and
/doorbellcamera/getMinimalStateList for DR40s; keep the API key server-side.
Standalone live audio
The smallest complete player is:
import { RhombusAudioPlayer } from "@rhombussystems/react";
export function LobbyAudio() {
return (
<RhombusAudioPlayer
source={{ type: "audio-gateway", uuid: "A100_UUID" }}
