@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 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: () => string | undefined | Promise<string | undefined>): void;
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 dynamic-range ABR constraint for the current display.
246
+ * applies the ABR constraints for the current display and pane size.
228
247
  */
229
248
  private _emitHlsTrackLists;
230
- /** Wire the `matchMedia('(dynamic-range: high)')` change listener once. */
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 the display's dynamic-range capability.
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 display (`displayHdr = false`):
243
- * - Sets `hls.autoLevelCapping` to the highest SDR level index so the
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
- * HDR display (`displayHdr = true`):
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 _applyHdrConstraint;
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 = await this._authHeaderProvider?.();
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(headerValue),
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 dynamic-range ABR constraint for the current display.
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._applyHdrConstraint(this._displayHdr);
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
- // ── HDR-aware ABR helpers ──
931
- /** Wire the `matchMedia('(dynamic-range: high)')` change listener once. */
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 = mql.matches;
969
+ const mql = window.matchMedia('(video-dynamic-range: high), (dynamic-range: high)');
970
+ this._displayHdr = detectDisplayHdr();
937
971
  this._hdrMql = mql;
938
- const listener = (event) => {
939
- this._onDisplayHdrChange(event.matches);
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 the display's dynamic-range capability.
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
- * SDR display (`displayHdr = false`):
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
- * HDR display (`displayHdr = true`):
979
- * - Lifts `autoLevelCapping` to `-1` (uncapped). ABR is free to select
980
- * HDR variants. If the current level is SDR and an HDR peer exists at
981
- * the same resolution, prefer it by setting `nextLevel` (soft switch —
982
- * the next fragment boundary picks it up; no stutter).
983
- * - Emits a fresh `levels` event so overlay plugins can reveal HDR rows.
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
- _applyHdrConstraint(displayHdr) {
1069
+ _applyAbrConstraints(displayHdr) {
986
1070
  this._displayHdr = displayHdr;
987
1071
  if (!this.hls || this._levelIsHdr.length === 0)
988
1072
  return;
989
- if (displayHdr) {
990
- // Lift the cap — ABR can now pick HDR variants.
991
- this.hls.autoLevelCapping = -1;
992
- // Upgrade the playing level to an HDR peer at the same resolution
993
- // if we were previously constrained to SDR.
994
- const playingIdx = this.currentLevel();
995
- if (playingIdx >= 0 && !this._levelIsHdr[playingIdx]) {
996
- const currentHeight = this.hls.levels[playingIdx]?.height;
997
- const hdrPeerIdx = this.hls.levels.findIndex((level, idx) => this._levelIsHdr[idx] && level.height === currentHeight);
998
- if (hdrPeerIdx >= 0)
999
- this.hls.nextLevel = hdrPeerIdx;
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
- else {
1003
- // Find the highest-indexed SDR level — cap ABR there.
1004
- let maxSdrIdx = -1;
1005
- for (let idx = 0; idx < this._levelIsHdr.length; idx++) {
1006
- if (!this._levelIsHdr[idx])
1007
- maxSdrIdx = idx;
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
- this.hls.autoLevelCapping = maxSdrIdx;
1010
- // If currently playing an HDR level, force-switch to the best SDR peer.
1011
- const playingIdx = this.currentLevel();
1012
- if (playingIdx >= 0 && this._levelIsHdr[playingIdx]) {
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._applyHdrConstraint(displayNowHdr);
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
- instance.setAuthHeaderProvider?.(async () => {
264
- const bearer = this.options?.auth?.bearerToken;
265
- if (!bearer)
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));
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
- return; // beforeDispose was prevented — backend + registry stay intact
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
  }