Managed player
Mode A: the SDK owns your <video> element end to end — hls.js, low-latency tuning, DRM/EME, retries, live pickup, stats, and a branded, skinnable control bar. This is the mode the hosted embed runs internally.
Create a player
import { createPlayer } from '@oddin-gg/havik-player';
const player = await createPlayer({
video: document.querySelector('video')!,
baseUrl: 'https://feed-dev.oddin-video.gg',
matchUrn: 'od:match:1234',
credential: { apiKey: 'pk_test_…' },
autoplay: true,
muted: true, // required for reliable autoplay
waitForLive: true,
});createPlayer resolves once the stream is attached — or armed, when waitForLive is set and the match is upcoming. Terminal errors (NOT_FOUND, GONE, UNAUTHORIZED) reject the promise; the live wait itself never blocks it. The full options table is in the API reference — the defaults are tuned for Oddin's streams, and most integrations set nothing beyond the required four options plus muted.
Events
const off = player.on('statechange', (s) => console.log(s));
player.on('waiting', (w) => console.log(`live in ~${Math.round(w.retryInMs / 1000)}s`));
player.on('stats', (s) => console.log(s.latencySeconds, s.bandwidthKbps));
player.on('error', (e) => console.error(e.code, e.httpStatus, e.message));
player.on('autoplayblocked', () => showTapToPlay());
// off() unsubscribes; player.destroy() tears everything down.States: idle | waiting | loading | playing | buffering | paused | ended | error. The events table lists every event and payload. Two that deserve UI treatment:
autoplayblocked— the browser refused autoplay. Show a tap-to-play affordance and callplayer.play()from the gesture.ended— the live stream finished. The player stops cleanly and (with the default controls) shows a branded "This stream has ended" card instead of a frozen frame.
Live pickup (waitForLive)
A live match isn't playable until the stream actually starts. With waitForLive, the player arms and attaches the instant it goes live — no user action, no reload. By default this rides a push channel (SSE), falling back to polling (425 TOO_EARLY + Retry-After) if the channel is unavailable:
waitForLive: {
timeoutMs: 30 * 60_000, // overall arm budget (default: unbounded)
ceilMs: 30_000, // cap far-from-kickoff backoff
onState: (s) => console.log(s.phase, s.retryInMs),
}End of stream is detected automatically — the player fires ended rather than sitting on a dead manifest.
Low latency, without the knobs
Playback targets ~2 s behind the live edge, with an internal drift ceiling (~12 s) that force-catches-up after stalls, an over-seek clamp so "drag to the right end" means go live rather than land on the bleeding edge, and ABR that starts on a sustainable rendition (~360p seed) and ramps up within seconds. Expect ~4–5 s glass-to-glass on the default configuration.
If you must tune: liveLatencyTarget is the only latency knob you need (everything else derives from it), startLevel/maxBitrate bound ABR, and hlsConfig is the escape hatch. Details: Low-latency & ABR.
Controls
Out of the box you get the branded control bar — seek, play/pause, volume, live badge + go-live, quality/audio/subtitle menus, PiP, fullscreen, buffering and error/retry overlays, keyboard shortcuts, screen-reader support. Options:
controls: 'custom' | 'native' | 'none'; // default 'custom''native' uses the browser's <video controls>; 'none' renders nothing and you drive the player API yourself. Skinning the custom bar is one theme object away — see Controls & theming.
Long sessions
The player silently refreshes the DRM license URL every 8 minutes (licenseRefreshMs) so multi-hour sessions never die on an expired license token, and reload() re-resolves playback in place if you need a hard refresh. retry() recovers from fatal errors (re-resolve + re-attach) — the default error card wires it to a Retry button.
Cleanup
player.destroy();Tears down hls.js, detaches EME, clears listeners, and releases the element. The instance is inert afterwards. Do this on unmount — a leaked player keeps a media pipeline alive.