@oddin-gg/havik-player 1.16.0 → 1.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/API.md ADDED
@@ -0,0 +1,1265 @@
1
+ # Havik Player SDK — API Reference
2
+
3
+ `@oddin-gg/havik-player` · HTML5 player SDK for Oddin live streams (LL‑HLS + DRM).
4
+
5
+ This is the complete API reference. For a quick start, see the [README](../README.md).
6
+
7
+ ---
8
+
9
+ ## Contents
10
+
11
+ - [Overview](#overview)
12
+ - [Installation](#installation)
13
+ - [Concepts](#concepts)
14
+ - [What you need](#what-you-need)
15
+ - [Credentials](#credentials)
16
+ - [The play sequence](#the-play-sequence)
17
+ - [Live pickup](#live-pickup)
18
+ - [DRM](#drm)
19
+ - [Browser support](#browser-support)
20
+ - [Mode A — Managed player](#mode-a--managed-player)
21
+ - [createPlayer](#createplayer)
22
+ - [Player](#player) · [Player events](#playereventmap)
23
+ - [Controls & theming](#controls--theming) · [HavikTheme](#haviktheme)
24
+ - [Mode B — Bring your own player](#mode-b--bring-your-own-player)
25
+ - [Mode C — Iframe embed](#mode-c--iframe-embed)
26
+ - [Reference](#reference)
27
+ - [Errors](#errors)
28
+ - [Data types](#data-types)
29
+ - [Live‑pickup options](#live-pickup-options)
30
+ - [Advanced](#advanced)
31
+ - [Onboarding & CORS](#onboarding--cors)
32
+ - [Troubleshooting](#troubleshooting)
33
+ - [Versioning & support](#versioning--support)
34
+
35
+ ---
36
+
37
+ ## Overview
38
+
39
+ The SDK plays Oddin live streams in the browser over **Low‑Latency HLS** with
40
+ **DRM**, and offers three integration modes:
41
+
42
+ | Mode | Entry point | You provide | The SDK handles |
43
+ | ----------------------------- | ----------------------------------- | ----------------------- | -------------------------------------------------- |
44
+ | **A — Managed** | [`createPlayer()`](#createplayer) | a `<video>` element | hls.js, LL‑HLS, DRM/EME, retries, stats |
45
+ | **B — Bring‑your‑own‑player** | [`resolveStream()`](#resolvestream) | your player + `<video>` | auth → catalog → playback resolution + DRM helpers |
46
+ | **C — Iframe embed** | [`mountEmbed()`](#mountembed) | an iframe slot | everything, behind a `postMessage` API |
47
+
48
+ All modes are exported from the package root and are fully typed (TypeScript
49
+ definitions ship in the package).
50
+
51
+ ---
52
+
53
+ ## Installation
54
+
55
+ **npm / bundlers:**
56
+
57
+ ```bash
58
+ npm install @oddin-gg/havik-player
59
+ ```
60
+
61
+ ```ts
62
+ import { createPlayer, resolveStream, mountEmbed } from '@oddin-gg/havik-player';
63
+ ```
64
+
65
+ **Via `<script>` (CDN):** exposes a global `window.HavikPlayer`.
66
+
67
+ ```html
68
+ <script src="https://cdn.jsdelivr.net/npm/@oddin-gg/havik-player/dist/havik-player.global.js"></script>
69
+ <script>
70
+ const player = await HavikPlayer.createPlayer({ /* … */ });
71
+ </script>
72
+ ```
73
+
74
+ > In production, pin a version in the CDN URL: `…/havik-player@X.Y.Z/dist/…`.
75
+
76
+ The package is ESM, side‑effect‑free, and ships ES2022 output plus a self‑contained
77
+ IIFE bundle for `<script>`/iframe use.
78
+
79
+ ---
80
+
81
+ ## Concepts
82
+
83
+ ### What you need
84
+
85
+ Two values are required to play any stream:
86
+
87
+ - A **match URN** — identifies the event, e.g. `od:match:1234`.
88
+ - A **publishable api‑key** — e.g. `pk_live_…` (production) or `pk_test_…` (test).
89
+ The key must be configured with an **allowed origin list** that includes the
90
+ origin your player runs on. See [Onboarding & CORS](#onboarding--cors).
91
+
92
+ You also need the **API base URL** — `https://feed-dev.oddin-video.gg` for the
93
+ integration environment, `https://feed.oddin-video.gg` for production. Treat the
94
+ endpoint directory as the source of truth rather than hard-coding either — see
95
+ [Service URL discovery](#service-url-discovery).
96
+
97
+ ### Credentials
98
+
99
+ Every API call carries your api‑key. Pass it as a [`Credential`](#credential), or as a
100
+ function returning one — see [`CredentialSource`](#credentialsource). The function form is
101
+ called per request, so it supports transparent refresh if you later move to
102
+ short‑lived tokens.
103
+
104
+ ```ts
105
+ // static
106
+ const credential = { apiKey: 'pk_live_…' };
107
+
108
+ // dynamic (called per request)
109
+ const credential = async () => ({ apiKey: await myTokenService.get() });
110
+ ```
111
+
112
+ ### The play sequence
113
+
114
+ 1. **(Optional) Discover** live/upcoming matches — [`fetchCatalog()`](#fetchcatalog) — or
115
+ watch a single match's status — [`watchStatus()`](#watchstatus).
116
+ 2. **Resolve playback** — [`resolveStream()`](#resolvestream) returns a
117
+ [`StreamDescriptor`](#streamdescriptor) with the manifest URL and DRM config.
118
+ 3. **Play** — feed the descriptor to the managed player (Mode A) or your own player
119
+ (Mode B).
120
+ 4. **DRM licenses** are acquired automatically by the player's EME layer as the CDM
121
+ encounters encrypted media.
122
+
123
+ Match status is available as a **push** channel ([`subscribeLiveState`](#subscribelivestate),
124
+ SSE) — used automatically by the managed player — with polling
125
+ ([`watchStatus`](#watchstatus) / `waitForLive`) as the fallback. The authoritative
126
+ signal that a stream is playable is still a successful playback resolution (not catalog
127
+ status, which can briefly lead the stream going live).
128
+
129
+ ### Live pickup
130
+
131
+ A match scheduled to go live isn't immediately playable. The **`waitForLive`** option
132
+ arms the player and attaches the **instant** the stream goes live, with zero manual
133
+ action. By default it does this over the **push** channel (SSE,
134
+ [`subscribeLiveState`](#subscribelivestate)): while connected it makes no
135
+ `/v1/playback` requests, waits for the pushed `live`, then resolves once. If the SSE
136
+ endpoint is unavailable it **falls back to polling** `TOO_EARLY` + the server's
137
+ `Retry-After`. End-of-stream is detected automatically too — the player stops and
138
+ fires `ended`. Set `liveStateEvents: false` to force polling-only.
139
+
140
+ See [`WaitForLive`](#waitforlive) for tuning, and [`createPlayer`](#createplayer) /
141
+ [`resolveStream`](#resolvestream) for usage.
142
+
143
+ ### DRM
144
+
145
+ - **Key systems:** Widevine (`com.widevine.alpha`) and FairPlay (`com.apple.fps`).
146
+ PlayReady is not used (Windows/Edge play via Widevine).
147
+ - **Container/encryption:** CMAF/fMP4, `cbcs`.
148
+ - The playback descriptor's `drm.*.licenseUrl` values are **pre‑signed**. When you
149
+ integrate your own player (Mode B), you must **POST to the license URL verbatim** —
150
+ its signed query string is the access token; do not rewrite, re‑encode, or strip
151
+ parameters. Use the **same api‑key** to resolve playback and to fetch licenses.
152
+ The managed and iframe players handle this for you.
153
+
154
+ > **FairPlay (Safari/iOS):** implemented on the hls.js engine. When the playback
155
+ > descriptor carries FairPlay material and the platform is WebKit, the player
156
+ > offers `com.apple.fps` alongside Widevine: hls.js fetches the application
157
+ > certificate, handles the encrypted event, POSTs the SPC to the signed license
158
+ > URL and applies the returned CKC. No configuration is needed beyond the same
159
+ > api‑key used for playback.
160
+ >
161
+ > **iOS third‑party browsers (Chrome/Firefox/Edge on iOS):** iOS has no
162
+ > Widevine CDM, so protected playback in these browsers depends entirely on
163
+ > whether the WebKit build grants them FairPlay. Don't assume either way —
164
+ > call [`detectDrmSupport`](#detectdrmsupport) and let the probe decide, so
165
+ > viewers see an accurate message instead of a black player.
166
+
167
+ ### Browser support
168
+
169
+ | Browser | Engine | DRM |
170
+ | -------------------------------- | --------------------- | -------------------------------------------------- |
171
+ | Chrome, Edge (desktop & Android) | hls.js | Widevine ✓ |
172
+ | Firefox | hls.js | Widevine ✓ |
173
+ | Safari (macOS, iOS 17.1+) | hls.js (via MMS) | FairPlay ✓ (`com.apple.fps`) |
174
+ | Older Safari / iOS | native HLS (fallback) | FairPlay — OS passthrough only |
175
+ | Chrome/Firefox/Edge **on iOS** | hls.js | probe with `detectDrmSupport` (no Widevine on iOS) |
176
+
177
+ Requires a browser with Media Source Extensions (or ManagedMediaSource) and
178
+ Encrypted Media Extensions (all evergreen browsers).
179
+
180
+ ---
181
+
182
+ ## Mode A — Managed player
183
+
184
+ The SDK owns the `<video>` element, bundles hls.js, and wires LL‑HLS + DRM
185
+ automatically. It also handles live‑pickup retries, buffering recovery, stats, and
186
+ silent DRM license refresh for long sessions.
187
+
188
+ ### `createPlayer`
189
+
190
+ ```ts
191
+ function createPlayer(opts: CreatePlayerOptions): Promise<Player>;
192
+ ```
193
+
194
+ Resolves to a [`Player`](#player) as soon as the stream is attached (or armed, when
195
+ `waitForLive` is set and the match is still upcoming). Terminal errors during the
196
+ initial resolution (e.g. `NOT_FOUND`, `GONE`, `UNAUTHORIZED`) reject the promise; the
197
+ live wait itself never blocks the promise and is surfaced through `'waiting'` events.
198
+
199
+ ```ts
200
+ const player = await createPlayer({
201
+ video: document.querySelector('video')!,
202
+ baseUrl: 'https://feed-dev.oddin-video.gg',
203
+ matchUrn: 'od:match:1234',
204
+ credential: { apiKey: 'pk_test_…' },
205
+ autoplay: true,
206
+ muted: true,
207
+ waitForLive: true,
208
+ });
209
+
210
+ player.on('statechange', (state) => console.log(state));
211
+ player.on('waiting', (w) => console.log(`live in ~${Math.round(w.retryInMs / 1000)}s`));
212
+ player.on('stats', (s) => console.log(s.droppedFrames, s.latencySeconds));
213
+ player.on('error', (e) => console.error(e.code, e.httpStatus, e.message));
214
+ ```
215
+
216
+ #### `CreatePlayerOptions`
217
+
218
+ | Option | Type | Default | Description |
219
+ | --------------------- | --------------------------------------- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
220
+ | `video` | `HTMLVideoElement` | — | **Required.** The element the SDK will control. |
221
+ | `baseUrl` | `string` | — | **Required.** API base URL, e.g. `https://feed-dev.oddin-video.gg`. |
222
+ | `matchUrn` | `string` | — | **Required.** The match to play, e.g. `od:match:1234`. |
223
+ | `credential` | [`CredentialSource`](#credentialsource) | — | **Required.** Your api‑key (or a function returning one). |
224
+ | `autoplay` | `boolean` | `true` | Start playback once ready. |
225
+ | `muted` | `boolean` | `false` | Mute the element. Set `true` for reliable autoplay under browser sound policies. |
226
+ | `controls` | `'custom' \| 'native' \| 'none'` | `'custom'` | Which controls to render. See [Controls & theming](#controls--theming). |
227
+ | `theme` | [`Partial<HavikTheme>`](#haviktheme) | Oddin brand | Re-skin the custom control bar (ignored unless `controls: 'custom'`). See [Controls & theming](#controls--theming). |
228
+ | `statusOverlays` | `boolean` | `true` | Branded 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'`. |
229
+ | `endedMessage` | `string` | "This stream has ended" | Message on the end-of-stream card. |
230
+ | `errorMessage` | `string` | the error's message | Message on the fatal-error card (otherwise the error's own message). |
231
+ | `waitForLive` | [`WaitForLive`](#waitforlive) | — | Arm on an upcoming match and auto‑attach at go‑live. |
232
+ | `lowLatency` | `'auto' \| boolean` | `'auto'` | `'auto'` lets the player decide from the manifest. |
233
+ | `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. |
234
+ | `userId` | `string` | — | Optional viewer id (sent as `X-User-Id` on license requests). |
235
+ | `debug` | `boolean` | `false` | Turn 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. |
236
+ | `hlsConfig` | `Partial<HlsConfig>` | — | Escape hatch for advanced hls.js tuning. |
237
+ | `statsIntervalMs` | `number` | `2000` | How often `'stats'` is emitted. |
238
+ | `licenseRefreshMs` | `number` | `480000` (8 min) | Silent DRM license‑URL refresh interval for long sessions. `0` disables. |
239
+ | `poster` | `string` | — | Poster image shown before playback starts. |
240
+ | `startLevel` | `number` | `-1` | Initial 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](#low-latency--adaptive-bitrate). |
241
+ | `maxBitrate` | `number` | — | Cap ABR to renditions at/below this bitrate (bps). |
242
+ | `liveLatencyTarget` | `number` | `2` | Target 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. |
243
+ | `snapToLiveOnRefocus` | `boolean` | `true` | Snap to the live edge when a backgrounded tab is refocused while far behind. Live/LL only. |
244
+ | `liveStateEvents` | `boolean` | `true` | Use the push-based **live-state SSE** channel for go-live and end-of-stream (see [Live state](#live-state-sse)). Graceful: falls back to polling if the endpoint is unreachable. Set `false` to disable. |
245
+ | `eventsBaseUrl` | `string` | derived | Override the SSE endpoint base. Default: `baseUrl` with the host prefixed by `events.` (e.g. `feed.<d>` → `events.<d>`). |
246
+
247
+ ### Low‑latency & adaptive bitrate
248
+
249
+ The player ships tuned defaults for Oddin's low‑latency HLS, so you normally
250
+ don't need to set any of the live or ABR knobs:
251
+
252
+ - **Live latency** — playback targets **~2 s** behind the live edge
253
+ (`liveLatencyTarget` → hls.js `liveSyncDuration`). It deliberately sits back
254
+ from the bleeding `PART‑HOLD‑BACK` edge, where the newest partial segments
255
+ aren't reliably published yet; chasing that edge causes constant rebuffering.
256
+ Set `liveLatencyTarget` lower for closer‑to‑live (more rebuffer‑prone) or
257
+ higher for steadier playback. _Lowered from 3 s — see the migration note below._
258
+ - **Drift ceiling** — after a stall, the player catches up at up to **1.5×** and,
259
+ as a backstop, force‑seeks back toward the target once latency exceeds an
260
+ internal **ceiling (~12 s, derived as `max(12, liveLatencyTarget + 9)`)**. This is what
261
+ stops latency from snowballing to tens of seconds after buffering. The ceiling
262
+ is internal and always kept above your `liveLatencyTarget`.
263
+ - **Over‑seek clamp** — the drift ceiling's counterpart on the _near_ side. A seek
264
+ that lands closer to live than `liveLatencyTarget` is pulled back onto it, so
265
+ dragging the scrub bar to its far right means **"go live"** rather than landing
266
+ on the bleeding edge — where latency reads under a second and playback stutters
267
+ frame‑by‑frame on an empty forward buffer. hls.js can't recover from that state
268
+ on its own (its live‑sync only ever corrects being _behind_), so the stutter
269
+ would otherwise persist for the rest of the session. Live only — VOD seeks are
270
+ untouched, and the clamp stands down while chasing is suspended on a stale
271
+ playlist (there is no live edge left to protect at that point). On the custom
272
+ control bar the bar's right end **is** the target, so the thumb reads full at
273
+ the live edge. Bring‑your‑own‑player (Mode B) owns its own seek UI and needs
274
+ the equivalent clamp itself.
275
+ - **Audio‑hole bridge** — `maxBufferHole` is raised to **0.5 s** so the ~0.45 s
276
+ audio "remainder" gap at each segment boundary is bridged as a sub‑perceptible
277
+ hitch instead of a rebuffer.
278
+ - **Start quality** — auto ABR seeds a **~1 Mbps** estimate so the first
279
+ rendition is one a modest link can sustain (**~360p**) and then **ramps up**
280
+ within a few seconds as measured bandwidth allows — rather than grabbing a high
281
+ rung and rebuffering on a constrained link. This trades a slightly softer first
282
+ ~2 s for a **stall‑free start** (the first‑seconds buffering fix). In addition,
283
+ the session is bounded to the player's pixel box, so a small/embedded player
284
+ won't fetch 1080p — but by an SDK‑managed, **debounced** size cap rather than
285
+ hls.js's own `capLevelToPlayerSize` poller: a size change only reaches ABR
286
+ once the box has been stable for **~2 s** (raise), and a shrink is applied
287
+ **lazily (~30 s)**, so a fullscreen enter/exit never forces a quality
288
+ switch — and its ~1 s stall — mid‑transition (the higher rendition simply
289
+ downscales into the smaller box until the down‑cap lands). Passing
290
+ `hlsConfig.capLevelToPlayerSize: true` restores hls.js's immediate poller and
291
+ stands the SDK manager down. Pin a fixed rung with `startLevel`,
292
+ cap the top with `maxBitrate`, or raise the seed via
293
+ `hlsConfig.abrEwmaDefaultEstimate` if you want a sharper (higher‑risk) start.
294
+
295
+ All of these can be overridden through the [`hlsConfig`](#playeroptions) escape
296
+ hatch (e.g. `maxLiveSyncPlaybackRate`), but the defaults are tuned for Oddin's
297
+ streams and rarely need changing. Raw live‑sync overrides are normalized so they
298
+ can't crash the player: if you pass the count‑based `liveSyncDurationCount` /
299
+ `liveMaxLatencyDurationCount`, the seconds‑based defaults step aside (hls.js
300
+ rejects mixing the two); if you set `liveSyncDuration` alone, a matching ceiling
301
+ is derived above it.
302
+
303
+ > **Migration note:** the default `liveLatencyTarget` **dropped from 3 s to 2 s**.
304
+ > Nothing to change — the drift ceiling stays 12 s and the ABR reaction budgets
305
+ > are derived from the target, so they tighten with it automatically. Expect
306
+ > playback ~1 s closer to live. If you were relying on the old behaviour, pass
307
+ > `liveLatencyTarget: 3` explicitly.
308
+
309
+ > **Migration note:** `liveCatchUpRate` was **removed** as a public option — the
310
+ > catch‑up rate (1.5×) is now managed internally alongside the drift ceiling. If
311
+ > you set it before, drop it; reach for `hlsConfig.maxLiveSyncPlaybackRate` only
312
+ > if you genuinely need to override the rate.
313
+
314
+ ### `Player`
315
+
316
+ ```ts
317
+ interface Player {
318
+ readonly state: PlayerState;
319
+ readonly descriptor: StreamDescriptor | null;
320
+ play(): Promise<void>;
321
+ pause(): void;
322
+ setMuted(muted: boolean): void;
323
+ setVolume(volume: number): void; // 0..1 (clamped)
324
+ getStats(): PlaybackStats;
325
+ // Track & quality control (empty/no-op on the native engine, which does ABR itself):
326
+ getQualityLevels(): QualityLevel[];
327
+ getCurrentQuality(): number; // -1 = auto
328
+ setQuality(index: number | 'auto'): void;
329
+ setMaxBitrate(bitrate: number | null): void;
330
+ getAudioTracks(): AudioTrackInfo[];
331
+ setAudioTrack(id: number): void;
332
+ getTextTracks(): TextTrackInfo[];
333
+ setTextTrack(id: number): void; // -1 = disable captions
334
+ seekToLive(): void;
335
+ enterPictureInPicture(): Promise<void>;
336
+ exitPictureInPicture(): Promise<void>;
337
+ enterFullscreen(): Promise<boolean>; // element FS on the player container; iPhone → video-native. false = no mechanism
338
+ exitFullscreen(): Promise<void>; // element or video-native; safe when not fullscreen
339
+ retry(): Promise<void>; // recover from an error (re-resolve + re-attach)
340
+ reload(): Promise<void>; // re‑resolve playback (refreshes the license URL) + re‑attach
341
+ on<E extends PlayerEvent>(event: E, cb: (payload: PlayerEventMap[E]) => void): () => void;
342
+ destroy(): void;
343
+ }
344
+ ```
345
+
346
+ `on(...)` returns an **unsubscribe** function. Call `destroy()` to tear down hls.js,
347
+ detach and clear listeners, and release the media element; the player is inert
348
+ afterward (`play`/`pause`/etc. become no-ops).
349
+
350
+ Notes:
351
+
352
+ - `reload()` **rejects** (and emits `error`, setting state to `error`) if the
353
+ re-resolution fails — `await` it or attach a `.catch`.
354
+ - `play()` resolves even when the browser blocks autoplay; rely on the `playing`
355
+ state / `error` event (not `play()`'s return value) to determine the outcome.
356
+
357
+ <a id="playereventmap"></a>
358
+
359
+ ### Player events
360
+
361
+ `on(event, cb)` — the payload type depends on the event ([`PlayerEventMap`](#playereventmap)):
362
+
363
+ | Event | Payload | Fires when |
364
+ | ------------------ | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
365
+ | `ready` | `void` | The manifest is parsed and the player is attached. |
366
+ | `waiting` | [`WaitState`](#waitstate) | Armed 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). |
367
+ | `playing` | `void` | Playback is progressing. |
368
+ | `buffering` | `void` | The player is rebuffering. |
369
+ | `paused` | `void` | Playback is paused. |
370
+ | `ended` | `void` | The live stream ended. Playback is stopped and the last frame is kept. |
371
+ | `error` | [`PlaybackError`](#playbackerror) | A fatal error occurred (playback stopped). |
372
+ | `warning` | [`PlaybackError`](#playbackerror) | A non-fatal condition (e.g. FairPlay passthrough, a failed license refresh). Playback continues. |
373
+ | `autoplayblocked` | `void` | The browser blocked autoplay (`NotAllowedError`). Show a tap-to-play / tap-to-unmute affordance, then call `player.play()` from the gesture. |
374
+ | `qualitychange` | `number` | Active rendition changed (level index, `-1` = auto). |
375
+ | `audiotrackchange` | `number` | Active audio track changed (track id). |
376
+ | `texttrackchange` | `number` | Active text track changed (track id, `-1` = off). |
377
+ | `pipchange` | `boolean` | Picture-in-Picture entered (`true`) or exited (`false`). |
378
+ | `stats` | [`PlaybackStats`](#playbackstats) | Periodic stats sample (`statsIntervalMs`). |
379
+ | `statechange` | [`PlayerState`](#playerstate) | Any state transition. |
380
+
381
+ <a id="controls--theming"></a>
382
+
383
+ ### Controls & theming
384
+
385
+ The managed player ships a built-in, fully **skinnable** control bar. By default
386
+ (`controls: 'custom'`) it wraps your `<video>` in a `.havik-player` container and
387
+ renders the Oddin-branded UI: a center play/overlay, a seek bar, play/pause,
388
+ volume, a live badge + go-live, quality / audio / subtitle menus,
389
+ Picture-in-Picture, fullscreen, a buffering spinner, and **branded status cards**
390
+ for the stopped/pre-play states — _ended_ ("This stream has ended"), _fatal
391
+ error_ (message + Retry when retryable), _pre-play_ (poster + play CTA), and
392
+ _armed-waiting_ ("Starting soon…") — so a finished or failed stream never leaves
393
+ a frozen frame behind a dead play button. The cards pick up `theme.logoUrl` and
394
+ the theme colors; customise the copy with `endedMessage`/`errorMessage`, or set
395
+ `statusOverlays: false` to drive your own end/error UI from the player events.
396
+ It auto-hides during playback, supports keyboard shortcuts (space/`k` play-pause,
397
+ `m` mute, `f` fullscreen, `c` captions, `l` go-live, arrows seek/volume), and is
398
+ fully keyboard- and screen-reader-accessible.
399
+
400
+ > **Fullscreen on iPhone:** iPhone Safari has no element Fullscreen API, so the
401
+ > fullscreen button there enters the **video-native** fullscreen
402
+ > (`webkitEnterFullscreen`) — the YouTube-style player with **native iOS
403
+ > controls**. The custom control bar, watermark, and overlays are not shown
404
+ > inside it; the viewer exits with the native Done button. Video-native
405
+ > fullscreen only exists once media is loaded, so on iPhone the button takes
406
+ > effect **after playback has started** (a tap on the pre-play poster is a
407
+ > no-op there). Everywhere else (desktop, Android, iPad) the button uses
408
+ > element fullscreen and keeps the branded controls.
409
+
410
+ ```ts
411
+ type Controls = 'custom' | 'native' | 'none';
412
+ ```
413
+
414
+ | Value | Behaviour |
415
+ | ---------- | ----------------------------------------------------------------------------------- |
416
+ | `'custom'` | **Default.** The branded, themeable Havik control bar. |
417
+ | `'native'` | The browser's built-in `<video controls>` UI. No wrapper element is added. |
418
+ | `'none'` | No controls. Drive the player entirely through its [API](#player) (build your own). |
419
+
420
+ Re-skin the custom bar in one of two ways — both set the same CSS variables, so
421
+ they're interchangeable:
422
+
423
+ **1. Pass a `theme`** (merged over the Oddin defaults; omitted keys keep the brand value):
424
+
425
+ ```ts
426
+ await createPlayer({
427
+ video,
428
+ baseUrl,
429
+ matchUrn,
430
+ credential: { apiKey },
431
+ theme: {
432
+ accent: '#14b8a6', // play button, seek fill, live dot, focus ring
433
+ accentText: '#04201c', // text/icon sitting on the accent
434
+ surface: '#0f172a', // control-bar + menu background
435
+ logoUrl: 'https://cdn.example.com/logo.svg', // optional corner watermark
436
+ },
437
+ });
438
+ ```
439
+
440
+ **2. Or override the CSS variables** in your own stylesheet (handy for theming many
441
+ players, dark/light switching, or matching an existing design system):
442
+
443
+ ```css
444
+ .havik-player {
445
+ --havik-accent: #14b8a6;
446
+ --havik-accent-text: #04201c;
447
+ --havik-surface: #0f172a;
448
+ --havik-radius: 4px;
449
+ }
450
+ ```
451
+
452
+ <a id="haviktheme"></a>
453
+
454
+ #### `HavikTheme`
455
+
456
+ Each `theme` key maps to a `--havik-*` CSS variable on the `.havik-player` element:
457
+
458
+ | `HavikTheme` key | CSS variable | Default (Oddin) | Used for |
459
+ | ---------------- | ----------------------- | -------------------------- | --------------------------------------- |
460
+ | `accent` | `--havik-accent` | `#E1B600` (gold) | Play button, seek fill, live dot, focus |
461
+ | `accentText` | `--havik-accent-text` | `#1B1C23` (navy) | Text/icon on the accent |
462
+ | `background` | `--havik-bg` | `#0e0f13` | Letterbox / behind-video background |
463
+ | `surface` | `--havik-surface` | `#1B1C23` | Control-bar + menu surface |
464
+ | `surfaceMuted` | `--havik-surface-muted` | `#2a2c36` | Hover / active surface |
465
+ | `text` | `--havik-text` | `#ffffff` | Primary text / icons |
466
+ | `textMuted` | `--havik-text-muted` | `#9aa0ad` | Secondary text (timestamps, inactive) |
467
+ | `border` | `--havik-border` | `rgba(255, 255, 255, 0.1)` | Hairline borders |
468
+ | `radius` | `--havik-radius` | `8px` | Corner radius for buttons / menus |
469
+ | `fontFamily` | `--havik-font` | system UI stack | Control-bar font |
470
+ | `logoUrl` | `--havik-logo` | — (none) | Optional corner watermark (a URL) |
471
+
472
+ The default theme (`ODDIN_THEME`) and the `applyTheme(root, theme?)` helper are
473
+ exported from the package root if you need the values directly or want to skin a
474
+ container yourself:
475
+
476
+ ```ts
477
+ import { applyTheme, ODDIN_THEME, type HavikTheme } from '@oddin-gg/havik-player';
478
+
479
+ applyTheme(myElement, { accent: '#14b8a6' }); // writes --havik-* vars
480
+ ```
481
+
482
+ ### `PlayerState`
483
+
484
+ ```ts
485
+ type PlayerState =
486
+ 'idle' | 'waiting' | 'loading' | 'playing' | 'buffering' | 'paused' | 'ended' | 'error';
487
+ ```
488
+
489
+ ---
490
+
491
+ ## Mode B — Bring your own player
492
+
493
+ The SDK resolves the stream and gives you DRM helpers; you own the `<video>` element
494
+ and the playback engine.
495
+
496
+ ### `resolveStream`
497
+
498
+ ```ts
499
+ function resolveStream(opts: ResolveOptions): Promise<StreamDescriptor>;
500
+ ```
501
+
502
+ Resolves a match into a [`StreamDescriptor`](#streamdescriptor). With `waitForLive`, it
503
+ polls (server‑paced) until the stream is live, then resolves.
504
+
505
+ ```ts
506
+ const credential = { apiKey: 'pk_test_…' };
507
+ const stream = await resolveStream({
508
+ baseUrl: 'https://feed-dev.oddin-video.gg',
509
+ matchUrn: 'od:match:1234',
510
+ credential,
511
+ waitForLive: true,
512
+ });
513
+ // stream.manifestUrl, stream.drmEnabled, stream.drm?.widevine?.licenseUrl, …
514
+ ```
515
+
516
+ #### `ResolveOptions`
517
+
518
+ | Option | Type | Default | Description |
519
+ | ------------- | --------------------------------------- | ------- | ------------------------------------------ |
520
+ | `baseUrl` | `string` | — | **Required.** API base URL. |
521
+ | `matchUrn` | `string` | — | **Required.** The match to resolve. |
522
+ | `credential` | [`CredentialSource`](#credentialsource) | — | **Required.** |
523
+ | `waitForLive` | [`WaitForLive`](#waitforlive) | — | Poll until live before resolving. |
524
+ | `signal` | `AbortSignal` | — | Cancel the resolution (and any live wait). |
525
+
526
+ ### `licenseRequestHeaders`
527
+
528
+ ```ts
529
+ function licenseRequestHeaders(
530
+ descriptor: StreamDescriptor,
531
+ credential: Credential,
532
+ opts?: LicenseHeaderOptions,
533
+ ): Record<string, string>;
534
+ ```
535
+
536
+ Returns the headers your player must attach to the DRM **license POST**:
537
+
538
+ ```
539
+ x-api-key: <your api-key> // same key used to resolve playback
540
+ X-Match-Urn: <descriptor.matchUrn>
541
+ X-Device-Id: <persistent device id>
542
+ Content-Type: application/octet-stream
543
+ X-User-Id: <opts.userId> // only when provided
544
+ ```
545
+
546
+ The license URL itself (`descriptor.drm.widevine.licenseUrl`) must be POSTed
547
+ **verbatim** with the raw EME challenge bytes as the body. `LicenseHeaderOptions`:
548
+
549
+ | Option | Type | Description |
550
+ | ---------- | -------- | -------------------------------------------------------------------------------- |
551
+ | `deviceId` | `string` | Override the persistent device id (defaults to [`getDeviceId()`](#getdeviceid)). |
552
+ | `userId` | `string` | Forwarded as `X-User-Id`. |
553
+
554
+ #### Example: wiring into hls.js yourself
555
+
556
+ ```ts
557
+ import Hls from 'hls.js';
558
+ import { resolveStream, licenseRequestHeaders } from '@oddin-gg/havik-player';
559
+
560
+ const credential = { apiKey: 'pk_live_…' };
561
+ const stream = await resolveStream({ baseUrl, matchUrn, credential, waitForLive: true });
562
+ const headers = licenseRequestHeaders(stream, credential);
563
+
564
+ const hls = new Hls({
565
+ emeEnabled: stream.drmEnabled && !!stream.drm?.widevine,
566
+ drmSystems: stream.drm?.widevine
567
+ ? { 'com.widevine.alpha': { licenseUrl: stream.drm.widevine.licenseUrl } }
568
+ : undefined,
569
+ drmSystemOptions: {
570
+ videoRobustness: 'SW_SECURE_DECODE',
571
+ audioRobustness: 'SW_SECURE_CRYPTO',
572
+ videoEncryptionScheme: 'cbcs',
573
+ audioEncryptionScheme: 'cbcs',
574
+ },
575
+ // EME license requests route through licenseXhrSetup (NOT xhrSetup):
576
+ licenseXhrSetup: (xhr) => {
577
+ for (const [k, v] of Object.entries(headers)) xhr.setRequestHeader(k, v);
578
+ },
579
+ lowLatencyMode: true,
580
+ });
581
+ hls.loadSource(stream.manifestUrl);
582
+ hls.attachMedia(videoElement);
583
+ ```
584
+
585
+ ### Fullscreen helpers
586
+
587
+ ```ts
588
+ function enterVideoFullscreen(video: HTMLVideoElement, container?: HTMLElement): Promise<boolean>;
589
+ function exitVideoFullscreen(video: HTMLVideoElement, container?: HTMLElement): Promise<void>;
590
+ function videoFullscreenActive(video: HTMLVideoElement, container?: HTMLElement): boolean;
591
+ ```
592
+
593
+ Fullscreen with the platform quirks handled, so your own fullscreen button
594
+ works everywhere the managed control bar's does. `enterVideoFullscreen` walks a
595
+ capability ladder: standard element fullscreen on `container` (pass your player
596
+ wrapper to keep your own controls visible; defaults to the video), the
597
+ `webkit`-prefixed variant (older iPad/macOS Safari), and finally
598
+ `video.webkitEnterFullscreen()` — **iPhone Safari has no element Fullscreen API
599
+ at all**, so the only fullscreen there is the video-native presentation with
600
+ native iOS controls (your DOM is not visible inside it, and it only takes
601
+ effect once media is loaded). Call it from a user gesture. It resolves `false`
602
+ when the platform has no mechanism at all (some webviews) so you can hide your
603
+ button; a denied request (permissions policy) rejects.
604
+
605
+ `exitVideoFullscreen` leaves whichever presentation is active (video-native
606
+ first — it reports only through the video, never the document), is safe to
607
+ call when not fullscreen, and is **scoped to your video's presentation**: an
608
+ unrelated element's fullscreen elsewhere on the page is left alone.
609
+ `videoFullscreenActive` tells you whether your video's presentation is
610
+ currently fullscreen. Pass the same `container` to all three. For fullscreen **state changes**, listen to `fullscreenchange` /
611
+ `webkitfullscreenchange` on the document and `webkitbeginfullscreen` /
612
+ `webkitendfullscreen` on the video — the last two are the only signal for
613
+ iPhone's video-native fullscreen.
614
+
615
+ <a id="detectdrmsupport"></a>
616
+
617
+ ### `detectDrmSupport`
618
+
619
+ ```ts
620
+ function detectDrmSupport(opts?: {
621
+ systems?: DrmSystem[]; // default: ['widevine', 'fairplay'], probed in order
622
+ timeoutMs?: number; // OVERALL budget for the whole call, default 5000
623
+ }): Promise<DrmSupportResult>;
624
+
625
+ interface DrmSupportResult {
626
+ verdict: 'supported' | 'no-cdm' | 'insecure-context' | 'blocked-by-policy' | 'probe-timeout';
627
+ system?: DrmSystem; // first working system, when supported
628
+ detail: Partial<Record<DrmSystem, string>>; // per-system outcome, for support bundles
629
+ }
630
+ ```
631
+
632
+ Answers "can protected playback work in this browser at all?" so you can show
633
+ an accurate message instead of a black player. It probes
634
+ `requestMediaKeySystemAccess` **and** `createMediaKeys()` per system (access
635
+ alone is not proof), with configs matching what the engine will actually
636
+ request, bounded by a timeout. It never rejects.
637
+
638
+ Rules of use:
639
+
640
+ - **Call it lazily, cache the verdict yourself** — only once you know the
641
+ content is DRM‑protected (`descriptor.drmEnabled`). The EME spec allows the
642
+ probe to trigger consent prompts or CDM downloads, and results are not
643
+ memoized; never run it on page load, and never probe for a clear stream.
644
+ - **Probe, don't UA‑sniff.** The verdict is the playability gate; use the UA
645
+ only to word the message.
646
+ - `insecure-context` means EME is unavailable by spec — typical when testing
647
+ over `http://<LAN‑IP>`; `localhost` is fine.
648
+ - `blocked-by-policy` means an embedding problem (an iframe missing
649
+ `allow="encrypted-media"`), not an unsupported browser — surface it to the
650
+ integrator, not the viewer.
651
+ - `supported` is necessary, not sufficient: licensing, CORS onboarding and
652
+ packaging still apply.
653
+
654
+ <a id="getdeviceid"></a>
655
+
656
+ ### `getDeviceId` / `clearDeviceId`
657
+
658
+ ```ts
659
+ function getDeviceId(): string;
660
+ function clearDeviceId(): void;
661
+ ```
662
+
663
+ `getDeviceId()` returns a persistent per‑device id (a random UUID stored in
664
+ `localStorage`, regenerated only if storage is unavailable). It is sent as
665
+ `X-Device-Id` on every DRM license request.
666
+
667
+ > **Privacy:** the device id is a stable, cross‑session identifier (personal data
668
+ > under GDPR/ePrivacy). Disclose it in your privacy policy and offer a reset —
669
+ > `clearDeviceId()` removes the stored id so the next call generates a fresh one.
670
+
671
+ ### `fetchCatalog`
672
+
673
+ ```ts
674
+ function fetchCatalog(opts: FetchCatalogOptions): Promise<Catalog>;
675
+ ```
676
+
677
+ Fetches the metadata catalog (tournaments → matches). **No** manifest or DRM data is
678
+ included — use [`resolveStream`](#resolvestream) for playback. Supports a conditional
679
+ refresh via ETag.
680
+
681
+ ```ts
682
+ const catalog = await fetchCatalog({ baseUrl, credential, status: ['live', 'upcoming'] });
683
+ // poll cheaply later with the previous etag:
684
+ const next = await fetchCatalog({ baseUrl, credential, etag: catalog.etag });
685
+ if (next.notModified) {
686
+ /* nothing changed */
687
+ }
688
+ ```
689
+
690
+ `flattenMatches(catalog)` returns all matches as a flat [`CatalogMatch[]`](#catalogmatch).
691
+
692
+ #### `FetchCatalogOptions`
693
+
694
+ | Option | Type | Description |
695
+ | ------------ | --------------------------------------- | -------------------------------------------------------------- |
696
+ | `baseUrl` | `string` | **Required.** |
697
+ | `credential` | [`CredentialSource`](#credentialsource) | **Required.** |
698
+ | `signal` | `AbortSignal` | Cancel the request. |
699
+ | `etag` | `string` | Prior ETag for a conditional GET (`notModified: true` on 304). |
700
+ | `status` | `MatchLiveStatus \| MatchLiveStatus[]` | Filter by status. |
701
+ | `sport` | `string` | Filter by sport. |
702
+
703
+ ### `watchStatus`
704
+
705
+ ```ts
706
+ function watchStatus(opts: WatchStatusOptions): StatusWatcher;
707
+ ```
708
+
709
+ Polls a single match's live status and fires `onChange` on transitions (and once on
710
+ first read). Returns a `StatusWatcher` with `stop()`. For a **push** channel (no
711
+ polling) use [`subscribeLiveState`](#subscribelivestate) instead; `watchStatus` is the
712
+ zero-dependency polling alternative when the SSE endpoint isn't available.
713
+
714
+ ```ts
715
+ const watcher = watchStatus({
716
+ baseUrl,
717
+ matchUrn,
718
+ credential,
719
+ onChange: (m) => {
720
+ if (m.status === 'live') startPlayback();
721
+ },
722
+ });
723
+ // later: watcher.stop();
724
+ ```
725
+
726
+ #### `WatchStatusOptions`
727
+
728
+ | Option | Type | Default | Description |
729
+ | ------------ | --------------------------------------- | ------- | ------------------------------------------ |
730
+ | `baseUrl` | `string` | — | **Required.** |
731
+ | `matchUrn` | `string` | — | **Required.** |
732
+ | `credential` | [`CredentialSource`](#credentialsource) | — | **Required.** |
733
+ | `onChange` | `(status: MatchStatus) => void` | — | **Required.** Fired on status transitions. |
734
+ | `onError` | `(err: PlaybackError) => void` | — | Fired on a poll error; polling continues. |
735
+ | `intervalMs` | `number` | `10000` | Poll interval (floored at `2000`). |
736
+
737
+ <a id="live-state-sse"></a>
738
+
739
+ ### `subscribeLiveState`
740
+
741
+ ```ts
742
+ function subscribeLiveState(opts: SubscribeLiveStateOptions): LiveStateSubscription;
743
+ function deriveEventsBaseUrl(baseUrl: string): string | undefined;
744
+ ```
745
+
746
+ **Push**-based live state over Server-Sent Events (`events.<domain>/v1/events/{urn}`).
747
+ The managed player uses this automatically (the `liveStateEvents` option) to go live
748
+ the instant a match starts — without polling `/v1/playback` — and to detect
749
+ end-of-stream instantly; BYO players can use it directly. `state` mirrors the
750
+ `/v1/playback` outcome ladder:
751
+
752
+ | `state` | meaning | maps to `/v1/playback` |
753
+ | ---------- | ----------------------------- | ---------------------- |
754
+ | `live` | serveable now — play | `200 OK` |
755
+ | `upcoming` | scheduled, not serving yet | `425 Too Early` |
756
+ | `ended` | was live; media stopped | `503` |
757
+ | `gone` | ended past the catchup window | `410 Gone` |
758
+
759
+ Uses `fetch` (not native `EventSource`, which can't set the `x-api-key` header),
760
+ reconnects with backoff, and replays the current state on connect. Every event is
761
+ also reported via `onAlive` (including heartbeats) so callers can treat the stream as
762
+ healthy. A subscription problem is **non-fatal** — it retries in the background.
763
+
764
+ ```ts
765
+ import { subscribeLiveState, deriveEventsBaseUrl } from '@oddin-gg/havik-player';
766
+
767
+ const sub = subscribeLiveState({
768
+ eventsBaseUrl: deriveEventsBaseUrl(baseUrl)!, // e.g. https://events.<domain>
769
+ matchUrn,
770
+ credential,
771
+ onState: (ev) => {
772
+ if (ev.state === 'live') startPlayback(); // then resolveStream()/createPlayer()
773
+ if (ev.state === 'ended' || ev.state === 'gone') stopPlayback();
774
+ },
775
+ });
776
+ // later: sub.close();
777
+ ```
778
+
779
+ #### `SubscribeLiveStateOptions`
780
+
781
+ | Option | Type | Default | Description |
782
+ | --------------- | --------------------------------------- | ------- | ----------------------------------------------------------------- |
783
+ | `eventsBaseUrl` | `string` | — | **Required.** SSE base, e.g. from `deriveEventsBaseUrl(baseUrl)`. |
784
+ | `matchUrn` | `string` | — | **Required.** |
785
+ | `credential` | [`CredentialSource`](#credentialsource) | — | **Required.** |
786
+ | `onState` | `(ev: LiveStateEvent) => void` | — | **Required.** Initial snapshot on connect + every transition. |
787
+ | `onError` | `(err: unknown) => void` | — | Non-fatal subscription error; the client keeps retrying. |
788
+ | `onAlive` | `() => void` | — | Liveness tick on every received frame (incl. heartbeats). |
789
+ | `minBackoffMs` | `number` | `1000` | Reconnect backoff floor. |
790
+ | `maxBackoffMs` | `number` | `15000` | Reconnect backoff ceiling. |
791
+
792
+ ### `isLowLatencyManifest`
793
+
794
+ ```ts
795
+ function isLowLatencyManifest(playlistText: string): boolean;
796
+ ```
797
+
798
+ Utility: returns `true` if an HLS playlist body contains low‑latency tags
799
+ (`EXT-X-PART-INF` / `EXT-X-PART` / `EXT-X-PRELOAD-HINT`).
800
+
801
+ ---
802
+
803
+ ## Mode C — Iframe embed
804
+
805
+ Embed a hosted player page and control it over `postMessage`. This is the lowest‑touch
806
+ integration and isolates playback in its own browsing context.
807
+
808
+ ### `mountEmbed`
809
+
810
+ ```ts
811
+ function mountEmbed(opts: MountEmbedOptions): EmbedHandle;
812
+ ```
813
+
814
+ Creates an iframe pointing at the hosted embed page, threads the configuration, and
815
+ sets up an origin‑verified `postMessage` bridge.
816
+
817
+ ```ts
818
+ const embed = mountEmbed({
819
+ container: document.querySelector('#player')!,
820
+ src: 'https://player-dev.oddin-video.gg/embed/',
821
+ baseUrl: 'https://feed-dev.oddin-video.gg',
822
+ matchUrn: 'od:match:1234',
823
+ onEvent: (msg) => console.log(msg),
824
+ });
825
+
826
+ embed.play();
827
+ embed.setMuted(false);
828
+ // embed.destroy();
829
+ ```
830
+
831
+ #### `MountEmbedOptions`
832
+
833
+ | Option | Type | Description |
834
+ | -------------------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
835
+ | `container` | `HTMLElement` | **Required.** Element to mount the iframe into. |
836
+ | `src` | `string` | **Required.** URL of the hosted embed page. |
837
+ | `baseUrl` | `string` | **Required.** API base URL (threaded to the iframe). |
838
+ | `matchUrn` | `string` | **Required.** Match to play. |
839
+ | `apiKey` | `string` | **Dev only.** Appended as a query param. In production the embed page injects the key server‑side — omit this. |
840
+ | `allowedFrameOrigin` | `string` | Origin of the embed page, used to verify its messages. Defaults to the origin of `src` (resolved against the current page when `src` is relative). |
841
+ | `sandbox` | `string` | iframe `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"`. |
842
+ | `muted` | `boolean` | Start muted. |
843
+ | `onEvent` | `(msg: FrameToHost) => void` | Called for every event from the iframe. |
844
+
845
+ #### `EmbedHandle`
846
+
847
+ ```ts
848
+ interface EmbedHandle {
849
+ readonly iframe: HTMLIFrameElement;
850
+ play(): void;
851
+ pause(): void;
852
+ setMuted(muted: boolean): void;
853
+ setVolume(volume: number): void;
854
+ setQuality(index: number): void; // -1 = auto
855
+ setMaxBitrate(bitrate: number | null): void;
856
+ setAudioTrack(id: number): void;
857
+ setTextTrack(id: number): void; // -1 = off
858
+ seekToLive(): void;
859
+ enterPip(): void;
860
+ exitPip(): void;
861
+ retry(): void;
862
+ load(matchUrn: string): void; // switch to a different match
863
+ on(cb: (msg: FrameToHost) => void): () => void; // returns unsubscribe
864
+ destroy(): void;
865
+ }
866
+ ```
867
+
868
+ Track lists arrive from the iframe as an `oddin:tracks` event (see below); use those to
869
+ build a quality/caption menu, then drive selection with `setQuality`/`setTextTrack`/etc.
870
+
871
+ ### The embed page
872
+
873
+ A ready‑to‑host template lives in the repository at
874
+ [`embed/index.html`](https://github.com/oddin-gg/havik-html5/blob/main/embed/index.html) (loads the CDN bundle and calls
875
+ `bootEmbed()`). To self‑host, serve a page that runs:
876
+
877
+ ```ts
878
+ import { bootEmbed } from '@oddin-gg/havik-player';
879
+ bootEmbed();
880
+ ```
881
+
882
+ `bootEmbed()` reads its [`EmbedConfig`](#embedconfig) from `window.HAVIK_EMBED_CONFIG`
883
+ (server‑injected, preferred) or the URL query string, runs a managed player full‑bleed,
884
+ and speaks the postMessage protocol. **The api‑key is never accepted over postMessage** —
885
+ inject it server‑side in production.
886
+
887
+ #### `EmbedConfig`
888
+
889
+ Read by `bootEmbed()` in one of two **mutually-exclusive** modes: if
890
+ `window.HAVIK_EMBED_CONFIG` is present (production) it is the **only** source — the
891
+ query string is ignored entirely, so a tampered `?baseUrl=` can't redirect the injected
892
+ api-key. Otherwise (dev/demo) config comes from the query string. The postMessage bridge
893
+ **fails closed**: with no `parentOrigin`, the embed neither accepts commands nor posts
894
+ events.
895
+
896
+ | Field | Type | Default | Description |
897
+ | -------------- | ----------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
898
+ | `baseUrl` | `string` | — | **Required.** API base URL. |
899
+ | `matchUrn` | `string` | — | **Required.** Match to play. |
900
+ | `apiKey` | `string` | — | **Required.** Inject server‑side in production; query param is dev‑only. |
901
+ | `parentOrigin` | `string` | — | Origin 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. |
902
+ | `autoplay` | `boolean` | `true` | Start playback once ready. |
903
+ | `muted` | `boolean` | `true` | Start muted (recommended for autoplay). |
904
+ | `waitForLive` | [`WaitForLive`](#waitforlive) | `true` | Arm and auto‑play at go‑live. |
905
+
906
+ <a id="frametohost"></a>
907
+ <a id="hosttoframe"></a>
908
+ <a id="isoddinmessage"></a>
909
+
910
+ ### postMessage protocol
911
+
912
+ Every message is filtered with [`isOddinMessage`](#isoddinmessage) (anything not a
913
+ recognized `oddin:` message is ignored). The **host** verifies `event.origin` against the
914
+ embed page's origin on every message. The **iframe** verifies `event.origin` against its
915
+ configured `parentOrigin` — `mountEmbed` always sets this, so the bridge is origin‑verified
916
+ end‑to‑end. If you self‑host the embed page and call `bootEmbed()` directly, **always set
917
+ `parentOrigin`** (see [`EmbedConfig`](#embedconfig)); without it the iframe accepts `oddin:`
918
+ messages from any origin and posts events with target origin `*`.
919
+
920
+ **Host → iframe** ([`HostToFrame`](#hosttoframe)):
921
+
922
+ ```ts
923
+ { type: 'oddin:play' } | { type: 'oddin:pause' }
924
+ { type: 'oddin:setMuted'; muted: boolean } | { type: 'oddin:setVolume'; volume: number }
925
+ { type: 'oddin:setQuality'; index: number } | { type: 'oddin:setMaxBitrate'; bitrate: number | null }
926
+ { type: 'oddin:setAudioTrack'; id: number } | { type: 'oddin:setTextTrack'; id: number }
927
+ { type: 'oddin:seekToLive' } | { type: 'oddin:enterPip' } | { type: 'oddin:exitPip' } | { type: 'oddin:retry' }
928
+ { type: 'oddin:load'; matchUrn: string } | { type: 'oddin:destroy' }
929
+ ```
930
+
931
+ **Iframe → host** ([`FrameToHost`](#frametohost)):
932
+
933
+ ```ts
934
+ { type: 'oddin:ready'; protocol: number } // protocol = PROTOCOL_VERSION
935
+ { type: 'oddin:autoplayblocked' }
936
+ { type: 'oddin:state'; state: PlayerState }
937
+ { type: 'oddin:waiting'; retryInMs: number; attempt: number; phase: string }
938
+ { type: 'oddin:tracks'; quality: QualityLevel[]; audio: AudioTrackInfo[]; text: TextTrackInfo[] }
939
+ { type: 'oddin:qualitychange'; index } | { type: 'oddin:audiotrackchange'; id } | { type: 'oddin:texttrackchange'; id }
940
+ { type: 'oddin:stats'; droppedFrames; latencySeconds?; bandwidthKbps?; levelHeight?; lowLatency?; isLive?; atLiveEdge? }
941
+ { type: 'oddin:error'; code: PlaybackErrorCode; message: string }
942
+ ```
943
+
944
+ The `oddin:ready` event carries the embed page's `protocol` version; `mountEmbed` warns on a
945
+ mismatch with the host SDK (pin the embed page version to avoid CDN auto-update skew).
946
+
947
+ If you handle messages directly (rather than via `EmbedHandle.on`), filter them with
948
+ `isOddinMessage(event.data)` and always check `event.origin`.
949
+
950
+ ---
951
+
952
+ ## Reference
953
+
954
+ <a id="playbackerror"></a>
955
+
956
+ ### Errors
957
+
958
+ Every API rejection is a typed **`PlaybackError`**:
959
+
960
+ ```ts
961
+ class PlaybackError extends Error {
962
+ readonly code: PlaybackErrorCode;
963
+ readonly httpStatus: number; // 0 for NETWORK / ABORTED; see the table notes for TIMEOUT / DRM_CLIENT
964
+ readonly retryAfterMs?: number; // set for TOO_EARLY / RATE_LIMITED / UNAVAILABLE
965
+ readonly liveStartsAtMs?: number; // epoch ms; set for TOO_EARLY when the server included `liveStartsAt`
966
+ readonly requestId?: string; // server correlation id, when present
967
+ }
968
+ ```
969
+
970
+ | `code` | HTTP | Meaning | Retryable |
971
+ | -------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------ | --------------------------- |
972
+ | `INVALID_URN` | 400 | Malformed match URN. | No |
973
+ | `NOT_FOUND` | 404 | Unknown match **or** not entitled (indistinguishable by design). | No |
974
+ | `GONE` | 410 | Ended and past the catch‑up window. | No |
975
+ | `TOO_EARLY` | 425 | Upcoming; not live yet. | Yes (via `waitForLive`) |
976
+ | `UNAVAILABLE` | 503 | Should be live; origin warming up. | Yes |
977
+ | `UNAUTHORIZED` | 401 | Bad/absent api‑key. | No |
978
+ | `FORBIDDEN` | 403 | Origin not allowed, or DRM signature rejected (a real server 403). | No |
979
+ | `DRM_CLIENT` | (lic.)² | Client‑side DRM failure: no key system usable on this platform, a CDM/EME error, or license delivery failed without a server denial. | Sometimes² |
980
+ | `RATE_LIMITED` | 429 | Per‑client request limit. | Back off |
981
+ | `INTERNAL` | 5xx | Unexpected server/SDK error. | No |
982
+ | `NETWORK` | 0 | The network request failed (offline, CORS, DNS). | Yes (in the live‑poll loop) |
983
+ | `TIMEOUT` | (last)¹ | `waitForLive` exceeded `timeoutMs`. | No |
984
+ | `ABORTED` | 0 | Cancelled via `AbortSignal`. | No |
985
+
986
+ ¹ `TIMEOUT.httpStatus` carries the status of the last error it was waiting on — typically
987
+ `425` while waiting for an upcoming match, `503` while an origin warms up, or `0` if the
988
+ timeout lands on a network failure.
989
+
990
+ ² `DRM_CLIENT.httpStatus` carries the license/certificate HTTP status when one was
991
+ involved (e.g. a `5xx` after the SDK's license-retry budget is exhausted), else `0`
992
+ (no usable key system, CDM/EME error, request never got a response). A genuine license
993
+ `401`/`403` surfaces as `UNAUTHORIZED`/`FORBIDDEN`, never as `DRM_CLIENT` — branch on
994
+ the `code`, not on `httpStatus`, to tell a server rejection from a device that can't
995
+ play DRM. The built-in error card still offers Retry (it re-resolves and re-attaches,
996
+ which can recover a transient license failure); an unsupported device will simply fail
997
+ the same way again.
998
+
999
+ ```ts
1000
+ import { PlaybackError } from '@oddin-gg/havik-player';
1001
+ try {
1002
+ await resolveStream({ baseUrl, matchUrn, credential });
1003
+ } catch (e) {
1004
+ if (e instanceof PlaybackError && e.code === 'NOT_FOUND') showUnavailable();
1005
+ }
1006
+ ```
1007
+
1008
+ ### Data types
1009
+
1010
+ #### `StreamDescriptor`
1011
+
1012
+ The normalized playback credential.
1013
+
1014
+ ```ts
1015
+ interface StreamDescriptor {
1016
+ matchUrn: string;
1017
+ protocol: 'hls';
1018
+ drmEnabled: boolean; // when false, no EME/CDM is needed
1019
+ manifestUrl: string; // unsigned HLS .m3u8
1020
+ drm?: DrmInfo; // present only when drmEnabled
1021
+ serverTime: string; // RFC3339
1022
+ liveStartsAt?: string; // present only while upcoming
1023
+ isLowLatency?: boolean; // filled by the player once detected
1024
+ }
1025
+ ```
1026
+
1027
+ #### `DrmInfo`, `WidevineInfo`, `FairPlayInfo`, `DrmSystem`
1028
+
1029
+ ```ts
1030
+ interface DrmInfo {
1031
+ widevine?: WidevineInfo;
1032
+ fairplay?: FairPlayInfo;
1033
+ }
1034
+ interface WidevineInfo {
1035
+ licenseUrl: string;
1036
+ } // pre-signed; POST verbatim
1037
+ interface FairPlayInfo {
1038
+ licenseUrl: string;
1039
+ certificateUrl: string;
1040
+ }
1041
+ type DrmSystem = 'widevine' | 'fairplay';
1042
+ ```
1043
+
1044
+ <a id="credential"></a>
1045
+ <a id="credentialsource"></a>
1046
+
1047
+ #### `Credential`, `CredentialSource`
1048
+
1049
+ ```ts
1050
+ interface Credential {
1051
+ apiKey: string;
1052
+ }
1053
+ type CredentialSource = Credential | (() => Credential | Promise<Credential>);
1054
+ ```
1055
+
1056
+ <a id="catalogmatch"></a>
1057
+
1058
+ #### `Catalog`, `CatalogTournament`, `CatalogMatch`
1059
+
1060
+ ```ts
1061
+ interface Catalog {
1062
+ tournaments: CatalogTournament[];
1063
+ etag?: string;
1064
+ notModified: boolean; // true on a 304 conditional refresh
1065
+ }
1066
+ interface CatalogTournament {
1067
+ urn: string;
1068
+ name: string;
1069
+ sport: string;
1070
+ isOffline: boolean;
1071
+ matches: CatalogMatch[];
1072
+ }
1073
+ interface CatalogMatch {
1074
+ matchUrn: string;
1075
+ matchName: string;
1076
+ status: MatchLiveStatus;
1077
+ datePlannedStart?: string;
1078
+ tournamentUrn: string;
1079
+ tournamentName: string;
1080
+ sport: string;
1081
+ }
1082
+ ```
1083
+
1084
+ #### `MatchStatus`, `MatchLiveStatus`
1085
+
1086
+ ```ts
1087
+ type MatchLiveStatus = 'upcoming' | 'live' | 'ended';
1088
+ interface MatchStatus {
1089
+ matchUrn: string;
1090
+ matchName?: string;
1091
+ status: MatchLiveStatus;
1092
+ datePlannedStart?: string;
1093
+ }
1094
+ ```
1095
+
1096
+ #### `PlaybackStats`
1097
+
1098
+ ```ts
1099
+ interface PlaybackStats {
1100
+ droppedFrames: number;
1101
+ latencySeconds?: number; // live-edge latency (hls.js engine)
1102
+ targetLatencySeconds?: number; // live-sync target latency; compare vs latencySeconds for drift
1103
+ bandwidthKbps?: number; // estimated bandwidth (hls.js engine)
1104
+ levelHeight?: number; // current rendition height
1105
+ lowLatency?: boolean; // whether LL-HLS was detected in the manifest
1106
+ isLive?: boolean; // current media playlist is live
1107
+ atLiveEdge?: boolean; // playback is at/near the live edge
1108
+ }
1109
+ ```
1110
+
1111
+ #### `QualityLevel`, `AudioTrackInfo`, `TextTrackInfo`
1112
+
1113
+ ```ts
1114
+ interface QualityLevel {
1115
+ index: number;
1116
+ width?: number;
1117
+ height?: number;
1118
+ bitrate: number;
1119
+ codecs?: string;
1120
+ }
1121
+ interface AudioTrackInfo {
1122
+ id: number;
1123
+ name: string;
1124
+ lang?: string;
1125
+ default: boolean;
1126
+ }
1127
+ interface TextTrackInfo {
1128
+ id: number;
1129
+ name: string;
1130
+ lang?: string;
1131
+ }
1132
+ ```
1133
+
1134
+ <a id="live-pickup-options"></a>
1135
+
1136
+ ### Live‑pickup options
1137
+
1138
+ #### `WaitForLive`
1139
+
1140
+ ```ts
1141
+ type WaitForLive = boolean | WaitForLiveOptions;
1142
+ ```
1143
+
1144
+ Set to `true` for sensible defaults, or pass options:
1145
+
1146
+ | Option | Type | Default | Description |
1147
+ | --------------- | ---------------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------- |
1148
+ | `timeoutMs` | `number` | unbounded | Overall budget before giving up with `TIMEOUT`. |
1149
+ | `floorMs` | `number` | `1000` | Minimum delay between polls (also the server minimum). |
1150
+ | `ceilMs` | `number` | `30000` | Maximum delay between polls **inside the imminent window** (see `imminentMs`). Far-from-kickoff polls are widened — see below. |
1151
+ | `kickoffAt` | `number \| string` | — | Scheduled kickoff (epoch ms or ISO string). When given, the SDK widens long-tail polls automatically — see "Far-future polling". |
1152
+ | `imminentMs` | `number` | `600000` | Time-to-kickoff threshold for "imminent" (collapses to the tight `[floorMs, ceilMs]` band). |
1153
+ | `sanityFloorMs` | `number` | `300000` | Hard cap on any single sleep (so an early go-live / schedule change is caught within bounded latency). 5 min default. |
1154
+ | `jitter` | `boolean` | `true` | Add up to +15% jitter (never polls faster than the server asks). |
1155
+ | `onState` | `(state: WaitState) => void` | — | Called before each wait. |
1156
+
1157
+ ##### Far-future polling
1158
+
1159
+ A viewer armed hours before kickoff would otherwise hammer `/v1/playback` every
1160
+ 30s for the entire long tail. When **`kickoffAt`** is set (or the server includes
1161
+ `liveStartsAt` on the 425 body — see [Errors](#errors) `liveStartsAtMs`), the
1162
+ poll cadence is widened to `min(ttk / 10, sanityFloorMs)` for the long tail and
1163
+ collapses to the tight `[floorMs, ceilMs]` range only inside the imminent
1164
+ window. Guards baked in: the server's `Retry-After` is always a **floor** (the
1165
+ server can tighten cadence near kickoff regardless), `sanityFloorMs` caps every
1166
+ single sleep so an early go-live is caught within ≤5 min, and a `200` resolves
1167
+ **immediately** at any point. Concretely, a match 2 h out now makes ≈30 polls
1168
+ instead of ≈240.
1169
+
1170
+ #### `WaitState`
1171
+
1172
+ ```ts
1173
+ interface WaitState {
1174
+ phase: 'tooEarly' | 'unavailable' | 'rateLimited' | 'network';
1175
+ retryInMs: number; // time until the next poll
1176
+ attempt: number; // 1-based retry count
1177
+ elapsedMs: number; // wall-clock since the wait started
1178
+ ttkMs?: number; // time-to-kickoff used to compute this delay, if known
1179
+ }
1180
+ ```
1181
+
1182
+ ### Advanced
1183
+
1184
+ These exports are for advanced/custom integrations.
1185
+
1186
+ - **`pollToLive(attempt, cfg, signal?)`** — the generic retry primitive behind
1187
+ `waitForLive`. Polls an arbitrary `() => Promise<T>`, retrying on retryable
1188
+ `PlaybackError`s and honoring `retryAfterMs`.
1189
+ - **`isOddinMessage(data)`** — type guard for the postMessage protocol.
1190
+ - **`ODDIN_THEME`** / **`applyTheme(root, theme?)`** — the default control-bar skin
1191
+ and the helper that writes a [`HavikTheme`](#haviktheme) onto an element as
1192
+ `--havik-*` CSS variables. See [Controls & theming](#controls--theming).
1193
+
1194
+ > The internal engine seam (`PlaybackEngine` / `EngineEvent`) and one-shot helpers
1195
+ > (`resolvePlaybackOnce`, `resolveCredential`) are **not** part of the public API and
1196
+ > are excluded from the type declarations.
1197
+
1198
+ ---
1199
+
1200
+ ## Onboarding & CORS
1201
+
1202
+ The api‑key is **publishable** — it is designed to live in browser JavaScript. Its
1203
+ protection is a **server‑side allow‑list**, so every origin you serve the player from
1204
+ must be onboarded before it will work:
1205
+
1206
+ 1. The key's **allowed origins** must include each origin running the player (and, for
1207
+ iframe embeds, the **parent page origins**).
1208
+ 2. The DRM service's CORS allow‑list must include those same origins, or the
1209
+ cross‑origin license request fails its preflight and DRM never starts.
1210
+ 3. Scope the key's entitlements to the tournaments you serve.
1211
+
1212
+ If you serve the player from a new domain, request it be added to the key's allow‑lists
1213
+ first. Key revocation is not instantaneous (validation is cached for a few minutes).
1214
+
1215
+ > **Never use a wildcard (`*`) origin allow‑list in production** — it removes the only
1216
+ > guardrail on a publishable key.
1217
+
1218
+ ### Service URL discovery
1219
+
1220
+ Don't hard‑code `baseUrl` in your application. Serve it from your own backend, and
1221
+ keep the backend current from the endpoint directory:
1222
+
1223
+ ```http
1224
+ GET https://status-dev.oddin-video.gg/v1/endpoints.json
1225
+ ```
1226
+
1227
+ The document lists the current base URL per region and is served with
1228
+ `Access-Control-Allow-Origin: *`, so any backend or build pipeline can consume it.
1229
+ If an endpoint moves — most likely in regions with distinct internet regulations —
1230
+ the directory updates and your backend follows, with no app release or store
1231
+ re‑review. Endpoint changes and live availability for every Havik service are
1232
+ published at [status-dev.oddin-video.gg](https://status-dev.oddin-video.gg), the integration
1233
+ directory. The production directory URL is provided at onboarding.
1234
+
1235
+ ---
1236
+
1237
+ ## Troubleshooting
1238
+
1239
+ | Symptom | Likely cause |
1240
+ | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1241
+ | `NETWORK` error on every call; preflight fails in devtools | Your origin isn't on the key's allowed‑origins / DRM CORS allow‑list. See [Onboarding](#onboarding--cors). |
1242
+ | `UNAUTHORIZED` (401) | Wrong/expired api‑key, or wrong `baseUrl` environment (test vs prod key). |
1243
+ | `FORBIDDEN` (403) on license requests | The 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. |
1244
+ | `DRM_CLIENT` | The device/browser can't play this DRM (no usable key system, CDM/EME error), or license delivery failed without a server denial. Check the console for the EME/CDM detail; call `detectDrmSupport()` to pre‑flight and message the viewer. |
1245
+ | `NOT_FOUND` (404) for a match you can see | The key isn't entitled to that tournament (404 is intentionally indistinguishable from "unknown"). |
1246
+ | Player stays in `waiting` forever | The match hasn't gone live yet; the SDK is polling. Set `waitForLive.timeoutMs` to bound it. |
1247
+ | Autoplay doesn't start | Browser sound policy — set `muted: true` for autoplay, or start playback from a user gesture. |
1248
+ | Black video on Safari with DRM | Check 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. |
1249
+ | Black video in Chrome/Firefox/Edge **on iOS** | iOS 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. |
1250
+ | Black video, `levelHeight`/`droppedFrames` stuck | Check the browser console for an EME/CDM error and verify DRM CORS onboarding. |
1251
+
1252
+ Include `PlaybackError.requestId` (when present) when contacting support — it correlates
1253
+ to server‑side logs.
1254
+
1255
+ ---
1256
+
1257
+ ## Versioning & support
1258
+
1259
+ - The package follows semantic versioning. Pin a version in production (npm and CDN).
1260
+ - TypeScript definitions ship with the package; the types are the source of truth for
1261
+ the API surface.
1262
+ - The bundled hls.js version is managed by the SDK; do not assume a specific version in
1263
+ managed mode.
1264
+
1265
+ For onboarding (api‑keys, origin allow‑lists, entitlements) and support, contact Oddin.