npm package: @instincthub/video-player
Description: HLS-first video player with MP4 fallback for React
Latest version: 0.1.0

# @instincthub/video-player

HLS-first video player for React with automatic MP4 fallback, adaptive bitrate streaming, and full playback controls.

Built on [hls.js](https://github.com/video-dev/hls.js) with lazy loading — hls.js is only downloaded when needed and never loaded on Safari (native HLS) or for MP4-only sources.

## Installation

```bash
npm install @instincthub/video-player
```

**Peer dependencies:** React 18 or 19.

## Quick Start

```tsx
import { VideoPlayer } from "@instincthub/video-player";
import "@instincthub/video-player/styles";

function Page() {
  return (
    <VideoPlayer
      src="https://example.com/stream/master.m3u8"
      fallbackSrc="https://example.com/video.mp4"
      poster="https://example.com/poster.jpg"
    />
  );
}
```

## Features

- **HLS streaming** with adaptive bitrate quality switching via hls.js
- **Automatic MP4 fallback** when HLS fails (network, media, or codec errors)
- **Native HLS on Safari/iOS** — hls.js is never loaded
- **Lazy loading** — hls.js bundle only downloads when an .m3u8 source is used
- **Full playback controls** — play/pause, seek, volume, speed, quality selector, fullscreen
- **Keyboard shortcuts** — Space/K, J/L, arrows, M, F
- **Accessible** — ARIA labels, focus-visible outlines, 44px tap targets
- **Themeable** — CSS custom properties for colors, fonts, radii
- **Headless mode** — `useVideoPlayer` hook for building custom UIs
- **Imperative ref API** — control the player programmatically
- **TypeScript** — full type definitions included
- **Idle tracking** — auto-pause on inactivity with customizable "Continue watching?" overlay
- **Lightweight** — ~42KB player core (before gzip), no runtime CSS-in-JS

## How It Works

The player automatically selects the best playback strategy:

1. **MP4 source** — plays directly via `<video>`
2. **M3U8 + Safari/iOS** — plays natively via `<video>` (no hls.js loaded)
3. **M3U8 + Chrome/Firefox/Edge** — lazy-loads hls.js for ABR streaming
4. **HLS failure** — falls back to `fallbackSrc` MP4, resuming from last position

## Component Props

```tsx
<VideoPlayer
  // Source (required)
  src="video.m3u8"              // Primary URL (.m3u8 or .mp4)
  fallbackSrc="video.mp4"       // MP4 fallback when HLS fails
  poster="poster.jpg"           // Poster image before playback
  type="auto"                   // Force type: "auto" | "hls" | "mp4"

  // Playback
  autoplay={false}              // Autoplay (must be muted for browser policy)
  muted={false}                 // Start muted
  loop={false}                  // Loop playback
  volume={0.5}                  // Initial volume (0-1)
  playbackRate={1}              // Initial speed
  preload="metadata"            // "none" | "metadata" | "auto"
  startTime={30}                // Resume position in seconds

  // HLS
  hlsConfig={{                  // hls.js config overrides
    maxBufferLength: 60,
    startLevel: -1,
    capLevelToPlayerSize: true,
  }}

  // Idle tracking
  idleTimeout={5}               // Auto-pause after 5 min of inactivity (0 = disabled)
  idleMessage="Still watching?" // Custom idle overlay message

  // UI
  controls={true}               // Show built-in controls
  className="my-player"         // Container CSS class
  style={{ maxWidth: 800 }}     // Container inline styles
  width="100%"                  // Container width
  height="auto"                 // Container height (16:9 default)

  // Events
  onReady={() => {}}
  onPlay={() => {}}
  onPause={() => {}}
  onEnded={() => {}}
  onTimeUpdate={(time, duration) => {}}
  onError={(error) => {}}
  onBufferStart={() => {}}
  onBufferEnd={() => {}}
  onQualityChange={(level) => {}}
  onFallback={(reason) => {}}
/>
```

### Props Reference

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `src` | `string` | — | Primary video URL (.m3u8 or .mp4). **Required.** |
| `fallbackSrc` | `string` | — | MP4 fallback URL when HLS fails |
| `poster` | `string` | — | Poster image URL |
| `type` | `"auto" \| "hls" \| "mp4"` | `"auto"` | Force source type detection |
| `autoplay` | `boolean` | `false` | Autoplay (must be muted for browser policy) |
| `muted` | `boolean` | `false` | Start muted |
| `loop` | `boolean` | `false` | Loop playback |
| `volume` | `number` | `0.5` | Initial volume (0–1) |
| `playbackRate` | `number` | `1` | Initial playback speed |
| `preload` | `"none" \| "metadata" \| "auto"` | `"metadata"` | Preload strategy |
| `startTime` | `number` | — | Resume position in seconds |
| `hlsConfig` | `Partial<HlsConfig>` | — | hls.js configuration overrides |
| `idleTimeout` | `number` | — | Idle timeout in minutes (0 or undefined disables) |
| `idleMessage` | `string` | `"Video paused. Continue watching?"` | Idle overlay message |
| `controls` | `boolean` | `true` | Show built-in controls |
| `className` | `string` | — | Container CSS class |
| `style` | `CSSProperties` | — | Container inline styles |
| `width` | `string \| number` | `"100%"` | Container width |
| `height` | `string \| number` | `"auto"` | Container height |

### Event Callbacks

| Event | Signature | Description |
|-------|-----------|-------------|
| `onReady` | `() => void` | Player is ready (metadata loaded) |
| `onPlay` | `() => void` | Playback starts or resumes |
| `onPause` | `() => void` | Playback pauses |
| `onEnded` | `() => void` | Video reaches the end |
| `onTimeUpdate` | `(time: number, duration: number) => void` | Current time changes (~4x/s) |
| `onError` | `(error: PlayerError) => void` | Any playback error |
| `onBufferStart` | `() => void` | Buffering begins |
| `onBufferEnd` | `() => void` | Buffering ends |
| `onQualityChange` | `(level: QualityLevel) => void` | HLS quality level changes |
| `onFallback` | `(reason: FallbackReason) => void` | HLS falls back to MP4 |
| `onIdlePause` | `() => void` | Video auto-paused due to inactivity |

## Idle Tracking

Auto-pause the video when the user is inactive. A "Continue watching?" overlay appears with a resume button.

```tsx
<VideoPlayer
  src="video.mp4"
  idleTimeout={5}                // Pause after 5 min of inactivity
  idleMessage="Still watching?"  // Custom message (optional)
  onIdlePause={() => console.log("User went idle")}
/>
```

- `idleTimeout` is in **minutes** (e.g., 2, 5, 10). Set to 0 or omit to disable.
- Timer resets on any user activity (mouse, keyboard, touch, scroll) and only runs while playing.

## Imperative Ref API

Control the player programmatically using a ref:

```tsx
import { useRef } from "react";
import { VideoPlayer, VideoPlayerRef } from "@instincthub/video-player";

function Page() {
  const playerRef = useRef<VideoPlayerRef>(null);

  return (
    <>
      <VideoPlayer src="video.mp4" ref={playerRef} />

      <button onClick={() => playerRef.current?.play()}>Play</button>
      <button onClick={() => playerRef.current?.seek(30)}>Jump to 30s</button>
      <button onClick={() => playerRef.current?.setPlaybackRate(2)}>2x</button>
    </>
  );
}
```

| Method | Description |
|--------|-------------|
| `play()` | Start or resume playback |
| `pause()` | Pause playback |
| `seek(time)` | Seek to time in seconds |
| `setVolume(vol)` | Set volume (0–1) |
| `setMuted(muted)` | Set mute state |
| `setPlaybackRate(rate)` | Set playback speed |
| `setQuality(index)` | Set HLS quality level (-1 for auto) |
| `getQualityLevels()` | Get available HLS quality levels |
| `getCurrentTime()` | Get current playback time |
| `getDuration()` | Get total duration |
| `requestFullscreen()` | Enter fullscreen |
| `exitFullscreen()` | Exit fullscreen |
| `destroy()` | Clean up all resources |

## Headless Mode (`useVideoPlayer`)

Build a fully custom UI using the hook directly:

```tsx
import { useVideoPlayer, formatTime } from "@instincthub/video-player";

function CustomPlayer() {
  const { containerRef, videoRef, state, controls } = useVideoPlayer({
    src: "https://example.com/video.mp4",
    controls: false,
  });

  return (
    <div ref={containerRef}>
      <video ref={videoRef} playsInline />

      <div className="my-controls">
        <button onClick={controls.togglePlay}>
          {state.status === "playing" ? "Pause" : "Play"}
        </button>
        <span>{formatTime(state.currentTime)} / {formatTime(state.duration)}</span>
        <button onClick={() => controls.seekRelative(-10)}>-10s</button>
        <button onClick={() => controls.seekRelative(10)}>+10s</button>
        <button onClick={controls.toggleMute}>
          {state.muted ? "Unmute" : "Mute"}
        </button>
        <button onClick={controls.toggleFullscreen}>Fullscreen</button>
      </div>
    </div>
  );
}
```

### Hook Return

| Property | Type | Description |
|----------|------|-------------|
| `containerRef` | `RefObject<HTMLDivElement>` | Attach to outermost container (for fullscreen + keyboard) |
| `videoRef` | `RefObject<HTMLVideoElement>` | Attach to the `<video>` element |
| `state` | `PlayerState` | Reactive player state |
| `controls` | `PlayerControls` | Imperative control methods |

### `PlayerState`

| Field | Type | Description |
|-------|------|-------------|
| `status` | `PlayerStatus` | `"idle"` \| `"loading"` \| `"ready"` \| `"playing"` \| `"paused"` \| `"buffering"` \| `"seeking"` \| `"ended"` \| `"error"` |
| `currentTime` | `number` | Current position (seconds) |
| `duration` | `number` | Total duration (seconds) |
| `buffered` | `number` | Buffered ahead (seconds) |
| `volume` | `number` | Current volume (0–1) |
| `muted` | `boolean` | Mute state |
| `playbackRate` | `number` | Current speed |
| `isFullscreen` | `boolean` | Fullscreen state |
| `qualityLevels` | `QualityLevel[]` | Available HLS quality levels |
| `currentQuality` | `number` | Current quality index (-1 = auto) |
| `autoQuality` | `boolean` | Whether auto quality is enabled |
| `error` | `PlayerError \| null` | Current error |
| `hasStarted` | `boolean` | Whether playback has started |

### `PlayerControls`

| Method | Description |
|--------|-------------|
| `play()` | Start playback |
| `pause()` | Pause playback |
| `togglePlay()` | Toggle play/pause |
| `seek(time)` | Seek to absolute time |
| `seekRelative(offset)` | Seek relative to current position |
| `setVolume(vol)` | Set volume (0–1) |
| `toggleMute()` | Toggle mute |
| `setPlaybackRate(rate)` | Set playback speed |
| `setQuality(index)` | Set HLS quality (-1 for auto) |
| `toggleFullscreen()` | Toggle fullscreen |

## Keyboard Shortcuts

Click the player to focus, then use:

| Key | Action |
|-----|--------|
| `Space` / `K` | Toggle play/pause |
| `J` / `ArrowLeft` | Rewind 10 seconds |
| `L` / `ArrowRight` | Forward 10 seconds |
| `ArrowUp` | Volume up 10% |
| `ArrowDown` | Volume down 10% |
| `M` | Toggle mute |
| `F` | Toggle fullscreen |

## Theming

Override CSS custom properties on the player container or any ancestor:

```css
.my-player {
  --ihub-player-primary: #ff6b6b;
  --ihub-player-accent: #ee5a24;
  --ihub-player-bg: #1a1a2e;
  --ihub-player-controls-bg: rgba(26, 26, 46, 0.8);
  --ihub-player-text: #ffffff;
  --ihub-player-progress: #ff6b6b;
  --ihub-player-progress-hover: #ee5a24;
  --ihub-player-buffer: rgba(255, 255, 255, 0.2);
  --ihub-player-error: #e74c3c;
  --ihub-player-border-radius: 12px;
  --ihub-player-font-family: "Inter", sans-serif;
  --ihub-player-heading-font: "Inter", sans-serif;
  --ihub-player-transition: 0.15s ease;
}
```

```tsx
<VideoPlayer src="video.mp4" className="my-player" />
```

| Variable | Default | Description |
|----------|---------|-------------|
| `--ihub-player-primary` | `#00838f` | Primary color (progress thumb, buttons) |
| `--ihub-player-accent` | `#0fabbc` | Hover/active color |
| `--ihub-player-bg` | `#000000` | Player background |
| `--ihub-player-controls-bg` | `rgba(0,0,0,0.7)` | Controls bar gradient |
| `--ihub-player-text` | `#ffffff` | Text and icon color |
| `--ihub-player-progress` | `#00838f` | Progress bar fill |
| `--ihub-player-progress-hover` | `#0fabbc` | Progress bar on hover |
| `--ihub-player-buffer` | `rgba(255,255,255,0.3)` | Buffer indicator |
| `--ihub-player-error` | `#ea5f5e` | Error text color |
| `--ihub-player-border-radius` | `8px` | Corner radius |
| `--ihub-player-font-family` | `"Nunito", sans-serif` | Body font |
| `--ihub-player-heading-font` | `"Montserrat", sans-serif` | Label font |
| `--ihub-player-transition` | `0.2s ease` | Animation speed |

## HLS Configuration

The player ships with sensible defaults. Override any [hls.js config option](https://github.com/video-dev/hls.js/blob/master/docs/API.md#fine-tuning) via the `hlsConfig` prop:

```tsx
<VideoPlayer
  src="stream.m3u8"
  fallbackSrc="video.mp4"
  hlsConfig={{
    startLevel: -1,               // Auto-select start quality
    capLevelToPlayerSize: true,    // Don't load above display size
    maxBufferLength: 30,           // Buffer 30s ahead
    maxMaxBufferLength: 60,        // Hard cap at 60s
    abrEwmaDefaultEstimate: 500000, // 500kbps initial estimate
    manifestLoadingMaxRetry: 3,    // Retry manifest 3 times
    fragLoadingMaxRetry: 3,        // Retry segments 3 times
    lowLatencyMode: false,         // Disable for VOD
  }}
/>
```

### Error Recovery

The player handles HLS errors automatically:

1. **Network errors** — retries with exponential backoff (3 attempts), then falls back to MP4
2. **Media errors** — calls `recoverMediaError()`, then `swapAudioCodec()`, then falls back to MP4
3. **Other errors** — falls back to MP4 immediately

Fallback preserves the current playback position and is seamless to the user.

## TypeScript

All types are exported:

```tsx
import type {
  VideoPlayerProps,
  VideoPlayerRef,
  PlayerState,
  PlayerControls,
  UseVideoPlayerReturn,
  PlayerStatus,
  SourceType,
  QualityLevel,
  PlayerError,
  FallbackReason,
  VideoPlayerEventProps,
} from "@instincthub/video-player";
```

## Browser Support

| Browser | HLS Strategy | Minimum Version |
|---------|-------------|-----------------|
| Safari (macOS/iOS) | Native `<video>` | 14+ |
| Chrome | hls.js | 90+ |
| Firefox | hls.js | 90+ |
| Edge | hls.js | 90+ |

## Development

```bash
# Install dependencies
npm install

# Run example app (Next.js)
npm run dev

# Build the library
npm run build

# Run tests
npm run test

# Type check
npm run typecheck

# Lint
npm run lint
```

## License

MIT