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

@classytic/react-media

v0.3.2

Published

Production-ready media library for React. Video/audio playback, recording, upload, MSE/HLS streaming, captions.

Readme

@classytic/react-media

The engine for professional video in React.
Performance-critical, HLS-first, and 100% headless. Built for React 19.

React 19 TypeScript License

Why another player?
Most React players are just wrappers around document.querySelector('video') or heavy Web Components (like Vidstack/Mux).
This is a native React engine. It uses requestAnimationFrame loops for 60fps UI updates, useSyncExternalStore for tearing-free state, and React 19's use() API for clean context consumption. It is built for developers building the next Netflix, not marketing sites hosting a YouTube embed.


✨ Features

  • 🚀 React 19 Native: Built with use(), useSyncExternalStore, and Server Components support.
  • ⚡ 60fps UI Sync: Custom TimeStore uses RAF loops to update progress bars/time displays without triggering React re-renders.
  • 🎨 100% Headless: No default styles. No "shadow DOM" fighting. You own every pixel.
  • 📡 HLS First: Production-ready HLS support (via hls.js) baked in.
  • 🎧 First-Class Audio: Full HLS audio support for podcasts and radio.
  • 🧹 No Bloat: No YouTube/Vimeo/DASH adapters. Focused purely on direct file/stream playback.

📦 Installation

npm install @classytic/react-media

⚡ Quick Start (Video)

import {
  VideoController,
  VideoRoot,
  Video,
  PlayButton,
  TimeSlider,
} from "@classytic/react-media";

export default function Player() {
  return (
    // 1. Controller manages state (HLS, buffering, errors)
    <VideoController src="https://stream.mux.com/YOUR_PLAYBACK_ID.m3u8">
      {/* 2. Root handles layout & keyboard shortcuts */}
      <VideoRoot className="relative aspect-video bg-black group">
        {/* 3. The native video element (handled by controller) */}
        <Video className="w-full h-full object-cover" />

        {/* 4. Headless Controls (Style with Tailwind) */}
        <div className="absolute bottom-0 w-full p-4 bg-gradient-to-t from-black/80">
          <TimeSlider className="h-1 bg-white/30 cursor-pointer">
            <div className="h-full bg-red-500 var-progress" />
          </TimeSlider>

          <div className="flex gap-4 mt-2">
            <PlayButton className="text-white hover:text-red-500">
              {({ isPlaying }) => (isPlaying ? "PAUSE" : "PLAY")}
            </PlayButton>
          </div>
        </div>
      </VideoRoot>
    </VideoController>
  );
}

🎧 Quick Start (Audio)

Same high-performance engine, built for Audio.

import {
  AudioController,
  AudioPlayButton,
  AudioProgress,
} from "@classytic/react-media/audio";

export default function PodcastPlayer() {
  return (
    <AudioController src="https://your-hls-stream/radio.m3u8">
      <div className="p-4 rounded-xl bg-zinc-900 border border-zinc-800">
        <div className="flex items-center gap-4">
          <AudioPlayButton className="w-12 h-12 rounded-full bg-white text-black flex items-center justify-center">
            {({ isPlaying }) => (isPlaying ? <PauseIcon /> : <PlayIcon />)}
          </AudioPlayButton>

          <AudioProgress className="flex-1 h-2 bg-zinc-800 rounded-full overflow-hidden">
            {/* Direct DOM update for 60fps smoothness */}
            {({ progress }) => (
              <div
                className="h-full bg-white transition-all duration-75"
                style={{ width: `${progress}%` }}
              />
            )}
          </AudioProgress>
        </div>
      </div>
    </AudioController>
  );
}

🏗 Architecture

We separate state into two stores to ensure the react render cycle never blocks playback UI. For deep dives into the engine's design, fault tolerance (circuit breakers), and HLS manifest polling, read the full Architecture Guide.

For S3 configuration, refer to the S3 Setup Guide for public vs private livestreaming.

| Store | Purpose | Update Frequency | Technology | | ---------------- | -------------------------------------- | ------------------ | ------------------------------------ | | VideoStore | Play/Pause, Buffering, Quality, Errors | Low (Event driven) | useSyncExternalStore | | TimeStore | CurrentTime, Progress, Duration | High (4-60Hz) | requestAnimationFrame + Direct DOM |

