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,502 @@
1
+ /** Browser media ownership, including listeners, pending loads and object URLs. */
2
+ export class NativePlayer extends EventTarget {
3
+ video;
4
+ remuxPolicy;
5
+ assetBase;
6
+ bufferedSeeks;
7
+ audioAdaptation;
8
+ initialAudioTrack;
9
+ nativeASS;
10
+ fonts;
11
+ ready = Promise.resolve();
12
+ properties = new Map();
13
+ stopped = false;
14
+ ass;
15
+ assAssets = [];
16
+ assIndex = -1;
17
+ gainContext;
18
+ gainSource;
19
+ gainNode;
20
+ gainValue = 1;
21
+ async gain(value) {
22
+ this.assertActive();
23
+ if (!Number.isFinite(value) || value < 0 || value > 1)
24
+ throw new Error('Gain must be between 0 and 1');
25
+ if (!this.gainSource && value !== 1) {
26
+ const context = this.gainContext ?? (this.gainContext = new AudioContext());
27
+ // Resume before redirecting an already playing element into the graph.
28
+ // Ownership is recorded before awaiting; destroy cancels the wait.
29
+ if (!this.video.paused)
30
+ await this.resumeGain();
31
+ this.assertActive();
32
+ const source = context.createMediaElementSource(this.video), gain = context.createGain();
33
+ gain.gain.setValueAtTime(value, context.currentTime);
34
+ source.connect(gain);
35
+ gain.connect(context.destination);
36
+ this.gainSource = source;
37
+ this.gainNode = gain;
38
+ }
39
+ if (this.gainNode)
40
+ this.gainNode.gain.setValueAtTime(value, this.gainContext.currentTime);
41
+ this.gainValue = value;
42
+ }
43
+ async resumeGain() {
44
+ const context = this.gainContext;
45
+ if (!context || context.state !== 'suspended')
46
+ return;
47
+ await new Promise((resolve, reject) => {
48
+ let settled = false;
49
+ const finish = (error) => {
50
+ if (settled)
51
+ return;
52
+ settled = true;
53
+ clearTimeout(timer);
54
+ this.cancelers.delete(cancel);
55
+ error ? reject(error) : resolve();
56
+ };
57
+ const cancel = (error) => finish(error);
58
+ const timer = setTimeout(() => finish(new DOMException('Audio activation timed out', 'NotAllowedError')), 10000);
59
+ this.cancelers.add(cancel);
60
+ context.resume().then(() => finish(), error => finish(error));
61
+ });
62
+ this.assertActive();
63
+ }
64
+ destruction;
65
+ opening = false;
66
+ remux;
67
+ adapted = false;
68
+ remuxSource;
69
+ directFailure;
70
+ shiftedCues = new WeakSet();
71
+ sourceTime() { return Math.max(0, this.video.currentTime - (this.remux?.timelineBias ?? 0)); }
72
+ sourceDuration() { if (this.remux?.windowed)
73
+ return this.remux.duration ?? 0; return Number.isFinite(this.video.duration) ? Math.max(0, this.video.duration - (this.remux?.timelineBias ?? 0)) : 0; }
74
+ objectURL;
75
+ selectedSub = 'auto';
76
+ subsVisible = true;
77
+ cancelers = new Set();
78
+ listeners = [];
79
+ constructor(video, remuxPolicy = 'auto', assetBase = new URL('../../../', import.meta.url), bufferedSeeks = false, audioAdaptation, initialAudioTrack, nativeASS = false, fonts = []) {
80
+ super();
81
+ this.video = video;
82
+ this.remuxPolicy = remuxPolicy;
83
+ this.assetBase = assetBase;
84
+ this.bufferedSeeks = bufferedSeeks;
85
+ this.audioAdaptation = audioAdaptation;
86
+ this.initialAudioTrack = initialAudioTrack;
87
+ this.nativeASS = nativeASS;
88
+ this.fonts = fonts;
89
+ video.playsInline = true;
90
+ video.preload = 'auto';
91
+ for (const event of ['timeupdate', 'durationchange', 'loadedmetadata', 'play', 'pause', 'volumechange', 'ratechange', 'ended', 'waiting', 'playing', 'progress', 'seeking', 'seeked', 'resize']) {
92
+ const listener = () => {
93
+ this.refresh();
94
+ this.emit('activity', event);
95
+ if (event === 'ended' && (!this.remux?.windowed || this.remux.playbackEnded))
96
+ this.emit('mpv', { event: 'end-file', reason: 'eof' });
97
+ };
98
+ video.addEventListener(event, listener);
99
+ this.listeners.push(() => video.removeEventListener(event, listener));
100
+ }
101
+ const failed = () => { if (!this.opening && !this.remux?.starting && !this.stopped)
102
+ this.emit('error', `Native playback failed (${video.error?.code ?? 'unknown'}): ${video.error?.message ?? 'unsupported media or network failure'}`); };
103
+ video.addEventListener('error', failed);
104
+ this.listeners.push(() => video.removeEventListener('error', failed));
105
+ const tracks = () => this.refresh();
106
+ video.textTracks.addEventListener('change', tracks);
107
+ video.textTracks.addEventListener('addtrack', tracks);
108
+ this.listeners.push(() => { video.textTracks.removeEventListener('change', tracks); video.textTracks.removeEventListener('addtrack', tracks); });
109
+ this.refresh();
110
+ }
111
+ emit(type, detail) { this.dispatchEvent(new CustomEvent(type, { detail })); }
112
+ assertActive() { if (this.stopped)
113
+ throw new Error('Player is destroyed'); }
114
+ wait(event, start) {
115
+ this.assertActive();
116
+ return new Promise((resolve, reject) => {
117
+ const finish = (error) => {
118
+ clearTimeout(timer);
119
+ this.video.removeEventListener(event, done);
120
+ this.video.removeEventListener('error', failed);
121
+ this.cancelers.delete(cancel);
122
+ error ? reject(error) : resolve();
123
+ };
124
+ const done = () => finish();
125
+ const failed = () => finish(new Error(`Native media operation failed: ${this.video.error?.message || this.video.error?.code}`));
126
+ const cancel = (error) => finish(error);
127
+ const timer = setTimeout(() => finish(new Error(`Native ${event} timed out`)), 25000);
128
+ this.cancelers.add(cancel);
129
+ this.video.addEventListener(event, done, { once: true });
130
+ this.video.addEventListener('error', failed, { once: true });
131
+ try {
132
+ start();
133
+ }
134
+ catch (error) {
135
+ finish(error);
136
+ }
137
+ });
138
+ }
139
+ refresh() {
140
+ const tracks = Array.from(this.video.textTracks, (t, i) => ({ id: String(i + 1), type: 'sub', title: t.label, lang: t.language, selected: t.mode === 'showing' }));
141
+ tracks.push(...this.assAssets.map((a, i) => ({ id: String(100001 + i), type: 'sub', codec: a.format, title: a.label, lang: a.language, external: true, 'external-index': i + 1, selected: (this.selectedSub === 'auto' || Number(this.selectedSub) === 100001 + i) && this.assIndex === i })));
142
+ const audio = this.video.audioTracks;
143
+ if (this.remux?.tracks)
144
+ tracks.push(...this.remux.tracks.filter(t => t.type === 'audio').map(t => ({ ...t, selected: t.selected && !this.video.muted })));
145
+ else if (audio)
146
+ tracks.push(...Array.from(audio, (t, i) => ({ id: String(i + 1), type: 'audio', title: t.label, lang: t.language, selected: t.enabled })));
147
+ const timeRanges = (r) => Array.from({ length: r.length }, (_, i) => ({ start: Math.max(0, r.start(i) - (this.remux?.timelineBias ?? 0)), end: Math.max(0, r.end(i) - (this.remux?.timelineBias ?? 0)) }));
148
+ const values = { 'time-pos': this.sourceTime(), duration: Number.isFinite(this.video.duration) ? this.sourceDuration() : null, 'native-buffered': this.remux?.windowed ? (this.remux.ranges?.() ?? []).map(([start, end]) => ({ start, end })) : timeRanges(this.video.buffered), 'native-seekable': this.remux?.windowed ? [{ start: 0, end: this.sourceDuration() }] : timeRanges(this.video.seekable), 'native-live': this.video.duration === Infinity, pause: this.remux?.playbackPaused ?? this.video.paused, 'eof-reached': this.remux?.playbackEnded ?? this.video.ended, volume: this.video.volume * 100, speed: this.video.playbackRate, 'track-list': tracks };
149
+ for (const [name, data] of Object.entries(values)) {
150
+ if (name !== 'track-list' && this.properties.get(name) === data)
151
+ continue;
152
+ this.properties.set(name, data);
153
+ this.emit('mpv', { event: 'property-change', name, data });
154
+ }
155
+ }
156
+ get diagnostics() { const q = this.video.getVideoPlaybackQuality(); return { path: 'native', plan: this.remux ? (this.adapted ? `adapted-${this.audioAdaptation}` : 'remux') : 'direct', subtitleOverlay: this.ass ? { component: 'libass', scope: 'external-ass', destination: 'container-only', ...this.ass.stats } : undefined, audioProcessing: { component: this.gainContext ? 'web-audio-gain' : 'media-element', gain: this.gainValue, contextState: this.gainContext?.state, baseLatency: this.gainContext?.baseLatency }, directFailure: this.directFailure, remux: this.remux?.snapshot(), position: this.sourceTime(), rendered: q.totalVideoFrames, dropped: q.droppedVideoFrames, readyState: this.video.readyState }; }
157
+ async load(url) {
158
+ await this.wait('loadeddata', () => { this.video.src = url; this.video.load(); });
159
+ this.refresh();
160
+ this.emit('mpv', { event: 'file-loaded' });
161
+ }
162
+ async startRemux(source, target = 0) {
163
+ this.assertActive();
164
+ if (!crossOriginIsolated || typeof MediaSource === 'undefined')
165
+ throw Error('Native remux requires MediaSource and cross-origin isolation');
166
+ if (source.options?.format && source.options.format !== 'file')
167
+ throw Error('Native remux currently requires a random-access file source; use Hybrid for this manifest');
168
+ const moduleURL = new URL('web/native-remux-player.js', this.assetBase).href;
169
+ const { RemuxPlayer } = await import(moduleURL);
170
+ this.assertActive();
171
+ const { refreshAuthorization, ...options } = source.options ?? {};
172
+ const transport = { ...source, ...(source.options ? { options: options } : {}), refreshAuthorization };
173
+ const attempt = async (adapted) => {
174
+ this.assertActive();
175
+ this.adapted = adapted;
176
+ this.remux ??= new RemuxPlayer(this.video, { bufferedSeeks: this.bufferedSeeks, audioAdaptation: adapted ? this.audioAdaptation : undefined });
177
+ this.remux.audioAdaptation = adapted ? this.audioAdaptation : undefined;
178
+ this.remux.onError = message => { if (!this.opening && !this.stopped)
179
+ this.emit('error', message); };
180
+ await this.remux.open(transport, target);
181
+ this.assertActive();
182
+ };
183
+ try {
184
+ await attempt(false);
185
+ }
186
+ catch (error) {
187
+ if (this.stopped || !this.audioAdaptation || !String(error).includes('Audio codec has no browser MP4 packet contract'))
188
+ throw error;
189
+ await attempt(true);
190
+ }
191
+ this.remuxSource = source;
192
+ if (this.video.seeking)
193
+ await this.wait('seeked', () => { });
194
+ this.refresh();
195
+ this.emit('source', { plan: this.adapted ? `adapted-${this.audioAdaptation}` : 'remux', tracks: this.remux.tracks });
196
+ this.emit('mpv', { event: 'file-loaded' });
197
+ }
198
+ async loadPlan(source, direct, requiresRemux = false) {
199
+ this.assertActive();
200
+ this.opening = true;
201
+ try {
202
+ if (this.remuxPolicy !== 'always' && !requiresRemux) {
203
+ try {
204
+ await direct();
205
+ return;
206
+ }
207
+ catch (error) {
208
+ if (this.stopped || this.remuxPolicy === 'never' || ![3, 4].includes(this.video.error?.code ?? 0))
209
+ throw error;
210
+ this.directFailure = String(error);
211
+ }
212
+ }
213
+ else if (this.remuxPolicy === 'never')
214
+ throw Error('Native direct cannot enforce these source permissions; enable native remux or choose Hybrid');
215
+ await this.startRemux(source);
216
+ }
217
+ finally {
218
+ this.opening = false;
219
+ }
220
+ }
221
+ async open(file) {
222
+ this.assertActive();
223
+ const local = file instanceof File ? file : new File([file], 'media');
224
+ this.objectURL = URL.createObjectURL(local);
225
+ try {
226
+ await this.loadPlan({ file: local, audioTrack: this.initialAudioTrack }, () => this.load(this.objectURL));
227
+ }
228
+ catch (error) {
229
+ URL.revokeObjectURL(this.objectURL);
230
+ this.objectURL = undefined;
231
+ throw error;
232
+ }
233
+ }
234
+ async openRemote(source) {
235
+ this.assertActive();
236
+ const url = new URL(source.url, location.href);
237
+ if (!['http:', 'https:'].includes(url.protocol))
238
+ throw Error('Remote sources require HTTP or HTTPS');
239
+ const requiresRemux = !!(source.headers || source.refreshAuthorization || source.allowedOrigins || source.immutable !== undefined || source.credentials === 'omit');
240
+ this.video.crossOrigin = source.credentials === 'include' ? 'use-credentials' : 'anonymous';
241
+ await this.loadPlan({ options: { ...source, url: url.href }, audioTrack: this.initialAudioTrack }, async () => {
242
+ if (source.format && source.format !== 'file') {
243
+ const mime = source.format === 'hls' ? 'application/vnd.apple.mpegurl' : 'application/dash+xml';
244
+ if (!this.video.canPlayType(mime))
245
+ throw Error(`Native ${source.format.toUpperCase()} playback is not supported by this browser`);
246
+ }
247
+ await this.load(url.href);
248
+ }, requiresRemux);
249
+ }
250
+ async play() { this.assertActive(); await this.resumeGain(); this.assertActive(); if (this.remux)
251
+ await this.remux.play();
252
+ else
253
+ await this.video.play(); this.refresh(); }
254
+ async pause() { this.assertActive(); if (this.remux)
255
+ this.remux.pause();
256
+ else
257
+ this.video.pause(); this.refresh(); }
258
+ async seek(seconds) {
259
+ this.assertActive();
260
+ if (this.remux) {
261
+ const paused = this.remux.playbackPaused ?? this.video.paused;
262
+ if (this.remux.canSeekBuffered?.(seconds) && this.video.videoWidth && Math.abs(this.sourceTime() - seconds) > .001) {
263
+ // Hold the presentation clock while verifying the target frame. Otherwise
264
+ // a playing clock can advance beyond the exact target before rVFC runs.
265
+ // Producer/session identity is retained; restore the captured intent below.
266
+ this.remux.pause();
267
+ await this.seekPresented(seconds, () => this.remux.seek(seconds));
268
+ }
269
+ else
270
+ await this.remux.seek(seconds);
271
+ this.assertActive();
272
+ if (this.video.seeking)
273
+ await this.wait('seeked', () => { });
274
+ if (!paused)
275
+ await this.remux.play();
276
+ this.refresh();
277
+ return;
278
+ }
279
+ if (Math.abs(this.video.currentTime - seconds) < .001 && !this.video.seeking)
280
+ return;
281
+ await this.wait('seeked', () => { this.video.currentTime = seconds; });
282
+ this.refresh();
283
+ }
284
+ seekPresented(target, action) {
285
+ return new Promise((resolve, reject) => {
286
+ let frame = 0, accepted = false, completed = false, finished = false;
287
+ const presentation = this.remux, generation = presentation?.generation, mediaTarget = target + (presentation?.timelineBias ?? 0), expected = presentation?.expectedVideoFrame?.(target);
288
+ const correlated = !!presentation?.muxedFrames || expected !== undefined;
289
+ let presented = false;
290
+ const seeked = () => { if (this.stopped || this.remux !== presentation || presentation?.generation !== generation) {
291
+ finish(new Error('Native seek presentation was retired'));
292
+ return;
293
+ } if (correlated && presented && !this.video.seeking && Math.abs(this.video.currentTime - mediaTarget) < .001) {
294
+ accepted = true;
295
+ if (completed)
296
+ finish();
297
+ } };
298
+ const finish = (error) => { if (finished)
299
+ return; finished = true; clearTimeout(timer); this.video.cancelVideoFrameCallback(frame); this.video.removeEventListener('seeked', seeked); this.cancelers.delete(cancel); error ? reject(error) : resolve(); };
300
+ const cancel = (error) => finish(error);
301
+ const timer = setTimeout(() => finish(new Error('Native seek did not present the target')), 10000);
302
+ const next = (_, metadata) => {
303
+ if (this.stopped || this.remux !== presentation || presentation?.generation !== generation) {
304
+ finish(new Error('Native seek presentation was retired'));
305
+ return;
306
+ }
307
+ // A browser may report the frame's PTS or clip that timestamp to the seek
308
+ // point (Firefox). Accept only the corresponding source-frame interval.
309
+ // Legacy remux artifacts without packet metadata retain their prior guard.
310
+ const matches = presentation?.matchesVideoFrame?.(target, metadata.mediaTime) ?? (expected !== undefined && metadata.mediaTime >= expected + (presentation?.timelineBias ?? 0) - .001 && metadata.mediaTime <= mediaTarget + .001);
311
+ if (correlated && matches && Math.abs(this.video.currentTime - mediaTarget) < .001)
312
+ presented = true;
313
+ if (!this.video.seeking && Math.abs(this.video.currentTime - mediaTarget) < .001 && (correlated ? presented : metadata.mediaTime <= mediaTarget + .001)) {
314
+ accepted = true;
315
+ if (completed)
316
+ finish();
317
+ }
318
+ else
319
+ frame = this.video.requestVideoFrameCallback(next);
320
+ };
321
+ // Register before currentTime changes: the compositor callback may precede
322
+ // the queued DOM seeking/seeked events, especially for buffered media.
323
+ this.cancelers.add(cancel);
324
+ this.video.addEventListener('seeked', seeked);
325
+ frame = this.video.requestVideoFrameCallback(next);
326
+ Promise.resolve().then(action).then(() => { completed = true; if (accepted)
327
+ finish(); }, error => finish(error));
328
+ });
329
+ }
330
+ async rate(value) { this.assertActive(); this.video.defaultPlaybackRate = value; this.video.playbackRate = value; this.refresh(); }
331
+ async volume(value) { this.assertActive(); this.video.volume = value / 100; this.refresh(); }
332
+ async selectTrack(type, id) {
333
+ this.assertActive();
334
+ if (type === 'audio') {
335
+ const audio = this.video.audioTracks;
336
+ if (id === 'auto') {
337
+ this.video.muted = false;
338
+ return;
339
+ }
340
+ if (id === 'no') {
341
+ this.video.muted = true;
342
+ return;
343
+ }
344
+ if (this.remux && this.remuxSource) {
345
+ const track = this.remux.tracks?.find(t => t.type === 'audio' && t.id === id);
346
+ if (!track)
347
+ throw Error('Unknown remux audio track');
348
+ if (!track.selected) {
349
+ const previous = this.remuxSource, position = this.sourceTime(), paused = this.video.paused;
350
+ this.opening = true;
351
+ try {
352
+ await this.startRemux({ ...previous, audioTrack: Number(id) - 1 }, position);
353
+ }
354
+ catch (error) {
355
+ try {
356
+ await this.startRemux(previous, position);
357
+ }
358
+ catch (recovery) {
359
+ this.emit('error', String(recovery));
360
+ }
361
+ throw error;
362
+ }
363
+ finally {
364
+ this.opening = false;
365
+ if (!paused)
366
+ await this.video.play();
367
+ }
368
+ }
369
+ this.video.muted = false;
370
+ this.refresh();
371
+ return;
372
+ }
373
+ if (!audio || !audio[Number(id) - 1])
374
+ throw new Error('Native audio track selection is not supported for this source/browser');
375
+ this.video.muted = false;
376
+ Array.from(audio).forEach((t, i) => { t.enabled = i === Number(id) - 1; });
377
+ }
378
+ else {
379
+ if (Number(id) >= 100001) {
380
+ const index = Number(id) - 100001;
381
+ if (!this.assAssets[index])
382
+ throw Error('Unknown Native ASS track');
383
+ await this.ass.load(this.assAssets[index]);
384
+ this.assIndex = index;
385
+ }
386
+ if (!['auto', 'no'].includes(id) && !this.assAssets[Number(id) - 100001] && !this.video.textTracks[Number(id) - 1])
387
+ throw new Error('Unknown native subtitle track');
388
+ this.selectedSub = id;
389
+ this.applySubtitles();
390
+ }
391
+ this.refresh();
392
+ }
393
+ applySubtitles() {
394
+ this.ass?.visible(this.assIndex >= 0 && this.subsVisible && this.selectedSub !== 'no' && (this.selectedSub === 'auto' || Number(this.selectedSub) >= 100001));
395
+ const preferred = this.video.querySelector('track[default]')?.track;
396
+ const autoIndex = Math.max(0, Array.from(this.video.textTracks).findIndex(t => t === preferred));
397
+ Array.from(this.video.textTracks).forEach((t, i) => { t.mode = this.subsVisible && !(this.assIndex >= 0 && this.selectedSub === 'auto') && this.selectedSub !== 'no' && (this.selectedSub === 'auto' ? i === autoIndex : i === Number(this.selectedSub) - 1) ? 'showing' : 'disabled'; });
398
+ }
399
+ async subtitleVisible(visible) { this.assertActive(); this.subsVisible = visible; this.applySubtitles(); this.refresh(); }
400
+ async addSubtitle(asset) {
401
+ this.assertActive();
402
+ if (this.adapted && this.audioAdaptation === 'opus')
403
+ throw Error('Native Opus plus ASS is not qualified');
404
+ if (!this.nativeASS || !['ass', 'ssa'].includes(asset.format))
405
+ throw Error('Native external ASS/SSA requires explicit experimental admission');
406
+ if (this.assAssets.length >= 16 || asset.bytes.byteLength > 8 * 1024 * 1024 || this.assAssets.reduce((n, a) => n + a.bytes.byteLength, 0) + asset.bytes.byteLength > 16 * 1024 * 1024)
407
+ throw Error('Subtitle budget exceeded');
408
+ if (!this.ass) {
409
+ const { NativeASS } = await import('./native-ass.js');
410
+ this.assertActive();
411
+ this.ass = new NativeASS(this.video, () => this.sourceTime(), this.assetBase, this.fonts, error => { if (!this.stopped)
412
+ this.emit('error', String(error)); });
413
+ }
414
+ if (asset.select) {
415
+ try {
416
+ await this.ass.load(asset);
417
+ this.assertActive();
418
+ }
419
+ catch (error) {
420
+ this.ass.destroy();
421
+ this.ass = undefined;
422
+ throw error;
423
+ }
424
+ this.assIndex = this.assAssets.length;
425
+ }
426
+ this.assAssets.push(asset);
427
+ this.applySubtitles();
428
+ this.refresh();
429
+ }
430
+ async addTextTrack(source) {
431
+ this.assertActive();
432
+ const url = new URL(source.src, location.href);
433
+ if (!['http:', 'https:', 'blob:'].includes(url.protocol))
434
+ throw new Error('Text tracks require HTTP, HTTPS or a blob URL');
435
+ const track = document.createElement('track');
436
+ track.kind = 'subtitles';
437
+ track.label = source.label;
438
+ track.srclang = source.language || '';
439
+ track.default = !!source.default;
440
+ track.src = url.href;
441
+ await new Promise((resolve, reject) => {
442
+ const finish = (error) => { clearTimeout(timer); track.removeEventListener('load', loaded); track.removeEventListener('error', failed); this.cancelers.delete(cancel); if (error) {
443
+ track.remove();
444
+ reject(error);
445
+ }
446
+ else
447
+ resolve(); };
448
+ const loaded = () => { this.shiftTextTrack(track); finish(); };
449
+ const failed = () => finish(new Error('Native text track failed to load'));
450
+ const cancel = (error) => finish(error);
451
+ const timer = setTimeout(() => finish(new Error('Native text track load timed out')), 15000);
452
+ this.cancelers.add(cancel);
453
+ track.addEventListener('load', loaded);
454
+ track.addEventListener('error', failed);
455
+ this.video.append(track);
456
+ track.addEventListener('load', () => this.shiftTextTrack(track));
457
+ track.track.mode = 'hidden';
458
+ });
459
+ this.applySubtitles();
460
+ this.refresh();
461
+ }
462
+ shiftTextTrack(track) {
463
+ if (!this.remux)
464
+ return;
465
+ for (const cue of Array.from(track.track.cues ?? []))
466
+ if (!this.shiftedCues.has(cue)) {
467
+ cue.startTime += this.remux.timelineBias;
468
+ cue.endTime += this.remux.timelineBias;
469
+ this.shiftedCues.add(cue);
470
+ }
471
+ }
472
+ resize(width, height) { this.assertActive(); this.video.width = width; this.video.height = height; }
473
+ audioDiagnostics() { return { state: this.stopped ? 'closed' : this.video.paused ? 'paused' : 'running', source: 'native', decodedSampleCountersAvailable: false }; }
474
+ destroy() {
475
+ if (this.destruction)
476
+ return this.destruction;
477
+ this.destruction = this.dispose();
478
+ return this.destruction;
479
+ }
480
+ async dispose() {
481
+ this.stopped = true;
482
+ this.ass?.destroy();
483
+ this.ass = undefined;
484
+ this.assAssets = [];
485
+ for (const cancel of this.cancelers)
486
+ cancel(new Error('Player is destroyed'));
487
+ await this.remux?.destroy();
488
+ this.gainSource?.disconnect();
489
+ this.gainNode?.disconnect();
490
+ if (this.gainContext)
491
+ await this.gainContext.close();
492
+ this.listeners.forEach(remove => remove());
493
+ this.listeners = [];
494
+ this.video.pause();
495
+ this.video.removeAttribute('src');
496
+ this.video.replaceChildren();
497
+ this.video.load();
498
+ if (this.objectURL)
499
+ URL.revokeObjectURL(this.objectURL);
500
+ this.objectURL = undefined;
501
+ }
502
+ }