demuxe 0.3.0-beta.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.
Files changed (124) hide show
  1. package/LICENSE +352 -0
  2. package/README.md +75 -0
  3. package/bin/demuxe.mjs +30 -0
  4. package/docs/API-MIGRATION.md +54 -0
  5. package/docs/BETA.md +98 -0
  6. package/docs/BRANDING-MIGRATION.md +77 -0
  7. package/docs/COMPATIBILITY-EXPANSION.md +181 -0
  8. package/docs/LICENSING.md +83 -0
  9. package/docs/OPTIMIZATION-COMPLETION.md +511 -0
  10. package/docs/OPTIMIZATION-FLAC.md +178 -0
  11. package/docs/OPTIMIZATION-INTEGRATION.md +221 -0
  12. package/docs/OPTIMIZATION-REVIEW-FIXES.md +78 -0
  13. package/docs/PLAYER-COMPONENT.md +247 -0
  14. package/docs/PUBLIC-API-VALIDATION.md +163 -0
  15. package/docs/PUBLIC-API.md +245 -0
  16. package/docs/RELEASE.md +181 -0
  17. package/docs/RUNTIME-ASSETS.md +127 -0
  18. package/engine-build.json +8781 -0
  19. package/examples/custom-controls.html +14 -0
  20. package/examples/player-element.html +5 -0
  21. package/fixtures/DejaVuSans.ttf +0 -0
  22. package/fixtures/FONT-LICENSE.txt +187 -0
  23. package/index.d.ts +1 -0
  24. package/index.js +1 -0
  25. package/package.json +46 -0
  26. package/player.d.ts +1 -0
  27. package/player.js +1 -0
  28. package/release-manifest.json +541 -0
  29. package/sources.lock.json +96 -0
  30. package/third_party/notices/dav1d/COPYING +23 -0
  31. package/third_party/notices/emscripten/LICENSE +102 -0
  32. package/third_party/notices/emscripten/system/lib/compiler-rt/LICENSE.TXT +311 -0
  33. package/third_party/notices/emscripten/system/lib/libc/musl/COPYRIGHT +193 -0
  34. package/third_party/notices/emscripten/system/lib/libcxx/LICENSE.TXT +311 -0
  35. package/third_party/notices/emscripten/system/lib/libcxxabi/LICENSE.TXT +311 -0
  36. package/third_party/notices/emsdk/LICENSE +27 -0
  37. package/third_party/notices/ffmpeg/COPYING.GPLv2 +339 -0
  38. package/third_party/notices/ffmpeg/COPYING.GPLv3 +674 -0
  39. package/third_party/notices/ffmpeg/COPYING.LGPLv2.1 +502 -0
  40. package/third_party/notices/ffmpeg/LICENSE.md +129 -0
  41. package/third_party/notices/ffmpeg-adaptation/COPYING.LGPLv2.1 +502 -0
  42. package/third_party/notices/ffmpeg-adaptation/LICENSE.md +127 -0
  43. package/third_party/notices/freetype/LICENSE.TXT +46 -0
  44. package/third_party/notices/freetype/docs/FTL.TXT +169 -0
  45. package/third_party/notices/freetype/docs/GPLv2.TXT +340 -0
  46. package/third_party/notices/fribidi/COPYING +504 -0
  47. package/third_party/notices/harfbuzz/COPYING +42 -0
  48. package/third_party/notices/libass/COPYING +15 -0
  49. package/third_party/notices/libplacebo/LICENSE +458 -0
  50. package/third_party/notices/libxml2/Copyright +23 -0
  51. package/third_party/notices/libxml2/dict.c.notice +17 -0
  52. package/third_party/notices/libxml2/list.c.notice +16 -0
  53. package/third_party/notices/mpv/Copyright +78 -0
  54. package/third_party/notices/mpv/LICENSE.GPL +338 -0
  55. package/third_party/notices/mpv/LICENSE.LGPL +501 -0
  56. package/third_party/notices/vulkan-headers/LICENSE.txt +202 -0
  57. package/third_party/notices/zimg/COPYING +14 -0
  58. package/third_party/notices/zlib/LICENSE +22 -0
  59. package/third_party/notices.json +166 -0
  60. package/toolchain.lock.json +204 -0
  61. package/web/audio-worklet.js +45 -0
  62. package/web/cheap-mp4-probe.js +82 -0
  63. package/web/engine-adaptation/manifest.json +75 -0
  64. package/web/engine-adaptation/remux.mjs +2 -0
  65. package/web/engine-adaptation/remux.wasm +0 -0
  66. package/web/engine-ass/manifest.json +55 -0
  67. package/web/engine-ass/subtitles.mjs +2 -0
  68. package/web/engine-ass/subtitles.wasm +0 -0
  69. package/web/engine-hybrid/player.mjs +2 -0
  70. package/web/engine-hybrid/player.wasm +0 -0
  71. package/web/engine-remux/remux.mjs +2 -0
  72. package/web/engine-remux/remux.wasm +0 -0
  73. package/web/engine-software-full/player.mjs +2 -0
  74. package/web/engine-software-full/player.wasm +0 -0
  75. package/web/file-reader.js +34 -0
  76. package/web/filter-retained-engine-worker.js +292 -0
  77. package/web/generated/index.d.ts +6 -0
  78. package/web/generated/index.js +4 -0
  79. package/web/generated/internal/assets.d.ts +2 -0
  80. package/web/generated/internal/assets.js +7 -0
  81. package/web/generated/internal/backend.d.ts +27 -0
  82. package/web/generated/internal/backend.js +1 -0
  83. package/web/generated/internal/errors.d.ts +13 -0
  84. package/web/generated/internal/errors.js +54 -0
  85. package/web/generated/internal/native-ass.d.ts +38 -0
  86. package/web/generated/internal/native-ass.js +206 -0
  87. package/web/generated/internal/native-player.d.ts +95 -0
  88. package/web/generated/internal/native-player.js +502 -0
  89. package/web/generated/internal/playback-plans.d.ts +526 -0
  90. package/web/generated/internal/playback-plans.js +110 -0
  91. package/web/generated/internal/selection.d.ts +45 -0
  92. package/web/generated/internal/selection.js +45 -0
  93. package/web/generated/internal/state.d.ts +8 -0
  94. package/web/generated/internal/state.js +56 -0
  95. package/web/generated/internal/wasm-player.d.ts +127 -0
  96. package/web/generated/internal/wasm-player.js +372 -0
  97. package/web/generated/player/index.d.ts +198 -0
  98. package/web/generated/player/index.js +873 -0
  99. package/web/generated/player/interaction.d.ts +6 -0
  100. package/web/generated/player/interaction.js +14 -0
  101. package/web/generated/player/styles.d.ts +1 -0
  102. package/web/generated/player/styles.js +86 -0
  103. package/web/generated/types.d.ts +227 -0
  104. package/web/generated/types.js +2 -0
  105. package/web/generated/unified-player.d.ts +129 -0
  106. package/web/generated/unified-player.js +1074 -0
  107. package/web/io-worker.js +65 -0
  108. package/web/native-ass-worker.js +26 -0
  109. package/web/native-remux-player.js +301 -0
  110. package/web/native-remux-source-worker.js +25 -0
  111. package/web/native-remux-worker.js +46 -0
  112. package/web/range-reader.js +130 -0
  113. package/web/remux-packaging.js +11 -0
  114. package/web/resource-loader.js +173 -0
  115. package/web/retained-decoder-worker.js +200 -0
  116. package/web/retained-video.js +15 -0
  117. package/web/segmented-subtitles.js +37 -0
  118. package/web/software-full-engine-worker.js +234 -0
  119. package/web/source-probe.js +28 -0
  120. package/web/split-mp4.js +159 -0
  121. package/web/streaming-manifest.js +136 -0
  122. package/web/subtitle-overlay.js +26 -0
  123. package/web/video-codec-config.js +59 -0
  124. package/web/vod-manifest.js +54 -0
