@nomercy-entertainment/nomercy-video-player 2.0.4 → 2.1.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/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [2.1.0] — 2026-08-20
6
+
7
+ ### Added
8
+
9
+ - Re-exports `volumeMemoryPlugin` / `VolumeMemoryPlugin` from `@nomercy-entertainment/nomercy-player-core@2.1.0`, alongside the rest of the kit's audio-graph plugins.
10
+ - New `./adapters` subpath export.
11
+
12
+ ### Fixed
13
+
14
+ - A server that went away is now ridden out as an outage rather than treated as a dead file.
15
+
16
+ ### Changed
17
+
18
+ - Built against `@nomercy-entertainment/nomercy-player-core@2.1.0`.
19
+
5
20
  ## [2.0.3] — 2026-07-31
6
21
 
7
22
  ### Fixed
@@ -40,6 +40,23 @@ export declare class Html5VideoBackend extends MediaElementBackend<HTMLVideoElem
40
40
  private _mediaRecoveryStartMs;
41
41
  /** Timer handle for exponential back-off retries. Cleared on unload/dispose. */
42
42
  private _retryTimer;
43
+ /**
44
+ * Whether this outage already failed below the HTTP layer, which is what
45
+ * tells a restarting server's 404 apart from a video that is genuinely gone.
46
+ */
47
+ private _sawConnectionFailure;
48
+ /**
49
+ * True while the ladder is riding out a source the server stopped serving.
50
+ *
51
+ * The backend is the first thing a consumer has that learns the origin is
52
+ * gone: it asks for bytes constantly, while a realtime socket through the
53
+ * same tunnel can stay up long after the server behind it died. An app
54
+ * watches this to raise its own server-offline screen on the player's
55
+ * evidence rather than waiting for a socket that may never drop.
56
+ */
57
+ private _recoveringFromOutage;
58
+ /** Retries the moment the device has a route again, rather than waiting out a rung. */
59
+ private _onlineHandler;
43
60
  private _sourceNode?;
44
61
  private _sourceCtx?;
45
62
  private _analyserNode?;
@@ -213,6 +230,30 @@ export declare class Html5VideoBackend extends MediaElementBackend<HTMLVideoElem
213
230
  private wireElementEvents;
214
231
  /** Reset all recovery counters. Call on successful playback resume or unload. */
215
232
  private _resetRecoveryState;
