npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

⚠️ 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-player

Or 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.licenseUrl verbatim: 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 no Origin header 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:

  1. havik-streams: the key's AllowedOrigins must include every origin your player runs on (and AllowedIframeParents for Mode C). Never ship ["*"] to prod; it disables the guardrail.
  2. havik-drm (drm-proxy): its CORS_ALLOWED_ORIGINS must also include those origins, or the cross-origin license POST fails its preflight and DRM never starts (silent black video).
  3. 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:5173

The 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 PlaybackEngine seam) 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.json for 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/mountEmbed in 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 app

Security

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.