Skip to content

Havik Player SDK — API Reference

@oddin-gg/havik-player · HTML5 player SDK for Oddin live streams (LL‑HLS + DRM).

This is the complete API reference. For a quick start, see the README.


Contents


Overview

The SDK plays Oddin live streams in the browser over Low‑Latency HLS with DRM, and offers three integration modes:

ModeEntry pointYou provideThe SDK handles
A — ManagedcreatePlayer()a <video> elementhls.js, LL‑HLS, DRM/EME, retries, stats
B — Bring‑your‑own‑playerresolveStream()your player + <video>auth → catalog → playback resolution + DRM helpers
C — Iframe embedmountEmbed()an iframe sloteverything, behind a postMessage API

All modes are exported from the package root and are fully typed (TypeScript definitions ship in the package).


Installation

npm / bundlers:

bash
npm install @oddin-gg/havik-player
ts
import { createPlayer, resolveStream, mountEmbed } from '@oddin-gg/havik-player';

Via <script> (CDN): exposes a global window.HavikPlayer.

html
<script src="https://cdn.jsdelivr.net/npm/@oddin-gg/havik-player/dist/havik-player.global.js"></script>
<script>
  const player = await HavikPlayer.createPlayer({ /* … */ });
</script>

In production, pin a version in the CDN URL: …/havik-player@X.Y.Z/dist/….

The package is ESM, side‑effect‑free, and ships ES2022 output plus a self‑contained IIFE bundle for <script>/iframe use.


Concepts

What you need

Two values are required to play any stream:

  • A match URN — identifies the event, e.g. od:match:1234.
  • A publishable api‑key — e.g. pk_live_… (production) or pk_test_… (test). The key must be configured with an allowed origin list that includes the origin your player runs on. See Onboarding & CORS.

You also need the API base URL, e.g. https://streams.oddin.gg.

Credentials

Every API call carries your api‑key. Pass it as a Credential, or as a function returning one — see CredentialSource. The function form is called per request, so it supports transparent refresh if you later move to short‑lived tokens.

ts
// static
const credential = { apiKey: 'pk_live_…' };

// dynamic (called per request)
const credential = async () => ({ apiKey: await myTokenService.get() });

The play sequence

  1. (Optional) Discover live/upcoming matches — fetchCatalog() — or watch a single match's status — watchStatus().
  2. Resolve playbackresolveStream() returns a StreamDescriptor with the manifest URL and DRM config.
  3. Play — feed the descriptor to the managed player (Mode A) or your own player (Mode B).
  4. DRM licenses are acquired automatically by the player's EME layer as the CDM encounters encrypted media.

Match status is available as a push channel (subscribeLiveState, SSE) — used automatically by the managed player — with polling (watchStatus / waitForLive) as the fallback. The authoritative signal that a stream is playable is still a successful playback resolution (not catalog status, which can briefly lead the stream going live).

Live pickup

A match scheduled to go live isn't immediately playable. The waitForLive option arms the player and attaches the instant the stream goes live, with zero manual action. By default it does this over the push channel (SSE, subscribeLiveState): while connected it makes no /v1/playback requests, waits for the pushed live, then resolves once. If the SSE endpoint is unavailable it falls back to polling TOO_EARLY + the server's Retry-After. End-of-stream is detected automatically too — the player stops and fires ended. Set liveStateEvents: false to force polling-only.

See WaitForLive for tuning, and createPlayer / resolveStream for usage.

DRM

  • Key systems: Widevine (com.widevine.alpha) and FairPlay (com.apple.fps). PlayReady is not used (Windows/Edge play via Widevine).
  • Container/encryption: CMAF/fMP4, cbcs.
  • The playback descriptor's drm.*.licenseUrl values are pre‑signed. When you integrate your own player (Mode B), you must POST to the license URL verbatim — its signed query string is the access token; do not rewrite, re‑encode, or strip parameters. Use the same api‑key to resolve playback and to fetch licenses. The managed and iframe players handle this for you.

FairPlay (Safari/iOS): implemented on the hls.js engine. When the playback descriptor carries FairPlay material and the platform is WebKit, the player offers com.apple.fps alongside Widevine: hls.js fetches the application certificate, handles the encrypted event, POSTs the SPC to the signed license URL and applies the returned CKC. No configuration is needed beyond the same api‑key used for playback.

iOS third‑party browsers (Chrome/Firefox/Edge on iOS): iOS has no Widevine CDM, so protected playback in these browsers depends entirely on whether the WebKit build grants them FairPlay. Don't assume either way — call detectDrmSupport and let the probe decide, so viewers see an accurate message instead of a black player.

Browser support

BrowserEngineDRM
Chrome, Edge (desktop & Android)hls.jsWidevine ✓
Firefoxhls.jsWidevine ✓
Safari (macOS, iOS 17.1+)hls.js (via MMS)FairPlay ✓ (com.apple.fps)
Older Safari / iOSnative HLS (fallback)FairPlay — OS passthrough only
Chrome/Firefox/Edge on iOShls.jsprobe with detectDrmSupport (no Widevine on iOS)

Requires a browser with Media Source Extensions (or ManagedMediaSource) and Encrypted Media Extensions (all evergreen browsers).


Mode A — Managed player

The SDK owns the <video> element, bundles hls.js, and wires LL‑HLS + DRM automatically. It also handles live‑pickup retries, buffering recovery, stats, and silent DRM license refresh for long sessions.

createPlayer

ts
function createPlayer(opts: CreatePlayerOptions): Promise<Player>;

Resolves to a Player as soon as the stream is attached (or armed, when waitForLive is set and the match is still upcoming). Terminal errors during the initial resolution (e.g. NOT_FOUND, GONE, UNAUTHORIZED) reject the promise; the live wait itself never blocks the promise and is surfaced through 'waiting' events.

