@oddin-gg/havik-player
v1.0.2
Published
Havik HTML5 player SDK — LL-HLS + DRM playback for Oddin live streams (managed, bring-your-own-player, and iframe-embed modes).
Downloads
85
Readme
@oddin-gg/havik-player
HTML5 player SDK for Oddin live streams — LL-HLS + DRM against the havik-streams API, in three integration modes:
| Mode | Entry | You provide | SDK owns |
| ----------------------------- | ---------------------------- | ---------------------- | -------------------------------------------------- |
| A — Managed | createPlayer() | a <video> element | hls.js, LL-HLS, DRM/EME, retries |
| B — Bring-your-own-player | resolveStream() | the player + <video> | auth → catalog → playback resolution + DRM helpers |
| C — Iframe embed | mountEmbed() / hosted page | an iframe slot | everything, behind a postMessage API |
Widevine (Chrome/Firefox/Edge/Android) is fully supported. Safari/iOS plays via native passthrough; full FairPlay EME is a tracked fast-follow. See Limitations.
📖 Full API reference: docs/API.md · Examples (vanilla / React / Vue): examples/ · API stability & versioning: docs/STABILITY.md
⚠️ Before you integrate — a server-side prerequisite that is out of your code's hands: the api-key's AllowedOrigins (and, for iframes, AllowedIframeParents) and the DRM service's CORS allow-list must both include every origin you serve the player from, or requests fail their preflight and DRM never starts. Onboard your origins with Oddin first; allow a few minutes for propagation. Never use a
["*"]allow-list in production.
Install
npm install @oddin-gg/havik-playerOr via CDN (<script> / iframe), exposing window.HavikPlayer:
<script src="https://cdn.jsdelivr.net/npm/@oddin-gg/havik-player/dist/havik-player.global.js"></script>You need two things to play a stream: a match URN (od:match:…) and a
publishable api-key (pk_live_… / pk_test_…) whose AllowedOrigins
includes the origin your player runs on. See Auth & onboarding.
Mode A — Managed player
The SDK owns the <video>, bundles hls.js, and wires LL-HLS + Widevine DRM.
import { createPlayer } from '@oddin-gg/havik-player';
const player = await createPlayer({
video: document.querySelector('video')!,
baseUrl: 'https://streams.oddin.gg',
matchUrn: 'od:match:1234',
credential: { apiKey: 'pk_live_…' },
autoplay: true,
muted: true, // required for reliable autoplay
waitForLive: true, // arm on an upcoming match, auto-play at go-live
});
player.on('statechange', (s) => console.log('state', s)); // loading|waiting|playing|buffering|paused|ended|error
player.on('waiting', (w) => console.log('go-live in', w.retryInMs, 'ms')); // armed countdown
player.on('stats', (s) => console.log(s)); // dropped frames, latency, bitrate, level
player.on('error', (e) => console.error(e.code, e.httpStatus, e.message));
// later
player.destroy();The managed player handles 425 TOO_EARLY retries (honoring Retry-After),
buffering recovery, and silent DRM license-URL refresh for long sessions.
Controls & skinning
Out of the box, Mode A renders a branded, fully skinnable control bar — seek,
play/pause, volume, a live badge + go-live, quality / audio / subtitle menus,
Picture-in-Picture, fullscreen, buffering + error/retry overlays, auto-hide, and
keyboard a11y. Re-skin it to match your brand with a theme (or by overriding the
--havik-* CSS variables); or opt out with controls: 'native' / 'none'.
await createPlayer({
video,
baseUrl,
matchUrn,
credential: { apiKey },
controls: 'custom', // 'custom' (default, branded bar) | 'native' | 'none'
theme: { accent: '#14b8a6', surface: '#0f172a' }, // merged over the Oddin defaults
});See Controls & theming for the full theme/CSS-variable reference.
Mode B — Bring your own player
The SDK resolves the stream and gives you the DRM header helpers; you own the
<video> and the playback engine.
import { resolveStream, licenseRequestHeaders, getDeviceId } from '@oddin-gg/havik-player';
const cred = { apiKey: 'pk_live_…' };
const stream = await resolveStream({
baseUrl: 'https://streams.oddin.gg',
matchUrn: 'od:match:1234',
credential: cred,
waitForLive: true,
});
// stream = { manifestUrl, drmEnabled, drm: { widevine: { licenseUrl } }, … }
// Wire into YOUR player. For hls.js, attach the license headers in licenseXhrSetup
// (NOT xhrSetup — EME license requests route through licenseXhrSetup):
const headers = licenseRequestHeaders(stream, cred); // { 'x-api-key', 'X-Match-Urn', 'X-Device-Id', 'Content-Type' }DRM rule: POST to
drm.widevine.licenseUrlverbatim — its signed query string is the token. Never rewrite/normalize the URL, and use the same api-key on playback and license requests or the license 403s.
Discovery helpers (there is no push channel — polling only):
import { fetchCatalog, watchStatus } from '@oddin-gg/havik-player';
const catalog = await fetchCatalog({ baseUrl, credential: cred }); // tournaments → matches (metadata only)
const watcher = watchStatus({
baseUrl,
matchUrn,
credential: cred,
onChange: (m) => console.log(m.status),
});
// watcher.stop();Mode C — Iframe embed
Drop in the hosted player page and control it over postMessage:
import { mountEmbed } from '@oddin-gg/havik-player';
const embed = mountEmbed({
container: document.querySelector('#player')!,
src: 'https://player.oddin.gg/embed/', // the hosted embed page
baseUrl: 'https://streams.oddin.gg',
matchUrn: 'od:match:1234',
// apiKey is DEV-ONLY here; in production the hosted page injects it server-side.
onEvent: (msg) => console.log(msg), // { type: 'oddin:ready' | 'oddin:state' | 'oddin:waiting' | 'oddin:stats' | 'oddin:error' }
});
embed.play();
embed.setMuted(false);
// embed.destroy();Both ends verify event.origin on every message. The embed page reads its
config from window.HAVIK_EMBED_CONFIG (server-injected) or the URL query.
A ready-to-host page is shipped at embed/index.html.
Iframe auth: the key's AllowedIframeParents must include the embedding page's origin — iframe navigations send a
Referer/Sec-Fetch-Dest: iframe, which is what havik-streams matches against (noOriginheader on a top-level iframe nav).
Live pickup (waitForLive)
A live match isn't playable until the stream is actually live. With waitForLive,
the player arms and auto-attaches the instant it goes live — no user action.
By default it does this over a push channel (SSE), not polling. While the
live-state stream is connected the player makes zero /v1/playback requests — it
waits for the pushed go-live, then resolves once (so it doesn't repeatedly poll while
waiting for kickoff). If the push channel is unavailable it transparently falls back
to polling 425 TOO_EARLY + Retry-After (scaled ~30s→1s near kickoff, never
faster than the server asks). Set liveStateEvents: false to force polling.
waitForLive: {
timeoutMs: 30 * 60_000, // overall arm budget (default: unbounded)
floorMs: 1000, // min poll interval (default 1000, server minimum)
ceilMs: 30_000, // cap far-from-kickoff backoff
onState: (s) => console.log(s.phase, s.retryInMs, s.attempt),
}End of stream is detected automatically: when the live stream finishes the player
stops and fires ended — it never sits buffering on a stream that's over. See
subscribeLiveState to consume the live-state
channel directly.
Error handling
All API errors are a typed PlaybackError with { code, httpStatus, retryAfterMs?, requestId? }:
| code | HTTP | meaning | retry? |
| ---------------------------- | --------- | --------------------------------------------------- | ----------------- |
| INVALID_URN | 400 | malformed URN | no |
| NOT_FOUND | 404 | unknown URN or not entitled (indistinguishable) | no |
| GONE | 410 | ended past catchup window | no |
| TOO_EARLY | 425 | upcoming, not live yet | yes (waitForLive) |
| UNAVAILABLE | 503 | should-be-live, origin warming up | yes |
| UNAUTHORIZED / FORBIDDEN | 401 / 403 | bad key / origin not allowed / DRM rejected | no |
| RATE_LIMITED | 429 | per-IP limiter | back off |
Auth & onboarding
The x-api-key is a publishable key, by design embeddable in browser JS. Its
only guardrail is the server-side per-key allowlist — so onboarding each
integrator origin is a hard dependency:
- havik-streams: the key's
AllowedOriginsmust include every origin your player runs on (andAllowedIframeParentsfor Mode C). Never ship["*"]to prod — it disables the guardrail. - havik-drm (
drm-proxy): itsCORS_ALLOWED_ORIGINSmust also include those origins, or the cross-origin license POST fails its preflight and DRM never starts (silent black video). - Scope the key's entitlements to the tournaments you serve.
Revocation isn't instant (≤5min validation cache + ≤24h rotation grace).
Forward-compatible credentials: credential also accepts an async function
() => Promise<{ apiKey }>, so you can later swap in a short-lived-token mint
service without changing your integration. (No such service exists today.)
Demo app
A full catalog-browser + live-watch + iframe demo lives in demo/:
cp demo/.env.local.example demo/.env.local # set VITE_HAVIK_BASE_URL + VITE_HAVIK_API_KEY
npm run dev # http://localhost:5173The key's AllowedOrigins must include http://localhost:5173.
Limitations
- FairPlay (Safari/iOS): not implemented in v1. Safari uses native HLS
passthrough, which only plays DRM where the OS already trusts the origins. Full
FairPlay EME (via a Shaka adapter behind the
PlaybackEngineseam) is a tracked fast-follow. - HLS only (no DASH); Widevine + FairPlay key systems (no PlayReady — Edge uses Widevine). Container CMAF/fMP4, encryption cbcs.
- hls.js is pinned to
1.7.0-beta.1(the first version that plays this LL-HLS+DRM content; 1.6.x has a fatal sourceBuffer bug). Pin to1.7.0stable when released.
Privacy
The SDK persists a random per-device id in localStorage and sends it as
X-Device-Id on every DRM license request (required by the license server). It is a
stable cross-session identifier — disclose it in your privacy policy and offer a reset
via clearDeviceId(). No other personal
data is collected by the SDK.
Browser & runtime support
- Browser-only. Importing the package is side-effect-safe (verified in CI), but
the SDK must be used client-side — call
createPlayer/mountEmbedin the browser, not during SSR (e.g. dynamic-import it in a Next.js client component). - Supported targets: see
.browserslistrc(Chrome/Edge ≥94, Firefox ≥91, Safari/iOS ≥15). DRM = Widevine on Chrome/Edge/Firefox/Android; FairPlay (Safari/iOS) is not yet implemented.
Development
npm run build # ESM lib (dist/index.js) + IIFE bundle (dist/havik-player.global.js)
npm run typecheck
npm run lint
npm test # vitest
npm run test:coverage
npm run dev # demo appSecurity
See SECURITY.md for the vulnerability-disclosure policy.
License
ISC — see LICENSE.
Third-party licenses
This package depends on, and the CDN bundle (dist/havik-player.global.js) statically
embeds, hls.js (Apache-2.0,
© Dailymotion). Full attribution is in THIRD_PARTY_NOTICES.txt,
which ships with the package and whose attribution is preserved in the bundle banner.
DRM uses the browser's built-in EME/CDM; Widevine™ (Google), FairPlay®/AirPlay® (Apple) and PlayReady (Microsoft) are trademarks of their respective owners and no CDM is distributed with this package.