233
+ /**
234
+ * True while the ladder is riding out a source the server stopped serving —
235
+ * see `_recoveringFromOutage`. A consumer probes server reachability on this
236
+ * so its own offline screen rises from the evidence that arrives first.
237
+ */
238
+ get isRecoveringFromOutage(): boolean;
239
+ /**
240
+ * A server that went away is an outage to ride out, not a dead file.
241
+ *
242
+ * Escalating after three tries over seven seconds is what made a host
243
+ * restart read to a viewer as a broken film: the ladder was spent before
244
+ * the server had finished booting. The budget now matches how long a real
245
+ * restart takes, and how many rungs a failure earns depends on what it was
246
+ * — an origin-down status or a refused connection gets all of them, a cold
247
+ * 4xx gets five, because a URL that is genuinely gone answers the same way
248
+ * every time and a viewer should be told so rather than shown a spinner.
249
+ */
250
+ private _rideOutNetworkError;
251
+ /**
252
+ * Retries the moment the device has a route again, so an outage longer than
253
+ * the ladder still ends in playback. Without it a Wi-Fi drop that outlasts
254
+ * the budget leaves the session unrecoverable until the viewer reloads.
255
+ */
256
+ private _watchForConnectivityReturn;
216
257
  /**
217
258
  * Escalate a fatal HLS error to a player-level error event.
218
259
  *
@@ -227,8 +268,8 @@ export declare class Html5VideoBackend extends MediaElementBackend<HTMLVideoElem
227
268
  *
228
269
  * Decision tree per hls.js error semantics:
229
270
  * - Non-fatal → emit `stream:error` with `fatal: false`, no escalation.
230
- * - Fatal NETWORK → retry `hls.startLoad()` up to 3× with exponential
231
- * back-off (1 s, 2 s, 4 s). If exhausted, escalate.
271
+ * - Fatal NETWORK → ride the outage out on `SOURCE_OUTAGE_BACKOFF_MS`,
272
+ * for as many rungs as the failure earns. If exhausted, escalate.
232
273
  * - Fatal MEDIA → call `hls.recoverMediaError()`. If a second media
233
274
  * error fires within 5 s, escalate.
234
275
  * - Fatal MUX / other → destroy + reload via `load()`. If that fails, escalate.
@@ -6,6 +6,7 @@
6
6
  // SPDX-License-Identifier: Apache-2.0
7
7
  // -----------------------------------------------------------------------------
8
8
  import { abrCeiling, appendAuthTokenParam, createAuthorizationXhrSetup, destroyHlsInstance, detectDisplayHdr, hdrDecision, isHls, MediaElementBackend, MediaFormatError, mediaFormatError, panePixels, resetMediaElement, sizeAbrCeiling, supportsNativeHls, } from '@nomercy-entertainment/nomercy-player-core';
9
+ import { SOURCE_OUTAGE_BACKOFF_MS, sourceOutageRetryLimit } from './source-outage.js';
9
10
  const CODEC_LABELS = [
10
11
  [/avc1|h264/i, 'H.264'],
11
12
  [/hvc1|hev1|h265/i, 'H.265 (HEVC)'],
@@ -69,6 +70,23 @@ export class Html5VideoBackend extends MediaElementBackend {
69
70
  _mediaRecoveryStartMs = 0;
70
71
  /** Timer handle for exponential back-off retries. Cleared on unload/dispose. */
71
72
  _retryTimer;
73
+ /**
74
+ * Whether this outage already failed below the HTTP layer, which is what
75
+ * tells a restarting server's 404 apart from a video that is genuinely gone.
76
+ */
77
+ _sawConnectionFailure = false;
78
+ /**
79
+ * True while the ladder is riding out a source the server stopped serving.
80
+ *
81
+ * The backend is the first thing a consumer has that learns the origin is
82
+ * gone: it asks for bytes constantly, while a realtime socket through the
83
+ * same tunnel can stay up long after the server behind it died. An app
84
+ * watches this to raise its own server-offline screen on the player's
85
+ * evidence rather than waiting for a socket that may never drop.
86
+ */
87
+ _recoveringFromOutage = false;
88
+ /** Retries the moment the device has a route again, rather than waiting out a rung. */
89
+ _onlineHandler;
72
90
  // ── Web Audio graph ──────────────────────────────────────────────────────
73
91
  _sourceNode;
74
92
  _sourceCtx;
@@ -811,6 +829,92 @@ export class Html5VideoBackend extends MediaElementBackend {
811
829
  }
812
830
  this._netRetryCount = 0;
813
831
  this._mediaRecoveryStartMs = 0;
