@lightbird/core 0.1.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/LICENSE +21 -0
- package/README.md +57 -0
- package/dist/index.cjs +1357 -0
- package/dist/index.d.cts +317 -0
- package/dist/index.d.ts +317 -0
- package/dist/index.js +1328 -0
- package/dist/react/index.cjs +915 -0
- package/dist/react/index.d.cts +252 -0
- package/dist/react/index.d.ts +252 -0
- package/dist/react/index.js +903 -0
- package/package.json +93 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,1357 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var assCompiler = require('ass-compiler');
|
|
4
|
+
var ffmpeg = require('@ffmpeg/ffmpeg');
|
|
5
|
+
var util = require('@ffmpeg/util');
|
|
6
|
+
|
|
7
|
+
var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
|
|
8
|
+
// src/config.ts
|
|
9
|
+
var config = {};
|
|
10
|
+
function configureLightBird(options) {
|
|
11
|
+
config = { ...config, ...options };
|
|
12
|
+
}
|
|
13
|
+
function getConfig() {
|
|
14
|
+
return config;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// src/players/simple-player.ts
|
|
18
|
+
var SimplePlayer = class {
|
|
19
|
+
constructor(file, externalSubtitles = []) {
|
|
20
|
+
this.videoElement = null;
|
|
21
|
+
this.currentSubtitleTrackIndex = -1;
|
|
22
|
+
this.file = {
|
|
23
|
+
name: file.name,
|
|
24
|
+
file,
|
|
25
|
+
url: URL.createObjectURL(file),
|
|
26
|
+
externalSubtitles: []
|
|
27
|
+
};
|
|
28
|
+
this.processExternalSubtitles(externalSubtitles);
|
|
29
|
+
}
|
|
30
|
+
async processExternalSubtitles(subtitleFiles) {
|
|
31
|
+
const subtitles = [];
|
|
32
|
+
for (let i = 0; i < subtitleFiles.length; i++) {
|
|
33
|
+
const file = subtitleFiles[i];
|
|
34
|
+
const url = URL.createObjectURL(file);
|
|
35
|
+
const langMatch = file.name.match(/\.([a-z]{2,3})\.(?:srt|vtt)$/i);
|
|
36
|
+
const lang = langMatch ? langMatch[1] : "unknown";
|
|
37
|
+
subtitles.push({
|
|
38
|
+
id: String(i),
|
|
39
|
+
name: `${lang.toUpperCase()} (${file.name})`,
|
|
40
|
+
lang,
|
|
41
|
+
type: "external",
|
|
42
|
+
url
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
this.file.externalSubtitles = subtitles;
|
|
46
|
+
}
|
|
47
|
+
async initialize(videoElement) {
|
|
48
|
+
this.videoElement = videoElement;
|
|
49
|
+
videoElement.src = this.file.url;
|
|
50
|
+
if (this.file.externalSubtitles) {
|
|
51
|
+
for (const subtitle of this.file.externalSubtitles) {
|
|
52
|
+
if (subtitle.url) {
|
|
53
|
+
const track = document.createElement("track");
|
|
54
|
+
track.kind = "subtitles";
|
|
55
|
+
track.label = subtitle.name;
|
|
56
|
+
track.srclang = subtitle.lang;
|
|
57
|
+
track.src = subtitle.url;
|
|
58
|
+
track.setAttribute("data-id", subtitle.id);
|
|
59
|
+
videoElement.appendChild(track);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return this.file;
|
|
64
|
+
}
|
|
65
|
+
getAudioTracks() {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
getSubtitles() {
|
|
69
|
+
return this.file.externalSubtitles || [];
|
|
70
|
+
}
|
|
71
|
+
switchAudioTrack(trackId) {
|
|
72
|
+
return Promise.resolve();
|
|
73
|
+
}
|
|
74
|
+
switchSubtitle(trackId) {
|
|
75
|
+
if (!this.videoElement) return Promise.resolve();
|
|
76
|
+
const tracks = this.videoElement.textTracks;
|
|
77
|
+
for (let i = 0; i < tracks.length; i++) {
|
|
78
|
+
tracks[i].mode = "disabled";
|
|
79
|
+
}
|
|
80
|
+
if (trackId === "-1") {
|
|
81
|
+
this.currentSubtitleTrackIndex = -1;
|
|
82
|
+
return Promise.resolve();
|
|
83
|
+
}
|
|
84
|
+
const trackElements = this.videoElement.querySelectorAll("track");
|
|
85
|
+
for (let i = 0; i < trackElements.length; i++) {
|
|
86
|
+
const trackElement = trackElements[i];
|
|
87
|
+
if (trackElement.getAttribute("data-id") === trackId) {
|
|
88
|
+
const textTrack = tracks[i];
|
|
89
|
+
if (textTrack) {
|
|
90
|
+
if (trackElement.readyState === 2) {
|
|
91
|
+
textTrack.mode = "hidden";
|
|
92
|
+
this.currentSubtitleTrackIndex = i;
|
|
93
|
+
} else {
|
|
94
|
+
const onLoad = () => {
|
|
95
|
+
textTrack.mode = "hidden";
|
|
96
|
+
this.currentSubtitleTrackIndex = i;
|
|
97
|
+
trackElement.removeEventListener("load", onLoad);
|
|
98
|
+
};
|
|
99
|
+
trackElement.addEventListener("load", onLoad);
|
|
100
|
+
textTrack.mode = "hidden";
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return Promise.resolve();
|
|
107
|
+
}
|
|
108
|
+
destroy() {
|
|
109
|
+
if (this.file.url.startsWith("blob:")) {
|
|
110
|
+
URL.revokeObjectURL(this.file.url);
|
|
111
|
+
}
|
|
112
|
+
if (this.file.externalSubtitles) {
|
|
113
|
+
for (const subtitle of this.file.externalSubtitles) {
|
|
114
|
+
if (subtitle.url?.startsWith("blob:")) {
|
|
115
|
+
URL.revokeObjectURL(subtitle.url);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
static isCompatible(file) {
|
|
121
|
+
const fileName = file.name.toLowerCase();
|
|
122
|
+
const supportedExtensions = [".mp4", ".webm", ".ogv", ".avi", ".mov", ".wmv", ".flv"];
|
|
123
|
+
return supportedExtensions.some((ext) => fileName.endsWith(ext)) || file.type.startsWith("video/");
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// src/subtitles/subtitle-converter.ts
|
|
128
|
+
var SubtitleConverter = class {
|
|
129
|
+
static async convertSrtToVtt(srtContent) {
|
|
130
|
+
let vttContent = "WEBVTT\n\n";
|
|
131
|
+
const blocks = srtContent.split(/\n\s*\n/);
|
|
132
|
+
for (const block of blocks) {
|
|
133
|
+
const lines = block.trim().split("\n");
|
|
134
|
+
if (lines.length >= 3) {
|
|
135
|
+
const timecodeLine = lines[1];
|
|
136
|
+
const textLines = lines.slice(2);
|
|
137
|
+
const vttTimecode = timecodeLine.replace(/,/g, ".");
|
|
138
|
+
vttContent += `${vttTimecode}
|
|
139
|
+
`;
|
|
140
|
+
vttContent += `${textLines.join("\n")}
|
|
141
|
+
|
|
142
|
+
`;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return vttContent;
|
|
146
|
+
}
|
|
147
|
+
static async convertFileToVtt(file) {
|
|
148
|
+
const fileName = file.name.toLowerCase();
|
|
149
|
+
if (fileName.endsWith(".vtt")) {
|
|
150
|
+
return file;
|
|
151
|
+
}
|
|
152
|
+
if (fileName.endsWith(".srt")) {
|
|
153
|
+
const srtContent = await file.text();
|
|
154
|
+
const vttContent = await this.convertSrtToVtt(srtContent);
|
|
155
|
+
const vttBlob = new Blob([vttContent], { type: "text/vtt" });
|
|
156
|
+
const vttFileName = file.name.replace(/\.srt$/i, ".vtt");
|
|
157
|
+
return new File([vttBlob], vttFileName, { type: "text/vtt" });
|
|
158
|
+
}
|
|
159
|
+
return file;
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
// src/parsers/chapter-parser.ts
|
|
164
|
+
function parseChaptersFromFFmpegLog(log, totalDuration) {
|
|
165
|
+
try {
|
|
166
|
+
const chapters = [];
|
|
167
|
+
const chapterRegex = /Chapter #\d+:\d+:\s+start\s+([\d.]+),\s+end\s+([\d.]+)/gi;
|
|
168
|
+
const titleRegex = /\btitle\s*:\s*(.+)/i;
|
|
169
|
+
let match;
|
|
170
|
+
const rawChapters = [];
|
|
171
|
+
while ((match = chapterRegex.exec(log)) !== null) {
|
|
172
|
+
const start = parseFloat(match[1]);
|
|
173
|
+
const end = parseFloat(match[2]);
|
|
174
|
+
const afterMatch = log.slice(match.index + match[0].length, match.index + match[0].length + 200);
|
|
175
|
+
rawChapters.push({ start, end, titleLine: afterMatch });
|
|
176
|
+
}
|
|
177
|
+
if (rawChapters.length === 0) return [];
|
|
178
|
+
for (let i = 0; i < rawChapters.length; i++) {
|
|
179
|
+
const { start, end, titleLine } = rawChapters[i];
|
|
180
|
+
const titleMatch = titleRegex.exec(titleLine);
|
|
181
|
+
const title = titleMatch ? titleMatch[1].trim() : `Chapter ${i + 1}`;
|
|
182
|
+
const isLast = i === rawChapters.length - 1;
|
|
183
|
+
const endTime = isLast ? totalDuration : end;
|
|
184
|
+
chapters.push({
|
|
185
|
+
index: i,
|
|
186
|
+
title,
|
|
187
|
+
startTime: start,
|
|
188
|
+
endTime
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
return chapters;
|
|
192
|
+
} catch {
|
|
193
|
+
return [];
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
function parseChaptersFromVtt(vttText) {
|
|
197
|
+
try {
|
|
198
|
+
if (!vttText || vttText.trim() === "") return [];
|
|
199
|
+
const chapters = [];
|
|
200
|
+
const blocks = vttText.split(/\n\s*\n/);
|
|
201
|
+
for (const block of blocks) {
|
|
202
|
+
const lines = block.trim().split("\n").map((l) => l.trim());
|
|
203
|
+
if (lines.length < 2) continue;
|
|
204
|
+
if (lines[0].startsWith("WEBVTT")) continue;
|
|
205
|
+
const tsIndex = lines.findIndex((l) => l.includes("-->"));
|
|
206
|
+
if (tsIndex === -1) continue;
|
|
207
|
+
const titleLine = tsIndex > 0 ? lines[tsIndex - 1] : "";
|
|
208
|
+
const title = titleLine || `Chapter ${chapters.length + 1}`;
|
|
209
|
+
const tsLine = lines[tsIndex];
|
|
210
|
+
const tsMatch = tsLine.match(
|
|
211
|
+
/^([\d:.,]+)\s*-->\s*([\d:.,]+)/
|
|
212
|
+
);
|
|
213
|
+
if (!tsMatch) continue;
|
|
214
|
+
const startTime = parseVttTimestamp(tsMatch[1]);
|
|
215
|
+
const endTime = parseVttTimestamp(tsMatch[2]);
|
|
216
|
+
if (isNaN(startTime) || isNaN(endTime)) continue;
|
|
217
|
+
chapters.push({
|
|
218
|
+
index: chapters.length,
|
|
219
|
+
title,
|
|
220
|
+
startTime,
|
|
221
|
+
endTime
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
return chapters;
|
|
225
|
+
} catch {
|
|
226
|
+
return [];
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
function parseVttTimestamp(ts) {
|
|
230
|
+
const normalized = ts.replace(",", ".");
|
|
231
|
+
const parts = normalized.split(":").map(Number);
|
|
232
|
+
if (parts.length === 3) {
|
|
233
|
+
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
|
234
|
+
} else if (parts.length === 2) {
|
|
235
|
+
return parts[0] * 60 + parts[1];
|
|
236
|
+
} else if (parts.length === 1) {
|
|
237
|
+
return parts[0];
|
|
238
|
+
}
|
|
239
|
+
return NaN;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// src/players/mkv-player.ts
|
|
243
|
+
async function canPlayNatively(objectUrl, timeoutMs = 3e3) {
|
|
244
|
+
return new Promise((resolve) => {
|
|
245
|
+
const video = document.createElement("video");
|
|
246
|
+
const cleanup = (result) => {
|
|
247
|
+
clearTimeout(timer);
|
|
248
|
+
video.removeAttribute("src");
|
|
249
|
+
video.load();
|
|
250
|
+
resolve(result);
|
|
251
|
+
};
|
|
252
|
+
const timer = setTimeout(() => cleanup(false), timeoutMs);
|
|
253
|
+
video.oncanplay = () => cleanup(true);
|
|
254
|
+
video.onerror = () => cleanup(false);
|
|
255
|
+
video.preload = "metadata";
|
|
256
|
+
video.src = objectUrl;
|
|
257
|
+
video.load();
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
var CancellationError = class extends Error {
|
|
261
|
+
constructor() {
|
|
262
|
+
super("MKVPlayer: operation cancelled");
|
|
263
|
+
this.name = "CancellationError";
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
function parseStreamInfo(logs) {
|
|
267
|
+
const videoTracks = [];
|
|
268
|
+
const audioTracks = [];
|
|
269
|
+
const subtitleTracks = [];
|
|
270
|
+
const lines = logs.split("\n");
|
|
271
|
+
for (const line of lines) {
|
|
272
|
+
const streamMatch = line.match(/Stream #\d+:\d+(?:\((\w+)\))?: (Video|Audio|Subtitle): (\S+)/i);
|
|
273
|
+
if (!streamMatch) continue;
|
|
274
|
+
const [, lang, type, codec] = streamMatch;
|
|
275
|
+
const titleMatch = line.match(/\btitle\s*:\s*([^,\n]+)/i);
|
|
276
|
+
const title = titleMatch ? titleMatch[1].trim() : void 0;
|
|
277
|
+
if (type.toLowerCase() === "video") {
|
|
278
|
+
videoTracks.push({ index: videoTracks.length, type: "video", codec, lang, title });
|
|
279
|
+
} else if (type.toLowerCase() === "audio") {
|
|
280
|
+
audioTracks.push({ index: audioTracks.length, type: "audio", codec, lang, title });
|
|
281
|
+
} else if (type.toLowerCase() === "subtitle") {
|
|
282
|
+
subtitleTracks.push({ index: subtitleTracks.length, type: "subtitle", codec, lang, title });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return { videoTracks, audioTracks, subtitleTracks };
|
|
286
|
+
}
|
|
287
|
+
var _MKVPlayer = class _MKVPlayer {
|
|
288
|
+
constructor(file, onProgress) {
|
|
289
|
+
this.videoElement = null;
|
|
290
|
+
this.objectUrl = null;
|
|
291
|
+
this._cancelled = false;
|
|
292
|
+
// Maps subtitle track ID → ffmpeg subtitle stream index
|
|
293
|
+
this.subtitleTrackMap = /* @__PURE__ */ new Map();
|
|
294
|
+
// Maps subtitle track ID → blob URL for cleanup
|
|
295
|
+
this.subtitleBlobUrls = /* @__PURE__ */ new Map();
|
|
296
|
+
// Parsed chapter data
|
|
297
|
+
this.chapters = [];
|
|
298
|
+
// Maps audioTrackIndex → blob URL of the remuxed video
|
|
299
|
+
this.remuxCache = /* @__PURE__ */ new Map();
|
|
300
|
+
// Worker management
|
|
301
|
+
this.worker = null;
|
|
302
|
+
this.pendingOperations = /* @__PURE__ */ new Map();
|
|
303
|
+
/**
|
|
304
|
+
* Resolves when track metadata (audio + subtitle) is fully populated.
|
|
305
|
+
* On the FFmpeg path this resolves after initialize() itself. On the native
|
|
306
|
+
* path the probe runs in the background so initialize() returns first —
|
|
307
|
+
* callers that need the real track list should await this promise.
|
|
308
|
+
*/
|
|
309
|
+
this.tracksReady = Promise.resolve();
|
|
310
|
+
this.file = file;
|
|
311
|
+
this.onProgress = onProgress;
|
|
312
|
+
this.playerFile = {
|
|
313
|
+
name: file.name,
|
|
314
|
+
file,
|
|
315
|
+
audioTracks: [],
|
|
316
|
+
subtitleTracks: [],
|
|
317
|
+
activeAudioTrack: "0",
|
|
318
|
+
activeSubtitleTrack: "-1"
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
getWorker() {
|
|
322
|
+
if (!this.worker) {
|
|
323
|
+
let w;
|
|
324
|
+
if (_MKVPlayer._workerFactory) {
|
|
325
|
+
w = _MKVPlayer._workerFactory();
|
|
326
|
+
} else {
|
|
327
|
+
w = new Worker(new URL("../workers/ffmpeg-worker.ts", (typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href))));
|
|
328
|
+
}
|
|
329
|
+
w.onmessage = (event) => {
|
|
330
|
+
this._handleWorkerMessage(event.data);
|
|
331
|
+
};
|
|
332
|
+
w.onerror = (error) => {
|
|
333
|
+
console.error("FFmpeg worker error:", error);
|
|
334
|
+
for (const { reject } of this.pendingOperations.values()) {
|
|
335
|
+
reject(error);
|
|
336
|
+
}
|
|
337
|
+
this.pendingOperations.clear();
|
|
338
|
+
if (this.worker === w) {
|
|
339
|
+
this.worker = null;
|
|
340
|
+
}
|
|
341
|
+
w.terminate();
|
|
342
|
+
};
|
|
343
|
+
this.worker = w;
|
|
344
|
+
}
|
|
345
|
+
return this.worker;
|
|
346
|
+
}
|
|
347
|
+
_handleWorkerMessage(msg) {
|
|
348
|
+
const { id, type } = msg;
|
|
349
|
+
if (type === "PROGRESS") {
|
|
350
|
+
this.onProgress?.(msg.progress);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
const pending = this.pendingOperations.get(id);
|
|
354
|
+
if (!pending) return;
|
|
355
|
+
this.pendingOperations.delete(id);
|
|
356
|
+
if (type === "ERROR") {
|
|
357
|
+
pending.reject(new Error(msg.error));
|
|
358
|
+
} else {
|
|
359
|
+
pending.resolve(msg);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
sendToWorker(message) {
|
|
363
|
+
return new Promise((resolve, reject) => {
|
|
364
|
+
this.pendingOperations.set(message.id, {
|
|
365
|
+
resolve,
|
|
366
|
+
reject
|
|
367
|
+
});
|
|
368
|
+
this.getWorker().postMessage(message);
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
async initialize(videoElement) {
|
|
372
|
+
this.videoElement = videoElement;
|
|
373
|
+
this._cancelled = false;
|
|
374
|
+
try {
|
|
375
|
+
const probeUrl = URL.createObjectURL(this.file);
|
|
376
|
+
const nativeOk = await _MKVPlayer._canPlayNatively(probeUrl);
|
|
377
|
+
if (this._cancelled) {
|
|
378
|
+
URL.revokeObjectURL(probeUrl);
|
|
379
|
+
throw new CancellationError();
|
|
380
|
+
}
|
|
381
|
+
if (nativeOk) {
|
|
382
|
+
this.objectUrl = probeUrl;
|
|
383
|
+
this.playerFile.videoUrl = probeUrl;
|
|
384
|
+
this.playerFile.audioTracks = [{ id: "0", name: "Default Audio", lang: "unknown" }];
|
|
385
|
+
videoElement.src = probeUrl;
|
|
386
|
+
this.onProgress?.(1);
|
|
387
|
+
this.tracksReady = this._probeForTracks().catch(() => {
|
|
388
|
+
this.playerFile.audioTracks = [{ id: "0", name: "Default Audio", lang: "unknown" }];
|
|
389
|
+
});
|
|
390
|
+
return this.playerFile;
|
|
391
|
+
}
|
|
392
|
+
URL.revokeObjectURL(probeUrl);
|
|
393
|
+
const opId = crypto.randomUUID();
|
|
394
|
+
const result = await this.sendToWorker({
|
|
395
|
+
id: opId,
|
|
396
|
+
type: "REMUX",
|
|
397
|
+
payload: {
|
|
398
|
+
file: this.file,
|
|
399
|
+
fileName: this.file.name,
|
|
400
|
+
audioTrackIndex: 0
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
const { audioTracks, subtitleTracks } = parseStreamInfo(result.logs);
|
|
404
|
+
this.chapters = parseChaptersFromFFmpegLog(result.logs, videoElement.duration || 0);
|
|
405
|
+
this.playerFile.audioTracks = audioTracks.length > 0 ? audioTracks.map((t, i) => ({
|
|
406
|
+
id: String(i),
|
|
407
|
+
name: t.title ?? (t.lang ? `Audio ${i + 1} (${t.lang})` : `Audio ${i + 1}`),
|
|
408
|
+
lang: t.lang ?? "unknown"
|
|
409
|
+
})) : [{ id: "0", name: "Default Audio", lang: "unknown" }];
|
|
410
|
+
this.subtitleTrackMap.clear();
|
|
411
|
+
this.playerFile.subtitleTracks = subtitleTracks.map((t, i) => {
|
|
412
|
+
const id = String(i);
|
|
413
|
+
this.subtitleTrackMap.set(id, i);
|
|
414
|
+
return {
|
|
415
|
+
id,
|
|
416
|
+
name: t.title ?? (t.lang ? `Subtitle ${i + 1} (${t.lang})` : `Subtitle ${i + 1}`),
|
|
417
|
+
lang: t.lang ?? "unknown",
|
|
418
|
+
type: "embedded"
|
|
419
|
+
};
|
|
420
|
+
});
|
|
421
|
+
const blob = new Blob([result.data], { type: "video/mp4" });
|
|
422
|
+
const url = URL.createObjectURL(blob);
|
|
423
|
+
this.remuxCache.set(0, url);
|
|
424
|
+
this.objectUrl = url;
|
|
425
|
+
this.playerFile.videoUrl = url;
|
|
426
|
+
videoElement.src = url;
|
|
427
|
+
this.onProgress?.(1);
|
|
428
|
+
} catch (error) {
|
|
429
|
+
if (error instanceof CancellationError) {
|
|
430
|
+
throw error;
|
|
431
|
+
}
|
|
432
|
+
console.error("MKVPlayer: Worker failed, falling back to native playback", error);
|
|
433
|
+
const url = URL.createObjectURL(this.file);
|
|
434
|
+
if (this.objectUrl) URL.revokeObjectURL(this.objectUrl);
|
|
435
|
+
this.objectUrl = url;
|
|
436
|
+
this.playerFile.videoUrl = url;
|
|
437
|
+
this.playerFile.audioTracks = [{ id: "0", name: "Default Audio", lang: "unknown" }];
|
|
438
|
+
videoElement.src = url;
|
|
439
|
+
}
|
|
440
|
+
return this.playerFile;
|
|
441
|
+
}
|
|
442
|
+
async _probeForTracks() {
|
|
443
|
+
const probeOpId = crypto.randomUUID();
|
|
444
|
+
const probeResult = await this.sendToWorker({
|
|
445
|
+
id: probeOpId,
|
|
446
|
+
type: "PROBE",
|
|
447
|
+
payload: { file: this.file, fileName: this.file.name }
|
|
448
|
+
});
|
|
449
|
+
if (this._cancelled) return;
|
|
450
|
+
const { audioTracks, subtitleTracks } = parseStreamInfo(probeResult.logs);
|
|
451
|
+
this.playerFile.audioTracks = audioTracks.length > 0 ? audioTracks.map((t, i) => ({
|
|
452
|
+
id: String(i),
|
|
453
|
+
name: t.title ?? (t.lang ? `Audio ${i + 1} (${t.lang})` : `Audio ${i + 1}`),
|
|
454
|
+
lang: t.lang ?? "unknown"
|
|
455
|
+
})) : [{ id: "0", name: "Default Audio", lang: "unknown" }];
|
|
456
|
+
this.subtitleTrackMap.clear();
|
|
457
|
+
this.playerFile.subtitleTracks = subtitleTracks.map((t, i) => {
|
|
458
|
+
const id = String(i);
|
|
459
|
+
this.subtitleTrackMap.set(id, i);
|
|
460
|
+
return {
|
|
461
|
+
id,
|
|
462
|
+
name: t.title ?? (t.lang ? `Subtitle ${i + 1} (${t.lang})` : `Subtitle ${i + 1}`),
|
|
463
|
+
lang: t.lang ?? "unknown",
|
|
464
|
+
type: "embedded"
|
|
465
|
+
};
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
async _remux(audioTrackIndex) {
|
|
469
|
+
const cached = this.remuxCache.get(audioTrackIndex);
|
|
470
|
+
if (cached) return cached;
|
|
471
|
+
const opId = crypto.randomUUID();
|
|
472
|
+
const result = await this.sendToWorker({
|
|
473
|
+
id: opId,
|
|
474
|
+
type: "REMUX",
|
|
475
|
+
payload: {
|
|
476
|
+
file: this.file,
|
|
477
|
+
fileName: this.file.name,
|
|
478
|
+
audioTrackIndex
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
const blob = new Blob([result.data], { type: "video/mp4" });
|
|
482
|
+
const url = URL.createObjectURL(blob);
|
|
483
|
+
this.remuxCache.set(audioTrackIndex, url);
|
|
484
|
+
this.objectUrl = url;
|
|
485
|
+
return url;
|
|
486
|
+
}
|
|
487
|
+
async _extractSubtitle(trackIndex) {
|
|
488
|
+
const opId = crypto.randomUUID();
|
|
489
|
+
const result = await this.sendToWorker({
|
|
490
|
+
id: opId,
|
|
491
|
+
type: "EXTRACT_SUBTITLE",
|
|
492
|
+
payload: {
|
|
493
|
+
file: this.file,
|
|
494
|
+
fileName: this.file.name,
|
|
495
|
+
trackIndex
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
return result.srtText;
|
|
499
|
+
}
|
|
500
|
+
getAudioTracks() {
|
|
501
|
+
return this.playerFile.audioTracks;
|
|
502
|
+
}
|
|
503
|
+
getSubtitles() {
|
|
504
|
+
return this.playerFile.subtitleTracks;
|
|
505
|
+
}
|
|
506
|
+
getChapters() {
|
|
507
|
+
return this.chapters;
|
|
508
|
+
}
|
|
509
|
+
async switchAudioTrack(trackId) {
|
|
510
|
+
if (!this.videoElement) return;
|
|
511
|
+
const trackIndex = parseInt(trackId, 10);
|
|
512
|
+
if (isNaN(trackIndex)) return;
|
|
513
|
+
this.playerFile.activeAudioTrack = trackId;
|
|
514
|
+
const currentTime = this.videoElement.currentTime;
|
|
515
|
+
const wasPlaying = !this.videoElement.paused;
|
|
516
|
+
const url = await this._remux(trackIndex);
|
|
517
|
+
this.playerFile.videoUrl = url;
|
|
518
|
+
this.videoElement.src = url;
|
|
519
|
+
this.videoElement.addEventListener(
|
|
520
|
+
"loadedmetadata",
|
|
521
|
+
() => {
|
|
522
|
+
this.videoElement.currentTime = currentTime;
|
|
523
|
+
if (wasPlaying) {
|
|
524
|
+
this.videoElement.play().catch(console.error);
|
|
525
|
+
}
|
|
526
|
+
},
|
|
527
|
+
{ once: true }
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
async switchSubtitle(trackId) {
|
|
531
|
+
this.playerFile.activeSubtitleTrack = trackId;
|
|
532
|
+
if (trackId === "-1" || !this.videoElement) return;
|
|
533
|
+
const trackIndex = this.subtitleTrackMap.get(trackId);
|
|
534
|
+
if (trackIndex === void 0) return;
|
|
535
|
+
try {
|
|
536
|
+
const existing = this.videoElement.querySelector(
|
|
537
|
+
`track[data-mkv-id="${trackId}"]`
|
|
538
|
+
);
|
|
539
|
+
if (existing) {
|
|
540
|
+
existing.track.mode = "hidden";
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
const srtContent = await this._extractSubtitle(trackIndex);
|
|
544
|
+
const vttContent = await SubtitleConverter.convertSrtToVtt(srtContent);
|
|
545
|
+
const blob = new Blob([vttContent], { type: "text/vtt" });
|
|
546
|
+
const url = URL.createObjectURL(blob);
|
|
547
|
+
this.subtitleBlobUrls.set(trackId, url);
|
|
548
|
+
const track = document.createElement("track");
|
|
549
|
+
track.kind = "subtitles";
|
|
550
|
+
track.src = url;
|
|
551
|
+
track.setAttribute("data-id", trackId);
|
|
552
|
+
track.setAttribute("data-mkv-id", trackId);
|
|
553
|
+
this.videoElement.appendChild(track);
|
|
554
|
+
track.track.mode = "hidden";
|
|
555
|
+
} catch (error) {
|
|
556
|
+
console.error("MKVPlayer: Failed to extract subtitle", error);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
getActiveAudioTrack() {
|
|
560
|
+
return this.playerFile.activeAudioTrack;
|
|
561
|
+
}
|
|
562
|
+
getActiveSubtitleTrack() {
|
|
563
|
+
return this.playerFile.activeSubtitleTrack;
|
|
564
|
+
}
|
|
565
|
+
cancel() {
|
|
566
|
+
this._cancelled = true;
|
|
567
|
+
if (!this.worker) return;
|
|
568
|
+
this.worker.terminate();
|
|
569
|
+
this.worker = null;
|
|
570
|
+
for (const { reject } of this.pendingOperations.values()) {
|
|
571
|
+
reject(new CancellationError());
|
|
572
|
+
}
|
|
573
|
+
this.pendingOperations.clear();
|
|
574
|
+
}
|
|
575
|
+
destroy() {
|
|
576
|
+
for (const { reject } of this.pendingOperations.values()) {
|
|
577
|
+
reject(new Error("MKVPlayer destroyed"));
|
|
578
|
+
}
|
|
579
|
+
this.pendingOperations.clear();
|
|
580
|
+
if (this.worker) {
|
|
581
|
+
this.worker.terminate();
|
|
582
|
+
this.worker = null;
|
|
583
|
+
}
|
|
584
|
+
for (const url of this.remuxCache.values()) {
|
|
585
|
+
URL.revokeObjectURL(url);
|
|
586
|
+
}
|
|
587
|
+
this.remuxCache.clear();
|
|
588
|
+
if (this.objectUrl) {
|
|
589
|
+
URL.revokeObjectURL(this.objectUrl);
|
|
590
|
+
this.objectUrl = null;
|
|
591
|
+
}
|
|
592
|
+
for (const url of this.subtitleBlobUrls.values()) {
|
|
593
|
+
URL.revokeObjectURL(url);
|
|
594
|
+
}
|
|
595
|
+
this.subtitleBlobUrls.clear();
|
|
596
|
+
}
|
|
597
|
+
static isCompatible(file) {
|
|
598
|
+
return file.name.toLowerCase().endsWith(".mkv");
|
|
599
|
+
}
|
|
600
|
+
};
|
|
601
|
+
/**
|
|
602
|
+
* Overrideable in tests to avoid real browser playback probes.
|
|
603
|
+
* @internal
|
|
604
|
+
*/
|
|
605
|
+
_MKVPlayer._canPlayNatively = canPlayNatively;
|
|
606
|
+
/**
|
|
607
|
+
* Overrideable factory for creating the FFmpeg worker. Tests override this
|
|
608
|
+
* to avoid `import.meta.url` (not available in CJS Jest).
|
|
609
|
+
* @internal
|
|
610
|
+
*/
|
|
611
|
+
_MKVPlayer._workerFactory = null;
|
|
612
|
+
var MKVPlayer = _MKVPlayer;
|
|
613
|
+
|
|
614
|
+
// src/video-processor.ts
|
|
615
|
+
var SimplePlayerAdapter = class {
|
|
616
|
+
constructor(file, externalSubtitles = []) {
|
|
617
|
+
this.player = new SimplePlayer(file, externalSubtitles);
|
|
618
|
+
}
|
|
619
|
+
async initialize(videoElement) {
|
|
620
|
+
return await this.player.initialize(videoElement);
|
|
621
|
+
}
|
|
622
|
+
getAudioTracks() {
|
|
623
|
+
return this.player.getAudioTracks();
|
|
624
|
+
}
|
|
625
|
+
getSubtitles() {
|
|
626
|
+
return this.player.getSubtitles();
|
|
627
|
+
}
|
|
628
|
+
async switchAudioTrack(trackId) {
|
|
629
|
+
return await this.player.switchAudioTrack(trackId);
|
|
630
|
+
}
|
|
631
|
+
async switchSubtitle(trackId) {
|
|
632
|
+
return await this.player.switchSubtitle(trackId);
|
|
633
|
+
}
|
|
634
|
+
destroy() {
|
|
635
|
+
this.player.destroy();
|
|
636
|
+
}
|
|
637
|
+
};
|
|
638
|
+
var MKVPlayerAdapter = class {
|
|
639
|
+
constructor(file, onProgress) {
|
|
640
|
+
this.player = new MKVPlayer(file, onProgress);
|
|
641
|
+
}
|
|
642
|
+
async initialize(videoElement) {
|
|
643
|
+
return await this.player.initialize(videoElement);
|
|
644
|
+
}
|
|
645
|
+
getAudioTracks() {
|
|
646
|
+
return this.player.getAudioTracks();
|
|
647
|
+
}
|
|
648
|
+
getSubtitles() {
|
|
649
|
+
return this.player.getSubtitles();
|
|
650
|
+
}
|
|
651
|
+
getChapters() {
|
|
652
|
+
return this.player.getChapters();
|
|
653
|
+
}
|
|
654
|
+
async switchAudioTrack(trackId) {
|
|
655
|
+
return await this.player.switchAudioTrack(trackId);
|
|
656
|
+
}
|
|
657
|
+
async switchSubtitle(trackId) {
|
|
658
|
+
return await this.player.switchSubtitle(trackId);
|
|
659
|
+
}
|
|
660
|
+
destroy() {
|
|
661
|
+
this.player.destroy();
|
|
662
|
+
}
|
|
663
|
+
cancel() {
|
|
664
|
+
this.player.cancel();
|
|
665
|
+
}
|
|
666
|
+
get tracksReady() {
|
|
667
|
+
return this.player.tracksReady;
|
|
668
|
+
}
|
|
669
|
+
};
|
|
670
|
+
function createVideoPlayer(file, externalSubtitles = [], onProgress) {
|
|
671
|
+
if (MKVPlayer.isCompatible(file)) {
|
|
672
|
+
return new MKVPlayerAdapter(file, onProgress);
|
|
673
|
+
} else if (SimplePlayer.isCompatible(file)) {
|
|
674
|
+
return new SimplePlayerAdapter(file, externalSubtitles);
|
|
675
|
+
} else {
|
|
676
|
+
return new SimplePlayerAdapter(file, externalSubtitles);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// src/subtitles/subtitle-offset.ts
|
|
681
|
+
function shiftTimestamp(ts, delta) {
|
|
682
|
+
ts.split(":").length === 3 ? [ts.slice(0, 5), ts.slice(6)] : ["00:00", ts.slice(3)];
|
|
683
|
+
const parts = ts.split(":");
|
|
684
|
+
const h = Number(parts[0]);
|
|
685
|
+
const m = Number(parts[1]);
|
|
686
|
+
const s = Number(parts[2]);
|
|
687
|
+
const total = Math.max(0, h * 3600 + m * 60 + s + delta);
|
|
688
|
+
const hh = Math.floor(total / 3600);
|
|
689
|
+
const mm = Math.floor(total % 3600 / 60);
|
|
690
|
+
const ss = total % 60;
|
|
691
|
+
const ssStr = ss.toFixed(3).padStart(6, "0");
|
|
692
|
+
return `${String(hh).padStart(2, "0")}:${String(mm).padStart(2, "0")}:${ssStr}`;
|
|
693
|
+
}
|
|
694
|
+
function applyOffsetToVtt(vttText, offsetSeconds) {
|
|
695
|
+
if (offsetSeconds === 0) return vttText;
|
|
696
|
+
return vttText.replace(
|
|
697
|
+
/(\d{2}:\d{2}:\d{2}\.\d{3})\s+-->\s+(\d{2}:\d{2}:\d{2}\.\d{3})/g,
|
|
698
|
+
(_, start, end) => {
|
|
699
|
+
const shiftedStart = shiftTimestamp(start, offsetSeconds);
|
|
700
|
+
const shiftedEnd = shiftTimestamp(end, offsetSeconds);
|
|
701
|
+
return `${shiftedStart} --> ${shiftedEnd}`;
|
|
702
|
+
}
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
async function createOffsetVttUrl(originalUrl, offsetSeconds) {
|
|
706
|
+
const response = await fetch(originalUrl);
|
|
707
|
+
const text = await response.text();
|
|
708
|
+
const shifted = applyOffsetToVtt(text, offsetSeconds);
|
|
709
|
+
const blob = new Blob([shifted], { type: "text/vtt" });
|
|
710
|
+
return URL.createObjectURL(blob);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// src/subtitles/subtitle-manager.ts
|
|
714
|
+
function detectEncoding(bytes) {
|
|
715
|
+
if (bytes.length >= 3 && bytes[0] === 239 && bytes[1] === 187 && bytes[2] === 191) {
|
|
716
|
+
return "UTF-8";
|
|
717
|
+
}
|
|
718
|
+
if (bytes.length >= 2 && bytes[0] === 255 && bytes[1] === 254) {
|
|
719
|
+
return "UTF-16LE";
|
|
720
|
+
}
|
|
721
|
+
if (bytes.length >= 2 && bytes[0] === 254 && bytes[1] === 255) {
|
|
722
|
+
return "UTF-16BE";
|
|
723
|
+
}
|
|
724
|
+
return "UTF-8";
|
|
725
|
+
}
|
|
726
|
+
async function readSubtitleFile(file) {
|
|
727
|
+
const buffer = await file.arrayBuffer();
|
|
728
|
+
const bytes = new Uint8Array(buffer);
|
|
729
|
+
const detected = detectEncoding(bytes);
|
|
730
|
+
const decoder = new TextDecoder(detected);
|
|
731
|
+
return decoder.decode(buffer);
|
|
732
|
+
}
|
|
733
|
+
function parseVttCues(vttText) {
|
|
734
|
+
const cues = [];
|
|
735
|
+
const cueRegex = /(\d{2}:\d{2}:\d{2}\.\d{3})\s+-->\s+(\d{2}:\d{2}:\d{2}\.\d{3})[^\n]*\n([\s\S]*?)(?=\n\n|\n*$)/g;
|
|
736
|
+
let match;
|
|
737
|
+
while ((match = cueRegex.exec(vttText)) !== null) {
|
|
738
|
+
const startTime = timestampToSeconds(match[1]);
|
|
739
|
+
const endTime = timestampToSeconds(match[2]);
|
|
740
|
+
const text = match[3].trim().replace(/<[^>]+>/g, "");
|
|
741
|
+
if (text) cues.push({ startTime, endTime, text });
|
|
742
|
+
}
|
|
743
|
+
return cues;
|
|
744
|
+
}
|
|
745
|
+
function timestampToSeconds(ts) {
|
|
746
|
+
const parts = ts.split(":");
|
|
747
|
+
const h = Number(parts[0]);
|
|
748
|
+
const m = Number(parts[1]);
|
|
749
|
+
const s = Number(parts[2]);
|
|
750
|
+
return h * 3600 + m * 60 + s;
|
|
751
|
+
}
|
|
752
|
+
var UniversalSubtitleManager = class {
|
|
753
|
+
constructor(videoElement) {
|
|
754
|
+
this.records = [];
|
|
755
|
+
this.videoElement = null;
|
|
756
|
+
this.nextId = 0;
|
|
757
|
+
this.videoElement = videoElement || null;
|
|
758
|
+
}
|
|
759
|
+
setVideoElement(videoElement) {
|
|
760
|
+
this.videoElement = videoElement;
|
|
761
|
+
}
|
|
762
|
+
async addSubtitleFiles(files) {
|
|
763
|
+
const newSubtitles = [];
|
|
764
|
+
for (const file of files) {
|
|
765
|
+
const ext = file.name.split(".").pop()?.toLowerCase();
|
|
766
|
+
const langMatch = file.name.match(/\.([a-z]{2,3})\.(?:srt|vtt|ass|ssa)$/i);
|
|
767
|
+
const lang = langMatch ? langMatch[1] : "unknown";
|
|
768
|
+
const subtitle = {
|
|
769
|
+
id: String(this.nextId++),
|
|
770
|
+
name: `${lang.toUpperCase()} (${file.name})`,
|
|
771
|
+
lang,
|
|
772
|
+
type: "external",
|
|
773
|
+
format: ext ?? "vtt"
|
|
774
|
+
};
|
|
775
|
+
let rawVtt;
|
|
776
|
+
let cues = [];
|
|
777
|
+
if (ext === "ass" || ext === "ssa") {
|
|
778
|
+
const rawText = await readSubtitleFile(file);
|
|
779
|
+
subtitle.url = void 0;
|
|
780
|
+
const blob = new Blob([rawText], { type: "text/plain" });
|
|
781
|
+
subtitle.url = URL.createObjectURL(blob);
|
|
782
|
+
rawVtt = void 0;
|
|
783
|
+
} else {
|
|
784
|
+
const fileText = await readSubtitleFile(file);
|
|
785
|
+
let vttText;
|
|
786
|
+
if (ext === "srt") {
|
|
787
|
+
vttText = await SubtitleConverter.convertSrtToVtt(fileText);
|
|
788
|
+
} else {
|
|
789
|
+
vttText = fileText;
|
|
790
|
+
}
|
|
791
|
+
rawVtt = vttText;
|
|
792
|
+
cues = parseVttCues(vttText);
|
|
793
|
+
const blob = new Blob([vttText], { type: "text/vtt" });
|
|
794
|
+
subtitle.url = URL.createObjectURL(blob);
|
|
795
|
+
}
|
|
796
|
+
newSubtitles.push(subtitle);
|
|
797
|
+
this.records.push({ subtitle, rawVtt, offset: 0, cues });
|
|
798
|
+
if (this.videoElement && ext !== "ass" && ext !== "ssa" && subtitle.url) {
|
|
799
|
+
const track = document.createElement("track");
|
|
800
|
+
track.kind = "subtitles";
|
|
801
|
+
track.label = subtitle.name;
|
|
802
|
+
track.srclang = subtitle.lang;
|
|
803
|
+
track.src = subtitle.url;
|
|
804
|
+
track.setAttribute("data-id", subtitle.id);
|
|
805
|
+
track.default = false;
|
|
806
|
+
track.addEventListener("load", () => {
|
|
807
|
+
console.log(`Subtitle track loaded: ${subtitle.name}`);
|
|
808
|
+
});
|
|
809
|
+
track.addEventListener("error", (e) => {
|
|
810
|
+
console.error(`Failed to load subtitle track: ${subtitle.name}`, e);
|
|
811
|
+
});
|
|
812
|
+
this.videoElement.appendChild(track);
|
|
813
|
+
const textTrack = track.track;
|
|
814
|
+
textTrack.mode = "hidden";
|
|
815
|
+
setTimeout(() => {
|
|
816
|
+
textTrack.mode = "disabled";
|
|
817
|
+
}, 100);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
return newSubtitles;
|
|
821
|
+
}
|
|
822
|
+
removeSubtitle(id) {
|
|
823
|
+
const index = this.records.findIndex((r) => r.subtitle.id === id);
|
|
824
|
+
if (index === -1) return false;
|
|
825
|
+
const { subtitle } = this.records[index];
|
|
826
|
+
if (this.videoElement) {
|
|
827
|
+
const tracks = this.videoElement.querySelectorAll("track");
|
|
828
|
+
for (const track of tracks) {
|
|
829
|
+
if (track.getAttribute("data-id") === id) {
|
|
830
|
+
track.remove();
|
|
831
|
+
break;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
if (subtitle.url && subtitle.url.startsWith("blob:")) {
|
|
836
|
+
URL.revokeObjectURL(subtitle.url);
|
|
837
|
+
}
|
|
838
|
+
this.records.splice(index, 1);
|
|
839
|
+
return true;
|
|
840
|
+
}
|
|
841
|
+
switchSubtitle(id) {
|
|
842
|
+
if (!this.videoElement) return;
|
|
843
|
+
const tracks = this.videoElement.textTracks;
|
|
844
|
+
for (let i = 0; i < tracks.length; i++) {
|
|
845
|
+
tracks[i].mode = "disabled";
|
|
846
|
+
}
|
|
847
|
+
if (id === "-1") return;
|
|
848
|
+
const trackElements = this.videoElement.querySelectorAll("track");
|
|
849
|
+
for (let i = 0; i < trackElements.length; i++) {
|
|
850
|
+
const trackElement = trackElements[i];
|
|
851
|
+
if (trackElement.getAttribute("data-id") === id) {
|
|
852
|
+
const textTrack = trackElement.track;
|
|
853
|
+
if (textTrack) {
|
|
854
|
+
if (trackElement.readyState === 2) {
|
|
855
|
+
textTrack.mode = "hidden";
|
|
856
|
+
} else {
|
|
857
|
+
const onLoad = () => {
|
|
858
|
+
textTrack.mode = "hidden";
|
|
859
|
+
trackElement.removeEventListener("load", onLoad);
|
|
860
|
+
};
|
|
861
|
+
trackElement.addEventListener("load", onLoad);
|
|
862
|
+
textTrack.mode = "hidden";
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
break;
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
/**
|
|
870
|
+
* Sets a time offset (in seconds) for a VTT/SRT subtitle.
|
|
871
|
+
* Regenerates the blob URL with shifted timestamps and updates the track element.
|
|
872
|
+
*/
|
|
873
|
+
async setOffset(id, offsetSeconds) {
|
|
874
|
+
const record = this.records.find((r) => r.subtitle.id === id);
|
|
875
|
+
if (!record || record.rawVtt === void 0) return;
|
|
876
|
+
if (record.offset === offsetSeconds) return;
|
|
877
|
+
record.offset = offsetSeconds;
|
|
878
|
+
if (record.subtitle.url && record.subtitle.url.startsWith("blob:")) {
|
|
879
|
+
URL.revokeObjectURL(record.subtitle.url);
|
|
880
|
+
}
|
|
881
|
+
const shifted = applyOffsetToVtt(record.rawVtt, offsetSeconds);
|
|
882
|
+
const blob = new Blob([shifted], { type: "text/vtt" });
|
|
883
|
+
const newUrl = URL.createObjectURL(blob);
|
|
884
|
+
record.subtitle.url = newUrl;
|
|
885
|
+
if (this.videoElement) {
|
|
886
|
+
const trackElements = this.videoElement.querySelectorAll("track");
|
|
887
|
+
for (const trackEl of trackElements) {
|
|
888
|
+
if (trackEl.getAttribute("data-id") === id) {
|
|
889
|
+
trackEl.src = newUrl;
|
|
890
|
+
break;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
/** Returns parsed cue index for a subtitle (empty array for ASS/SSA). */
|
|
896
|
+
getCues(id) {
|
|
897
|
+
return this.records.find((r) => r.subtitle.id === id)?.cues ?? [];
|
|
898
|
+
}
|
|
899
|
+
/** Returns cues for all loaded VTT/SRT subtitles merged, for global search. */
|
|
900
|
+
getAllCues() {
|
|
901
|
+
return this.records.flatMap((r) => r.cues);
|
|
902
|
+
}
|
|
903
|
+
getSubtitles() {
|
|
904
|
+
return this.records.map((r) => r.subtitle);
|
|
905
|
+
}
|
|
906
|
+
clearSubtitles() {
|
|
907
|
+
for (const { subtitle } of this.records) {
|
|
908
|
+
if (subtitle.url && subtitle.url.startsWith("blob:")) {
|
|
909
|
+
URL.revokeObjectURL(subtitle.url);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
if (this.videoElement) {
|
|
913
|
+
const tracks = this.videoElement.querySelectorAll("track");
|
|
914
|
+
tracks.forEach((track) => track.remove());
|
|
915
|
+
}
|
|
916
|
+
this.records = [];
|
|
917
|
+
}
|
|
918
|
+
destroy() {
|
|
919
|
+
this.clearSubtitles();
|
|
920
|
+
this.videoElement = null;
|
|
921
|
+
}
|
|
922
|
+
importSubtitles(subtitles) {
|
|
923
|
+
this.records = subtitles.map((s) => ({
|
|
924
|
+
subtitle: s,
|
|
925
|
+
rawVtt: void 0,
|
|
926
|
+
offset: 0,
|
|
927
|
+
cues: []
|
|
928
|
+
}));
|
|
929
|
+
this.nextId = Math.max(...subtitles.map((s) => parseInt(s.id)), 0) + 1;
|
|
930
|
+
}
|
|
931
|
+
};
|
|
932
|
+
function assColorToCss(color, alphaHex) {
|
|
933
|
+
const hex = color.replace("&H", "").padStart(8, "0");
|
|
934
|
+
const a = parseInt(hex.slice(0, 2), 16);
|
|
935
|
+
const b = parseInt(hex.slice(2, 4), 16);
|
|
936
|
+
const g = parseInt(hex.slice(4, 6), 16);
|
|
937
|
+
const r = parseInt(hex.slice(6, 8), 16);
|
|
938
|
+
const alpha = 1 - a / 255;
|
|
939
|
+
return `rgba(${r},${g},${b},${alpha.toFixed(3)})`;
|
|
940
|
+
}
|
|
941
|
+
function alignmentToPosition(alignment, canvasWidth, canvasHeight, marginL, marginR, marginV) {
|
|
942
|
+
const col = (alignment - 1) % 3;
|
|
943
|
+
const row = Math.floor((alignment - 1) / 3);
|
|
944
|
+
let x;
|
|
945
|
+
const textAlign = col === 0 ? "left" : col === 1 ? "center" : "right";
|
|
946
|
+
if (col === 0) x = marginL;
|
|
947
|
+
else if (col === 1) x = canvasWidth / 2;
|
|
948
|
+
else x = canvasWidth - marginR;
|
|
949
|
+
let y;
|
|
950
|
+
const textBaseline = row === 0 ? "bottom" : row === 1 ? "middle" : "top";
|
|
951
|
+
if (row === 0) y = canvasHeight - marginV;
|
|
952
|
+
else if (row === 1) y = canvasHeight / 2;
|
|
953
|
+
else y = marginV;
|
|
954
|
+
return { x, y, textAlign, textBaseline };
|
|
955
|
+
}
|
|
956
|
+
var ASSRenderer = class {
|
|
957
|
+
constructor(canvas) {
|
|
958
|
+
this.compiled = null;
|
|
959
|
+
this.animFrame = null;
|
|
960
|
+
this.canvas = canvas;
|
|
961
|
+
this.ctx = canvas.getContext("2d");
|
|
962
|
+
}
|
|
963
|
+
load(assText) {
|
|
964
|
+
try {
|
|
965
|
+
this.compiled = assCompiler.compile(assText, {});
|
|
966
|
+
} catch (err) {
|
|
967
|
+
console.error("ASSRenderer: failed to compile ASS file", err);
|
|
968
|
+
this.compiled = null;
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
start(videoEl) {
|
|
972
|
+
this.stop();
|
|
973
|
+
const loop = () => {
|
|
974
|
+
this.syncCanvasSize(videoEl);
|
|
975
|
+
this.renderAt(videoEl.currentTime);
|
|
976
|
+
this.animFrame = requestAnimationFrame(loop);
|
|
977
|
+
};
|
|
978
|
+
this.animFrame = requestAnimationFrame(loop);
|
|
979
|
+
}
|
|
980
|
+
stop() {
|
|
981
|
+
if (this.animFrame !== null) {
|
|
982
|
+
cancelAnimationFrame(this.animFrame);
|
|
983
|
+
this.animFrame = null;
|
|
984
|
+
}
|
|
985
|
+
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
|
986
|
+
}
|
|
987
|
+
renderAt(currentTimeSec) {
|
|
988
|
+
if (!this.compiled) return;
|
|
989
|
+
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
|
990
|
+
const active = this.compiled.dialogues.filter(
|
|
991
|
+
(d) => d.start <= currentTimeSec && d.end >= currentTimeSec
|
|
992
|
+
);
|
|
993
|
+
for (const dialogue of active) {
|
|
994
|
+
this.drawDialogue(dialogue);
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
syncCanvasSize(videoEl) {
|
|
998
|
+
const rect = videoEl.getBoundingClientRect();
|
|
999
|
+
if (this.canvas.width !== rect.width || this.canvas.height !== rect.height) {
|
|
1000
|
+
this.canvas.width = rect.width;
|
|
1001
|
+
this.canvas.height = rect.height;
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
drawDialogue(dialogue) {
|
|
1005
|
+
if (!this.compiled) return;
|
|
1006
|
+
const styleName = dialogue.style;
|
|
1007
|
+
const styleObj = this.compiled.styles[styleName] ?? this.compiled.styles["Default"];
|
|
1008
|
+
if (!styleObj) return;
|
|
1009
|
+
const style = styleObj.style;
|
|
1010
|
+
const tag = styleObj.tag;
|
|
1011
|
+
const scaleX = this.compiled.width > 0 ? this.canvas.width / this.compiled.width : 1;
|
|
1012
|
+
const scaleY = this.compiled.height > 0 ? this.canvas.height / this.compiled.height : 1;
|
|
1013
|
+
const scale = Math.min(scaleX, scaleY);
|
|
1014
|
+
const fontSize = Math.round(style.Fontsize * scale);
|
|
1015
|
+
const fontWeight = style.Bold === -1 ? "bold" : "normal";
|
|
1016
|
+
const fontStyle = style.Italic === -1 ? "italic" : "normal";
|
|
1017
|
+
this.ctx.font = `${fontStyle} ${fontWeight} ${fontSize}px "${style.Fontname}", sans-serif`;
|
|
1018
|
+
const primaryColor = assColorToCss(style.PrimaryColour, tag.a1 ?? "00");
|
|
1019
|
+
const outlineColor = assColorToCss(style.OutlineColour, tag.a3 ?? "00");
|
|
1020
|
+
const outlineWidth = style.Outline * scale;
|
|
1021
|
+
const text = dialogue.slices.flatMap((slice) => slice.fragments.map((f) => f.text)).join("");
|
|
1022
|
+
if (!text.trim()) return;
|
|
1023
|
+
let x;
|
|
1024
|
+
let y;
|
|
1025
|
+
let textAlign;
|
|
1026
|
+
let textBaseline;
|
|
1027
|
+
if (dialogue.pos) {
|
|
1028
|
+
x = dialogue.pos.x * scaleX;
|
|
1029
|
+
y = dialogue.pos.y * scaleY;
|
|
1030
|
+
textAlign = "left";
|
|
1031
|
+
textBaseline = "top";
|
|
1032
|
+
} else {
|
|
1033
|
+
const marginL = style.MarginL * scaleX;
|
|
1034
|
+
const marginR = style.MarginR * scaleX;
|
|
1035
|
+
const marginV = style.MarginV * scaleY;
|
|
1036
|
+
({ x, y, textAlign, textBaseline } = alignmentToPosition(
|
|
1037
|
+
dialogue.alignment,
|
|
1038
|
+
this.canvas.width,
|
|
1039
|
+
this.canvas.height,
|
|
1040
|
+
marginL,
|
|
1041
|
+
marginR,
|
|
1042
|
+
marginV
|
|
1043
|
+
));
|
|
1044
|
+
}
|
|
1045
|
+
this.ctx.textAlign = textAlign;
|
|
1046
|
+
this.ctx.textBaseline = textBaseline;
|
|
1047
|
+
const lines = text.split(/\\[Nn]|\n/);
|
|
1048
|
+
const lineHeight = fontSize * 1.2;
|
|
1049
|
+
const totalHeight = lines.length * lineHeight;
|
|
1050
|
+
let startY = y;
|
|
1051
|
+
if (textBaseline === "bottom") {
|
|
1052
|
+
startY = y - (lines.length - 1) * lineHeight;
|
|
1053
|
+
} else if (textBaseline === "middle") {
|
|
1054
|
+
startY = y - totalHeight / 2 + lineHeight / 2;
|
|
1055
|
+
}
|
|
1056
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1057
|
+
const lineY = startY + i * lineHeight;
|
|
1058
|
+
if (outlineWidth > 0) {
|
|
1059
|
+
this.ctx.strokeStyle = outlineColor;
|
|
1060
|
+
this.ctx.lineWidth = outlineWidth * 2;
|
|
1061
|
+
this.ctx.lineJoin = "round";
|
|
1062
|
+
this.ctx.strokeText(lines[i], x, lineY);
|
|
1063
|
+
}
|
|
1064
|
+
this.ctx.fillStyle = primaryColor;
|
|
1065
|
+
this.ctx.fillText(lines[i], x, lineY);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
};
|
|
1069
|
+
|
|
1070
|
+
// src/parsers/m3u-parser.ts
|
|
1071
|
+
function exportPlaylist(items) {
|
|
1072
|
+
const lines = ["#EXTM3U"];
|
|
1073
|
+
for (const item of items) {
|
|
1074
|
+
lines.push(`#EXTINF:-1,${item.name}`);
|
|
1075
|
+
lines.push(item.type === "stream" ? item.url : item.name);
|
|
1076
|
+
}
|
|
1077
|
+
const blob = new Blob([lines.join("\n")], {
|
|
1078
|
+
type: "application/vnd.apple.mpegurl"
|
|
1079
|
+
});
|
|
1080
|
+
const url = URL.createObjectURL(blob);
|
|
1081
|
+
const a = document.createElement("a");
|
|
1082
|
+
a.href = url;
|
|
1083
|
+
a.download = "lightbird-playlist.m3u8";
|
|
1084
|
+
a.click();
|
|
1085
|
+
URL.revokeObjectURL(url);
|
|
1086
|
+
}
|
|
1087
|
+
function parseM3U8(text) {
|
|
1088
|
+
const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
1089
|
+
const items = [];
|
|
1090
|
+
let nextName = null;
|
|
1091
|
+
for (const line of lines) {
|
|
1092
|
+
if (line.startsWith("#EXTINF:")) {
|
|
1093
|
+
nextName = line.split(",").slice(1).join(",").trim() || null;
|
|
1094
|
+
} else if (!line.startsWith("#")) {
|
|
1095
|
+
const isStream = line.startsWith("http");
|
|
1096
|
+
items.push({
|
|
1097
|
+
name: nextName ?? line,
|
|
1098
|
+
url: isStream ? line : "",
|
|
1099
|
+
type: isStream ? "stream" : "video"
|
|
1100
|
+
});
|
|
1101
|
+
nextName = null;
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
return items;
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
// src/utils/media-error.ts
|
|
1108
|
+
var MEDIA_ERR_ABORTED = 1;
|
|
1109
|
+
var MEDIA_ERR_NETWORK = 2;
|
|
1110
|
+
var MEDIA_ERR_DECODE = 3;
|
|
1111
|
+
var MEDIA_ERR_SRC_NOT_SUPPORTED = 4;
|
|
1112
|
+
function parseMediaError(error) {
|
|
1113
|
+
if (!error) {
|
|
1114
|
+
return { type: "unknown", message: "An unknown error occurred.", recoverable: true, retryable: true };
|
|
1115
|
+
}
|
|
1116
|
+
switch (error.code) {
|
|
1117
|
+
case MEDIA_ERR_ABORTED:
|
|
1118
|
+
return { type: "aborted", message: "Playback was aborted.", recoverable: true, retryable: false };
|
|
1119
|
+
case MEDIA_ERR_NETWORK:
|
|
1120
|
+
return { type: "network", message: "A network error interrupted loading. Check your connection.", recoverable: true, retryable: true };
|
|
1121
|
+
case MEDIA_ERR_DECODE:
|
|
1122
|
+
return { type: "decode", message: "The video could not be decoded. It may be corrupted.", recoverable: false, retryable: false };
|
|
1123
|
+
case MEDIA_ERR_SRC_NOT_SUPPORTED:
|
|
1124
|
+
return { type: "unsupported", message: "This format is not supported by your browser.", recoverable: false, retryable: false };
|
|
1125
|
+
default:
|
|
1126
|
+
return { type: "unknown", message: error.message || "An unexpected error occurred.", recoverable: true, retryable: true };
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
function validateFile(file) {
|
|
1130
|
+
const MAX_SIZE_BYTES = 10 * 1024 * 1024 * 1024;
|
|
1131
|
+
const SUPPORTED = ["mp4", "webm", "mkv", "mov", "avi", "wmv", "flv", "m4v", "ogv"];
|
|
1132
|
+
if (file.size > MAX_SIZE_BYTES) {
|
|
1133
|
+
return {
|
|
1134
|
+
valid: false,
|
|
1135
|
+
reason: `File is too large (${(file.size / 1e9).toFixed(1)} GB). Maximum is 10 GB.`
|
|
1136
|
+
};
|
|
1137
|
+
}
|
|
1138
|
+
const ext = file.name.split(".").pop()?.toLowerCase();
|
|
1139
|
+
if (!ext || !SUPPORTED.includes(ext)) {
|
|
1140
|
+
return { valid: false, reason: `"${ext}" is not a supported video format.` };
|
|
1141
|
+
}
|
|
1142
|
+
return { valid: true };
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
// src/utils/video-info.ts
|
|
1146
|
+
function detectContainerFromUrl(url) {
|
|
1147
|
+
const ext = url.split("?")[0].split(".").pop()?.toUpperCase();
|
|
1148
|
+
return ext ?? "Unknown";
|
|
1149
|
+
}
|
|
1150
|
+
function extractNativeMetadata(videoEl, file) {
|
|
1151
|
+
const container = file ? file.name.split(".").pop()?.toUpperCase() ?? "Unknown" : detectContainerFromUrl(videoEl.currentSrc);
|
|
1152
|
+
return {
|
|
1153
|
+
filename: file?.name ?? videoEl.currentSrc,
|
|
1154
|
+
fileSize: file?.size ?? null,
|
|
1155
|
+
duration: videoEl.duration || 0,
|
|
1156
|
+
container,
|
|
1157
|
+
width: videoEl.videoWidth,
|
|
1158
|
+
height: videoEl.videoHeight,
|
|
1159
|
+
frameRate: null,
|
|
1160
|
+
videoBitrate: null,
|
|
1161
|
+
videoCodec: null,
|
|
1162
|
+
colorSpace: null,
|
|
1163
|
+
audioTracks: [],
|
|
1164
|
+
subtitleTracks: []
|
|
1165
|
+
};
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
// src/utils/video-thumbnail.ts
|
|
1169
|
+
async function captureVideoThumbnail(videoEl, atSeconds = 5) {
|
|
1170
|
+
return new Promise((resolve) => {
|
|
1171
|
+
const savedTime = videoEl.currentTime;
|
|
1172
|
+
const canvas = document.createElement("canvas");
|
|
1173
|
+
canvas.width = 320;
|
|
1174
|
+
canvas.height = 180;
|
|
1175
|
+
const ctx = canvas.getContext("2d");
|
|
1176
|
+
if (!ctx) {
|
|
1177
|
+
resolve(null);
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
const cleanup = () => {
|
|
1181
|
+
videoEl.removeEventListener("seeked", onSeeked);
|
|
1182
|
+
videoEl.removeEventListener("error", onError);
|
|
1183
|
+
};
|
|
1184
|
+
const onSeeked = () => {
|
|
1185
|
+
try {
|
|
1186
|
+
ctx.drawImage(videoEl, 0, 0, 320, 180);
|
|
1187
|
+
resolve(canvas.toDataURL("image/jpeg", 0.7));
|
|
1188
|
+
} catch {
|
|
1189
|
+
resolve(null);
|
|
1190
|
+
} finally {
|
|
1191
|
+
videoEl.currentTime = savedTime;
|
|
1192
|
+
cleanup();
|
|
1193
|
+
}
|
|
1194
|
+
};
|
|
1195
|
+
const onError = () => {
|
|
1196
|
+
cleanup();
|
|
1197
|
+
resolve(null);
|
|
1198
|
+
};
|
|
1199
|
+
videoEl.addEventListener("seeked", onSeeked, { once: true });
|
|
1200
|
+
videoEl.addEventListener("error", onError, { once: true });
|
|
1201
|
+
try {
|
|
1202
|
+
videoEl.currentTime = Math.min(atSeconds, videoEl.duration || 0);
|
|
1203
|
+
} catch {
|
|
1204
|
+
cleanup();
|
|
1205
|
+
resolve(null);
|
|
1206
|
+
}
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
// src/utils/keyboard-shortcuts.ts
|
|
1211
|
+
var DEFAULT_SHORTCUTS = [
|
|
1212
|
+
{ action: "play-pause", label: "Play / Pause", defaultKey: " ", key: " " },
|
|
1213
|
+
{ action: "seek-forward-5", label: "Seek Forward 5s", defaultKey: "ArrowRight", key: "ArrowRight" },
|
|
1214
|
+
{ action: "seek-backward-5", label: "Seek Backward 5s", defaultKey: "ArrowLeft", key: "ArrowLeft" },
|
|
1215
|
+
{ action: "seek-forward-30", label: "Seek Forward 30s", defaultKey: "ArrowRight", key: "ArrowRight", modifiers: { shift: true } },
|
|
1216
|
+
{ action: "seek-backward-30", label: "Seek Backward 30s", defaultKey: "ArrowLeft", key: "ArrowLeft", modifiers: { shift: true } },
|
|
1217
|
+
{ action: "volume-up", label: "Volume Up", defaultKey: "ArrowUp", key: "ArrowUp" },
|
|
1218
|
+
{ action: "volume-down", label: "Volume Down", defaultKey: "ArrowDown", key: "ArrowDown" },
|
|
1219
|
+
{ action: "mute", label: "Toggle Mute", defaultKey: "m", key: "m" },
|
|
1220
|
+
{ action: "fullscreen", label: "Toggle Fullscreen", defaultKey: "f", key: "f" },
|
|
1221
|
+
{ action: "next-item", label: "Next in Playlist", defaultKey: "n", key: "n" },
|
|
1222
|
+
{ action: "prev-item", label: "Previous in Playlist", defaultKey: "p", key: "p" },
|
|
1223
|
+
{ action: "screenshot", label: "Screenshot", defaultKey: "s", key: "s", modifiers: { ctrl: true } },
|
|
1224
|
+
{ action: "show-shortcuts", label: "Show Shortcuts Help", defaultKey: "?", key: "?" },
|
|
1225
|
+
{ action: "next-chapter", label: "Next Chapter", defaultKey: "]", key: "]" },
|
|
1226
|
+
{ action: "prev-chapter", label: "Previous Chapter", defaultKey: "[", key: "[" }
|
|
1227
|
+
];
|
|
1228
|
+
var STORAGE_KEY = "lightbird-shortcuts";
|
|
1229
|
+
function loadShortcuts() {
|
|
1230
|
+
try {
|
|
1231
|
+
const saved = localStorage.getItem(STORAGE_KEY);
|
|
1232
|
+
if (!saved) return DEFAULT_SHORTCUTS;
|
|
1233
|
+
const overrides = JSON.parse(saved);
|
|
1234
|
+
return DEFAULT_SHORTCUTS.map((s) => ({
|
|
1235
|
+
...s,
|
|
1236
|
+
key: overrides[s.action] ?? s.defaultKey
|
|
1237
|
+
}));
|
|
1238
|
+
} catch {
|
|
1239
|
+
return DEFAULT_SHORTCUTS;
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
function saveShortcuts(bindings) {
|
|
1243
|
+
const overrides = {};
|
|
1244
|
+
for (const b of bindings) {
|
|
1245
|
+
if (b.key !== b.defaultKey) overrides[b.action] = b.key;
|
|
1246
|
+
}
|
|
1247
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(overrides));
|
|
1248
|
+
}
|
|
1249
|
+
function matchesShortcut(e, binding) {
|
|
1250
|
+
const keyMatches = e.key === binding.key || e.key.toLowerCase() === binding.key.toLowerCase();
|
|
1251
|
+
if (!keyMatches) return false;
|
|
1252
|
+
if (!!binding.modifiers?.ctrl !== e.ctrlKey) return false;
|
|
1253
|
+
if (!!binding.modifiers?.shift !== e.shiftKey) return false;
|
|
1254
|
+
if (!!binding.modifiers?.alt !== e.altKey) return false;
|
|
1255
|
+
return true;
|
|
1256
|
+
}
|
|
1257
|
+
function isInteractiveElement(el) {
|
|
1258
|
+
if (!el || !(el instanceof HTMLElement)) return false;
|
|
1259
|
+
const tag = el.tagName.toLowerCase();
|
|
1260
|
+
return ["input", "textarea", "select", "button", "a"].includes(tag) || el.contentEditable === "true" || el.getAttribute("contenteditable") !== null;
|
|
1261
|
+
}
|
|
1262
|
+
function formatShortcutKey(binding) {
|
|
1263
|
+
const mods = [];
|
|
1264
|
+
if (binding.modifiers?.ctrl) mods.push("Ctrl");
|
|
1265
|
+
if (binding.modifiers?.shift) mods.push("Shift");
|
|
1266
|
+
if (binding.modifiers?.alt) mods.push("Alt");
|
|
1267
|
+
const key = binding.key === " " ? "Space" : binding.key;
|
|
1268
|
+
return [...mods, key].join(" + ");
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
// src/utils/progress-estimator.ts
|
|
1272
|
+
var ProgressEstimator = class {
|
|
1273
|
+
constructor(fileSizeBytes) {
|
|
1274
|
+
this.startTime = null;
|
|
1275
|
+
// set lazily on first real progress
|
|
1276
|
+
this.lastProgress = 0;
|
|
1277
|
+
this.fileSizeBytes = fileSizeBytes;
|
|
1278
|
+
}
|
|
1279
|
+
update(progress) {
|
|
1280
|
+
if (!Number.isFinite(progress)) return;
|
|
1281
|
+
progress = Math.min(1, Math.max(0, progress));
|
|
1282
|
+
if (progress > 0 && this.startTime === null) {
|
|
1283
|
+
this.startTime = Date.now();
|
|
1284
|
+
}
|
|
1285
|
+
this.lastProgress = progress;
|
|
1286
|
+
}
|
|
1287
|
+
getEstimate() {
|
|
1288
|
+
if (this.startTime === null || this.lastProgress <= 0) {
|
|
1289
|
+
return { speedMBps: 0, etaSeconds: null };
|
|
1290
|
+
}
|
|
1291
|
+
const elapsedMs = Date.now() - this.startTime;
|
|
1292
|
+
if (elapsedMs < 500) {
|
|
1293
|
+
return { speedMBps: 0, etaSeconds: null };
|
|
1294
|
+
}
|
|
1295
|
+
const elapsedSeconds = elapsedMs / 1e3;
|
|
1296
|
+
const bytesProcessed = this.fileSizeBytes * this.lastProgress;
|
|
1297
|
+
const speedMBps = bytesProcessed / elapsedSeconds / (1024 * 1024);
|
|
1298
|
+
const bytesRemaining = this.fileSizeBytes * (1 - this.lastProgress);
|
|
1299
|
+
const etaSeconds = speedMBps > 0 ? bytesRemaining / (speedMBps * 1024 * 1024) : null;
|
|
1300
|
+
return {
|
|
1301
|
+
speedMBps: Math.round(speedMBps * 10) / 10,
|
|
1302
|
+
// 1 decimal place
|
|
1303
|
+
etaSeconds: etaSeconds !== null ? Math.round(etaSeconds) : null
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
// No reset() method — create a new instance per file load instead.
|
|
1307
|
+
};
|
|
1308
|
+
var instance = null;
|
|
1309
|
+
var loading = null;
|
|
1310
|
+
var defaultCDN = "https://unpkg.com/@ffmpeg/core@0.12.10/dist/umd";
|
|
1311
|
+
async function getFFmpeg() {
|
|
1312
|
+
if (instance) return instance;
|
|
1313
|
+
if (loading) return loading;
|
|
1314
|
+
loading = (async () => {
|
|
1315
|
+
const ffmpeg$1 = new ffmpeg.FFmpeg();
|
|
1316
|
+
const baseURL = getConfig().ffmpegCDN || defaultCDN;
|
|
1317
|
+
await ffmpeg$1.load({
|
|
1318
|
+
coreURL: await util.toBlobURL(`${baseURL}/ffmpeg-core.js`, "text/javascript"),
|
|
1319
|
+
wasmURL: await util.toBlobURL(`${baseURL}/ffmpeg-core.wasm`, "application/wasm")
|
|
1320
|
+
});
|
|
1321
|
+
instance = ffmpeg$1;
|
|
1322
|
+
return ffmpeg$1;
|
|
1323
|
+
})();
|
|
1324
|
+
return loading;
|
|
1325
|
+
}
|
|
1326
|
+
function resetFFmpeg() {
|
|
1327
|
+
instance = null;
|
|
1328
|
+
loading = null;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
exports.ASSRenderer = ASSRenderer;
|
|
1332
|
+
exports.CancellationError = CancellationError;
|
|
1333
|
+
exports.DEFAULT_SHORTCUTS = DEFAULT_SHORTCUTS;
|
|
1334
|
+
exports.MKVPlayer = MKVPlayer;
|
|
1335
|
+
exports.ProgressEstimator = ProgressEstimator;
|
|
1336
|
+
exports.SimplePlayer = SimplePlayer;
|
|
1337
|
+
exports.SubtitleConverter = SubtitleConverter;
|
|
1338
|
+
exports.UniversalSubtitleManager = UniversalSubtitleManager;
|
|
1339
|
+
exports.applyOffsetToVtt = applyOffsetToVtt;
|
|
1340
|
+
exports.captureVideoThumbnail = captureVideoThumbnail;
|
|
1341
|
+
exports.configureLightBird = configureLightBird;
|
|
1342
|
+
exports.createOffsetVttUrl = createOffsetVttUrl;
|
|
1343
|
+
exports.createVideoPlayer = createVideoPlayer;
|
|
1344
|
+
exports.exportPlaylist = exportPlaylist;
|
|
1345
|
+
exports.extractNativeMetadata = extractNativeMetadata;
|
|
1346
|
+
exports.formatShortcutKey = formatShortcutKey;
|
|
1347
|
+
exports.getFFmpeg = getFFmpeg;
|
|
1348
|
+
exports.isInteractiveElement = isInteractiveElement;
|
|
1349
|
+
exports.loadShortcuts = loadShortcuts;
|
|
1350
|
+
exports.matchesShortcut = matchesShortcut;
|
|
1351
|
+
exports.parseChaptersFromFFmpegLog = parseChaptersFromFFmpegLog;
|
|
1352
|
+
exports.parseChaptersFromVtt = parseChaptersFromVtt;
|
|
1353
|
+
exports.parseM3U8 = parseM3U8;
|
|
1354
|
+
exports.parseMediaError = parseMediaError;
|
|
1355
|
+
exports.resetFFmpeg = resetFFmpeg;
|
|
1356
|
+
exports.saveShortcuts = saveShortcuts;
|
|
1357
|
+
exports.validateFile = validateFile;
|