@oddin-gg/havik-player
v2.9.2
Published
Havik HTML5 player SDK — LL-HLS + DRM playback for Oddin live streams (managed, bring-your-own-player, and iframe-embed modes).
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.
📖 Hosted docs and embed page: player-dev.oddin-video.gg. The site pins and bundles this SDK's released npm package; its source is havik-player-site.
- Full API reference: docs/API.md
- Examples (vanilla / React / Vue): examples/
- API stability and versioning: docs/STABILITY.md
- Service status and endpoint directory: status-dev.oddin-video.gg
⚠️ Before you integrate. One prerequisite sits outside your code. 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. Otherwise requests fail their preflight and DRM never starts.
Onboard your origins with Oddin first. Email [email protected] with the origins you need allowlisted, then 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_…). The key's
AllowedOrigins must include 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')!,
env: 'integration', // or 'production'
matchUrn: 'od:match:1234',
credential: { apiKey: 'pk_test_…' },
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.
Choosing an environment
env names the stack: integration is the shared pre-production environment,
production is the production one. They are separate deployments, not
aliases. Prefer env over a hand-written baseUrl. The API host is then
correct by construction, and so is every host derived from it, including the
QoE beacons endpoint.
Pass baseUrl instead when the named environments do not cover your
deployment:
- your own CDN or gateway in front of the streams API
- the CN plane, whose host is not under this domain
- a pinned build that has to outlive a domain change
baseUrl is not deprecated and is not going away. Pass exactly one of the
two. In TypeScript, passing both or neither is a compile error. In JavaScript
it throws at createPlayer, because the two disagreeing is the
misconfiguration worth refusing.
Modes B and C still take a baseUrl. Use baseUrlForEnv there rather than
copying a hostname into your own config:
import { baseUrlForEnv, resolveStream } from '@oddin-gg/havik-player';
const stream = await resolveStream({
baseUrl: baseUrlForEnv('production'),
matchUrn: 'od:match:1234',
credential: { apiKey: 'pk_live_…' },
});Controls & skinning
By default, Mode A renders a branded, fully skinnable control bar. It covers
seek, play/pause, volume, a live badge and go-live, quality / audio / subtitle
menus, Picture-in-Picture, fullscreen, buffering and 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_test_…' };
const stream = await resolveStream({
baseUrl: 'https://feed-dev.oddin-video.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, so 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-dev.oddin-video.gg/embed/', // the hosted embed page
baseUrl: 'https://feed-dev.oddin-video.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 template lives in the repo at embed/index.html (not part of the npm package).
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; there is noOriginheader on a top-level iframe nav.
The injected config also takes analytics, forwarded to the managed player.
Set it when baseUrl is not a feed host, or QoE beacons stay off. See
Analytics.
window.HAVIK_EMBED_CONFIG = {
baseUrl: 'https://cdn.example.com',
matchUrn: 'od:match:1234',
apiKey: 'pk_test_…',
parentOrigin: 'https://your-site.example',
analytics: { endpoint: 'https://beacons.oddin-video.gg' },
};analytics is read from the injected config only. It names a telemetry sink,
so the query string is not allowed to set one.
That has a consequence worth knowing before you pick a Mode C path.
mountEmbed passes its config to the embed page as query parameters, so it
cannot carry analytics either. If your baseUrl is not a feed host and
you need beacons, host the embed page yourself and inject the config. A
mountEmbed integration on a non-feed base has no way to turn analytics on.
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, with 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 server-side | no |
| DRM_CLIENT | 0 or license status | client-side DRM failure: key system unusable on this platform, CDM/EME error, or license transport exhausted (not an entitlement denial) | sometimes; the control bar offers retry (see docs/API.md) |
| 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.
Where to send onboarding requests: email [email protected] with the origins you need allowlisted, and the iframe parents for Mode C. Include the tournaments you expect to serve. Steps 1 and 2 are applied on our side; you cannot self-serve them.
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 exactly (see
package.jsonfor the current version). LL-HLS+DRM playback is sensitive to hls.js regressions, so bumps are deliberate and tested, never automatic.
Analytics (QoE beacons)
Mode A emits anonymous QoE beacons to the Havik analytics plane: startup time,
heartbeat byte and quality deltas, state changes, errors, and session end. They
are correlated with CDN logs via a server-minted CMCD session id (sid). The
sid lives only in JS memory, never in storage or cookies, and dies with the
player. Opt out or
redirect with the analytics option on createPlayer
(analytics: false | { endpoint?: string; idleMs?: number }); without a
server-minted sid the emitter is inert.
A session closes itself
(END_REASON_IDLE) after idleMs of sitting paused. The default is 10
minutes, and 0 disables it. The same close happens when the page was
suspended (OS sleep, a frozen tab) for that long. The suspension gap never
goes below five minutes, whatever idleMs is. The next play() reloads the
stream as a fresh session.
If your baseUrl is not a feed host, set analytics.endpoint
By default the beacons host is derived from baseUrl by hostname convention:
feed.<domain> becomes beacons.<domain>, and feed-dev.<domain> becomes
beacons-dev.<domain>. The rule is deliberately narrow. It maps only hosts
whose first label is feed or feed-*. Any other baseUrl derives nothing,
and beacons are disabled. This usually happens when your own CDN or gateway
hostname fronts the streams API:
// No beacons: 'cdn.example.com' is not a feed host, so there is
// nothing to derive and no endpoint was given. Playback is unaffected.
createPlayer({
video,
baseUrl: 'https://cdn.example.com',
matchUrn: 'od:match:1234',
credential: { apiKey: 'pk_test_…' },
});
// Beacons flow: name the host the derivation cannot guess.
createPlayer({
video,
baseUrl: 'https://cdn.example.com',
matchUrn: 'od:match:1234',
credential: { apiKey: 'pk_test_…' },
analytics: { endpoint: 'https://beacons.oddin-video.gg' },
});Playback is unaffected, so nothing on screen changes. Only the analytics plane
stays empty. The SDK therefore says so instead of failing quietly. It prints a
console.warn naming the offending host, once per host per page. It also emits
a non-fatal warning event carrying the same
sentence, so you can alert on it without watching a browser console.
Ask Oddin for the beacons host that matches your deployment. A guessed host delivers nothing, just as quietly.
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, so disclose it in your privacy policy and offer a
reset via clearDeviceId(). The QoE
beacons above are anonymous. No IP is stored, the user agent is minimized on
arrival, and the in-memory sid is not a cross-session identifier. 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; in Next.js, dynamic-import it in a 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.