@@ -0,0 +1,1074 @@
1
+ import { featureRejection, executionPlan, qualifiedAudioFilter, planAdmission } from './internal/playback-plans.js';
2
+ import { runtimeBase } from './internal/assets.js';
3
+ import { PlayerError, playerError, redact } from './internal/errors.js';
4
+ import { freeze, ranges, tracks, trackKey, usesRemuxTracks, mediaInfo } from './internal/state.js';
5
+ import { PLAYBACK_MODES } from './types.js';
6
+ import { nativeRejection, losslessAdaptationRejection } from './internal/selection.js';
7
+ const filterChain = (value) => {
8
+ if (typeof value !== 'string' || value.length > 4096 || value.includes('\0'))
9
+ throw new PlayerError('INVALID_ARGUMENT', 'Invalid filter chain');
10
+ return value.trim();
11
+ };
12
+ const terminalSourceFailure = (error) => /Source transport:|representation changed|changed length|origin is not allowed|Authorization refresh|HTTP (?:401|403)|received (?:401|403)/i.test(String(error));
13
+ const modeValue = (mode) => {
14
+ if (!PLAYBACK_MODES.includes(mode))
15
+ throw new PlayerError('INVALID_ARGUMENT', 'Mode must be native, hybrid or software');
16
+ return mode;
17
+ };
18
+ const dimensions = (width, height) => {
19
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1 || width > 1920 || height > 1080)
20
+ throw new PlayerError('INVALID_ARGUMENT', 'Output dimensions must be within 1920×1080');
21
+ };
22
+ /** Three explicit playback modes. Mode/filter changes reopen transactionally. */
23
+ export class Player extends EventTarget {
24
+ ready = Promise.resolve();
25
+ assetBase;
26
+ snapshot;
27
+ subscribers = new Set();
28
+ publishQueued = false;
29
+ sourceSerial = 0;
30
+ publicSelections = new Map();
31
+ operationSerial = 0;
32
+ operationEpoch = 0;
33
+ activeOperation;
34
+ pendingOperation = null;
35
+ sessionError = null;
36
+ observedPlaying = false;
37
+ observedWaiting = false;
38
+ muted = false;
39
+ closing;
40
+ currentMode;
41
+ automatic;
42
+ attempts = [];
43
+ inspection;
44
+ recovering = false;
45
+ lifetime = new AbortController();
46
+ recoveredSessions = new WeakSet();
47
+ audioAdaptation;
48
+ automaticLossless = false;
49
+ losslessInspection;
50
+ bufferedNativeSeeks;
51
+ hybridAudioFilters;
52
+ nativeASS;
53
+ allowLossy = false;
54
+ planDecisions = [];
55
+ admissionContext = { automatic: false };
56
+ nativeRemux;
57
+ softwarePresenter;
58
+ settings;
59
+ audioOutput;
60
+ audioFallback;
61
+ toneMapping;
62
+ resourceLimits;
63
+ fonts = [];
64
+ subtitleAssets = [];
65
+ root;
66
+ width;
67
+ height;
68
+ current;
69
+ candidate;
70
+ source;
71
+ nativeTracks = [];
72
+ queue = Promise.resolve();
73
+ queued = 0;
74
+ destroyed = false;
75
+ destruction;
76
+ busy = false;
77
+ empty = new Map();
78
+ monitor;
79
+ constructor(container, options = {}) {
80
+ super();
81
+ if (typeof HTMLElement === 'undefined')
82
+ throw new PlayerError('INVALID_ARGUMENT', 'Player construction requires a browser');
83
+ this.assetBase = runtimeBase(options.assetBase);
84
+ if (!(container instanceof HTMLElement) || container instanceof HTMLCanvasElement || container instanceof HTMLVideoElement)
85
+ throw new PlayerError('INVALID_ARGUMENT', 'Pass a container element; Player owns its video/canvas surface');
86
+ this.currentMode = modeValue(options.mode ?? 'native');
87
+ this.automatic = options.automaticSelection ?? options.mode === undefined;
88
+ if (typeof this.automatic !== 'boolean')
89
+ throw new PlayerError('INVALID_ARGUMENT', 'Invalid automatic selection policy');
90
+ this.audioOutput = options.audioOutput ?? 'stereo';
91
+ this.audioFallback = options.audioFallback ?? 'stereo';
92
+ this.toneMapping = options.toneMapping ?? 'off';
93
+ if (!['stereo', '5.1', '7.1', 'auto'].includes(this.audioOutput) || !['stereo', 'reject'].includes(this.audioFallback))
94
+ throw new PlayerError('INVALID_ARGUMENT', 'Invalid audio output policy');
95
+ if (!['off', 'hdr-to-sdr'].includes(this.toneMapping))
96
+ throw new PlayerError('INVALID_ARGUMENT', 'Invalid tone mapping policy');
97
+ this.resourceLimits = { maxDecodePixels: options.resourceLimits?.maxDecodePixels ?? 8294400, maxAllocationBytes: options.resourceLimits?.maxAllocationBytes ?? 134217728 };
98
+ if (!Number.isInteger(this.resourceLimits.maxDecodePixels) || this.resourceLimits.maxDecodePixels < 1 || this.resourceLimits.maxDecodePixels > 8294400 || !Number.isInteger(this.resourceLimits.maxAllocationBytes) || this.resourceLimits.maxAllocationBytes < 33554432 || this.resourceLimits.maxAllocationBytes > 268435456)
99
+ throw new PlayerError('INVALID_ARGUMENT', 'Invalid decode resource limits');
100
+ this.audioAdaptation = options.experimentalAudioAdaptation;
101
+ if (options.automaticAudioAdaptation !== undefined && options.automaticAudioAdaptation !== 'lossless')
102
+ throw new PlayerError('INVALID_ARGUMENT', 'Unsupported automatic audio adaptation policy');
103
+ this.automaticLossless = options.automaticAudioAdaptation === 'lossless';
104
+ if (this.audioAdaptation !== undefined && this.audioAdaptation !== 'flac' && this.audioAdaptation !== 'opus')
105
+ throw new PlayerError('INVALID_ARGUMENT', 'Unsupported audio adaptation policy');
106
+ this.bufferedNativeSeeks = options.experimentalBufferedNativeSeeks ?? false;
107
+ if (typeof this.bufferedNativeSeeks !== 'boolean')
108
+ throw new PlayerError('INVALID_ARGUMENT', 'Invalid buffered seek policy');
109
+ this.hybridAudioFilters = options.experimentalHybridAudioFilters ?? false;
110
+ if (typeof this.hybridAudioFilters !== 'boolean')
111
+ throw new PlayerError('INVALID_ARGUMENT', 'Invalid Hybrid audio filter policy');
112
+ this.nativeASS = options.experimentalNativeASS ?? false;
113
+ if (typeof this.nativeASS !== 'boolean')
114
+ throw new PlayerError('INVALID_ARGUMENT', 'Invalid Native ASS policy');
115
+ this.allowLossy = options.allowLossyAudio ?? false;
116
+ if (options.allowLossyAudio !== undefined && typeof options.allowLossyAudio !== 'boolean')
117
+ throw new PlayerError('INVALID_ARGUMENT', 'Invalid lossy audio permission');
118
+ if (this.audioAdaptation === 'opus' && options.allowLossyAudio !== true)
119
+ throw new PlayerError('INVALID_ARGUMENT', 'Opus adaptation requires allowLossyAudio: true');
120
+ this.nativeRemux = options.nativeRemux ?? 'auto';
121
+ this.softwarePresenter = options.softwarePresenter ?? 'rgb';
122
+ if (!['rgb', 'experimental-yuv'].includes(this.softwarePresenter))
123
+ throw new PlayerError('INVALID_ARGUMENT', 'Invalid software presenter');
124
+ if (!['auto', 'never', 'always'].includes(this.nativeRemux))
125
+ throw new PlayerError('INVALID_ARGUMENT', 'Invalid native remux policy');
126
+ this.width = options.width ?? 640;
127
+ this.height = options.height ?? 360;
128
+ dimensions(this.width, this.height);
129
+ this.settings = { pause: true, volume: 100, speed: 1, aid: 'auto', sid: 'auto', subtitles: true, vf: filterChain(options.videoFilters ?? ''), af: filterChain(options.audioFilters ?? ''), gain: options.audioGain ?? 1 };
130
+ if (!Number.isFinite(this.settings.gain) || this.settings.gain < 0 || this.settings.gain > 1)
131
+ throw new PlayerError('INVALID_ARGUMENT', 'Gain must be between 0 and 1');
132
+ if (this.automatic && (this.settings.vf || this.settings.af || this.toneMapping !== 'off'))
133
+ this.currentMode = this.settings.vf || this.toneMapping !== 'off' || !this.hybridAudioFilters || !qualifiedAudioFilter(this.settings.af) ? 'software' : 'hybrid';
134
+ this.validateFilters(this.currentMode, this.settings);
135
+ this.root = document.createElement('div');
136
+ this.root.className = 'demuxe-player';
137
+ container.append(this.root);
138
+ this.publish();
139
+ }
140
+ get state() { return this.snapshot; }
141
+ get mediaInfo() { return this.snapshot.mediaInfo; }
142
+ subscribe(listener) {
143
+ this.subscribers.add(listener);
144
+ listener(this.snapshot);
145
+ return () => { this.subscribers.delete(listener); };
146
+ }
147
+ addEventListener(type, listener, options) { super.addEventListener(type, listener, options); }
148
+ removeEventListener(type, listener, options) { super.removeEventListener(type, listener, options); }
149
+ schedulePublish() {
150
+ if (this.publishQueued)
151
+ return;
152
+ this.publishQueued = true;
153
+ queueMicrotask(() => { this.publishQueued = false; if (!this.busy)
154
+ this.publish(); });
155
+ }
156
+ publish() {
157
+ const previous = this.snapshot, p = this.properties;
158
+ let raw = (p.get('track-list') ?? []);
159
+ if (this.mode === 'native' && this.surface?.videoWidth && !raw.some(t => t.type === 'video'))
160
+ raw = [...raw, { id: '1', type: 'video', selected: true }];
161
+ const list = this.current ? tracks(raw, this.sourceSerial, this.mode, this.current.backend.diagnostics?.plan) : [];
162
+ const d = p.get('duration'), reportedDuration = typeof d === 'number' && Number.isFinite(d) && d >= 0 ? d : null;
163
+ const live = (this.source?.kind === 'remote' && this.source.options.streaming?.live) || p.get('native-live') === true;
164
+ const duration = live ? null : reportedDuration;
165
+ const streamType = !this.source ? 'unknown' : live ? 'live' : duration !== null ? 'vod' : 'unknown';
166
+ const cache = p.get('demuxer-cache-state');
167
+ const seekable = !this.current ? null : this.mode === 'native' ? ranges(p.get('native-seekable')) : live ? ranges(cache?.['seekable-ranges']) : p.get('seekable') === false ? [] : p.get('seekable') === true && duration !== null ? [{ start: 0, end: duration }] : null;
168
+ const caps = this.featureCapabilities(seekable, list.filter(t => t.type === 'audio').length, list.filter(t => t.type === 'subtitle').length);
169
+ const status = !this.current ? (this.sessionError ? 'error' : 'idle') : this.sessionError ? 'error' : p.get('eof-reached') === true ? 'ended' : this.settings.pause || p.get('pause') === true ? 'paused' : this.observedWaiting || p.get('paused-for-cache') === true ? 'buffering' : this.observedPlaying ? 'playing' : 'paused';
170
+ const next = { status, playbackIntent: this.settings.pause ? 'pause' : 'play', pendingOperation: this.pendingOperation, sourceId: this.current ? this.sourceSerial : null,
171
+ currentTime: Math.max(0, Number(p.get('time-pos')) || 0), duration, streamType, subtitlesVisible: this.settings.subtitles, volume: this.settings.volume / 100, muted: this.muted, playbackRate: this.settings.speed,
172
+ activeMode: this.current ? this.mode : null, automaticSelection: this.automatic, buffered: this.mode === 'native' && this.current ? ranges(p.get('native-buffered')) : null, seekable,
173
+ audioTracks: list.filter(t => t.type === 'audio'), subtitleTracks: list.filter(t => t.type === 'subtitle'), mediaInfo: mediaInfo(p, this.mode, this.surface, list), capabilities: caps, error: this.sessionError };
174
+ if (previous && JSON.stringify(previous) === JSON.stringify(next))
175
+ return;
176
+ this.snapshot = freeze(next);
177
+ for (const fn of [...this.subscribers]) {
178
+ try {
179
+ fn(this.snapshot);
180
+ }
181
+ catch (error) {
182
+ globalThis.reportError?.(error);
183
+ }
184
+ }
185
+ this.dispatchEvent(new CustomEvent('statechange', { detail: this.snapshot }));
186
+ if (!previous)
187
+ return;
188
+ const changed = (a, b) => JSON.stringify(a) !== JSON.stringify(b);
189
+ for (const [event, a, b] of [
190
+ ['sourcechange', previous.sourceId, next.sourceId], ['durationchange', previous.duration, next.duration],
191
+ ['trackschange', [previous.audioTracks, previous.subtitleTracks], [next.audioTracks, next.subtitleTracks]],
192
+ ['capabilitieschange', previous.capabilities, next.capabilities], ['volumechange', [previous.volume, previous.muted], [next.volume, next.muted]],
193
+ ['ratechange', previous.playbackRate, next.playbackRate], ['timeupdate', previous.currentTime, next.currentTime],
194
+ ])
195
+ if (changed(a, b))
196
+ this.dispatchEvent(new CustomEvent(event, { detail: this.snapshot }));
197
+ if (previous.playbackIntent !== next.playbackIntent && next.playbackIntent === 'play')
198
+ this.dispatchEvent(new CustomEvent('play', { detail: this.snapshot }));
199
+ if (previous.status !== next.status) {
200
+ const event = { playing: 'playing', paused: 'pause', buffering: 'waiting', ended: 'ended' }[next.status];
201
+ if (event)
202
+ this.dispatchEvent(new CustomEvent(event, { detail: this.snapshot }));
203
+ }
204
+ }
205
+ featureCapabilities(seekable, audio, sub) {
206
+ const isolated = globalThis.crossOriginIsolated === true, available = { availability: 'available' };
207
+ const unavailable = (reason) => ({ availability: 'unavailable', reason });
208
+ const unknown = { availability: 'unknown', reason: 'Open a source to establish availability' };
209
+ const nativeOverlay = this.nativeASS && isolated && this.current?.backend.diagnostics?.plan !== 'adapted-opus' && !(this.source?.kind === 'remote' && this.source.options.format && this.source.options.format !== 'file');
210
+ const route = (mode) => !isolated ? unavailable('This deployment requires cross-origin isolation') : this.mode === mode || (mode === 'hybrid' && this.mode === 'software') ? available : this.automatic ? { availability: 'switch', mode, reason: `This feature requires ${mode} playback` } : unavailable(`Select ${mode} mode first`);
211
+ return { ...this.legacyCapabilities, deployment: { isolated, webCodecs: typeof VideoDecoder !== 'undefined', mediaSource: typeof MediaSource !== 'undefined' }, features: {
212
+ seek: seekable === null ? { availability: 'unknown', reason: 'Seek window has not been established' } : seekable.length ? available : unavailable('The source currently has no seekable time range'),
213
+ audioTracks: !this.current ? unknown : audio ? available : unavailable('Audio track selection is not exposed by this source/browser'),
214
+ subtitleTracks: !this.current ? unknown : sub ? available : unavailable('No subtitle tracks are available'),
215
+ audioGain: this.mode === 'native' ? (typeof AudioContext === 'undefined' ? unavailable('Web Audio is unavailable') : available) : route('hybrid'),
216
+ externalSubtitles: this.mode === 'native' && nativeOverlay ? available : route('hybrid'), customFonts: this.mode === 'native' && nativeOverlay ? available : route('hybrid'), videoFilters: route('software'), audioFilters: route(this.hybridAudioFilters ? 'hybrid' : 'software')
217
+ } };
218
+ }
219
+ get mode() { return this.currentMode; }
220
+ get automaticSelection() { return this.automatic; }
221
+ get surface() { return this.current?.surface; }
222
+ get properties() { return this.current?.backend.properties ?? this.empty; }
223
+ get capabilities() { return this.snapshot?.capabilities ?? this.featureCapabilities(null, 0, 0); }
224
+ get legacyCapabilities() {
225
+ return { videoFilters: this.automatic || this.mode === 'software', audioFilters: this.automatic || this.mode === 'software' || (this.mode === 'hybrid' && this.hybridAudioFilters), mpvSubtitles: this.mode !== 'native', externalTextTracks: this.mode === 'native', externalSubtitles: this.nativeASS || this.automatic || this.mode !== 'native', customFonts: this.nativeASS || this.automatic || this.mode !== 'native', customRequestHeaders: this.mode !== 'native' || (this.nativeRemux !== 'never' && crossOriginIsolated && typeof MediaSource !== 'undefined') };
226
+ }
227
+ get diagnostics() {
228
+ return redact({ mode: this.mode, plan: this.current ? executionPlan(this.mode, this.current.backend.diagnostics?.plan, this.settings.af, this.settings.gain, !!this.current.backend.diagnostics?.subtitleOverlay) : undefined, planAdmission: this.planDecisions, selection: { automatic: this.automatic, attempts: this.attempts.map(a => ({ ...a })) }, switching: this.busy, videoFilters: this.settings.vf, audioFilters: this.settings.af, audioGain: this.settings.gain, toneMapping: this.toneMapping, resourceLimits: { ...this.resourceLimits }, backend: this.current?.backend.diagnostics });
229
+ }
230
+ audioDiagnostics() { return this.current?.backend.audioDiagnostics(); }
231
+ emit(type, detail) {
232
+ if (type === 'error') {
233
+ const error = playerError(detail, this.activeOperation?.id ?? null, this.activeOperation?.kind ?? null, 'session');
234
+ this.sessionError = error.toJSON();
235
+ this.publish();
236
+ detail = this.sessionError;
237
+ }
238
+ this.dispatchEvent(new CustomEvent(type, { detail: redact(detail) }));
239
+ }
240
+ assertOperation() { if (this.destroyed || this.activeOperation?.controller.signal.aborted)
241
+ throw new PlayerError('ABORTED', this.destroyed ? 'Player is destroyed' : 'Operation aborted'); }
242
+ validateFilters(mode, settings) {
243
+ const reason = featureRejection(mode, { ...settings, toneMapping: this.toneMapping, hybridAudioFilters: this.hybridAudioFilters });
244
+ if (reason)
245
+ throw new PlayerError('UNSUPPORTED_FEATURE', reason);
246
+ }
247
+ enqueue(operation, kind = null, signal) {
248
+ const id = ++this.operationSerial, epoch = this.operationEpoch;
249
+ if (this.destroyed)
250
+ return Promise.reject(new PlayerError('ABORTED', 'Player is destroyed', id, kind));
251
+ if (this.queued >= 32 && kind !== 'closing')
252
+ return Promise.reject(new PlayerError('INVALID_ARGUMENT', 'Player operation queue is full', id, kind));
253
+ this.queued++;
254
+ const controller = new AbortController();
255
+ const cancel = () => { controller.abort(); if (this.activeOperation?.id === id) {
256
+ this.inspection?.abort();
257
+ void this.candidate?.backend.destroy().catch(() => { });
258
+ } };
259
+ signal?.addEventListener('abort', cancel, { once: true });
260
+ if (signal?.aborted)
261
+ cancel();
262
+ const result = this.queue.then(async () => {
263
+ if (epoch !== this.operationEpoch || this.destroyed || controller.signal.aborted)
264
+ throw new PlayerError('ABORTED', this.destroyed ? 'Player is destroyed' : 'Operation aborted', id, kind);
265
+ this.activeOperation = { id, kind, controller, detachCallerAbort: () => signal?.removeEventListener('abort', cancel) };
266
+ this.pendingOperation = kind ? { id, kind } : null;
267
+ this.publish();
268
+ if (kind === 'seeking')
269
+ this.dispatchEvent(new CustomEvent('seeking', { detail: this.state }));
270
+ try {
271
+ await operation();
272
+ this.assertOperation();
273
+ }
274
+ catch (error) {
275
+ const structured = playerError(controller.signal.aborted ? new PlayerError('ABORTED', this.destroyed ? 'Player is destroyed' : 'Operation aborted') : error, id, kind);
276
+ if (!this.current && kind === 'opening' && structured.code !== 'ABORTED')
277
+ this.sessionError = { ...structured.toJSON(), scope: 'session' };
278
+ this.publish();
279
+ this.dispatchEvent(new CustomEvent('error', { detail: freeze(structured.toJSON()) }));
280
+ throw structured;
281
+ }
282
+ finally {
283
+ this.activeOperation = undefined;
284
+ this.pendingOperation = null;
285
+ this.publish();
286
+ }
287
+ if (kind === 'seeking')
288
+ this.dispatchEvent(new CustomEvent('seeked', { detail: this.state }));
289
+ }).finally(() => signal?.removeEventListener('abort', cancel));
290
+ this.queue = result.catch(() => { }).finally(() => { this.queued--; });
291
+ return result;
292
+ }
293
+ async interruptible(work) {
294
+ const signal = this.activeOperation?.controller.signal ?? this.lifetime.signal;
295
+ this.assertOperation();
296
+ let cancel;
297
+ try {
298
+ return await Promise.race([work, new Promise((_, reject) => {
299
+ cancel = () => reject(new PlayerError('ABORTED', this.destroyed ? 'Player is destroyed' : 'Operation aborted'));
300
+ signal.addEventListener('abort', cancel, { once: true });
301
+ })]);
302
+ }
303
+ finally {
304
+ signal.removeEventListener('abort', cancel);
305
+ }
306
+ }
307
+ async dispose(session) {
308
+ if (!session)
309
+ return;
310
+ try {
311
+ await session.backend.destroy();
312
+ }
313
+ finally {
314
+ session.surface.remove();
315
+ }
316
+ }
317
+ async create(mode, aid = 'auto', adaptation, forcePreparation = false) {
318
+ let backend;
319
+ const surface = document.createElement(mode === 'native' ? 'video' : 'canvas');
320
+ surface.width = this.width;
321
+ surface.height = this.height;
322
+ surface.style.cssText = 'display:none;width:100%;background:#000';
323
+ // Import before allocating workers; destroy during import cannot orphan an engine.
324
+ const module = mode === 'native' ? await this.interruptible(import('./internal/native-player.js')) : await this.interruptible(import('./internal/wasm-player.js'));
325
+ this.assertOperation();
326
+ this.root.append(surface);
327
+ try {
328
+ backend = 'NativePlayer' in module ? new module.NativePlayer(surface, forcePreparation ? 'always' : this.nativeRemux, this.assetBase, this.bufferedNativeSeeks, adaptation, ['auto', 'no'].includes(aid) ? undefined : Number(aid) - 1, this.nativeASS, this.fonts) : new module.WasmPlayer(surface, { mode: mode, softwarePresenter: this.softwarePresenter, audioOutput: this.audioOutput, audioFallback: this.audioFallback, resourceLimits: this.resourceLimits, fonts: this.fonts, assetBase: this.assetBase });
329
+ }
330
+ catch (error) {
331
+ surface.remove();
332
+ throw error;
333
+ }
334
+ const session = { backend, surface };
335
+ for (const type of ['mpv', 'error', 'log', 'output', 'source', 'activity'])
336
+ backend.addEventListener(type, event => {
337
+ const detail = event.detail;
338
+ if (type === 'error')
339
+ session.error = new Error(String(detail));
340
+ if (type === 'mpv' && detail.event === 'end-file' && detail.reason === 'error')
341
+ session.error = new Error(String(detail.file_error));
342
+ if (this.current === session && !this.busy && !this.destroyed) {
343
+ if (session.error && (type === 'error' || (type === 'mpv' && detail.event === 'end-file')) && this.automatic && this.mode !== 'software') {
344
+ this.recover(session);
345
+ return;
346
+ }
347
+ if (type === 'mpv' && detail.event === 'end-file' && detail.reason === 'error')
348
+ this.emit('error', session.error);
349
+ if (type === 'activity') {
350
+ if (detail === 'waiting')
351
+ this.observedWaiting = true;
352
+ if (detail === 'playing') {
353
+ this.observedPlaying = true;
354
+ this.observedWaiting = false;
355
+ }
356
+ this.schedulePublish();
357
+ return;
358
+ }
359
+ if (type === 'mpv') {
360
+ if (detail.event === 'property-change' && detail.name === 'time-pos' && !this.settings.pause && Number(detail.data) > this.state.currentTime) {
361
+ this.observedPlaying = true;
362
+ this.observedWaiting = false;
363
+ }
364
+ if (detail.event === 'property-change' && detail.name === 'pause' && detail.data === true && !this.activeOperation)
365
+ this.settings.pause = true;
366
+ this.schedulePublish();
367
+ }
368
+ this.emit(type, detail);
369
+ }
370
+ });
371
+ return session;
372
+ }
373
+ async settled(session, mode, target) {
374
+ if (mode === 'native')
375
+ return;
376
+ const deadline = performance.now() + 25000;
377
+ while (performance.now() < deadline) {
378
+ this.assertOperation();
379
+ if (session.error)
380
+ throw session.error;
381
+ const d = session.backend.diagnostics;
382
+ const tracks = session.backend.properties.get('track-list');
383
+ // Selection is transiently empty while mpv initializes a video track.
384
+ const hasVideo = tracks?.some(t => t.type === 'video');
385
+ if (hasVideo === false && tracks?.length)
386
+ return;
387
+ if (mode === 'hybrid' && tracks?.some(t => t.type === 'video' && t.selected && !['h264', 'hevc', 'vp8', 'vp9', 'av1'].includes(t.codec ?? '')))
388
+ throw new Error('Hybrid mode has no browser bridge for this video codec. Choose software mode for this source.');
389
+ const position = mode === 'hybrid' ? d?.presentation?.position : d?.presentedPosition;
390
+ if (d?.rendered && (mode !== 'hybrid' || d.decoder === 'webcodecs') && !d.seeking && position !== undefined && Math.abs(position - target) < .15)
391
+ return;
392
+ await new Promise(resolve => setTimeout(resolve, 25));
393
+ }
394
+ throw new Error(`${mode} mode did not present the requested position`);
395
+ }
396
+ admissible(source, settings, attachments, textTracks, nativeSourceRejection, automatic = this.automatic) {
397
+ const remote = source.kind === 'remote' ? source.options : undefined;
398
+ return planAdmission({ automatic, ...settings, toneMapping: this.toneMapping, hybridAudioFilters: this.hybridAudioFilters,
399
+ adaptation: this.audioAdaptation, allowLossy: this.allowLossy, nativeASS: this.nativeASS, externalFormats: attachments.map(a => a.format), browserTextTracks: !!textTracks.length,
400
+ automaticLossless: this.automaticLossless, adaptationSourceRejection: source.kind !== 'local' ? 'Automatic FLAC is qualified only for local files' : this.losslessInspection?.source === source ? this.losslessInspection.reason : 'Automatic FLAC source has not been qualified',
401
+ adaptationSourceQualified: source.kind === 'local' && this.losslessInspection?.source === source && !this.losslessInspection.reason,
402
+ audioOutput: this.audioOutput, nativeRemux: this.nativeRemux, manifest: !!remote?.format && remote.format !== 'file',
403
+ requiresRemux: !!(remote && (remote.headers || remote.refreshAuthorization || remote.allowedOrigins || remote.immutable !== undefined || remote.credentials === 'omit')),
404
+ isolated: globalThis.crossOriginIsolated === true, mse: typeof MediaSource !== 'undefined', webCodecs: typeof VideoDecoder !== 'undefined', webAudio: typeof AudioContext !== 'undefined', nativeSourceRejection });
405
+ }
406
+ async replace(source, mode, settings, preserve, nativeTracks, requestedTarget, automaticAdmission = this.automatic) {
407
+ this.validateFilters(mode, settings);
408
+ const attachments = preserve ? this.subtitleAssets : [];
409
+ const admitted = this.admissible(source, settings, attachments, nativeTracks, automaticAdmission ? this.admissionContext.nativeReason : undefined, automaticAdmission);
410
+ if (!automaticAdmission) {
411
+ this.planDecisions = admitted;
412
+ this.admissionContext = { automatic: false };
413
+ }
414
+ if (!admitted.some(p => p.mode === mode && p.eligible)) {
415
+ const candidates = admitted.filter(p => p.mode === mode);
416
+ const rejection = candidates.find(p => p.code === 'ISOLATION_REQUIRED') ?? candidates.find(p => p.code !== 'PLAN_NOT_REQUESTED');
417
+ throw new PlayerError(rejection?.code === 'ISOLATION_REQUIRED' ? 'ISOLATION_REQUIRED' : 'UNSUPPORTED_FEATURE', rejection?.reason ?? 'No qualified complete playback plan');
418
+ }
419
+ if (mode === 'native' && attachments.length && (!this.nativeASS || attachments.some(a => !['ass', 'ssa'].includes(a.format))))
420
+ throw Error('External mpv subtitles require Hybrid or Software');
421
+ if (mode === 'native' && this.audioOutput !== 'stereo')
422
+ throw Error('Explicit PCM output layout requires Hybrid or Software');
423
+ if (source.kind === 'local' && source.file instanceof ArrayBuffer && source.file.byteLength > 32 * 1024 * 1024)
424
+ throw new Error('ArrayBuffer sources are limited to 32 MiB');
425
+ const old = this.current;
426
+ const wasPaused = this.settings.pause;
427
+ const desired = { ...settings, pause: preserve ? !!wasPaused : true };
428
+ const target = requestedTarget ?? (preserve ? Math.max(0, Number(old?.backend.properties.get('time-pos')) || 0) : 0);
429
+ if ((!preserve && old) || (preserve && (mode === 'native') !== (this.mode === 'native'))) {
430
+ desired.aid = preserve && settings.aid === 'no' ? 'no' : 'auto';
431
+ desired.sid = preserve && settings.sid === 'no' ? 'no' : 'auto';
432
+ }
433
+ // Public stream identities are known before Native preparation starts.
434
+ // Select that stream at open rather than adapting the default track first.
435
+ const publicAudio = preserve && mode === 'native' ? /^audio:stream:(\d+)$/.exec(this.publicSelections.get('audio') ?? '') : null;
436
+ if (publicAudio)
437
+ desired.aid = String(Number(publicAudio[1]) + 1);
438
+ const crossing = preserve && this.automatic && (mode === 'native') !== (this.mode === 'native');
439
+ const trackIndexes = new Map();
440
+ let externalSubtitleKey;
441
+ if (crossing) {
442
+ for (const type of ['audio', 'sub']) {
443
+ const id = settings[type === 'audio' ? 'aid' : 'sid'];
444
+ if (['auto', 'no'].includes(id))
445
+ continue;
446
+ const list = old?.backend.properties.get('track-list');
447
+ const track = list?.find(t => t.type === type && String(t.id) === id);
448
+ if (type === 'sub' && track?.external) {
449
+ externalSubtitleKey = trackKey(track, this.mode);
450
+ desired.sid = 'auto';
451
+ continue;
452
+ }
453
+ const index = this.mode === 'native' && usesRemuxTracks(old?.backend.diagnostics?.plan) ? Number(id) - 1 : track?.['ff-index'];
454
+ if (index === undefined)
455
+ throw Error('Cannot preserve selected track across playback modes');
456
+ trackIndexes.set(type, index);
457
+ }
458
+ }
459
+ if (this.activeOperation && !this.pendingOperation) {
460
+ this.activeOperation.kind = 'switching';
461
+ this.pendingOperation = { id: this.activeOperation.id, kind: 'switching' };
462
+ }
463
+ this.busy = true;
464
+ this.publish();
465
+ this.emit('modechange', { phase: 'loading', mode });
466
+ let candidate;
467
+ try {
468
+ if (old && !old.error)
469
+ await old.backend.pause();
470
+ const adaptation = automaticAdmission ? (admitted.some(p => p.eligible && p.id.startsWith('native-flac')) ? 'flac' : undefined) : this.audioAdaptation;
471
+ candidate = this.candidate = await this.create(mode, desired.aid, adaptation, automaticAdmission && !!this.admissionContext.nativeReason && adaptation === 'flac');
472
+ this.assertOperation();
473
+ const p = candidate.backend;
474
+ await p.ready;
475
+ this.assertOperation();
476
+ const tone = this.toneMapping === 'hdr-to-sdr' ? 'zscale=transfer=linear:npl=100,format=gbrpf32le,zscale=primaries=bt709,tonemap=tonemap=mobius:desat=0,zscale=transfer=bt709:matrix=bt709:range=limited,format=yuv420p' : '';
477
+ const vf = [tone ? `lavfi=[${tone}]` : '', desired.vf].filter(Boolean).join(',');
478
+ if (vf)
479
+ await p.command('set', 'vf', vf);
480
+ if (mode !== 'native' && desired.af)
481
+ await p.command('set', 'af', desired.af);
482
+ await p.gain(desired.gain);
483
+ await p.volume(this.muted ? 0 : desired.volume);
484
+ await p.rate(desired.speed);
485
+ // Native numeric track IDs only exist after metadata/text-track loading.
486
+ if (mode !== 'native') {
487
+ await p.selectTrack('audio', desired.aid);
488
+ await p.selectTrack('sub', desired.sid);
489
+ await p.subtitleVisible(desired.subtitles);
490
+ }
491
+ if (source.kind === 'local')
492
+ await p.open(source.file, source.input);
493
+ else
494
+ await p.openRemote(source.options);
495
+ if ('inspectMetadata' in p)
496
+ await p.inspectMetadata();
497
+ this.assertOperation();
498
+ for (const subtitle of attachments)
499
+ await p.addSubtitle(subtitle);
500
+ if (mode !== 'native' && attachments.length && desired.sid !== 'auto')
501
+ await p.selectTrack('sub', desired.sid);
502
+ if (mode === 'native') {
503
+ for (const track of nativeTracks)
504
+ await p.addTextTrack(track);
505
+ await p.selectTrack('audio', desired.aid);
506
+ await p.selectTrack('sub', desired.sid);
507
+ await p.subtitleVisible(desired.subtitles);
508
+ }
509
+ for (const [type, index] of trackIndexes) {
510
+ const list = p.properties.get('track-list');
511
+ const track = list?.find(t => t.type === type && (mode === 'native' ? usesRemuxTracks(p.diagnostics?.plan) && Number(t.id) - 1 === index : t['ff-index'] === index));
512
+ if (!track)
513
+ throw Error('Cannot preserve selected track across playback modes');
514
+ const id = String(track.id);
515
+ await p.selectTrack(type, id);
516
+ desired[type === 'audio' ? 'aid' : 'sid'] = id;
517
+ }
518
+ if (externalSubtitleKey) {
519
+ const track = (p.properties.get('track-list') ?? []).find(t => trackKey(t, mode) === externalSubtitleKey);
520
+ if (!track)
521
+ throw Error('Cannot preserve external subtitle identity');
522
+ desired.sid = String(track.id);
523
+ await p.selectTrack('sub', desired.sid);
524
+ }
525
+ if (preserve)
526
+ for (const [type, key] of this.publicSelections) {
527
+ const raw = (p.properties.get('track-list') ?? []);
528
+ const plan = p.diagnostics?.plan;
529
+ const track = raw.find(t => t.type === type && trackKey(t, mode, plan) === key);
530
+ if (!track)
531
+ throw new PlayerError('UNSUPPORTED_FEATURE', `Cannot preserve explicit public track selection across playback modes (${key}; available ${raw.map(t => trackKey(t, mode, plan)).join(', ')})`);
532
+ const id = String(track.id);
533
+ await p.selectTrack(type, id);
534
+ desired[type === 'audio' ? 'aid' : 'sid'] = id;
535
+ }
536
+ await this.settled(candidate, mode, 0);
537
+ if (target > 0) {
538
+ await p.seek(target);
539
+ await this.settled(candidate, mode, target);
540
+ }
541
+ if (candidate.error)
542
+ throw candidate.error;
543
+ const actual = executionPlan(mode, p.diagnostics?.plan, desired.af, desired.gain, !!p.diagnostics?.subtitleOverlay);
544
+ if (!actual || !admitted.some(plan => plan.id === actual.id && plan.eligible))
545
+ throw new PlayerError('UNSUPPORTED_FEATURE', 'The prepared components do not match an admitted complete playback plan');
546
+ if (!desired.pause)
547
+ await p.play();
548
+ this.assertOperation();
549
+ if (!preserve) {
550
+ this.sourceSerial++;
551
+ this.publicSelections.clear();
552
+ }
553
+ this.sessionError = null;
554
+ this.observedPlaying = false;
555
+ this.observedWaiting = false;
556
+ this.current = candidate;
557
+ this.candidate = undefined;
558
+ this.source = source;
559
+ this.currentMode = mode;
560
+ this.settings = desired;
561
+ this.nativeTracks = nativeTracks;
562
+ if (!preserve)
563
+ this.subtitleAssets = [];
564
+ // Acceptance is the cancellation boundary, including synchronous observers.
565
+ // close/destroy still abort the internal controller during old-session cleanup.
566
+ this.activeOperation?.detachCallerAbort();
567
+ this.publish();
568
+ candidate.surface.style.display = 'block';
569
+ if (old)
570
+ old.surface.style.display = 'none';
571
+ clearInterval(this.monitor);
572
+ let inactiveSamples = 0;
573
+ if (mode === 'hybrid')
574
+ this.monitor = setInterval(() => {
575
+ const d = p.diagnostics;
576
+ const hasVideo = p.properties.get('track-list')?.some(t => t.type === 'video' && t.selected);
577
+ inactiveSamples = hasVideo && !this.busy && this.queued === 0 && d?.decoder === 'software' ? inactiveSamples + 1 : 0;
578
+ if (inactiveSamples >= 4) {
579
+ clearInterval(this.monitor);
580
+ if (this.automatic) {
581
+ candidate.error = new Error('Hybrid browser decoder became inactive for four consecutive checks');
582
+ this.recover(candidate);
583
+ return;
584
+ }
585
+ this.settings.pause = true;
586
+ void p.pause().catch(() => { });
587
+ this.emit('error', 'Hybrid decoder stopped. Reopen in software mode.');
588
+ }
589
+ }, 250);
590
+ // The new session is committed. Cleanup failures must not pretend to roll it back.
591
+ try {
592
+ await this.dispose(old);
593
+ }
594
+ catch (error) {
595
+ this.dispatchEvent(new CustomEvent('error', { detail: freeze(playerError(error, this.activeOperation?.id ?? null, this.activeOperation?.kind ?? null, 'operation').toJSON()) }));
596
+ }
597
+ for (const [name, data] of p.properties)
598
+ this.emit('mpv', { event: 'property-change', name, data });
599
+ this.emit('mpv', { event: 'file-loaded' });
600
+ this.emit('modechange', { phase: 'ready', mode, position: target });
601
+ }
602
+ catch (error) {
603
+ if (candidate && candidate !== this.current)
604
+ await this.dispose(candidate).catch(() => { });
605
+ this.candidate = undefined;
606
+ if (old && !old.error && this.current === old && !wasPaused && !this.destroyed && !this.closing)
607
+ await old.backend.play().catch(() => { });
608
+ this.emit('modechange', { phase: 'failed', mode, rolledBack: this.current === old, message: String(error) });
609
+ throw error;
610
+ }
611
+ finally {
612
+ this.busy = false;
613
+ this.publish();
614
+ }
615
+ }
616
+ record(attempt) {
617
+ this.attempts.push(attempt);
618
+ if (this.attempts.length > 32)
619
+ this.attempts.shift();
620
+ this.emit('selectionchange', { ...attempt });
621
+ }
622
+ async select(source, settings, preserve, tracks, start = 0, target, priorAttempts = []) {
623
+ if (!this.automatic)
624
+ return this.replace(source, this.mode, settings, preserve, tracks, target);
625
+ this.attempts = [];
626
+ for (const attempt of priorAttempts)
627
+ this.record(attempt);
628
+ let nativeReason;
629
+ this.losslessInspection = undefined;
630
+ if (start === 0 && !(settings.vf || settings.af || this.toneMapping !== 'off')) {
631
+ if ((source.kind === 'local' && source.input?.demuxer) || (source.kind === 'remote' && (source.options.demuxer || (source.options.format && source.options.format !== 'file')))) {
632
+ nativeReason = 'Manifest track requirements require mpv inspection';
633
+ }
634
+ else {
635
+ const controller = this.inspection = new AbortController();
636
+ try {
637
+ let probe;
638
+ // Immutable local bytes permit bounded inspection without an engine download.
639
+ // Remote identity/permission enforcement continues through the existing inspector.
640
+ if (source.kind === 'local' && this.nativeRemux !== 'always') {
641
+ const { cheapMP4Probe } = await this.interruptible(import(new URL('web/cheap-mp4-probe.js', this.assetBase).href));
642
+ const local = source.file instanceof File ? source.file : new File([source.file], 'media');
643
+ const cheap = await cheapMP4Probe(local, controller.signal, document.createElement('video'));
644
+ probe = cheap.probe;
645
+ this.record({ mode: 'probe', outcome: probe ? 'selected' : 'skipped', reason: `Local MP4 metadata: ${cheap.bytesRead} bytes; ${probe ? 'no inspector Wasm required' : cheap.reason}` });
646
+ }
647
+ this.assertOperation();
648
+ const transport = source.kind === 'local' ? { file: source.file instanceof File ? source.file : new File([source.file], 'media') } : (() => { const { refreshAuthorization, ...options } = source.options; return { options: { ...options, url: new URL(options.url, location.href).href }, refreshAuthorization }; })();
649
+ if (!probe) {
650
+ const { probeSource } = await this.interruptible(import(new URL('web/source-probe.js', this.assetBase).href));
651
+ probe = await probeSource(transport, controller.signal);
652
+ }
653
+ if (!probe)
654
+ throw Error('Source inspection returned no metadata');
655
+ if (source.kind === 'remote' && probe.identity)
656
+ source.options.identity ??= probe.identity;
657
+ // Cross-mode track IDs reset to auto in replace(); preflight that same selection.
658
+ let aid = preserve && (this.mode === 'native' || settings.aid === 'no') ? settings.aid : 'auto';
659
+ const sid = tracks.length ? 'no' : preserve && (this.mode === 'native' || settings.sid === 'no') ? settings.sid : 'auto';
660
+ if (preserve && this.mode === 'native' && usesRemuxTracks(this.current?.backend.diagnostics?.plan) && !['auto', 'no'].includes(aid))
661
+ aid = probe.tracks.find(t => t.type === 'audio' && t.index === Number(aid) - 1)?.id ?? aid;
662
+ const publicAudio = preserve ? /^audio:stream:(\d+)$/.exec(this.publicSelections.get('audio') ?? '') : null;
663
+ if (publicAudio)
664
+ aid = probe.tracks.find(t => t.type === 'audio' && t.index === Number(publicAudio[1]))?.id ?? 'missing';
665
+ nativeReason = nativeRejection(probe, { ...settings, aid, sid }, document.createElement('video'));
666
+ // Original-track playback must not depend on optional preparation assets.
667
+ // Inspect for adaptation only after ordinary Native eligibility rejects
668
+ // this presentation. The existing terminal-source/cancellation guard
669
+ // still owns failures from either inspector.
670
+ const audioTracks = probe.tracks.filter(track => track.type === 'audio');
671
+ const selectedAudio = aid === 'auto' ? (audioTracks.find(track => track.default) ?? audioTracks[0]) : audioTracks.find(track => track.id === aid);
672
+ if (nativeReason && this.automaticLossless && source.kind === 'local' && selectedAudio && ['pcm_s16le', 'pcm_s24le'].includes(selectedAudio.codec)) {
673
+ const { probeSource } = await this.interruptible(import(new URL('web/source-probe.js', this.assetBase).href));
674
+ const preparedProbe = await probeSource(transport, controller.signal, 'flac');
675
+ this.assertOperation();
676
+ this.losslessInspection = { source, reason: losslessAdaptationRejection(preparedProbe, { ...settings, aid, sid }) };
677
+ }
678
+ }
679
+ catch (error) {
680
+ if (this.destroyed || this.activeOperation?.controller.signal.aborted || playerError(error).code === 'AUTOPLAY_BLOCKED' || terminalSourceFailure(error))
681
+ throw error;
682
+ nativeReason = 'Native eligibility could not be established: ' + String(error);
683
+ this.record({ mode: 'probe', outcome: 'failed', reason: String(error) });
684
+ }
685
+ finally {
686
+ controller.abort();
687
+ if (this.inspection === controller)
688
+ this.inspection = undefined;
689
+ }
690
+ }
691
+ }
692
+ const errors = [];
693
+ for (const mode of PLAYBACK_MODES.slice(start)) {
694
+ this.assertOperation();
695
+ this.admissionContext = { nativeReason, automatic: this.automatic };
696
+ this.planDecisions = this.admissible(source, settings, preserve ? this.subtitleAssets : [], tracks, nativeReason);
697
+ const candidates = this.planDecisions.filter(p => p.mode === mode);
698
+ const reason = candidates.some(p => p.eligible) ? undefined : candidates.find(p => p.code !== 'PLAN_NOT_REQUESTED')?.reason ?? 'No eligible complete playback plan';
699
+ if (reason) {
700
+ this.record({ mode, outcome: 'skipped', reason });
701
+ continue;
702
+ }
703
+ try {
704
+ await this.replace(source, mode, settings, preserve, tracks, target);
705
+ this.record({ mode, outcome: 'selected', reason: 'Playback requirements and actual presentation accepted' });
706
+ if (this.current?.error && !this.recovering)
707
+ this.recover(this.current);
708
+ return;
709
+ }
710
+ catch (error) {
711
+ if (this.destroyed || this.activeOperation?.controller.signal.aborted || playerError(error).code === 'AUTOPLAY_BLOCKED' || terminalSourceFailure(error))
712
+ throw error;
713
+ errors.push(`${mode}: ${String(error)}`);
714
+ this.record({ mode, outcome: 'failed', reason: String(error) });
715
+ }
716
+ }
717
+ throw Error('No playback route satisfied the source: ' + (errors.join('; ') || this.attempts.map(a => a.reason).join('; ')));
718
+ }
719
+ recover(session) {
720
+ if (this.recovering || this.recoveredSessions.has(session) || this.destroyed || !this.automatic || !this.source || this.mode === 'software')
721
+ return;
722
+ this.recoveredSessions.add(session);
723
+ if (terminalSourceFailure(session.error)) {
724
+ void session.backend.pause().catch(() => { });
725
+ this.emit('error', String(session.error));
726
+ return;
727
+ }
728
+ this.recovering = true;
729
+ void this.enqueue(async () => {
730
+ if (this.current !== session || !this.automatic)
731
+ return;
732
+ await session.backend.pause().catch(() => { });
733
+ const policy = this.nativeRemux;
734
+ const tryRemux = this.mode === 'native' && session.backend.diagnostics?.plan === 'direct' && policy !== 'never';
735
+ const priorAttempts = [...this.attempts.filter(attempt => attempt.outcome !== 'selected'), { mode: this.mode, outcome: 'failed', reason: `Runtime playback failure: ${session.error?.message ?? 'Playback backend became unavailable'}` }];
736
+ try {
737
+ if (tryRemux)
738
+ this.nativeRemux = 'always';
739
+ await this.select(this.source, this.settings, true, this.nativeTracks, tryRemux ? 0 : PLAYBACK_MODES.indexOf(this.mode) + 1, undefined, priorAttempts);
740
+ }
741
+ finally {
742
+ this.nativeRemux = policy;
743
+ }
744
+ }, 'switching').catch(error => { if (!this.destroyed)
745
+ this.emit('error', String(error)); }).finally(() => { this.recovering = false; if (this.current?.error && this.current !== session)
746
+ this.recover(this.current); });
747
+ }
748
+ setAutomaticSelection(enabled = true) {
749
+ if (typeof enabled !== 'boolean')
750
+ throw new PlayerError('INVALID_ARGUMENT', 'Invalid automatic selection policy');
751
+ return this.enqueue(async () => {
752
+ const previous = this.automatic;
753
+ this.automatic = enabled;
754
+ try {
755
+ if (enabled && this.source)
756
+ await this.select(this.source, this.settings, true, this.nativeTracks);
757
+ }
758
+ catch (error) {
759
+ this.automatic = previous;
760
+ throw error;
761
+ }
762
+ }, 'switching');
763
+ }
764
+ open(input, options = {}) {
765
+ let source;
766
+ try {
767
+ if (typeof input === 'string' || input instanceof URL || (!(input instanceof File) && !(input instanceof ArrayBuffer) && input && typeof input === 'object')) {
768
+ const value = typeof input === 'string' || input instanceof URL ? { url: String(input) } : input;
769
+ if (typeof value.url !== 'string' || !value.url.trim())
770
+ throw Error('Invalid remote URL');
771
+ const url = new URL(value.url, location.href);
772
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password)
773
+ throw Error('Invalid remote URL');
774
+ source = { kind: 'remote', options: { ...value, url: url.href, headers: value.headers ? { ...value.headers } : undefined, streaming: value.streaming ? { ...value.streaming } : undefined, allowedOrigins: value.allowedOrigins?.slice() } };
775
+ }
776
+ else {
777
+ if (!(input instanceof File) && !(input instanceof ArrayBuffer))
778
+ throw Error('Expected File, ArrayBuffer or remote source');
779
+ if (input instanceof ArrayBuffer && input.byteLength > 32 * 1024 * 1024)
780
+ throw Error('ArrayBuffer sources are limited to 32 MiB; use File for larger sources');
781
+ source = { kind: 'local', file: input instanceof File ? input : input.slice(0), input: { demuxer: options.demuxer } };
782
+ }
783
+ }
784
+ catch (error) {
785
+ return Promise.reject(playerError(error));
786
+ }
787
+ return this.enqueue(() => this.select(source, this.settings, false, []), 'opening', options.signal);
788
+ }
789
+ openRemote(source, options = {}) { return this.open(source, options); }
790
+ setMode(mode) {
791
+ modeValue(mode);
792
+ return this.enqueue(async () => {
793
+ this.validateFilters(mode, this.settings);
794
+ if (mode === this.mode) {
795
+ this.automatic = false;
796
+ return;
797
+ }
798
+ if (this.source)
799
+ await this.replace(this.source, mode, this.settings, true, this.nativeTracks, undefined, false);
800
+ else {
801
+ this.currentMode = mode;
802
+ this.emit('modechange', { phase: 'ready', mode, position: 0 });
803
+ }
804
+ this.automatic = false;
805
+ }, 'switching');
806
+ }
807
+ filters(key, value) {
808
+ const chain = filterChain(value);
809
+ return this.enqueue(async () => {
810
+ if (!this.automatic)
811
+ this.validateFilters(this.mode, { ...this.settings, [key]: chain });
812
+ if (chain === this.settings[key])
813
+ return;
814
+ const settings = { ...this.settings, [key]: chain };
815
+ if (this.source)
816
+ await this.select(this.source, settings, true, this.nativeTracks);
817
+ else {
818
+ this.settings = settings;
819
+ if (this.automatic && (settings.vf || settings.af))
820
+ this.currentMode = featureRejection('hybrid', { ...settings, toneMapping: this.toneMapping, hybridAudioFilters: this.hybridAudioFilters }) ? 'software' : 'hybrid';
821
+ }
822
+ }, 'switching');
823
+ }
824
+ setVideoFilters(value) { return this.filters('vf', value); }
825
+ setAudioFilters(value) { return this.filters('af', value); }
826
+ setAudioGain(value) {
827
+ if (!Number.isFinite(value) || value < 0 || value > 1)
828
+ throw new PlayerError('INVALID_ARGUMENT', 'Gain must be between 0 and 1');
829
+ return this.enqueue(async () => {
830
+ if (value === this.settings.gain)
831
+ return;
832
+ const desired = { ...this.settings, gain: value };
833
+ if (this.current?.backend.gain) {
834
+ await this.current.backend.gain(value);
835
+ this.assertOperation();
836
+ this.settings = desired;
837
+ if (this.source)
838
+ this.planDecisions = this.admissible(this.source, desired, this.subtitleAssets, this.nativeTracks, this.admissionContext.nativeReason, this.admissionContext.automatic);
839
+ }
840
+ else if (this.source)
841
+ await this.select(this.source, desired, true, this.nativeTracks);
842
+ else
843
+ this.settings = desired;
844
+ });
845
+ }
846
+ setting(action, update) {
847
+ return this.enqueue(async () => { if (this.current)
848
+ await action(this.current.backend); update(); });
849
+ }
850
+ play() {
851
+ // Initiate resume before yielding the user's activation to the operation queue.
852
+ const immediate = !this.destroyed && this.queued === 0 && this.current ? this.current.backend.play() : undefined;
853
+ immediate?.catch(() => { });
854
+ return this.enqueue(async () => { if (!this.current)
855
+ throw Error('No source'); await (immediate ?? this.current.backend.play()); this.settings.pause = false; });
856
+ }
857
+ pause() { return this.setting(p => p.pause(), () => { this.settings.pause = true; this.observedPlaying = false; this.observedWaiting = false; }); }
858
+ seek(seconds) {
859
+ if (!Number.isFinite(seconds) || seconds < 0)
860
+ throw new PlayerError('INVALID_ARGUMENT', 'Invalid seek time');
861
+ return this.enqueue(async () => {
862
+ if (!this.current)
863
+ throw new Error('No source');
864
+ const window = this.state.seekable;
865
+ if (window && !window.some(r => seconds >= r.start && seconds <= r.end))
866
+ throw new PlayerError('INVALID_ARGUMENT', 'Seek is outside the current seekable window');
867
+ try {
868
+ await this.current.backend.seek(seconds);
869
+ await this.settled(this.current, this.mode, seconds);
870
+ }
871
+ catch (error) {
872
+ if (this.activeOperation?.controller.signal.aborted || playerError(error).code === 'AUTOPLAY_BLOCKED' || !this.automatic || this.mode === 'software' || terminalSourceFailure(error) || /out of range|Invalid seek/i.test(String(error)))
873
+ throw error;
874
+ const priorAttempts = [...this.attempts.filter(attempt => attempt.outcome !== 'selected'), { mode: this.mode, outcome: 'failed', reason: `Seek presentation failure: ${playerError(error).message}` }];
875
+ await this.select(this.source, this.settings, true, this.nativeTracks, PLAYBACK_MODES.indexOf(this.mode) + 1, seconds, priorAttempts);
876
+ }
877
+ }, 'seeking');
878
+ }
879
+ volume(value) {
880
+ if (!Number.isFinite(value) || value < 0 || value > 100)
881
+ throw new PlayerError('INVALID_ARGUMENT', 'Invalid volume');
882
+ return this.setting(p => p.volume(this.muted ? 0 : value), () => { this.settings.volume = value; });
883
+ }
884
+ setVolume(value) { if (!Number.isFinite(value) || value < 0 || value > 1)
885
+ throw new PlayerError('INVALID_ARGUMENT', 'Volume must be 0 to 1'); return this.volume(value * 100); }
886
+ setMuted(value) { if (typeof value !== 'boolean')
887
+ throw new PlayerError('INVALID_ARGUMENT', 'Expected boolean mute state'); return this.setting(p => p.volume(value ? 0 : this.settings.volume), () => { this.muted = value; }); }
888
+ setPlaybackRate(value) { return this.rate(value); }
889
+ selectAudioTrack(id) { return this.selectPublicTrack('audio', id); }
890
+ selectSubtitleTrack(id) { return this.selectPublicTrack('sub', id); }
891
+ selectPublicTrack(type, id) {
892
+ return this.enqueue(async () => {
893
+ if (!this.current)
894
+ throw Error('No source');
895
+ const raw = (this.properties.get('track-list') ?? []);
896
+ const plan = this.current.backend.diagnostics?.plan;
897
+ const track = raw.find(t => t.type === type && `${this.sourceSerial}:${trackKey(t, this.mode, plan)}` === id);
898
+ if (id !== null && id !== 'auto' && !track)
899
+ throw new PlayerError('INVALID_ARGUMENT', 'Unknown or stale public track ID');
900
+ const backendId = id === null ? 'no' : id === 'auto' ? 'auto' : String(track.id);
901
+ if (type === 'audio' && this.automatic && this.automaticLossless && this.source) {
902
+ const previous = this.publicSelections.get(type);
903
+ if (track)
904
+ this.publicSelections.set(type, trackKey(track, this.mode, plan));
905
+ else
906
+ this.publicSelections.delete(type);
907
+ try {
908
+ await this.select(this.source, { ...this.settings, aid: backendId }, true, this.nativeTracks);
909
+ }
910
+ catch (error) {
911
+ if (previous)
912
+ this.publicSelections.set(type, previous);
913
+ else
914
+ this.publicSelections.delete(type);
915
+ throw error;
916
+ }
917
+ return;
918
+ }
919
+ await this.current.backend.selectTrack(type, backendId);
920
+ this.settings[type === 'audio' ? 'aid' : 'sid'] = backendId;
921
+ if (track)
922
+ this.publicSelections.set(type, trackKey(track, this.mode, plan));
923
+ else
924
+ this.publicSelections.delete(type);
925
+ }, 'switching');
926
+ }
927
+ rate(value) {
928
+ if (!Number.isFinite(value) || value < .5 || value > 2)
929
+ throw new PlayerError('INVALID_ARGUMENT', 'Playback rate must be 0.5 to 2');
930
+ return this.setting(p => p.rate(value), () => { this.settings.speed = value; });
931
+ }
932
+ selectTrack(type, id) {
933
+ if (!['audio', 'sub'].includes(type) || !/^(?:[1-9][0-9]*|auto|no)$/.test(id))
934
+ throw new PlayerError('INVALID_ARGUMENT', 'Invalid track selection');
935
+ if (type === 'audio' && this.automatic && this.automaticLossless) {
936
+ const raw = (this.properties.get('track-list') ?? []);
937
+ const track = raw.find(t => t.type === 'audio' && String(t.id) === id);
938
+ const plan = this.current?.backend.diagnostics?.plan;
939
+ return this.selectPublicTrack(type, id === 'no' ? null : id === 'auto' ? 'auto' : track ? `${this.sourceSerial}:${trackKey(track, this.mode, plan)}` : 'missing');
940
+ }
941
+ return this.setting(p => p.selectTrack(type, id), () => { this.settings[type === 'audio' ? 'aid' : 'sid'] = id; this.publicSelections.delete(type); });
942
+ }
943
+ subtitleVisible(visible) {
944
+ if (typeof visible !== 'boolean')
945
+ throw new PlayerError('INVALID_ARGUMENT', 'Expected boolean subtitle visibility');
946
+ return this.enqueue(async () => {
947
+ const settings = { ...this.settings, subtitles: visible };
948
+ if (this.automatic && this.source && this.mode === 'native' && visible && !this.settings.subtitles)
949
+ await this.select(this.source, settings, true, this.nativeTracks);
950
+ else {
951
+ if (this.current)
952
+ await this.current.backend.subtitleVisible(visible);
953
+ this.settings = settings;
954
+ }
955
+ });
956
+ }
957
+ addSubtitle(file, options = {}) {
958
+ if (!(file instanceof File))
959
+ return Promise.reject(new PlayerError('INVALID_ARGUMENT', 'Expected a subtitle File'));
960
+ const format = file.name.split('.').at(-1)?.toLowerCase();
961
+ if (!['ass', 'ssa', 'srt', 'vtt'].includes(format ?? '') || file.size > 8 * 1024 * 1024)
962
+ return Promise.reject(new PlayerError('INVALID_ARGUMENT', 'Expected an SRT, ASS, SSA or WebVTT file up to 8 MiB'));
963
+ return this.enqueue(async () => {
964
+ if (!this.source)
965
+ throw Error('Open a movie before adding subtitles');
966
+ if (this.subtitleAssets.length >= 16 || this.subtitleAssets.reduce((n, a) => n + a.bytes.byteLength, 0) + file.size > 16 * 1024 * 1024)
967
+ throw Error('Subtitle budget exceeded');
968
+ const bytes = await this.interruptible(file.arrayBuffer());
969
+ const old = this.subtitleAssets;
970
+ this.subtitleAssets = [...old, { bytes, format: format, label: options.label ?? file.name, language: options.language, select: options.select ?? true }];
971
+ try {
972
+ await this.select(this.source, { ...this.settings, sid: options.select === false ? this.settings.sid : 'auto' }, true, this.nativeTracks);
973
+ }
974
+ catch (error) {
975
+ this.subtitleAssets = old;
976
+ throw error;
977
+ }
978
+ });
979
+ }
980
+ addFont(file) {
981
+ if (!(file instanceof File) || !/\.(ttf|otf)$/i.test(file.name) || file.size > 8 * 1024 * 1024)
982
+ return Promise.reject(new PlayerError('INVALID_ARGUMENT', 'Expected a TTF/OTF font up to 8 MiB'));
983
+ return this.enqueue(async () => {
984
+ if (this.fonts.length >= 16 || this.fonts.reduce((n, a) => n + a.bytes.byteLength, 0) + file.size > 32 * 1024 * 1024)
985
+ throw Error('Font budget exceeded');
986
+ const bytes = await this.interruptible(file.arrayBuffer()), old = this.fonts;
987
+ this.fonts = [...old, { name: 'user-' + old.length + '.' + file.name.split('.').at(-1).toLowerCase(), bytes }];
988
+ try {
989
+ if (this.source && (this.mode !== 'native' || (this.nativeASS && this.subtitleAssets.length)))
990
+ await this.replace(this.source, this.mode, this.settings, true, this.nativeTracks);
991
+ }
992
+ catch (error) {
993
+ this.fonts = old;
994
+ throw error;
995
+ }
996
+ });
997
+ }
998
+ setToneMapping(value) {
999
+ if (!['off', 'hdr-to-sdr'].includes(value))
1000
+ throw new PlayerError('INVALID_ARGUMENT', 'Invalid tone mapping policy');
1001
+ return this.enqueue(async () => { const old = this.toneMapping; this.toneMapping = value; try {
1002
+ if (this.source)
1003
+ await this.select(this.source, this.settings, true, this.nativeTracks);
1004
+ else if (this.automatic && value !== 'off')
1005
+ this.currentMode = 'software';
1006
+ else
1007
+ this.validateFilters(this.mode, this.settings);
1008
+ }
1009
+ catch (error) {
1010
+ this.toneMapping = old;
1011
+ throw error;
1012
+ } });
1013
+ }
1014
+ addTextTrack(track) {
1015
+ const source = { ...track };
1016
+ return this.enqueue(async () => {
1017
+ if (this.mode !== 'native' || !this.current)
1018
+ throw new Error('External browser text tracks require an open native player');
1019
+ await this.current.backend.addTextTrack(source);
1020
+ this.nativeTracks.push(source);
1021
+ });
1022
+ }
1023
+ resize(width, height) {
1024
+ if (this.destroyed)
1025
+ throw new PlayerError('ABORTED', 'Player is destroyed');
1026
+ dimensions(width, height);
1027
+ this.width = width;
1028
+ this.height = height;
1029
+ this.current?.backend.resize(width, height);
1030
+ }
1031
+ close() {
1032
+ if (this.destroyed)
1033
+ return this.destruction;
1034
+ if (this.closing)
1035
+ return this.closing;
1036
+ this.operationEpoch++;
1037
+ this.activeOperation?.controller.abort();
1038
+ this.inspection?.abort();
1039
+ clearInterval(this.monitor);
1040
+ const cleanup = Promise.all([this.candidate, this.current].map(s => s?.backend.destroy().catch(() => { })));
1041
+ this.closing = this.enqueue(async () => { await cleanup; await this.dispose(this.current); this.current = undefined; this.candidate = undefined; this.source = undefined; this.nativeTracks = []; this.subtitleAssets = []; this.publicSelections.clear(); this.settings = { ...this.settings, pause: true, aid: 'auto', sid: 'auto' }; this.sessionError = null; this.observedPlaying = false; this.observedWaiting = false; }, 'closing').finally(() => { this.closing = undefined; });
1042
+ return this.closing;
1043
+ }
1044
+ destroy() {
1045
+ if (this.destruction)
1046
+ return this.destruction;
1047
+ this.destroyed = true;
1048
+ this.operationEpoch++;
1049
+ this.activeOperation?.controller.abort();
1050
+ this.lifetime.abort();
1051
+ this.inspection?.abort();
1052
+ clearInterval(this.monitor);
1053
+ this.destruction = (async () => {
1054
+ await Promise.all([this.candidate, this.current].map(session => session?.backend.destroy().catch(() => { })));
1055
+ await this.queue;
1056
+ try {
1057
+ await this.dispose(this.current);
1058
+ }
1059
+ finally {
1060
+ this.current = undefined;
1061
+ this.source = undefined;
1062
+ this.nativeTracks = [];
1063
+ this.subtitleAssets = [];
1064
+ this.fonts = [];
1065
+ this.settings.pause = true;
1066
+ this.sessionError = null;
1067
+ this.publish();
1068
+ this.subscribers.clear();
1069
+ this.root.remove();
1070
+ }
1071
+ })();
1072
+ return this.destruction;
1073
+ }
1074
+ }