playsvideo 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/engine.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import Hls from 'hls.js';
2
2
  import { WasmFfmpegRunner } from './adapters/wasm-ffmpeg.js';
3
+ import { createBrowserPlaybackCapabilities, evaluatePlaybackOptions, } from './playback-selection.js';
3
4
  import { createLocalAudioTranscoder, makeAacDecoderConfig } from './pipeline/audio-transcode.js';
4
- import { audioNeedsTranscode, createBrowserProber } from './pipeline/codec-probe.js';
5
5
  import { demuxSource, getKeyframeIndex } from './pipeline/demux.js';
6
6
  import { generateVodPlaylist } from './pipeline/playlist.js';
7
7
  import { buildSegmentPlan } from './pipeline/segment-plan.js';
8
+ import { parseSubtitleFile, subtitleDataToWebVTT } from './pipeline/subtitle.js';
8
9
  import { processSegmentWithAbort } from './pipeline/segment-processor.js';
9
10
  import { isAbortableSource } from './pipeline/source-signal.js';
10
11
  function defaultTranscodeWorkerCount() {
@@ -18,6 +19,8 @@ export class PlaysVideoEngine extends EventTarget {
18
19
  options;
19
20
  worker = null;
20
21
  transcodeWorkers = [];
22
+ _transcodeWorkerStates = [];
23
+ _segmentStates = new Map();
21
24
  hls = null;
22
25
  // Pending segment requests from hls.js custom loader
23
26
  pendingSegments = new Map();
@@ -28,7 +31,7 @@ export class PlaysVideoEngine extends EventTarget {
28
31
  pendingPlaylist = null;
29
32
  segmentRequestTimes = new Map();
30
33
  // Subtitle state
31
- subtitleBlobUrls = [];
34
+ attachedSubtitleTracks = [];
32
35
  _subtitleTracks = [];
33
36
  // Public read-only state
34
37
  _phase = 'idle';
@@ -38,6 +41,13 @@ export class PlaysVideoEngine extends EventTarget {
38
41
  _passthrough = false;
39
42
  _blobUrl = null;
40
43
  _pendingFileType = null;
44
+ _codecPath = {
45
+ mode: 'pipeline',
46
+ sourceVideo: { short: null, full: null },
47
+ sourceAudio: { short: null, full: null },
48
+ outputVideo: { short: null, full: null },
49
+ outputAudio: { short: null, full: null },
50
+ };
41
51
  // Pre-built keyframe index (e.g. from MKV cues) to skip mediabunny scan
42
52
  _keyframeIndex = null;
43
53
  // Main-thread pipeline state (used by loadSource)
@@ -68,6 +78,26 @@ export class PlaysVideoEngine extends EventTarget {
68
78
  get passthrough() {
69
79
  return this._passthrough;
70
80
  }
81
+ get codecPath() {
82
+ return {
83
+ mode: this._codecPath.mode,
84
+ sourceVideo: { ...this._codecPath.sourceVideo },
85
+ sourceAudio: { ...this._codecPath.sourceAudio },
86
+ outputVideo: { ...this._codecPath.outputVideo },
87
+ outputAudio: { ...this._codecPath.outputAudio },
88
+ };
89
+ }
90
+ get transcodeWorkerStates() {
91
+ return this._transcodeWorkerStates.map((worker) => ({ ...worker }));
92
+ }
93
+ get segmentStates() {
94
+ return Array.from(this._segmentStates.values())
95
+ .sort((a, b) => a.index - b.index)
96
+ .map((segment) => ({
97
+ ...segment,
98
+ events: segment.events.map((event) => ({ ...event })),
99
+ }));
100
+ }
71
101
  constructor(video, options = {}) {
72
102
  super();
73
103
  this.video = video;
@@ -91,6 +121,31 @@ export class PlaysVideoEngine extends EventTarget {
91
121
  this.worker.postMessage({ type: 'open-url', url });
92
122
  mlog(`open url=${url}`);
93
123
  }
124
+ async loadExternalSubtitle(file, options = {}) {
125
+ if (this._phase !== 'ready') {
126
+ throw new Error('Load a video before adding an external subtitle file');
127
+ }
128
+ const text = await file.text();
129
+ const data = parseSubtitleFile(text, file.name);
130
+ if (data.codec === 'ass' || data.codec === 'ssa') {
131
+ throw new Error('External .ass/.ssa subtitles are not supported yet');
132
+ }
133
+ const webvtt = subtitleDataToWebVTT(data);
134
+ this.clearExternalSubtitles();
135
+ this.addSubtitleTrack({
136
+ webvtt,
137
+ source: 'external',
138
+ label: options.label ?? file.name.replace(/\.[^.]+$/, ''),
139
+ language: options.language ?? 'und',
140
+ kind: options.kind ?? 'subtitles',
141
+ defaultTrack: true,
142
+ selectTrack: true,
143
+ });
144
+ }
145
+ clearExternalSubtitles() {
146
+ this.removeSubtitleTracks('external');
147
+ this.restoreDefaultTextTrack();
148
+ }
94
149
  /**
95
150
  * Load from an external Source (e.g. TorrentSource).
96
151
  *
@@ -135,9 +190,6 @@ export class PlaysVideoEngine extends EventTarget {
135
190
  this.initData = null;
136
191
  this.pendingSegments.clear();
137
192
  this.segmentRequestTimes.clear();
138
- for (const url of this.subtitleBlobUrls)
139
- URL.revokeObjectURL(url);
140
- this.subtitleBlobUrls = [];
141
193
  this.removeSubtitleTracks();
142
194
  this._phase = 'demuxing';
143
195
  this._totalSegments = 0;
@@ -146,6 +198,13 @@ export class PlaysVideoEngine extends EventTarget {
146
198
  this._passthrough = false;
147
199
  this._pendingFileType = null;
148
200
  this._keyframeIndex = null;
201
+ this._codecPath = {
202
+ mode: 'pipeline',
203
+ sourceVideo: { short: null, full: null },
204
+ sourceAudio: { short: null, full: null },
205
+ outputVideo: { short: null, full: null },
206
+ outputAudio: { short: null, full: null },
207
+ };
149
208
  // Source pipeline cleanup
150
209
  if (this._sourceSegmentAbort) {
151
210
  this._sourceSegmentAbort.abort();
@@ -162,7 +221,9 @@ export class PlaysVideoEngine extends EventTarget {
162
221
  this._sourceAudioDecoderConfig = null;
163
222
  this._sourceInitSegment = null;
164
223
  this._sourceFfmpeg = null;
224
+ this._segmentStates.clear();
165
225
  this.dispatchEvent(new CustomEvent('loading', { detail }));
226
+ this.dispatchSegmentStateChange();
166
227
  }
167
228
  createWorker() {
168
229
  this.worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
@@ -180,17 +241,40 @@ export class PlaysVideoEngine extends EventTarget {
180
241
  const worker = new Worker(new URL('./transcode-worker.js', import.meta.url), {
181
242
  type: 'module',
182
243
  });
244
+ worker.onmessage = (event) => this.handleTranscodeWorkerMessage(i, event);
245
+ worker.onerror = (event) => {
246
+ this.updateTranscodeWorkerState(i, {
247
+ phase: 'error',
248
+ jobId: null,
249
+ lastError: event.message || 'Transcode worker crashed',
250
+ });
251
+ };
183
252
  const channel = new MessageChannel();
184
253
  worker.postMessage({ type: 'connect' }, [channel.port2]);
185
254
  this.worker.postMessage({ type: 'transcode-port', id: i }, [channel.port1]);
186
255
  this.transcodeWorkers.push({ worker });
256
+ this._transcodeWorkerStates.push({
257
+ id: i,
258
+ phase: 'starting',
259
+ sourceCodec: null,
260
+ jobId: null,
261
+ inputBytes: null,
262
+ outputBytes: null,
263
+ totalMs: null,
264
+ ffmpegMs: null,
265
+ jobsCompleted: 0,
266
+ lastError: null,
267
+ });
187
268
  }
269
+ this.dispatchWorkerStateChange();
188
270
  }
189
271
  destroyTranscodeWorkers() {
190
272
  for (const handle of this.transcodeWorkers) {
191
273
  handle.worker.terminate();
192
274
  }
193
275
  this.transcodeWorkers = [];
276
+ this._transcodeWorkerStates = [];
277
+ this.dispatchWorkerStateChange();
194
278
  }
195
279
  destroy() {
196
280
  if (this.hls) {
@@ -210,29 +294,172 @@ export class PlaysVideoEngine extends EventTarget {
210
294
  this.video.removeAttribute('src');
211
295
  this.video.load();
212
296
  }
213
- for (const url of this.subtitleBlobUrls)
214
- URL.revokeObjectURL(url);
215
- this.subtitleBlobUrls = [];
216
297
  this.removeSubtitleTracks();
217
298
  this.pendingSegments.clear();
218
299
  this.segmentRequestTimes.clear();
219
300
  this._phase = 'idle';
220
301
  this._passthrough = false;
302
+ this._segmentStates.clear();
303
+ this.dispatchSegmentStateChange();
221
304
  }
222
305
  addEventListener(type, listener, options) {
223
306
  super.addEventListener(type, listener, options);
224
307
  }
225
- checkNativePlayback(videoCodec, audioCodec) {
226
- const mime = this._pendingFileType;
227
- if (!mime)
228
- return false;
229
- const codecs = audioCodec ? `${videoCodec}, ${audioCodec}` : videoCodec;
230
- const fullMime = `${mime}; codecs="${codecs}"`;
231
- const result = this.video.canPlayType(fullMime);
232
- mlog(`canPlayType("${fullMime}") = "${result}"`);
233
- if (FORCE_REMUX)
234
- return false;
235
- return result === 'probably' || result === 'maybe';
308
+ dispatchWorkerStateChange() {
309
+ this.dispatchEvent(new CustomEvent('workerstatechange', {
310
+ detail: {
311
+ workers: this.transcodeWorkerStates,
312
+ },
313
+ }));
314
+ }
315
+ dispatchSegmentStateChange() {
316
+ this.dispatchEvent(new CustomEvent('segmentstatechange', {
317
+ detail: {
318
+ segments: this.segmentStates,
319
+ },
320
+ }));
321
+ }
322
+ handleTranscodeWorkerMessage(id, event) {
323
+ const msg = event.data;
324
+ if (!msg || msg.type !== 'worker-state') {
325
+ return;
326
+ }
327
+ this.updateTranscodeWorkerState(id, msg.state);
328
+ }
329
+ updateTranscodeWorkerState(id, patch) {
330
+ const index = this._transcodeWorkerStates.findIndex((worker) => worker.id === id);
331
+ if (index === -1) {
332
+ return;
333
+ }
334
+ this._transcodeWorkerStates[index] = {
335
+ ...this._transcodeWorkerStates[index],
336
+ ...patch,
337
+ id,
338
+ };
339
+ this.dispatchWorkerStateChange();
340
+ }
341
+ noteSegmentState(index, phase, opts = {}) {
342
+ const existing = this._segmentStates.get(index);
343
+ const next = existing
344
+ ? {
345
+ ...existing,
346
+ events: [...existing.events],
347
+ }
348
+ : {
349
+ index,
350
+ phase,
351
+ requestCount: 0,
352
+ sizeBytes: null,
353
+ latencyMs: null,
354
+ error: null,
355
+ prefetched: false,
356
+ events: [],
357
+ };
358
+ next.phase = phase;
359
+ if (opts.incrementRequestCount) {
360
+ next.requestCount += 1;
361
+ }
362
+ if (opts.prefetched !== undefined) {
363
+ next.prefetched = opts.prefetched;
364
+ }
365
+ else if (phase === 'prefetching') {
366
+ next.prefetched = true;
367
+ }
368
+ if (opts.sizeBytes !== undefined) {
369
+ next.sizeBytes = opts.sizeBytes;
370
+ }
371
+ if (opts.latencyMs !== undefined) {
372
+ next.latencyMs = opts.latencyMs;
373
+ }
374
+ next.error = phase === 'error' ? (opts.message ?? next.error) : null;
375
+ next.events.push({
376
+ phase,
377
+ atMs: performance.now(),
378
+ sizeBytes: opts.sizeBytes ?? null,
379
+ message: opts.message ?? null,
380
+ });
381
+ this._segmentStates.set(index, next);
382
+ this.dispatchSegmentStateChange();
383
+ }
384
+ handleWorkerSegmentState(msg) {
385
+ this.noteSegmentState(msg.index, msg.phase, {
386
+ sizeBytes: msg.sizeBytes,
387
+ message: msg.message,
388
+ });
389
+ }
390
+ createPlaybackCapabilities() {
391
+ const capabilities = createBrowserPlaybackCapabilities(this.video);
392
+ if (FORCE_REMUX) {
393
+ return {
394
+ ...capabilities,
395
+ canPlayType: () => '',
396
+ };
397
+ }
398
+ return capabilities;
399
+ }
400
+ evaluateInitialPlayback(media) {
401
+ const options = this._blobUrl
402
+ ? [
403
+ { mode: 'direct-bytes', mimeType: this._pendingFileType, url: this._blobUrl },
404
+ { mode: 'hls' },
405
+ ]
406
+ : [{ mode: 'hls' }];
407
+ return evaluatePlaybackOptions({
408
+ options: [...options],
409
+ media,
410
+ capabilities: this.createPlaybackCapabilities(),
411
+ });
412
+ }
413
+ evaluateHlsPlayback(media) {
414
+ return evaluatePlaybackOptions({
415
+ options: [{ mode: 'hls' }],
416
+ media,
417
+ capabilities: this.createPlaybackCapabilities(),
418
+ }).evaluations[0];
419
+ }
420
+ logPlaybackDiagnostics(context, evaluation) {
421
+ for (const entry of evaluation.evaluations) {
422
+ for (const diagnostic of entry.diagnostics) {
423
+ mlog(`${context}: mode=${entry.option.mode} ${diagnostic.code} ${diagnostic.message}`);
424
+ }
425
+ }
426
+ if (!evaluation.recommended) {
427
+ mlog(`${context}: no-supported-option`);
428
+ }
429
+ }
430
+ throwPlaybackSelectionError(context, diagnostics) {
431
+ const detail = diagnostics.length > 0
432
+ ? diagnostics.map((diagnostic) => diagnostic.message).join(' ')
433
+ : 'No supported playback option.';
434
+ throw new Error(`${context}: ${detail}`);
435
+ }
436
+ failPlaybackSelection(context, diagnostics) {
437
+ const detail = diagnostics.length > 0
438
+ ? diagnostics.map((diagnostic) => diagnostic.message).join(' ')
439
+ : 'No supported playback option.';
440
+ this._phase = 'error';
441
+ this.dispatchEvent(new CustomEvent('error', { detail: { message: `${context}: ${detail}` } }));
442
+ }
443
+ makeCodecPathFromSource(media, mode, outputAudio = {
444
+ short: media.sourceAudioCodec,
445
+ full: media.audioCodec,
446
+ }) {
447
+ return {
448
+ mode,
449
+ sourceVideo: {
450
+ short: media.sourceVideoCodec,
451
+ full: media.videoCodec,
452
+ },
453
+ sourceAudio: {
454
+ short: media.sourceAudioCodec,
455
+ full: media.audioCodec,
456
+ },
457
+ outputVideo: {
458
+ short: media.sourceVideoCodec,
459
+ full: media.videoCodec,
460
+ },
461
+ outputAudio,
462
+ };
236
463
  }
237
464
  startPassthrough(src) {
238
465
  this._passthrough = true;
@@ -251,6 +478,7 @@ export class PlaysVideoEngine extends EventTarget {
251
478
  durationSec: this._durationSec,
252
479
  subtitleTracks: [],
253
480
  passthrough: true,
481
+ codecPath: this.codecPath,
254
482
  },
255
483
  }));
256
484
  };
@@ -265,23 +493,44 @@ export class PlaysVideoEngine extends EventTarget {
265
493
  const msg = event.data;
266
494
  if (msg.type === 'probed') {
267
495
  // Worker finished demux — decide passthrough vs pipeline
268
- const canPlay = this.checkNativePlayback(msg.videoCodec, msg.audioCodec);
496
+ const media = {
497
+ sourceVideoCodec: msg.sourceVideoCodec ?? null,
498
+ sourceAudioCodec: msg.sourceAudioCodec ?? null,
499
+ videoCodec: msg.videoCodec ?? null,
500
+ audioCodec: msg.audioCodec ?? null,
501
+ };
502
+ const evaluation = this.evaluateInitialPlayback(media);
503
+ this.logPlaybackDiagnostics('playback selection', evaluation);
269
504
  this._subtitleTracks = msg.subtitleTracks ?? [];
270
- if (canPlay && this._blobUrl) {
271
- mlog(`passthrough: canPlayType accepted codecs=${msg.videoCodec}/${msg.audioCodec}`);
272
- this.startPassthrough(this._blobUrl);
505
+ const blobUrl = this._blobUrl;
506
+ const usePassthrough = evaluation.recommended?.option.mode === 'direct-bytes' && blobUrl !== null;
507
+ this._codecPath = this.makeCodecPathFromSource(media, usePassthrough ? 'passthrough' : 'pipeline');
508
+ if (usePassthrough && blobUrl) {
509
+ mlog(`passthrough: selected direct playback codecs=${msg.videoCodec}/${msg.audioCodec}`);
510
+ this.startPassthrough(blobUrl);
273
511
  this.worker.postMessage({ type: 'passthrough-pipeline' });
512
+ if (this._subtitleTracks.length > 0) {
513
+ this.dispatchSubtitleStatus(`Extracting ${this._subtitleTracks.length} subtitle track(s)...`);
514
+ }
515
+ else {
516
+ this.dispatchSubtitleStatus('No embedded subtitles');
517
+ }
274
518
  for (const track of this._subtitleTracks) {
275
519
  mlog(`requesting subtitle track=${track.index} lang=${track.language} codec=${track.codec}`);
276
520
  this.worker.postMessage({ type: 'subtitle', trackIndex: track.index });
277
521
  }
278
522
  }
279
523
  else {
524
+ const hlsEvaluation = evaluation.evaluations.find((entry) => entry.option.mode === 'hls');
525
+ if (evaluation.recommended?.option.mode !== 'hls') {
526
+ this.failPlaybackSelection('Playback selection failed', hlsEvaluation?.diagnostics ?? []);
527
+ return;
528
+ }
280
529
  if (this._blobUrl) {
281
530
  URL.revokeObjectURL(this._blobUrl);
282
531
  this._blobUrl = null;
283
532
  }
284
- mlog(`pipeline: canPlayType rejected, proceeding with remux pipeline`);
533
+ mlog('pipeline: selected remux/HLS playback');
285
534
  this.ensureTranscodeWorkers();
286
535
  const remuxMsg = { type: 'remux-pipeline' };
287
536
  if (this._keyframeIndex)
@@ -296,6 +545,25 @@ export class PlaysVideoEngine extends EventTarget {
296
545
  this._durationSec = msg.durationSec;
297
546
  this._subtitleTracks = msg.subtitleTracks ?? [];
298
547
  this._phase = 'ready';
548
+ this._codecPath = {
549
+ mode: 'pipeline',
550
+ sourceVideo: {
551
+ short: msg.sourceVideoCodec ?? this._codecPath.sourceVideo.short,
552
+ full: msg.sourceVideoCodecFull ?? this._codecPath.sourceVideo.full,
553
+ },
554
+ sourceAudio: {
555
+ short: msg.sourceAudioCodec ?? this._codecPath.sourceAudio.short,
556
+ full: msg.sourceAudioCodecFull ?? this._codecPath.sourceAudio.full,
557
+ },
558
+ outputVideo: {
559
+ short: msg.outputVideoCodec ?? this._codecPath.outputVideo.short,
560
+ full: msg.outputVideoCodecFull ?? this._codecPath.outputVideo.full,
561
+ },
562
+ outputAudio: {
563
+ short: msg.outputAudioCodec ?? this._codecPath.outputAudio.short,
564
+ full: msg.outputAudioCodecFull ?? this._codecPath.outputAudio.full,
565
+ },
566
+ };
299
567
  mlog(`ready segments=${msg.totalSegments} dur=${msg.durationSec.toFixed(1)}s`);
300
568
  // Resolve any pending requests
301
569
  if (this.pendingPlaylist) {
@@ -307,6 +575,12 @@ export class PlaysVideoEngine extends EventTarget {
307
575
  this.pendingInit = null;
308
576
  }
309
577
  // Request subtitle extraction for all embedded tracks
578
+ if (this._subtitleTracks.length > 0) {
579
+ this.dispatchSubtitleStatus(`Extracting ${this._subtitleTracks.length} subtitle track(s)...`);
580
+ }
581
+ else {
582
+ this.dispatchSubtitleStatus('No embedded subtitles');
583
+ }
310
584
  for (const track of this._subtitleTracks) {
311
585
  mlog(`requesting subtitle track=${track.index} lang=${track.language} codec=${track.codec}`);
312
586
  this.worker.postMessage({ type: 'subtitle', trackIndex: track.index });
@@ -316,24 +590,44 @@ export class PlaysVideoEngine extends EventTarget {
316
590
  totalSegments: this._totalSegments,
317
591
  durationSec: this._durationSec,
318
592
  subtitleTracks: this._subtitleTracks,
593
+ codecPath: this.codecPath,
319
594
  },
320
595
  }));
321
596
  this.startHls();
322
597
  }
323
598
  else if (msg.type === 'subtitle') {
324
599
  mlog(`subtitle arrived track=${msg.trackIndex} codec=${msg.codec} len=${msg.webvtt?.length}`);
325
- this.addSubtitleTrack(msg.webvtt, msg.trackIndex);
600
+ const info = this._subtitleTracks.find((t) => t.index === msg.trackIndex);
601
+ const lang = info?.language ?? '?';
602
+ const cueMatch = msg.webvtt?.match(/\d\d:\d\d/g);
603
+ const cueCount = cueMatch ? Math.floor(cueMatch.length / 2) : 0;
604
+ this.dispatchSubtitleStatus(`Subtitle track ${msg.trackIndex}: ${lang} ${msg.codec} ${cueCount} cues, ${msg.webvtt?.length ?? 0} bytes`);
605
+ this.addSubtitleTrack({
606
+ webvtt: msg.webvtt,
607
+ source: 'embedded',
608
+ trackIndex: msg.trackIndex,
609
+ defaultTrack: msg.trackIndex === 0,
610
+ selectTrack: msg.trackIndex === 0,
611
+ });
612
+ }
613
+ else if (msg.type === 'segment-state') {
614
+ this.handleWorkerSegmentState(msg);
326
615
  }
327
616
  else if (msg.type === 'segment') {
328
617
  const pending = this.pendingSegments.get(msg.index);
329
618
  const reqTime = this.segmentRequestTimes.get(msg.index);
330
- const latency = reqTime ? (performance.now() - reqTime).toFixed(1) : '?';
619
+ const latencyMs = reqTime ? performance.now() - reqTime : null;
620
+ const latency = latencyMs !== null ? latencyMs.toFixed(1) : '?';
331
621
  const size = msg.data?.byteLength ?? 0;
332
622
  this.segmentRequestTimes.delete(msg.index);
333
623
  if (pending) {
334
624
  pending.resolve(msg.data);
335
625
  this.pendingSegments.delete(msg.index);
336
626
  }
627
+ this.noteSegmentState(msg.index, 'delivered', {
628
+ sizeBytes: size,
629
+ latencyMs: latencyMs ?? undefined,
630
+ });
337
631
  mlog(`seg ${msg.index} arrived latency=${latency}ms size=${size} pending=${this.pendingSegments.size}`);
338
632
  }
339
633
  else if (msg.type === 'error') {
@@ -341,7 +635,8 @@ export class PlaysVideoEngine extends EventTarget {
341
635
  this._phase = 'error';
342
636
  this.dispatchEvent(new CustomEvent('error', { detail: { message: msg.message } }));
343
637
  // Reject all pending requests
344
- for (const [, p] of this.pendingSegments) {
638
+ for (const [index, p] of this.pendingSegments) {
639
+ this.noteSegmentState(index, 'error', { message: msg.message });
345
640
  p.reject(new Error(msg.message));
346
641
  }
347
642
  this.pendingSegments.clear();
@@ -366,6 +661,7 @@ export class PlaysVideoEngine extends EventTarget {
366
661
  }
367
662
  mlog(`req seg ${index} pending=${pendingCount}`);
368
663
  this.segmentRequestTimes.set(index, performance.now());
664
+ this.noteSegmentState(index, 'requested', { incrementRequestCount: true });
369
665
  return new Promise((resolve, reject) => {
370
666
  this.pendingSegments.set(index, { resolve, reject });
371
667
  this.worker.postMessage({ type: 'segment', index });
@@ -375,6 +671,7 @@ export class PlaysVideoEngine extends EventTarget {
375
671
  const pending = this.pendingSegments.get(index);
376
672
  if (pending) {
377
673
  mlog(`cancel seg ${index}`);
674
+ this.noteSegmentState(index, 'canceled');
378
675
  pending.reject(new DOMException('Segment aborted', 'AbortError'));
379
676
  this.pendingSegments.delete(index);
380
677
  this.segmentRequestTimes.delete(index);
@@ -402,14 +699,36 @@ export class PlaysVideoEngine extends EventTarget {
402
699
  durationSec: index.duration,
403
700
  targetSegmentDurationSec: this._sourceTargetSegDuration,
404
701
  });
405
- // Check transcode
406
- const codecProber = createBrowserProber();
407
- this._sourceDoTranscode =
408
- demux.audioCodec !== null &&
409
- audioNeedsTranscode(codecProber, demux.audioCodec, demux.audioDecoderConfig?.codec);
702
+ const media = {
703
+ sourceVideoCodec: demux.videoCodec,
704
+ sourceAudioCodec: demux.audioCodec,
705
+ videoCodec: demux.videoDecoderConfig.codec,
706
+ audioCodec: demux.audioDecoderConfig?.codec ?? null,
707
+ };
708
+ const hlsEvaluation = this.evaluateHlsPlayback(media);
709
+ this.logPlaybackDiagnostics('source playback selection', {
710
+ recommended: hlsEvaluation.status === 'supported'
711
+ ? {
712
+ option: hlsEvaluation.option,
713
+ reason: hlsEvaluation.diagnostics[hlsEvaluation.diagnostics.length - 1] ?? {
714
+ code: 'selected-hls',
715
+ message: 'Recommended HLS playback.',
716
+ },
717
+ }
718
+ : null,
719
+ evaluations: [hlsEvaluation],
720
+ });
721
+ if (hlsEvaluation.status !== 'supported') {
722
+ this.throwPlaybackSelectionError('Source playback selection failed', hlsEvaluation.diagnostics);
723
+ }
724
+ this._sourceDoTranscode = hlsEvaluation.pipelineAudioRequiresTranscode === true;
410
725
  this._sourceAudioDecoderConfig = this._sourceDoTranscode
411
726
  ? makeAacDecoderConfig(demux.audioDecoderConfig)
412
727
  : demux.audioDecoderConfig;
728
+ this._codecPath = this.makeCodecPathFromSource(media, 'pipeline', {
729
+ short: this._sourceDoTranscode ? 'aac' : demux.audioCodec,
730
+ full: this._sourceAudioDecoderConfig?.codec ?? null,
731
+ });
413
732
  // Pre-process segment 0
414
733
  const seg0Result = await processSegmentWithAbort(this.makeSourceProcessorConfig(), 0);
415
734
  if (seg0Result.initSegment) {
@@ -438,6 +757,7 @@ export class PlaysVideoEngine extends EventTarget {
438
757
  totalSegments: this._totalSegments,
439
758
  durationSec: this._durationSec,
440
759
  subtitleTracks: this._subtitleTracks,
760
+ codecPath: this.codecPath,
441
761
  },
442
762
  }));
443
763
  this.startHls();
@@ -526,8 +846,12 @@ export class PlaysVideoEngine extends EventTarget {
526
846
  context = null;
527
847
  stats = makeStats();
528
848
  currentSegmentIndex = null;
849
+ callbacks = null;
850
+ aborted = false;
529
851
  load(context, _config, callbacks) {
530
852
  this.context = context;
853
+ this.callbacks = callbacks;
854
+ this.aborted = false;
531
855
  const url = context.url;
532
856
  if (url.includes('init.mp4')) {
533
857
  this.loadInit(context, callbacks);
@@ -571,22 +895,36 @@ export class PlaysVideoEngine extends EventTarget {
571
895
  : engine.requestSegment(index);
572
896
  segmentPromise
573
897
  .then((data) => {
898
+ if (this.aborted) {
899
+ return;
900
+ }
574
901
  this.currentSegmentIndex = null;
575
902
  this.stats.loaded = data.byteLength;
576
903
  this.stats.loading.end = performance.now();
577
904
  callbacks.onSuccess({ url: context.url, data }, this.stats, context, null);
578
905
  })
579
906
  .catch((err) => {
907
+ if (this.aborted) {
908
+ return;
909
+ }
580
910
  this.currentSegmentIndex = null;
581
911
  if (err instanceof DOMException && err.name === 'AbortError') {
582
912
  this.stats.aborted = true;
913
+ callbacks.onAbort?.(this.stats, context, null);
583
914
  return;
584
915
  }
585
916
  callbacks.onError({ code: 0, text: err.message }, context, null, this.stats);
586
917
  });
587
918
  }
588
919
  abort() {
920
+ if (this.aborted) {
921
+ return;
922
+ }
923
+ this.aborted = true;
924
+ this.stats.aborted = true;
925
+ let abortedActiveSegment = false;
589
926
  if (this.currentSegmentIndex !== null) {
927
+ abortedActiveSegment = true;
590
928
  if (engine._source) {
591
929
  // Source mode: abort the in-flight main-thread processing
592
930
  engine._sourceSegmentAbort?.abort();
@@ -597,9 +935,14 @@ export class PlaysVideoEngine extends EventTarget {
597
935
  }
598
936
  this.currentSegmentIndex = null;
599
937
  }
938
+ if (abortedActiveSegment && this.callbacks && this.context) {
939
+ this.callbacks.onAbort?.(this.stats, this.context, null);
940
+ }
600
941
  }
601
942
  destroy() {
602
943
  this.abort();
944
+ this.callbacks = null;
945
+ this.context = null;
603
946
  }
604
947
  }
605
948
  this.hls = new Hls({
@@ -626,36 +969,71 @@ export class PlaysVideoEngine extends EventTarget {
626
969
  mlog(`hls BUFFER_APPENDING type=${data.type}`);
627
970
  });
628
971
  this.hls.on(Hls.Events.ERROR, (_evt, data) => {
629
- mlog(`hls ERROR fatal=${data.fatal} type=${data.type} details=${data.details}`);
972
+ const underlyingMessage = data.error?.message ?? data.reason ?? data.response?.text ?? data.err?.message ?? null;
973
+ mlog(`hls ERROR fatal=${data.fatal} type=${data.type} details=${data.details}${underlyingMessage ? ` message=${underlyingMessage}` : ''}`);
630
974
  if (data.fatal) {
631
975
  console.error('hls.js fatal error:', data);
632
976
  this._phase = 'error';
633
- this.dispatchEvent(new CustomEvent('error', { detail: { message: `Playback error: ${data.details}` } }));
977
+ const message = underlyingMessage
978
+ ? `Playback error: ${data.details} (${underlyingMessage})`
979
+ : `Playback error: ${data.details}`;
980
+ this.dispatchEvent(new CustomEvent('error', { detail: { message } }));
634
981
  }
635
982
  });
636
983
  this.hls.loadSource('/virtual/playlist.m3u8');
637
984
  this.hls.attachMedia(this.video);
638
985
  }
639
- addSubtitleTrack(webvtt, trackIndex) {
986
+ addSubtitleTrack({ webvtt, source, trackIndex, label, language, kind, defaultTrack = false, selectTrack = false, }) {
640
987
  const blob = new Blob([webvtt], { type: 'text/vtt' });
641
988
  const url = URL.createObjectURL(blob);
642
- this.subtitleBlobUrls.push(url);
643
- const info = this._subtitleTracks.find((t) => t.index === trackIndex);
989
+ const info = trackIndex === undefined
990
+ ? undefined
991
+ : this._subtitleTracks.find((t) => t.index === trackIndex);
644
992
  const track = document.createElement('track');
645
- track.kind = info?.disposition.hearingImpaired ? 'captions' : 'subtitles';
993
+ track.kind = kind ?? (info?.disposition.hearingImpaired ? 'captions' : 'subtitles');
646
994
  track.src = url;
647
- track.srclang = iso639_2to1(info?.language ?? 'und');
648
- track.label = info?.name ?? languageLabel(info?.language ?? 'und', trackIndex);
649
- if (trackIndex === 0) {
650
- track.default = true;
651
- }
995
+ track.srclang = normalizeSubtitleLanguageCode(language ?? info?.language ?? 'und');
996
+ track.label =
997
+ label ??
998
+ info?.name ??
999
+ languageLabel(info?.language ?? 'und', trackIndex ?? this.video.querySelectorAll('track').length);
1000
+ track.default = defaultTrack;
652
1001
  this.video.appendChild(track);
653
- mlog(`subtitle track ${trackIndex} attached as <track kind=${track.kind} lang=${track.srclang}>`);
1002
+ this.attachedSubtitleTracks.push({ element: track, url, source });
1003
+ if (selectTrack) {
1004
+ track.addEventListener('load', () => this.showTextTrack(track), { once: true });
1005
+ queueMicrotask(() => this.showTextTrack(track));
1006
+ }
1007
+ mlog(`subtitle track ${trackIndex ?? 'external'} attached as <track kind=${track.kind} lang=${track.srclang}>`);
1008
+ }
1009
+ removeSubtitleTracks(source) {
1010
+ const keep = [];
1011
+ for (const attached of this.attachedSubtitleTracks) {
1012
+ if (source && attached.source !== source) {
1013
+ keep.push(attached);
1014
+ continue;
1015
+ }
1016
+ attached.element.remove();
1017
+ URL.revokeObjectURL(attached.url);
1018
+ }
1019
+ this.attachedSubtitleTracks = keep;
654
1020
  }
655
- removeSubtitleTracks() {
656
- for (const track of Array.from(this.video.querySelectorAll('track'))) {
657
- track.remove();
1021
+ showTextTrack(track) {
1022
+ for (let i = 0; i < this.video.textTracks.length; i++) {
1023
+ this.video.textTracks[i].mode = 'disabled';
658
1024
  }
1025
+ track.track.mode = 'showing';
1026
+ }
1027
+ dispatchSubtitleStatus(message) {
1028
+ mlog(`subtitle-status: ${message}`);
1029
+ this.dispatchEvent(new CustomEvent('subtitle-status', { detail: { message } }));
1030
+ }
1031
+ restoreDefaultTextTrack() {
1032
+ const preferred = this.attachedSubtitleTracks.find((attached) => attached.element.default) ??
1033
+ this.attachedSubtitleTracks[0];
1034
+ if (!preferred)
1035
+ return;
1036
+ queueMicrotask(() => this.showTextTrack(preferred.element));
659
1037
  }
660
1038
  }
661
1039
  /** Set to true to bypass native playback and force the remux pipeline (for testing). */
@@ -701,6 +1079,11 @@ function iso639_2to1(code) {
701
1079
  };
702
1080
  return map[code] ?? code;
703
1081
  }
1082
+ function normalizeSubtitleLanguageCode(code) {
1083
+ if (code.length === 2)
1084
+ return code;
1085
+ return iso639_2to1(code);
1086
+ }
704
1087
  function languageLabel(langCode, trackIndex) {
705
1088
  const names = {
706
1089
  eng: 'English',