ts
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,
  waitForLive: true,
});

player.on('statechange', (state) => console.log(state));
player.on('waiting', (w) => console.log(`live in ~${Math.round(w.retryInMs / 1000)}s`));
player.on('stats', (s) => console.log(s.droppedFrames, s.latencySeconds));
player.on('error', (e) => console.error(e.code, e.httpStatus, e.message));

CreatePlayerOptions

OptionTypeDefaultDescription
videoHTMLVideoElementRequired. The element the SDK will control.
baseUrlstringRequired. API base URL, e.g. https://streams.oddin.gg.
matchUrnstringRequired. The match to play, e.g. od:match:1234.
credentialCredentialSourceRequired. Your api‑key (or a function returning one).
autoplaybooleantrueStart playback once ready.
mutedbooleanfalseMute the element. Set true for reliable autoplay under browser sound policies.
controls'custom' | 'native' | 'none''custom'Which controls to render. See Controls & theming.
themePartial<HavikTheme>Oddin brandRe-skin the custom control bar (ignored unless controls: 'custom'). See Controls & theming.
statusOverlaysbooleantrueBranded full-bleed cards for the stopped/pre-play states (ended, fatal error, pre-play, armed-waiting) instead of a frozen frame + a dead play button. Themed via the control-bar tokens + theme.logoUrl. Set false to render your own UI from the ended/error events. Ignored unless controls: 'custom'.
endedMessagestring"This stream has ended"Message on the end-of-stream card.
errorMessagestringthe error's messageMessage on the fatal-error card (otherwise the error's own message).
waitForLiveWaitForLiveArm on an upcoming match and auto‑attach at go‑live.
lowLatency'auto' | boolean'auto''auto' lets the player decide from the manifest.
engine'auto' | 'hls' | 'native''auto''auto' prefers hls.js wherever it is supported — including modern Safari/iOS via ManagedMediaSource — and falls back to native HLS otherwise.
userIdstringOptional viewer id (sent as X-User-Id on license requests).
debugbooleanfalseTurn on hls.js's internal console logging — fragment/part scheduling, ABR switches, EME key sessions and license traffic. For diagnosing a playback problem with Oddin support; leave it off in production (it logs on every fragment, and the EME lines include license request/response metadata). hls.js engine only. hlsConfig.debug still wins if you set both.
hlsConfigPartial<HlsConfig>Escape hatch for advanced hls.js tuning.
statsIntervalMsnumber2000How often 'stats' is emitted.
licenseRefreshMsnumber480000 (8 min)Silent DRM license‑URL refresh interval for long sessions. 0 disables.
posterstringPoster image shown before playback starts.
startLevelnumber-1Initial rendition index (-1 = auto). With auto, ABR starts on a conservative, sustainable rendition (~360p) and ramps up as bandwidth allows — see Low‑latency & adaptive bitrate.
maxBitratenumberCap ABR to renditions at/below this bitrate (bps).
liveLatencyTargetnumber2Target live‑edge latency in seconds (hls.js liveSyncDuration). The player derives a matching latency ceiling internally and catches up at 1.5×, so this is the only live‑latency knob you need. Lower = closer to live but more rebuffer‑prone. Live/LL only.
snapToLiveOnRefocusbooleantrueSnap to the live edge when a backgrounded tab is refocused while far behind. Live/LL only.
liveStateEventsbooleantrueUse the push-based live-state SSE channel for go-live and end-of-stream (see Live state). Graceful: falls back to polling if the endpoint is unreachable. Set false to disable.
eventsBaseUrlstringderivedOverride the SSE endpoint base. Default: baseUrl with the host prefixed by events. (e.g. feed.<d>events.<d>).

Low‑latency & adaptive bitrate

The player ships tuned defaults for Oddin's low‑latency HLS, so you normally don't need to set any of the live or ABR knobs:

  • Live latency — playback targets ~2 s behind the live edge (liveLatencyTarget → hls.js liveSyncDuration). It deliberately sits back from the bleeding PART‑HOLD‑BACK edge, where the newest partial segments aren't reliably published yet; chasing that edge causes constant rebuffering. Set liveLatencyTarget lower for closer‑to‑live (more rebuffer‑prone) or higher for steadier playback. Lowered from 3 s — see the migration note below.
  • Drift ceiling — after a stall, the player catches up at up to 1.5× and, as a backstop, force‑seeks back toward the target once latency exceeds an internal ceiling (~12 s, derived as max(12, liveLatencyTarget + 9)). This is what stops latency from snowballing to tens of seconds after buffering. The ceiling is internal and always kept above your liveLatencyTarget.
  • Over‑seek clamp — the drift ceiling's counterpart on the near side. A seek that lands closer to live than liveLatencyTarget is pulled back onto it, so dragging the scrub bar to its far right means "go live" rather than landing on the bleeding edge — where latency reads under a second and playback stutters frame‑by‑frame on an empty forward buffer. hls.js can't recover from that state on its own (its live‑sync only ever corrects being behind), so the stutter would otherwise persist for the rest of the session. Live only — VOD seeks are untouched, and the clamp stands down while chasing is suspended on a stale playlist (there is no live edge left to protect at that point). On the custom control bar the bar's right end is the target, so the thumb reads full at the live edge. Bring‑your‑own‑player (Mode B) owns its own seek UI and needs the equivalent clamp itself.
  • Audio‑hole bridgemaxBufferHole is raised to 0.5 s so the ~0.45 s audio "remainder" gap at each segment boundary is bridged as a sub‑perceptible hitch instead of a rebuffer.
  • Start quality — auto ABR seeds a ~1 Mbps estimate so the first rendition is one a modest link can sustain (~360p) and then ramps up within a few seconds as measured bandwidth allows — rather than grabbing a high rung and rebuffering on a constrained link. This trades a slightly softer first ~2 s for a stall‑free start (the first‑seconds buffering fix). In addition, capLevelToPlayerSize bounds the whole session to the player's pixel box, so a small/embedded player won't fetch 1080p. Pin a fixed rung with startLevel, cap the top with maxBitrate, or raise the seed via hlsConfig.abrEwmaDefaultEstimate if you want a sharper (higher‑risk) start.

