@nomercy-entertainment/nomercy-video-player 2.2.1 → 2.2.3

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,30 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [2.2.3] — 2026-09-25
6
+
7
+ ### Added
8
+
9
+ - `disableClickToPause` in `setup()` stops a click from pausing the video.
10
+
11
+ ### Fixed
12
+
13
+ - Streams added with `registerStream()` are now used for playback.
14
+ - Playlist thumbnails load from the configured image URL.
15
+ - Changing auth at runtime now applies to media requests.
16
+ - Georgia subtitles fall back to a serif font, and subtitle colors match the palette.
17
+ - One press of `?` opens the shortcuts overlay.
18
+ - Cast artwork shows the right image, and the media-key option works.
19
+ - The help key on a TV remote works.
20
+
21
+ ## [2.2.2] — 2026-09-04
22
+
23
+ ### Fixed
24
+
25
+ - A 404 got the full retry ladder: thirty seconds of spinner on an episode that was never encoded, ending in the generic network error. The file is not late, it is absent, and no amount of waiting changes the answer; 410 is the same answer said more firmly. Both now get no retries and their own code, `core:stream/media-absent`, so a consumer can offer the next item instead of the dead-end overlay it shows for a decode failure. The code matches `CoreErrorCodes.MEDIA_ABSENT` in the Kotlin trio.
26
+ - Georgia's subtitle-font fallback was `sans-serif`. Georgia is a serif face, so a client without it installed — Android carries none — fell back to the one font every other choice already draws, collapsing distinct options into each other.
27
+
28
+
5
29
  ## [2.2.1] — 2026-09-04
6
30
 
7
31
  ### Changed
@@ -1,5 +1,11 @@
1
- import type { AudioTrack, AuthHeaderProvider, BackendLoaderState, BackendState, HdrOnSdrFallback, MinimalBackendEventPayload, QualityLevel, SubtitleCueChange, SubtitleTrack } from '@nomercy-entertainment/nomercy-player-core';
1
+ import type { AudioTrack, AuthHeaderProvider, BackendLoaderState, BackendState, HdrOnSdrFallback, IStreamSource, MinimalBackendEventPayload, QualityLevel, SubtitleCueChange, SubtitleTrack } from '@nomercy-entertainment/nomercy-player-core';
2
2
  import type { HtmlPreloadMode } from '../../types.js';
3
+ /**
4
+ * Asks the player's stream registry whether a consumer registered a factory for
5
+ * this URL. Returns a source to play it with, or `undefined` to let the backend
6
+ * handle the URL the way it always has.
7
+ */
8
+ export type StreamResolver = (url: string, contentType?: string) => IStreamSource | undefined;
3
9
  /**
4
10
  * Per-event payload map. Each backend event has a fixed payload shape so
5
11
  * `IVideoBackend.on(event, fn)` narrows the listener's parameter type
@@ -136,6 +142,14 @@ export interface IVideoBackend {
136
142
  * stack omit it.
137
143
  */
138
144
  setAuthHeaderProvider?(provider: AuthHeaderProvider): void;
145
+ /**
146
+ * Wire the lookup that asks the player's stream registry whether a consumer
147
+ * has registered a factory for a URL. The backend calls it before its own
148
+ * HLS and progressive handling, and falls through to that handling when the
149
+ * lookup returns nothing, so a player with no registered factory behaves
150
+ * exactly as it did before. Optional — backends with no registry omit it.
151
+ */
152
+ setStreamResolver?(resolver: StreamResolver): void;
139
153
  /**
140
154
  * Consumer policy for an all-HDR item on a display that can't show HDR and
141
155
  * a backend that can't tone-map it. Optional — backends that never face the
@@ -1,15 +1,16 @@
1
1
  import type { AudioTrack, HdrOnSdrFallback, QualityLevel, SubtitleTrack } from '@nomercy-entertainment/nomercy-player-core';
2
2
  import type { HtmlPreloadMode } from '../../types.js';
3
- import type { BackendEventPayload, BackendState, IVideoBackend } from './IVideoBackend.js';
3
+ import type { BackendEventPayload, BackendState, IVideoBackend, StreamResolver } from './IVideoBackend.js';
4
4
  import { MediaElementBackend } from '@nomercy-entertainment/nomercy-player-core';
5
5
  /**
6
6
  * Default video backend. Wraps an `<HTMLVideoElement>` for transport.
7
7
  *
8
- * HLS support: native pass-through when `canPlayType` reports support
9
- * (Safari / iOS), otherwise dynamically imports `hls.js` (peer dep — not
10
- * bundled; IIFE build externalises it as `window.Hls`, ESM build resolves
11
- * it from the consumer's node_modules) and attaches it. MSE / WebCodecs
12
- * backends ship later.
8
+ * HLS support: native pass-through when `canPlayType` reports support,
9
+ * otherwise dynamically imports `hls.js` and attaches it. `hls.js` is a
10
+ * runtime dependency of nomercy-player-core, not a peer dependency, so an
11
+ * npm consumer installs it without asking. The IIFE build externalizes it
12
+ * as `window.Hls`; the ESM build resolves it from node_modules. MSE /
13
+ * WebCodecs backends ship later.
13
14
  */
