@oddin-gg/havik-player 2.27.2 → 2.28.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/docs/API.md CHANGED
@@ -253,6 +253,7 @@ player.on('error', (e) => console.error(e.code, e.httpStatus, e.message));
253
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
254
  | `stallCap` | `boolean` | `true` | hls.js engine: after a playback stall (not in the first 10 s after playback starts or resumes, not while seeking, not with media still buffered ahead, not a wait at the live edge for a part not yet published), cap quality one rendition below the one that stalled, and give one rendition back per 3 minutes of playback without another stall (paused time does not count); a stall while capped steps down again or restarts the 3 minutes. Without it, ABR climbs straight back onto a rendition the connection just failed to carry: on measured production sessions, 55–65 % of rebuffer time was such a repeat within 3 minutes. Counted in `stallDownCaps`; `capReason` is `'stall'` while it is the binding ceiling. Never applies to a quality the viewer pinned; off when `hlsConfig.capLevelToPlayerSize` or `capLevelOnFPSDrop: true` hands capping to hls.js. |
255
255
  | `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. |
256
+ | `stuckReload` | `boolean` | `true` | Native engine (Safari, iPhone, iPad), live only: when the playhead has not moved and the buffered media under it has not grown for 8 s, with the page visible, the element playing and the browser online, the player reloads the stream at the live edge. WebKit's native HLS has a freeze it never leaves by itself: on production iPhone Safari over 8 days to 2026-11-04 there were 2.31 such freezes per watch-hour (hls.js on Android, on the same CDN slots and in the same countries: 0.35), not one recovered on its own, and viewers paused or left after a median 24 s. At most twice per 30 s of healthy playback; a slow start, a seek, a pause, a hidden tab and an offline browser never count. Each reload counts in `stuckReloads`. |
256
257
  | `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). |