832
+ this._sawConnectionFailure = false;
833
+ this._recoveringFromOutage = false;
834
+ if (this._onlineHandler !== undefined) {
835
+ globalThis.removeEventListener?.('online', this._onlineHandler);
836
+ this._onlineHandler = undefined;
837
+ }
838
+ }
839
+ /**
840
+ * True while the ladder is riding out a source the server stopped serving —
841
+ * see `_recoveringFromOutage`. A consumer probes server reachability on this
842
+ * so its own offline screen rises from the evidence that arrives first.
843
+ */
844
+ get isRecoveringFromOutage() {
845
+ return this._recoveringFromOutage;
846
+ }
847
+ /**
848
+ * A server that went away is an outage to ride out, not a dead file.
849
+ *
850
+ * Escalating after three tries over seven seconds is what made a host
851
+ * restart read to a viewer as a broken film: the ladder was spent before
852
+ * the server had finished booting. The budget now matches how long a real
853
+ * restart takes, and how many rungs a failure earns depends on what it was
854
+ * — an origin-down status or a refused connection gets all of them, a cold
855
+ * 4xx gets five, because a URL that is genuinely gone answers the same way
856
+ * every time and a viewer should be told so rather than shown a spinner.
857
+ */
858
+ _rideOutNetworkError(data) {
859
+ const httpStatus = data.response?.code ?? 0;
860
+ if (httpStatus === 0)
861
+ this._sawConnectionFailure = true;
862
+ const limit = sourceOutageRetryLimit(httpStatus, this._sawConnectionFailure);
863
+ if (this._netRetryCount >= limit) {
864
+ // The ladder is spent, but a device that gets a route back later
865
+ // still deserves its session rather than a dead error overlay.
866
+ this._watchForConnectivityReturn();
867
+ this._recoveringFromOutage = false;
868
+ this._escalateHlsError(data.details, `HLS network error after ${limit} retries: ${data.details}`);
869
+ return;
870
+ }
871
+ // An outage on THIS device's side is not evidence that the server is
872
+ // gone, so it must not spend the budget — a two-minute Wi-Fi drop would
873
+ // burn the whole ladder without a single attempt ever having a route to
874
+ // try. Hold at the current rung and let the `online` event take over.
875
+ const deviceOffline = globalThis.navigator?.onLine === false;
876
+ const attempt = this._netRetryCount;
877
+ if (!deviceOffline)
878
+ this._netRetryCount = attempt + 1;
879
+ this._watchForConnectivityReturn();
880
+ this._recoveringFromOutage = true;
881
+ this.emit('stream:recovering', { details: data.details, attempt: attempt + 1, maxAttempts: limit });
882
+ this._retryTimer = setTimeout(() => {
883
+ this._retryTimer = undefined;
884
+ if (!this.hls)
885
+ return;
886
+ try {
887
+ this.hls.startLoad();
888
+ }
889
+ catch {
890
+ this._escalateHlsError(data.details, `HLS startLoad failed: ${data.details}`);
891
+ }
892
+ }, SOURCE_OUTAGE_BACKOFF_MS[attempt]);
893
+ }
894
+ /**
895
+ * Retries the moment the device has a route again, so an outage longer than
896
+ * the ladder still ends in playback. Without it a Wi-Fi drop that outlasts
897
+ * the budget leaves the session unrecoverable until the viewer reloads.
898
+ */
899
+ _watchForConnectivityReturn() {
900
+ if (this._onlineHandler !== undefined)
901
+ return;
902
+ this._onlineHandler = () => {
903
+ // A fresh outage deserves the whole ladder: the attempts spent
904
+ // waiting for the network to come back say nothing about whether
905
+ // the server answers now.
906
+ this._netRetryCount = 0;
907
+ this._sawConnectionFailure = false;
908
+ if (this._retryTimer !== undefined) {
909
+ clearTimeout(this._retryTimer);
910
+ this._retryTimer = undefined;
911
+ }
912
+ try {
913
+ this.hls?.startLoad();
914
+ }
915
+ catch { /* the ladder's next failure reports it; a throw here is not the viewer's problem */ }
916
+ };
917
+ globalThis.addEventListener?.('online', this._onlineHandler);
814
918
  }