14
15
  export declare class Html5VideoBackend extends MediaElementBackend<HTMLVideoElement, BackendEventPayload> implements IVideoBackend {
15
16
  readonly kind: "html5";
@@ -137,6 +138,14 @@ export declare class Html5VideoBackend extends MediaElementBackend<HTMLVideoElem
137
138
  unload(): void;
138
139
  dispose(): void;
139
140
  private _teardownAudioGraph;
141
+ private _streamResolver?;
142
+ private _customSource?;
143
+ /**
144
+ * Wire the lookup that asks the player's stream registry for a consumer's
145
+ * factory. Without it the backend keeps its own HLS and progressive handling
146
+ * and a registered factory is never consulted.
147
+ */
148
+ setStreamResolver(resolver: StreamResolver): void;
140
149
  buffered(): number;
141
150
  videoWidth(): number;
142
151
  videoHeight(): number;
@@ -6,7 +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
+ import { isMediaAbsentStatus, MEDIA_ABSENT, SOURCE_OUTAGE_BACKOFF_MS, sourceOutageRetryLimit } from './source-outage.js';
10
10
  const CODEC_LABELS = [
11
11
  [/avc1|h264/i, 'H.264'],
12
12
  [/hvc1|hev1|h265/i, 'H.265 (HEVC)'],
@@ -34,11 +34,12 @@ function humanCodec(raw) {
34
34
  /**
35
35
  * Default video backend. Wraps an `<HTMLVideoElement>` for transport.
36
36
  *
37
- * HLS support: native pass-through when `canPlayType` reports support
38
- * (Safari / iOS), otherwise dynamically imports `hls.js` (peer dep — not
39
- * bundled; IIFE build externalises it as `window.Hls`, ESM build resolves
40
- * it from the consumer's node_modules) and attaches it. MSE / WebCodecs
41
- * backends ship later.
37
+ * HLS support: native pass-through when `canPlayType` reports support,
38
+ * otherwise dynamically imports `hls.js` and attaches it. `hls.js` is a
39
+ * runtime dependency of nomercy-player-core, not a peer dependency, so an
40
+ * npm consumer installs it without asking. The IIFE build externalizes it
41
+ * as `window.Hls`; the ESM build resolves it from node_modules. MSE /
42
+ * WebCodecs backends ship later.
42
43
  */
43
44
  export class Html5VideoBackend extends MediaElementBackend {
44
45
  kind = 'html5';
@@ -238,6 +239,21 @@ export class Html5VideoBackend extends MediaElementBackend {
238
239
  resetMediaElement(this.element);
239
240
  performance.mark('nm:backend:load:start');
240
241
  const headerValue = this._authHeaderProvider?.(url);
242
+ // A consumer's factory is asked first, because the branches below cover
243
+ // only HLS and whatever the element itself decodes. Nothing registered
244
+ // means nothing returned, and the built-in path runs untouched.
245
+ if (this._customSource) {
246
+ this._customSource.destroy();
247
+ this._customSource = undefined;
248
+ }
249
+ const customSource = this._streamResolver?.(url);
250
+ if (customSource) {
251
+ this._customSource = customSource;
252
+ await customSource.attach(this.element);
253
+ this._state = 'ready';
254
+ performance.mark('nm:backend:load:end');
255
+ return;
256
+ }
241
257
  if (hlsUrl && !nativeHls) {
242
258
  const { default: Hls } = await import('hls.js');
243
259
  if (!Hls?.isSupported?.()) {
@@ -349,6 +365,16 @@ export class Html5VideoBackend extends MediaElementBackend {
349
365
  // playbackRate / volume / mute / unmute / captureStream / setSinkId /
350
366
  // getSinkId / mediaKeys / setMediaKeys / mediaElement / pauseLoader /
351
367
  // resumeLoader / loaderState / setAuthHeaderProvider
368
+ _streamResolver;
369
+ _customSource;
370
+ /**
371
+ * Wire the lookup that asks the player's stream registry for a consumer's
372
+ * factory. Without it the backend keeps its own HLS and progressive handling
373
+ * and a registered factory is never consulted.
374
+ */
375
+ setStreamResolver(resolver) {
376
+ this._streamResolver = resolver;
377
+ }
352
378
  // ── Time / position (video-specific: buffered uses currentTime walk) ──
353
379
  buffered() {
354
380
  const ranges = this.element.buffered;
@@ -381,7 +407,9 @@ export class Html5VideoBackend extends MediaElementBackend {
381
407
  default: hlsAudioTrack.default === true,
382
408
  }));
383
409
  }
384
- // Native: HTMLMediaElement.audioTracks (Safari/Chrome with multi-audio).
410
+ // Native: HTMLMediaElement.audioTracks. Measured absent in Chrome 152,
411
+ // Edge 152 and bundled Chromium, so this branch is Safari in practice
412
+ // and yields [] elsewhere; the HLS path above is what fills the list.
385
413
  const nativeTracks = this.element.audioTracks;
386
414
  if (nativeTracks && nativeTracks.length > 0) {
387
415
  const out = [];
@@ -860,6 +888,15 @@ export class Html5VideoBackend extends MediaElementBackend {
860
888
  if (httpStatus === 0)
861
889
  this._sawConnectionFailure = true;
862
890
  const limit = sourceOutageRetryLimit(httpStatus, this._sawConnectionFailure);
891
+ // The server answered that this item has no media. Waiting cannot change
892
+ // that, and a consumer can act on it — offer the next item — but only if
893
+ // it is told which failure this was, so it gets its own code rather than
894
+ // the generic network one every outage ends with.
895
+ if (isMediaAbsentStatus(httpStatus) && !this._sawConnectionFailure) {
896
+ this._recoveringFromOutage = false;
897
+ this._escalateHlsError(MEDIA_ABSENT, `media absent (HTTP ${httpStatus}): ${data.details}`);
898
+ return;
899
+ }
863
900
  if (this._netRetryCount >= limit) {
864
901
  // The ladder is spent, but a device that gets a route back later
865
902
  // still deserves its session rather than a dead error overlay.
@@ -45,9 +45,26 @@ export declare function sourceOutageBudgetMs(): number;
45
45
  * connection-failure-gated budget would cut short.
46
46
  */
47
47
  export declare function isOriginDownStatus(httpStatus: number): boolean;
48
+ /** No rung at all: the answer will not change by asking again. */
49
+ export declare const NO_RETRIES = 0;
50
+ /**
51
+ * True for a status that means the server answered, and answered that this
52
+ * media does not exist.
53
+ *
54
+ * An episode that has not been encoded yet answers 404, and it will answer 404
55
+ * to every retry: the file is not late, it is absent. Waiting is not a strategy
56
+ * for it, and the viewer is the only one who can decide what to do instead.
57
+ * 410 is the same answer said more firmly.
58
+ */
59
+ export declare function isMediaAbsentStatus(httpStatus: number): boolean;
48
60
  /**
49
61
  * How many rungs this failure is allowed.
50
62
  *
63
+ * A 404 gets NONE. It used to get five, which is thirty seconds of spinner on
64
+ * an episode that was never encoded, and at the end of it the viewer was told
65
+ * nothing they could act on. A definite answer is worth surfacing the moment it
66
+ * arrives, so the consumer can offer to move to the next item.
67
+ *
51
68
  * @param httpStatus - The status the load gave up on, or 0 when the failure
52
69
  * carried no response at all, which is what a refused connection looks like
53
70
  * from a browser.
@@ -56,3 +73,10 @@ export declare function isOriginDownStatus(httpStatus: number): boolean;
56
73
  * from a video that is genuinely gone.
57
74
  */
58
75
  export declare function sourceOutageRetryLimit(httpStatus: number, sawConnectionFailure: boolean): number;
76
+ /**
77
+ * The code a consumer reads to offer "skip to the next item" rather than a dead
78
+ * end. Its Kotlin peer is `CoreErrorCodes.MEDIA_ABSENT`; the two trios must
79
+ * name this the same failure or a client written against one is wrong on the
80
+ * other.
81
+ */
82
+ export declare const MEDIA_ABSENT = "core:stream/media-absent";
@@ -63,9 +63,28 @@ export function isOriginDownStatus(httpStatus) {
63
63
  || httpStatus === 503
64
64
  || httpStatus === 504;
65
65
  }
66
+ /** No rung at all: the answer will not change by asking again. */
67
+ export const NO_RETRIES = 0;
68
+ /**
69
+ * True for a status that means the server answered, and answered that this
70
+ * media does not exist.
71
+ *
72
+ * An episode that has not been encoded yet answers 404, and it will answer 404
73
+ * to every retry: the file is not late, it is absent. Waiting is not a strategy
74
+ * for it, and the viewer is the only one who can decide what to do instead.
75
+ * 410 is the same answer said more firmly.
76
+ */
77
+ export function isMediaAbsentStatus(httpStatus) {
78
+ return httpStatus === 404 || httpStatus === 410;
79
+ }
66
80
  /**
67
81
  * How many rungs this failure is allowed.
68
82
  *
83
+ * A 404 gets NONE. It used to get five, which is thirty seconds of spinner on
84
+ * an episode that was never encoded, and at the end of it the viewer was told
85
+ * nothing they could act on. A definite answer is worth surfacing the moment it
86
+ * arrives, so the consumer can offer to move to the next item.
87
+ *
69
88
  * @param httpStatus - The status the load gave up on, or 0 when the failure
70
89
  * carried no response at all, which is what a refused connection looks like
71
90
  * from a browser.
@@ -74,11 +93,22 @@ export function isOriginDownStatus(httpStatus) {
74
93
  * from a video that is genuinely gone.
75
94
  */
76
95
  export function sourceOutageRetryLimit(httpStatus, sawConnectionFailure) {
96
+ // A connection failure first means the host went away; the 404 that follows
97
+ // is a route table still warming up, not a missing file.
77
98
  if (sawConnectionFailure)
78
99
  return SOURCE_OUTAGE_BACKOFF_MS.length;
79
100
  if (isOriginDownStatus(httpStatus))
80
101
  return SOURCE_OUTAGE_BACKOFF_MS.length;
102
+ if (isMediaAbsentStatus(httpStatus))
103
+ return NO_RETRIES;
81
104
  if (httpStatus > 0)
82
105
  return HTTP_STATUS_RETRY_LIMIT;
83
106
  return SOURCE_OUTAGE_BACKOFF_MS.length;
84
107
  }
108
+ /**
109
+ * The code a consumer reads to offer "skip to the next item" rather than a dead
110
+ * end. Its Kotlin peer is `CoreErrorCodes.MEDIA_ABSENT`; the two trios must
111
+ * name this the same failure or a client written against one is wrong on the
112
+ * other.
113
+ */
114
+ export const MEDIA_ABSENT = 'core:stream/media-absent';
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ActionOptions, AriaLiveLevel, AudioTrack, AuthConfig, BasePlaylistItem, BufferState, CanPlayResult, CastState, CastTarget, Chapter, CurrentAudioTrackSelection, CurrentQualitySelection, CurrentSubtitleSelection, DeviceCapabilities, ICueParser, IPlatform, IPlayer, IPreloadStrategy, IStreamFactory, ITransitionStrategy, IUrlResolver, TimeState as KitTimeState, LoadOptions, NetworkState, PlaybackMetrics, PlayerExperimental, PlayerPhase, Plugin, PluginCtorWithId, QualityLevel, ResolvedUrl, SetupState, SidecarSubtitleInput, SubtitleStyle, SubtitleTrack, Translations, UrlCategory, VisibilityState } from '@nomercy-entertainment/nomercy-player-core';
1
+ import type { ActionOptions, AriaLiveLevel, AudioTrack, AuthConfig, BasePlaylistItem, BufferState, CanPlayResult, CastState, CastTarget, Chapter, CurrentAudioTrackSelection, CurrentQualitySelection, CurrentSubtitleSelection, DeviceCapabilities, ICueParser, IPlatform, IPlayer, IPreloadStrategy, IStreamFactory, IStreamSource, ITransitionStrategy, IUrlResolver, TimeState as KitTimeState, LoadOptions, NetworkState, PlaybackMetrics, PlayerExperimental, PlayerPhase, Plugin, PluginCtorWithId, QualityLevel, ResolvedUrl, SetupState, SidecarSubtitleInput, SubtitleStyle, SubtitleTrack, Translations, UrlCategory, VisibilityState } from '@nomercy-entertainment/nomercy-player-core';
2
2
  import type { IVideoBackend, VideoBackendKind } from './adapters/video-backend/IVideoBackend.js';
3
3
  import type { AudioTrackState, IVideoPlayer, PlayState, QualityState, RepeatState, SegmentOptions, ShuffleState, Stretching, VideoEventMap, VideoPlayerConfig, VideoPlaylistItem, VideoRect, VolumeState } from './types.js';
4
4
  import { EventEmitter } from '@nomercy-entertainment/nomercy-player-core';
@@ -200,12 +200,15 @@ export declare class NMVideoPlayer<T extends VideoPlaylistItem = VideoPlaylistIt
200
200
  * `mediaReady` fires so the backend's track lists are populated.
201
201
  *
202
202
  * Language matching: exact tag first, then prefix (e.g. `'en'` matches
203
- * `'en-US'`). No match → leave selection at off; no warning emitted.
203
+ * `'en-US'`). No match leaves an UNSELECTED slot alone, but where a track is
204
+ * already selected the slot is handed to the stream's own default rendition
205
+ * instead. No warning either way.
204
206
  */
205
207
  private _applyDefaultTracks;
206
208
  /** Test-only: clear the registry. Not part of the public API. */
207
209
  static _resetRegistry(): void;
208
210
  registerStream: (factory: IStreamFactory, prepend?: boolean) => this;
211
+ resolveCustomStream: (url: string, contentType?: string) => IStreamSource | undefined;
209
212
  unregisterStream: (id: string) => this;
210
213
  streams: () => ReadonlyArray<string>;
211
214
  getStreamFactory: (id: string) => IStreamFactory | undefined;
package/dist/index.js CHANGED
@@ -212,7 +212,9 @@ export class NMVideoPlayer extends EventEmitter {
212
212
  * `mediaReady` fires so the backend's track lists are populated.
213
213
  *
214
214
  * Language matching: exact tag first, then prefix (e.g. `'en'` matches
215
- * `'en-US'`). No match → leave selection at off; no warning emitted.
215
+ * `'en-US'`). No match leaves an UNSELECTED slot alone, but where a track is
216
+ * already selected the slot is handed to the stream's own default rendition
217
+ * instead. No warning either way.
216
218
  */
217
219
  _applyDefaultTracks() {
218
220
  const remembered = this.languageMemory.subtitleChoice();
@@ -308,7 +310,14 @@ export class NMVideoPlayer extends EventEmitter {
308
310
  // lazily so getter-style tokens (Vue refs, stores) stay live.
309
311
  // Read per request rather than captured, so a consumer whose token or
310
312
  // rule changes mid-session is not answered from a stale closure.
311
- instance.setAuthHeaderProvider?.((url) => this.options?.auth?.mediaAuthorization?.(url));
313
+ // Live config, not `options.auth`: setup seeds it and `player.auth()`
314
+ // updates it, so a rule set after setup reaches media requests too.
315
+ instance.setAuthHeaderProvider?.((url) => this.auth()?.mediaAuthorization?.(url));
316
+ // Without this the stream registry is seeded and never asked, so
317
+ // `registerStream` records a factory that no load path consults. Only
318
+ // consumer-registered factories are offered: HLS and progressive files
319
+ // stay on the backend's own path exactly as before.
320
+ instance.setStreamResolver?.((url, contentType) => this.resolveCustomStream?.(url, contentType));
312
321
  // Consumer's HDR-on-SDR policy, read once here rather than normalized
313
322
  // into `options` — `hdrDecision`'s own default ('play') applies at the
314
323
  // point of use when this was never pushed at all.