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