@nomercy-entertainment/nomercy-video-player 2.0.2 → 2.0.4
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 +10 -0
- package/dist/adapters/video-backend/IVideoBackend.d.ts +25 -2
- package/dist/adapters/video-backend/html5.d.ts +77 -19
- package/dist/adapters/video-backend/html5.js +155 -55
- package/dist/index.d.ts +2 -0
- package/dist/index.js +32 -9
- package/dist/nomercy-video-player.iife.js +1 -1
- package/dist/plugins/desktop-ui/helpers/menus.js +2 -3
- package/dist/plugins/desktop-ui/helpers/responsive.js +5 -1
- package/dist/plugins/desktop-ui/index.js +1 -1
- package/dist/plugins/desktop-ui/mixins/domMethods.js +5 -1
- package/dist/plugins/desktop-ui/mixins/feedbackMethods.js +11 -4
- package/dist/plugins/desktop-ui/styles.css +10 -1
- package/dist/types.d.ts +6 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [2.0.3] — 2026-07-31
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- `desktop-ui`: the full-width rows ran past their parent by exactly their own padding. `top-row`, `bottom-row`, `language-button` and `playlist-menu-button` each set `width: 100%` alongside horizontal padding without `border-box`, so consumers were patching the overflow themselves.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Built against `@nomercy-entertainment/nomercy-player-core@2.0.3`, which carries the fix for a start item picked between `setup()` and `ready()`. A `?season=&episode=` style deep link now opens on the item it names.
|
|
14
|
+
|
|
5
15
|
## [2.0.1] — 2026-07-18
|
|
6
16
|
|
|
7
17
|
First published stable of the v2 line (2.0.0 was withdrawn before release), published under the `latest` dist-tag — a plain `npm install @nomercy-entertainment/nomercy-video-player` now resolves `2.0.1` instead of the v1 `1.2.7`. Built against the stable `@nomercy-entertainment/nomercy-player-core@^2.0.1`. The entries below shipped since `2.0.0-rc.35`; everything earlier is captured in the release-candidate history.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AudioTrack, BackendLoaderState, BackendState, 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,13 +135,36 @@ 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
|
+
/**
|
|
140
|
+
* Consumer policy for an all-HDR item on a display that can't show HDR and
|
|
141
|
+
* a backend that can't tone-map it. Optional — backends that never face the
|
|
142
|
+
* question (no HDR-aware ABR) omit it. `NMVideoPlayer` pushes `options.hdrOnSdr`
|
|
143
|
+
* here once, at backend construction; unset means the backend's own default.
|
|
144
|
+
*/
|
|
145
|
+
setHdrOnSdrFallback?(fallback: HdrOnSdrFallback): void;
|
|
139
146
|
play(): Promise<void>;
|
|
140
147
|
pause(): void;
|
|
141
148
|
stop(): void;
|
|
142
149
|
currentTime(): number;
|
|
143
150
|
currentTime(seconds: number): void;
|
|
144
151
|
duration(): number;
|
|
152
|
+
/**
|
|
153
|
+
* The absolute timeline position, in seconds, that buffered data reaches.
|
|
154
|
+
* Not a duration ahead of the playhead, and not a fraction: a scrubber
|
|
155
|
+
* draws its buffered bar as `buffered() / duration()`, the same frame of
|
|
156
|
+
* reference `currentTime()` and `duration()` use.
|
|
157
|
+
*
|
|
158
|
+
* Contiguous from the playhead. The frontier is the end of the range the
|
|
159
|
+
* playhead is inside, so a range on the far side of a hole does not count
|
|
160
|
+
* towards it — a seek back an hour leaves `[0, 90]` and `[3500, 3600]`
|
|
161
|
+
* with the playhead at 5, and a bar drawn to 3600 promises an hour of
|
|
162
|
+
* buffer over a stretch that holds none.
|
|
163
|
+
*
|
|
164
|
+
* When nothing is buffered ahead — the playhead sits in a hole, or before
|
|
165
|
+
* the first range — the answer is the playhead's own position, which draws
|
|
166
|
+
* no bar ahead of it. `0` when no data is buffered at all.
|
|
167
|
+
*/
|
|
145
168
|
buffered(): number;
|
|
146
169
|
bufferedRanges(): TimeRanges;
|
|
147
170
|
seekable(): TimeRanges;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AudioTrack, QualityLevel, SubtitleTrack } from '@nomercy-entertainment/nomercy-player-core';
|
|
1
|
+
import type { AudioTrack, HdrOnSdrFallback, QualityLevel, SubtitleTrack } from '@nomercy-entertainment/nomercy-player-core';
|
|
2
2
|
import type { HtmlPreloadMode } from '../../types.js';
|
|
3
3
|
import type { BackendEventPayload, BackendState, IVideoBackend } from './IVideoBackend.js';
|
|
4
4
|
import { MediaElementBackend } from '@nomercy-entertainment/nomercy-player-core';
|
|
@@ -86,6 +86,25 @@ export declare class Html5VideoBackend extends MediaElementBackend<HTMLVideoElem
|
|
|
86
86
|
* `removeEventListener` with the same reference on dispose.
|
|
87
87
|
*/
|
|
88
88
|
private _hdrMqlListener;
|
|
89
|
+
/**
|
|
90
|
+
* Consumer policy for an all-HDR item on an SDR display, pushed in by
|
|
91
|
+
* `NMVideoPlayer` via `setHdrOnSdrFallback`. `undefined` until then —
|
|
92
|
+
* `hdrDecision`'s own default (`'play'`) applies at read time.
|
|
93
|
+
*/
|
|
94
|
+
private _hdrOnSdrFallback;
|
|
95
|
+
/**
|
|
96
|
+
* Observer on the media element, so the ceiling follows a window drag, a
|
|
97
|
+
* fullscreen toggle and an orientation change. The facade's own observer
|
|
98
|
+
* feeds the `videoRect` event on `NMVideoPlayer` and the backend holds no
|
|
99
|
+
* reference to the facade, so the size signal is re-derived here.
|
|
100
|
+
*/
|
|
101
|
+
private _paneObserver;
|
|
102
|
+
/**
|
|
103
|
+
* Pending coalesced re-apply. A drag fires the observer on every frame and
|
|
104
|
+
* every `autoLevelCapping` write is a decision ABR has to react to, so the
|
|
105
|
+
* burst collapses into one apply.
|
|
106
|
+
*/
|
|
107
|
+
private _paneReapplyTimer;
|
|
89
108
|
constructor(container: HTMLElement);
|
|
90
109
|
/**
|
|
91
110
|
* `startTime` is consumed natively (declared via `canStartAt`): the hls.js
|
|
@@ -224,11 +243,33 @@ export declare class Html5VideoBackend extends MediaElementBackend<HTMLVideoElem
|
|
|
224
243
|
* overlay plugins can update button visibility without polling.
|
|
225
244
|
*
|
|
226
245
|
* Also builds the per-level HDR classification table and immediately
|
|
227
|
-
* applies the
|
|
246
|
+
* applies the ABR constraints for the current display and pane size.
|
|
228
247
|
*/
|
|
229
248
|
private _emitHlsTrackLists;
|
|
230
|
-
/**
|
|
249
|
+
/**
|
|
250
|
+
* Consumer policy for an all-HDR item on an SDR display with no converter.
|
|
251
|
+
* Pushed once by `NMVideoPlayer` right after construction — see `IVideoBackend`.
|
|
252
|
+
*/
|
|
253
|
+
setHdrOnSdrFallback(fallback: HdrOnSdrFallback): void;
|
|
254
|
+
/**
|
|
255
|
+
* Wire a combined-query matchMedia listener once. The comma joins the two
|
|
256
|
+
* queries as an OR at the CSS level, so one listener catches a change in
|
|
257
|
+
* either signal; the boolean answer itself always comes from
|
|
258
|
+
* `detectDisplayHdr`, which asks them in priority order (video plane
|
|
259
|
+
* before page) rather than trusting whichever one the event fired for.
|
|
260
|
+
*/
|
|
231
261
|
private _wireHdrMatchMedia;
|
|
262
|
+
/**
|
|
263
|
+
* Watch the media element's box so the pane ceiling follows the layout.
|
|
264
|
+
*
|
|
265
|
+
* The element rather than the container: `object-fit` letterboxing means the
|
|
266
|
+
* container can be wider than the picture, and the picture is what a
|
|
267
|
+
* rendition has to cover.
|
|
268
|
+
*/
|
|
269
|
+
private _wirePaneResizeObserver;
|
|
270
|
+
private _schedulePaneReapply;
|
|
271
|
+
/** Disconnect the pane observer and drop a pending re-apply. Called from `dispose()`. */
|
|
272
|
+
private _teardownPaneResizeObserver;
|
|
232
273
|
/** Remove the matchMedia listener. Called from `dispose()`. */
|
|
233
274
|
private _teardownHdrMatchMedia;
|
|
234
275
|
/**
|
|
@@ -237,25 +278,42 @@ export declare class Html5VideoBackend extends MediaElementBackend<HTMLVideoElem
|
|
|
237
278
|
*/
|
|
238
279
|
private _classifyHdrLevels;
|
|
239
280
|
/**
|
|
240
|
-
* Constrain HLS.js ABR to
|
|
281
|
+
* Constrain HLS.js ABR to what this display and this pane can actually show,
|
|
282
|
+
* per `hdrDecision` and `sizeAbrCeiling` — the oracles both native ports
|
|
283
|
+
* mirror. The only writer of `autoLevelCapping`.
|
|
284
|
+
*
|
|
285
|
+
* The pane ceiling applies on every branch, including the ones with no
|
|
286
|
+
* dynamic-range cap to make: an SDR item in a 740px player is the ordinary
|
|
287
|
+
* case, and it is the one that was pulling a 4K rendition.
|
|
288
|
+
*
|
|
289
|
+
* `as-is` (HDR display, or an ordinary SDR item with no HDR rungs to cap):
|
|
290
|
+
* - `autoLevelCapping` falls back to the pane ceiling, or `-1` when the
|
|
291
|
+
* pane is unmeasured or already as large as the ladder's best rung. If
|
|
292
|
+
* the current level is SDR and an HDR peer exists at the same
|
|
293
|
+
* resolution, prefer it via `nextLevel` (soft switch — the next fragment
|
|
294
|
+
* boundary picks it up, no stutter). The peer shares the current level's
|
|
295
|
+
* resolution, so it cannot violate the pane ceiling. A no-op on the
|
|
296
|
+
* SDR-only case: there is no HDR peer to find.
|
|
297
|
+
*
|
|
298
|
+
* `cap-to` (SDR display, an SDR rung exists):
|
|
299
|
+
* - Caps `autoLevelCapping` to the more restrictive of the dynamic-range
|
|
300
|
+
* rung and the pane rung, by raw manifest index. For interleaved
|
|
301
|
+
* manifests (HDR index < SDR index at the same resolution) the cap alone
|
|
302
|
+
* is insufficient — `nextLevel` also forces off any currently-playing
|
|
303
|
+
* HDR variant to its SDR peer.
|
|
304
|
+
*
|
|
305
|
+
* `tone-map` — never returned here; see `backendCanToneMap` below.
|
|
306
|
+
*
|
|
307
|
+
* `play-unconverted` — no SDR rung, no converter, consumer opted in: the
|
|
308
|
+
* dynamic range goes unconstrained on purpose, the pane ceiling still
|
|
309
|
+
* holds, no error.
|
|
241
310
|
*
|
|
242
|
-
* SDR
|
|
243
|
-
*
|
|
244
|
-
* ABR algorithm never auto-selects above it. For interleaved manifests
|
|
245
|
-
* where some HDR level indices sit below some SDR level indices,
|
|
246
|
-
* `autoLevelCapping` alone is insufficient — we also force a `nextLevel`
|
|
247
|
-
* switch away from any currently-playing HDR variant to its nearest SDR
|
|
248
|
-
* peer at the same (or next-lower) resolution.
|
|
249
|
-
* - Emits a fresh `levels` event so overlay plugins can hide HDR rows.
|
|
311
|
+
* `refuse` — no SDR rung, no converter, consumer opted out: escalated
|
|
312
|
+
* through the same fatal-error channel HLS stream errors use.
|
|
250
313
|
*
|
|
251
|
-
*
|
|
252
|
-
* - Lifts `autoLevelCapping` to `-1` (uncapped). ABR is free to select
|
|
253
|
-
* HDR variants. If the current level is SDR and an HDR peer exists at
|
|
254
|
-
* the same resolution, prefer it by setting `nextLevel` (soft switch —
|
|
255
|
-
* the next fragment boundary picks it up; no stutter).
|
|
256
|
-
* - Emits a fresh `levels` event so overlay plugins can reveal HDR rows.
|
|
314
|
+
* Always re-emits `levels` so overlay plugins can update their menus.
|
|
257
315
|
*/
|
|
258
|
-
private
|
|
316
|
+
private _applyAbrConstraints;
|
|
259
317
|
/** Called by the matchMedia listener when the display's HDR capability changes. */
|
|
260
318
|
private _onDisplayHdrChange;
|
|
261
319
|
/**
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
//
|
|
6
6
|
// SPDX-License-Identifier: Apache-2.0
|
|
7
7
|
// -----------------------------------------------------------------------------
|
|
8
|
-
import { appendAuthTokenParam, createAuthorizationXhrSetup, destroyHlsInstance, isHls, MediaElementBackend, MediaFormatError, resetMediaElement, supportsNativeHls, } from '@nomercy-entertainment/nomercy-player-core';
|
|
8
|
+
import { abrCeiling, appendAuthTokenParam, createAuthorizationXhrSetup, destroyHlsInstance, detectDisplayHdr, hdrDecision, isHls, MediaElementBackend, MediaFormatError, mediaFormatError, panePixels, resetMediaElement, sizeAbrCeiling, supportsNativeHls, } from '@nomercy-entertainment/nomercy-player-core';
|
|
9
9
|
const CODEC_LABELS = [
|
|
10
10
|
[/avc1|h264/i, 'H.264'],
|
|
11
11
|
[/hvc1|hev1|h265/i, 'H.265 (HEVC)'],
|
|
@@ -117,6 +117,25 @@ export class Html5VideoBackend extends MediaElementBackend {
|
|
|
117
117
|
* `removeEventListener` with the same reference on dispose.
|
|
118
118
|
*/
|
|
119
119
|
_hdrMqlListener;
|
|
120
|
+
/**
|
|
121
|
+
* Consumer policy for an all-HDR item on an SDR display, pushed in by
|
|
122
|
+
* `NMVideoPlayer` via `setHdrOnSdrFallback`. `undefined` until then —
|
|
123
|
+
* `hdrDecision`'s own default (`'play'`) applies at read time.
|
|
124
|
+
*/
|
|
125
|
+
_hdrOnSdrFallback;
|
|
126
|
+
/**
|
|
127
|
+
* Observer on the media element, so the ceiling follows a window drag, a
|
|
128
|
+
* fullscreen toggle and an orientation change. The facade's own observer
|
|
129
|
+
* feeds the `videoRect` event on `NMVideoPlayer` and the backend holds no
|
|
130
|
+
* reference to the facade, so the size signal is re-derived here.
|
|
131
|
+
*/
|
|
132
|
+
_paneObserver;
|
|
133
|
+
/**
|
|
134
|
+
* Pending coalesced re-apply. A drag fires the observer on every frame and
|
|
135
|
+
* every `autoLevelCapping` write is a decision ABR has to react to, so the
|
|
136
|
+
* burst collapses into one apply.
|
|
137
|
+
*/
|
|
138
|
+
_paneReapplyTimer;
|
|
120
139
|
constructor(container) {
|
|
121
140
|
const existing = container.querySelector('video');
|
|
122
141
|
let element;
|
|
@@ -133,6 +152,7 @@ export class Html5VideoBackend extends MediaElementBackend {
|
|
|
133
152
|
super(element, ownsElement, 'html5');
|
|
134
153
|
this.wireElementEvents();
|
|
135
154
|
this._wireHdrMatchMedia();
|
|
155
|
+
this._wirePaneResizeObserver();
|
|
136
156
|
}
|
|
137
157
|
// ── Lifecycle ──
|
|
138
158
|
/**
|
|
@@ -199,7 +219,7 @@ export class Html5VideoBackend extends MediaElementBackend {
|
|
|
199
219
|
// what produces the ~8 s stall on auto-advance.
|
|
200
220
|
resetMediaElement(this.element);
|
|
201
221
|
performance.mark('nm:backend:load:start');
|
|
202
|
-
const headerValue =
|
|
222
|
+
const headerValue = this._authHeaderProvider?.(url);
|
|
203
223
|
if (hlsUrl && !nativeHls) {
|
|
204
224
|
const { default: Hls } = await import('hls.js');
|
|
205
225
|
if (!Hls?.isSupported?.()) {
|
|
@@ -219,7 +239,7 @@ export class Html5VideoBackend extends MediaElementBackend {
|
|
|
219
239
|
enableCEA708Captions: true,
|
|
220
240
|
startPosition: typeof opts?.startTime === 'number' && opts.startTime > 0 ? opts.startTime : -1,
|
|
221
241
|
startFragPrefetch: true,
|
|
222
|
-
xhrSetup: createAuthorizationXhrSetup(
|
|
242
|
+
xhrSetup: createAuthorizationXhrSetup(this._authHeaderProvider),
|
|
223
243
|
});
|
|
224
244
|
this.hls = hlsInstance;
|
|
225
245
|
this.hlsInstance = hlsInstance;
|
|
@@ -280,6 +300,7 @@ export class Html5VideoBackend extends MediaElementBackend {
|
|
|
280
300
|
this._retryTimer = undefined;
|
|
281
301
|
}
|
|
282
302
|
this._teardownHdrMatchMedia();
|
|
303
|
+
this._teardownPaneResizeObserver();
|
|
283
304
|
this._teardownAudioGraph();
|
|
284
305
|
this.unload();
|
|
285
306
|
this.detachDomBridges(this.element);
|
|
@@ -912,13 +933,13 @@ export class Html5VideoBackend extends MediaElementBackend {
|
|
|
912
933
|
* overlay plugins can update button visibility without polling.
|
|
913
934
|
*
|
|
914
935
|
* Also builds the per-level HDR classification table and immediately
|
|
915
|
-
* applies the
|
|
936
|
+
* applies the ABR constraints for the current display and pane size.
|
|
916
937
|
*/
|
|
917
938
|
_emitHlsTrackLists() {
|
|
918
939
|
if (!this.hls)
|
|
919
940
|
return;
|
|
920
941
|
this._classifyHdrLevels();
|
|
921
|
-
this.
|
|
942
|
+
this._applyAbrConstraints(this._displayHdr);
|
|
922
943
|
void this._probeCodecCapabilities().then(() => {
|
|
923
944
|
const levels = this.qualityLevels();
|
|
924
945
|
this.emit('levels', { levels });
|
|
@@ -927,20 +948,66 @@ export class Html5VideoBackend extends MediaElementBackend {
|
|
|
927
948
|
if (tracks.length > 0)
|
|
928
949
|
this.emit('audioTracks', { tracks });
|
|
929
950
|
}
|
|
930
|
-
// ──
|
|
931
|
-
/**
|
|
951
|
+
// ── Display- and pane-aware ABR helpers ──
|
|
952
|
+
/**
|
|
953
|
+
* Consumer policy for an all-HDR item on an SDR display with no converter.
|
|
954
|
+
* Pushed once by `NMVideoPlayer` right after construction — see `IVideoBackend`.
|
|
955
|
+
*/
|
|
956
|
+
setHdrOnSdrFallback(fallback) {
|
|
957
|
+
this._hdrOnSdrFallback = fallback;
|
|
958
|
+
}
|
|
959
|
+
/**
|
|
960
|
+
* Wire a combined-query matchMedia listener once. The comma joins the two
|
|
961
|
+
* queries as an OR at the CSS level, so one listener catches a change in
|
|
962
|
+
* either signal; the boolean answer itself always comes from
|
|
963
|
+
* `detectDisplayHdr`, which asks them in priority order (video plane
|
|
964
|
+
* before page) rather than trusting whichever one the event fired for.
|
|
965
|
+
*/
|
|
932
966
|
_wireHdrMatchMedia() {
|
|
933
967
|
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function')
|
|
934
968
|
return;
|
|
935
|
-
const mql = window.matchMedia('(dynamic-range: high)');
|
|
936
|
-
this._displayHdr =
|
|
969
|
+
const mql = window.matchMedia('(video-dynamic-range: high), (dynamic-range: high)');
|
|
970
|
+
this._displayHdr = detectDisplayHdr();
|
|
937
971
|
this._hdrMql = mql;
|
|
938
|
-
const listener = (
|
|
939
|
-
this._onDisplayHdrChange(
|
|
972
|
+
const listener = () => {
|
|
973
|
+
this._onDisplayHdrChange(detectDisplayHdr());
|
|
940
974
|
};
|
|
941
975
|
this._hdrMqlListener = listener;
|
|
942
976
|
mql.addEventListener('change', listener);
|
|
943
977
|
}
|
|
978
|
+
/**
|
|
979
|
+
* Watch the media element's box so the pane ceiling follows the layout.
|
|
980
|
+
*
|
|
981
|
+
* The element rather than the container: `object-fit` letterboxing means the
|
|
982
|
+
* container can be wider than the picture, and the picture is what a
|
|
983
|
+
* rendition has to cover.
|
|
984
|
+
*/
|
|
985
|
+
_wirePaneResizeObserver() {
|
|
986
|
+
if (typeof ResizeObserver === 'undefined')
|
|
987
|
+
return;
|
|
988
|
+
const observer = new ResizeObserver(() => {
|
|
989
|
+
this._schedulePaneReapply();
|
|
990
|
+
});
|
|
991
|
+
observer.observe(this.element);
|
|
992
|
+
this._paneObserver = observer;
|
|
993
|
+
}
|
|
994
|
+
_schedulePaneReapply() {
|
|
995
|
+
if (this._paneReapplyTimer !== undefined)
|
|
996
|
+
return;
|
|
997
|
+
this._paneReapplyTimer = setTimeout(() => {
|
|
998
|
+
this._paneReapplyTimer = undefined;
|
|
999
|
+
this._applyAbrConstraints(this._displayHdr);
|
|
1000
|
+
}, 0);
|
|
1001
|
+
}
|
|
1002
|
+
/** Disconnect the pane observer and drop a pending re-apply. Called from `dispose()`. */
|
|
1003
|
+
_teardownPaneResizeObserver() {
|
|
1004
|
+
if (this._paneReapplyTimer !== undefined) {
|
|
1005
|
+
clearTimeout(this._paneReapplyTimer);
|
|
1006
|
+
this._paneReapplyTimer = undefined;
|
|
1007
|
+
}
|
|
1008
|
+
this._paneObserver?.disconnect();
|
|
1009
|
+
this._paneObserver = undefined;
|
|
1010
|
+
}
|
|
944
1011
|
/** Remove the matchMedia listener. Called from `dispose()`. */
|
|
945
1012
|
_teardownHdrMatchMedia() {
|
|
946
1013
|
if (this._hdrMql && this._hdrMqlListener) {
|
|
@@ -964,57 +1031,90 @@ export class Html5VideoBackend extends MediaElementBackend {
|
|
|
964
1031
|
});
|
|
965
1032
|
}
|
|
966
1033
|
/**
|
|
967
|
-
* Constrain HLS.js ABR to
|
|
1034
|
+
* Constrain HLS.js ABR to what this display and this pane can actually show,
|
|
1035
|
+
* per `hdrDecision` and `sizeAbrCeiling` — the oracles both native ports
|
|
1036
|
+
* mirror. The only writer of `autoLevelCapping`.
|
|
1037
|
+
*
|
|
1038
|
+
* The pane ceiling applies on every branch, including the ones with no
|
|
1039
|
+
* dynamic-range cap to make: an SDR item in a 740px player is the ordinary
|
|
1040
|
+
* case, and it is the one that was pulling a 4K rendition.
|
|
1041
|
+
*
|
|
1042
|
+
* `as-is` (HDR display, or an ordinary SDR item with no HDR rungs to cap):
|
|
1043
|
+
* - `autoLevelCapping` falls back to the pane ceiling, or `-1` when the
|
|
1044
|
+
* pane is unmeasured or already as large as the ladder's best rung. If
|
|
1045
|
+
* the current level is SDR and an HDR peer exists at the same
|
|
1046
|
+
* resolution, prefer it via `nextLevel` (soft switch — the next fragment
|
|
1047
|
+
* boundary picks it up, no stutter). The peer shares the current level's
|
|
1048
|
+
* resolution, so it cannot violate the pane ceiling. A no-op on the
|
|
1049
|
+
* SDR-only case: there is no HDR peer to find.
|
|
1050
|
+
*
|
|
1051
|
+
* `cap-to` (SDR display, an SDR rung exists):
|
|
1052
|
+
* - Caps `autoLevelCapping` to the more restrictive of the dynamic-range
|
|
1053
|
+
* rung and the pane rung, by raw manifest index. For interleaved
|
|
1054
|
+
* manifests (HDR index < SDR index at the same resolution) the cap alone
|
|
1055
|
+
* is insufficient — `nextLevel` also forces off any currently-playing
|
|
1056
|
+
* HDR variant to its SDR peer.
|
|
968
1057
|
*
|
|
969
|
-
*
|
|
970
|
-
* - Sets `hls.autoLevelCapping` to the highest SDR level index so the
|
|
971
|
-
* ABR algorithm never auto-selects above it. For interleaved manifests
|
|
972
|
-
* where some HDR level indices sit below some SDR level indices,
|
|
973
|
-
* `autoLevelCapping` alone is insufficient — we also force a `nextLevel`
|
|
974
|
-
* switch away from any currently-playing HDR variant to its nearest SDR
|
|
975
|
-
* peer at the same (or next-lower) resolution.
|
|
976
|
-
* - Emits a fresh `levels` event so overlay plugins can hide HDR rows.
|
|
1058
|
+
* `tone-map` — never returned here; see `backendCanToneMap` below.
|
|
977
1059
|
*
|
|
978
|
-
*
|
|
979
|
-
*
|
|
980
|
-
*
|
|
981
|
-
*
|
|
982
|
-
*
|
|
983
|
-
*
|
|
1060
|
+
* `play-unconverted` — no SDR rung, no converter, consumer opted in: the
|
|
1061
|
+
* dynamic range goes unconstrained on purpose, the pane ceiling still
|
|
1062
|
+
* holds, no error.
|
|
1063
|
+
*
|
|
1064
|
+
* `refuse` — no SDR rung, no converter, consumer opted out: escalated
|
|
1065
|
+
* through the same fatal-error channel HLS stream errors use.
|
|
1066
|
+
*
|
|
1067
|
+
* Always re-emits `levels` so overlay plugins can update their menus.
|
|
984
1068
|
*/
|
|
985
|
-
|
|
1069
|
+
_applyAbrConstraints(displayHdr) {
|
|
986
1070
|
this._displayHdr = displayHdr;
|
|
987
1071
|
if (!this.hls || this._levelIsHdr.length === 0)
|
|
988
1072
|
return;
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1073
|
+
const levels = this.qualityLevels();
|
|
1074
|
+
const pane = panePixels(this.element);
|
|
1075
|
+
const sizeCeiling = sizeAbrCeiling(levels, pane.widthPx, pane.heightPx);
|
|
1076
|
+
const sizeCapIndex = sizeCeiling === null ? -1 : sizeCeiling.index;
|
|
1077
|
+
// Neither hls.js nor any shipping <video> implementation performs an
|
|
1078
|
+
// HDR→SDR tone-map during decode — a platform fact, not unfinished work.
|
|
1079
|
+
const backendCanToneMap = false;
|
|
1080
|
+
const decision = hdrDecision(levels, displayHdr, backendCanToneMap, this._hdrOnSdrFallback ?? 'play');
|
|
1081
|
+
switch (decision.kind) {
|
|
1082
|
+
case 'as-is': {
|
|
1083
|
+
this.hls.autoLevelCapping = sizeCapIndex;
|
|
1084
|
+
const playingIdx = this.currentLevel();
|
|
1085
|
+
if (playingIdx >= 0 && !this._levelIsHdr[playingIdx]) {
|
|
1086
|
+
const currentHeight = this.hls.levels[playingIdx]?.height;
|
|
1087
|
+
const hdrPeerIdx = this.hls.levels.findIndex((level, idx) => this._levelIsHdr[idx] && level.height === currentHeight);
|
|
1088
|
+
if (hdrPeerIdx >= 0)
|
|
1089
|
+
this.hls.nextLevel = hdrPeerIdx;
|
|
1090
|
+
}
|
|
1091
|
+
break;
|
|
1000
1092
|
}
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1093
|
+
case 'cap-to': {
|
|
1094
|
+
const ceiling = abrCeiling(decision.level, sizeCeiling) ?? decision.level;
|
|
1095
|
+
this.hls.autoLevelCapping = ceiling.index;
|
|
1096
|
+
const playingIdx = this.currentLevel();
|
|
1097
|
+
if (playingIdx >= 0 && this._levelIsHdr[playingIdx]) {
|
|
1098
|
+
const currentHeight = this.hls.levels[playingIdx]?.height;
|
|
1099
|
+
const sdrPeerIdx = this.hls.levels.findIndex((level, idx) => !this._levelIsHdr[idx] && level.height === currentHeight);
|
|
1100
|
+
this.hls.nextLevel = sdrPeerIdx >= 0 ? sdrPeerIdx : ceiling.index;
|
|
1101
|
+
}
|
|
1102
|
+
break;
|
|
1103
|
+
}
|
|
1104
|
+
case 'tone-map': {
|
|
1105
|
+
// Unreachable while `backendCanToneMap` is false above — handled
|
|
1106
|
+
// explicitly so a backend that DOES gain a converter can't fall
|
|
1107
|
+
// through this switch unnoticed.
|
|
1108
|
+
break;
|
|
1109
|
+
}
|
|
1110
|
+
case 'play-unconverted': {
|
|
1111
|
+
this.hls.autoLevelCapping = sizeCapIndex;
|
|
1112
|
+
break;
|
|
1008
1113
|
}
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
const currentHeight = this.hls.levels[playingIdx]?.height;
|
|
1014
|
-
const sdrPeerIdx = this.hls.levels.findIndex((level, idx) => !this._levelIsHdr[idx] && level.height === currentHeight);
|
|
1015
|
-
const targetIdx = sdrPeerIdx >= 0 ? sdrPeerIdx : maxSdrIdx;
|
|
1016
|
-
if (targetIdx >= 0)
|
|
1017
|
-
this.hls.nextLevel = targetIdx;
|
|
1114
|
+
case 'refuse': {
|
|
1115
|
+
const err = mediaFormatError('video:media/hdr-unplayable', 'Every rendition in this manifest is HDR and the active display cannot render HDR.');
|
|
1116
|
+
this._escalateHlsError(err.code, err.message);
|
|
1117
|
+
break;
|
|
1018
1118
|
}
|
|
1019
1119
|
}
|
|
1020
1120
|
// Re-emit the level list so overlay plugins can update their menus
|
|
@@ -1026,7 +1126,7 @@ export class Html5VideoBackend extends MediaElementBackend {
|
|
|
1026
1126
|
}
|
|
1027
1127
|
/** Called by the matchMedia listener when the display's HDR capability changes. */
|
|
1028
1128
|
_onDisplayHdrChange(displayNowHdr) {
|
|
1029
|
-
this.
|
|
1129
|
+
this._applyAbrConstraints(displayNowHdr);
|
|
1030
1130
|
}
|
|
1031
1131
|
/**
|
|
1032
1132
|
* Subscribe to HLS level-change signals on the current `this.hls` instance
|
package/dist/index.d.ts
CHANGED
|
@@ -107,6 +107,8 @@ export declare class NMVideoPlayer<T extends VideoPlaylistItem = VideoPlaylistIt
|
|
|
107
107
|
private _checkItemEndingSoon;
|
|
108
108
|
/** @internal */
|
|
109
109
|
private _timeStateAt;
|
|
110
|
+
/** @internal */
|
|
111
|
+
private _mediaIsStale;
|
|
110
112
|
playbackRate: {
|
|
111
113
|
(): number;
|
|
112
114
|
(rate: number): Promise<void>;
|
package/dist/index.js
CHANGED
|
@@ -260,13 +260,13 @@ 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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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));
|
|
266
|
+
// Consumer's HDR-on-SDR policy, read once here rather than normalized
|
|
267
|
+
// into `options` — `hdrDecision`'s own default ('play') applies at the
|
|
268
|
+
// point of use when this was never pushed at all.
|
|
269
|
+
instance.setHdrOnSdrFallback?.(this.options?.hdrOnSdr ?? 'play');
|
|
270
270
|
// Mount the ResizeObserver that keeps videoRect() fresh. Done here so
|
|
271
271
|
// the observer sees the real video element immediately after backend init.
|
|
272
272
|
this._mountVideoRectObserver();
|
|
@@ -356,6 +356,15 @@ export class NMVideoPlayer extends EventEmitter {
|
|
|
356
356
|
this.emit('subtitleCue', data);
|
|
357
357
|
});
|
|
358
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;
|
|
359
368
|
const currentTime = instance.currentTime();
|
|
360
369
|
const duration = instance.duration();
|
|
361
370
|
// The payload is core's full TimeState snapshot, built from the
|
|
@@ -698,9 +707,23 @@ composeMixins(NMVideoPlayer.prototype, ...playerCoreMethods);
|
|
|
698
707
|
{
|
|
699
708
|
const composedDispose = NMVideoPlayer.prototype.dispose;
|
|
700
709
|
NMVideoPlayer.prototype.dispose = async function () {
|
|
710
|
+
// The container id is released before the first await, not after the
|
|
711
|
+
// teardown resolves. Consumers re-create on the same container in the
|
|
712
|
+
// same tick — a responsive view handing `#player1` from its desktop
|
|
713
|
+
// variant to its mobile one does exactly that — and the registry still
|
|
714
|
+
// held this player, so they were given back the very instance they had
|
|
715
|
+
// just asked to dispose. `setup()` then threw `already-setup`, and the
|
|
716
|
+
// throw left that player attached and playing with nobody holding a
|
|
717
|
+
// reference to stop it.
|
|
718
|
+
const claimedId = this.playerId;
|
|
719
|
+
_instances.delete(claimedId);
|
|
701
720
|
await composedDispose.call(this);
|
|
702
|
-
if (this.phase() !== 'disposed')
|
|
703
|
-
|
|
721
|
+
if (this.phase() !== 'disposed') {
|
|
722
|
+
// beforeDispose was prevented — the player is still alive, so it
|
|
723
|
+
// takes its id back and the backend stays intact.
|
|
724
|
+
_instances.set(claimedId, this);
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
704
727
|
this._disposeBackend();
|
|
705
728
|
};
|
|
706
729
|
}
|