@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.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
- const streamMatch = line.match(/Stream #\d+:\d+(?:\((\w+)\))?: (Video|Audio|Subtitle): (\S+)/i);
270
- if (!streamMatch) continue;
271
- const [, lang, type, codec] = streamMatch;
272
- const titleMatch = line.match(/\btitle\s*:\s*([^,\n]+)/i);
273
- const title = titleMatch ? titleMatch[1].trim() : void 0;
274
- if (type.toLowerCase() === "video") {
275
- videoTracks.push({ index: videoTracks.length, type: "video", codec, lang, title });
276
- } else if (type.toLowerCase() === "audio") {
277
- audioTracks.push({ index: audioTracks.length, type: "audio", codec, lang, title });
278
- } else if (type.toLowerCase() === "subtitle") {
279
- subtitleTracks.push({ index: subtitleTracks.length, type: "subtitle", codec, lang, title });
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.length > 0 ? audioTracks.map((t, i) => ({
403
- id: String(i),
404
- name: t.title ?? (t.lang ? `Audio ${i + 1} (${t.lang})` : `Audio ${i + 1}`),
405
- lang: t.lang ?? "unknown"
406
- })) : [{ id: "0", name: "Default Audio", lang: "unknown" }];
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.length > 0 ? audioTracks.map((t, i) => ({
449
- id: String(i),
450
- name: t.title ?? (t.lang ? `Audio ${i + 1} (${t.lang})` : `Audio ${i + 1}`),
451
- lang: t.lang ?? "unknown"
452
- })) : [{ id: "0", name: "Default Audio", lang: "unknown" }];
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 };
@@ -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;