All of these can be overridden through the hlsConfig escape hatch (e.g. maxLiveSyncPlaybackRate), but the defaults are tuned for Oddin's streams and rarely need changing. Raw live‑sync overrides are normalized so they can't crash the player: if you pass the count‑based liveSyncDurationCount / liveMaxLatencyDurationCount, the seconds‑based defaults step aside (hls.js rejects mixing the two); if you set liveSyncDuration alone, a matching ceiling is derived above it.

Migration note: the default liveLatencyTarget dropped from 3 s to 2 s. Nothing to change — the drift ceiling stays 12 s and the ABR reaction budgets are derived from the target, so they tighten with it automatically. Expect playback ~1 s closer to live. If you were relying on the old behaviour, pass liveLatencyTarget: 3 explicitly.

Migration note: liveCatchUpRate was removed as a public option — the catch‑up rate (1.5×) is now managed internally alongside the drift ceiling. If you set it before, drop it; reach for hlsConfig.maxLiveSyncPlaybackRate only if you genuinely need to override the rate.

Player

ts
interface Player {
  readonly state: PlayerState;
  readonly descriptor: StreamDescriptor | null;
  play(): Promise<void>;
  pause(): void;
  setMuted(muted: boolean): void;
  setVolume(volume: number): void; // 0..1 (clamped)
  getStats(): PlaybackStats;
  // Track & quality control (empty/no-op on the native engine, which does ABR itself):
  getQualityLevels(): QualityLevel[];
  getCurrentQuality(): number; // -1 = auto
  setQuality(index: number | 'auto'): void;
  setMaxBitrate(bitrate: number | null): void;
  getAudioTracks(): AudioTrackInfo[];
  setAudioTrack(id: number): void;
  getTextTracks(): TextTrackInfo[];
  setTextTrack(id: number): void; // -1 = disable captions
  seekToLive(): void;
  enterPictureInPicture(): Promise<void>;
  exitPictureInPicture(): Promise<void>;
  retry(): Promise<void>; // recover from an error (re-resolve + re-attach)
  reload(): Promise<void>; // re‑resolve playback (refreshes the license URL) + re‑attach
  on<E extends PlayerEvent>(event: E, cb: (payload: PlayerEventMap[E]) => void): () => void;
  destroy(): void;
}

on(...) returns an unsubscribe function. Call destroy() to tear down hls.js, detach and clear listeners, and release the media element; the player is inert afterward (play/pause/etc. become no-ops).