815
919
  /**
816
920
  * Escalate a fatal HLS error to a player-level error event.
@@ -836,8 +940,8 @@ export class Html5VideoBackend extends MediaElementBackend {
836
940
  *
837
941
  * Decision tree per hls.js error semantics:
838
942
  * - Non-fatal → emit `stream:error` with `fatal: false`, no escalation.
839
- * - Fatal NETWORK → retry `hls.startLoad()` up to 3× with exponential
840
- * back-off (1 s, 2 s, 4 s). If exhausted, escalate.
943
+ * - Fatal NETWORK → ride the outage out on `SOURCE_OUTAGE_BACKOFF_MS`,
944
+ * for as many rungs as the failure earns. If exhausted, escalate.
841
945
  * - Fatal MEDIA → call `hls.recoverMediaError()`. If a second media
842
946
  * error fires within 5 s, escalate.
843
947
  * - Fatal MUX / other → destroy + reload via `load()`. If that fails, escalate.
@@ -848,7 +952,6 @@ export class Html5VideoBackend extends MediaElementBackend {
848
952
  _attachHlsErrorHandler(Hls, url) {
849
953
  if (!this.hls)
850
954
  return;
851
- const MAX_NET_RETRIES = 3;
852
955
  this.hls.on(Hls.Events.ERROR, (_e, data) => {
853
956
  if (!data.fatal) {
854
957
  if (data.details === 'bufferIncompatibleCodecsError') {
@@ -868,24 +971,7 @@ export class Html5VideoBackend extends MediaElementBackend {
868
971
  return;
869
972
  }
870
973
  if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
871
- if (this._netRetryCount >= MAX_NET_RETRIES) {
872
- this._escalateHlsError(data.details, `HLS network error after ${MAX_NET_RETRIES} retries: ${data.details}`);
873
- return;
874
- }
875
- this._netRetryCount++;
876
- const delayMs = 1_000 * (2 ** (this._netRetryCount - 1)); // 1s, 2s, 4s
877
- this.emit('stream:recovering', { details: data.details, attempt: this._netRetryCount, maxAttempts: MAX_NET_RETRIES });
878
- this._retryTimer = setTimeout(() => {
879
- this._retryTimer = undefined;
880
- if (!this.hls)
881
- return;
882
- try {
883
- this.hls.startLoad();
884
- }
885
- catch {
886
- this._escalateHlsError(data.details, `HLS startLoad failed: ${data.details}`);
887
- }
888
- }, delayMs);
974
+ this._rideOutNetworkError(data);
889
975
  }
890
976
  else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
891
977
  const now = Date.now();
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Telling "the bytes are not there right now" apart from "this media is broken".
3
+ *
4
+ * A media-server restart produces two failure shapes in one outage:
5
+ * connection-refused while the host is down, then a bad HTTP status the moment
6
+ * it is listening again but has not finished mapping its routes. Treating only
7
+ * the first as recoverable ends the restart on a 404 no retry ladder ever sees,
8
+ * and the session dies under an error overlay while the server is two seconds
9
+ * from answering again.
10
+ *
11
+ * The previous ladder here was three tries over seven seconds, which is shorter
12
+ * than a .NET host takes to warm EF Core and reload its plugins — so the common
13
+ * case, a server the viewer themselves just restarted, always lost.
14
+ *
15
+ * Kept separate from the backend so the decision can be checked without a
16
+ * browser: every answer below is a number-to-verdict lookup, and a lookup that
17
+ * has drifted is invisible in a screenshot. Playback still fails, it just fails
18
+ * for a reason nobody chose.
19
+ */
20
+ /**
21
+ * Backoff for re-loading after the server drops out mid-playback. Starts tight
22
+ * so a momentary blip is invisible, then settles to a steady 15 s poll — long
23
+ * enough to sit out a host restart without hammering a server that is still
24
+ * booting. The ladder's length IS the give-up budget (~105 s); past that the
25
+ * failure is real and the error surfaces.
26
+ */
27
+ export declare const SOURCE_OUTAGE_BACKOFF_MS: readonly number[];
28
+ /**
29
+ * Rungs an HTTP-status failure gets when nothing connection-level preceded it
30
+ * (~30 s). A video whose URL is genuinely gone answers 404 identically every
31
+ * time, and making the viewer watch a spinner for the full budget before being
32
+ * told so is its own bug.
33
+ */
34
+ export declare const HTTP_STATUS_RETRY_LIMIT = 5;
35
+ /** Total wall-clock the ladder covers before a failure is treated as real. */
36
+ export declare function sourceOutageBudgetMs(): number;
37
+ /**
38
+ * True for a status that means "the server is not there", as opposed to "the
39
+ * server is there and this file is not".
40
+ *
41
+ * Measured on a real outage: a NoMercy server behind cloudflared never refuses
42
+ * a connection while it restarts — the edge answers 530, and 52x for its other
43
+ * failure modes. So the shape a restart actually takes is a bad HTTP status
44
+ * with no connection error anywhere in front of it, which is exactly the case a
45
+ * connection-failure-gated budget would cut short.
46
+ */
47
+ export declare function isOriginDownStatus(httpStatus: number): boolean;
48
+ /**
49
+ * How many rungs this failure is allowed.
50
+ *
51
+ * @param httpStatus - The status the load gave up on, or 0 when the failure
52
+ * carried no response at all, which is what a refused connection looks like
53
+ * from a browser.
54
+ * @param sawConnectionFailure - Whether anything in this outage already failed
55
+ * below the HTTP layer. That is what tells a restarting server's 404 apart
56
+ * from a video that is genuinely gone.
57
+ */
58
+ export declare function sourceOutageRetryLimit(httpStatus: number, sawConnectionFailure: boolean): number;
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Telling "the bytes are not there right now" apart from "this media is broken".
3
+ *
4
+ * A media-server restart produces two failure shapes in one outage:
5
+ * connection-refused while the host is down, then a bad HTTP status the moment
6
+ * it is listening again but has not finished mapping its routes. Treating only
7
+ * the first as recoverable ends the restart on a 404 no retry ladder ever sees,
8
+ * and the session dies under an error overlay while the server is two seconds
9
+ * from answering again.
10
+ *
11
+ * The previous ladder here was three tries over seven seconds, which is shorter
12
+ * than a .NET host takes to warm EF Core and reload its plugins — so the common
13
+ * case, a server the viewer themselves just restarted, always lost.
14
+ *
15
+ * Kept separate from the backend so the decision can be checked without a
16
+ * browser: every answer below is a number-to-verdict lookup, and a lookup that
17
+ * has drifted is invisible in a screenshot. Playback still fails, it just fails
18
+ * for a reason nobody chose.
19
+ */
20
+ /**
21
+ * Backoff for re-loading after the server drops out mid-playback. Starts tight
22
+ * so a momentary blip is invisible, then settles to a steady 15 s poll — long
23
+ * enough to sit out a host restart without hammering a server that is still
24
+ * booting. The ladder's length IS the give-up budget (~105 s); past that the
25
+ * failure is real and the error surfaces.
26
+ */
27
+ export const SOURCE_OUTAGE_BACKOFF_MS = [
28
+ 1_000,
29
+ 2_000,
30
+ 4_000,
31
+ 8_000,
32
+ 15_000,
33
+ 15_000,
34
+ 15_000,
35
+ 15_000,
36
+ 15_000,
37
+ 15_000,
38
+ ];
39
+ /**
40
+ * Rungs an HTTP-status failure gets when nothing connection-level preceded it
41
+ * (~30 s). A video whose URL is genuinely gone answers 404 identically every
42
+ * time, and making the viewer watch a spinner for the full budget before being
43
+ * told so is its own bug.
44
+ */
45
+ export const HTTP_STATUS_RETRY_LIMIT = 5;
46
+ /** Total wall-clock the ladder covers before a failure is treated as real. */
47
+ export function sourceOutageBudgetMs() {
48
+ return SOURCE_OUTAGE_BACKOFF_MS.reduce((total, rung) => total + rung, 0);
49
+ }
50
+ /**
51
+ * True for a status that means "the server is not there", as opposed to "the
52
+ * server is there and this file is not".
53
+ *
54
+ * Measured on a real outage: a NoMercy server behind cloudflared never refuses
55
+ * a connection while it restarts — the edge answers 530, and 52x for its other
56
+ * failure modes. So the shape a restart actually takes is a bad HTTP status
57
+ * with no connection error anywhere in front of it, which is exactly the case a
58
+ * connection-failure-gated budget would cut short.
59
+ */
60
+ export function isOriginDownStatus(httpStatus) {
61
+ return (httpStatus >= 520 && httpStatus <= 530)
62
+ || httpStatus === 502
63
+ || httpStatus === 503
64
+ || httpStatus === 504;
65
+ }
66
+ /**
67
+ * How many rungs this failure is allowed.
68
+ *
69
+ * @param httpStatus - The status the load gave up on, or 0 when the failure
70
+ * carried no response at all, which is what a refused connection looks like
71
+ * from a browser.
72
+ * @param sawConnectionFailure - Whether anything in this outage already failed
73
+ * below the HTTP layer. That is what tells a restarting server's 404 apart
74
+ * from a video that is genuinely gone.
75
+ */
76
+ export function sourceOutageRetryLimit(httpStatus, sawConnectionFailure) {
77
+ if (sawConnectionFailure)
78
+ return SOURCE_OUTAGE_BACKOFF_MS.length;
79
+ if (isOriginDownStatus(httpStatus))
80
+ return SOURCE_OUTAGE_BACKOFF_MS.length;
81
+ if (httpStatus > 0)
82
+ return HTTP_STATUS_RETRY_LIMIT;
83
+ return SOURCE_OUTAGE_BACKOFF_MS.length;
84
+ }
package/dist/index.d.ts CHANGED
@@ -103,12 +103,6 @@ export declare class NMVideoPlayer<T extends VideoPlaylistItem = VideoPlaylistIt
103
103
  timeData: () => KitTimeState;