This means your progress bar updates smoothly even if your React app is busy rendering a complex component tree.


📚 API Reference

/video — Video Player

Core

| Export | Description | | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | VideoController | Headless state provider. Wraps children in video context | | useVideoState(selector) | Subscribe to video state with selector (e.g., s => s.isPlaying) | | useShallowVideoState(selector) | Same as above with shallow comparison for object selectors | | useVideoActions() | Get stable action methods: play, pause, seek, setVolume, toggleMute, toggleFullscreen, togglePictureInPicture, setQuality, setPlaybackRate, seekToLiveEdge | | useVideoRefs() | Access videoRef and containerRef for direct DOM work |

Time Hooks (Performance)

| Export | Description | | ----------------------- | --------------------------------------------------- | | useDirectTime(cb) | Subscribe to time updates directly (bypasses React) | | useDirectProgress(cb) | Subscribe to progress % directly | | useDirectDuration(cb) | Subscribe to duration changes | | useTimeRef() | Returns a ref that auto-updates with formatted time | | useProgressRef() | Returns a ref that auto-updates style.width | | useTimeGetter() | On-demand getter: const getTime = useTimeGetter() | | useDurationGetter() | On-demand getter for duration | | formatTime(seconds) | Formats 90 as "1:30" |

Primitives (Headless)

| Export | Description | | ----------------------------------- | ----------------------------------------------------- | | VideoRoot | Container div with data attributes for state tracking | | Video | The <video> element managed by the controller | | PlayButton | Render-prop button: {({ isPlaying }) => ...} | | MuteButton | Render-prop mute toggle | | FullscreenButton | Render-prop fullscreen toggle | | PictureInPictureButton | Render-prop PiP toggle | | TimeSlider | Seek scrubber with drag support | | TimeDisplay | Direct-DOM time readout | | VolumeSlider | Volume control with drag | | QualitySelect | Quality level picker (HLS) | | PlaybackSpeed | Playback rate selector | | SeekButton | Skip forward/backward | | DoubleTapSeek | Mobile double-tap seek gesture | | Captions / CaptionsButton | Subtitle rendering + toggle | | BufferingOverlay / ErrorOverlay | State overlays | | StatsOverlay | Debug "stats for nerds" panel | | VideoContextMenu | Right-click context menu | | ChaptersMenu | Chapter navigation | | ThumbnailPreview | Sprite-based thumbnail on hover |

Features

| Export | Description | | ------------------------------ | ---------------------------------------------------------------- | | useCast() | Chromecast integration | | useAirPlay() | Apple AirPlay integration | | useNetworkQuality() | Network bandwidth/RTT monitoring | | useAdaptiveBitrate(options?) | Automatic quality switching (bandwidth/buffer/hybrid strategies) | | useMediaSession(options?) | Lock screen / OS media controls (Media Session API) | | useAnalytics(options?) | Video analytics & telemetry | | useErrorRecovery(options?) | Auto-recovery with exponential backoff | | useVideoDebug(options?) | Debug metrics for "stats for nerds" | | useFrameCapture() | Capture video frames as images | | useCopyVideoUrl(options?) | Share URLs with timestamps / embed codes |

useAdaptiveBitrate(options?)
const { isAutoMode, recommendedQuality, override, enableAuto, bufferHealth } =
  useAdaptiveBitrate({
    strategy: "hybrid", // 'bandwidth' | 'buffer' | 'hybrid'
    switchCooldown: 10000, // ms between switches
    minQuality: 0,
    maxQuality: 3,
    onQualitySwitch: (from, to, reason) => console.log(reason),
  });
useMediaSession(options?)
useMediaSession({
  metadata: { title: "Interview", artist: "Company" },
  seekOffset: 10, // seconds for skip buttons
  autoSync: true, // sync position state with playback
});

Icons

Built-in SVG icons (24x24, filled style): PlayIcon, PauseIcon, VolumeHighIcon, VolumeLowIcon, VolumeMutedIcon, FullscreenIcon, FullscreenExitIcon, PictureInPictureIcon, CaptionsIcon, SettingsIcon, ChevronLeftIcon, ChevronRightIcon.


