@oddin-gg/havik-player 2.24.0 → 2.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/API.md CHANGED
@@ -19,7 +19,7 @@ This is the complete API reference. For a quick start, see the [README](../READM
19
19
  - [Browser support](#browser-support)
20
20
  - [Mode A: Managed player](#mode-a-managed-player)
21
21
  - [createPlayer](#createplayer)
22
- - [Player](#player) · [Player events](#playereventmap)
22
+ - [Player](#player) · [Player events](#playereventmap) · [Connectivity](#connectivity)
23
23
  - [Controls & theming](#controls--theming) · [HavikTheme](#haviktheme)
24
24
  - [Mode B: Bring your own player](#mode-b-bring-your-own-player)
25
25
  - [Mode C: Iframe embed](#mode-c-iframe-embed)
@@ -220,45 +220,46 @@ player.on('error', (e) => console.error(e.code, e.httpStatus, e.message));
220
220
 
221
221
  #### `CreatePlayerOptions`
222
222
 
223
- | Option | Type | Default | Description |
224
- | ------------------------- | --------------------------------------- | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
225
- | `video` | `HTMLVideoElement` | — | **Required.** The element the SDK will control. |
226
- | `env` | `'integration' \| 'production'` | — | **Required unless `baseUrl` is given.** Which Oddin environment to play against. Preferred: the host is correct by construction, and so is everything derived from it (the SSE events host, the QoE beacons host). |
227
- | `baseUrl` | `string` | — | **Required unless `env` is given.** API base URL, e.g. `https://feed-dev.oddin-video.gg`. Use for a deployment the named environments do not cover: your own CDN or gateway fronting the streams API, the CN plane, or a pinned build that must outlive a domain change. Passing both `env` and `baseUrl` — or neither — is a compile error in TypeScript and throws at runtime in JavaScript. |
228
- | `matchUrn` | `string` | — | **Required.** The match to play, e.g. `od:match:1234`. |
229
- | `credential` | [`CredentialSource`](#credentialsource) | — | **Required.** Your api‑key (or a function returning one). |
230
- | `autoplay` | `boolean` | `true` | Start playback once ready. With `false`, the player holds in a pre-play `'paused'` state (poster + play card, no spinner) and fetches no media and no DRM license until the first `play()`. On the hls.js engine the manifest is still parsed pre-play, so `'ready'` and the track lists keep working; the native (Safari) engine defers all fetching and emits `'ready'` at attach instead. Note: if a programmatic `play()` is policy-blocked (`autoplayblocked`), the loader keeps warming the buffer — media and DRM traffic flows from that kick, not from a viewer gesture. |
231
- | `muted` | `boolean` | `false` | Mute the element. Set `true` for reliable autoplay under browser sound policies. |
232
- | `controls` | `'custom' \| 'native' \| 'none'` | `'custom'` | Which controls to render. See [Controls & theming](#controls--theming). |
233
- | `theme` | [`Partial<HavikTheme>`](#haviktheme) | Oddin brand | Re-skin the custom control bar (ignored unless `controls: 'custom'`). See [Controls & theming](#controls--theming). |
234
- | `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'`. |
235
- | `cspNonce` | `string` | — | Your page's CSP nonce, stamped on the `<style>` tag the custom control bar injects. Needed only when `style-src` has no `'unsafe-inline'`: without it the browser blocks the tag and the controls render unstyled. See [Content-Security-Policy](#content-security-policy). Ignored unless `controls: 'custom'`. |
236
- | `endedMessage` | `string` | "This stream has ended" | Message on the end-of-stream card. |
237
- | `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. |
238
- | `errorMessage` | `string` | the error's message | Message on the fatal-error card (otherwise the error's own message). |
239
- | `waitForLive` | [`WaitForLive`](#waitforlive) | — | Arm on an upcoming match and auto‑attach at go‑live. |
240
- | `lowLatency` | `'auto' \| boolean` | `'auto'` | `'auto'` lets the player decide from the manifest. |
241
- | `engine` | `'auto' \| 'hls' \| 'native'` | `'auto'` | `'auto'` uses the browser's native player on iPhone and iPad for every stream, and for FairPlay on any Apple browser; elsewhere it prefers hls.js (desktop Safari included) and falls back to native HLS. hls.js on iOS runs on ManagedMediaSource, which closed mid‑session and played slower than real time in testing. |
242
- | `userId` | `string` | — | Optional viewer id (sent as `X-User-Id` on license requests). |
243
- | `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. |
244
- | `hlsConfig` | `Partial<HlsConfig>` | — | Escape hatch for advanced hls.js tuning. |
245
- | `statsIntervalMs` | `number` | `2000` | How often `'stats'` is emitted. |
246
- | `licenseRefreshMs` | `number` | `480000` (8 min) | How often to silently re-resolve playback so the signed DRM license URL the player would use next stays inside its signature lifetime (~10 min). Refreshes the URL, not the license: it cannot extend a license the CDM already holds, so a session that outlasts its license still ends with a fatal [`DRM_CLIENT`](#errors) error. `0` disables the timer only: a lapsed URL is still refreshed on refocus and before a license request (Widevine on hls.js; the native FairPlay engine has neither check). |
247
- | `poster` | `string` | — | Poster image shown before playback starts. |
248
- | `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). |
249
- | `maxBitrate` | `number` | — | Cap ABR to renditions at/below this bitrate (bps). |
250
- | `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.1×, so this is the only live‑latency knob you need. Lower = closer to live but more rebuffer‑prone. Live/LL only. |
251
- | `adaptiveHoldBack` | `boolean` | `true` | hls.js engine: raise the live‑sync target for a session whose part fetches are slow, instead of letting it stall. A viewer far from the origin plays from a local edge whose every cache miss on a 0.5 s part is a cross‑continent fetch (250–310 ms per part from South America, Canada/Mexico and APAC on the global slot, against 44 ms in Europe), so the fetch cycle outruns the part cadence and the 2 s buffer drains. The session's own part fetches are timed (first byte, as a share of the part's duration, over a 10 s window): a window whose p95 is at or above 60% **and** whose median is at or above 40%, two 3 s ticks running, steps the target up 0.5 s, to `adaptiveHoldBackCeiling` at most; a p95 under 40% for 60 s steps it back down. A session whose fetches are fast never moves. Each move fires [`latencytargetchange`](#player-events), counts in `holdBackSteps`, and shows in `targetLatencySeconds`. Live/LL only. |
252
- | `adaptiveHoldBackCeiling` | `number` | `3.5` | Highest live‑sync target the adaptive hold‑back may step to, in seconds. Kept a second under the drift ceiling; at or below `liveLatencyTarget` it disables the stepping. |
253
- | `snapToLiveOnRefocus` | `boolean` | `true` | Snap to the live edge when a backgrounded tab is refocused: on hls.js while far behind; on the native player (iPhone, iPad, FairPlay) on every return after 2 s or more away and on every back/forward‑cache restore, 3 s behind the edge once the first playlist refresh after the return moves it (up to 6 s wait; the edge on return is still the one from before leaving), which also redraws a picture iOS left black. Live/LL only. |
254
- | `rememberBandwidth` | `boolean` | `true` | hls.js engine: start from the bandwidth this device measured in a recent session (`localStorage` key `havik.player.bandwidth`, per connection type, up to 3 days old, reused at 80%) instead of the conservative ~1 Mbps start; a slow record starts from that lower figure (0.3 Mbps floor). A first visit, a stale record or blocked storage keeps the conservative start; an explicit `hlsConfig.abrEwmaDefaultEstimate` or `startLevel` wins. Reset with [`clearRememberedBandwidth()`](#clearrememberedbandwidth). |
255
- | `sizeCapHeadroom` | `number` | unset | hls.js engine: rendition rungs ABR may pick above the one that merely covers the player's pixel box. Unset: `1` on displays below 2× `devicePixelRatio`, `0` at 2× and above. A 960 px box at 1x covers with 960×540; one rung of headroom plays 720p downscaled instead, which looks sharper for the same box; phones and 2× screens keep the cover-only cap (their covering rung already matches their device pixels; a rung above it mostly costs data). An explicit value applies on every display: `0` = strict cover-only cap, `Infinity` = no size cap. Never past the top rung; `maxBitrate` and the frame-drop cap still apply. `hlsConfig.capLevelToPlayerSize: true` caps to the covering rung with no headroom. |
256
- | `mediaDuringKey` | `boolean` | `true` | DRM cold start: download the first init segment and parts while the first key is still being acquired, instead of after it. Bytes reach hls.js only when it asks for them; only the network time moves off the critical path (~0.5 s on Widevine LL-HLS). Ignored on clear streams and when `hlsConfig.fLoader` is set. Pass `false` to opt out. |
257
- | `preloadHints` | `boolean` | `false` | Live LL-HLS, hls.js engine: fetch the part each playlist's `EXT-X-PRELOAD-HINT` names as soon as the playlist arrives, instead of after the next playlist lists it. The origin and CDNs hold that request until the part exists, so a viewer far from the origin saves about one round trip per part. Bytes reach hls.js only when it asks for the part; an unrequested hint is dropped after 4 s or on a rendition switch. Off while paused. Ignored when `hlsConfig.fLoader` is set. Opt-in while it is validated. |
258
- | `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; a 410 (match gone) stops the subscription and a 404 (not in the catalog) slows it to one retry every 5 min, each with one `warning`. On a return from the background (5 s or more away, or a back/forward-cache restore) the subscription is reopened so the server replays the current state: a page suspended through the end of a match does not sit buffering on a socket that will never deliver it. Set `false` to disable. |
259
- | `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`. |
260
- | `analytics` | `boolean \| { endpoint?: string }` | `true` | QoE beacons. Active only when the playback response carries a server-minted `sid`. **havik-streams v0.10.0+ names the beacons host in that response, and it is used whatever `baseUrl` is**; it also wins over `{ endpoint }` (a differing value is ignored with one `console.warn`). Against an older server the host is `{ endpoint }` if given, else derived from `baseUrl` by hostname convention (`feed[-dev].<domain>` → `beacons[-dev].<domain>`); there, **if `baseUrl` is not a `feed` host nothing derives and beacons are disabled** — the SDK then logs one `console.warn` naming the host and emits a non-fatal [`warning`](#player-events) event carrying the same sentence. Recovery: pass `{ endpoint: 'https://beacons[-dev].<domain>' }`. Pass `false` to opt out deliberately (no warning). |
261
- | `eventsBaseUrl` | `string` | derived | Override the SSE endpoint base. Default: `baseUrl` with the host prefixed by `events.` (e.g. `feed.<d>` → `events.<d>`). |
223
+ | Option | Type | Default | Description |
224
+ | ------------------------- | --------------------------------------- | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
225
+ | `video` | `HTMLVideoElement` | — | **Required.** The element the SDK will control. |
226
+ | `env` | `'integration' \| 'production'` | — | **Required unless `baseUrl` is given.** Which Oddin environment to play against. Preferred: the host is correct by construction, and so is everything derived from it (the SSE events host, the QoE beacons host). |
227
+ | `baseUrl` | `string` | — | **Required unless `env` is given.** API base URL, e.g. `https://feed-dev.oddin-video.gg`. Use for a deployment the named environments do not cover: your own CDN or gateway fronting the streams API, the CN plane, or a pinned build that must outlive a domain change. Passing both `env` and `baseUrl` — or neither — is a compile error in TypeScript and throws at runtime in JavaScript. |
228
+ | `matchUrn` | `string` | — | **Required.** The match to play, e.g. `od:match:1234`. |
229
+ | `credential` | [`CredentialSource`](#credentialsource) | — | **Required.** Your api‑key (or a function returning one). |
230
+ | `autoplay` | `boolean` | `true` | Start playback once ready. With `false`, the player holds in a pre-play `'paused'` state (poster + play card, no spinner) and fetches no media and no DRM license until the first `play()`. On the hls.js engine the manifest is still parsed pre-play, so `'ready'` and the track lists keep working; the native (Safari) engine defers all fetching and emits `'ready'` at attach instead. Note: if a programmatic `play()` is policy-blocked (`autoplayblocked`), the loader keeps warming the buffer — media and DRM traffic flows from that kick, not from a viewer gesture. |
231
+ | `muted` | `boolean` | `false` | Mute the element. Set `true` for reliable autoplay under browser sound policies. |
232
+ | `controls` | `'custom' \| 'native' \| 'none'` | `'custom'` | Which controls to render. See [Controls & theming](#controls--theming). |
233
+ | `theme` | [`Partial<HavikTheme>`](#haviktheme) | Oddin brand | Re-skin the custom control bar (ignored unless `controls: 'custom'`). See [Controls & theming](#controls--theming). |
234
+ | `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'`. |
235
+ | `cspNonce` | `string` | — | Your page's CSP nonce, stamped on the `<style>` tag the custom control bar injects. Needed only when `style-src` has no `'unsafe-inline'`: without it the browser blocks the tag and the controls render unstyled. See [Content-Security-Policy](#content-security-policy). Ignored unless `controls: 'custom'`. |
236
+ | `endedMessage` | `string` | "This stream has ended" | Message on the end-of-stream card. |
237
+ | `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. |
238
+ | `errorMessage` | `string` | the error's message | Message on the fatal-error card (otherwise the error's own message). |
239
+ | `offlineMessage` | `string` | "No internet connection — waiting to reconnect" | Message on the card shown while the browser reports no network (`navigator.onLine === false`). It replaces the error and reconnecting cards for as long as the browser says offline and carries no Retry button: nothing can succeed without a route, and the player resumes by itself on the browser's `online` event. See [Connectivity](#connectivity). |
240
+ | `waitForLive` | [`WaitForLive`](#waitforlive) | — | Arm on an upcoming match and auto‑attach at go‑live. |
241
+ | `lowLatency` | `'auto' \| boolean` | `'auto'` | `'auto'` lets the player decide from the manifest. |
242
+ | `engine` | `'auto' \| 'hls' \| 'native'` | `'auto'` | `'auto'` uses the browser's native player on iPhone and iPad for every stream, and for FairPlay on any Apple browser; elsewhere it prefers hls.js (desktop Safari included) and falls back to native HLS. hls.js on iOS runs on ManagedMediaSource, which closed mid‑session and played slower than real time in testing. |
243
+ | `userId` | `string` | — | Optional viewer id (sent as `X-User-Id` on license requests). |
244
+ | `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. |
245
+ | `hlsConfig` | `Partial<HlsConfig>` | — | Escape hatch for advanced hls.js tuning. |
246
+ | `statsIntervalMs` | `number` | `2000` | How often `'stats'` is emitted. |
247
+ | `licenseRefreshMs` | `number` | `480000` (8 min) | How often to silently re-resolve playback so the signed DRM license URL the player would use next stays inside its signature lifetime (~10 min). Refreshes the URL, not the license: it cannot extend a license the CDM already holds, so a session that outlasts its license still ends with a fatal [`DRM_CLIENT`](#errors) error. `0` disables the timer only: a lapsed URL is still refreshed on refocus and before a license request (Widevine on hls.js; the native FairPlay engine has neither check). |
248
+ | `poster` | `string` | — | Poster image shown before playback starts. |
249
+ | `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). |
250
+ | `maxBitrate` | `number` | — | Cap ABR to renditions at/below this bitrate (bps). |
251
+ | `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.1×, so this is the only live‑latency knob you need. Lower = closer to live but more rebuffer‑prone. Live/LL only. |
252
+ | `adaptiveHoldBack` | `boolean` | `true` | hls.js engine: raise the live‑sync target for a session whose part fetches are slow, instead of letting it stall. A viewer far from the origin plays from a local edge whose every cache miss on a 0.5 s part is a cross‑continent fetch (250–310 ms per part from South America, Canada/Mexico and APAC on the global slot, against 44 ms in Europe), so the fetch cycle outruns the part cadence and the 2 s buffer drains. The session's own part fetches are timed (first byte, as a share of the part's duration, over a 10 s window): a window whose p95 is at or above 60% **and** whose median is at or above 40%, two 3 s ticks running, steps the target up 0.5 s, to `adaptiveHoldBackCeiling` at most; a p95 under 40% for 60 s steps it back down. A session whose fetches are fast never moves. Each move fires [`latencytargetchange`](#player-events), counts in `holdBackSteps`, and shows in `targetLatencySeconds`. Live/LL only. |
253
+ | `adaptiveHoldBackCeiling` | `number` | `3.5` | Highest live‑sync target the adaptive hold‑back may step to, in seconds. Kept a second under the drift ceiling; at or below `liveLatencyTarget` it disables the stepping. |
254
+ | `snapToLiveOnRefocus` | `boolean` | `true` | Snap to the live edge when a backgrounded tab is refocused: on hls.js while far behind; on the native player (iPhone, iPad, FairPlay) on every return after 2 s or more away and on every back/forward‑cache restore, 3 s behind the edge once the first playlist refresh after the return moves it (up to 6 s wait; the edge on return is still the one from before leaving), which also redraws a picture iOS left black. Live/LL only. |
255
+ | `rememberBandwidth` | `boolean` | `true` | hls.js engine: start from the bandwidth this device measured in a recent session (`localStorage` key `havik.player.bandwidth`, per connection type, up to 3 days old, reused at 80%) instead of the conservative ~1 Mbps start; a slow record starts from that lower figure (0.3 Mbps floor). A first visit, a stale record or blocked storage keeps the conservative start; an explicit `hlsConfig.abrEwmaDefaultEstimate` or `startLevel` wins. Reset with [`clearRememberedBandwidth()`](#clearrememberedbandwidth). |
256
+ | `sizeCapHeadroom` | `number` | unset | hls.js engine: rendition rungs ABR may pick above the one that merely covers the player's pixel box. Unset: `1` on displays below 2× `devicePixelRatio`, `0` at 2× and above. A 960 px box at 1x covers with 960×540; one rung of headroom plays 720p downscaled instead, which looks sharper for the same box; phones and 2× screens keep the cover-only cap (their covering rung already matches their device pixels; a rung above it mostly costs data). An explicit value applies on every display: `0` = strict cover-only cap, `Infinity` = no size cap. Never past the top rung; `maxBitrate` and the frame-drop cap still apply. `hlsConfig.capLevelToPlayerSize: true` caps to the covering rung with no headroom. |
257
+ | `mediaDuringKey` | `boolean` | `true` | DRM cold start: download the first init segment and parts while the first key is still being acquired, instead of after it. Bytes reach hls.js only when it asks for them; only the network time moves off the critical path (~0.5 s on Widevine LL-HLS). Ignored on clear streams and when `hlsConfig.fLoader` is set. Pass `false` to opt out. |
258
+ | `preloadHints` | `boolean` | `false` | Live LL-HLS, hls.js engine: fetch the part each playlist's `EXT-X-PRELOAD-HINT` names as soon as the playlist arrives, instead of after the next playlist lists it. The origin and CDNs hold that request until the part exists, so a viewer far from the origin saves about one round trip per part. Bytes reach hls.js only when it asks for the part; an unrequested hint is dropped after 4 s or on a rendition switch. Off while paused. Ignored when `hlsConfig.fLoader` is set. Opt-in while it is validated. |
259
+ | `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; a 410 (match gone) stops the subscription and a 404 (not in the catalog) slows it to one retry every 5 min, each with one `warning`. On a return from the background (5 s or more away, or a back/forward-cache restore) the subscription is reopened so the server replays the current state: a page suspended through the end of a match does not sit buffering on a socket that will never deliver it. Set `false` to disable. |
260
+ | `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`. |
261
+ | `analytics` | `boolean \| { endpoint?: string }` | `true` | QoE beacons. Active only when the playback response carries a server-minted `sid`. **havik-streams v0.10.0+ names the beacons host in that response, and it is used whatever `baseUrl` is**; it also wins over `{ endpoint }` (a differing value is ignored with one `console.warn`). Against an older server the host is `{ endpoint }` if given, else derived from `baseUrl` by hostname convention (`feed[-dev].<domain>` → `beacons[-dev].<domain>`); there, **if `baseUrl` is not a `feed` host nothing derives and beacons are disabled** — the SDK then logs one `console.warn` naming the host and emits a non-fatal [`warning`](#player-events) event carrying the same sentence. Recovery: pass `{ endpoint: 'https://beacons[-dev].<domain>' }`. Pass `false` to opt out deliberately (no warning). |
262
+ | `eventsBaseUrl` | `string` | derived | Override the SSE endpoint base. Default: `baseUrl` with the host prefixed by `events.` (e.g. `feed.<d>` → `events.<d>`). |
262
263
 
263
264
  ### Low-latency & adaptive bitrate
264
265
 
@@ -415,6 +416,7 @@ is derived above it.
415
416
  interface Player {
416
417
  readonly state: PlayerState;
417
418
  readonly descriptor: StreamDescriptor | null;
419
+ readonly offline: boolean; // the browser reports no network (navigator.onLine === false); see Connectivity
418
420
  play(): Promise<void>;
419
421
  pause(): void;
420
422
  setMuted(muted: boolean): void;
@@ -477,6 +479,52 @@ Notes:
477
479
  | `latencytargetchange` | [`LiveTargetChange`](#livetargetchange) | The SDK moved the live‑sync target itself (hls.js engine): the adaptive hold‑back stepped it up for slow part fetches or down for fast ones, a high browser‑reported RTT started the session a step up, or a futile catch‑up parked it where playback is. Never for your own `liveLatencyTarget`. |
478
480
  | `stats` | [`PlaybackStats`](#playbackstats) | Periodic stats sample (`statsIntervalMs`). |
479
481
  | `statechange` | [`PlayerState`](#playerstate) | Any state transition. |
482
+ | `offline` | `void` | The browser reported it lost its network (the window's `offline` event). `state` is unchanged; `player.offline` is now `true`. See [Connectivity](#connectivity). |
483
+ | `online` | `void` | The browser reported its network is back (the window's `online` event). The player resumes on its own: a parked hls.js loader restarts at once, and a fatal error that happened while offline (or any `NETWORK` error) is retried, the same re-resolve as `retry()`. |
484
+
485
+ <a id="connectivity"></a>
486
+
487
+ ### Connectivity
488
+
489
+ The player acts on the browser's own network signal, `navigator.onLine` and the
490
+ window's `online`/`offline` events, on the **offline side only**: `false` is
491
+ reliable (wifi off, airplane mode, cable out), while `true` only means "not known
492
+ to be offline" (a captive portal or a dead upstream still reads as online), so a
493
+ failure while "online" keeps its ordinary retry path.
494
+
495
+ What happens on a network loss, by layer:
496
+
497
+ - **hls.js engine**: the browser's `offline` event engages a hold at once: the
498
+ end-of-stream and starvation watchdogs stand down (a playlist that cannot be
499
+ fetched is not a stream that ended), and a fatal network error with no HTTP
500
+ status parks the loader instead of spending the five-attempt retry budget.
501
+ The hold keys off the browser's signal because hls.js itself may never raise
502
+ a fatal error during an outage: measured in Chromium, it sits in its own
503
+ playlist-retry backoff for the whole of a 20 s loss. The `online` event
504
+ releases the hold, refunds the budget and restarts the loader right away,
505
+ with hls.js, the buffer and the DRM key sessions all still in place: measured
506
+ in Chromium against a local live stream, the offline card is up 5 ms after
507
+ the loss and playback is back 120 ms after the link returns, through one
508
+ resolve for the whole session. A failure that carries a status is the
509
+ origin's answer and is never parked.
510
+ - **Native engine (Safari)**: the element reports one opaque error for
511
+ everything; while the browser says offline it is typed `NETWORK`, and the
512
+ managed player re-attaches on `online`.
513
+ - **Managed player**: emits `offline`/`online`, exposes `player.offline`, and on
514
+ `online` retries a session that had already failed by then, whether the resolve
515
+ threw `NETWORK`, the native element errored, or the hls.js budget was spent
516
+ before the browser noticed. Terminal codes (`GONE`, `NOT_FOUND`,
517
+ `INVALID_URN`, `UNAUTHORIZED`, `GEO_DENIED`, `DRM_DEVICE_REVOKED`) are never
518
+ retried. The retry is the same re-resolve as `retry()`, so a failed one lands
519
+ on the error card as usual.
520
+ - **Control bar**: shows the `offlineMessage` card over the error and
521
+ reconnecting cards while `player.offline` is true, with no Retry button. The
522
+ ended card and the pre-play cards (the play button, the pre-live "Starting
523
+ soon…" wait) are never covered.
524
+
525
+ An hls.js network failure with no HTTP status (no route, DNS, CORS, a reset) is
526
+ reported as `NETWORK` (earlier releases reported `INTERNAL`). One that carries a
527
+ status is the origin's answer and keeps the status-derived code.
480
528
 
481
529
  <a id="controls--theming"></a>
482
530
 
@@ -497,7 +545,7 @@ booked for your key but not playable in the viewer's country) gets its own copy,
497
545
  saw, and no Retry button: a retry would be refused again. A `DRM_DEVICE_REVOKED` error
498
546
  (the license server refuses this device's DRM client) likewise gets "This device can't
499
547
  play protected video" and no Retry. The cards pick up `theme.logoUrl` and
500
- the theme colors; customise the copy with `endedMessage`/`interruptedMessage`/`errorMessage`
548
+ the theme colors; customise the copy with `endedMessage`/`interruptedMessage`/`errorMessage`/`offlineMessage`
501
549
  (an `errorMessage` replaces the `GEO_DENIED` and `DRM_DEVICE_REVOKED` copy too), or set
502
550
  `statusOverlays: false` to drive your own end/error UI from the player events.
503
551
  It auto-hides during playback, supports keyboard shortcuts (space/`k` play-pause,
@@ -1189,23 +1237,23 @@ class PlaybackError extends Error {
1189
1237
  }
1190
1238
  ```
1191
1239
 
1192
- | `code` | HTTP | Meaning | Retryable |
1193
- | -------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------- |
1194
- | `INVALID_URN` | 400 | Malformed match URN. | No |
1195
- | `NOT_FOUND` | 404 | Unknown match **or** not entitled (indistinguishable by design). | No |
1196
- | `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 |
1197
- | `GONE` | 410 | Ended and past the catch‑up window. | No |
1198
- | `TOO_EARLY` | 425 | Upcoming; not live yet. | Yes (via `waitForLive`) |
1199
- | `UNAVAILABLE` | 503 | Should be live; origin warming up. | Yes |
1200
- | `UNAUTHORIZED` | 401 | Bad/absent api‑key. | No |
1201
- | `FORBIDDEN` | 403 | Origin not allowed, DRM signature rejected, or a license refused for a reason the server did not name (`serverCode: 'license_refused'`); a real server 403. | No⁴ |
1202
- | `DRM_DEVICE_REVOKED` | 403 | The license server refuses this device's DRM client outright (a revoked Widevine certificate); the license 403 body carries `cdm_revoked`. The device is the problem, not the match, the key or the network. | No³ |
1203
- | `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² |
1204
- | `RATE_LIMITED` | 429 | Per‑client request limit. | Back off |
1205
- | `INTERNAL` | 5xx | Unexpected server/SDK error. | No |
1206
- | `NETWORK` | 0 | The network request failed (offline, CORS, DNS). | Yes (in the live‑poll loop) |
1207
- | `TIMEOUT` | (last)¹ | `waitForLive` exceeded `timeoutMs`. | No |
1208
- | `ABORTED` | 0 | Cancelled via `AbortSignal`. | No |
1240
+ | `code` | HTTP | Meaning | Retryable |
1241
+ | -------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------- |
1242
+ | `INVALID_URN` | 400 | Malformed match URN. | No |
1243
+ | `NOT_FOUND` | 404 | Unknown match **or** not entitled (indistinguishable by design). | No |
1244
+ | `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 |
1245
+ | `GONE` | 410 | Ended and past the catch‑up window. | No |
1246
+ | `TOO_EARLY` | 425 | Upcoming; not live yet. | Yes (via `waitForLive`) |
1247
+ | `UNAVAILABLE` | 503 | Should be live; origin warming up. | Yes |
1248
+ | `UNAUTHORIZED` | 401 | Bad/absent api‑key. | No |
1249
+ | `FORBIDDEN` | 403 | Origin not allowed, DRM signature rejected, or a license refused for a reason the server did not name (`serverCode: 'license_refused'`); a real server 403. | No⁴ |
1250
+ | `DRM_DEVICE_REVOKED` | 403 | The license server refuses this device's DRM client outright (a revoked Widevine certificate); the license 403 body carries `cdm_revoked`. The device is the problem, not the match, the key or the network. | No³ |
1251
+ | `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² |
1252
+ | `RATE_LIMITED` | 429 | Per‑client request limit. | Back off |
1253
+ | `INTERNAL` | 5xx | Unexpected server/SDK error. | No |
1254
+ | `NETWORK` | 0 | The network request failed (offline, CORS, DNS), including an hls.js media or playlist fetch with no HTTP status once the engine's retry budget is spent. Retried by the managed player on the browser's `online` event (see [Connectivity](#connectivity)). | Yes (in the live‑poll loop) |
1255
+ | `TIMEOUT` | (last)¹ | `waitForLive` exceeded `timeoutMs`. | No |
1256
+ | `ABORTED` | 0 | Cancelled via `AbortSignal`. | No |
1209
1257
 
1210
1258
  ¹ `TIMEOUT.httpStatus` carries the status of the last error it was waiting on, typically
1211
1259
  `425` while waiting for an upcoming match, `503` while an origin warms up, or `0` if the
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.24.0/dist/havik-player.global.js" integrity="sha384-Qunf/cw/th/tyNbXOcMnAqElELOsUh/gU/w4JHnNSjQkidr1Bj5ny/hHFQfaHcaH" crossorigin="anonymous"></script>
31
+ <script src="https://cdn.jsdelivr.net/npm/@oddin-gg/havik-player@2.25.1/dist/havik-player.global.js" integrity="sha384-yWIIiYBGAmMNuObEaCqXU3CpnniQZuR2ZsjYW/6rbXx6pC+nM9r5RyK4UaJuCDQM" crossorigin="anonymous"></script>
32
32
  <script>
33
33
  HavikPlayer.bootEmbed();
34
34
  </script>
@@ -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.24.0/dist/havik-player.global.js" integrity="sha384-Qunf/cw/th/tyNbXOcMnAqElELOsUh/gU/w4JHnNSjQkidr1Bj5ny/hHFQfaHcaH" crossorigin="anonymous"></script>
15
+ <script src="https://cdn.jsdelivr.net/npm/@oddin-gg/havik-player@2.25.1/dist/havik-player.global.js" integrity="sha384-yWIIiYBGAmMNuObEaCqXU3CpnniQZuR2ZsjYW/6rbXx6pC+nM9r5RyK4UaJuCDQM" 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.24.0",
3
+ "version": "2.25.1",
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,