@instincthub/shaka-player
v0.1.0
Published
Shaka Player-based video player with adaptive streaming for React
Downloads
14
Maintainers
Readme
@instincthub/shaka-player
A feature-rich React video player built on Shaka Player with adaptive bitrate streaming (HLS/DASH), automatic MP4 fallback, keyboard controls, subtitles, and a fully customizable UI.
Features
- Adaptive streaming — HLS and DASH via Shaka Player with automatic ABR
- Native HLS on Safari/iOS — uses the browser's built-in HLS support
- MP4 fallback — seamless fallback when streaming fails, with position resume
- Quality selector — auto (ABR) or manual quality switching
- Subtitles/captions — VTT and SRT support with multi-language picker
- Keyboard shortcuts — play/pause, seek, volume, fullscreen, captions
- Idle detection — auto-pause after configurable inactivity timeout
- Next video — end-screen with countdown and thumbnail
- Stall recovery — 5-stage escalation from playhead nudge to full restart
- CSS theming — customizable via CSS custom properties
- Headless mode —
useVideoPlayerhook for building custom UIs - TypeScript — full type definitions for all props, state, and events
- Lightweight — < 55 kB (Shaka Player loaded on demand)
Installation
npm install @instincthub/shaka-playerPeer dependencies
React 18 or 19 is required:
npm install react react-domQuick Start
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 | — | MP4 fallback URL when adaptive streaming fails |
| poster | string | — | 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–1) |
| playbackRate | number | 1 | Initial playback speed |
| preload | "none" \| "metadata" \| "auto" | "metadata" | Preload strategy |
| startTime | number | — | Resume position in seconds |
Streaming & Subtitles
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| shakaConfig | Record<string, unknown> | — | Shaka Player configuration overrides |
| subtitles | SubtitleTrack[] | — | Subtitle/caption tracks |
Next Video
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| nextVideo | NextVideo | — | Next video info for end screen |
| onNextVideo | () => void | — | Called when user clicks "next" or countdown finishes |
Idle Tracking
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| idleTimeout | number | — | 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 | — | CSS class on the container |
| style | CSSProperties | — | 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:
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–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:
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
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–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–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):
<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:
<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:
- MP4 — set directly on the
<video>element - HLS on Safari/iOS — native
<video>HLS with quality levels parsed from the master playlist - HLS/DASH elsewhere — 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:
<VideoPlayer
src="https://example.com/stream.m3u8"
shakaConfig={{
streaming: {
bufferingGoal: 120,
rebufferingGoal: 5,
},
abr: {
enabled: true,
defaultBandwidthEstimate: 10_000_000,
},
}}
/>{
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,
},
},
}CSS Theming
The player UI is styled with CSS custom properties. Override them on the container or globally:
.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;
}<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:
import type {
VideoPlayerProps,
VideoPlayerRef,
PlayerState,
PlayerControls,
UseVideoPlayerReturn,
PlayerStatus,
SourceType,
PlaybackMode,
QualityLevel,
PlayerError,
FallbackReason,
VideoPlayerEventProps,
NextVideo,
SubtitleTrack,
} from "@instincthub/shaka-player";Key Types
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
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
<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
<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
# 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