104
104
  /** Seek to a position expressed as a percentage (0–100) of total duration. */
105
105
  seekByPercentage: (pct: number, opts?: ActionOptions) => void;
106
- /** @internal */
107
- private _checkItemEndingSoon;
108
- /** @internal */
109
- private _timeStateAt;
110
- /** @internal */
111
- private _mediaIsStale;
112
106
  playbackRate: {
113
107
  (): number;
114
108
  (rate: number): Promise<void>;
@@ -258,7 +252,6 @@ export declare class NMVideoPlayer<T extends VideoPlaylistItem = VideoPlaylistIt
258
252
  togglePip(): void;
259
253
  toggleTheater(): void;
260
254
  cycleSubtitles(): void;
261
- cycleAudioTracks(): void;
262
255
  private _aspectRatio;
263
256
  private static readonly _OBJECT_FIT_MAP;
264
257
  aspectRatio(): Stretching;
@@ -335,6 +328,8 @@ export declare class NMVideoPlayer<T extends VideoPlaylistItem = VideoPlaylistIt
335
328
  (): CurrentAudioTrackSelection | null;
336
329
  (idx: number): Promise<void>;
337
330
  };
331
+ /** Composed in via `mediaTracksMethods` — see that mixin for the full contract. */
332
+ cycleAudioTracks: () => void;
338
333
  qualityLevels: {
339
334
  (): QualityLevel[];
340
335
  (opts: {
package/dist/index.js CHANGED
@@ -63,7 +63,7 @@ function _matchLanguage(candidates, target) {
63
63
  */
64
64
  export class NMVideoPlayer extends EventEmitter {
65
65
  playerId = '';
66
- container = {};
66
+ container;
67
67
  videoElement;
68
68
  get id() {
69
69
  return this.playerId;
@@ -528,24 +528,7 @@ export class NMVideoPlayer extends EventEmitter {
528
528
  const next = current >= list.length - 1 ? -1 : current + 1;
529
529
  this.subtitle(next === -1 ? null : next);
530
530
  }
531
- cycleAudioTracks() {
532
- let list = [];
533
- try {
534
- list = this.audioTracks();
535
- }
536
- catch { /* tracks API not implemented yet — treat as empty */ }
537
- if (!list || list.length === 0)
538
- return;
539
- let current = -1;
540
- try {
541
- const sel = this.audioTrack();
542
- if (sel != null && typeof sel.index === 'number' && sel.index >= 0)
543
- current = sel.index;
544
- }
545
- catch { /* state unavailable — start from 0 */ }
546
- const next = current >= list.length - 1 ? 0 : current + 1;
547
- this.audioTrack(next);
548
- }
531
+ // cycleAudioTracks — composed in via `mediaTracksMethods` mixin (core, shared with music).
549
532
  _aspectRatio = 'uniform';
550
533
  static _OBJECT_FIT_MAP = {
551
534
  uniform: 'contain',