@oddin-gg/havik-player 2.18.0 → 2.20.0
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/README.md +1 -0
- package/dist/havik-player.global.js +18 -18
- package/dist/havik-player.global.js.map +1 -1
- package/dist/index.cjs +3 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +42 -2
- package/dist/index.d.ts +42 -2
- package/dist/index.js +361 -333
- package/dist/index.js.map +1 -1
- package/docs/API.md +67 -40
- package/embed/index.html +1 -1
- package/examples/vanilla.html +1 -1
- package/package.json +1 -1
package/docs/API.md
CHANGED
|
@@ -198,7 +198,7 @@ function createPlayer(opts: CreatePlayerOptions): Promise<Player>;
|
|
|
198
198
|
|
|
199
199
|
Resolves to a [`Player`](#player) as soon as the stream is attached (or armed, when
|
|
200
200
|
`waitForLive` is set and the match is still upcoming). Terminal errors during the
|
|
201
|
-
initial resolution (e.g. `NOT_FOUND`, `GONE`, `UNAUTHORIZED`) reject the promise; the
|
|
201
|
+
initial resolution (e.g. `NOT_FOUND`, `GEO_DENIED`, `GONE`, `UNAUTHORIZED`) reject the promise; the
|
|
202
202
|
live wait itself never blocks the promise and is surfaced through `'waiting'` events.
|
|
203
203
|
|
|
204
204
|
```ts
|
|
@@ -394,6 +394,7 @@ interface Player {
|
|
|
394
394
|
exitPictureInPicture(): Promise<void>;
|
|
395
395
|
enterFullscreen(): Promise<boolean>; // element FS on the player container; iPhone → video-native. false = no mechanism
|
|
396
396
|
exitFullscreen(): Promise<void>; // element or video-native; safe when not fullscreen
|
|
397
|
+
fullscreenSupported(): boolean; // false = permissions policy blocks it (iframe without allow=fullscreen) or no mechanism; optimistic before media loads on iPhone
|
|
397
398
|
retry(): Promise<void>; // recover from an error (re-resolve + re-attach)
|
|
398
399
|
reload(): Promise<void>; // re‑resolve playback (refreshes the license URL) + re‑attach
|
|
399
400
|
on<E extends PlayerEvent>(event: E, cb: (payload: PlayerEventMap[E]) => void): () => void;
|
|
@@ -449,8 +450,12 @@ Picture-in-Picture, fullscreen, a buffering spinner, and **branded status cards*
|
|
|
449
450
|
for the stopped/pre-play states: _ended_ ("This stream has ended"), _fatal
|
|
450
451
|
error_ (message + Retry when retryable), _pre-play_ (poster + play CTA), and
|
|
451
452
|
_armed-waiting_ ("Starting soon…"), so a finished or failed stream never leaves
|
|
452
|
-
a frozen frame behind a dead play button.
|
|
453
|
-
|
|
453
|
+
a frozen frame behind a dead play button. A `GEO_DENIED` error (the match is
|
|
454
|
+
booked for your key but not playable in the viewer's country) gets its own copy,
|
|
455
|
+
"This match isn't available in your country (RU)", naming the country the server
|
|
456
|
+
saw, and no Retry button: a retry would be refused again. The cards pick up `theme.logoUrl` and
|
|
457
|
+
the theme colors; customise the copy with `endedMessage`/`interruptedMessage`/`errorMessage`
|
|
458
|
+
(an `errorMessage` replaces the `GEO_DENIED` copy too), or set
|
|
454
459
|
`statusOverlays: false` to drive your own end/error UI from the player events.
|
|
455
460
|
It auto-hides during playback, supports keyboard shortcuts (space/`k` play-pause,
|
|
456
461
|
`m` mute, `f` fullscreen, `c` captions, `l` go-live, arrows seek/volume), and is
|
|
@@ -468,6 +473,17 @@ stream.
|
|
|
468
473
|
> effect **after playback has started** (a tap on the pre-play poster is a
|
|
469
474
|
> no-op there). Everywhere else (desktop, Android, iPad) the button uses
|
|
470
475
|
> element fullscreen and keeps the branded controls.
|
|
476
|
+
>
|
|
477
|
+
> **Where fullscreen cannot work, the button is not shown** and the `f`
|
|
478
|
+
> shortcut is inert, the same gate the Picture-in-Picture button has: a
|
|
479
|
+
> cross-origin iframe whose `allow` list omits `fullscreen` (the browser
|
|
480
|
+
> reports `document.fullscreenEnabled === false`), or a webview with no
|
|
481
|
+
> fullscreen mechanism at all. So an operator who must block fullscreen on an
|
|
482
|
+
> embed drops it from the iframe's `allow` attribute and needs no custom
|
|
483
|
+
> control bar. The decision is re-made on the video's `loadedmetadata`, which
|
|
484
|
+
> is when iPhone first reports whether its video-native presentation exists,
|
|
485
|
+
> so on iPhone the button is still shown (and inert, as above) until playback
|
|
486
|
+
> has started.
|
|
471
487
|
|
|
472
488
|
```ts
|
|
473
489
|
type Controls = 'custom' | 'native' | 'none';
|
|
@@ -697,6 +713,7 @@ hls.attachMedia(videoElement);
|
|
|
697
713
|
function enterVideoFullscreen(video: HTMLVideoElement, container?: HTMLElement): Promise<boolean>;
|
|
698
714
|
function exitVideoFullscreen(video: HTMLVideoElement, container?: HTMLElement): Promise<void>;
|
|
699
715
|
function videoFullscreenActive(video: HTMLVideoElement, container?: HTMLElement): boolean;
|
|
716
|
+
function fullscreenSupported(video: HTMLVideoElement, container?: HTMLElement): boolean;
|
|
700
717
|
```
|
|
701
718
|
|
|
702
719
|
Fullscreen with the platform quirks handled, so your own fullscreen button
|
|
@@ -716,7 +733,13 @@ first; it reports only through the video, never the document), is safe to
|
|
|
716
733
|
call when not fullscreen, and is **scoped to your video's presentation**: an
|
|
717
734
|
unrelated element's fullscreen elsewhere on the page is left alone.
|
|
718
735
|
`videoFullscreenActive` tells you whether your video's presentation is
|
|
719
|
-
currently fullscreen.
|
|
736
|
+
currently fullscreen. `fullscreenSupported` is the pre-flight for your button:
|
|
737
|
+
`false` when a request is known to fail: the element API is there but the
|
|
738
|
+
document reports `fullscreenEnabled === false` (permissions policy; an iframe
|
|
739
|
+
without `fullscreen` in `allow`), or media is loaded and the platform has no
|
|
740
|
+
mechanism at all. It stays `true` before media loads where no element API
|
|
741
|
+
exists (iPhone cannot say yet), so re-check it on `loadedmetadata`. Pass the
|
|
742
|
+
same `container` to all four. For fullscreen **state changes**, listen to `fullscreenchange` /
|
|
720
743
|
`webkitfullscreenchange` on the document and `webkitbeginfullscreen` /
|
|
721
744
|
`webkitendfullscreen` on the video; the last two are the only signal for
|
|
722
745
|
iPhone's video-native fullscreen.
|
|
@@ -847,14 +870,14 @@ const watcher = watchStatus({
|
|
|
847
870
|
|
|
848
871
|
#### `WatchStatusOptions`
|
|
849
872
|
|
|
850
|
-
| Option | Type | Default | Description
|
|
851
|
-
| ------------ | --------------------------------------- | ------- |
|
|
852
|
-
| `baseUrl` | `string` | — | **Required.**
|
|
853
|
-
| `matchUrn` | `string` | — | **Required.**
|
|
854
|
-
| `credential` | [`CredentialSource`](#credentialsource) | — | **Required.**
|
|
855
|
-
| `onChange` | `(status: MatchStatus) => void` | — | **Required.** Fired on first read, then on any status, teams or start change.
|
|
856
|
-
| `onError` | `(err: PlaybackError) => void` | — | Fired on a poll error; polling continues, except on `NOT_FOUND`, `GONE` or `INVALID_URN`: the watcher stops itself and this is its last call. |
|
|
857
|
-
| `intervalMs` | `number` | `10000` | Poll interval (floored at `2000`).
|
|
873
|
+
| Option | Type | Default | Description |
|
|
874
|
+
| ------------ | --------------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
875
|
+
| `baseUrl` | `string` | — | **Required.** |
|
|
876
|
+
| `matchUrn` | `string` | — | **Required.** |
|
|
877
|
+
| `credential` | [`CredentialSource`](#credentialsource) | — | **Required.** |
|
|
878
|
+
| `onChange` | `(status: MatchStatus) => void` | — | **Required.** Fired on first read, then on any status, teams or start change. |
|
|
879
|
+
| `onError` | `(err: PlaybackError) => void` | — | Fired on a poll error; polling continues, except on `NOT_FOUND`, `GEO_DENIED`, `GONE` or `INVALID_URN`: the watcher stops itself and this is its last call. |
|
|
880
|
+
| `intervalMs` | `number` | `10000` | Poll interval (floored at `2000`). |
|
|
858
881
|
|
|
859
882
|
<a id="live-state-sse"></a>
|
|
860
883
|
|
|
@@ -1084,7 +1107,7 @@ messages from any origin and posts events with target origin `*`.
|
|
|
1084
1107
|
{ type: 'oddin:tracks'; quality: QualityLevel[]; audio: AudioTrackInfo[]; text: TextTrackInfo[] }
|
|
1085
1108
|
{ type: 'oddin:qualitychange'; index } | { type: 'oddin:audiotrackchange'; id } | { type: 'oddin:texttrackchange'; id }
|
|
1086
1109
|
{ type: 'oddin:stats'; droppedFrames; latencySeconds?; bandwidthKbps?; levelHeight?; lowLatency?; isLive?; atLiveEdge? }
|
|
1087
|
-
{ type: 'oddin:error'; code: PlaybackErrorCode; message: string }
|
|
1110
|
+
{ type: 'oddin:error'; code: PlaybackErrorCode; message: string; country?: string } // country: set with GEO_DENIED
|
|
1088
1111
|
```
|
|
1089
1112
|
|
|
1090
1113
|
The `oddin:ready` event carries the embed page's `protocol` version; `mountEmbed` warns on a
|
|
@@ -1109,25 +1132,27 @@ class PlaybackError extends Error {
|
|
|
1109
1132
|
readonly httpStatus: number; // 0 for NETWORK / ABORTED; see the table notes for TIMEOUT / DRM_CLIENT
|
|
1110
1133
|
readonly retryAfterMs?: number; // set for TOO_EARLY / RATE_LIMITED / UNAVAILABLE
|
|
1111
1134
|
readonly liveStartsAtMs?: number; // epoch ms; set for TOO_EARLY when the server included `liveStartsAt`
|
|
1135
|
+
readonly country?: string; // upper-case ISO 3166-1 alpha-2; set for GEO_DENIED
|
|
1112
1136
|
readonly requestId?: string; // server correlation id, when present
|
|
1113
1137
|
}
|
|
1114
1138
|
```
|
|
1115
1139
|
|
|
1116
|
-
| `code` | HTTP | Meaning
|
|
1117
|
-
| -------------- | ------- |
|
|
1118
|
-
| `INVALID_URN` | 400 | Malformed match URN.
|
|
1119
|
-
| `NOT_FOUND` | 404 | Unknown match **or** not entitled (indistinguishable by design).
|
|
1120
|
-
| `
|
|
1121
|
-
| `
|
|
1122
|
-
| `
|
|
1123
|
-
| `
|
|
1124
|
-
| `
|
|
1125
|
-
| `
|
|
1126
|
-
| `
|
|
1127
|
-
| `
|
|
1128
|
-
| `
|
|
1129
|
-
| `
|
|
1130
|
-
| `
|
|
1140
|
+
| `code` | HTTP | Meaning | Retryable |
|
|
1141
|
+
| -------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- |
|
|
1142
|
+
| `INVALID_URN` | 400 | Malformed match URN. | No |
|
|
1143
|
+
| `NOT_FOUND` | 404 | Unknown match **or** not entitled (indistinguishable by design). | No |
|
|
1144
|
+
| `GEO_DENIED` | 451 | The match is booked for your key, but a grant's country list excludes the viewer's country; `country` names it. A server without the 451 answers `404 NOT_FOUND` for the same case. | No |
|
|
1145
|
+
| `GONE` | 410 | Ended and past the catch‑up window. | No |
|
|
1146
|
+
| `TOO_EARLY` | 425 | Upcoming; not live yet. | Yes (via `waitForLive`) |
|
|
1147
|
+
| `UNAVAILABLE` | 503 | Should be live; origin warming up. | Yes |
|
|
1148
|
+
| `UNAUTHORIZED` | 401 | Bad/absent api‑key. | No |
|
|
1149
|
+
| `FORBIDDEN` | 403 | Origin not allowed, or DRM signature rejected (a real server 403). | No |
|
|
1150
|
+
| `DRM_CLIENT` | (lic.)² | Client‑side DRM failure: no key system usable on this platform, a CDM/EME error, or license delivery failed without a server denial. | Sometimes² |
|
|
1151
|
+
| `RATE_LIMITED` | 429 | Per‑client request limit. | Back off |
|
|
1152
|
+
| `INTERNAL` | 5xx | Unexpected server/SDK error. | No |
|
|
1153
|
+
| `NETWORK` | 0 | The network request failed (offline, CORS, DNS). | Yes (in the live‑poll loop) |
|
|
1154
|
+
| `TIMEOUT` | (last)¹ | `waitForLive` exceeded `timeoutMs`. | No |
|
|
1155
|
+
| `ABORTED` | 0 | Cancelled via `AbortSignal`. | No |
|
|
1131
1156
|
|
|
1132
1157
|
¹ `TIMEOUT.httpStatus` carries the status of the last error it was waiting on, typically
|
|
1133
1158
|
`425` while waiting for an upcoming match, `503` while an origin warms up, or `0` if the
|
|
@@ -1162,6 +1187,7 @@ try {
|
|
|
1162
1187
|
await resolveStream({ baseUrl, matchUrn, credential });
|
|
1163
1188
|
} catch (e) {
|
|
1164
1189
|
if (e instanceof PlaybackError && e.code === 'NOT_FOUND') showUnavailable();
|
|
1190
|
+
if (e instanceof PlaybackError && e.code === 'GEO_DENIED') showBlockedIn(e.country); // e.g. "RU"
|
|
1165
1191
|
}
|
|
1166
1192
|
```
|
|
1167
1193
|
|
|
@@ -1444,18 +1470,19 @@ whose `baseUrl` you serve.
|
|
|
1444
1470
|
|
|
1445
1471
|
## Troubleshooting
|
|
1446
1472
|
|
|
1447
|
-
| Symptom | Likely cause
|
|
1448
|
-
| ---------------------------------------------------------- |
|
|
1449
|
-
| `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).
|
|
1450
|
-
| `UNAUTHORIZED` (401) | Wrong/expired api‑key, or wrong `baseUrl` environment (test vs prod key).
|
|
1451
|
-
| `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.
|
|
1452
|
-
| `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.
|
|
1453
|
-
| `NOT_FOUND` (404) for a match you can see | The key isn't entitled to that tournament (404 is intentionally indistinguishable from "unknown").
|
|
1454
|
-
|
|
|
1455
|
-
|
|
|
1456
|
-
|
|
|
1457
|
-
| Black video
|
|
1458
|
-
| Black video
|
|
1473
|
+
| Symptom | Likely cause |
|
|
1474
|
+
| ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1475
|
+
| `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). |
|
|
1476
|
+
| `UNAUTHORIZED` (401) | Wrong/expired api‑key, or wrong `baseUrl` environment (test vs prod key). |
|
|
1477
|
+
| `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. |
|
|
1478
|
+
| `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. |
|
|
1479
|
+
| `NOT_FOUND` (404) for a match you can see | The key isn't entitled to that tournament (404 is intentionally indistinguishable from "unknown"). |
|
|
1480
|
+
| `GEO_DENIED` (451) for a match you can see | The key's grant for that match carries a country list that excludes the viewer's country (`e.country`). The viewer sees a card naming the country; the listings never show such a match to that viewer, so this only happens on a direct URN. |
|
|
1481
|
+
| Player stays in `waiting` forever | The match hasn't gone live yet; the SDK is polling. Set `waitForLive.timeoutMs` to bound it. |
|
|
1482
|
+
| Autoplay doesn't start | Browser sound policy: set `muted: true` for autoplay, or start playback from a user gesture. |
|
|
1483
|
+
| 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. |
|
|
1484
|
+
| 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. |
|
|
1485
|
+
| Black video, `levelHeight`/`droppedFrames` stuck | Check the browser console for an EME/CDM error and verify DRM CORS onboarding. |
|
|
1459
1486
|
|
|
1460
1487
|
Include `PlaybackError.requestId` (when present) when contacting support; it correlates
|
|
1461
1488
|
to server‑side logs.
|
package/embed/index.html
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
beacons endpoint can be derived and QoE analytics is silently off.
|
|
29
29
|
`analytics` is honoured from the injected config only, never the query.
|
|
30
30
|
-->
|
|
31
|
-
<script src="https://cdn.jsdelivr.net/npm/@oddin-gg/havik-player@2.
|
|
31
|
+
<script src="https://cdn.jsdelivr.net/npm/@oddin-gg/havik-player@2.20.0/dist/havik-player.global.js" integrity="sha384-PPVgkwJKWlmuj52+pDqQWp+UmqBJk608YuskZya+/ESHrelRqOEfgzcXn3glsZv4" crossorigin="anonymous"></script>
|
|
32
32
|
<script>
|
|
33
33
|
HavikPlayer.bootEmbed();
|
|
34
34
|
</script>
|
package/examples/vanilla.html
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
shipped in each npm release is pinned to that release with Subresource
|
|
13
13
|
Integrity, injected at publish time (scripts/inject-sri.mjs). In
|
|
14
14
|
production, always use a pinned + SRI'd tag like the released copy. -->
|
|
15
|
-
<script src="https://cdn.jsdelivr.net/npm/@oddin-gg/havik-player@2.
|
|
15
|
+
<script src="https://cdn.jsdelivr.net/npm/@oddin-gg/havik-player@2.20.0/dist/havik-player.global.js" integrity="sha384-PPVgkwJKWlmuj52+pDqQWp+UmqBJk608YuskZya+/ESHrelRqOEfgzcXn3glsZv4" crossorigin="anonymous"></script>
|
|
16
16
|
<script>
|
|
17
17
|
(async () => {
|
|
18
18
|
const player = await HavikPlayer.createPlayer({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oddin-gg/havik-player",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.20.0",
|
|
4
4
|
"description": "Havik HTML5 player SDK — LL-HLS + DRM playback for Oddin live streams (managed, bring-your-own-player, and iframe-embed modes).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|