npm package: @instincthub/shaka-player
Description: Shaka Player-based video player with adaptive streaming for React
Latest version: 0.1.0

# @instincthub/shaka-player

A feature-rich React video player built on [Shaka Player](https://github.com/shaka-project/shaka-player) with adaptive bitrate streaming (HLS/DASH), automatic MP4 fallback, keyboard controls, subtitles, and a fully customizable UI.

## Features

- **Adaptive streaming** &mdash; HLS and DASH via Shaka Player with automatic ABR
- **Native HLS on Safari/iOS** &mdash; uses the browser's built-in HLS support
- **MP4 fallback** &mdash; seamless fallback when streaming fails, with position resume
- **Quality selector** &mdash; auto (ABR) or manual quality switching
- **Subtitles/captions** &mdash; VTT and SRT support with multi-language picker
- **Keyboard shortcuts** &mdash; play/pause, seek, volume, fullscreen, captions
- **Idle detection** &mdash; auto-pause after configurable inactivity timeout
- **Next video** &mdash; end-screen with countdown and thumbnail
- **Stall recovery** &mdash; 5-stage escalation from playhead nudge to full restart
- **CSS theming** &mdash; customizable via CSS custom properties
- **Headless mode** &mdash; `useVideoPlayer` hook for building custom UIs
- **TypeScript** &mdash; full type definitions for all props, state, and events
- **Lightweight** &mdash; < 55 kB (Shaka Player loaded on demand)

## Installation

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

### Peer dependencies

React 18 or 19 is required:

```bash
npm install react react-dom
```

## Quick Start

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

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

## Props

### Source & Playback

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `src` | `string` | *required* | Primary video URL (`.m3u8`, `.mpd`, or `.mp4`) |
| `fallbackSrc` | `string` | &mdash; | MP4 fallback URL when adaptive streaming fails |
| `poster` | `string` | &mdash; | Poster image shown before playback |
| `type` | `"auto" \| "hls" \| "dash" \| "mp4"` | `"auto"` | Force source type detection |
| `autoplay` | `boolean` | `false` | Start playback automatically |
| `muted` | `boolean` | `false` | Start muted |
| `loop` | `boolean` | `false` | Loop playback |
| `volume` | `number` | `0.5` | Initial volume (0&ndash;1) |
| `playbackRate` | `number` | `1` | Initial playback speed |
| `preload` | `"none" \| "metadata" \| "auto"` | `"metadata"` | Preload strategy |
| `startTime` | `number` | &mdash; | Resume position in seconds |

### Streaming & Subtitles

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `shakaConfig` | `Record<string, unknown>` | &mdash; | [Shaka Player configuration](https://shaka-player-demo.appspot.com/docs/api/shaka.extern.html#.PlayerConfiguration) overrides |
| `subtitles` | `SubtitleTrack[]` | &mdash; | Subtitle/caption tracks |

### Next Video

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `nextVideo` | `NextVideo` | &mdash; | Next video info for end screen |
| `onNextVideo` | `() => void` | &mdash; | Called when user clicks "next" or countdown finishes |

### Idle Tracking

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `idleTimeout` | `number` | &mdash; | Idle timeout in minutes (`0` or `undefined` disables) |
| `idleMessage` | `string` | `"Video paused. Continue watching?"` | Message shown when auto-paused |

### UI & Styling

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `controls` | `boolean` | `true` | Show built-in control bar |
| `className` | `string` | &mdash; | CSS class on the container |
| `style` | `CSSProperties` | &mdash; | Inline styles on the container |
| `width` | `string \| number` | `"100%"` | Container width |
| `height` | `string \| number` | `"auto"` | Container height |

### Event Callbacks

| Prop | Type | Description |
|------|------|-------------|
| `onReady` | `() => void` | Player is ready for playback |
| `onPlay` | `() => void` | Playback started |
| `onPause` | `() => void` | Playback paused |
| `onEnded` | `() => void` | Playback reached the end |
| `onTimeUpdate` | `(time: number, duration: number) => void` | Current time changed |
| `onError` | `(error: PlayerError) => void` | An error occurred |
| `onQualityChange` | `(level: QualityLevel) => void` | Active quality level changed |
| `onFallback` | `(reason: FallbackReason) => void` | Player fell back to MP4 |
| `onBufferStart` | `() => void` | Buffering started |
| `onBufferEnd` | `() => void` | Buffering ended |
| `onSubtitleChange` | `(track: SubtitleTrack \| null) => void` | Active subtitle changed |
| `onIdlePause` | `() => void` | Video auto-paused due to inactivity |

## Imperative Ref API

Use a ref when you need programmatic control from a parent component:

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

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

  return (
    <>
      <VideoPlayer ref={playerRef} src="https://example.com/stream.m3u8" />
      <button onClick={() => playerRef.current?.seek(60)}>Jump to 1:00</button>
      <button onClick={() => playerRef.current?.setPlaybackRate(2)}>2x Speed</button>
    </>
  );
}
```

### Ref Methods

| Method | Description |
|--------|-------------|
| `play()` | Start playback |
| `pause()` | Pause playback |
| `seek(time)` | Seek to time in seconds |
| `setVolume(volume)` | Set volume (0&ndash;1) |
| `setMuted(muted)` | Set muted state |
| `setPlaybackRate(rate)` | Set playback speed |
| `setQuality(levelIndex)` | Set quality level (`-1` for auto) |
| `getQualityLevels()` | Get available quality levels |
| `setSubtitle(trackIndex)` | Set subtitle track (`-1` for off) |
| `getSubtitleTracks()` | Get available subtitle tracks |
| `getCurrentTime()` | Get current time in seconds |
| `getDuration()` | Get total duration in seconds |
| `requestFullscreen()` | Enter fullscreen |
| `exitFullscreen()` | Exit fullscreen |
| `destroy()` | Clean up all resources |

## Headless Mode

Use the `useVideoPlayer` hook to build a completely custom UI while the hook manages all streaming, state, and playback logic:

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

function CustomPlayer() {
  const { containerRef, videoRef, state, controls } = useVideoPlayer({
    src: "https://example.com/stream.m3u8",
    fallbackSrc: "https://example.com/video.mp4",
    onReady: () => console.log("Ready"),
  });

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

      <button onClick={controls.togglePlay}>
        {state.status === "playing" ? "Pause" : "Play"}
      </button>

      <input
        type="range"
        min={0}
        max={state.duration}
        value={state.currentTime}
        onChange={(e) => controls.seek(Number(e.target.value))}
      />

      <span>
        {formatTime(state.currentTime)} / {formatTime(state.duration)}
      </span>

      {state.qualityLevels.length > 0 && (
        <select
          value={state.currentQuality}
          onChange={(e) => controls.setQuality(Number(e.target.value))}
        >
          <option value={-1}>Auto</option>
          {state.qualityLevels.map((level) => (
            <option key={level.index} value={level.index}>
              {level.label}
            </option>
          ))}
        </select>
      )}
    </div>
  );
}
```

### Hook Return Value

```ts
interface UseVideoPlayerReturn {
  containerRef: RefObject<HTMLDivElement>;  // Attach to container element
  videoRef: RefObject<HTMLVideoElement>;    // Attach to <video> element
  state: PlayerState;                       // Reactive player state
  controls: PlayerControls;                 // Control methods
}
```

### Player State

| Field | Type | Description |
|-------|------|-------------|
| `status` | `PlayerStatus` | `"idle"` `"loading"` `"ready"` `"playing"` `"paused"` `"buffering"` `"seeking"` `"ended"` `"error"` |
| `currentTime` | `number` | Current position in seconds |
| `duration` | `number` | Total duration in seconds |
| `buffered` | `number` | Buffered ahead in seconds |
| `volume` | `number` | Current volume (0&ndash;1) |
| `muted` | `boolean` | Muted state |
| `playbackRate` | `number` | Current playback speed |
| `isFullscreen` | `boolean` | Fullscreen state |
| `qualityLevels` | `QualityLevel[]` | Available quality levels |
| `currentQuality` | `number` | Active quality index (`-1` = auto) |
| `autoQuality` | `boolean` | Whether ABR is active |
| `subtitleTracks` | `SubtitleTrack[]` | Available subtitle tracks |
| `activeSubtitle` | `number` | Active subtitle index (`-1` = off) |
| `error` | `PlayerError \| null` | Current error, if any |
| `hasStarted` | `boolean` | Whether playback has started at least once |

### Player Controls

| Method | Description |
|--------|-------------|
| `play()` | Start playback |
| `pause()` | Pause playback |
| `togglePlay()` | Toggle play/pause |
| `seek(time)` | Seek to absolute time in seconds |
| `seekRelative(offset)` | Seek relative to current position |
| `setVolume(volume)` | Set volume (0&ndash;1) |
| `toggleMute()` | Toggle mute |
| `setPlaybackRate(rate)` | Set playback speed |
| `setQuality(levelIndex)` | Set quality (`-1` for auto) |
| `setSubtitle(trackIndex)` | Set subtitle (`-1` for off) |
| `toggleSubtitles()` | Toggle subtitles (remembers last track) |
| `toggleFullscreen()` | Toggle fullscreen |

## Subtitles

Pass an array of `SubtitleTrack` objects. Both VTT and SRT formats are supported (SRT is auto-converted to VTT):

```tsx
<VideoPlayer
  src="https://example.com/stream.m3u8"
  subtitles={[
    { src: "/subs/en.vtt", label: "English", srclang: "en", default: true },
    { src: "/subs/es.srt", label: "Spanish", srclang: "es" },
    { src: "/subs/fr.vtt", label: "French", srclang: "fr" },
  ]}
/>
```

### SubtitleTrack

| Field | Type | Description |
|-------|------|-------------|
| `src` | `string` | URL to `.vtt` or `.srt` file |
| `label` | `string` | Display label (e.g. `"English"`) |
| `srclang` | `string` | BCP 47 language tag (e.g. `"en"`) |
| `default` | `boolean` | Active by default |

## Next Video

Show a "next video" end screen with an optional auto-play countdown:

```tsx
<VideoPlayer
  src="https://example.com/lesson-1.m3u8"
  nextVideo={{
    title: "Lesson 2: Components",
    thumbnail: "https://example.com/lesson-2-thumb.jpg",
    countdown: 10, // seconds, set 0 to disable auto-play
  }}
  onNextVideo={() => router.push("/lessons/2")}
/>
```

## Keyboard Shortcuts

When the player container has focus:

| Key | Action |
|-----|--------|
| `Space` / `K` | Play / Pause |
| `Arrow Left` / `J` | Rewind 10s |
| `Arrow Right` / `L` | Forward 10s |
| `Arrow Up` | Volume +10% |
| `Arrow Down` | Volume -10% |
| `M` | Toggle mute |
| `F` | Toggle fullscreen |
| `C` | Toggle captions |

## Streaming Architecture

The player selects a playback mode based on the source type and browser support:

1. **MP4** &mdash; set directly on the `<video>` element
2. **HLS on Safari/iOS** &mdash; native `<video>` HLS with quality levels parsed from the master playlist
3. **HLS/DASH elsewhere** &mdash; Shaka Player (lazy-loaded) with ABR and quality switching

### Error Recovery

Shaka mode uses a 5-stage stall recovery escalation:

| Stage | Action |
|-------|--------|
| 0 | Nudge playhead +0.1s |
| 1 | Drop to lowest quality |
| 2 | Detach and reattach player |
| 3 | Full Shaka restart |
| 4 | Fall back to `fallbackSrc` MP4 |

If `player.load()` fails (e.g. 403/404), the player skips the restart cycle and falls back to MP4 immediately.

Fatal Shaka errors during playback trigger restart attempts. After 3 fatal errors, the player falls back to MP4.

If no `fallbackSrc` is provided, the error overlay with a retry button is shown instead.

### Shaka Configuration

Override the default Shaka config with the `shakaConfig` prop. The config is deep-merged with the defaults:

```tsx
<VideoPlayer
  src="https://example.com/stream.m3u8"
  shakaConfig={{
    streaming: {
      bufferingGoal: 120,
      rebufferingGoal: 5,
    },
    abr: {
      enabled: true,
      defaultBandwidthEstimate: 10_000_000,
    },
  }}
/>
```

<details>
<summary>Default Shaka configuration</summary>

```js
{
  streaming: {
    bufferingGoal: 60,
    rebufferingGoal: 2,
    bufferBehind: 30,
    smallGapLimit: 0.5,
    retryParameters: {
      maxAttempts: 5,
      baseDelay: 1000,
      backoffFactor: 2,
      timeout: 30_000,
    },
  },
  abr: {
    enabled: true,
    defaultBandwidthEstimate: 5_000_000,
    switchInterval: 3,
    bandwidthUpgradeTarget: 0.85,
    bandwidthDowngradeTarget: 0.7,
  },
  manifest: {
    retryParameters: {
      maxAttempts: 3,
      baseDelay: 1000,
      backoffFactor: 2,
      timeout: 30_000,
    },
  },
}
```

</details>

## CSS Theming

The player UI is styled with CSS custom properties. Override them on the container or globally:

```css
.my-player {
  --ihub-player-primary: #6200ea;
  --ihub-player-accent: #b388ff;
  --ihub-player-bg: #1a1a2e;
  --ihub-player-controls-bg: rgba(26, 26, 46, 0.85);
  --ihub-player-text: #ffffff;
  --ihub-player-progress: #6200ea;
  --ihub-player-progress-hover: #b388ff;
  --ihub-player-buffer: rgba(255, 255, 255, 0.3);
  --ihub-player-error: #cf6679;
  --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 className="my-player" src="..." />
```

### CSS Custom Properties

| Variable | Default | Description |
|----------|---------|-------------|
| `--ihub-player-primary` | `#00838f` | Primary brand color (focus rings, thumb) |
| `--ihub-player-accent` | `#0fabbc` | Accent color (hover states) |
| `--ihub-player-bg` | `#000000` | Player background |
| `--ihub-player-controls-bg` | `rgba(0,0,0,0.7)` | Control bar background |
| `--ihub-player-text` | `#ffffff` | Text and icon color |
| `--ihub-player-progress` | `#00838f` | Progress bar fill |
| `--ihub-player-progress-hover` | `#0fabbc` | Progress bar fill on hover |
| `--ihub-player-buffer` | `rgba(255,255,255,0.4)` | Buffer indicator |
| `--ihub-player-error` | `#ea5f5e` | Error state color |
| `--ihub-player-border-radius` | `8px` | Container border radius |
| `--ihub-player-font-family` | `"Nunito", sans-serif` | Body font |
| `--ihub-player-heading-font` | `"Montserrat", sans-serif` | Heading font |
| `--ihub-player-transition` | `0.2s ease` | Transition timing |

## Types

All types are exported from the package:

```ts
import type {
  VideoPlayerProps,
  VideoPlayerRef,
  PlayerState,
  PlayerControls,
  UseVideoPlayerReturn,
  PlayerStatus,
  SourceType,
  PlaybackMode,
  QualityLevel,
  PlayerError,
  FallbackReason,
  VideoPlayerEventProps,
  NextVideo,
  SubtitleTrack,
} from "@instincthub/shaka-player";
```

### Key Types

```ts
interface PlayerError {
  type: "network" | "media" | "shaka" | "unknown";
  fatal: boolean;
  message: string;
  details?: string;
  code?: number;      // Shaka error code
  category?: number;  // Shaka error category
}

interface FallbackReason {
  from: "shaka" | "hls";
  to: "mp4";
  cause: "unsupported" | "manifest_error" | "media_error" | "network_error";
  errorCount: number;
}

interface QualityLevel {
  index: number;
  width: number;
  height: number;
  bitrate: number;
  label: string;  // e.g. "720p"
}

type PlayerStatus =
  | "idle"
  | "loading"
  | "ready"
  | "playing"
  | "paused"
  | "buffering"
  | "seeking"
  | "ended"
  | "error";

type PlaybackMode = "shaka" | "native" | "none";
```

## Common Patterns

### Course Video Player

```tsx
function CoursePlayer({ lesson, onComplete }) {
  const playerRef = useRef<VideoPlayerRef>(null);

  return (
    <VideoPlayer
      ref={playerRef}
      src={lesson.hlsUrl}
      fallbackSrc={lesson.mp4Url}
      poster={lesson.thumbnail}
      startTime={lesson.lastPosition}
      subtitles={lesson.subtitles}
      idleTimeout={15}
      nextVideo={
        lesson.next
          ? { title: lesson.next.title, thumbnail: lesson.next.thumbnail, countdown: 10 }
          : undefined
      }
      onNextVideo={() => router.push(`/lessons/${lesson.next.id}`)}
      onTimeUpdate={(time) => saveProgress(lesson.id, time)}
      onEnded={() => onComplete(lesson.id)}
      onError={(err) => console.error("Playback error:", err)}
      onFallback={(reason) => {
        analytics.track("fallback", { cause: reason.cause, from: reason.from });
      }}
    />
  );
}
```

### Watch Progress Tracking

```tsx
<VideoPlayer
  src="https://example.com/stream.m3u8"
  startTime={savedProgress}
  onTimeUpdate={(time) => {
    // Throttle saves to every 5 seconds
    if (Math.floor(time) % 5 === 0) {
      saveProgress(videoId, time);
    }
  }}
  onEnded={() => markAsCompleted(videoId)}
/>
```

### Error Monitoring

```tsx
<VideoPlayer
  src="https://example.com/stream.m3u8"
  fallbackSrc="https://example.com/video.mp4"
  onError={(error) => {
    Sentry.captureMessage("Video playback error", {
      extra: { type: error.type, code: error.code, fatal: error.fatal },
    });
  }}
  onFallback={(reason) => {
    analytics.track("video_fallback", {
      from: reason.from,
      cause: reason.cause,
      errorCount: reason.errorCount,
    });
  }}
/>
```

## Browser Support

| Browser | Mode |
|---------|------|
| Chrome / Edge / Firefox | Shaka Player (MSE) |
| Safari / iOS Safari | Native HLS |
| Older / unsupported | MP4 fallback |

## Development

```bash
# Install dependencies
npm install

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

# Type-check
npm run typecheck

# Run tests
npm run test

# Lint & format
npm run lint:fix
npm run format

# Build
npm run build

# Check bundle size
npm run size
```

## License

[MIT](LICENSE)