playsvideo 0.2.0 → 0.3.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/README.md CHANGED
@@ -67,6 +67,9 @@ engine.loadFile(file);
67
67
  // Or play from a URL (requires CORS + range request support)
68
68
  engine.loadUrl('https://example.com/video.mkv');
69
69
 
70
+ // Or attach an external .srt/.vtt subtitle file after loading
71
+ await engine.loadExternalSubtitle(subtitleFile);
72
+
70
73
  engine.destroy(); // clean up
71
74
  ```
72
75
 
package/dist/engine.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Source } from 'mediabunny';
2
2
  import type { FfmpegRunner, KeyframeIndex, SubtitleTrackInfo } from './pipeline/types.js';
3
+ import type { TranscodeWorkerSnapshot } from './transcode-protocol.js';
3
4
  export type EnginePhase = 'idle' | 'demuxing' | 'ready' | 'error';
4
5
  export interface ReadyDetail {
5
6
  totalSegments: number;
@@ -14,6 +15,32 @@ export interface LoadingDetail {
14
15
  file?: File;
15
16
  url?: string;
16
17
  }
18
+ export interface WasmWorkerState extends TranscodeWorkerSnapshot {
19
+ id: number;
20
+ }
21
+ export interface WorkerStateDetail {
22
+ workers: WasmWorkerState[];
23
+ }
24
+ export type SegmentPhase = 'requested' | 'queued' | 'prefetching' | 'processing' | 'ready' | 'cache-hit' | 'delivered' | 'canceled' | 'aborted' | 'error';
25
+ export interface SegmentTimelineEvent {
26
+ phase: SegmentPhase;
27
+ atMs: number;
28
+ sizeBytes: number | null;
29
+ message: string | null;
30
+ }
31
+ export interface SegmentState {
32
+ index: number;
33
+ phase: SegmentPhase;
34
+ requestCount: number;
35
+ sizeBytes: number | null;
36
+ latencyMs: number | null;
37
+ error: string | null;
38
+ prefetched: boolean;
39
+ events: SegmentTimelineEvent[];
40
+ }
41
+ export interface SegmentStateDetail {
42
+ segments: SegmentState[];
43
+ }
17
44
  export interface EngineOptions {
18
45
  /**
19
46
  * Number of internal audio transcode workers to create for worker-mode playback.
@@ -21,16 +48,25 @@ export interface EngineOptions {
21
48
  */
22
49
  transcodeWorkers?: number;
23
50
  }
51
+ export interface ExternalSubtitleOptions {
52
+ label?: string;
53
+ language?: string;
54
+ kind?: 'subtitles' | 'captions';
55
+ }
24
56
  interface EngineEventMap {
25
57
  ready: CustomEvent<ReadyDetail>;
26
58
  error: CustomEvent<ErrorDetail>;
27
59
  loading: CustomEvent<LoadingDetail>;
60
+ workerstatechange: CustomEvent<WorkerStateDetail>;
61
+ segmentstatechange: CustomEvent<SegmentStateDetail>;
28
62
  }
29
63
  export declare class PlaysVideoEngine extends EventTarget {
30
64
  readonly video: HTMLVideoElement;
31
65
  readonly options: Required<EngineOptions>;
32
66
  private worker;
33
67
  private transcodeWorkers;
68
+ private _transcodeWorkerStates;
69
+ private _segmentStates;
34
70
  private hls;
35
71
  private pendingSegments;
36
72
  private playlist;
@@ -38,7 +74,7 @@ export declare class PlaysVideoEngine extends EventTarget {
38
74
  private pendingInit;
39
75
  private pendingPlaylist;
40
76
  private segmentRequestTimes;
41
- private subtitleBlobUrls;
77
+ private attachedSubtitleTracks;
42
78
  private _subtitleTracks;
43
79
  private _phase;
44
80
  private _totalSegments;
@@ -62,6 +98,8 @@ export declare class PlaysVideoEngine extends EventTarget {
62
98
  get durationSec(): number;
63
99
  get subtitleTracks(): SubtitleTrackInfo[];
64
100
  get passthrough(): boolean;
101
+ get transcodeWorkerStates(): WasmWorkerState[];
102
+ get segmentStates(): SegmentState[];
65
103
  constructor(video: HTMLVideoElement, options?: EngineOptions);
66
104
  loadFile(file: File, opts?: {
67
105
  keyframeIndex?: KeyframeIndex;
@@ -69,6 +107,8 @@ export declare class PlaysVideoEngine extends EventTarget {
69
107
  loadUrl(url: string, opts?: {
70
108
  keyframeIndex?: KeyframeIndex;
71
109
  }): void;
110
+ loadExternalSubtitle(file: File, options?: ExternalSubtitleOptions): Promise<void>;
111
+ clearExternalSubtitles(): void;
72
112
  /**
73
113
  * Load from an external Source (e.g. TorrentSource).
74
114
  *
@@ -91,6 +131,12 @@ export declare class PlaysVideoEngine extends EventTarget {
91
131
  destroy(): void;
92
132
  addEventListener<K extends keyof EngineEventMap>(type: K, listener: (ev: EngineEventMap[K]) => void, options?: boolean | AddEventListenerOptions): void;
93
133
  addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void;
134
+ private dispatchWorkerStateChange;
135
+ private dispatchSegmentStateChange;
136
+ private handleTranscodeWorkerMessage;
137
+ private updateTranscodeWorkerState;
138
+ private noteSegmentState;
139
+ private handleWorkerSegmentState;
94
140
  private checkNativePlayback;
95
141
  private startPassthrough;
96
142
  private handleWorkerMessage;
@@ -102,6 +148,8 @@ export declare class PlaysVideoEngine extends EventTarget {
102
148
  private startHls;
103
149
  private addSubtitleTrack;
104
150
  private removeSubtitleTracks;
151
+ private showTextTrack;
152
+ private restoreDefaultTextTrack;
105
153
  }
106
154
  export {};
107
155
  //# sourceMappingURL=engine.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"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"}
package/dist/engine.js CHANGED
@@ -5,6 +5,7 @@ import { audioNeedsTranscode, createBrowserProber } from './pipeline/codec-probe
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';
@@ -68,6 +71,17 @@ export class PlaysVideoEngine extends EventTarget {
68
71
  get passthrough() {
69
72
  return this._passthrough;
70
73
  }
74
+ get transcodeWorkerStates() {
75
+ return this._transcodeWorkerStates.map((worker) => ({ ...worker }));
76
+ }
77
+ get segmentStates() {
78
+ return Array.from(this._segmentStates.values())
79
+ .sort((a, b) => a.index - b.index)
80
+ .map((segment) => ({
81
+ ...segment,
82
+ events: segment.events.map((event) => ({ ...event })),
83
+ }));
84
+ }
71
85
  constructor(video, options = {}) {
72
86
  super();
73
87
  this.video = video;
@@ -91,6 +105,31 @@ export class PlaysVideoEngine extends EventTarget {
91
105
  this.worker.postMessage({ type: 'open-url', url });
92
106
  mlog(`open url=${url}`);
93
107
  }
108
+ async loadExternalSubtitle(file, options = {}) {
109
+ if (this._phase !== 'ready') {
110
+ throw new Error('Load a video before adding an external subtitle file');
111
+ }
112
+ const text = await file.text();
113
+ const data = parseSubtitleFile(text, file.name);
114
+ if (data.codec === 'ass' || data.codec === 'ssa') {
115
+ throw new Error('External .ass/.ssa subtitles are not supported yet');
116
+ }
117
+ const webvtt = subtitleDataToWebVTT(data);
118
+ this.clearExternalSubtitles();
119
+ this.addSubtitleTrack({
120
+ webvtt,
121
+ source: 'external',
122
+ label: options.label ?? file.name.replace(/\.[^.]+$/, ''),
123
+ language: options.language ?? 'und',
124
+ kind: options.kind ?? 'subtitles',
125
+ defaultTrack: true,
126
+ selectTrack: true,
127
+ });
128
+ }
129
+ clearExternalSubtitles() {
130
+ this.removeSubtitleTracks('external');
131
+ this.restoreDefaultTextTrack();
132
+ }
94
133
  /**
95
134
  * Load from an external Source (e.g. TorrentSource).
96
135
  *
@@ -135,9 +174,6 @@ export class PlaysVideoEngine extends EventTarget {
135
174
  this.initData = null;
136
175
  this.pendingSegments.clear();
137
176
  this.segmentRequestTimes.clear();
138
- for (const url of this.subtitleBlobUrls)
139
- URL.revokeObjectURL(url);
140
- this.subtitleBlobUrls = [];
141
177
  this.removeSubtitleTracks();
142
178
  this._phase = 'demuxing';
143
179
  this._totalSegments = 0;
@@ -162,14 +198,12 @@ export class PlaysVideoEngine extends EventTarget {
162
198
  this._sourceAudioDecoderConfig = null;
163
199
  this._sourceInitSegment = null;
164
200
  this._sourceFfmpeg = null;
201
+ this._segmentStates.clear();
165
202
  this.dispatchEvent(new CustomEvent('loading', { detail }));
203
+ this.dispatchSegmentStateChange();
166
204
  }
167
205
  createWorker() {
168
- // Variable indirection prevents Vite/Rollup from statically detecting this as a
169
- // worker entry point. Consumers that only use loadSource() don't need the worker,
170
- // but without this, bundlers try to resolve & bundle worker.js (which isn't in dist).
171
- const workerPath = './worker.js';
172
- this.worker = new Worker(new URL(workerPath, import.meta.url), { type: 'module' });
206
+ this.worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
173
207
  this.worker.onmessage = (e) => this.handleWorkerMessage(e);
174
208
  this.worker.onerror = (e) => {
175
209
  this._phase = 'error';
@@ -180,22 +214,44 @@ export class PlaysVideoEngine extends EventTarget {
180
214
  if (!this.worker || this.transcodeWorkers.length > 0 || this.options.transcodeWorkers <= 0) {
181
215
  return;
182
216
  }
183
- const transcodeWorkerPath = './transcode-worker.js';
184
217
  for (let i = 0; i < this.options.transcodeWorkers; i++) {
185
- const worker = new Worker(new URL(transcodeWorkerPath, import.meta.url), {
218
+ const worker = new Worker(new URL('./transcode-worker.js', import.meta.url), {
186
219
  type: 'module',
187
220
  });
221
+ worker.onmessage = (event) => this.handleTranscodeWorkerMessage(i, event);
222
+ worker.onerror = (event) => {
223
+ this.updateTranscodeWorkerState(i, {
224
+ phase: 'error',
225
+ jobId: null,
226
+ lastError: event.message || 'Transcode worker crashed',
227
+ });
228
+ };
188
229
  const channel = new MessageChannel();
189
230
  worker.postMessage({ type: 'connect' }, [channel.port2]);
190
231
  this.worker.postMessage({ type: 'transcode-port', id: i }, [channel.port1]);
191
232
  this.transcodeWorkers.push({ worker });
233
+ this._transcodeWorkerStates.push({
234
+ id: i,
235
+ phase: 'starting',
236
+ sourceCodec: null,
237
+ jobId: null,
238
+ inputBytes: null,
239
+ outputBytes: null,
240
+ totalMs: null,
241
+ ffmpegMs: null,
242
+ jobsCompleted: 0,
243
+ lastError: null,
244
+ });
192
245
  }
246
+ this.dispatchWorkerStateChange();
193
247
  }
194
248
  destroyTranscodeWorkers() {
195
249
  for (const handle of this.transcodeWorkers) {
196
250
  handle.worker.terminate();
197
251
  }
198
252
  this.transcodeWorkers = [];
253
+ this._transcodeWorkerStates = [];
254
+ this.dispatchWorkerStateChange();
199
255
  }
200
256
  destroy() {
201
257
  if (this.hls) {
@@ -215,18 +271,99 @@ export class PlaysVideoEngine extends EventTarget {
215
271
  this.video.removeAttribute('src');
216
272
  this.video.load();
217
273
  }
218
- for (const url of this.subtitleBlobUrls)
219
- URL.revokeObjectURL(url);
220
- this.subtitleBlobUrls = [];
221
274
  this.removeSubtitleTracks();
222
275
  this.pendingSegments.clear();
223
276
  this.segmentRequestTimes.clear();
224
277
  this._phase = 'idle';
225
278
  this._passthrough = false;
279
+ this._segmentStates.clear();
280
+ this.dispatchSegmentStateChange();
226
281
  }
227
282
  addEventListener(type, listener, options) {
228
283
  super.addEventListener(type, listener, options);
229
284
  }
285
+ dispatchWorkerStateChange() {
286
+ this.dispatchEvent(new CustomEvent('workerstatechange', {
287
+ detail: {
288
+ workers: this.transcodeWorkerStates,
289
+ },
290
+ }));
291
+ }
292
+ dispatchSegmentStateChange() {
293
+ this.dispatchEvent(new CustomEvent('segmentstatechange', {
294
+ detail: {
295
+ segments: this.segmentStates,
296
+ },
297
+ }));
298
+ }
299
+ handleTranscodeWorkerMessage(id, event) {
300
+ const msg = event.data;
301
+ if (!msg || msg.type !== 'worker-state') {
302
+ return;
303
+ }
304
+ this.updateTranscodeWorkerState(id, msg.state);
305
+ }
306
+ updateTranscodeWorkerState(id, patch) {
307
+ const index = this._transcodeWorkerStates.findIndex((worker) => worker.id === id);
308
+ if (index === -1) {
309
+ return;
310
+ }
311
+ this._transcodeWorkerStates[index] = {
312
+ ...this._transcodeWorkerStates[index],
313
+ ...patch,
314
+ id,
315
+ };
316
+ this.dispatchWorkerStateChange();
317
+ }
318
+ noteSegmentState(index, phase, opts = {}) {
319
+ const existing = this._segmentStates.get(index);
320
+ const next = existing
321
+ ? {
322
+ ...existing,
323
+ events: [...existing.events],
324
+ }
325
+ : {
326
+ index,
327
+ phase,
328
+ requestCount: 0,
329
+ sizeBytes: null,
330
+ latencyMs: null,
331
+ error: null,
332
+ prefetched: false,
333
+ events: [],
334
+ };
335
+ next.phase = phase;
336
+ if (opts.incrementRequestCount) {
337
+ next.requestCount += 1;
338
+ }
339
+ if (opts.prefetched !== undefined) {
340
+ next.prefetched = opts.prefetched;
341
+ }
342
+ else if (phase === 'prefetching') {
343
+ next.prefetched = true;
344
+ }
345
+ if (opts.sizeBytes !== undefined) {
346
+ next.sizeBytes = opts.sizeBytes;
347
+ }
348
+ if (opts.latencyMs !== undefined) {
349
+ next.latencyMs = opts.latencyMs;
350
+ }
351
+ next.error = phase === 'error' ? (opts.message ?? next.error) : null;
352
+ next.events.push({
353
+ phase,
354
+ atMs: performance.now(),
355
+ sizeBytes: opts.sizeBytes ?? null,
356
+ message: opts.message ?? null,
357
+ });
358
+ this._segmentStates.set(index, next);
359
+ this.dispatchSegmentStateChange();
360
+ }
361
+ handleWorkerSegmentState(msg) {
362
+ this.noteSegmentState(msg.index, msg.phase, {
363
+ sizeBytes: msg.sizeBytes,
364
+ message: msg.message,
365
+ });
366
+ }
230
367
  checkNativePlayback(videoCodec, audioCodec) {
231
368
  const mime = this._pendingFileType;
232
369
  if (!mime)
@@ -327,18 +464,31 @@ export class PlaysVideoEngine extends EventTarget {
327
464
  }
328
465
  else if (msg.type === 'subtitle') {
329
466
  mlog(`subtitle arrived track=${msg.trackIndex} codec=${msg.codec} len=${msg.webvtt?.length}`);
330
- this.addSubtitleTrack(msg.webvtt, msg.trackIndex);
467
+ this.addSubtitleTrack({
468
+ webvtt: msg.webvtt,
469
+ source: 'embedded',
470
+ trackIndex: msg.trackIndex,
471
+ defaultTrack: msg.trackIndex === 0,
472
+ });
473
+ }
474
+ else if (msg.type === 'segment-state') {
475
+ this.handleWorkerSegmentState(msg);
331
476
  }
332
477
  else if (msg.type === 'segment') {
333
478
  const pending = this.pendingSegments.get(msg.index);
334
479
  const reqTime = this.segmentRequestTimes.get(msg.index);
335
- const latency = reqTime ? (performance.now() - reqTime).toFixed(1) : '?';
480
+ const latencyMs = reqTime ? performance.now() - reqTime : null;
481
+ const latency = latencyMs !== null ? latencyMs.toFixed(1) : '?';
336
482
  const size = msg.data?.byteLength ?? 0;
337
483
  this.segmentRequestTimes.delete(msg.index);
338
484
  if (pending) {
339
485
  pending.resolve(msg.data);
340
486
  this.pendingSegments.delete(msg.index);
341
487
  }
488
+ this.noteSegmentState(msg.index, 'delivered', {
489
+ sizeBytes: size,
490
+ latencyMs: latencyMs ?? undefined,
491
+ });
342
492
  mlog(`seg ${msg.index} arrived latency=${latency}ms size=${size} pending=${this.pendingSegments.size}`);
343
493
  }
344
494
  else if (msg.type === 'error') {
@@ -346,7 +496,8 @@ export class PlaysVideoEngine extends EventTarget {
346
496
  this._phase = 'error';
347
497
  this.dispatchEvent(new CustomEvent('error', { detail: { message: msg.message } }));
348
498
  // Reject all pending requests
349
- for (const [, p] of this.pendingSegments) {
499
+ for (const [index, p] of this.pendingSegments) {
500
+ this.noteSegmentState(index, 'error', { message: msg.message });
350
501
  p.reject(new Error(msg.message));
351
502
  }
352
503
  this.pendingSegments.clear();
@@ -371,6 +522,7 @@ export class PlaysVideoEngine extends EventTarget {
371
522
  }
372
523
  mlog(`req seg ${index} pending=${pendingCount}`);
373
524
  this.segmentRequestTimes.set(index, performance.now());
525
+ this.noteSegmentState(index, 'requested', { incrementRequestCount: true });
374
526
  return new Promise((resolve, reject) => {
375
527
  this.pendingSegments.set(index, { resolve, reject });
376
528
  this.worker.postMessage({ type: 'segment', index });
@@ -380,6 +532,7 @@ export class PlaysVideoEngine extends EventTarget {
380
532
  const pending = this.pendingSegments.get(index);
381
533
  if (pending) {
382
534
  mlog(`cancel seg ${index}`);
535
+ this.noteSegmentState(index, 'canceled');
383
536
  pending.reject(new DOMException('Segment aborted', 'AbortError'));
384
537
  this.pendingSegments.delete(index);
385
538
  this.segmentRequestTimes.delete(index);
@@ -631,36 +784,67 @@ export class PlaysVideoEngine extends EventTarget {
631
784
  mlog(`hls BUFFER_APPENDING type=${data.type}`);
632
785
  });
633
786
  this.hls.on(Hls.Events.ERROR, (_evt, data) => {
634
- mlog(`hls ERROR fatal=${data.fatal} type=${data.type} details=${data.details}`);
787
+ const underlyingMessage = data.error?.message ?? data.reason ?? data.response?.text ?? data.err?.message ?? null;
788
+ mlog(`hls ERROR fatal=${data.fatal} type=${data.type} details=${data.details}${underlyingMessage ? ` message=${underlyingMessage}` : ''}`);
635
789
  if (data.fatal) {
636
790
  console.error('hls.js fatal error:', data);
637
791
  this._phase = 'error';
638
- this.dispatchEvent(new CustomEvent('error', { detail: { message: `Playback error: ${data.details}` } }));
792
+ const message = underlyingMessage
793
+ ? `Playback error: ${data.details} (${underlyingMessage})`
794
+ : `Playback error: ${data.details}`;
795
+ this.dispatchEvent(new CustomEvent('error', { detail: { message } }));
639
796
  }
640
797
  });
641
798
  this.hls.loadSource('/virtual/playlist.m3u8');
642
799
  this.hls.attachMedia(this.video);
643
800
  }
644
- addSubtitleTrack(webvtt, trackIndex) {
801
+ addSubtitleTrack({ webvtt, source, trackIndex, label, language, kind, defaultTrack = false, selectTrack = false, }) {
645
802
  const blob = new Blob([webvtt], { type: 'text/vtt' });
646
803
  const url = URL.createObjectURL(blob);
647
- this.subtitleBlobUrls.push(url);
648
- const info = this._subtitleTracks.find((t) => t.index === trackIndex);
804
+ const info = trackIndex === undefined
805
+ ? undefined
806
+ : this._subtitleTracks.find((t) => t.index === trackIndex);
649
807
  const track = document.createElement('track');
650
- track.kind = info?.disposition.hearingImpaired ? 'captions' : 'subtitles';
808
+ track.kind = kind ?? (info?.disposition.hearingImpaired ? 'captions' : 'subtitles');
651
809
  track.src = url;
652
- track.srclang = iso639_2to1(info?.language ?? 'und');
653
- track.label = info?.name ?? languageLabel(info?.language ?? 'und', trackIndex);
654
- if (trackIndex === 0) {
655
- track.default = true;
656
- }
810
+ track.srclang = normalizeSubtitleLanguageCode(language ?? info?.language ?? 'und');
811
+ track.label =
812
+ label ??
813
+ info?.name ??
814
+ languageLabel(info?.language ?? 'und', trackIndex ?? this.video.querySelectorAll('track').length);
815
+ track.default = defaultTrack;
657
816
  this.video.appendChild(track);
658
- mlog(`subtitle track ${trackIndex} attached as <track kind=${track.kind} lang=${track.srclang}>`);
817
+ this.attachedSubtitleTracks.push({ element: track, url, source });
818
+ if (selectTrack) {
819
+ track.addEventListener('load', () => this.showTextTrack(track), { once: true });
820
+ queueMicrotask(() => this.showTextTrack(track));
821
+ }
822
+ mlog(`subtitle track ${trackIndex ?? 'external'} attached as <track kind=${track.kind} lang=${track.srclang}>`);
823
+ }
824
+ removeSubtitleTracks(source) {
825
+ const keep = [];
826
+ for (const attached of this.attachedSubtitleTracks) {
827
+ if (source && attached.source !== source) {
828
+ keep.push(attached);
829
+ continue;
830
+ }
831
+ attached.element.remove();
832
+ URL.revokeObjectURL(attached.url);
833
+ }
834
+ this.attachedSubtitleTracks = keep;
659
835
  }
660
- removeSubtitleTracks() {
661
- for (const track of Array.from(this.video.querySelectorAll('track'))) {
662
- track.remove();
836
+ showTextTrack(track) {
837
+ for (let i = 0; i < this.video.textTracks.length; i++) {
838
+ this.video.textTracks[i].mode = 'disabled';
663
839
  }
840
+ track.track.mode = 'showing';
841
+ }
842
+ restoreDefaultTextTrack() {
843
+ const preferred = this.attachedSubtitleTracks.find((attached) => attached.element.default) ??
844
+ this.attachedSubtitleTracks[0];
845
+ if (!preferred)
846
+ return;
847
+ queueMicrotask(() => this.showTextTrack(preferred.element));
664
848
  }
665
849
  }
666
850
  /** Set to true to bypass native playback and force the remux pipeline (for testing). */
@@ -706,6 +890,11 @@ function iso639_2to1(code) {
706
890
  };
707
891
  return map[code] ?? code;
708
892
  }
893
+ function normalizeSubtitleLanguageCode(code) {
894
+ if (code.length === 2)
895
+ return code;
896
+ return iso639_2to1(code);
897
+ }
709
898
  function languageLabel(langCode, trackIndex) {
710
899
  const names = {
711
900
  eng: 'English',