@oddin-gg/havik-player 2.27.2 → 2.28.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
@@ -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,26 @@ 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.
384
+ - **A buffer hole hls.js cannot nudge across jumps to live.** When hls.js gives
385
+ up seeking the playhead over a hole in the buffer (its fatal
386
+ `bufferSeekOverHole`, or a `bufferStalledError` after its nudge budget) on a
387
+ live stream, the SDK restarts loading at the live edge, which is past the
388
+ hole, with the media element, the buffer and the DRM key sessions still in
389
+ place. Twice per 30 s of healthy playback, out of the same three-recovery
390
+ budget the MediaSource rebuild uses; after that, and on VOD, the
391
+ MediaSource is rebuilt as before, and the fourth consecutive failure is the
392
+ fatal error it always was.
372
393
 
373
394
  - **Futile catch‑up is stopped.** hls.js speeds playback up (to 1.1×) whenever
374
395
  latency sits above the target, and only lowers its ambition after a counted
@@ -470,26 +491,26 @@ Notes:
470
491
 
471
492
  `on(event, cb)`: the payload type depends on the event ([`PlayerEventMap`](#playereventmap)):
472
493
 
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()`. |
494
+ | Event | Payload | Fires when |
495
+ | --------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
496
+ | `ready` | `void` | The manifest is parsed and the player is attached. |
497
+ | `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). |
498
+ | `playing` | `void` | Playback is progressing. |
499
+ | `buffering` | `void` | The player is rebuffering. |
500
+ | `paused` | `void` | Playback is paused. |
501
+ | `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`. |
502
+ | `error` | [`PlaybackError`](#playbackerror) | A fatal error occurred (playback stopped). |
503
+ | `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. |
504
+ | `autoplayblocked` | `void` | The browser blocked autoplay (`NotAllowedError`). Show a tap-to-play / tap-to-unmute affordance, then call `player.play()` from the gesture. |
505
+ | `qualitychange` | `number` | Active rendition changed (level index, `-1` = auto). |
506
+ | `audiotrackchange` | `number` | Active audio track changed (track id). |
507
+ | `texttrackchange` | `number` | Active text track changed (track id, `-1` = off). |
508
+ | `pipchange` | `boolean` | Picture-in-Picture entered (`true`) or exited (`false`). |
509
+ | `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`. |
510
+ | `stats` | [`PlaybackStats`](#playbackstats) | Periodic stats sample (`statsIntervalMs`). |
511
+ | `statechange` | [`PlayerState`](#playerstate) | Any state transition. |
512
+ | `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). |
513
+ | `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
514
 
494
515
  <a id="connectivity"></a>
495
516
 
@@ -553,9 +574,13 @@ booked for your key but not playable in the viewer's country) gets its own copy,
553
574
  "This match isn't available in your country (RU)", naming the country the server
554
575
  saw, and no Retry button: a retry would be refused again. A `DRM_DEVICE_REVOKED` error
555
576
  (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
577
+ play protected video" and no Retry. A `DRM_CLIENT` error whose `detail` is
578
+ `keySystemNoAccess` (the browser has no usable Widevine) gets "This browser can't play
579
+ protected video" with the way out named, and one whose `detail` is
580
+ `keySystemStatusOutputRestricted` (no HDCP on the display path) gets "Protected video
581
+ can't be shown on this display"; both keep Retry. The cards pick up `theme.logoUrl` and
557
582
  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
583
+ (an `errorMessage` replaces the `GEO_DENIED`, `DRM_DEVICE_REVOKED` and `DRM_CLIENT` copy too), or set
559
584
  `statusOverlays: false` to drive your own end/error UI from the player events.
560
585
  It auto-hides during playback, supports keyboard shortcuts (space/`k` play-pause,
561
586
  `m` mute, `f` fullscreen, `c` captions, `l` go-live, arrows seek/volume), and is
@@ -1243,6 +1268,7 @@ class PlaybackError extends Error {
1243
1268
  readonly country?: string; // upper-case ISO 3166-1 alpha-2; set for GEO_DENIED
1244
1269
  readonly serverCode?: string; // the server's own code, when the body carried one (e.g. 'cdm_revoked', 'license_refused')
1245
1270
  readonly requestId?: string; // server correlation id, when present
1271
+ readonly detail?: string; // the engine's own name for the failure (an hls.js ErrorDetails value), when it supplied one
1246
1272
  }
1247
1273
  ```
1248
1274
 
@@ -1275,7 +1301,14 @@ involved (e.g. a `5xx` after the SDK's license-retry budget is exhausted), else
1275
1301
  the `code`, not on `httpStatus`, to tell a server rejection from a device that can't
1276
1302
  play DRM. The built-in error card still offers Retry (it re-resolves and re-attaches,
1277
1303
  which can recover a transient license failure); an unsupported device will simply fail
1278
- the same way again.
1304
+ the same way again. `detail` carries hls.js's own name for the failure: on
1305
+ `keySystemNoAccess` (the browser has no usable Widevine: a Chromium build without the
1306
+ CDM, a WebView, DRM switched off) the card says "This browser can't play protected
1307
+ video" and names the site setting and the browsers that work (Safari only when the stream
1308
+ carries FairPlay, since the hls.js engine is Widevine-only), and on `keySystemStatusOutputRestricted` (the display path
1309
+ refuses the key, no HDCP) it says "Protected video can't be shown on this display". Both
1310
+ keep Retry: Firefox asks the viewer to allow DRM on the first attempt, and a changed
1311
+ display is a retry away.
1279
1312
 
1280
1313
  ³ `DRM_DEVICE_REVOKED` is read off the body of a license `403`: the DRM proxy answers
1281
1314
  `{"error":"cdm_revoked"}` when the license server refuses the device's DRM client
@@ -1464,6 +1497,7 @@ interface PlaybackStats {
1464
1497
  fpsDownCaps?: number; // times quality was capped because the device dropped frames (hls.js engine)
1465
1498
  maxLevelHeight?: number; // highest rendition ABR may pick right now; absent when uncapped (hls.js engine)
1466
1499
  stallDownCaps?: number; // times a playback stall stepped the quality ceiling down (hls.js engine)
1500
+ stuckReloads?: number; // times a frozen playhead with a buffer that stopped growing was reloaded at live (native engine)
1467
1501
  capReason?: 'size' | 'bitrate' | 'fps' | 'stall'; // which SDK cap set maxLevelHeight: player pixel box, maxBitrate, frame drops, recent stall
1468
1502
  }
1469
1503
  ```
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.1/dist/havik-player.global.js" integrity="sha384-MsDlqFBRNSeEkb89GR3AJ7jOau99rUmmttHqnHzmad+L9+HpYWywrL4YuFIPn+4o" 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.1/dist/havik-player.global.js" integrity="sha384-MsDlqFBRNSeEkb89GR3AJ7jOau99rUmmttHqnHzmad+L9+HpYWywrL4YuFIPn+4o" 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.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,