@nomercy-entertainment/nomercy-video-player 2.0.3 → 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 +15 -0
- package/dist/adapters/video-backend/IVideoBackend.d.ts +2 -2
- package/dist/adapters/video-backend/html5.d.ts +43 -2
- package/dist/adapters/video-backend/html5.js +109 -23
- package/dist/adapters/video-backend/source-outage.d.ts +58 -0
- package/dist/adapters/video-backend/source-outage.js +84 -0
- package/dist/index.d.ts +2 -5
- package/dist/index.js +30 -28
- package/dist/nomercy-video-player.iife.js +1 -1
- package/dist/plugins/desktop-ui/helpers/menuControl.js +10 -0
- package/dist/plugins/desktop-ui/helpers/responsive.js +15 -4
- package/dist/plugins/desktop-ui/index.js +1 -1
- package/dist/plugins/desktop-ui/mixins/feedbackMethods.js +11 -4
- package/dist/plugins/desktop-ui/styles.css +69 -7
- package/dist/plugins/index.d.ts +2 -2
- package/dist/plugins/index.js +1 -1
- package/package.json +6 -2
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
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AudioTrack, BackendLoaderState, BackendState, HdrOnSdrFallback, MinimalBackendEventPayload, QualityLevel, SubtitleCueChange, SubtitleTrack } from '@nomercy-entertainment/nomercy-player-core';
|
|
1
|
+
import type { AudioTrack, AuthHeaderProvider, BackendLoaderState, BackendState, HdrOnSdrFallback, MinimalBackendEventPayload, QualityLevel, SubtitleCueChange, SubtitleTrack } from '@nomercy-entertainment/nomercy-player-core';
|
|
2
2
|
import type { HtmlPreloadMode } from '../../types.js';
|
|
3
3
|
/**
|
|
4
4
|
* Per-event payload map. Each backend event has a fixed payload shape so
|
|
@@ -135,7 +135,7 @@ export interface IVideoBackend {
|
|
|
135
135
|
* manifests/segments). Optional — backends without their own network
|
|
136
136
|
* stack omit it.
|
|
137
137
|
*/
|
|
138
|
-
setAuthHeaderProvider?(provider:
|
|
138
|
+
setAuthHeaderProvider?(provider: AuthHeaderProvider): void;
|
|
139
139
|
/**
|
|
140
140
|
* Consumer policy for an all-HDR item on a display that can't show HDR and
|
|
141
141
|
* a backend that can't tone-map it. Optional — backends that never face the
|
|
@@ -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 →
|
|
231
|
-
*
|
|
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;
|
|
@@ -219,7 +237,7 @@ export class Html5VideoBackend extends MediaElementBackend {
|
|
|
219
237
|
// what produces the ~8 s stall on auto-advance.
|
|
220
238
|
resetMediaElement(this.element);
|
|
221
239
|
performance.mark('nm:backend:load:start');
|
|
222
|
-
const headerValue =
|
|
240
|
+
const headerValue = this._authHeaderProvider?.(url);
|
|
223
241
|
if (hlsUrl && !nativeHls) {
|
|
224
242
|
const { default: Hls } = await import('hls.js');
|
|
225
243
|
if (!Hls?.isSupported?.()) {
|
|
@@ -239,7 +257,7 @@ export class Html5VideoBackend extends MediaElementBackend {
|
|
|
239
257
|
enableCEA708Captions: true,
|
|
240
258
|
startPosition: typeof opts?.startTime === 'number' && opts.startTime > 0 ? opts.startTime : -1,
|
|
241
259
|
startFragPrefetch: true,
|
|
242
|
-
xhrSetup: createAuthorizationXhrSetup(
|
|
260
|
+
xhrSetup: createAuthorizationXhrSetup(this._authHeaderProvider),
|
|
243
261
|
});
|
|
244
262
|
this.hls = hlsInstance;
|
|
245
263
|
this.hlsInstance = hlsInstance;
|
|
@@ -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 →
|
|
840
|
-
*
|
|
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
|
-
|
|
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,10 +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
106
|
playbackRate: {
|
|
111
107
|
(): number;
|
|
112
108
|
(rate: number): Promise<void>;
|
|
@@ -256,7 +252,6 @@ export declare class NMVideoPlayer<T extends VideoPlaylistItem = VideoPlaylistIt
|
|
|
256
252
|
togglePip(): void;
|
|
257
253
|
toggleTheater(): void;
|
|
258
254
|
cycleSubtitles(): void;
|
|
259
|
-
cycleAudioTracks(): void;
|
|
260
255
|
private _aspectRatio;
|
|
261
256
|
private static readonly _OBJECT_FIT_MAP;
|
|
262
257
|
aspectRatio(): Stretching;
|
|
@@ -333,6 +328,8 @@ export declare class NMVideoPlayer<T extends VideoPlaylistItem = VideoPlaylistIt
|
|
|
333
328
|
(): CurrentAudioTrackSelection | null;
|
|
334
329
|
(idx: number): Promise<void>;
|
|
335
330
|
};
|
|
331
|
+
/** Composed in via `mediaTracksMethods` — see that mixin for the full contract. */
|
|
332
|
+
cycleAudioTracks: () => void;
|
|
336
333
|
qualityLevels: {
|
|
337
334
|
(): QualityLevel[];
|
|
338
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;
|
|
@@ -260,13 +260,9 @@ export class NMVideoPlayer extends EventEmitter {
|
|
|
260
260
|
// Authenticated media servers need the Authorization header on every
|
|
261
261
|
// hls.js manifest/segment request. The provider reads the auth config
|
|
262
262
|
// lazily so getter-style tokens (Vue refs, stores) stay live.
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
return undefined;
|
|
267
|
-
const token = typeof bearer === 'function' ? await bearer() : bearer;
|
|
268
|
-
return token ? `Bearer ${token}` : undefined;
|
|
269
|
-
});
|
|
263
|
+
// Read per request rather than captured, so a consumer whose token or
|
|
264
|
+
// rule changes mid-session is not answered from a stale closure.
|
|
265
|
+
instance.setAuthHeaderProvider?.((url) => this.options?.auth?.mediaAuthorization?.(url));
|
|
270
266
|
// Consumer's HDR-on-SDR policy, read once here rather than normalized
|
|
271
267
|
// into `options` — `hdrDecision`'s own default ('play') applies at the
|
|
272
268
|
// point of use when this was never pushed at all.
|
|
@@ -360,6 +356,15 @@ export class NMVideoPlayer extends EventEmitter {
|
|
|
360
356
|
this.emit('subtitleCue', data);
|
|
361
357
|
});
|
|
362
358
|
instance.on('timeupdate', () => {
|
|
359
|
+
// Between a `next()` and the incoming item's mount, this element is
|
|
360
|
+
// still the OUTGOING item's — the cursor already moved. Publishing
|
|
361
|
+
// its position here hands every listener the previous item's
|
|
362
|
+
// position stamped with the new item's identity. Optional call:
|
|
363
|
+
// the dependency range admits a core that predates the reader, and
|
|
364
|
+
// an older core must degrade to the old behaviour, not throw on
|
|
365
|
+
// every tick.
|
|
366
|
+
if (this._mediaIsStale?.())
|
|
367
|
+
return;
|
|
363
368
|
const currentTime = instance.currentTime();
|
|
364
369
|
const duration = instance.duration();
|
|
365
370
|
// The payload is core's full TimeState snapshot, built from the
|
|
@@ -523,24 +528,7 @@ export class NMVideoPlayer extends EventEmitter {
|
|
|
523
528
|
const next = current >= list.length - 1 ? -1 : current + 1;
|
|
524
529
|
this.subtitle(next === -1 ? null : next);
|
|
525
530
|
}
|
|
526
|
-
cycleAudioTracks()
|
|
527
|
-
let list = [];
|
|
528
|
-
try {
|
|
529
|
-
list = this.audioTracks();
|
|
530
|
-
}
|
|
531
|
-
catch { /* tracks API not implemented yet — treat as empty */ }
|
|
532
|
-
if (!list || list.length === 0)
|
|
533
|
-
return;
|
|
534
|
-
let current = -1;
|
|
535
|
-
try {
|
|
536
|
-
const sel = this.audioTrack();
|
|
537
|
-
if (sel != null && typeof sel.index === 'number' && sel.index >= 0)
|
|
538
|
-
current = sel.index;
|
|
539
|
-
}
|
|
540
|
-
catch { /* state unavailable — start from 0 */ }
|
|
541
|
-
const next = current >= list.length - 1 ? 0 : current + 1;
|
|
542
|
-
this.audioTrack(next);
|
|
543
|
-
}
|
|
531
|
+
// cycleAudioTracks — composed in via `mediaTracksMethods` mixin (core, shared with music).
|
|
544
532
|
_aspectRatio = 'uniform';
|
|
545
533
|
static _OBJECT_FIT_MAP = {
|
|
546
534
|
uniform: 'contain',
|
|
@@ -702,9 +690,23 @@ composeMixins(NMVideoPlayer.prototype, ...playerCoreMethods);
|
|
|
702
690
|
{
|
|
703
691
|
const composedDispose = NMVideoPlayer.prototype.dispose;
|
|
704
692
|
NMVideoPlayer.prototype.dispose = async function () {
|
|
693
|
+
// The container id is released before the first await, not after the
|
|
694
|
+
// teardown resolves. Consumers re-create on the same container in the
|
|
695
|
+
// same tick — a responsive view handing `#player1` from its desktop
|
|
696
|
+
// variant to its mobile one does exactly that — and the registry still
|
|
697
|
+
// held this player, so they were given back the very instance they had
|
|
698
|
+
// just asked to dispose. `setup()` then threw `already-setup`, and the
|
|
699
|
+
// throw left that player attached and playing with nobody holding a
|
|
700
|
+
// reference to stop it.
|
|
701
|
+
const claimedId = this.playerId;
|
|
702
|
+
_instances.delete(claimedId);
|
|
705
703
|
await composedDispose.call(this);
|
|
706
|
-
if (this.phase() !== 'disposed')
|
|
707
|
-
|
|
704
|
+
if (this.phase() !== 'disposed') {
|
|
705
|
+
// beforeDispose was prevented — the player is still alive, so it
|
|
706
|
+
// takes its id back and the backend stays intact.
|
|
707
|
+
_instances.set(claimedId, this);
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
708
710
|
this._disposeBackend();
|
|
709
711
|
};
|
|
710
712
|
}
|