Notes:

  • reload() rejects (and emits error, setting state to error) if the re-resolution fails — await it or attach a .catch.
  • play() resolves even when the browser blocks autoplay; rely on the playing state / error event (not play()'s return value) to determine the outcome.

Player events

on(event, cb) — the payload type depends on the event (PlayerEventMap):

EventPayloadFires when
readyvoidThe manifest is parsed and the player is attached.
waitingWaitStateArmed and waiting for go‑live. On the polling path it fires per poll with the countdown; on the SSE arming path it fires once, just after createPlayer resolves (with ttkMs when the server sent a kickoff time).
playingvoidPlayback is progressing.
bufferingvoidThe player is rebuffering.
pausedvoidPlayback is paused.
endedvoidThe live stream ended. Playback is stopped and the last frame is kept.
errorPlaybackErrorA fatal error occurred (playback stopped).
warningPlaybackErrorA non-fatal condition (e.g. FairPlay passthrough, a failed license refresh). Playback continues.
autoplayblockedvoidThe browser blocked autoplay (NotAllowedError). Show a tap-to-play / tap-to-unmute affordance, then call player.play() from the gesture.
qualitychangenumberActive rendition changed (level index, -1 = auto).
audiotrackchangenumberActive audio track changed (track id).
texttrackchangenumberActive text track changed (track id, -1 = off).
pipchangebooleanPicture-in-Picture entered (true) or exited (false).
statsPlaybackStatsPeriodic stats sample (statsIntervalMs).
statechangePlayerStateAny state transition.

Controls & theming

The managed player ships a built-in, fully skinnable control bar. By default (controls: 'custom') it wraps your <video> in a .havik-player container and renders the Oddin-branded UI: a center play/overlay, a seek bar, play/pause, volume, a live badge + go-live, quality / audio / subtitle menus, Picture-in-Picture, fullscreen, a buffering spinner, and branded status cards for the stopped/pre-play states — ended ("This stream has ended"), fatal error (message + Retry when retryable), pre-play (poster + play CTA), and armed-waiting ("Starting soon…") — so a finished or failed stream never leaves a frozen frame behind a dead play button. The cards pick up theme.logoUrl and the theme colors; customise the copy with endedMessage/errorMessage, or set statusOverlays: false to drive your own end/error UI from the player events. It auto-hides during playback, supports keyboard shortcuts (space/k play-pause, m mute, f fullscreen, c captions, l go-live, arrows seek/volume), and is fully keyboard- and screen-reader-accessible.

ts
type Controls = 'custom' | 'native' | 'none';
ValueBehaviour
'custom'Default. The branded, themeable Havik control bar.
'native'The browser's built-in <video controls> UI. No wrapper element is added.
'none'No controls. Drive the player entirely through its API (build your own).

Re-skin the custom bar in one of two ways — both set the same CSS variables, so they're interchangeable:

1. Pass a theme (merged over the Oddin defaults; omitted keys keep the brand value):

ts
await createPlayer({
  video,
  baseUrl,
  matchUrn,
  credential: { apiKey },
  theme: {
    accent: '#14b8a6', // play button, seek fill, live dot, focus ring
    accentText: '#04201c', // text/icon sitting on the accent
    surface: '#0f172a', // control-bar + menu background
    logoUrl: 'https://cdn.example.com/logo.svg', // optional corner watermark
  },
});

2. Or override the CSS variables in your own stylesheet (handy for theming many players, dark/light switching, or matching an existing design system):

css
.havik-player {
  --havik-accent: #14b8a6;
  --havik-accent-text: #04201c;
  --havik-surface: #0f172a;
  --havik-radius: 4px;
}

HavikTheme

Each theme key maps to a --havik-* CSS variable on the .havik-player element:

HavikTheme keyCSS variableDefault (Oddin)Used for
accent--havik-accent#E1B600 (gold)Play button, seek fill, live dot, focus
accentText--havik-accent-text#1B1C23 (navy)Text/icon on the accent
background--havik-bg#0e0f13Letterbox / behind-video background
surface--havik-surface#1B1C23Control-bar + menu surface
surfaceMuted--havik-surface-muted#2a2c36Hover / active surface
text--havik-text#ffffffPrimary text / icons
textMuted--havik-text-muted#9aa0adSecondary text (timestamps, inactive)
border--havik-borderrgba(255, 255, 255, 0.1)Hairline borders
radius--havik-radius8pxCorner radius for buttons / menus
fontFamily--havik-fontsystem UI stackControl-bar font
logoUrl--havik-logo— (none)Optional corner watermark (a URL)

The default theme (ODDIN_THEME) and the applyTheme(root, theme?) helper are exported from the package root if you need the values directly or want to skin a container yourself:

ts
import { applyTheme, ODDIN_THEME, type HavikTheme } from '@oddin-gg/havik-player';

applyTheme(myElement, { accent: '#14b8a6' }); // writes --havik-* vars

PlayerState

ts
type PlayerState =
  'idle' | 'waiting' | 'loading' | 'playing' | 'buffering' | 'paused' | 'ended' | 'error';

Mode B — Bring your own player

The SDK resolves the stream and gives you DRM helpers; you own the <video> element and the playback engine.

resolveStream

ts
function resolveStream(opts: ResolveOptions): Promise<StreamDescriptor>;

Resolves a match into a StreamDescriptor. With waitForLive, it polls (server‑paced) until the stream is live, then resolves.

ts
const credential = { apiKey: 'pk_live_…' };
const stream = await resolveStream({
  baseUrl: 'https://streams.oddin.gg',
  matchUrn: 'od:match:1234',
  credential,
  waitForLive: true,
});
// stream.manifestUrl, stream.drmEnabled, stream.drm?.widevine?.licenseUrl, …

ResolveOptions

OptionTypeDefaultDescription
baseUrlstringRequired. API base URL.
matchUrnstringRequired. The match to resolve.
credentialCredentialSourceRequired.
waitForLiveWaitForLivePoll until live before resolving.
signalAbortSignalCancel the resolution (and any live wait).

licenseRequestHeaders

ts
function licenseRequestHeaders(
  descriptor: StreamDescriptor,
  credential: Credential,
  opts?: LicenseHeaderOptions,
): Record<string, string>;

Returns the headers your player must attach to the DRM license POST:

x-api-key:    <your api-key>      // same key used to resolve playback
X-Match-Urn:  <descriptor.matchUrn>
X-Device-Id:  <persistent device id>
Content-Type: application/octet-stream
X-User-Id:    <opts.userId>       // only when provided

The license URL itself (descriptor.drm.widevine.licenseUrl) must be POSTed verbatim with the raw EME challenge bytes as the body. LicenseHeaderOptions:

OptionTypeDescription
deviceIdstringOverride the persistent device id (defaults to getDeviceId()).
userIdstringForwarded as X-User-Id.

Example: wiring into hls.js yourself

ts
import Hls from 'hls.js';
import { resolveStream, licenseRequestHeaders } from '@oddin-gg/havik-player';

const credential = { apiKey: 'pk_live_…' };
const stream = await resolveStream({ baseUrl, matchUrn, credential, waitForLive: true });
const headers = licenseRequestHeaders(stream, credential);

const hls = new Hls({
  emeEnabled: stream.drmEnabled && !!stream.drm?.widevine,
  drmSystems: stream.drm?.widevine
    ? { 'com.widevine.alpha': { licenseUrl: stream.drm.widevine.licenseUrl } }
    : undefined,
  drmSystemOptions: {
    videoRobustness: 'SW_SECURE_DECODE',
    audioRobustness: 'SW_SECURE_CRYPTO',
    videoEncryptionScheme: 'cbcs',
    audioEncryptionScheme: 'cbcs',
  },
  // EME license requests route through licenseXhrSetup (NOT xhrSetup):
  licenseXhrSetup: (xhr) => {
    for (const [k, v] of Object.entries(headers)) xhr.setRequestHeader(k, v);
  },
  lowLatencyMode: true,
});
hls.loadSource(stream.manifestUrl);
hls.attachMedia(videoElement);

detectDrmSupport

ts
function detectDrmSupport(opts?: {
  systems?: DrmSystem[]; // default: ['widevine', 'fairplay'], probed in order
  timeoutMs?: number; // OVERALL budget for the whole call, default 5000
}): Promise<DrmSupportResult>;

interface DrmSupportResult {
  verdict: 'supported' | 'no-cdm' | 'insecure-context' | 'blocked-by-policy' | 'probe-timeout';
  system?: DrmSystem; // first working system, when supported
  detail: Partial<Record<DrmSystem, string>>; // per-system outcome, for support bundles
}

Answers "can protected playback work in this browser at all?" so you can show an accurate message instead of a black player. It probes requestMediaKeySystemAccess and createMediaKeys() per system (access alone is not proof), with configs matching what the engine will actually request, bounded by a timeout. It never rejects.

Rules of use:

  • Call it lazily, cache the verdict yourself — only once you know the content is DRM‑protected (descriptor.drmEnabled). The EME spec allows the probe to trigger consent prompts or CDM downloads, and results are not memoized; never run it on page load, and never probe for a clear stream.
  • Probe, don't UA‑sniff. The verdict is the playability gate; use the UA only to word the message.
  • insecure-context means EME is unavailable by spec — typical when testing over http://<LAN‑IP>; localhost is fine.
  • blocked-by-policy means an embedding problem (an iframe missing allow="encrypted-media"), not an unsupported browser — surface it to the integrator, not the viewer.
  • supported is necessary, not sufficient: licensing, CORS onboarding and packaging still apply.

getDeviceId / clearDeviceId

ts
function getDeviceId(): string;
function clearDeviceId(): void;

getDeviceId() returns a persistent per‑device id (a random UUID stored in localStorage, regenerated only if storage is unavailable). It is sent as X-Device-Id on every DRM license request.

Privacy: the device id is a stable, cross‑session identifier (personal data under GDPR/ePrivacy). Disclose it in your privacy policy and offer a reset — clearDeviceId() removes the stored id so the next call generates a fresh one.

fetchCatalog

ts
function fetchCatalog(opts: FetchCatalogOptions): Promise<Catalog>;

Fetches the metadata catalog (tournaments → matches). No manifest or DRM data is included — use resolveStream for playback. Supports a conditional refresh via ETag.

ts
const catalog = await fetchCatalog({ baseUrl, credential, status: ['live', 'upcoming'] });
// poll cheaply later with the previous etag:
const next = await fetchCatalog({ baseUrl, credential, etag: catalog.etag });
if (next.notModified) {
  /* nothing changed */
}

flattenMatches(catalog) returns all matches as a flat CatalogMatch[].

FetchCatalogOptions

OptionTypeDescription
baseUrlstringRequired.
credentialCredentialSourceRequired.
signalAbortSignalCancel the request.
etagstringPrior ETag for a conditional GET (notModified: true on 304).
statusMatchLiveStatus | MatchLiveStatus[]Filter by status.
sportstringFilter by sport.

watchStatus

ts
function watchStatus(opts: WatchStatusOptions): StatusWatcher;

Polls a single match's live status and fires onChange on transitions (and once on first read). Returns a StatusWatcher with stop(). For a push channel (no polling) use subscribeLiveState instead; watchStatus is the zero-dependency polling alternative when the SSE endpoint isn't available.

ts
const watcher = watchStatus({
  baseUrl,
  matchUrn,
  credential,
  onChange: (m) => {
    if (m.status === 'live') startPlayback();
  },
});
// later: watcher.stop();

WatchStatusOptions

OptionTypeDefaultDescription
baseUrlstringRequired.
matchUrnstringRequired.
credentialCredentialSourceRequired.
onChange(status: MatchStatus) => voidRequired. Fired on status transitions.
onError(err: PlaybackError) => voidFired on a poll error; polling continues.
intervalMsnumber10000Poll interval (floored at 2000).

subscribeLiveState

ts
function subscribeLiveState(opts: SubscribeLiveStateOptions): LiveStateSubscription;
function deriveEventsBaseUrl(baseUrl: string): string | undefined;

Push-based live state over Server-Sent Events (events.<domain>/v1/events/{urn}). The managed player uses this automatically (the liveStateEvents option) to go live the instant a match starts — without polling /v1/playback — and to detect end-of-stream instantly; BYO players can use it directly. state mirrors the /v1/playback outcome ladder:

statemeaningmaps to /v1/playback
liveserveable now — play200 OK
upcomingscheduled, not serving yet425 Too Early
endedwas live; media stopped503
goneended past the catchup window410 Gone

Uses fetch (not native EventSource, which can't set the x-api-key header), reconnects with backoff, and replays the current state on connect. Every event is also reported via onAlive (including heartbeats) so callers can treat the stream as healthy. A subscription problem is non-fatal — it retries in the background.

ts
import { subscribeLiveState, deriveEventsBaseUrl } from '@oddin-gg/havik-player';

const sub = subscribeLiveState({
  eventsBaseUrl: deriveEventsBaseUrl(baseUrl)!, // e.g. https://events.<domain>
  matchUrn,
  credential,
  onState: (ev) => {
    if (ev.state === 'live') startPlayback(); // then resolveStream()/createPlayer()
    if (ev.state === 'ended' || ev.state === 'gone') stopPlayback();
  },
});
// later: sub.close();

SubscribeLiveStateOptions

OptionTypeDefaultDescription
eventsBaseUrlstringRequired. SSE base, e.g. from deriveEventsBaseUrl(baseUrl).
matchUrnstringRequired.
credentialCredentialSourceRequired.
onState(ev: LiveStateEvent) => voidRequired. Initial snapshot on connect + every transition.
onError(err: unknown) => voidNon-fatal subscription error; the client keeps retrying.
onAlive() => voidLiveness tick on every received frame (incl. heartbeats).
minBackoffMsnumber1000Reconnect backoff floor.
maxBackoffMsnumber15000Reconnect backoff ceiling.

isLowLatencyManifest

ts
function isLowLatencyManifest(playlistText: string): boolean;

Utility: returns true if an HLS playlist body contains low‑latency tags (EXT-X-PART-INF / EXT-X-PART / EXT-X-PRELOAD-HINT).


Mode C — Iframe embed

Embed a hosted player page and control it over postMessage. This is the lowest‑touch integration and isolates playback in its own browsing context.

mountEmbed

ts
function mountEmbed(opts: MountEmbedOptions): EmbedHandle;

Creates an iframe pointing at the hosted embed page, threads the configuration, and sets up an origin‑verified postMessage bridge.

ts
const embed = mountEmbed({
  container: document.querySelector('#player')!,
  src: 'https://player.oddin.gg/embed/',
  baseUrl: 'https://streams.oddin.gg',
  matchUrn: 'od:match:1234',
  onEvent: (msg) => console.log(msg),
});

embed.play();
embed.setMuted(false);
// embed.destroy();

MountEmbedOptions

OptionTypeDescription
containerHTMLElementRequired. Element to mount the iframe into.
srcstringRequired. URL of the hosted embed page.
baseUrlstringRequired. API base URL (threaded to the iframe).
matchUrnstringRequired. Match to play.
apiKeystringDev only. Appended as a query param. In production the embed page injects the key server‑side — omit this.
allowedFrameOriginstringOrigin of the embed page, used to verify its messages. Defaults to the origin of src (resolved against the current page when src is relative).
sandboxstringiframe sandbox value. Default allow-scripts allow-same-origin allow-presentation (keeps EME + fullscreen, blocks top-frame navigation and popups). The iframe is also mounted with referrerpolicy="no-referrer".
mutedbooleanStart muted.
onEvent(msg: FrameToHost) => voidCalled for every event from the iframe.

EmbedHandle

ts
interface EmbedHandle {
  readonly iframe: HTMLIFrameElement;
  play(): void;
  pause(): void;
  setMuted(muted: boolean): void;
  setVolume(volume: number): void;
  setQuality(index: number): void; // -1 = auto
  setMaxBitrate(bitrate: number | null): void;
  setAudioTrack(id: number): void;
  setTextTrack(id: number): void; // -1 = off
  seekToLive(): void;
  enterPip(): void;
  exitPip(): void;
  retry(): void;
  load(matchUrn: string): void; // switch to a different match
  on(cb: (msg: FrameToHost) => void): () => void; // returns unsubscribe
  destroy(): void;
}

Track lists arrive from the iframe as an oddin:tracks event (see below); use those to build a quality/caption menu, then drive selection with setQuality/setTextTrack/etc.

The embed page

A ready‑to‑host template lives in the repository at embed/index.html (loads the CDN bundle and calls bootEmbed()). To self‑host, serve a page that runs:

ts
import { bootEmbed } from '@oddin-gg/havik-player';
bootEmbed();

bootEmbed() reads its EmbedConfig from window.HAVIK_EMBED_CONFIG (server‑injected, preferred) or the URL query string, runs a managed player full‑bleed, and speaks the postMessage protocol. The api‑key is never accepted over postMessage — inject it server‑side in production.

EmbedConfig

Read by bootEmbed() in one of two mutually-exclusive modes: if window.HAVIK_EMBED_CONFIG is present (production) it is the only source — the query string is ignored entirely, so a tampered ?baseUrl= can't redirect the injected api-key. Otherwise (dev/demo) config comes from the query string. The postMessage bridge fails closed: with no parentOrigin, the embed neither accepts commands nor posts events.

FieldTypeDefaultDescription
baseUrlstringRequired. API base URL.
matchUrnstringRequired. Match to play.
apiKeystringRequired. Inject server‑side in production; query param is dev‑only.
parentOriginstringOrigin to post events to and accept commands from. Set this in production (mountEmbed sets it automatically). When absent, the iframe posts to * and skips the inbound origin check.
autoplaybooleantrueStart playback once ready.
mutedbooleantrueStart muted (recommended for autoplay).
waitForLiveWaitForLivetrueArm and auto‑play at go‑live.

postMessage protocol

Every message is filtered with isOddinMessage (anything not a recognized oddin: message is ignored). The host verifies event.origin against the embed page's origin on every message. The iframe verifies event.origin against its configured parentOriginmountEmbed always sets this, so the bridge is origin‑verified end‑to‑end. If you self‑host the embed page and call bootEmbed() directly, always set parentOrigin (see EmbedConfig); without it the iframe accepts oddin: messages from any origin and posts events with target origin *.

Host → iframe (HostToFrame):

ts
{ type: 'oddin:play' } | { type: 'oddin:pause' }
{ type: 'oddin:setMuted'; muted: boolean } | { type: 'oddin:setVolume'; volume: number }
{ type: 'oddin:setQuality'; index: number } | { type: 'oddin:setMaxBitrate'; bitrate: number | null }
{ type: 'oddin:setAudioTrack'; id: number } | { type: 'oddin:setTextTrack'; id: number }
{ type: 'oddin:seekToLive' } | { type: 'oddin:enterPip' } | { type: 'oddin:exitPip' } | { type: 'oddin:retry' }
{ type: 'oddin:load'; matchUrn: string } | { type: 'oddin:destroy' }

Iframe → host (FrameToHost):

ts
{ type: 'oddin:ready'; protocol: number }            // protocol = PROTOCOL_VERSION
{ type: 'oddin:autoplayblocked' }
{ type: 'oddin:state'; state: PlayerState }
{ type: 'oddin:waiting'; retryInMs: number; attempt: number; phase: string }
{ type: 'oddin:tracks'; quality: QualityLevel[]; audio: AudioTrackInfo[]; text: TextTrackInfo[] }
{ type: 'oddin:qualitychange'; index } | { type: 'oddin:audiotrackchange'; id } | { type: 'oddin:texttrackchange'; id }
{ type: 'oddin:stats'; droppedFrames; latencySeconds?; bandwidthKbps?; levelHeight?; lowLatency?; isLive?; atLiveEdge? }
{ type: 'oddin:error'; code: PlaybackErrorCode; message: string }

The oddin:ready event carries the embed page's protocol version; mountEmbed warns on a mismatch with the host SDK (pin the embed page version to avoid CDN auto-update skew).

If you handle messages directly (rather than via EmbedHandle.on), filter them with isOddinMessage(event.data) and always check event.origin.


Reference

Errors

Every API rejection is a typed PlaybackError:

ts
class PlaybackError extends Error {
  readonly code: PlaybackErrorCode;
  readonly httpStatus: number; // 0 for NETWORK / ABORTED; see the table for TIMEOUT
  readonly retryAfterMs?: number; // set for TOO_EARLY / RATE_LIMITED / UNAVAILABLE
  readonly liveStartsAtMs?: number; // epoch ms; set for TOO_EARLY when the server included `liveStartsAt`
  readonly requestId?: string; // server correlation id, when present
}
codeHTTPMeaningRetryable
INVALID_URN400Malformed match URN.No
NOT_FOUND404Unknown match or not entitled (indistinguishable by design).No
GONE410Ended and past the catch‑up window.No
TOO_EARLY425Upcoming; not live yet.Yes (via waitForLive)
UNAVAILABLE503Should be live; origin warming up.Yes
UNAUTHORIZED401Bad/absent api‑key.No
FORBIDDEN403Origin not allowed, or DRM signature rejected.No
RATE_LIMITED429Per‑client request limit.Back off
INTERNAL5xxUnexpected server/SDK error.No
NETWORK0The network request failed (offline, CORS, DNS).Yes (in the live‑poll loop)
TIMEOUT(last)¹waitForLive exceeded timeoutMs.No
ABORTED0Cancelled via AbortSignal.No

¹ TIMEOUT.httpStatus carries the status of the last error it was waiting on — typically 425 while waiting for an upcoming match, 503 while an origin warms up, or 0 if the timeout lands on a network failure.

ts
import { PlaybackError } from '@oddin-gg/havik-player';
try {
  await resolveStream({ baseUrl, matchUrn, credential });
} catch (e) {
  if (e instanceof PlaybackError && e.code === 'NOT_FOUND') showUnavailable();
}

Data types

StreamDescriptor

The normalized playback credential.

ts
interface StreamDescriptor {
  matchUrn: string;
  protocol: 'hls';
  drmEnabled: boolean; // when false, no EME/CDM is needed
  manifestUrl: string; // unsigned HLS .m3u8
  drm?: DrmInfo; // present only when drmEnabled
  serverTime: string; // RFC3339
  liveStartsAt?: string; // present only while upcoming
  isLowLatency?: boolean; // filled by the player once detected
}

DrmInfo, WidevineInfo, FairPlayInfo, DrmSystem

ts
interface DrmInfo {
  widevine?: WidevineInfo;
  fairplay?: FairPlayInfo;
}
interface WidevineInfo {
  licenseUrl: string;
} // pre-signed; POST verbatim
interface FairPlayInfo {
  licenseUrl: string;
  certificateUrl: string;
}
type DrmSystem = 'widevine' | 'fairplay';

Credential, CredentialSource

ts
interface Credential {
  apiKey: string;
}
type CredentialSource = Credential | (() => Credential | Promise<Credential>);

Catalog, CatalogTournament, CatalogMatch

ts
interface Catalog {
  tournaments: CatalogTournament[];
  etag?: string;
  notModified: boolean; // true on a 304 conditional refresh
}
interface CatalogTournament {
  urn: string;
  name: string;
  sport: string;
  isOffline: boolean;
  matches: CatalogMatch[];
}
interface CatalogMatch {
  matchUrn: string;
  matchName: string;
  status: MatchLiveStatus;
  datePlannedStart?: string;
  tournamentUrn: string;
  tournamentName: string;
  sport: string;
}

MatchStatus, MatchLiveStatus

ts
type MatchLiveStatus = 'upcoming' | 'live' | 'ended';
interface MatchStatus {
  matchUrn: string;
  matchName?: string;
  status: MatchLiveStatus;
  datePlannedStart?: string;
}

PlaybackStats

ts
interface PlaybackStats {
  droppedFrames: number;
  latencySeconds?: number; // live-edge latency (hls.js engine)
  targetLatencySeconds?: number; // live-sync target latency; compare vs latencySeconds for drift
  bandwidthKbps?: number; // estimated bandwidth (hls.js engine)
  levelHeight?: number; // current rendition height
  lowLatency?: boolean; // whether LL-HLS was detected in the manifest
  isLive?: boolean; // current media playlist is live
  atLiveEdge?: boolean; // playback is at/near the live edge
}

QualityLevel, AudioTrackInfo, TextTrackInfo

ts
interface QualityLevel {
  index: number;
  width?: number;
  height?: number;
  bitrate: number;
  codecs?: string;
}
interface AudioTrackInfo {
  id: number;
  name: string;
  lang?: string;
  default: boolean;
}
interface TextTrackInfo {
  id: number;
  name: string;
  lang?: string;
}

Live‑pickup options

WaitForLive

ts
type WaitForLive = boolean | WaitForLiveOptions;

Set to true for sensible defaults, or pass options:

OptionTypeDefaultDescription
timeoutMsnumberunboundedOverall budget before giving up with TIMEOUT.
floorMsnumber1000Minimum delay between polls (also the server minimum).
ceilMsnumber30000Maximum delay between polls inside the imminent window (see imminentMs). Far-from-kickoff polls are widened — see below.
kickoffAtnumber | stringScheduled kickoff (epoch ms or ISO string). When given, the SDK widens long-tail polls automatically — see "Far-future polling".
imminentMsnumber600000Time-to-kickoff threshold for "imminent" (collapses to the tight [floorMs, ceilMs] band).
sanityFloorMsnumber300000Hard cap on any single sleep (so an early go-live / schedule change is caught within bounded latency). 5 min default.
jitterbooleantrueAdd up to +15% jitter (never polls faster than the server asks).
onState(state: WaitState) => voidCalled before each wait.
Far-future polling

A viewer armed hours before kickoff would otherwise hammer /v1/playback every 30s for the entire long tail. When kickoffAt is set (or the server includes liveStartsAt on the 425 body — see Errors liveStartsAtMs), the poll cadence is widened to min(ttk / 10, sanityFloorMs) for the long tail and collapses to the tight [floorMs, ceilMs] range only inside the imminent window. Guards baked in: the server's Retry-After is always a floor (the server can tighten cadence near kickoff regardless), sanityFloorMs caps every single sleep so an early go-live is caught within ≤5 min, and a 200 resolves immediately at any point. Concretely, a match 2 h out now makes ≈30 polls instead of ≈240.

WaitState

ts
interface WaitState {
  phase: 'tooEarly' | 'unavailable' | 'rateLimited' | 'network';
  retryInMs: number; // time until the next poll
  attempt: number; // 1-based retry count
  elapsedMs: number; // wall-clock since the wait started
  ttkMs?: number; // time-to-kickoff used to compute this delay, if known
}

Advanced

These exports are for advanced/custom integrations.

  • pollToLive(attempt, cfg, signal?) — the generic retry primitive behind waitForLive. Polls an arbitrary () => Promise<T>, retrying on retryable PlaybackErrors and honoring retryAfterMs.
  • isOddinMessage(data) — type guard for the postMessage protocol.
  • ODDIN_THEME / applyTheme(root, theme?) — the default control-bar skin and the helper that writes a HavikTheme onto an element as --havik-* CSS variables. See Controls & theming.

The internal engine seam (PlaybackEngine / EngineEvent) and one-shot helpers (resolvePlaybackOnce, resolveCredential) are not part of the public API and are excluded from the type declarations.


Onboarding & CORS

The api‑key is publishable — it is designed to live in browser JavaScript. Its protection is a server‑side allow‑list, so every origin you serve the player from must be onboarded before it will work:

  1. The key's allowed origins must include each origin running the player (and, for iframe embeds, the parent page origins).
  2. The DRM service's CORS allow‑list must include those same origins, or the cross‑origin license request fails its preflight and DRM never starts.
  3. Scope the key's entitlements to the tournaments you serve.

If you serve the player from a new domain, request it be added to the key's allow‑lists first. Key revocation is not instantaneous (validation is cached for a few minutes).

Never use a wildcard (*) origin allow‑list in production — it removes the only guardrail on a publishable key.

Service URL discovery

Don't hard‑code baseUrl in your application. Serve it from your own backend, and keep the backend current from the endpoint directory:

http
GET https://status.oddin-video.gg/v1/endpoints.json

The document lists the current base URL per region and is served with Access-Control-Allow-Origin: *, so any backend or build pipeline can consume it. If an endpoint moves — most likely in regions with distinct internet regulations — the directory updates and your backend follows, with no app release or store re‑review. Endpoint changes and live availability for every Havik service are published at status.oddin-video.gg.


Troubleshooting

SymptomLikely cause
NETWORK error on every call; preflight fails in devtoolsYour origin isn't on the key's allowed‑origins / DRM CORS allow‑list. See Onboarding.
UNAUTHORIZED (401)Wrong/expired api‑key, or wrong baseUrl environment (test vs prod key).
FORBIDDEN (403) on license requestsThe license URL was modified, or a different api‑key was used for the license than for playback. POST the URL verbatim with the same key.
NOT_FOUND (404) for a match you can seeThe key isn't entitled to that tournament (404 is intentionally indistinguishable from "unknown").
Player stays in waiting foreverThe match hasn't gone live yet; the SDK is polling. Set waitForLive.timeoutMs to bound it.
Autoplay doesn't startBrowser sound policy — set muted: true for autoplay, or start playback from a user gesture.
Black video on Safari with DRMCheck the console for an EME/CDM error; verify the origin is on the DRM CORS allow‑list. On very old Safari the native fallback plays only OS‑trusted passthrough.
Black video in Chrome/Firefox/Edge on iOSiOS has no Widevine CDM, and FairPlay availability outside Safari varies by WebKit build. Call detectDrmSupport(): on no-cdm, tell the viewer this browser can't play protected streams.
Black video, levelHeight/droppedFrames stuckCheck the browser console for an EME/CDM error and verify DRM CORS onboarding.

Include PlaybackError.requestId (when present) when contacting support — it correlates to server‑side logs.


Versioning & support

  • The package follows semantic versioning. Pin a version in production (npm and CDN).
  • TypeScript definitions ship with the package; the types are the source of truth for the API surface.
  • The bundled hls.js version is managed by the SDK; do not assume a specific version in managed mode.

For onboarding (api‑keys, origin allow‑lists, entitlements) and support, contact Oddin.

ISC licensed. Bundles hls.js (Apache-2.0).