segmented-video-player
v1.0.1
Published
Headless React hooks for synchronized segmented video playback
Maintainers
Readme
segmented-video-player
A headless React library for synchronized, segmented video playback.
Designed for use cases where a long recording is split into many short video files (segments), each starting at a known wall-clock offset, and multiple players must stay in perfect sync with a shared global timeline — e.g. multi-camera CCTV, live-stream DVR, or forensic video review tools.
The package contains zero UI. It exports only React context, hooks, and TypeScript types. You bring your own <video> elements and controls.
Features
- 🎯 Segment-aware seeking — maps a global timestamp to the correct file and local offset automatically
- 🔄 Two sync strategies —
lazy(event-driven, great for fMP4/HLS) andstrict(fetches exact duration, great for plain MP4) - ⚡ Drift correction — soft (playback-rate nudge) and hard (direct seek) tolerances, configurable per player
- 🔁 Reload API — force-reload any player after a network error or URL rotation without touching the global clock
- 🧩 Decoupled architecture — the clock (
PlayerProvider) and each player (useVideoSync) are connected via a subscription function you pass in, making both independently testable and composable - 🌐 Browser-adaptive —
automode automatically useslazystrategy on Firefox (wherepreload="metadata"is unreliable) - 📦 Dual build — ships CJS + ESM with full
.d.tsdeclarations
Installation
npm install segmented-video-player
# or
yarn add segmented-video-playerReact 18 or later is required as a peer dependency.
npm install react react-domCore Concepts
Segments
A segment is a single video file that covers a slice of a longer timeline:
type Segment = {
url: string; // URL to the video file
startTime: number; // When this file starts, in seconds from epoch (or any shared origin)
};You pass an array of segments to useVideoSync. The hook finds the correct file for any global time by scanning the array for the segment with the largest startTime <= globalTime.
Global Clock (PlayerProvider)
PlayerProvider owns the shared timeline. It ticks every 250 ms and broadcasts the current time to all subscribers. It does not touch any <video> elements — that is useVideoSync's job.
Player Hook (useVideoSync)
useVideoSync runs inside each individual camera/player component. It subscribes to ticks from the clock via a subscribeToTicks callback, determines which segment to load, and keeps the <video> element in sync with the global time.
Quick Start
import React, { useRef } from 'react';
import {
PlayerProvider,
usePlayerContext,
useVideoSync,
type Segment,
} from 'segmented-video-player';
// ─── 1. Wrap your app (or the relevant subtree) with PlayerProvider ───────────
const segments: Segment[] = [
{ url: '/recordings/cam1_08-00.mp4', startTime: 28800 }, // 08:00:00
{ url: '/recordings/cam1_08-01.mp4', startTime: 28860 }, // 08:01:00
{ url: '/recordings/cam1_08-02.mp4', startTime: 28920 }, // 08:02:00
];
export default function App() {
return (
<PlayerProvider initialTime={28800} allSegmentStarts={segments.map(s => s.startTime)}>
<CameraPlayer id="cam-1" segments={segments} />
<Controls />
</PlayerProvider>
);
}
// ─── 2. Build a player component with useVideoSync ───────────────────────────
function CameraPlayer({ id, segments }: { id: string; segments: Segment[] }) {
const videoRef = useRef<HTMLVideoElement>(null);
const { playing, timeStore } = usePlayerContext();
const { videoSrc, status, handleVideoEnded, reload } = useVideoSync({
id,
videoNodeRef: videoRef,
segments,
playing,
subscribeToTicks: timeStore.subscribeRaw,
});
return (
<div>
<p>Status: {status}</p>
<video
ref={videoRef}
src={videoSrc ?? undefined}
onEnded={handleVideoEnded}
muted
playsInline
/>
<button onClick={reload}>Reload</button>
</div>
);
}
// ─── 3. Build controls using the context ─────────────────────────────────────
function Controls() {
const {
playing, setPlaying,
playbackRate, setPlaybackRate,
issueSeek,
skipMinuteForward, skipMinuteBackward,
setUpNextSegment, setUpPrevSegment,
} = usePlayerContext();
return (
<div>
<button onClick={() => setPlaying(!playing)}>
{playing ? 'Pause' : 'Play'}
</button>
<button onClick={skipMinuteBackward}>-1 min</button>
<button onClick={skipMinuteForward}>+1 min</button>
<button onClick={setUpPrevSegment}>Prev segment</button>
<button onClick={setUpNextSegment}>Next segment</button>
<button onClick={() => issueSeek(28800)}>Jump to 08:00</button>
<select
value={playbackRate}
onChange={e => setPlaybackRate(Number(e.target.value))}
>
{[0.5, 1, 2, 4, 8].map(r => (
<option key={r} value={r}>{r}×</option>
))}
</select>
</div>
);
}API Reference
<PlayerProvider>
The context provider that owns the global clock and playback state. Mount it once above all player components that should be in sync.
<PlayerProvider
initialTime={28800}
allSegmentStarts={[28800, 28860, 28920]}
>
{children}
</PlayerProvider>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| initialTime | number | 0 | Starting position in seconds |
| allSegmentStarts | number[] | [] | All known segment start times across all cameras, used by setUpNextSegment / setUpPrevSegment to jump between recording boundaries |
| children | ReactNode | — | Your component tree |
Note:
issueSeekalways setsplayingtotrue. This is intentional: seeking implies intent to play from that position. If you want to seek while paused, callsetPlaying(false)immediately after.
usePlayerContext()
Returns the full playback context. Must be called inside <PlayerProvider>.
const {
playing, // boolean
setPlaying, // (playing: boolean) => void
playbackRate, // number (1 = normal speed)
setPlaybackRate, // (rate: number) => void
issueSeek, // (seconds: number) => void — seek + auto-play
skipMinuteForward, // () => void
skipMinuteBackward,// () => void
setUpNextSegment, // () => void — jump to next segment start
setUpPrevSegment, // () => void — jump to previous segment start
timeStore, // TimeStore — subscribe to raw ticks
} = usePlayerContext();Throws if called outside <PlayerProvider>.
usePlayerTime()
Returns the current global time (in seconds) as a React state value. Re-renders on every tick (~4 fps at 250 ms interval). Use this for timeline scrubbers and time displays.
function TimeDisplay() {
const time = usePlayerTime(); // number, re-renders every tick
return <span>{new Date(time * 1000).toISOString().slice(11, 19)}</span>;
}useVideoTick(callback)
Subscribes a callback to raw time ticks without causing a re-render. Ideal for custom sync logic or performance-sensitive consumers.
useVideoTick((time: number, isSeek: boolean, speed: number) => {
// Called on every clock tick (~4 fps) and on every seek
console.log('Global time:', time, 'Is seek:', isSeek, 'Speed:', speed);
});| Argument | Type | Description |
|---|---|---|
| time | number | Current global time in seconds |
| isSeek | boolean | true when this tick is the result of a seek, false during normal playback |
| speed | number | Current playback rate |
useVideoSync(props)
The core hook. Manages segment selection, video element control, and drift correction for a single <video> element.
const { videoSrc, status, handleVideoEnded, reload } = useVideoSync({
id,
videoNodeRef,
segments,
playing,
subscribeToTicks: timeStore.subscribeRaw,
options: {
syncStrategy: 'auto',
hardSyncTolerance: 3,
softSyncTolerance: 2,
onStatusChange: (id, status) => console.log(id, status),
},
});Props (UseVideoSyncProps)
| Prop | Type | Required | Description |
|---|---|---|---|
| id | string \| number | ✅ | Unique identifier for this player instance |
| videoNodeRef | RefObject<HTMLVideoElement> | ✅ | Ref attached to the <video> DOM element |
| segments | Segment[] | ✅ | List of segments for this camera/source |
| playing | boolean | ✅ | Whether the player should be playing. Pass usePlayerContext().playing |
| subscribeToTicks | (cb) => () => void | ✅ | Clock subscription function. Pass timeStore.subscribeRaw from context, or any compatible clock |
| options | PlayerOptions | ❌ | Fine-tuning options (see below) |
Returns
| Field | Type | Description |
|---|---|---|
| videoSrc | string \| null | The URL to set as the <video> element's src. null means no active segment |
| status | VideoStatus | Current playback status: 'playing', 'loading', or 'gap' |
| handleVideoEnded | () => void | Attach to the <video onEnded> event. Marks the current segment as finished |
| reload | () => void | Resets internal state and force-seeks to the last known time. Use after URL changes or network errors |
VideoStatus
| Value | Meaning |
|---|---|
| 'playing' | A segment is loaded and the video is (or should be) playing |
| 'loading' | Fetching segment metadata (only in strict mode) |
| 'gap' | No segment covers the current time — there is a recording gap |
PlayerOptions
Fine-tune the sync behaviour per player:
interface PlayerOptions {
syncStrategy?: 'lazy' | 'strict' | 'auto'; // default: 'auto'
hardSyncTolerance?: number; // default: 3 (seconds)
softSyncTolerance?: number; // default: 2 (seconds)
onStatusChange?: (id: string | number, status: VideoStatus) => void;
}syncStrategy
| Value | How it works | Best for |
|---|---|---|
| 'lazy' | Never fetches metadata. Uses the browser's natural ended event to detect when a segment is over. | fMP4, HLS, DASH, any format where the browser reports duration reliably |
| 'strict' | Fetches each segment's duration via a silent <video preload="metadata"> probe before loading it. Knows exactly when a segment ends — never overshoots into a gap. | Plain MP4 where you need precise gap detection |
| 'auto' | Uses lazy on Firefox (where preload="metadata" is unreliable), strict elsewhere | Default — recommended for most setups |
hardSyncTolerance (seconds, default 3)
If the difference between expected and actual video time exceeds this threshold, a hard seek is issued (video.currentTime = expected). The effective threshold scales with speed: hardSyncTolerance + speed × 0.25.
softSyncTolerance (seconds, default 2)
If the drift is between softSyncTolerance and hardSyncTolerance, the playback rate is nudged by ±0.15× to let the video catch up gradually without a disruptive seek.
onStatusChange
Called synchronously whenever status changes. Use this to aggregate status across many players in a parent component.
<PlayerProvider ...>
{cameras.map(cam => (
<CameraPlayer
key={cam.id}
segments={cam.segments}
options={{
onStatusChange: (id, status) => {
setPlayerStatuses(prev => ({ ...prev, [id]: status }));
},
}}
/>
))}
</PlayerProvider>clearMetadataCache()
Manually clears the module-level duration cache used by strict mode. You should rarely need this — the cache is cleared automatically when segments changes (by reference). Call it only if you're reusing the same URLs with different content.
import { clearMetadataCache } from 'segmented-video-player';
clearMetadataCache();TypeScript
All public types are exported from the package root:
import type {
Segment,
PlayingSegment,
VideoStatus,
PlayerOptions,
UseVideoSyncProps,
TimeStore,
PlayerContextState,
PlayerProviderProps,
} from 'segmented-video-player';Advanced Recipes
Multiple cameras in sync
function MultiCameraView({ cameras }: { cameras: Camera[] }) {
return (
<PlayerProvider allSegmentStarts={getAllStarts(cameras)}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)' }}>
{cameras.map(cam => (
<CameraPlayer key={cam.id} id={cam.id} segments={cam.segments} />
))}
</div>
<SharedControls />
</PlayerProvider>
);
}Each CameraPlayer runs its own useVideoSync instance but shares the same PlayerProvider clock. They all receive ticks at the same instant.
Custom clock (no PlayerProvider)
If you have your own clock source (e.g. a WebSocket feed from a server), you can use useVideoSync independently:
function ServerSyncedPlayer({ segments }: { segments: Segment[] }) {
const videoRef = useRef<HTMLVideoElement>(null);
const [playing, setPlaying] = useState(false);
// Build a subscribeToTicks function that wraps your own event source
const subscribeToTicks = useCallback(
(callback: (time: number, isSeek: boolean, speed: number) => void) => {
const handler = (e: MessageEvent) => {
callback(e.data.time, e.data.isSeek ?? false, 1);
};
myWebSocket.addEventListener('message', handler);
return () => myWebSocket.removeEventListener('message', handler);
},
[]
);
const { videoSrc, status, handleVideoEnded } = useVideoSync({
id: 'server-cam',
videoNodeRef: videoRef,
segments,
playing,
subscribeToTicks,
});
return <video ref={videoRef} src={videoSrc ?? undefined} onEnded={handleVideoEnded} />;
}Timeline scrubber
function Scrubber({ totalDuration }: { totalDuration: number }) {
const time = usePlayerTime();
const { issueSeek } = usePlayerContext();
return (
<input
type="range"
min={0}
max={totalDuration}
value={time}
onChange={e => issueSeek(Number(e.target.value))}
/>
);
}Tracking all player statuses
function StatusBar({ cameraIds }: { cameraIds: string[] }) {
const [statuses, setStatuses] = useState<Record<string, VideoStatus>>({});
const onStatusChange = useCallback((id: string | number, status: VideoStatus) => {
setStatuses(prev => ({ ...prev, [String(id)]: status }));
}, []);
return (
<>
{cameraIds.map(id => (
// Mount players with the shared callback
<CameraPlayer key={id} id={id} options={{ onStatusChange }} />
))}
<div>
{cameraIds.map(id => (
<span key={id}>{id}: {statuses[id] ?? 'unknown'}</span>
))}
</div>
</>
);
}How Sync Works
PlayerProvider (clock)
│
│ setInterval every 250 ms
│ → cursorSec += timeDelta × playbackRate
│ → broadcast to all rawSubscribers
│
├── useVideoSync (camera 1) ← subscribeToTicks = timeStore.subscribeRaw
│ evaluateTime(time, isSeek, speed)
│ ├─ find candidate segment (largest startTime ≤ globalTime)
│ ├─ if new segment → set videoSrc → video loads
│ └─ if same segment → check drift
│ soft drift → nudge playbackRate ±0.15
│ hard drift → video.currentTime = expected
│
├── useVideoSync (camera 2) ← same subscribeRaw, independent state
│ ...
│
└── usePlayerTime() ← subscribeUI, triggers re-render for UIBuilding
npm run build # tsup → dist/index.js + dist/index.mjs + dist/index.d.ts
npm publish # runs build automatically via prepublishOnlyLicense
MIT
