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 +3 -0
- package/dist/engine.d.ts +49 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +221 -32
- package/dist/engine.js.map +1 -1
- package/dist/external-subtitle-picker.d.ts +13 -0
- package/dist/external-subtitle-picker.d.ts.map +1 -0
- package/dist/external-subtitle-picker.js +41 -0
- package/dist/external-subtitle-picker.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/transcode-protocol.d.ts +16 -0
- package/dist/transcode-protocol.d.ts.map +1 -1
- package/dist/transcode-worker.js +54 -0
- package/dist/transcode-worker.js.map +1 -1
- package/dist/worker-protocol.d.ts +9 -0
- package/dist/worker-protocol.d.ts.map +1 -0
- package/dist/worker-protocol.js +2 -0
- package/dist/worker-protocol.js.map +1 -0
- package/dist/worker.d.ts +2 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +439 -0
- package/dist/worker.js.map +1 -0
- package/package.json +5 -3
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
|
|
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
|
package/dist/engine.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC;
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
648
|
-
|
|
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 =
|
|
653
|
-
track.label =
|
|
654
|
-
|
|
655
|
-
|
|
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
|
-
|
|
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
|
-
|
|
661
|
-
for (
|
|
662
|
-
|
|
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',
|