@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.
Why another player?
Most React players are just wrappers arounddocument.querySelector('video')or heavy Web Components (like Vidstack/Mux).
This is a native React engine. It usesrequestAnimationFrameloops for 60fps UI updates,useSyncExternalStorefor tearing-free state, and React 19'suse()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
TimeStoreuses 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
purgeStalePendingplus the S3AbortIncompleteMultipartUploadbucket lifecycle rule — see media-kit'sdocs/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 } · VideoContextMenu — className
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