/audio — Audio Player

| Export | Description | | ------------------------- | ------------------------------------------------ | | AudioController | Headless audio state provider | | AudioPlayButton | Render-prop play/pause button | | AudioProgress | Progress bar with direct DOM updates | | useAudioState(selector) | Subscribe to audio state | | useAudioActions() | Get play, pause, seek, setVolume actions | | useAudioCast() | Chromecast for audio |


/core — Upload Engine

| Export | Description | | ------------------------------------------- | ------------------------------------------------------------- | | Upload | Multi-part upload orchestrator (S3, Cloudflare, HLS segments) | | useUpload(options) | React hook for file uploads with progress | | useFileUpload() | Drop-in hook with file picker integration | | RetryManager | Exponential backoff retry logic | | calculateCRC32(blob) | CRC32 checksum (fast, for chunks) | | calculateSHA256(blob) | SHA-256 checksum (Web Crypto) | | verifyChecksum(blob, expected, algorithm) | Verify integrity | | createMediaKitProvider(config) | createS3Provider preconfigured for a @classytic/media-kit backend — host recipe in media-kit's react-media-integration guide |

Upload providers: @classytic/react-media/providers/s3, /cloudflare, /hls-segment, /mse-segment, /media-kit.

Live Recording (MSE Segment Upload)

| Export | Description | | --------------------------- | ------------------------------------------------------------------ | | useLiveRecorderProfile | High-level live recording hook (fMP4 segments to S3) | | createMseSegmentProvider | Upload provider for per-segment S3 uploads with manifest.json | | useDirectMSE | MSE player hook for manifest.json + fMP4/WebM segment playback | | DirectMSEPlayer | Full-featured MSE player component with controls and timeline |

useDirectMSE(options)

Plays back fMP4/WebM segments from a manifest.json using the browser's MediaSource Extensions API.

import { useDirectMSE } from '@classytic/react-media/video';

const videoRef = useRef<HTMLVideoElement>(null);

const { status, error, segmentCount, totalDuration, isLive, isEnded } = useDirectMSE({
  manifestUrl: '/api/interviews/123/live/manifest.json',
  baseUrl: '/api/interviews/123/live/',
  videoRef,
  autoPlay: true,
  bufferAhead: 30,
  bufferBehind: 30,
  bufferMode: 'auto',
  debug: false,         // Enable console logging for troubleshooting
  onSegment: (count) => console.log(`${count} segments loaded`),
  onEnded: () => console.log('Stream ended'),
});

Manifest Format (LiveManifest)

The manifest is the contract between the recorder and the player. Both sides use the LiveManifest type:

import type { LiveManifest, LiveManifestSegment } from '@classytic/react-media/video';
{
  "version": 7,
  "targetDuration": 7,
  "initSegment": "init.mp4",
  "mimeType": "video/mp4;codecs=avc1.42E01E,mp4a.40.2",
  "ended": false,
  "segments": [
    { "uri": "segments/seg_0000.m4s", "duration": 6.012 },
    { "uri": "segments/seg_0001.m4s", "duration": 5.987 }
  ]
}

See recording.md for full live recording documentation including manifest lifecycle, data loss scenarios, and backend reconciliation.

Recorder lifecycle guards — browser closed mid-recording

Opt-in guards that cover the "user closes the tab mid-recording" gap: without them the backend is left with an open multipart upload / stale session until its sweep runs. Configure on useVideoRecorder (or RecorderOptions for the VideoRecorder / WebCodecs engines directly):

const recorder = useVideoRecorder({
  provider: 'media-kit',
  providerConfig: { apiBase: '/api/media' },
  lifecycleGuards: {
    // POST target for the abort notification (string or (ctx) => string)
    abortBeaconUrl: (ctx) => `/api/media/${ctx.uploadId}/abort`,
    // Optional custom body — default is a JSON Blob of { key, uploadId, sessionId }
    beaconPayload: (ctx) => JSON.stringify({ uploadId: ctx.uploadId }),
    // Native leave-confirmation while recording (default: true)
    warnOnUnload: true,
  },
});