257
258
  | `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`, **earned per session on LL-HLS** (its parts download within half a part duration even at that rung's bitrate, the bandwidth estimate is twice the rung and stays above 1.5×, and no stall in the last 2 minutes; a stall after the first 10 s of playback drops the rung at once; streams without parts get the rung as before), `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. |
258
259
  | `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. |
@@ -369,6 +370,17 @@ is derived above it.
369
370
  `hlsConfig.capLevelOnFPSDrop: false` turns frame‑drop capping off; `true` runs
370
371
  hls.js's own controller instead, whose cap a later player‑size or `maxBitrate`
371
372
  recompute can overwrite.
373
+ - **A frozen picture on Safari is reloaded.** The native engine (Safari,
374
+ iPhone, iPad) watches the playhead and the buffered media under it: when
375
+ neither has moved for 8 s while the page is visible, the element is playing
376
+ and the browser is online, the stream is reloaded at the live edge. WebKit's
377
+ native HLS has a freeze it never leaves by itself: the playhead stops, the
378
+ buffer stops growing, nothing decodes and no error is raised. On production
379
+ iPhone Safari (8 days to 2026-11-04) that happened 2.31 times per watch-hour,
380
+ none recovered, and viewers paused or left after a median 24 s. Twice per
381
+ 30 s of healthy playback; a slow start, a seek, a pause, a hidden tab and an
382
+ offline browser never count. Counted in `stuckReloads`; `stuckReload: false`
383
+ turns it off.
372
384
 
373
385
  - **Futile catch‑up is stopped.** hls.js speeds playback up (to 1.1×) whenever
374
386
  latency sits above the target, and only lowers its ambition after a counted
@@ -470,26 +482,26 @@ Notes:
470
482
 
471
483
  `on(event, cb)`: the payload type depends on the event ([`PlayerEventMap`](#playereventmap)):
472
484
 
473
- | Event | Payload | Fires when |
474
- | --------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
475
- | `ready` | `void` | The manifest is parsed and the player is attached. |
476
- | `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). |
477
- | `playing` | `void` | Playback is progressing. |
478
- | `buffering` | `void` | The player is rebuffering. |
479
- | `paused` | `void` | Playback is paused. |
480
- | `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`. |
481
- | `error` | [`PlaybackError`](#playbackerror) | A fatal error occurred (playback stopped). |
482
- | `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. |
483
- | `autoplayblocked` | `void` | The browser blocked autoplay (`NotAllowedError`). Show a tap-to-play / tap-to-unmute affordance, then call `player.play()` from the gesture. |
484
- | `qualitychange` | `number` | Active rendition changed (level index, `-1` = auto). |
485
- | `audiotrackchange` | `number` | Active audio track changed (track id). |
486
- | `texttrackchange` | `number` | Active text track changed (track id, `-1` = off). |
487
- | `pipchange` | `boolean` | Picture-in-Picture entered (`true`) or exited (`false`). |
488
- | `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`. |
489
- | `stats` | [`PlaybackStats`](#playbackstats) | Periodic stats sample (`statsIntervalMs`). |
490
- | `statechange` | [`PlayerState`](#playerstate) | Any state transition. |
491
- | `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). |
492
- | `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()`. |
485
+ | Event | Payload | Fires when |
486
+ | --------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
487
+ | `ready` | `void` | The manifest is parsed and the player is attached. |
488
+ | `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). |
489
+ | `playing` | `void` | Playback is progressing. |
490
+ | `buffering` | `void` | The player is rebuffering. |
491
+ | `paused` | `void` | Playback is paused. |
492
+ | `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. While the live-state push is fresh (a frame within the last 60 s) and last said `live`, a watchdog end is not delivered: the player fires a `warning` (`UNAVAILABLE`, "reloading instead of ending") and reloads the stream instead, at most twice per 10 minutes; the third ends as before. 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`. |
493
+ | `error` | [`PlaybackError`](#playbackerror) | A fatal error occurred (playback stopped). |
494
+ | `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. |
495
+ | `autoplayblocked` | `void` | The browser blocked autoplay (`NotAllowedError`). Show a tap-to-play / tap-to-unmute affordance, then call `player.play()` from the gesture. |
496
+ | `qualitychange` | `number` | Active rendition changed (level index, `-1` = auto). |
497
+ | `audiotrackchange` | `number` | Active audio track changed (track id). |
498
+ | `texttrackchange` | `number` | Active text track changed (track id, `-1` = off). |
499
+ | `pipchange` | `boolean` | Picture-in-Picture entered (`true`) or exited (`false`). |
500
+ | `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`. |
501
+ | `stats` | [`PlaybackStats`](#playbackstats) | Periodic stats sample (`statsIntervalMs`). |
502
+ | `statechange` | [`PlayerState`](#playerstate) | Any state transition. |
503
+ | `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). |
504
+ | `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()`. |
493
505
 
494
506
  <a id="connectivity"></a>
495
507
 
@@ -553,9 +565,13 @@ booked for your key but not playable in the viewer's country) gets its own copy,
553
565
  "This match isn't available in your country (RU)", naming the country the server
554
566
  saw, and no Retry button: a retry would be refused again. A `DRM_DEVICE_REVOKED` error
555
567
  (the license server refuses this device's DRM client) likewise gets "This device can't
556
- play protected video" and no Retry. The cards pick up `theme.logoUrl` and
568
+ play protected video" and no Retry. A `DRM_CLIENT` error whose `detail` is
569
+ `keySystemNoAccess` (the browser has no usable Widevine) gets "This browser can't play
570
+ protected video" with the way out named, and one whose `detail` is
571
+ `keySystemStatusOutputRestricted` (no HDCP on the display path) gets "Protected video
572
+ can't be shown on this display"; both keep Retry. The cards pick up `theme.logoUrl` and
557
573
  the theme colors; customise the copy with `endedMessage`/`interruptedMessage`/`errorMessage`/`offlineMessage`
558
- (an `errorMessage` replaces the `GEO_DENIED` and `DRM_DEVICE_REVOKED` copy too), or set
574
+ (an `errorMessage` replaces the `GEO_DENIED`, `DRM_DEVICE_REVOKED` and `DRM_CLIENT` copy too), or set
559
575
  `statusOverlays: false` to drive your own end/error UI from the player events.
560
576
  It auto-hides during playback, supports keyboard shortcuts (space/`k` play-pause,
561
577
  `m` mute, `f` fullscreen, `c` captions, `l` go-live, arrows seek/volume), and is
@@ -1243,6 +1259,7 @@ class PlaybackError extends Error {
1243
1259
  readonly country?: string; // upper-case ISO 3166-1 alpha-2; set for GEO_DENIED
1244
1260
  readonly serverCode?: string; // the server's own code, when the body carried one (e.g. 'cdm_revoked', 'license_refused')
1245
1261
  readonly requestId?: string; // server correlation id, when present
1262
+ readonly detail?: string; // the engine's own name for the failure (an hls.js ErrorDetails value), when it supplied one
1246
1263
  }
1247
1264
  ```
1248
1265
 
@@ -1275,7 +1292,14 @@ involved (e.g. a `5xx` after the SDK's license-retry budget is exhausted), else
1275
1292
  the `code`, not on `httpStatus`, to tell a server rejection from a device that can't
1276
1293
  play DRM. The built-in error card still offers Retry (it re-resolves and re-attaches,
1277
1294
  which can recover a transient license failure); an unsupported device will simply fail
1278
- the same way again.
1295
+ the same way again. `detail` carries hls.js's own name for the failure: on
1296
+ `keySystemNoAccess` (the browser has no usable Widevine: a Chromium build without the
1297
+ CDM, a WebView, DRM switched off) the card says "This browser can't play protected
1298
+ video" and names the site setting and the browsers that work (Safari only when the stream
1299
+ carries FairPlay, since the hls.js engine is Widevine-only), and on `keySystemStatusOutputRestricted` (the display path
1300
+ refuses the key, no HDCP) it says "Protected video can't be shown on this display". Both
1301
+ keep Retry: Firefox asks the viewer to allow DRM on the first attempt, and a changed
1302
+ display is a retry away.
1279
1303
 
1280
1304
  ³ `DRM_DEVICE_REVOKED` is read off the body of a license `403`: the DRM proxy answers
1281
1305
  `{"error":"cdm_revoked"}` when the license server refuses the device's DRM client
@@ -1464,6 +1488,7 @@ interface PlaybackStats {
1464
1488
  fpsDownCaps?: number; // times quality was capped because the device dropped frames (hls.js engine)
1465
1489
  maxLevelHeight?: number; // highest rendition ABR may pick right now; absent when uncapped (hls.js engine)
1466
1490
  stallDownCaps?: number; // times a playback stall stepped the quality ceiling down (hls.js engine)
1491
+ stuckReloads?: number; // times a frozen playhead with a buffer that stopped growing was reloaded at live (native engine)
1467
1492
  capReason?: 'size' | 'bitrate' | 'fps' | 'stall'; // which SDK cap set maxLevelHeight: player pixel box, maxBitrate, frame drops, recent stall
1468
1493
  }
1469
1494
  ```
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.27.2/dist/havik-player.global.js" integrity="sha384-KvBlAGzWd6w+zO/K1zUB9xgxjGcv/0PX+sMbk4D1xPNNbeKwuGM1K4Ju2rLxsbuq" crossorigin="anonymous"></script>
31
+ <script src="https://cdn.jsdelivr.net/npm/@oddin-gg/havik-player@2.28.0/dist/havik-player.global.js" integrity="sha384-d22l6E/5nLGZ474vyfD82Dwvx1PeYb2GLOr+aN/78lgvtAVhGrdhXpBBJKOkRIwE" 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.27.2/dist/havik-player.global.js" integrity="sha384-KvBlAGzWd6w+zO/K1zUB9xgxjGcv/0PX+sMbk4D1xPNNbeKwuGM1K4Ju2rLxsbuq" crossorigin="anonymous"></script>
15
+ <script src="https://cdn.jsdelivr.net/npm/@oddin-gg/havik-player@2.28.0/dist/havik-player.global.js" integrity="sha384-d22l6E/5nLGZ474vyfD82Dwvx1PeYb2GLOr+aN/78lgvtAVhGrdhXpBBJKOkRIwE" 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.27.2",
3
+ "version": "2.28.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,