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

segmented-video-player

v1.0.1

Published

Headless React hooks for synchronized segmented video playback

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 strategieslazy (event-driven, great for fMP4/HLS) and strict (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-adaptiveauto mode automatically uses lazy strategy on Firefox (where preload="metadata" is unreliable)
  • 📦 Dual build — ships CJS + ESM with full .d.ts declarations

Installation

npm install segmented-video-player
# or
yarn add segmented-video-player

React 18 or later is required as a peer dependency.

npm install react react-dom

Core 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: issueSeek always sets playing to true. This is intentional: seeking implies intent to play from that position. If you want to seek while paused, call setPlaying(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 UI

Building

npm run build       # tsup → dist/index.js + dist/index.mjs + dist/index.d.ts
npm publish         # runs build automatically via prepublishOnly

License

MIT