@oddin-gg/havik-player 2.22.1 → 2.23.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
@@ -220,42 +220,44 @@ 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
- | `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. |
252
- | `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). |
253
- | `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. |
254
- | `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. |
255
- | `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. 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. |
256
- | `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`. |
257
- | `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). |
258
- | `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
+ | `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
+ | `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. 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. |
258
+ | `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`. |
259
+ | `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). |
260
+ | `eventsBaseUrl` | `string` | derived | Override the SSE endpoint base. Default: `baseUrl` with the host prefixed by `events.` (e.g. `feed.<d>` → `events.<d>`). |
259
261
 
260
262
  ### Low-latency & adaptive bitrate
261
263
 
@@ -368,6 +370,33 @@ is derived above it.
368
370
  back to 1.0×. One‑way for the session; `targetLatencySeconds` reflects the
369
371
  move and `liveRetargets` counts them. A rate the host sets itself is ignored.
370
372
 
373
+ - **Far‑from‑origin sessions get more buffer, nobody else does.** The origin is
374
+ in Frankfurt and the edges are local, so a viewer in South America, Canada/
375
+ Mexico or APAC pays a 250–310 ms cross‑continent fetch (p99 ~580 ms) for
376
+ every part their edge misses, against ~44 ms in Europe; with 0.5 s parts the
377
+ fetch cycle outruns the cadence, the 2 s buffer drains, and 37–50% of those
378
+ sessions stalled at least once (3–13% rebuffer, against under 1% in the US
379
+ and Europe, measured 2026‑10‑30). Longer parts or WebRTC would cost every
380
+ viewer latency, so instead the player watches **its own part fetches**
381
+ (`adaptiveHoldBack`, on by default): the first‑byte time of each part the
382
+ playlist had already announced, as a share of the part's duration, over a
383
+ 10 s window. A window whose p95 is at or above **60%** (300 ms on a 0.5 s
384
+ part) **and** whose median is at or above **40%** (200 ms), two 3 s ticks
385
+ running, steps the target up **0.5 s**, up to **3.5 s**
386
+ (`adaptiveHoldBackCeiling`): a session that stalls drifts to ~6 s anyway, so a
387
+ steady 3.5 s is the better trade for a latency‑sensitive viewer. A p95 under
388
+ 40% for 60 s steps it back down, one step per minute, never below
389
+ `liveLatencyTarget`. A session whose fetches are fast never moves; neither
390
+ does one whose link is merely slow (transfer time is ABR's problem, so only
391
+ the first byte counts), nor on a preload‑hint request (it blocks until the
392
+ part exists, so its timing is production, not the path). A target the futile
393
+ catch‑up watchdog raised, or hls.js raised after a stall, is never lowered.
394
+ A browser reporting `navigator.connection.rtt` ≥ 300 ms starts one step up
395
+ as soon as the stream is seen live, without waiting for evidence. Every move fires
396
+ [`latencytargetchange`](#player-events) with its reason, counts in
397
+ `holdBackSteps`, and shows in `targetLatencySeconds` (and the QoE heartbeat's
398
+ target latency). `adaptiveHoldBack: false` turns it off.
399
+
371
400
  > **Migration note:** the default `liveLatencyTarget` **dropped from 3 s to 2 s**.
372
401
  > Nothing to change: the drift ceiling stays 12 s and the ABR reaction budgets
373
402
  > are derived from the target, so they tighten with it automatically. Expect
@@ -429,23 +458,24 @@ Notes:
429
458
 
430
459
  `on(event, cb)`: the payload type depends on the event ([`PlayerEventMap`](#playereventmap)):
431
460
 
432
- | Event | Payload | Fires when |
433
- | ------------------ | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
434
- | `ready` | `void` | The manifest is parsed and the player is attached. |
435
- | `waiting` | [`WaitState`](#waitstate) | Armed and waiting for go‑live. On the polling path it fires per poll with the countdown; on the SSE arming path it fires once, just after `createPlayer` resolves (with `ttkMs` when the server sent a kickoff time). |
436
- | `playing` | `void` | Playback is progressing. |
437
- | `buffering` | `void` | The player is rebuffering. |
438
- | `paused` | `void` | Playback is paused. |
439
- | `ended` | `EndedReason \| undefined` | The live stream ended; playback stops and the last frame is kept. The payload says **why**: `'interrupted'` (the match is still running, and its stream dropped, so show "reconnecting", **never** "ended"; the player rejoins by itself) or `'match_ended'` (a real end). `undefined` = the end came from the staleness watchdog (either engine: a live playlist that stopped advancing for 12 s), or a server predating the field; treat it as a real end. Fires **again** with `'match_ended'` if an interruption later turns out to be the end, so don't latch on the first one. Also readable any time as `player.endedReason`. |
440
- | `error` | [`PlaybackError`](#playbackerror) | A fatal error occurred (playback stopped). |
441
- | `warning` | [`PlaybackError`](#playbackerror) | A non-fatal condition (e.g. FairPlay passthrough, a failed license refresh, **QoE beacons disabled because the endpoint could not be derived** — see [`analytics`](#createplayeroptions) — or **QoE beacons refused by the page's Content-Security-Policy**, naming the origin to add to `connect-src`). Playback continues. |
442
- | `autoplayblocked` | `void` | The browser blocked autoplay (`NotAllowedError`). Show a tap-to-play / tap-to-unmute affordance, then call `player.play()` from the gesture. |
443
- | `qualitychange` | `number` | Active rendition changed (level index, `-1` = auto). |
444
- | `audiotrackchange` | `number` | Active audio track changed (track id). |
445
- | `texttrackchange` | `number` | Active text track changed (track id, `-1` = off). |
446
- | `pipchange` | `boolean` | Picture-in-Picture entered (`true`) or exited (`false`). |
447
- | `stats` | [`PlaybackStats`](#playbackstats) | Periodic stats sample (`statsIntervalMs`). |
448
- | `statechange` | [`PlayerState`](#playerstate) | Any state transition. |
461
+ | Event | Payload | Fires when |
462
+ | --------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
463
+ | `ready` | `void` | The manifest is parsed and the player is attached. |
464
+ | `waiting` | [`WaitState`](#waitstate) | Armed and waiting for go‑live. On the polling path it fires per poll with the countdown; on the SSE arming path it fires once, just after `createPlayer` resolves (with `ttkMs` when the server sent a kickoff time). |
465
+ | `playing` | `void` | Playback is progressing. |
466
+ | `buffering` | `void` | The player is rebuffering. |
467
+ | `paused` | `void` | Playback is paused. |
468
+ | `ended` | `EndedReason \| undefined` | The live stream ended; playback stops and the last frame is kept. The payload says **why**: `'interrupted'` (the match is still running, and its stream dropped, so show "reconnecting", **never** "ended"; the player rejoins by itself) or `'match_ended'` (a real end). `undefined` = the end came from the staleness watchdog (either engine: a live playlist that stopped advancing for 12 s), or a server predating the field; treat it as a real end. Fires **again** with `'match_ended'` if an interruption later turns out to be the end, so don't latch on the first one. Also readable any time as `player.endedReason`. |
469
+ | `error` | [`PlaybackError`](#playbackerror) | A fatal error occurred (playback stopped). |
470
+ | `warning` | [`PlaybackError`](#playbackerror) | A non-fatal condition (e.g. FairPlay passthrough, a failed license refresh, **QoE beacons disabled because the endpoint could not be derived** — see [`analytics`](#createplayeroptions) — or **QoE beacons refused by the page's Content-Security-Policy**, naming the origin to add to `connect-src`). Playback continues. |
471
+ | `autoplayblocked` | `void` | The browser blocked autoplay (`NotAllowedError`). Show a tap-to-play / tap-to-unmute affordance, then call `player.play()` from the gesture. |
472
+ | `qualitychange` | `number` | Active rendition changed (level index, `-1` = auto). |
473
+ | `audiotrackchange` | `number` | Active audio track changed (track id). |
474
+ | `texttrackchange` | `number` | Active text track changed (track id, `-1` = off). |
475
+ | `pipchange` | `boolean` | Picture-in-Picture entered (`true`) or exited (`false`). |
476
+ | `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`. |
477
+ | `stats` | [`PlaybackStats`](#playbackstats) | Periodic stats sample (`statsIntervalMs`). |
478
+ | `statechange` | [`PlayerState`](#playerstate) | Any state transition. |
449
479
 
450
480
  <a id="controls--theming"></a>
451
481
 
@@ -1364,12 +1394,25 @@ interface PlaybackStats {
1364
1394
  isLive?: boolean; // current media playlist is live
1365
1395
  atLiveEdge?: boolean; // playback is at/near the live edge
1366
1396
  liveRetargets?: number; // times a futile catch-up moved the live target (hls.js engine)
1397
+ holdBackSteps?: number; // times the adaptive hold-back moved the live target, up or down (hls.js engine)
1367
1398
  fpsDownCaps?: number; // times quality was capped because the device dropped frames (hls.js engine)
1368
1399
  maxLevelHeight?: number; // highest rendition ABR may pick right now; absent when uncapped (hls.js engine)
1369
1400
  capReason?: 'size' | 'bitrate' | 'fps'; // which SDK cap set maxLevelHeight: player pixel box, maxBitrate, frame drops
1370
1401
  }
1371
1402
  ```
1372
1403
 
1404
+ #### `LiveTargetChange`
1405
+
1406
+ Payload of [`latencytargetchange`](#player-events).
1407
+
1408
+ ```ts
1409
+ interface LiveTargetChange {
1410
+ targetSeconds: number; // the target hls.js now converges to
1411
+ previousSeconds: number; // the one it replaced
1412
+ reason: 'slow-fetches' | 'fast-fetches' | 'connection-rtt' | 'futile-chase';
1413
+ }
1414
+ ```
1415
+
1373
1416
  #### `QualityLevel`, `AudioTrackInfo`, `TextTrackInfo`
1374
1417
 
1375
1418
  ```ts
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.22.1/dist/havik-player.global.js" integrity="sha384-O1vQDOlf0kbqRybKaeTfXlJ6leLt9fSwx+6WMhXVc6Z6QV3QV1e8fvR+XbGbVsJx" crossorigin="anonymous"></script>
31
+ <script src="https://cdn.jsdelivr.net/npm/@oddin-gg/havik-player@2.23.1/dist/havik-player.global.js" integrity="sha384-C/0lAOR8miXHWFG0wUIODjKXTkq16+/6u72CoFkde0zsuQLUKqf2zVXotI7O7+Je" 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.22.1/dist/havik-player.global.js" integrity="sha384-O1vQDOlf0kbqRybKaeTfXlJ6leLt9fSwx+6WMhXVc6Z6QV3QV1e8fvR+XbGbVsJx" crossorigin="anonymous"></script>
15
+ <script src="https://cdn.jsdelivr.net/npm/@oddin-gg/havik-player@2.23.1/dist/havik-player.global.js" integrity="sha384-C/0lAOR8miXHWFG0wUIODjKXTkq16+/6u72CoFkde0zsuQLUKqf2zVXotI7O7+Je" 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.22.1",
3
+ "version": "2.23.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,
@@ -85,7 +85,7 @@
85
85
  "blackhole:serve": "npm run build:global && node blackhole/server.mjs"
86
86
  },
87
87
  "dependencies": {
88
- "hls.js": "1.7.2"
88
+ "hls.js": "1.7.4"
89
89
  },
90
90
  "devDependencies": {
91
91
  "@eslint/js": "^10.0.1",