@oddin-gg/havik-player 1.18.0 → 1.18.2

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 CHANGED
@@ -1,4 +1,4 @@
1
- # Havik Player SDK — API Reference
1
+ # Havik Player SDK API Reference
2
2
 
3
3
  `@oddin-gg/havik-player` · HTML5 player SDK for Oddin live streams (LL‑HLS + DRM).
4
4
 
@@ -17,12 +17,12 @@ This is the complete API reference. For a quick start, see the [README](../READM
17
17
  - [Live pickup](#live-pickup)
18
18
  - [DRM](#drm)
19
19
  - [Browser support](#browser-support)
20
- - [Mode A — Managed player](#mode-a--managed-player)
20
+ - [Mode A: Managed player](#mode-a-managed-player)
21
21
  - [createPlayer](#createplayer)
22
22
  - [Player](#player) · [Player events](#playereventmap)
23
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)
24
+ - [Mode B: Bring your own player](#mode-b-bring-your-own-player)
25
+ - [Mode C: Iframe embed](#mode-c-iframe-embed)
26
26
  - [Reference](#reference)
27
27
  - [Errors](#errors)
28
28
  - [Data types](#data-types)
@@ -41,9 +41,9 @@ The SDK plays Oddin live streams in the browser over **Low‑Latency HLS** with
41
41
 
42
42
  | Mode | Entry point | You provide | The SDK handles |
43
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 |
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
47
 
48
48
  All modes are exported from the package root and are fully typed (TypeScript
49
49
  definitions ship in the package).
@@ -84,20 +84,20 @@ IIFE bundle for `<script>`/iframe use.
84
84
 
85
85
  Two values are required to play any stream:
86
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).
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
89
  The key must be configured with an **allowed origin list** that includes the
90
90
  origin your player runs on. See [Onboarding & CORS](#onboarding--cors).
91
91
 
92
- You also need the **API base URL** — `https://feed-dev.oddin-video.gg` for the
92
+ You also need the **API base URL**: `https://feed-dev.oddin-video.gg` for the
93
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
94
+ endpoint directory as the source of truth rather than hard-coding either. See
95
95
  [Service URL discovery](#service-url-discovery).
96
96
 
97
97
  ### Credentials
98
98
 
99
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
100
+ function returning one; see [`CredentialSource`](#credentialsource). The function form is
101
101
  called per request, so it supports transparent refresh if you later move to
102
102
  short‑lived tokens.
103
103
 
@@ -111,17 +111,17 @@ const credential = async () => ({ apiKey: await myTokenService.get() });
111
111
 
112
112
  ### The play sequence
113
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
114
+ 1. **(Optional) Discover** live/upcoming matches with [`fetchCatalog()`](#fetchcatalog), or
115
+ watch a single match's status with [`watchStatus()`](#watchstatus).
116
+ 2. **Resolve playback**: [`resolveStream()`](#resolvestream) returns a
117
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
118
+ 3. **Play**: feed the descriptor to the managed player (Mode A) or your own player
119
119
  (Mode B).
120
120
  4. **DRM licenses** are acquired automatically by the player's EME layer as the CDM
121
121
  encounters encrypted media.
122
122
 
123
123
  Match status is available as a **push** channel ([`subscribeLiveState`](#subscribelivestate),
124
- SSE) — used automatically by the managed player — with polling
124
+ SSE), used automatically by the managed player, with polling
125
125
  ([`watchStatus`](#watchstatus) / `waitForLive`) as the fallback. The authoritative
126
126
  signal that a stream is playable is still a successful playback resolution (not catalog
127
127
  status, which can briefly lead the stream going live).
@@ -134,10 +134,10 @@ action. By default it does this over the **push** channel (SSE,
134
134
  [`subscribeLiveState`](#subscribelivestate)): while connected it makes no
135
135
  `/v1/playback` requests, waits for the pushed `live`, then resolves once. If the SSE
136
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
137
+ `Retry-After`. End-of-stream is detected automatically too: the player stops and
138
138
  fires `ended`. And ended is not necessarily forever: if the encoder reconnects after
139
139
  an outage long enough to tear the session down, the recreated session serves under a
140
- **new manifest URL** — so on the next pushed `live` the player **rejoins**
140
+ **new manifest URL**: so on the next pushed `live` the player **rejoins**
141
141
  automatically (re-resolves and re-attaches, `rejoinOnLive` option). Set
142
142
  `liveStateEvents: false` to force polling-only.
143
143
 
@@ -150,7 +150,7 @@ See [`WaitForLive`](#waitforlive) for tuning, and [`createPlayer`](#createplayer
150
150
  PlayReady is not used (Windows/Edge play via Widevine).
151
151
  - **Container/encryption:** CMAF/fMP4, `cbcs`.
152
152
  - The playback descriptor's `drm.*.licenseUrl` values are **pre‑signed**. When you
153
- integrate your own player (Mode B), you must **POST to the license URL verbatim** —
153
+ integrate your own player (Mode B), you must **POST to the license URL verbatim**:
154
154
  its signed query string is the access token; do not rewrite, re‑encode, or strip
155
155
  parameters. Use the **same api‑key** to resolve playback and to fetch licenses.
156
156
  The managed and iframe players handle this for you.
@@ -164,7 +164,7 @@ See [`WaitForLive`](#waitforlive) for tuning, and [`createPlayer`](#createplayer
164
164
  >
165
165
  > **iOS third‑party browsers (Chrome/Firefox/Edge on iOS):** iOS has no
166
166
  > Widevine CDM, so protected playback in these browsers depends entirely on
167
- > whether the WebKit build grants them FairPlay. Don't assume either way —
167
+ > whether the WebKit build grants them FairPlay. Don't assume either way:
168
168
  > call [`detectDrmSupport`](#detectdrmsupport) and let the probe decide, so
169
169
  > viewers see an accurate message instead of a black player.
170
170
 
@@ -175,7 +175,7 @@ See [`WaitForLive`](#waitforlive) for tuning, and [`createPlayer`](#createplayer
175
175
  | Chrome, Edge (desktop & Android) | hls.js | Widevine ✓ |
176
176
  | Firefox | hls.js | Widevine ✓ |
177
177
  | Safari (macOS, iOS 17.1+) | hls.js (via MMS) | FairPlay ✓ (`com.apple.fps`) |
178
- | Older Safari / iOS | native HLS (fallback) | FairPlay — OS passthrough only |
178
+ | Older Safari / iOS | native HLS (fallback) | FairPlay, OS passthrough only |
179
179
  | Chrome/Firefox/Edge **on iOS** | hls.js | probe with `detectDrmSupport` (no Widevine on iOS) |
180
180
 
181
181
  Requires a browser with Media Source Extensions (or ManagedMediaSource) and
@@ -183,7 +183,7 @@ Encrypted Media Extensions (all evergreen browsers).
183
183
 
184
184
  ---
185
185
 
186
- ## Mode A — Managed player
186
+ ## Mode A: Managed player
187
187
 
188
188
  The SDK owns the `<video>` element, bundles hls.js, and wires LL‑HLS + DRM
189
189
  automatically. It also handles live‑pickup retries, buffering recovery, stats, and
@@ -219,87 +219,87 @@ player.on('error', (e) => console.error(e.code, e.httpStatus, e.message));
219
219
 
220
220
  #### `CreatePlayerOptions`
221
221
 
222
- | Option | Type | Default | Description |
223
- | --------------------- | --------------------------------------- | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
224
- | `video` | `HTMLVideoElement` | — | **Required.** The element the SDK will control. |
225
- | `baseUrl` | `string` | — | **Required.** API base URL, e.g. `https://feed-dev.oddin-video.gg`. |
226
- | `matchUrn` | `string` | — | **Required.** The match to play, e.g. `od:match:1234`. |
227
- | `credential` | [`CredentialSource`](#credentialsource) | — | **Required.** Your api‑key (or a function returning one). |
228
- | `autoplay` | `boolean` | `true` | Start playback once ready. |
229
- | `muted` | `boolean` | `false` | Mute the element. Set `true` for reliable autoplay under browser sound policies. |
230
- | `controls` | `'custom' \| 'native' \| 'none'` | `'custom'` | Which controls to render. See [Controls & theming](#controls--theming). |
231
- | `theme` | [`Partial<HavikTheme>`](#haviktheme) | Oddin brand | Re-skin the custom control bar (ignored unless `controls: 'custom'`). See [Controls & theming](#controls--theming). |
232
- | `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'`. |
233
- | `endedMessage` | `string` | "This stream has ended" | Message on the end-of-stream card. |
234
- | `interruptedMessage` | `string` | "Reconnecting — please stand by" | Message shown **instead of** `endedMessage` when the server reports the stream stopped but the match is still running (`endedReason: 'interrupted'` — an ingest outage that outlived the bridge's hold). The player rejoins automatically when the stream returns **while `rejoinOnLive` is on** (the default); override this wording if you disable auto-rejoin, since nothing will re-attach on its own. |
235
- | `errorMessage` | `string` | the error's message | Message on the fatal-error card (otherwise the error's own message). |
236
- | `waitForLive` | [`WaitForLive`](#waitforlive) | — | Arm on an upcoming match and auto‑attach at go‑live. |
237
- | `lowLatency` | `'auto' \| boolean` | `'auto'` | `'auto'` lets the player decide from the manifest. |
238
- | `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. |
239
- | `userId` | `string` | — | Optional viewer id (sent as `X-User-Id` on license requests). |
240
- | `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. |
241
- | `hlsConfig` | `Partial<HlsConfig>` | — | Escape hatch for advanced hls.js tuning. |
242
- | `statsIntervalMs` | `number` | `2000` | How often `'stats'` is emitted. |
243
- | `licenseRefreshMs` | `number` | `480000` (8 min) | Silent DRM license‑URL refresh interval for long sessions. `0` disables. |
244
- | `poster` | `string` | — | Poster image shown before playback starts. |
245
- | `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). |
246
- | `maxBitrate` | `number` | — | Cap ABR to renditions at/below this bitrate (bps). |
247
- | `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. |
248
- | `snapToLiveOnRefocus` | `boolean` | `true` | Snap to the live edge when a backgrounded tab is refocused while far behind. Live/LL only. |
249
- | `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. |
250
- | `rejoinOnLive` | `boolean` | `true` | Rejoin automatically when a stream that ended comes back live. An ingest outage longer than the bridge's reconnect grace recreates the session under a **new manifest URL** (the old playlist freezes at HTTP 200), so the player keeps the live-state subscription open after `ended` (until `gone`) and re-arms on a pushed `live`. Set `false` to keep `ended` terminal. Needs `liveStateEvents`. |
251
- | `eventsBaseUrl` | `string` | derived | Override the SSE endpoint base. Default: `baseUrl` with the host prefixed by `events.` (e.g. `feed.<d>` → `events.<d>`). |
252
-
253
- ### Low‑latency & adaptive bitrate
222
+ | Option | Type | Default | Description |
223
+ | --------------------- | --------------------------------------- | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
224
+ | `video` | `HTMLVideoElement` | — | **Required.** The element the SDK will control. |
225
+ | `baseUrl` | `string` | — | **Required.** API base URL, e.g. `https://feed-dev.oddin-video.gg`. |
226
+ | `matchUrn` | `string` | — | **Required.** The match to play, e.g. `od:match:1234`. |
227
+ | `credential` | [`CredentialSource`](#credentialsource) | — | **Required.** Your api‑key (or a function returning one). |
228
+ | `autoplay` | `boolean` | `true` | Start playback once ready. |
229
+ | `muted` | `boolean` | `false` | Mute the element. Set `true` for reliable autoplay under browser sound policies. |
230
+ | `controls` | `'custom' \| 'native' \| 'none'` | `'custom'` | Which controls to render. See [Controls & theming](#controls--theming). |
231
+ | `theme` | [`Partial<HavikTheme>`](#haviktheme) | Oddin brand | Re-skin the custom control bar (ignored unless `controls: 'custom'`). See [Controls & theming](#controls--theming). |
232
+ | `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'`. |
233
+ | `endedMessage` | `string` | "This stream has ended" | Message on the end-of-stream card. |
234
+ | `interruptedMessage` | `string` | "Reconnecting — please stand by" | Message shown **instead of** `endedMessage` when the server reports the stream stopped but the match is still running (`endedReason: 'interrupted'`, an ingest outage that outlived the bridge's hold). The player rejoins automatically when the stream returns **while `rejoinOnLive` is on** (the default); override this wording if you disable auto-rejoin, since nothing will re-attach on its own. |
235
+ | `errorMessage` | `string` | the error's message | Message on the fatal-error card (otherwise the error's own message). |
236
+ | `waitForLive` | [`WaitForLive`](#waitforlive) | — | Arm on an upcoming match and auto‑attach at go‑live. |
237
+ | `lowLatency` | `'auto' \| boolean` | `'auto'` | `'auto'` lets the player decide from the manifest. |
238
+ | `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. |
239
+ | `userId` | `string` | — | Optional viewer id (sent as `X-User-Id` on license requests). |
240
+ | `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. |
241
+ | `hlsConfig` | `Partial<HlsConfig>` | — | Escape hatch for advanced hls.js tuning. |
242
+ | `statsIntervalMs` | `number` | `2000` | How often `'stats'` is emitted. |
243
+ | `licenseRefreshMs` | `number` | `480000` (8 min) | Silent DRM license‑URL refresh interval for long sessions. `0` disables. |
244
+ | `poster` | `string` | — | Poster image shown before playback starts. |
245
+ | `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). |
246
+ | `maxBitrate` | `number` | — | Cap ABR to renditions at/below this bitrate (bps). |
247
+ | `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. |
248
+ | `snapToLiveOnRefocus` | `boolean` | `true` | Snap to the live edge when a backgrounded tab is refocused while far behind. Live/LL only. |
249
+ | `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. |
250
+ | `rejoinOnLive` | `boolean` | `true` | Rejoin automatically when a stream that ended comes back live. An ingest outage longer than the bridge's reconnect grace recreates the session under a **new manifest URL** (the old playlist freezes at HTTP 200), so the player keeps the live-state subscription open after `ended` (until `gone`) and re-arms on a pushed `live`. Set `false` to keep `ended` terminal. Needs `liveStateEvents`. |
251
+ | `eventsBaseUrl` | `string` | derived | Override the SSE endpoint base. Default: `baseUrl` with the host prefixed by `events.` (e.g. `feed.<d>` → `events.<d>`). |
252
+
253
+ ### Low-latency & adaptive bitrate
254
254
 
255
255
  The player ships tuned defaults for Oddin's low‑latency HLS, so you normally
256
256
  don't need to set any of the live or ABR knobs:
257
257
 
258
- - **Live latency** — playback targets **~2 s** behind the live edge
258
+ - **Live latency**: playback targets **~2 s** behind the live edge
259
259
  (`liveLatencyTarget` → hls.js `liveSyncDuration`). It deliberately sits back
260
260
  from the bleeding `PART‑HOLD‑BACK` edge, where the newest partial segments
261
261
  aren't reliably published yet; chasing that edge causes constant rebuffering.
262
262
  Set `liveLatencyTarget` lower for closer‑to‑live (more rebuffer‑prone) or
263
- higher for steadier playback. _Lowered from 3 s — see the migration note below._
264
- - **Drift ceiling** — after a stall, the player catches up at up to **1.5×** and,
263
+ higher for steadier playback. _Lowered from 3 s; see the migration note below._
264
+ - **Drift ceiling**: after a stall, the player catches up at up to **1.5×** and,
265
265
  as a backstop, force‑seeks back toward the target once latency exceeds an
266
266
  internal **ceiling (~12 s, derived as `max(12, liveLatencyTarget + 9)`)**. This is what
267
267
  stops latency from snowballing to tens of seconds after buffering. The ceiling
268
268
  is internal and always kept above your `liveLatencyTarget`.
269
- - **Over‑seek clamp** — the drift ceiling's counterpart on the _near_ side. A seek
269
+ - **Over‑seek clamp**: the drift ceiling's counterpart on the _near_ side. A seek
270
270
  that lands closer to live than `liveLatencyTarget` is pulled back onto it, so
271
271
  dragging the scrub bar to its far right means **"go live"** rather than landing
272
- on the bleeding edge — where latency reads under a second and playback stutters
272
+ on the bleeding edge, where latency reads under a second and playback stutters
273
273
  frame‑by‑frame on an empty forward buffer. hls.js can't recover from that state
274
274
  on its own (its live‑sync only ever corrects being _behind_), so the stutter
275
- would otherwise persist for the rest of the session. Live only — VOD seeks are
275
+ would otherwise persist for the rest of the session. Live only: VOD seeks are
276
276
  untouched, and the clamp stands down while chasing is suspended on a stale
277
277
  playlist (there is no live edge left to protect at that point). On the custom
278
278
  control bar the bar's right end **is** the target, so the thumb reads full at
279
279
  the live edge. Bring‑your‑own‑player (Mode B) owns its own seek UI and needs
280
280
  the equivalent clamp itself.
281
- - **Audio‑hole bridge** — `maxBufferHole` is raised to **0.5 s** so the ~0.45 s
281
+ - **Audio‑hole bridge**: `maxBufferHole` is raised to **0.5 s** so the ~0.45 s
282
282
  audio "remainder" gap at each segment boundary is bridged as a sub‑perceptible
283
283
  hitch instead of a rebuffer.
284
- - **Start quality** — auto ABR seeds a **~1 Mbps** estimate so the first
284
+ - **Start quality**: auto ABR seeds a **~1 Mbps** estimate so the first
285
285
  rendition is one a modest link can sustain (**~360p**) and then **ramps up**
286
- within a few seconds as measured bandwidth allows — rather than grabbing a high
286
+ within a few seconds as measured bandwidth allows, rather than grabbing a high
287
287
  rung and rebuffering on a constrained link. This trades a slightly softer first
288
288
  ~2 s for a **stall‑free start** (the first‑seconds buffering fix). In addition,
289
289
  the session is bounded to the player's pixel box, so a small/embedded player
290
- won't fetch 1080p — but by an SDK‑managed, **debounced** size cap rather than
290
+ won't fetch 1080p, but by an SDK‑managed, **debounced** size cap rather than
291
291
  hls.js's own `capLevelToPlayerSize` poller: a size change only reaches ABR
292
292
  once the box has been stable for **~2 s** (raise), and a shrink is applied
293
293
  **lazily (~30 s)**, so a fullscreen enter/exit never forces a quality
294
- switch — and its ~1 s stall — mid‑transition (the higher rendition simply
294
+ switch, and its ~1 s stall, mid‑transition (the higher rendition simply
295
295
  downscales into the smaller box until the down‑cap lands). Passing
296
296
  `hlsConfig.capLevelToPlayerSize: true` restores hls.js's immediate poller and
297
297
  stands the SDK manager down. Pin a fixed rung with `startLevel`,
298
298
  cap the top with `maxBitrate`, or raise the seed via
299
299
  `hlsConfig.abrEwmaDefaultEstimate` if you want a sharper (higher‑risk) start.
300
300
 
301
- All of these can be overridden through the [`hlsConfig`](#playeroptions) escape
302
- hatch (e.g. `maxLiveSyncPlaybackRate`), but the defaults are tuned for Oddin's
301
+ All of these can be overridden through [`hlsConfig`](#playeroptions)
302
+ (e.g. `maxLiveSyncPlaybackRate`), but the defaults are tuned for Oddin's
303
303
  streams and rarely need changing. Raw live‑sync overrides are normalized so they
304
304
  can't crash the player: if you pass the count‑based `liveSyncDurationCount` /
305
305
  `liveMaxLatencyDurationCount`, the seconds‑based defaults step aside (hls.js
@@ -307,12 +307,12 @@ rejects mixing the two); if you set `liveSyncDuration` alone, a matching ceiling
307
307
  is derived above it.
308
308
 
309
309
  > **Migration note:** the default `liveLatencyTarget` **dropped from 3 s to 2 s**.
310
- > Nothing to change — the drift ceiling stays 12 s and the ABR reaction budgets
310
+ > Nothing to change: the drift ceiling stays 12 s and the ABR reaction budgets
311
311
  > are derived from the target, so they tighten with it automatically. Expect
312
312
  > playback ~1 s closer to live. If you were relying on the old behaviour, pass
313
313
  > `liveLatencyTarget: 3` explicitly.
314
314
 
315
- > **Migration note:** `liveCatchUpRate` was **removed** as a public option — the
315
+ > **Migration note:** `liveCatchUpRate` was **removed** as a public option; the
316
316
  > catch‑up rate (1.5×) is now managed internally alongside the drift ceiling. If
317
317
  > you set it before, drop it; reach for `hlsConfig.maxLiveSyncPlaybackRate` only
318
318
  > if you genuinely need to override the rate.
@@ -356,7 +356,7 @@ afterward (`play`/`pause`/etc. become no-ops).
356
356
  Notes:
357
357
 
358
358
  - `reload()` **rejects** (and emits `error`, setting state to `error`) if the
359
- re-resolution fails — `await` it or attach a `.catch`.
359
+ re-resolution fails, so `await` it or attach a `.catch`.
360
360
  - `play()` resolves even when the browser blocks autoplay; rely on the `playing`
361
361
  state / `error` event (not `play()`'s return value) to determine the outcome.
362
362
 
@@ -364,25 +364,25 @@ Notes:
364
364
 
365
365
  ### Player events
366
366
 
367
- `on(event, cb)` — the payload type depends on the event ([`PlayerEventMap`](#playereventmap)):
368
-
369
- | Event | Payload | Fires when |
370
- | ------------------ | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
371
- | `ready` | `void` | The manifest is parsed and the player is attached. |
372
- | `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). |
373
- | `playing` | `void` | Playback is progressing. |
374
- | `buffering` | `void` | The player is rebuffering. |
375
- | `paused` | `void` | Playback is paused. |
376
- | `ended` | `EndedReason \| undefined` | The live stream ended; playback stops and the last frame is kept. The payload says **why**: `'interrupted'` (the match is still running — its stream dropped, so show "reconnecting", **never** "ended"; the player rejoins by itself) or `'match_ended'` (a real end). `undefined` = the end came from the HLS staleness watchdog, or a server predating the field — treat it as a real end. Fires **again** with `'match_ended'` if an interruption later turns out to be the end, so don't latch on the first one. Also readable any time as `player.endedReason`. |
377
- | `error` | [`PlaybackError`](#playbackerror) | A fatal error occurred (playback stopped). |
378
- | `warning` | [`PlaybackError`](#playbackerror) | A non-fatal condition (e.g. FairPlay passthrough, a failed license refresh). Playback continues. |
379
- | `autoplayblocked` | `void` | The browser blocked autoplay (`NotAllowedError`). Show a tap-to-play / tap-to-unmute affordance, then call `player.play()` from the gesture. |
380
- | `qualitychange` | `number` | Active rendition changed (level index, `-1` = auto). |
381
- | `audiotrackchange` | `number` | Active audio track changed (track id). |
382
- | `texttrackchange` | `number` | Active text track changed (track id, `-1` = off). |
383
- | `pipchange` | `boolean` | Picture-in-Picture entered (`true`) or exited (`false`). |
384
- | `stats` | [`PlaybackStats`](#playbackstats) | Periodic stats sample (`statsIntervalMs`). |
385
- | `statechange` | [`PlayerState`](#playerstate) | Any state transition. |
367
+ `on(event, cb)`: the payload type depends on the event ([`PlayerEventMap`](#playereventmap)):
368
+
369
+ | Event | Payload | Fires when |
370
+ | ------------------ | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
371
+ | `ready` | `void` | The manifest is parsed and the player is attached. |
372
+ | `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). |
373
+ | `playing` | `void` | Playback is progressing. |
374
+ | `buffering` | `void` | The player is rebuffering. |
375
+ | `paused` | `void` | Playback is paused. |
376
+ | `ended` | `EndedReason \| undefined` | The live stream ended; playback stops and the last frame is kept. The payload says **why**: `'interrupted'` (the match is still running, and its stream dropped, so show "reconnecting", **never** "ended"; the player rejoins by itself) or `'match_ended'` (a real end). `undefined` = the end came from the HLS staleness watchdog, or a server predating the field; treat it as a real end. Fires **again** with `'match_ended'` if an interruption later turns out to be the end, so don't latch on the first one. Also readable any time as `player.endedReason`. |
377
+ | `error` | [`PlaybackError`](#playbackerror) | A fatal error occurred (playback stopped). |
378
+ | `warning` | [`PlaybackError`](#playbackerror) | A non-fatal condition (e.g. FairPlay passthrough, a failed license refresh). Playback continues. |
379
+ | `autoplayblocked` | `void` | The browser blocked autoplay (`NotAllowedError`). Show a tap-to-play / tap-to-unmute affordance, then call `player.play()` from the gesture. |
380
+ | `qualitychange` | `number` | Active rendition changed (level index, `-1` = auto). |
381
+ | `audiotrackchange` | `number` | Active audio track changed (track id). |
382
+ | `texttrackchange` | `number` | Active text track changed (track id, `-1` = off). |
383
+ | `pipchange` | `boolean` | Picture-in-Picture entered (`true`) or exited (`false`). |
384
+ | `stats` | [`PlaybackStats`](#playbackstats) | Periodic stats sample (`statsIntervalMs`). |
385
+ | `statechange` | [`PlayerState`](#playerstate) | Any state transition. |
386
386
 
387
387
  <a id="controls--theming"></a>
388
388
 
@@ -393,9 +393,9 @@ The managed player ships a built-in, fully **skinnable** control bar. By default
393
393
  renders the Oddin-branded UI: a center play/overlay, a seek bar, play/pause,
394
394
  volume, a live badge + go-live, quality / audio / subtitle menus,
395
395
  Picture-in-Picture, fullscreen, a buffering spinner, and **branded status cards**
396
- for the stopped/pre-play states — _ended_ ("This stream has ended"), _fatal
396
+ for the stopped/pre-play states: _ended_ ("This stream has ended"), _fatal
397
397
  error_ (message + Retry when retryable), _pre-play_ (poster + play CTA), and
398
- _armed-waiting_ ("Starting soon…") — so a finished or failed stream never leaves
398
+ _armed-waiting_ ("Starting soon…"), so a finished or failed stream never leaves
399
399
  a frozen frame behind a dead play button. The cards pick up `theme.logoUrl` and
400
400
  the theme colors; customise the copy with `endedMessage`/`interruptedMessage`/`errorMessage`, or set
401
401
  `statusOverlays: false` to drive your own end/error UI from the player events.
@@ -405,7 +405,7 @@ fully keyboard- and screen-reader-accessible.
405
405
 
406
406
  > **Fullscreen on iPhone:** iPhone Safari has no element Fullscreen API, so the
407
407
  > fullscreen button there enters the **video-native** fullscreen
408
- > (`webkitEnterFullscreen`) — the YouTube-style player with **native iOS
408
+ > (`webkitEnterFullscreen`): the YouTube-style player with **native iOS
409
409
  > controls**. The custom control bar, watermark, and overlays are not shown
410
410
  > inside it; the viewer exits with the native Done button. Video-native
411
411
  > fullscreen only exists once media is loaded, so on iPhone the button takes
@@ -423,7 +423,7 @@ type Controls = 'custom' | 'native' | 'none';
423
423
  | `'native'` | The browser's built-in `<video controls>` UI. No wrapper element is added. |
424
424
  | `'none'` | No controls. Drive the player entirely through its [API](#player) (build your own). |
425
425
 
426
- Re-skin the custom bar in one of two ways — both set the same CSS variables, so
426
+ Re-skin the custom bar in one of two ways; both set the same CSS variables, so
427
427
  they're interchangeable:
428
428
 
429
429
  **1. Pass a `theme`** (merged over the Oddin defaults; omitted keys keep the brand value):
@@ -494,7 +494,7 @@ type PlayerState =
494
494
 
495
495
  ---
496
496
 
497
- ## Mode B — Bring your own player
497
+ ## Mode B: Bring your own player
498
498
 
499
499
  The SDK resolves the stream and gives you DRM helpers; you own the `<video>` element
500
500
  and the playback engine.
@@ -601,7 +601,7 @@ works everywhere the managed control bar's does. `enterVideoFullscreen` walks a
601
601
  capability ladder: standard element fullscreen on `container` (pass your player
602
602
  wrapper to keep your own controls visible; defaults to the video), the
603
603
  `webkit`-prefixed variant (older iPad/macOS Safari), and finally
604
- `video.webkitEnterFullscreen()` — **iPhone Safari has no element Fullscreen API
604
+ `video.webkitEnterFullscreen()`: **iPhone Safari has no element Fullscreen API
605
605
  at all**, so the only fullscreen there is the video-native presentation with
606
606
  native iOS controls (your DOM is not visible inside it, and it only takes
607
607
  effect once media is loaded). Call it from a user gesture. It resolves `false`
@@ -609,13 +609,13 @@ when the platform has no mechanism at all (some webviews) so you can hide your
609
609
  button; a denied request (permissions policy) rejects.
610
610
 
611
611
  `exitVideoFullscreen` leaves whichever presentation is active (video-native
612
- first — it reports only through the video, never the document), is safe to
612
+ first; it reports only through the video, never the document), is safe to
613
613
  call when not fullscreen, and is **scoped to your video's presentation**: an
614
614
  unrelated element's fullscreen elsewhere on the page is left alone.
615
615
  `videoFullscreenActive` tells you whether your video's presentation is
616
616
  currently fullscreen. Pass the same `container` to all three. For fullscreen **state changes**, listen to `fullscreenchange` /
617
617
  `webkitfullscreenchange` on the document and `webkitbeginfullscreen` /
618
- `webkitendfullscreen` on the video — the last two are the only signal for
618
+ `webkitendfullscreen` on the video; the last two are the only signal for
619
619
  iPhone's video-native fullscreen.
620
620
 
621
621
  <a id="detectdrmsupport"></a>
@@ -643,16 +643,16 @@ request, bounded by a timeout. It never rejects.
643
643
 
644
644
  Rules of use:
645
645
 
646
- - **Call it lazily, cache the verdict yourself** — only once you know the
646
+ - **Call it lazily, cache the verdict yourself**: only once you know the
647
647
  content is DRM‑protected (`descriptor.drmEnabled`). The EME spec allows the
648
648
  probe to trigger consent prompts or CDM downloads, and results are not
649
649
  memoized; never run it on page load, and never probe for a clear stream.
650
650
  - **Probe, don't UA‑sniff.** The verdict is the playability gate; use the UA
651
651
  only to word the message.
652
- - `insecure-context` means EME is unavailable by spec — typical when testing
652
+ - `insecure-context` means EME is unavailable by spec, typical when testing
653
653
  over `http://<LAN‑IP>`; `localhost` is fine.
654
654
  - `blocked-by-policy` means an embedding problem (an iframe missing
655
- `allow="encrypted-media"`), not an unsupported browser — surface it to the
655
+ `allow="encrypted-media"`), not an unsupported browser, so surface it to the
656
656
  integrator, not the viewer.
657
657
  - `supported` is necessary, not sufficient: licensing, CORS onboarding and
658
658
  packaging still apply.
@@ -671,7 +671,7 @@ function clearDeviceId(): void;
671
671
  `X-Device-Id` on every DRM license request.
672
672
 
673
673
  > **Privacy:** the device id is a stable, cross‑session identifier (personal data
674
- > under GDPR/ePrivacy). Disclose it in your privacy policy and offer a reset —
674
+ > under GDPR/ePrivacy). Disclose it in your privacy policy and offer a reset:
675
675
  > `clearDeviceId()` removes the stored id so the next call generates a fresh one.
676
676
 
677
677
  ### `fetchCatalog`
@@ -681,7 +681,7 @@ function fetchCatalog(opts: FetchCatalogOptions): Promise<Catalog>;
681
681
  ```
682
682
 
683
683
  Fetches the metadata catalog (tournaments → matches). **No** manifest or DRM data is
684
- included — use [`resolveStream`](#resolvestream) for playback. Supports a conditional
684
+ included; use [`resolveStream`](#resolvestream) for playback. Supports a conditional
685
685
  refresh via ETag.
686
686
 
687
687
  ```ts
@@ -751,18 +751,18 @@ function deriveEventsBaseUrl(baseUrl: string): string | undefined;
751
751
 
752
752
  **Push**-based live state over Server-Sent Events (`events.<domain>/v1/events/{urn}`).
753
753
  The managed player uses this automatically (the `liveStateEvents` option) to go live
754
- the instant a match starts — without polling `/v1/playback` — and to detect
754
+ the instant a match starts, without polling `/v1/playback`, and to detect
755
755
  end-of-stream instantly; BYO players can use it directly. `state` mirrors the
756
756
  `/v1/playback` outcome ladder:
757
757
 
758
758
  | `state` | meaning | maps to `/v1/playback` |
759
759
  | ---------- | ----------------------------- | ---------------------- |
760
- | `live` | serveable now — play | `200 OK` |
760
+ | `live` | serveable now, so play | `200 OK` |
761
761
  | `upcoming` | scheduled, not serving yet | `425 Too Early` |
762
762
  | `ended` | was live; media stopped | `503` |
763
763
  | `gone` | ended past the catchup window | `410 Gone` |
764
764
 
765
- An `ended` event also carries a `reason` — `'interrupted'` (the match is still
765
+ An `ended` event also carries a `reason`: `'interrupted'` (the match is still
766
766
  running; only its stream dropped, so a BYO player should show "reconnecting"
767
767
  and expect play to resume) or `'match_ended'` (a real end). It is absent for
768
768
  other states, and absent entirely from servers predating it, so read
@@ -774,7 +774,7 @@ listening after the first.
774
774
  Uses `fetch` (not native `EventSource`, which can't set the `x-api-key` header),
775
775
  reconnects with backoff, and replays the current state on connect. Every event is
776
776
  also reported via `onAlive` (including heartbeats) so callers can treat the stream as
777
- healthy. A subscription problem is **non-fatal** — it retries in the background.
777
+ healthy. A subscription problem is **non-fatal**; it retries in the background.
778
778
 
779
779
  ```ts
780
780
  import { subscribeLiveState, deriveEventsBaseUrl } from '@oddin-gg/havik-player';
@@ -815,7 +815,7 @@ Utility: returns `true` if an HLS playlist body contains low‑latency tags
815
815
 
816
816
  ---
817
817
 
818
- ## Mode C — Iframe embed
818
+ ## Mode C: Iframe embed
819
819
 
820
820
  Embed a hosted player page and control it over `postMessage`. This is the lowest‑touch
821
821
  integration and isolates playback in its own browsing context.
@@ -851,7 +851,7 @@ embed.setMuted(false);
851
851
  | `src` | `string` | **Required.** URL of the hosted embed page. |
852
852
  | `baseUrl` | `string` | **Required.** API base URL (threaded to the iframe). |
853
853
  | `matchUrn` | `string` | **Required.** Match to play. |
854
- | `apiKey` | `string` | **Dev only.** Appended as a query param. In production the embed page injects the key server‑side — omit this. |
854
+ | `apiKey` | `string` | **Dev only.** Appended as a query param. In production the embed page injects the key server‑side, so omit this. |
855
855
  | `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). |
856
856
  | `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"`. |
857
857
  | `muted` | `boolean` | Start muted. |
@@ -896,13 +896,13 @@ bootEmbed();
896
896
 
897
897
  `bootEmbed()` reads its [`EmbedConfig`](#embedconfig) from `window.HAVIK_EMBED_CONFIG`
898
898
  (server‑injected, preferred) or the URL query string, runs a managed player full‑bleed,
899
- and speaks the postMessage protocol. **The api‑key is never accepted over postMessage** —
899
+ and speaks the postMessage protocol. **The api‑key is never accepted over postMessage**;
900
900
  inject it server‑side in production.
901
901
 
902
902
  #### `EmbedConfig`
903
903
 
904
904
  Read by `bootEmbed()` in one of two **mutually-exclusive** modes: if
905
- `window.HAVIK_EMBED_CONFIG` is present (production) it is the **only** source — the
905
+ `window.HAVIK_EMBED_CONFIG` is present (production) it is the **only** source; the
906
906
  query string is ignored entirely, so a tampered `?baseUrl=` can't redirect the injected
907
907
  api-key. Otherwise (dev/demo) config comes from the query string. The postMessage bridge
908
908
  **fails closed**: with no `parentOrigin`, the embed neither accepts commands nor posts
@@ -927,7 +927,7 @@ events.
927
927
  Every message is filtered with [`isOddinMessage`](#isoddinmessage) (anything not a
928
928
  recognized `oddin:` message is ignored). The **host** verifies `event.origin` against the
929
929
  embed page's origin on every message. The **iframe** verifies `event.origin` against its
930
- configured `parentOrigin` — `mountEmbed` always sets this, so the bridge is origin‑verified
930
+ configured `parentOrigin`, and `mountEmbed` always sets this, so the bridge is origin‑verified
931
931
  end‑to‑end. If you self‑host the embed page and call `bootEmbed()` directly, **always set
932
932
  `parentOrigin`** (see [`EmbedConfig`](#embedconfig)); without it the iframe accepts `oddin:`
933
933
  messages from any origin and posts events with target origin `*`.
@@ -998,14 +998,14 @@ class PlaybackError extends Error {
998
998
  | `TIMEOUT` | (last)¹ | `waitForLive` exceeded `timeoutMs`. | No |
999
999
  | `ABORTED` | 0 | Cancelled via `AbortSignal`. | No |
1000
1000
 
1001
- ¹ `TIMEOUT.httpStatus` carries the status of the last error it was waiting on — typically
1001
+ ¹ `TIMEOUT.httpStatus` carries the status of the last error it was waiting on, typically
1002
1002
  `425` while waiting for an upcoming match, `503` while an origin warms up, or `0` if the
1003
1003
  timeout lands on a network failure.
1004
1004
 
1005
1005
  ² `DRM_CLIENT.httpStatus` carries the license/certificate HTTP status when one was
1006
1006
  involved (e.g. a `5xx` after the SDK's license-retry budget is exhausted), else `0`
1007
1007
  (no usable key system, CDM/EME error, request never got a response). A genuine license
1008
- `401`/`403` surfaces as `UNAUTHORIZED`/`FORBIDDEN`, never as `DRM_CLIENT` — branch on
1008
+ `401`/`403` surfaces as `UNAUTHORIZED`/`FORBIDDEN`, never as `DRM_CLIENT`, so branch on
1009
1009
  the `code`, not on `httpStatus`, to tell a server rejection from a device that can't
1010
1010
  play DRM. The built-in error card still offers Retry (it re-resolves and re-attaches,
1011
1011
  which can recover a transient license failure); an unsupported device will simply fail
@@ -1148,7 +1148,7 @@ interface TextTrackInfo {
1148
1148
 
1149
1149
  <a id="live-pickup-options"></a>
1150
1150
 
1151
- ### Live‑pickup options
1151
+ ### Live-pickup options
1152
1152
 
1153
1153
  #### `WaitForLive`
1154
1154
 
@@ -1158,22 +1158,22 @@ type WaitForLive = boolean | WaitForLiveOptions;
1158
1158
 
1159
1159
  Set to `true` for sensible defaults, or pass options:
1160
1160
 
1161
- | Option | Type | Default | Description |
1162
- | --------------- | ---------------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------- |
1163
- | `timeoutMs` | `number` | unbounded | Overall budget before giving up with `TIMEOUT`. |
1164
- | `floorMs` | `number` | `1000` | Minimum delay between polls (also the server minimum). |
1165
- | `ceilMs` | `number` | `30000` | Maximum delay between polls **inside the imminent window** (see `imminentMs`). Far-from-kickoff polls are widened — see below. |
1166
- | `kickoffAt` | `number \| string` | — | Scheduled kickoff (epoch ms or ISO string). When given, the SDK widens long-tail polls automatically — see "Far-future polling". |
1167
- | `imminentMs` | `number` | `600000` | Time-to-kickoff threshold for "imminent" (collapses to the tight `[floorMs, ceilMs]` band). |
1168
- | `sanityFloorMs` | `number` | `300000` | Hard cap on any single sleep (so an early go-live / schedule change is caught within bounded latency). 5 min default. |
1169
- | `jitter` | `boolean` | `true` | Add up to +15% jitter (never polls faster than the server asks). |
1170
- | `onState` | `(state: WaitState) => void` | — | Called before each wait. |
1161
+ | Option | Type | Default | Description |
1162
+ | --------------- | ---------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------- |
1163
+ | `timeoutMs` | `number` | unbounded | Overall budget before giving up with `TIMEOUT`. |
1164
+ | `floorMs` | `number` | `1000` | Minimum delay between polls (also the server minimum). |
1165
+ | `ceilMs` | `number` | `30000` | Maximum delay between polls **inside the imminent window** (see `imminentMs`). Far-from-kickoff polls are widened; see below. |
1166
+ | `kickoffAt` | `number \| string` | — | Scheduled kickoff (epoch ms or ISO string). When given, the SDK widens long-tail polls automatically; see "Far-future polling". |
1167
+ | `imminentMs` | `number` | `600000` | Time-to-kickoff threshold for "imminent" (collapses to the tight `[floorMs, ceilMs]` band). |
1168
+ | `sanityFloorMs` | `number` | `300000` | Hard cap on any single sleep (so an early go-live / schedule change is caught within bounded latency). 5 min default. |
1169
+ | `jitter` | `boolean` | `true` | Add up to +15% jitter (never polls faster than the server asks). |
1170
+ | `onState` | `(state: WaitState) => void` | — | Called before each wait. |
1171
1171
 
1172
1172
  ##### Far-future polling
1173
1173
 
1174
1174
  A viewer armed hours before kickoff would otherwise hammer `/v1/playback` every
1175
1175
  30s for the entire long tail. When **`kickoffAt`** is set (or the server includes
1176
- `liveStartsAt` on the 425 body — see [Errors](#errors) `liveStartsAtMs`), the
1176
+ `liveStartsAt` on the 425 body; see [Errors](#errors) `liveStartsAtMs`), the
1177
1177
  poll cadence is widened to `min(ttk / 10, sanityFloorMs)` for the long tail and
1178
1178
  collapses to the tight `[floorMs, ceilMs]` range only inside the imminent
1179
1179
  window. Guards baked in: the server's `Retry-After` is always a **floor** (the
@@ -1198,11 +1198,11 @@ interface WaitState {
1198
1198
 
1199
1199
  These exports are for advanced/custom integrations.
1200
1200
 
1201
- - **`pollToLive(attempt, cfg, signal?)`** — the generic retry primitive behind
1201
+ - **`pollToLive(attempt, cfg, signal?)`**: the generic retry primitive behind
1202
1202
  `waitForLive`. Polls an arbitrary `() => Promise<T>`, retrying on retryable
1203
1203
  `PlaybackError`s and honoring `retryAfterMs`.
1204
- - **`isOddinMessage(data)`** — type guard for the postMessage protocol.
1205
- - **`ODDIN_THEME`** / **`applyTheme(root, theme?)`** — the default control-bar skin
1204
+ - **`isOddinMessage(data)`**: type guard for the postMessage protocol.
1205
+ - **`ODDIN_THEME`** / **`applyTheme(root, theme?)`**: the default control-bar skin
1206
1206
  and the helper that writes a [`HavikTheme`](#haviktheme) onto an element as
1207
1207
  `--havik-*` CSS variables. See [Controls & theming](#controls--theming).
1208
1208
 
@@ -1214,7 +1214,7 @@ These exports are for advanced/custom integrations.
1214
1214
 
1215
1215
  ## Onboarding & CORS
1216
1216
 
1217
- The api‑key is **publishable** — it is designed to live in browser JavaScript. Its
1217
+ The api‑key is **publishable**: it is designed to live in browser JavaScript. Its
1218
1218
  protection is a **server‑side allow‑list**, so every origin you serve the player from
1219
1219
  must be onboarded before it will work:
1220
1220
 
@@ -1227,7 +1227,7 @@ must be onboarded before it will work:
1227
1227
  If you serve the player from a new domain, request it be added to the key's allow‑lists
1228
1228
  first. Key revocation is not instantaneous (validation is cached for a few minutes).
1229
1229
 
1230
- > **Never use a wildcard (`*`) origin allow‑list in production** — it removes the only
1230
+ > **Never use a wildcard (`*`) origin allow‑list in production**: it removes the only
1231
1231
  > guardrail on a publishable key.
1232
1232
 
1233
1233
  ### Service URL discovery
@@ -1241,7 +1241,7 @@ GET https://status-dev.oddin-video.gg/v1/endpoints.json
1241
1241
 
1242
1242
  The document lists the current base URL per region and is served with
1243
1243
  `Access-Control-Allow-Origin: *`, so any backend or build pipeline can consume it.
1244
- If an endpoint moves — most likely in regions with distinct internet regulations —
1244
+ If an endpoint moves, most likely in regions with distinct internet regulations,
1245
1245
  the directory updates and your backend follows, with no app release or store
1246
1246
  re‑review. Endpoint changes and live availability for every Havik service are
1247
1247
  published at [status-dev.oddin-video.gg](https://status-dev.oddin-video.gg), the integration
@@ -1259,12 +1259,12 @@ directory. The production directory URL is provided at onboarding.
1259
1259
  | `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. |
1260
1260
  | `NOT_FOUND` (404) for a match you can see | The key isn't entitled to that tournament (404 is intentionally indistinguishable from "unknown"). |
1261
1261
  | Player stays in `waiting` forever | The match hasn't gone live yet; the SDK is polling. Set `waitForLive.timeoutMs` to bound it. |
1262
- | Autoplay doesn't start | Browser sound policy — set `muted: true` for autoplay, or start playback from a user gesture. |
1262
+ | Autoplay doesn't start | Browser sound policy: set `muted: true` for autoplay, or start playback from a user gesture. |
1263
1263
  | 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. |
1264
1264
  | 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. |
1265
1265
  | Black video, `levelHeight`/`droppedFrames` stuck | Check the browser console for an EME/CDM error and verify DRM CORS onboarding. |
1266
1266
 
1267
- Include `PlaybackError.requestId` (when present) when contacting support — it correlates
1267
+ Include `PlaybackError.requestId` (when present) when contacting support; it correlates
1268
1268
  to server‑side logs.
1269
1269
 
1270
1270
  ---