While recording is active: beforeunload triggers the browser's leave-confirmation (warnOnUnload), and pagehide — which fires far more reliably than beforeunload, including on mobile tab kills — sends a navigator.sendBeacon abort notification with the session identifiers. Both handlers are deregistered on clean stop/finalize/abort, so a completed recording never fires a beacon. For visible-tab cancels call recorder.abortSession() — it sends the same abort notification via fetch(..., { keepalive: true }) (beacons are reserved for pagehide) and then runs the normal provider abort.

Best-effort by design. Beacons can be lost (crashed tab, killed process, offline). The guarantee is the server-side sweep: media-kit's purgeStalePending plus the S3 AbortIncompleteMultipartUpload bucket lifecycle rule — see media-kit's docs/guides/react-media-integration.mdx, section "Abandoned sessions". The guards just make the common case (ordinary tab close) clean up in seconds instead of waiting for the sweep.


/shared — Shared Utilities

Captions

| Export | Description | | --------------------------- | --------------------------------- | | parseVTT(content) | Parse WebVTT → { cues, errors } | | parseSRT(content) | Parse SRT subtitles | | parseSubtitles(content) | Auto-detect VTT/SRT | | getActiveCues(cues, time) | Filter cues active at time | | searchCues(cues, query) | Full-text search through cues | | formatTimestamp(seconds) | 90.5"01:30.500" | | vttToHtml(text) | Convert VTT formatting to HTML |

Hooks

| Export | Description | | ----------------------------------- | --------------------------------------------- | | useAutoplay(options) | Autoplay policy detection with muted fallback | | useIntersectionObserver(options?) | Viewport-based lazy loading |

useAutoplay(options)
const { canAutoplay, canAutoplayMuted, isBlocked, isMuted, unmute, retry } =
  useAutoplay({
    mediaRef: videoRef,
    viewportTrigger: false, // trigger on viewport entry
    autoDetect: true, // detect on mount
  });

{
  canAutoplayMuted && !canAutoplay && (
    <button onClick={unmute}>Tap to unmute</button>
  );
}

Other

| Export | Description | | ------------------------------ | ----------------------------------- | | cn(...classes) | Class name utility (clsx + twMerge) | | useIntersectionObserver() | Viewport observer with once mode | | fetchWithAbort(url, options) | Fetch with timeout + AbortSignal | | TIMING, DEFAULTS | Config constants |


🏗 Architecture

We separate state into two stores to ensure the react render cycle never blocks playback UI. For deep dives into the engine's design, fault tolerance (circuit breakers), and HLS manifest polling, read the full Architecture Guide.

For S3 configuration, refer to the S3 Setup Guide for public vs private livestreaming.

| Store | Purpose | Update Frequency | Technology | | ---------------- | -------------------------------------- | ------------------ | ------------------------------------ | | VideoStore | Play/Pause, Buffering, Quality, Errors | Low (Event driven) | useSyncExternalStore | | TimeStore | CurrentTime, Progress, Duration | High (4-60Hz) | requestAnimationFrame + Direct DOM |

This means your progress bar updates smoothly even if your React app is busy rendering a complex component tree.


Theming — bring your own design system

The primitives are headless (positioning + interaction inline, zero visual opinion). Hosts restyle at four standardized levels (shadcn/Radix conventions):

1. className + classNames slots. Every component takes className (root). Composite components with a default multi-part UI ALSO take a classNames record of named parts, each merged via cn (tailwind-merge) after the defaults — your classes win conflicts:

<ErrorOverlay
  className="absolute inset-0"
  classNames={{
    content: 'bg-neutral-900/90',   // beats the default overlay bg
    message: 'text-rose-400',
    retry: 'bg-rose-500 hover:bg-rose-600',
  }}
/>

<CaptionSelector
  tracks={tracks}
  activeTrackId={id}
  classNames={{ trigger: '…', menu: 'bg-neutral-900', option: '…', optionActive: 'ring-1', divider: '…' }}
/>

