@lightbird/core 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +246 -43
- package/dist/index.d.cts +25 -1
- package/dist/index.d.ts +25 -1
- package/dist/index.js +245 -44
- package/dist/react/index.cjs +313 -0
- package/dist/react/index.d.cts +101 -1
- package/dist/react/index.d.ts +101 -1
- package/dist/react/index.js +311 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -236,6 +236,121 @@ function parseVttTimestamp(ts) {
|
|
|
236
236
|
return NaN;
|
|
237
237
|
}
|
|
238
238
|
|
|
239
|
+
// src/utils/language-names.ts
|
|
240
|
+
var LANGUAGE_NAMES = {
|
|
241
|
+
// English
|
|
242
|
+
en: "English",
|
|
243
|
+
eng: "English",
|
|
244
|
+
// Japanese
|
|
245
|
+
ja: "Japanese",
|
|
246
|
+
jpn: "Japanese",
|
|
247
|
+
// Chinese
|
|
248
|
+
zh: "Chinese",
|
|
249
|
+
chi: "Chinese",
|
|
250
|
+
zho: "Chinese",
|
|
251
|
+
// Korean
|
|
252
|
+
ko: "Korean",
|
|
253
|
+
kor: "Korean",
|
|
254
|
+
// French
|
|
255
|
+
fr: "French",
|
|
256
|
+
fre: "French",
|
|
257
|
+
fra: "French",
|
|
258
|
+
// German
|
|
259
|
+
de: "German",
|
|
260
|
+
ger: "German",
|
|
261
|
+
deu: "German",
|
|
262
|
+
// Spanish
|
|
263
|
+
es: "Spanish",
|
|
264
|
+
spa: "Spanish",
|
|
265
|
+
// Italian
|
|
266
|
+
it: "Italian",
|
|
267
|
+
ita: "Italian",
|
|
268
|
+
// Portuguese
|
|
269
|
+
pt: "Portuguese",
|
|
270
|
+
por: "Portuguese",
|
|
271
|
+
// Russian
|
|
272
|
+
ru: "Russian",
|
|
273
|
+
rus: "Russian",
|
|
274
|
+
// Dutch
|
|
275
|
+
nl: "Dutch",
|
|
276
|
+
dut: "Dutch",
|
|
277
|
+
nld: "Dutch",
|
|
278
|
+
// Polish
|
|
279
|
+
pl: "Polish",
|
|
280
|
+
pol: "Polish",
|
|
281
|
+
// Arabic
|
|
282
|
+
ar: "Arabic",
|
|
283
|
+
ara: "Arabic",
|
|
284
|
+
// Hindi
|
|
285
|
+
hi: "Hindi",
|
|
286
|
+
hin: "Hindi",
|
|
287
|
+
// Bengali
|
|
288
|
+
bn: "Bengali",
|
|
289
|
+
ben: "Bengali",
|
|
290
|
+
// Turkish
|
|
291
|
+
tr: "Turkish",
|
|
292
|
+
tur: "Turkish",
|
|
293
|
+
// Swedish
|
|
294
|
+
sv: "Swedish",
|
|
295
|
+
swe: "Swedish",
|
|
296
|
+
// Norwegian
|
|
297
|
+
no: "Norwegian",
|
|
298
|
+
nor: "Norwegian",
|
|
299
|
+
// Danish
|
|
300
|
+
da: "Danish",
|
|
301
|
+
dan: "Danish",
|
|
302
|
+
// Finnish
|
|
303
|
+
fi: "Finnish",
|
|
304
|
+
fin: "Finnish",
|
|
305
|
+
// Greek
|
|
306
|
+
el: "Greek",
|
|
307
|
+
gre: "Greek",
|
|
308
|
+
ell: "Greek",
|
|
309
|
+
// Hebrew
|
|
310
|
+
he: "Hebrew",
|
|
311
|
+
heb: "Hebrew",
|
|
312
|
+
// Thai
|
|
313
|
+
th: "Thai",
|
|
314
|
+
tha: "Thai",
|
|
315
|
+
// Vietnamese
|
|
316
|
+
vi: "Vietnamese",
|
|
317
|
+
vie: "Vietnamese",
|
|
318
|
+
// Indonesian
|
|
319
|
+
id: "Indonesian",
|
|
320
|
+
ind: "Indonesian",
|
|
321
|
+
// Malay
|
|
322
|
+
ms: "Malay",
|
|
323
|
+
may: "Malay",
|
|
324
|
+
msa: "Malay",
|
|
325
|
+
// Czech
|
|
326
|
+
cs: "Czech",
|
|
327
|
+
cze: "Czech",
|
|
328
|
+
ces: "Czech",
|
|
329
|
+
// Hungarian
|
|
330
|
+
hu: "Hungarian",
|
|
331
|
+
hun: "Hungarian",
|
|
332
|
+
// Romanian
|
|
333
|
+
ro: "Romanian",
|
|
334
|
+
rum: "Romanian",
|
|
335
|
+
ron: "Romanian",
|
|
336
|
+
// Ukrainian
|
|
337
|
+
uk: "Ukrainian",
|
|
338
|
+
ukr: "Ukrainian",
|
|
339
|
+
// Tamil
|
|
340
|
+
ta: "Tamil",
|
|
341
|
+
tam: "Tamil",
|
|
342
|
+
// Telugu
|
|
343
|
+
te: "Telugu",
|
|
344
|
+
tel: "Telugu"
|
|
345
|
+
};
|
|
346
|
+
var UNDETERMINED = /* @__PURE__ */ new Set(["", "und", "unknown", "mis", "zxx", "mul"]);
|
|
347
|
+
function getLanguageName(code) {
|
|
348
|
+
if (!code) return void 0;
|
|
349
|
+
const normalized = code.trim().toLowerCase();
|
|
350
|
+
if (UNDETERMINED.has(normalized)) return void 0;
|
|
351
|
+
return LANGUAGE_NAMES[normalized] ?? code.trim();
|
|
352
|
+
}
|
|
353
|
+
|
|
239
354
|
// src/players/mkv-player.ts
|
|
240
355
|
async function canPlayNatively(objectUrl, timeoutMs = 3e3) {
|
|
241
356
|
return new Promise((resolve) => {
|
|
@@ -264,23 +379,76 @@ function parseStreamInfo(logs) {
|
|
|
264
379
|
const videoTracks = [];
|
|
265
380
|
const audioTracks = [];
|
|
266
381
|
const subtitleTracks = [];
|
|
382
|
+
let current = null;
|
|
267
383
|
const lines = logs.split("\n");
|
|
268
384
|
for (const line of lines) {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
385
|
+
if (/^\s*Stream #\d+:\d+/.test(line)) {
|
|
386
|
+
current = null;
|
|
387
|
+
}
|
|
388
|
+
const streamMatch = line.match(
|
|
389
|
+
/Stream #\d+:\d+(?:\((\w+)\))?: (Video|Audio|Subtitle): (.+)$/i
|
|
390
|
+
);
|
|
391
|
+
if (streamMatch) {
|
|
392
|
+
const [, lang, type, rest] = streamMatch;
|
|
393
|
+
const codec = rest.trim().split(/[\s,]/)[0];
|
|
394
|
+
const forced = /\(forced\)/i.test(rest);
|
|
395
|
+
const isDefault = /\(default\)/i.test(rest);
|
|
396
|
+
const t = type.toLowerCase();
|
|
397
|
+
const bucket = t === "video" ? videoTracks : t === "audio" ? audioTracks : subtitleTracks;
|
|
398
|
+
current = {
|
|
399
|
+
index: bucket.length,
|
|
400
|
+
type: t,
|
|
401
|
+
codec,
|
|
402
|
+
lang,
|
|
403
|
+
forced,
|
|
404
|
+
default: isDefault
|
|
405
|
+
};
|
|
406
|
+
bucket.push(current);
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
if (/^\s*(Chapter #|Program )/i.test(line)) {
|
|
410
|
+
current = null;
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
if (current && current.title === void 0) {
|
|
414
|
+
const titleMatch = line.match(/^\s*title\s*:\s*(.+)$/i);
|
|
415
|
+
if (titleMatch) {
|
|
416
|
+
current.title = titleMatch[1].trim();
|
|
417
|
+
}
|
|
280
418
|
}
|
|
281
419
|
}
|
|
282
420
|
return { videoTracks, audioTracks, subtitleTracks };
|
|
283
421
|
}
|
|
422
|
+
function formatTrackName(index, t) {
|
|
423
|
+
let label = t.title?.trim() || `Track ${index + 1}`;
|
|
424
|
+
const langName = getLanguageName(t.lang);
|
|
425
|
+
if (langName) label += ` - [${langName}]`;
|
|
426
|
+
if (t.forced) label += " [Forced]";
|
|
427
|
+
return label;
|
|
428
|
+
}
|
|
429
|
+
function buildAudioTracks(tracks) {
|
|
430
|
+
if (tracks.length === 0) {
|
|
431
|
+
return [{ id: "0", name: "Default Audio", lang: "unknown" }];
|
|
432
|
+
}
|
|
433
|
+
return tracks.map((t, i) => ({
|
|
434
|
+
id: String(i),
|
|
435
|
+
name: formatTrackName(i, t),
|
|
436
|
+
lang: t.lang ?? "unknown"
|
|
437
|
+
}));
|
|
438
|
+
}
|
|
439
|
+
function buildSubtitleTracks(tracks, trackMap) {
|
|
440
|
+
trackMap.clear();
|
|
441
|
+
return tracks.map((t, i) => {
|
|
442
|
+
const id = String(i);
|
|
443
|
+
trackMap.set(id, i);
|
|
444
|
+
return {
|
|
445
|
+
id,
|
|
446
|
+
name: formatTrackName(i, t),
|
|
447
|
+
lang: t.lang ?? "unknown",
|
|
448
|
+
type: "embedded"
|
|
449
|
+
};
|
|
450
|
+
});
|
|
451
|
+
}
|
|
284
452
|
var _MKVPlayer = class _MKVPlayer {
|
|
285
453
|
constructor(file, onProgress) {
|
|
286
454
|
this.videoElement = null;
|
|
@@ -399,22 +567,11 @@ var _MKVPlayer = class _MKVPlayer {
|
|
|
399
567
|
});
|
|
400
568
|
const { audioTracks, subtitleTracks } = parseStreamInfo(result.logs);
|
|
401
569
|
this.chapters = parseChaptersFromFFmpegLog(result.logs, videoElement.duration || 0);
|
|
402
|
-
this.playerFile.audioTracks = audioTracks
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
this.subtitleTrackMap.clear();
|
|
408
|
-
this.playerFile.subtitleTracks = subtitleTracks.map((t, i) => {
|
|
409
|
-
const id = String(i);
|
|
410
|
-
this.subtitleTrackMap.set(id, i);
|
|
411
|
-
return {
|
|
412
|
-
id,
|
|
413
|
-
name: t.title ?? (t.lang ? `Subtitle ${i + 1} (${t.lang})` : `Subtitle ${i + 1}`),
|
|
414
|
-
lang: t.lang ?? "unknown",
|
|
415
|
-
type: "embedded"
|
|
416
|
-
};
|
|
417
|
-
});
|
|
570
|
+
this.playerFile.audioTracks = buildAudioTracks(audioTracks);
|
|
571
|
+
this.playerFile.subtitleTracks = buildSubtitleTracks(
|
|
572
|
+
subtitleTracks,
|
|
573
|
+
this.subtitleTrackMap
|
|
574
|
+
);
|
|
418
575
|
const blob = new Blob([result.data], { type: "video/mp4" });
|
|
419
576
|
const url = URL.createObjectURL(blob);
|
|
420
577
|
this.remuxCache.set(0, url);
|
|
@@ -445,22 +602,11 @@ var _MKVPlayer = class _MKVPlayer {
|
|
|
445
602
|
});
|
|
446
603
|
if (this._cancelled) return;
|
|
447
604
|
const { audioTracks, subtitleTracks } = parseStreamInfo(probeResult.logs);
|
|
448
|
-
this.playerFile.audioTracks = audioTracks
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
this.subtitleTrackMap.clear();
|
|
454
|
-
this.playerFile.subtitleTracks = subtitleTracks.map((t, i) => {
|
|
455
|
-
const id = String(i);
|
|
456
|
-
this.subtitleTrackMap.set(id, i);
|
|
457
|
-
return {
|
|
458
|
-
id,
|
|
459
|
-
name: t.title ?? (t.lang ? `Subtitle ${i + 1} (${t.lang})` : `Subtitle ${i + 1}`),
|
|
460
|
-
lang: t.lang ?? "unknown",
|
|
461
|
-
type: "embedded"
|
|
462
|
-
};
|
|
463
|
-
});
|
|
605
|
+
this.playerFile.audioTracks = buildAudioTracks(audioTracks);
|
|
606
|
+
this.playerFile.subtitleTracks = buildSubtitleTracks(
|
|
607
|
+
subtitleTracks,
|
|
608
|
+
this.subtitleTrackMap
|
|
609
|
+
);
|
|
464
610
|
}
|
|
465
611
|
async _remux(audioTrackIndex) {
|
|
466
612
|
const cached = this.remuxCache.get(audioTrackIndex);
|
|
@@ -1409,6 +1555,61 @@ async function captureVideoThumbnail(videoEl, atSeconds = 5) {
|
|
|
1409
1555
|
}
|
|
1410
1556
|
});
|
|
1411
1557
|
}
|
|
1558
|
+
async function captureFrameAt(videoEl, timeSeconds, width = 160, height = 90) {
|
|
1559
|
+
return new Promise((resolve) => {
|
|
1560
|
+
const canvas = document.createElement("canvas");
|
|
1561
|
+
canvas.width = width;
|
|
1562
|
+
canvas.height = height;
|
|
1563
|
+
const ctx = canvas.getContext("2d");
|
|
1564
|
+
if (!ctx) {
|
|
1565
|
+
resolve(null);
|
|
1566
|
+
return;
|
|
1567
|
+
}
|
|
1568
|
+
let settled = false;
|
|
1569
|
+
const cleanup = () => {
|
|
1570
|
+
videoEl.removeEventListener("seeked", onSeeked);
|
|
1571
|
+
videoEl.removeEventListener("error", onError);
|
|
1572
|
+
videoEl.removeEventListener("loadedmetadata", onLoadedMetadata);
|
|
1573
|
+
};
|
|
1574
|
+
const finish = (value) => {
|
|
1575
|
+
if (settled) return;
|
|
1576
|
+
settled = true;
|
|
1577
|
+
cleanup();
|
|
1578
|
+
resolve(value);
|
|
1579
|
+
};
|
|
1580
|
+
const draw = () => {
|
|
1581
|
+
try {
|
|
1582
|
+
ctx.drawImage(videoEl, 0, 0, width, height);
|
|
1583
|
+
finish(canvas.toDataURL("image/jpeg", 0.6));
|
|
1584
|
+
} catch {
|
|
1585
|
+
finish(null);
|
|
1586
|
+
}
|
|
1587
|
+
};
|
|
1588
|
+
const seekTo = () => {
|
|
1589
|
+
const duration = videoEl.duration || 0;
|
|
1590
|
+
const target = duration > 0 ? Math.max(0, Math.min(timeSeconds, duration)) : Math.max(0, timeSeconds);
|
|
1591
|
+
if (Math.abs(videoEl.currentTime - target) < 0.05) {
|
|
1592
|
+
draw();
|
|
1593
|
+
return;
|
|
1594
|
+
}
|
|
1595
|
+
try {
|
|
1596
|
+
videoEl.currentTime = target;
|
|
1597
|
+
} catch {
|
|
1598
|
+
finish(null);
|
|
1599
|
+
}
|
|
1600
|
+
};
|
|
1601
|
+
const onSeeked = () => draw();
|
|
1602
|
+
const onError = () => finish(null);
|
|
1603
|
+
const onLoadedMetadata = () => seekTo();
|
|
1604
|
+
videoEl.addEventListener("seeked", onSeeked);
|
|
1605
|
+
videoEl.addEventListener("error", onError);
|
|
1606
|
+
if (videoEl.readyState >= 1) {
|
|
1607
|
+
seekTo();
|
|
1608
|
+
} else {
|
|
1609
|
+
videoEl.addEventListener("loadedmetadata", onLoadedMetadata, { once: true });
|
|
1610
|
+
}
|
|
1611
|
+
});
|
|
1612
|
+
}
|
|
1412
1613
|
|
|
1413
1614
|
// src/utils/keyboard-shortcuts.ts
|
|
1414
1615
|
var DEFAULT_SHORTCUTS = [
|
|
@@ -1537,4 +1738,4 @@ function resetFFmpeg() {
|
|
|
1537
1738
|
loading = null;
|
|
1538
1739
|
}
|
|
1539
1740
|
|
|
1540
|
-
export { ASSRenderer, CancellationError, DEFAULT_SHORTCUTS, DEFAULT_TRACKERS, DISCLAIMER_KEY, FLAG_MAGNET_LINK, HLSPlayer, MKVPlayer, ProgressEstimator, SimplePlayer, SubtitleConverter, UniversalSubtitleManager, VIDEO_EXTENSIONS, acceptDisclaimer, applyOffsetToVtt, captureVideoThumbnail, configureLightBird, createOffsetVttUrl, createVideoPlayer, destroyWebTorrentClient, exportPlaylist, extractNativeMetadata, formatShortcutKey, getFFmpeg, getVideoFiles, getWebTorrentClient, hasAcceptedDisclaimer, initFeatureFlags, isHlsUrl, isInteractiveElement, isMagnetUri, isVideoFile, loadShortcuts, matchesShortcut, parseChaptersFromFFmpegLog, parseChaptersFromVtt, parseM3U8, parseMediaError, resetFFmpeg, saveShortcuts, validateFile };
|
|
1741
|
+
export { ASSRenderer, CancellationError, DEFAULT_SHORTCUTS, DEFAULT_TRACKERS, DISCLAIMER_KEY, FLAG_MAGNET_LINK, HLSPlayer, MKVPlayer, ProgressEstimator, SimplePlayer, SubtitleConverter, UniversalSubtitleManager, VIDEO_EXTENSIONS, acceptDisclaimer, applyOffsetToVtt, captureFrameAt, captureVideoThumbnail, configureLightBird, createOffsetVttUrl, createVideoPlayer, destroyWebTorrentClient, exportPlaylist, extractNativeMetadata, formatShortcutKey, getFFmpeg, getLanguageName, getVideoFiles, getWebTorrentClient, hasAcceptedDisclaimer, initFeatureFlags, isHlsUrl, isInteractiveElement, isMagnetUri, isVideoFile, loadShortcuts, matchesShortcut, parseChaptersFromFFmpegLog, parseChaptersFromVtt, parseM3U8, parseMediaError, resetFFmpeg, saveShortcuts, validateFile };
|
package/dist/react/index.cjs
CHANGED
|
@@ -1143,6 +1143,317 @@ function useMagnet() {
|
|
|
1143
1143
|
return { torrentStatus, addMagnet, destroyMagnet };
|
|
1144
1144
|
}
|
|
1145
1145
|
|
|
1146
|
+
// src/utils/video-thumbnail.ts
|
|
1147
|
+
async function captureFrameAt(videoEl, timeSeconds, width = 160, height = 90) {
|
|
1148
|
+
return new Promise((resolve) => {
|
|
1149
|
+
const canvas = document.createElement("canvas");
|
|
1150
|
+
canvas.width = width;
|
|
1151
|
+
canvas.height = height;
|
|
1152
|
+
const ctx = canvas.getContext("2d");
|
|
1153
|
+
if (!ctx) {
|
|
1154
|
+
resolve(null);
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
let settled = false;
|
|
1158
|
+
const cleanup = () => {
|
|
1159
|
+
videoEl.removeEventListener("seeked", onSeeked);
|
|
1160
|
+
videoEl.removeEventListener("error", onError);
|
|
1161
|
+
videoEl.removeEventListener("loadedmetadata", onLoadedMetadata);
|
|
1162
|
+
};
|
|
1163
|
+
const finish = (value) => {
|
|
1164
|
+
if (settled) return;
|
|
1165
|
+
settled = true;
|
|
1166
|
+
cleanup();
|
|
1167
|
+
resolve(value);
|
|
1168
|
+
};
|
|
1169
|
+
const draw = () => {
|
|
1170
|
+
try {
|
|
1171
|
+
ctx.drawImage(videoEl, 0, 0, width, height);
|
|
1172
|
+
finish(canvas.toDataURL("image/jpeg", 0.6));
|
|
1173
|
+
} catch {
|
|
1174
|
+
finish(null);
|
|
1175
|
+
}
|
|
1176
|
+
};
|
|
1177
|
+
const seekTo = () => {
|
|
1178
|
+
const duration = videoEl.duration || 0;
|
|
1179
|
+
const target = duration > 0 ? Math.max(0, Math.min(timeSeconds, duration)) : Math.max(0, timeSeconds);
|
|
1180
|
+
if (Math.abs(videoEl.currentTime - target) < 0.05) {
|
|
1181
|
+
draw();
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
try {
|
|
1185
|
+
videoEl.currentTime = target;
|
|
1186
|
+
} catch {
|
|
1187
|
+
finish(null);
|
|
1188
|
+
}
|
|
1189
|
+
};
|
|
1190
|
+
const onSeeked = () => draw();
|
|
1191
|
+
const onError = () => finish(null);
|
|
1192
|
+
const onLoadedMetadata = () => seekTo();
|
|
1193
|
+
videoEl.addEventListener("seeked", onSeeked);
|
|
1194
|
+
videoEl.addEventListener("error", onError);
|
|
1195
|
+
if (videoEl.readyState >= 1) {
|
|
1196
|
+
seekTo();
|
|
1197
|
+
} else {
|
|
1198
|
+
videoEl.addEventListener("loadedmetadata", onLoadedMetadata, { once: true });
|
|
1199
|
+
}
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
// src/react/use-seek-preview.ts
|
|
1204
|
+
function useSeekPreview(videoRef, options = {}) {
|
|
1205
|
+
const { debounceMs = 120, width = 160, height = 90 } = options;
|
|
1206
|
+
const [thumbnail, setThumbnail] = react.useState(null);
|
|
1207
|
+
const [time, setTime] = react.useState(null);
|
|
1208
|
+
const offscreenRef = react.useRef(null);
|
|
1209
|
+
const loadedSrcRef = react.useRef("");
|
|
1210
|
+
const cacheRef = react.useRef(/* @__PURE__ */ new Map());
|
|
1211
|
+
const timerRef = react.useRef(null);
|
|
1212
|
+
const requestSeqRef = react.useRef(0);
|
|
1213
|
+
const syncOffscreenSource = react.useCallback(() => {
|
|
1214
|
+
const main = videoRef.current;
|
|
1215
|
+
const src = main?.currentSrc || main?.src || "";
|
|
1216
|
+
if (!src) return null;
|
|
1217
|
+
if (!offscreenRef.current) {
|
|
1218
|
+
const v = document.createElement("video");
|
|
1219
|
+
v.muted = true;
|
|
1220
|
+
v.preload = "metadata";
|
|
1221
|
+
v.crossOrigin = "anonymous";
|
|
1222
|
+
v.setAttribute("playsinline", "");
|
|
1223
|
+
offscreenRef.current = v;
|
|
1224
|
+
}
|
|
1225
|
+
const offscreen = offscreenRef.current;
|
|
1226
|
+
if (loadedSrcRef.current !== src) {
|
|
1227
|
+
loadedSrcRef.current = src;
|
|
1228
|
+
cacheRef.current.clear();
|
|
1229
|
+
offscreen.src = src;
|
|
1230
|
+
}
|
|
1231
|
+
return offscreen;
|
|
1232
|
+
}, [videoRef]);
|
|
1233
|
+
const clearPreview = react.useCallback(() => {
|
|
1234
|
+
if (timerRef.current) {
|
|
1235
|
+
clearTimeout(timerRef.current);
|
|
1236
|
+
timerRef.current = null;
|
|
1237
|
+
}
|
|
1238
|
+
requestSeqRef.current += 1;
|
|
1239
|
+
setThumbnail(null);
|
|
1240
|
+
setTime(null);
|
|
1241
|
+
}, []);
|
|
1242
|
+
const requestPreview = react.useCallback(
|
|
1243
|
+
(timeSeconds) => {
|
|
1244
|
+
const main = videoRef.current;
|
|
1245
|
+
if (!main) return;
|
|
1246
|
+
const duration = main.duration;
|
|
1247
|
+
const hasDuration = !!duration && !Number.isNaN(duration);
|
|
1248
|
+
const clamped = hasDuration ? Math.max(0, Math.min(timeSeconds, duration)) : Math.max(0, timeSeconds);
|
|
1249
|
+
setTime(clamped);
|
|
1250
|
+
if (!hasDuration) return;
|
|
1251
|
+
const seq = ++requestSeqRef.current;
|
|
1252
|
+
const bucket = Math.round(clamped);
|
|
1253
|
+
const cached = cacheRef.current.get(bucket);
|
|
1254
|
+
if (cached) {
|
|
1255
|
+
setThumbnail(cached);
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
1259
|
+
timerRef.current = setTimeout(() => {
|
|
1260
|
+
timerRef.current = null;
|
|
1261
|
+
const offscreen = syncOffscreenSource();
|
|
1262
|
+
if (!offscreen) return;
|
|
1263
|
+
captureFrameAt(offscreen, bucket, width, height).then((dataUrl) => {
|
|
1264
|
+
if (seq !== requestSeqRef.current) return;
|
|
1265
|
+
if (dataUrl) {
|
|
1266
|
+
cacheRef.current.set(bucket, dataUrl);
|
|
1267
|
+
setThumbnail(dataUrl);
|
|
1268
|
+
}
|
|
1269
|
+
});
|
|
1270
|
+
}, debounceMs);
|
|
1271
|
+
},
|
|
1272
|
+
[videoRef, syncOffscreenSource, debounceMs, width, height]
|
|
1273
|
+
);
|
|
1274
|
+
react.useEffect(() => {
|
|
1275
|
+
return () => {
|
|
1276
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
1277
|
+
const offscreen = offscreenRef.current;
|
|
1278
|
+
if (offscreen) offscreen.removeAttribute("src");
|
|
1279
|
+
};
|
|
1280
|
+
}, []);
|
|
1281
|
+
return { thumbnail, time, requestPreview, clearPreview };
|
|
1282
|
+
}
|
|
1283
|
+
function useABLoop(videoRef) {
|
|
1284
|
+
const [pointA, setA] = react.useState(null);
|
|
1285
|
+
const [pointB, setB] = react.useState(null);
|
|
1286
|
+
const setPointA = react.useCallback(() => {
|
|
1287
|
+
const el = videoRef.current;
|
|
1288
|
+
if (!el) return;
|
|
1289
|
+
const t = el.currentTime;
|
|
1290
|
+
setA(t);
|
|
1291
|
+
setB((b) => b !== null && b <= t ? null : b);
|
|
1292
|
+
}, [videoRef]);
|
|
1293
|
+
const setPointB = react.useCallback(() => {
|
|
1294
|
+
const el = videoRef.current;
|
|
1295
|
+
if (!el || pointA === null) return;
|
|
1296
|
+
const t = el.currentTime;
|
|
1297
|
+
if (t <= pointA) return;
|
|
1298
|
+
setB(t);
|
|
1299
|
+
}, [videoRef, pointA]);
|
|
1300
|
+
const clear = react.useCallback(() => {
|
|
1301
|
+
setA(null);
|
|
1302
|
+
setB(null);
|
|
1303
|
+
}, []);
|
|
1304
|
+
react.useEffect(() => {
|
|
1305
|
+
const el = videoRef.current;
|
|
1306
|
+
if (!el || pointA === null || pointB === null) return;
|
|
1307
|
+
const onTimeUpdate = () => {
|
|
1308
|
+
if (el.currentTime >= pointB || el.currentTime < pointA) {
|
|
1309
|
+
el.currentTime = pointA;
|
|
1310
|
+
}
|
|
1311
|
+
};
|
|
1312
|
+
el.addEventListener("timeupdate", onTimeUpdate);
|
|
1313
|
+
return () => el.removeEventListener("timeupdate", onTimeUpdate);
|
|
1314
|
+
}, [videoRef, pointA, pointB]);
|
|
1315
|
+
react.useEffect(() => {
|
|
1316
|
+
const el = videoRef.current;
|
|
1317
|
+
if (!el) return;
|
|
1318
|
+
const onReset = () => clear();
|
|
1319
|
+
el.addEventListener("loadstart", onReset);
|
|
1320
|
+
el.addEventListener("emptied", onReset);
|
|
1321
|
+
return () => {
|
|
1322
|
+
el.removeEventListener("loadstart", onReset);
|
|
1323
|
+
el.removeEventListener("emptied", onReset);
|
|
1324
|
+
};
|
|
1325
|
+
}, [videoRef, clear]);
|
|
1326
|
+
return {
|
|
1327
|
+
pointA,
|
|
1328
|
+
pointB,
|
|
1329
|
+
isLooping: pointA !== null && pointB !== null,
|
|
1330
|
+
setPointA,
|
|
1331
|
+
setPointB,
|
|
1332
|
+
clear
|
|
1333
|
+
};
|
|
1334
|
+
}
|
|
1335
|
+
var SWIPE_THRESHOLD = 10;
|
|
1336
|
+
var DOUBLE_TAP_DISTANCE = 40;
|
|
1337
|
+
function useTouchGestures(targetRef, handlers, options = {}) {
|
|
1338
|
+
const { enabled = true, seekSeconds = 10, doubleTapMs = 300, feedbackMs = 700 } = options;
|
|
1339
|
+
const [feedback, setFeedback] = react.useState(null);
|
|
1340
|
+
const handlersRef = react.useRef(handlers);
|
|
1341
|
+
react.useEffect(() => {
|
|
1342
|
+
handlersRef.current = handlers;
|
|
1343
|
+
});
|
|
1344
|
+
const startRef = react.useRef(null);
|
|
1345
|
+
const phaseRef = react.useRef("idle");
|
|
1346
|
+
const swipeBaseRef = react.useRef(0);
|
|
1347
|
+
const lastTapRef = react.useRef(null);
|
|
1348
|
+
const feedbackTimerRef = react.useRef(null);
|
|
1349
|
+
const showFeedback = react.useCallback(
|
|
1350
|
+
(fb) => {
|
|
1351
|
+
setFeedback(fb);
|
|
1352
|
+
if (feedbackTimerRef.current) clearTimeout(feedbackTimerRef.current);
|
|
1353
|
+
feedbackTimerRef.current = setTimeout(() => setFeedback(null), feedbackMs);
|
|
1354
|
+
},
|
|
1355
|
+
[feedbackMs]
|
|
1356
|
+
);
|
|
1357
|
+
react.useEffect(() => {
|
|
1358
|
+
const el = targetRef.current;
|
|
1359
|
+
if (!el || !enabled) return;
|
|
1360
|
+
const onTouchStart = (e) => {
|
|
1361
|
+
if (e.touches.length !== 1) {
|
|
1362
|
+
startRef.current = null;
|
|
1363
|
+
phaseRef.current = "ignore";
|
|
1364
|
+
return;
|
|
1365
|
+
}
|
|
1366
|
+
const t = e.touches[0];
|
|
1367
|
+
const rect = el.getBoundingClientRect();
|
|
1368
|
+
const relX = t.clientX - rect.left;
|
|
1369
|
+
startRef.current = {
|
|
1370
|
+
x: t.clientX,
|
|
1371
|
+
y: t.clientY,
|
|
1372
|
+
zone: relX < rect.width / 2 ? "left" : "right",
|
|
1373
|
+
height: rect.height || 1
|
|
1374
|
+
};
|
|
1375
|
+
phaseRef.current = "pending";
|
|
1376
|
+
};
|
|
1377
|
+
const onTouchMove = (e) => {
|
|
1378
|
+
const start = startRef.current;
|
|
1379
|
+
if (!start || phaseRef.current === "ignore" || phaseRef.current === "idle") return;
|
|
1380
|
+
const t = e.touches[0];
|
|
1381
|
+
if (!t) return;
|
|
1382
|
+
const dx = t.clientX - start.x;
|
|
1383
|
+
const dy = t.clientY - start.y;
|
|
1384
|
+
if (phaseRef.current === "pending") {
|
|
1385
|
+
if (Math.hypot(dx, dy) < SWIPE_THRESHOLD) return;
|
|
1386
|
+
if (Math.abs(dy) > Math.abs(dx)) {
|
|
1387
|
+
phaseRef.current = "swipe";
|
|
1388
|
+
const h = handlersRef.current;
|
|
1389
|
+
swipeBaseRef.current = start.zone === "left" ? h.getBrightness?.() ?? 1 : h.getVolume?.() ?? 1;
|
|
1390
|
+
} else {
|
|
1391
|
+
phaseRef.current = "ignore";
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
if (phaseRef.current === "swipe") {
|
|
1396
|
+
if (e.cancelable) e.preventDefault();
|
|
1397
|
+
const fraction = -dy / start.height;
|
|
1398
|
+
const next = Math.max(0, Math.min(1, swipeBaseRef.current + fraction));
|
|
1399
|
+
const h = handlersRef.current;
|
|
1400
|
+
if (start.zone === "left") {
|
|
1401
|
+
h.setBrightness?.(next);
|
|
1402
|
+
showFeedback({ type: "brightness", value: next });
|
|
1403
|
+
} else {
|
|
1404
|
+
h.setVolume?.(next);
|
|
1405
|
+
showFeedback({ type: "volume", value: next });
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
};
|
|
1409
|
+
const onTouchEnd = (e) => {
|
|
1410
|
+
const start = startRef.current;
|
|
1411
|
+
const phase = phaseRef.current;
|
|
1412
|
+
startRef.current = null;
|
|
1413
|
+
phaseRef.current = "idle";
|
|
1414
|
+
if (!start || phase === "ignore") return;
|
|
1415
|
+
if (phase === "swipe") {
|
|
1416
|
+
lastTapRef.current = null;
|
|
1417
|
+
return;
|
|
1418
|
+
}
|
|
1419
|
+
const point = e.changedTouches[0];
|
|
1420
|
+
const px = point ? point.clientX : start.x;
|
|
1421
|
+
const py = point ? point.clientY : start.y;
|
|
1422
|
+
const now = Date.now();
|
|
1423
|
+
const last = lastTapRef.current;
|
|
1424
|
+
if (last && last.zone === start.zone && now - last.time < doubleTapMs && Math.hypot(px - last.x, py - last.y) < DOUBLE_TAP_DISTANCE) {
|
|
1425
|
+
lastTapRef.current = null;
|
|
1426
|
+
if (e.cancelable) e.preventDefault();
|
|
1427
|
+
const h = handlersRef.current;
|
|
1428
|
+
if (start.zone === "left") {
|
|
1429
|
+
h.seekBy?.(-seekSeconds);
|
|
1430
|
+
showFeedback({ type: "seek", direction: "backward", seconds: seekSeconds });
|
|
1431
|
+
} else {
|
|
1432
|
+
h.seekBy?.(seekSeconds);
|
|
1433
|
+
showFeedback({ type: "seek", direction: "forward", seconds: seekSeconds });
|
|
1434
|
+
}
|
|
1435
|
+
} else {
|
|
1436
|
+
lastTapRef.current = { x: px, y: py, time: now, zone: start.zone };
|
|
1437
|
+
}
|
|
1438
|
+
};
|
|
1439
|
+
el.addEventListener("touchstart", onTouchStart, { passive: true });
|
|
1440
|
+
el.addEventListener("touchmove", onTouchMove, { passive: false });
|
|
1441
|
+
el.addEventListener("touchend", onTouchEnd, { passive: false });
|
|
1442
|
+
return () => {
|
|
1443
|
+
el.removeEventListener("touchstart", onTouchStart);
|
|
1444
|
+
el.removeEventListener("touchmove", onTouchMove);
|
|
1445
|
+
el.removeEventListener("touchend", onTouchEnd);
|
|
1446
|
+
};
|
|
1447
|
+
}, [targetRef, enabled, seekSeconds, doubleTapMs, showFeedback]);
|
|
1448
|
+
react.useEffect(() => {
|
|
1449
|
+
return () => {
|
|
1450
|
+
if (feedbackTimerRef.current) clearTimeout(feedbackTimerRef.current);
|
|
1451
|
+
};
|
|
1452
|
+
}, []);
|
|
1453
|
+
return { feedback };
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
exports.useABLoop = useABLoop;
|
|
1146
1457
|
exports.useChapters = useChapters;
|
|
1147
1458
|
exports.useFullscreen = useFullscreen;
|
|
1148
1459
|
exports.useKeyboardShortcuts = useKeyboardShortcuts;
|
|
@@ -1151,7 +1462,9 @@ exports.useMediaSession = useMediaSession;
|
|
|
1151
1462
|
exports.usePictureInPicture = usePictureInPicture;
|
|
1152
1463
|
exports.usePlaylist = usePlaylist;
|
|
1153
1464
|
exports.useProgressPersistence = useProgressPersistence;
|
|
1465
|
+
exports.useSeekPreview = useSeekPreview;
|
|
1154
1466
|
exports.useSubtitles = useSubtitles;
|
|
1467
|
+
exports.useTouchGestures = useTouchGestures;
|
|
1155
1468
|
exports.useVideoFilters = useVideoFilters;
|
|
1156
1469
|
exports.useVideoInfo = useVideoInfo;
|
|
1157
1470
|
exports.useVideoPlayback = useVideoPlayback;
|