Slots per composite: BufferingOverlay/LoadingOverlay { content, indicator } · ErrorOverlay { content, body, message, retry } · StatsOverlay { panel, header, row, label, value } · CaptionSelector { trigger, menu, option, optionActive, divider } · CaptionRenderer { window, cueContainer, cue, text } · VideoErrorFallback { title, message, retry } · VideoContextMenuclassName styles the fixed positioning shell (the menu content is yours). Leaf primitives (PlayButton, MuteButton, TimeSlider.*, SeekButton, cast/AirPlay buttons, audio primitives) are single-element and already take className directly.

2. State via data-attributes. Interactive state is exposed on the DOM, so Tailwind (data-[state=playing]:…) and plain CSS both target it:

| Element | Attributes | | --- | --- | | PlayButton | data-playing, data-paused, data-loading, data-buffering | | MuteButton / VolumeSlider | data-muted, data-volume, data-dragging, data-state="muted\|idle" | | TimeSlider (root) | data-progress, data-seeking, data-dragging, data-live | | FullscreenButton / PictureInPictureButton | data-fullscreen / data-pip | | BufferingOverlay | data-state="buffering\|seeking", data-buffering, data-seeking, data-loading | | LoadingOverlay / ErrorOverlay | data-state="loading" / data-state="error" | | AudioProgress / AudioVolume | data-state, data-progress/data-volume, data-buffered, data-disabled | | CastButton / AirPlayButton | data-state, data-available, data-connecting/data-supported | | CaptionSelector (trigger + options) | data-state="active\|idle" | | SeekButton | data-seek-amount, data-seek-direction | | VideoContextMenu | data-state="open\|closed" | | CaptionRenderer (window, when draggable) | data-dragging |

3. --rm-* design tokens. Everything the package styles inline (overlays, stats panel, error fallback, accent color) reads a CSS custom property with a safe inline fallback — override at :root (or any wrapper) to retheme. Defaults live at the top of styles.css:

| Token | Default | Drives | | --- | --- | --- | | --rm-accent | var(--color-primary, #8b5cf6) | active caption track, selected states | | --rm-danger | var(--color-destructive, #ef4444) | error overlay + retry button | | --rm-overlay-bg | #000 | loading/error full-cover backdrop | | --rm-stats-bg / --rm-stats-fg | rgba(0,0,0,.85) / #fff | stats "for nerds" panel | | --rm-radius | 0.5rem | inline-styled buttons/panels corners | | --rm-font-mono | system mono stack | stats panel font |

--rm-accent falls back to the shadcn --color-primary, so one shadcn/Tailwind theme drives react-media, @classytic/vixel-ui, and your own UI together.

Importing styles.css stays optional — components are fully functional unstyled (enforced by the no-styles smoke tests); styles.css carries only presentation (token defaults + <video> normalization), never behavior.

Movable captions — position + draggable

CaptionRenderer (and everything that wraps it: ConnectedCaptionRenderer, CaptionOverlay, VideoCaptions) takes a CaptionPosition:

// Edge presets — unchanged default behavior
<ConnectedCaptionRenderer position="bottom" />   // default
<ConnectedCaptionRenderer position="top" />

// Fractional { x, y } — 0..1 of the container, addressing the WINDOW CENTER.
// Resolution-independent: survives resizes and fullscreen toggles.
<ConnectedCaptionRenderer position={{ x: 0.5, y: 0.15 }} />

// YouTube-style movable captions: the window itself is the drag handle
<ConnectedCaptionRenderer
  draggable
  onPositionChange={(pos) => savePreferredPosition(pos)} // { x, y } fractions
/>

With draggable, pointer-drag repositions the window (clamped inside the container, data-dragging set on the window while active — style it via classNames.window + data-[dragging=true]:…). The window is focusable and arrow keys nudge it by 2% per press (keyboard a11y). Controlled vs uncontrolled: a fractional position prop is controlled and always wins over drag (listen to onPositionChange and feed it back); a string preset or no position leaves the component uncontrolled — the preset seeds the layout and the user's drag position lives in internal state.

CSS Setup

Import the styles in your global CSS (Tailwind v4):

@import "tailwindcss";
@import "@classytic/react-media/styles.css";

The @source directive in the CSS tells Tailwind to scan the dist folder for class names.


License

MIT