@lightbird/core 0.4.0 → 0.5.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 CHANGED
@@ -1412,6 +1412,61 @@ async function captureVideoThumbnail(videoEl, atSeconds = 5) {
1412
1412
  }
1413
1413
  });
1414
1414
  }
1415
+ async function captureFrameAt(videoEl, timeSeconds, width = 160, height = 90) {
1416
+ return new Promise((resolve) => {
1417
+ const canvas = document.createElement("canvas");
1418
+ canvas.width = width;
1419
+ canvas.height = height;
1420
+ const ctx = canvas.getContext("2d");
1421
+ if (!ctx) {
1422
+ resolve(null);
1423
+ return;
1424
+ }
1425
+ let settled = false;
1426
+ const cleanup = () => {
1427
+ videoEl.removeEventListener("seeked", onSeeked);
1428
+ videoEl.removeEventListener("error", onError);
1429
+ videoEl.removeEventListener("loadedmetadata", onLoadedMetadata);
1430
+ };
1431
+ const finish = (value) => {
1432
+ if (settled) return;
1433
+ settled = true;
1434
+ cleanup();
1435
+ resolve(value);
1436
+ };
1437
+ const draw = () => {
1438
+ try {
1439
+ ctx.drawImage(videoEl, 0, 0, width, height);
1440
+ finish(canvas.toDataURL("image/jpeg", 0.6));
1441
+ } catch {
1442
+ finish(null);
1443
+ }
1444
+ };
1445
+ const seekTo = () => {
1446
+ const duration = videoEl.duration || 0;
1447
+ const target = duration > 0 ? Math.max(0, Math.min(timeSeconds, duration)) : Math.max(0, timeSeconds);
1448
+ if (Math.abs(videoEl.currentTime - target) < 0.05) {
1449
+ draw();
1450
+ return;
1451
+ }
1452
+ try {
1453
+ videoEl.currentTime = target;
1454
+ } catch {
1455
+ finish(null);
1456
+ }
1457
+ };
1458
+ const onSeeked = () => draw();
1459
+ const onError = () => finish(null);
1460
+ const onLoadedMetadata = () => seekTo();
1461
+ videoEl.addEventListener("seeked", onSeeked);
1462
+ videoEl.addEventListener("error", onError);
1463
+ if (videoEl.readyState >= 1) {
1464
+ seekTo();
1465
+ } else {
1466
+ videoEl.addEventListener("loadedmetadata", onLoadedMetadata, { once: true });
1467
+ }
1468
+ });
1469
+ }
1415
1470
 
1416
1471
  // src/utils/keyboard-shortcuts.ts
1417
1472
  var DEFAULT_SHORTCUTS = [
@@ -1555,6 +1610,7 @@ exports.UniversalSubtitleManager = UniversalSubtitleManager;
1555
1610
  exports.VIDEO_EXTENSIONS = VIDEO_EXTENSIONS;
1556
1611
  exports.acceptDisclaimer = acceptDisclaimer;
1557
1612
  exports.applyOffsetToVtt = applyOffsetToVtt;
1613
+ exports.captureFrameAt = captureFrameAt;
1558
1614
  exports.captureVideoThumbnail = captureVideoThumbnail;
1559
1615
  exports.configureLightBird = configureLightBird;
1560
1616
  exports.createOffsetVttUrl = createOffsetVttUrl;
package/dist/index.d.cts CHANGED
@@ -372,6 +372,21 @@ declare function validateFile(file: File): {
372
372
  declare function extractNativeMetadata(videoEl: HTMLVideoElement, file?: File): Partial<VideoMetadata>;
373
373
 
374
374
  declare function captureVideoThumbnail(videoEl: HTMLVideoElement, atSeconds?: number): Promise<string | null>;
375
+ /**
376
+ * Capture a single video frame at a specific timestamp.
377
+ *
378
+ * Unlike {@link captureVideoThumbnail}, this does not save/restore
379
+ * `currentTime` — it is built for a dedicated, offscreen preview video so the
380
+ * main playback element is never disturbed. Powers seek-bar hover previews.
381
+ *
382
+ * @param videoEl - A video element (typically offscreen) to seek and capture.
383
+ * @param timeSeconds - Timestamp to capture, clamped to the video duration.
384
+ * @param width - Output thumbnail width in pixels.
385
+ * @param height - Output thumbnail height in pixels.
386
+ * @returns A JPEG data URL, or `null` if capture failed (no 2d context, a
387
+ * tainted canvas, or a media error).
388
+ */
389
+ declare function captureFrameAt(videoEl: HTMLVideoElement, timeSeconds: number, width?: number, height?: number): Promise<string | null>;
375
390
 
376
391
  type ShortcutAction = 'play-pause' | 'seek-forward-5' | 'seek-backward-5' | 'seek-forward-30' | 'seek-backward-30' | 'volume-up' | 'volume-down' | 'mute' | 'fullscreen' | 'next-item' | 'prev-item' | 'screenshot' | 'show-shortcuts' | 'next-chapter' | 'prev-chapter';
377
392
  interface ShortcutBinding {
@@ -426,4 +441,4 @@ declare class ProgressEstimator {
426
441
  declare function getFFmpeg(): Promise<FFmpeg>;
427
442
  declare function resetFFmpeg(): void;
428
443
 
429
- export { ASSRenderer, type AudioTrack, type AudioTrackMeta, CancellationError, type Chapter, DEFAULT_SHORTCUTS, DEFAULT_TRACKERS, DISCLAIMER_KEY, FLAG_MAGNET_LINK, HLSPlayer, type HLSPlayerFile, type LightBirdConfig, MKVPlayer, type MKVPlayerFile, type MediaErrorType, type ParsedMediaError, type PlaylistItem, type ProcessedFile, ProgressEstimator, type QualityLevel, type ShortcutAction, type ShortcutBinding, SimplePlayer, type SimplePlayerFile, type Subtitle, SubtitleConverter, type SubtitleCue, type SubtitleTrackMeta, type TorrentStatus, UniversalSubtitleManager, VIDEO_EXTENSIONS, type VideoFilters, type VideoMetadata, type VideoPlayer, 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 };
444
+ export { ASSRenderer, type AudioTrack, type AudioTrackMeta, CancellationError, type Chapter, DEFAULT_SHORTCUTS, DEFAULT_TRACKERS, DISCLAIMER_KEY, FLAG_MAGNET_LINK, HLSPlayer, type HLSPlayerFile, type LightBirdConfig, MKVPlayer, type MKVPlayerFile, type MediaErrorType, type ParsedMediaError, type PlaylistItem, type ProcessedFile, ProgressEstimator, type QualityLevel, type ShortcutAction, type ShortcutBinding, SimplePlayer, type SimplePlayerFile, type Subtitle, SubtitleConverter, type SubtitleCue, type SubtitleTrackMeta, type TorrentStatus, UniversalSubtitleManager, VIDEO_EXTENSIONS, type VideoFilters, type VideoMetadata, type VideoPlayer, acceptDisclaimer, applyOffsetToVtt, captureFrameAt, 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 };
package/dist/index.d.ts CHANGED
@@ -372,6 +372,21 @@ declare function validateFile(file: File): {
372
372
  declare function extractNativeMetadata(videoEl: HTMLVideoElement, file?: File): Partial<VideoMetadata>;
373
373
 
374
374
  declare function captureVideoThumbnail(videoEl: HTMLVideoElement, atSeconds?: number): Promise<string | null>;
375
+ /**
376
+ * Capture a single video frame at a specific timestamp.
377
+ *
378
+ * Unlike {@link captureVideoThumbnail}, this does not save/restore
379
+ * `currentTime` — it is built for a dedicated, offscreen preview video so the
380
+ * main playback element is never disturbed. Powers seek-bar hover previews.
381
+ *
382
+ * @param videoEl - A video element (typically offscreen) to seek and capture.
383
+ * @param timeSeconds - Timestamp to capture, clamped to the video duration.
384
+ * @param width - Output thumbnail width in pixels.
385
+ * @param height - Output thumbnail height in pixels.
386
+ * @returns A JPEG data URL, or `null` if capture failed (no 2d context, a
387
+ * tainted canvas, or a media error).
388
+ */
389
+ declare function captureFrameAt(videoEl: HTMLVideoElement, timeSeconds: number, width?: number, height?: number): Promise<string | null>;
375
390
 
376
391
  type ShortcutAction = 'play-pause' | 'seek-forward-5' | 'seek-backward-5' | 'seek-forward-30' | 'seek-backward-30' | 'volume-up' | 'volume-down' | 'mute' | 'fullscreen' | 'next-item' | 'prev-item' | 'screenshot' | 'show-shortcuts' | 'next-chapter' | 'prev-chapter';
377
392
  interface ShortcutBinding {
@@ -426,4 +441,4 @@ declare class ProgressEstimator {
426
441
  declare function getFFmpeg(): Promise<FFmpeg>;
427
442
  declare function resetFFmpeg(): void;
428
443
 
429
- export { ASSRenderer, type AudioTrack, type AudioTrackMeta, CancellationError, type Chapter, DEFAULT_SHORTCUTS, DEFAULT_TRACKERS, DISCLAIMER_KEY, FLAG_MAGNET_LINK, HLSPlayer, type HLSPlayerFile, type LightBirdConfig, MKVPlayer, type MKVPlayerFile, type MediaErrorType, type ParsedMediaError, type PlaylistItem, type ProcessedFile, ProgressEstimator, type QualityLevel, type ShortcutAction, type ShortcutBinding, SimplePlayer, type SimplePlayerFile, type Subtitle, SubtitleConverter, type SubtitleCue, type SubtitleTrackMeta, type TorrentStatus, UniversalSubtitleManager, VIDEO_EXTENSIONS, type VideoFilters, type VideoMetadata, type VideoPlayer, 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 };
444
+ export { ASSRenderer, type AudioTrack, type AudioTrackMeta, CancellationError, type Chapter, DEFAULT_SHORTCUTS, DEFAULT_TRACKERS, DISCLAIMER_KEY, FLAG_MAGNET_LINK, HLSPlayer, type HLSPlayerFile, type LightBirdConfig, MKVPlayer, type MKVPlayerFile, type MediaErrorType, type ParsedMediaError, type PlaylistItem, type ProcessedFile, ProgressEstimator, type QualityLevel, type ShortcutAction, type ShortcutBinding, SimplePlayer, type SimplePlayerFile, type Subtitle, SubtitleConverter, type SubtitleCue, type SubtitleTrackMeta, type TorrentStatus, UniversalSubtitleManager, VIDEO_EXTENSIONS, type VideoFilters, type VideoMetadata, type VideoPlayer, acceptDisclaimer, applyOffsetToVtt, captureFrameAt, 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 };
package/dist/index.js CHANGED
@@ -1409,6 +1409,61 @@ async function captureVideoThumbnail(videoEl, atSeconds = 5) {
1409
1409
  }
1410
1410
  });
1411
1411
  }
1412
+ async function captureFrameAt(videoEl, timeSeconds, width = 160, height = 90) {
1413
+ return new Promise((resolve) => {
1414
+ const canvas = document.createElement("canvas");
1415
+ canvas.width = width;
1416
+ canvas.height = height;
1417
+ const ctx = canvas.getContext("2d");
1418
+ if (!ctx) {
1419
+ resolve(null);
1420
+ return;
1421
+ }
1422
+ let settled = false;
1423
+ const cleanup = () => {
1424
+ videoEl.removeEventListener("seeked", onSeeked);
1425
+ videoEl.removeEventListener("error", onError);
1426
+ videoEl.removeEventListener("loadedmetadata", onLoadedMetadata);
1427
+ };
1428
+ const finish = (value) => {
1429
+ if (settled) return;
1430
+ settled = true;
1431
+ cleanup();
1432
+ resolve(value);
1433
+ };
1434
+ const draw = () => {
1435
+ try {
1436
+ ctx.drawImage(videoEl, 0, 0, width, height);
1437
+ finish(canvas.toDataURL("image/jpeg", 0.6));
1438
+ } catch {
1439
+ finish(null);
1440
+ }
1441
+ };
1442
+ const seekTo = () => {
1443
+ const duration = videoEl.duration || 0;
1444
+ const target = duration > 0 ? Math.max(0, Math.min(timeSeconds, duration)) : Math.max(0, timeSeconds);
1445
+ if (Math.abs(videoEl.currentTime - target) < 0.05) {
1446
+ draw();
1447
+ return;
1448
+ }
1449
+ try {
1450
+ videoEl.currentTime = target;
1451
+ } catch {
1452
+ finish(null);
1453
+ }
1454
+ };
1455
+ const onSeeked = () => draw();
1456
+ const onError = () => finish(null);
1457
+ const onLoadedMetadata = () => seekTo();
1458
+ videoEl.addEventListener("seeked", onSeeked);
1459
+ videoEl.addEventListener("error", onError);
1460
+ if (videoEl.readyState >= 1) {
1461
+ seekTo();
1462
+ } else {
1463
+ videoEl.addEventListener("loadedmetadata", onLoadedMetadata, { once: true });
1464
+ }
1465
+ });
1466
+ }
1412
1467
 
1413
1468
  // src/utils/keyboard-shortcuts.ts
1414
1469
  var DEFAULT_SHORTCUTS = [
@@ -1537,4 +1592,4 @@ function resetFFmpeg() {
1537
1592
  loading = null;
1538
1593
  }
1539
1594
 
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 };
1595
+ 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, 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;
@@ -284,4 +284,104 @@ interface UseMagnetReturn {
284
284
  }
285
285
  declare function useMagnet(): UseMagnetReturn;
286
286
 
287
- export { type ShortcutHandlers, type UseMagnetReturn, type UseMediaSessionOptions, type UseSubtitlesOptions, useChapters, useFullscreen, useKeyboardShortcuts, useMagnet, useMediaSession, usePictureInPicture, usePlaylist, useProgressPersistence, useSubtitles, useVideoFilters, useVideoInfo, useVideoPlayback };
287
+ interface SeekPreviewState {
288
+ /** Data URL of the captured frame for the hovered position, or `null`. */
289
+ thumbnail: string | null;
290
+ /** Hovered timestamp (seconds) the preview corresponds to, or `null`. */
291
+ time: number | null;
292
+ /** Request a preview at a hovered timestamp. Debounced and cached. */
293
+ requestPreview: (timeSeconds: number) => void;
294
+ /** Dismiss the preview, e.g. when the pointer leaves the seek bar. */
295
+ clearPreview: () => void;
296
+ }
297
+ interface UseSeekPreviewOptions {
298
+ /** Debounce window in ms before a frame is captured. Default `120`. */
299
+ debounceMs?: number;
300
+ /** Captured thumbnail width in pixels. Default `160`. */
301
+ width?: number;
302
+ /** Captured thumbnail height in pixels. Default `90`. */
303
+ height?: number;
304
+ }
305
+ /**
306
+ * Generates thumbnail previews for seek-bar hover.
307
+ *
308
+ * Captures frames from a dedicated offscreen video element kept in sync with
309
+ * the main player's source, so scrubbing the preview never disturbs playback.
310
+ * Captures are debounced and cached per whole second.
311
+ */
312
+ declare function useSeekPreview(videoRef: RefObject<HTMLVideoElement | null>, options?: UseSeekPreviewOptions): SeekPreviewState;
313
+
314
+ interface ABLoopState {
315
+ /** Loop start point in seconds, or `null` when unset. */
316
+ pointA: number | null;
317
+ /** Loop end point in seconds, or `null` when unset. */
318
+ pointB: number | null;
319
+ /** True when both points are set and playback loops between them. */
320
+ isLooping: boolean;
321
+ /** Capture the current playback time as point A. */
322
+ setPointA: () => void;
323
+ /** Capture the current playback time as point B (requires A, must be after A). */
324
+ setPointB: () => void;
325
+ /** Clear both points and stop looping. */
326
+ clear: () => void;
327
+ }
328
+ /**
329
+ * A-B loop: repeat playback between two user-set points.
330
+ *
331
+ * `setPointA` captures the current time as the loop start; `setPointB`
332
+ * captures the loop end (accepted only once A is set and the playhead is past
333
+ * it). While both points are set, the playhead is kept within `[A, B]` and
334
+ * jumps back to A whenever it reaches B. Points reset when a new source loads.
335
+ */
336
+ declare function useABLoop(videoRef: RefObject<HTMLVideoElement | null>): ABLoopState;
337
+
338
+ interface TouchGestureHandlers {
339
+ /** Seek relative to the current time by a signed number of seconds. */
340
+ seekBy?: (seconds: number) => void;
341
+ /** Read the current volume as a 0..1 value. */
342
+ getVolume?: () => number;
343
+ /** Set the volume to a 0..1 value. */
344
+ setVolume?: (value: number) => void;
345
+ /** Read the current brightness as a 0..1 fraction. */
346
+ getBrightness?: () => number;
347
+ /** Set the brightness to a 0..1 fraction. */
348
+ setBrightness?: (value: number) => void;
349
+ }
350
+ type TouchGestureFeedback = {
351
+ type: "seek";
352
+ direction: "forward" | "backward";
353
+ seconds: number;
354
+ } | {
355
+ type: "volume";
356
+ value: number;
357
+ } | {
358
+ type: "brightness";
359
+ value: number;
360
+ };
361
+ interface UseTouchGesturesOptions {
362
+ /** Disable the gesture listeners entirely. Default `true` (enabled). */
363
+ enabled?: boolean;
364
+ /** Seconds to seek on a double-tap. Default `10`. */
365
+ seekSeconds?: number;
366
+ /** Time window in ms for a second tap to count as a double-tap. Default `300`. */
367
+ doubleTapMs?: number;
368
+ /** How long the feedback indicator stays visible, in ms. Default `700`. */
369
+ feedbackMs?: number;
370
+ }
371
+ interface TouchGesturesState {
372
+ /** The most recent gesture for a transient on-screen indicator, or `null`. */
373
+ feedback: TouchGestureFeedback | null;
374
+ }
375
+ /**
376
+ * Touch gesture controls for the player surface.
377
+ *
378
+ * - Double-tap the left/right half to seek backward/forward.
379
+ * - Vertical swipe on the right half to adjust volume.
380
+ * - Vertical swipe on the left half to adjust brightness.
381
+ *
382
+ * Returns transient `feedback` describing the latest gesture so a UI overlay
383
+ * can show what changed.
384
+ */
385
+ declare function useTouchGestures(targetRef: RefObject<HTMLElement | null>, handlers: TouchGestureHandlers, options?: UseTouchGesturesOptions): TouchGesturesState;
386
+
387
+ export { type ABLoopState, type SeekPreviewState, type ShortcutHandlers, type TouchGestureFeedback, type TouchGestureHandlers, type TouchGesturesState, type UseMagnetReturn, type UseMediaSessionOptions, type UseSeekPreviewOptions, type UseSubtitlesOptions, type UseTouchGesturesOptions, useABLoop, useChapters, useFullscreen, useKeyboardShortcuts, useMagnet, useMediaSession, usePictureInPicture, usePlaylist, useProgressPersistence, useSeekPreview, useSubtitles, useTouchGestures, useVideoFilters, useVideoInfo, useVideoPlayback };
@@ -284,4 +284,104 @@ interface UseMagnetReturn {
284
284
  }
285
285
  declare function useMagnet(): UseMagnetReturn;
286
286
 
287
- export { type ShortcutHandlers, type UseMagnetReturn, type UseMediaSessionOptions, type UseSubtitlesOptions, useChapters, useFullscreen, useKeyboardShortcuts, useMagnet, useMediaSession, usePictureInPicture, usePlaylist, useProgressPersistence, useSubtitles, useVideoFilters, useVideoInfo, useVideoPlayback };
287
+ interface SeekPreviewState {
288
+ /** Data URL of the captured frame for the hovered position, or `null`. */
289
+ thumbnail: string | null;
290
+ /** Hovered timestamp (seconds) the preview corresponds to, or `null`. */
291
+ time: number | null;
292
+ /** Request a preview at a hovered timestamp. Debounced and cached. */
293
+ requestPreview: (timeSeconds: number) => void;
294
+ /** Dismiss the preview, e.g. when the pointer leaves the seek bar. */
295
+ clearPreview: () => void;
296
+ }
297
+ interface UseSeekPreviewOptions {
298
+ /** Debounce window in ms before a frame is captured. Default `120`. */
299
+ debounceMs?: number;
300
+ /** Captured thumbnail width in pixels. Default `160`. */
301
+ width?: number;
302
+ /** Captured thumbnail height in pixels. Default `90`. */
303
+ height?: number;
304
+ }
305
+ /**
306
+ * Generates thumbnail previews for seek-bar hover.
307
+ *
308
+ * Captures frames from a dedicated offscreen video element kept in sync with
309
+ * the main player's source, so scrubbing the preview never disturbs playback.
310
+ * Captures are debounced and cached per whole second.
311
+ */
312
+ declare function useSeekPreview(videoRef: RefObject<HTMLVideoElement | null>, options?: UseSeekPreviewOptions): SeekPreviewState;
313
+
314
+ interface ABLoopState {
315
+ /** Loop start point in seconds, or `null` when unset. */
316
+ pointA: number | null;
317
+ /** Loop end point in seconds, or `null` when unset. */
318
+ pointB: number | null;
319
+ /** True when both points are set and playback loops between them. */
320
+ isLooping: boolean;
321
+ /** Capture the current playback time as point A. */
322
+ setPointA: () => void;
323
+ /** Capture the current playback time as point B (requires A, must be after A). */
324
+ setPointB: () => void;
325
+ /** Clear both points and stop looping. */
326
+ clear: () => void;
327
+ }
328
+ /**
329
+ * A-B loop: repeat playback between two user-set points.
330
+ *
331
+ * `setPointA` captures the current time as the loop start; `setPointB`
332
+ * captures the loop end (accepted only once A is set and the playhead is past
333
+ * it). While both points are set, the playhead is kept within `[A, B]` and
334
+ * jumps back to A whenever it reaches B. Points reset when a new source loads.
335
+ */
336
+ declare function useABLoop(videoRef: RefObject<HTMLVideoElement | null>): ABLoopState;
337
+
338
+ interface TouchGestureHandlers {
339
+ /** Seek relative to the current time by a signed number of seconds. */
340
+ seekBy?: (seconds: number) => void;
341
+ /** Read the current volume as a 0..1 value. */
342
+ getVolume?: () => number;
343
+ /** Set the volume to a 0..1 value. */
344
+ setVolume?: (value: number) => void;
345
+ /** Read the current brightness as a 0..1 fraction. */
346
+ getBrightness?: () => number;
347
+ /** Set the brightness to a 0..1 fraction. */
348
+ setBrightness?: (value: number) => void;
349
+ }
350
+ type TouchGestureFeedback = {
351
+ type: "seek";
352
+ direction: "forward" | "backward";
353
+ seconds: number;
354
+ } | {
355
+ type: "volume";
356
+ value: number;
357
+ } | {
358
+ type: "brightness";
359
+ value: number;
360
+ };
361
+ interface UseTouchGesturesOptions {
362
+ /** Disable the gesture listeners entirely. Default `true` (enabled). */
363
+ enabled?: boolean;
364
+ /** Seconds to seek on a double-tap. Default `10`. */
365
+ seekSeconds?: number;
366
+ /** Time window in ms for a second tap to count as a double-tap. Default `300`. */
367
+ doubleTapMs?: number;
368
+ /** How long the feedback indicator stays visible, in ms. Default `700`. */
369
+ feedbackMs?: number;
370
+ }
371
+ interface TouchGesturesState {
372
+ /** The most recent gesture for a transient on-screen indicator, or `null`. */
373
+ feedback: TouchGestureFeedback | null;
374
+ }
375
+ /**
376
+ * Touch gesture controls for the player surface.
377
+ *
378
+ * - Double-tap the left/right half to seek backward/forward.
379
+ * - Vertical swipe on the right half to adjust volume.
380
+ * - Vertical swipe on the left half to adjust brightness.
381
+ *
382
+ * Returns transient `feedback` describing the latest gesture so a UI overlay
383
+ * can show what changed.
384
+ */
385
+ declare function useTouchGestures(targetRef: RefObject<HTMLElement | null>, handlers: TouchGestureHandlers, options?: UseTouchGesturesOptions): TouchGesturesState;
386
+
387
+ export { type ABLoopState, type SeekPreviewState, type ShortcutHandlers, type TouchGestureFeedback, type TouchGestureHandlers, type TouchGesturesState, type UseMagnetReturn, type UseMediaSessionOptions, type UseSeekPreviewOptions, type UseSubtitlesOptions, type UseTouchGesturesOptions, useABLoop, useChapters, useFullscreen, useKeyboardShortcuts, useMagnet, useMediaSession, usePictureInPicture, usePlaylist, useProgressPersistence, useSeekPreview, useSubtitles, useTouchGestures, useVideoFilters, useVideoInfo, useVideoPlayback };
@@ -1141,4 +1141,314 @@ function useMagnet() {
1141
1141
  return { torrentStatus, addMagnet, destroyMagnet };
1142
1142
  }
1143
1143
 
1144
- export { useChapters, useFullscreen, useKeyboardShortcuts, useMagnet, useMediaSession, usePictureInPicture, usePlaylist, useProgressPersistence, useSubtitles, useVideoFilters, useVideoInfo, useVideoPlayback };
1144
+ // src/utils/video-thumbnail.ts
1145
+ async function captureFrameAt(videoEl, timeSeconds, width = 160, height = 90) {
1146
+ return new Promise((resolve) => {
1147
+ const canvas = document.createElement("canvas");
1148
+ canvas.width = width;
1149
+ canvas.height = height;
1150
+ const ctx = canvas.getContext("2d");
1151
+ if (!ctx) {
1152
+ resolve(null);
1153
+ return;
1154
+ }
1155
+ let settled = false;
1156
+ const cleanup = () => {
1157
+ videoEl.removeEventListener("seeked", onSeeked);
1158
+ videoEl.removeEventListener("error", onError);
1159
+ videoEl.removeEventListener("loadedmetadata", onLoadedMetadata);
1160
+ };
1161
+ const finish = (value) => {
1162
+ if (settled) return;
1163
+ settled = true;
1164
+ cleanup();
1165
+ resolve(value);
1166
+ };
1167
+ const draw = () => {
1168
+ try {
1169
+ ctx.drawImage(videoEl, 0, 0, width, height);
1170
+ finish(canvas.toDataURL("image/jpeg", 0.6));
1171
+ } catch {
1172
+ finish(null);
1173
+ }
1174
+ };
1175
+ const seekTo = () => {
1176
+ const duration = videoEl.duration || 0;
1177
+ const target = duration > 0 ? Math.max(0, Math.min(timeSeconds, duration)) : Math.max(0, timeSeconds);
1178
+ if (Math.abs(videoEl.currentTime - target) < 0.05) {
1179
+ draw();
1180
+ return;
1181
+ }
1182
+ try {
1183
+ videoEl.currentTime = target;
1184
+ } catch {
1185
+ finish(null);
1186
+ }
1187
+ };
1188
+ const onSeeked = () => draw();
1189
+ const onError = () => finish(null);
1190
+ const onLoadedMetadata = () => seekTo();
1191
+ videoEl.addEventListener("seeked", onSeeked);
1192
+ videoEl.addEventListener("error", onError);
1193
+ if (videoEl.readyState >= 1) {
1194
+ seekTo();
1195
+ } else {
1196
+ videoEl.addEventListener("loadedmetadata", onLoadedMetadata, { once: true });
1197
+ }
1198
+ });
1199
+ }
1200
+
1201
+ // src/react/use-seek-preview.ts
1202
+ function useSeekPreview(videoRef, options = {}) {
1203
+ const { debounceMs = 120, width = 160, height = 90 } = options;
1204
+ const [thumbnail, setThumbnail] = useState(null);
1205
+ const [time, setTime] = useState(null);
1206
+ const offscreenRef = useRef(null);
1207
+ const loadedSrcRef = useRef("");
1208
+ const cacheRef = useRef(/* @__PURE__ */ new Map());
1209
+ const timerRef = useRef(null);
1210
+ const requestSeqRef = useRef(0);
1211
+ const syncOffscreenSource = useCallback(() => {
1212
+ const main = videoRef.current;
1213
+ const src = main?.currentSrc || main?.src || "";
1214
+ if (!src) return null;
1215
+ if (!offscreenRef.current) {
1216
+ const v = document.createElement("video");
1217
+ v.muted = true;
1218
+ v.preload = "metadata";
1219
+ v.crossOrigin = "anonymous";
1220
+ v.setAttribute("playsinline", "");
1221
+ offscreenRef.current = v;
1222
+ }
1223
+ const offscreen = offscreenRef.current;
1224
+ if (loadedSrcRef.current !== src) {
1225
+ loadedSrcRef.current = src;
1226
+ cacheRef.current.clear();
1227
+ offscreen.src = src;
1228
+ }
1229
+ return offscreen;
1230
+ }, [videoRef]);
1231
+ const clearPreview = useCallback(() => {
1232
+ if (timerRef.current) {
1233
+ clearTimeout(timerRef.current);
1234
+ timerRef.current = null;
1235
+ }
1236
+ requestSeqRef.current += 1;
1237
+ setThumbnail(null);
1238
+ setTime(null);
1239
+ }, []);
1240
+ const requestPreview = useCallback(
1241
+ (timeSeconds) => {
1242
+ const main = videoRef.current;
1243
+ if (!main) return;
1244
+ const duration = main.duration;
1245
+ const hasDuration = !!duration && !Number.isNaN(duration);
1246
+ const clamped = hasDuration ? Math.max(0, Math.min(timeSeconds, duration)) : Math.max(0, timeSeconds);
1247
+ setTime(clamped);
1248
+ if (!hasDuration) return;
1249
+ const seq = ++requestSeqRef.current;
1250
+ const bucket = Math.round(clamped);
1251
+ const cached = cacheRef.current.get(bucket);
1252
+ if (cached) {
1253
+ setThumbnail(cached);
1254
+ return;
1255
+ }
1256
+ if (timerRef.current) clearTimeout(timerRef.current);
1257
+ timerRef.current = setTimeout(() => {
1258
+ timerRef.current = null;
1259
+ const offscreen = syncOffscreenSource();
1260
+ if (!offscreen) return;
1261
+ captureFrameAt(offscreen, bucket, width, height).then((dataUrl) => {
1262
+ if (seq !== requestSeqRef.current) return;
1263
+ if (dataUrl) {
1264
+ cacheRef.current.set(bucket, dataUrl);
1265
+ setThumbnail(dataUrl);
1266
+ }
1267
+ });
1268
+ }, debounceMs);
1269
+ },
1270
+ [videoRef, syncOffscreenSource, debounceMs, width, height]
1271
+ );
1272
+ useEffect(() => {
1273
+ return () => {
1274
+ if (timerRef.current) clearTimeout(timerRef.current);
1275
+ const offscreen = offscreenRef.current;
1276
+ if (offscreen) offscreen.removeAttribute("src");
1277
+ };
1278
+ }, []);
1279
+ return { thumbnail, time, requestPreview, clearPreview };
1280
+ }
1281
+ function useABLoop(videoRef) {
1282
+ const [pointA, setA] = useState(null);
1283
+ const [pointB, setB] = useState(null);
1284
+ const setPointA = useCallback(() => {
1285
+ const el = videoRef.current;
1286
+ if (!el) return;
1287
+ const t = el.currentTime;
1288
+ setA(t);
1289
+ setB((b) => b !== null && b <= t ? null : b);
1290
+ }, [videoRef]);
1291
+ const setPointB = useCallback(() => {
1292
+ const el = videoRef.current;
1293
+ if (!el || pointA === null) return;
1294
+ const t = el.currentTime;
1295
+ if (t <= pointA) return;
1296
+ setB(t);
1297
+ }, [videoRef, pointA]);
1298
+ const clear = useCallback(() => {
1299
+ setA(null);
1300
+ setB(null);
1301
+ }, []);
1302
+ useEffect(() => {
1303
+ const el = videoRef.current;
1304
+ if (!el || pointA === null || pointB === null) return;
1305
+ const onTimeUpdate = () => {
1306
+ if (el.currentTime >= pointB || el.currentTime < pointA) {
1307
+ el.currentTime = pointA;
1308
+ }
1309
+ };
1310
+ el.addEventListener("timeupdate", onTimeUpdate);
1311
+ return () => el.removeEventListener("timeupdate", onTimeUpdate);
1312
+ }, [videoRef, pointA, pointB]);
1313
+ useEffect(() => {
1314
+ const el = videoRef.current;
1315
+ if (!el) return;
1316
+ const onReset = () => clear();
1317
+ el.addEventListener("loadstart", onReset);
1318
+ el.addEventListener("emptied", onReset);
1319
+ return () => {
1320
+ el.removeEventListener("loadstart", onReset);
1321
+ el.removeEventListener("emptied", onReset);
1322
+ };
1323
+ }, [videoRef, clear]);
1324
+ return {
1325
+ pointA,
1326
+ pointB,
1327
+ isLooping: pointA !== null && pointB !== null,
1328
+ setPointA,
1329
+ setPointB,
1330
+ clear
1331
+ };
1332
+ }
1333
+ var SWIPE_THRESHOLD = 10;
1334
+ var DOUBLE_TAP_DISTANCE = 40;
1335
+ function useTouchGestures(targetRef, handlers, options = {}) {
1336
+ const { enabled = true, seekSeconds = 10, doubleTapMs = 300, feedbackMs = 700 } = options;
1337
+ const [feedback, setFeedback] = useState(null);
1338
+ const handlersRef = useRef(handlers);
1339
+ useEffect(() => {
1340
+ handlersRef.current = handlers;
1341
+ });
1342
+ const startRef = useRef(null);
1343
+ const phaseRef = useRef("idle");
1344
+ const swipeBaseRef = useRef(0);
1345
+ const lastTapRef = useRef(null);
1346
+ const feedbackTimerRef = useRef(null);
1347
+ const showFeedback = useCallback(
1348
+ (fb) => {
1349
+ setFeedback(fb);
1350
+ if (feedbackTimerRef.current) clearTimeout(feedbackTimerRef.current);
1351
+ feedbackTimerRef.current = setTimeout(() => setFeedback(null), feedbackMs);
1352
+ },
1353
+ [feedbackMs]
1354
+ );
1355
+ useEffect(() => {
1356
+ const el = targetRef.current;
1357
+ if (!el || !enabled) return;
1358
+ const onTouchStart = (e) => {
1359
+ if (e.touches.length !== 1) {
1360
+ startRef.current = null;
1361
+ phaseRef.current = "ignore";
1362
+ return;
1363
+ }
1364
+ const t = e.touches[0];
1365
+ const rect = el.getBoundingClientRect();
1366
+ const relX = t.clientX - rect.left;
1367
+ startRef.current = {
1368
+ x: t.clientX,
1369
+ y: t.clientY,
1370
+ zone: relX < rect.width / 2 ? "left" : "right",
1371
+ height: rect.height || 1
1372
+ };
1373
+ phaseRef.current = "pending";
1374
+ };
1375
+ const onTouchMove = (e) => {
1376
+ const start = startRef.current;
1377
+ if (!start || phaseRef.current === "ignore" || phaseRef.current === "idle") return;
1378
+ const t = e.touches[0];
1379
+ if (!t) return;
1380
+ const dx = t.clientX - start.x;
1381
+ const dy = t.clientY - start.y;
1382
+ if (phaseRef.current === "pending") {
1383
+ if (Math.hypot(dx, dy) < SWIPE_THRESHOLD) return;
1384
+ if (Math.abs(dy) > Math.abs(dx)) {
1385
+ phaseRef.current = "swipe";
1386
+ const h = handlersRef.current;
1387
+ swipeBaseRef.current = start.zone === "left" ? h.getBrightness?.() ?? 1 : h.getVolume?.() ?? 1;
1388
+ } else {
1389
+ phaseRef.current = "ignore";
1390
+ return;
1391
+ }
1392
+ }
1393
+ if (phaseRef.current === "swipe") {
1394
+ if (e.cancelable) e.preventDefault();
1395
+ const fraction = -dy / start.height;
1396
+ const next = Math.max(0, Math.min(1, swipeBaseRef.current + fraction));
1397
+ const h = handlersRef.current;
1398
+ if (start.zone === "left") {
1399
+ h.setBrightness?.(next);
1400
+ showFeedback({ type: "brightness", value: next });
1401
+ } else {
1402
+ h.setVolume?.(next);
1403
+ showFeedback({ type: "volume", value: next });
1404
+ }
1405
+ }
1406
+ };
1407
+ const onTouchEnd = (e) => {
1408
+ const start = startRef.current;
1409
+ const phase = phaseRef.current;
1410
+ startRef.current = null;
1411
+ phaseRef.current = "idle";
1412
+ if (!start || phase === "ignore") return;
1413
+ if (phase === "swipe") {
1414
+ lastTapRef.current = null;
1415
+ return;
1416
+ }
1417
+ const point = e.changedTouches[0];
1418
+ const px = point ? point.clientX : start.x;
1419
+ const py = point ? point.clientY : start.y;
1420
+ const now = Date.now();
1421
+ const last = lastTapRef.current;
1422
+ if (last && last.zone === start.zone && now - last.time < doubleTapMs && Math.hypot(px - last.x, py - last.y) < DOUBLE_TAP_DISTANCE) {
1423
+ lastTapRef.current = null;
1424
+ if (e.cancelable) e.preventDefault();
1425
+ const h = handlersRef.current;
1426
+ if (start.zone === "left") {
1427
+ h.seekBy?.(-seekSeconds);
1428
+ showFeedback({ type: "seek", direction: "backward", seconds: seekSeconds });
1429
+ } else {
1430
+ h.seekBy?.(seekSeconds);
1431
+ showFeedback({ type: "seek", direction: "forward", seconds: seekSeconds });
1432
+ }
1433
+ } else {
1434
+ lastTapRef.current = { x: px, y: py, time: now, zone: start.zone };
1435
+ }
1436
+ };
1437
+ el.addEventListener("touchstart", onTouchStart, { passive: true });
1438
+ el.addEventListener("touchmove", onTouchMove, { passive: false });
1439
+ el.addEventListener("touchend", onTouchEnd, { passive: false });
1440
+ return () => {
1441
+ el.removeEventListener("touchstart", onTouchStart);
1442
+ el.removeEventListener("touchmove", onTouchMove);
1443
+ el.removeEventListener("touchend", onTouchEnd);
1444
+ };
1445
+ }, [targetRef, enabled, seekSeconds, doubleTapMs, showFeedback]);
1446
+ useEffect(() => {
1447
+ return () => {
1448
+ if (feedbackTimerRef.current) clearTimeout(feedbackTimerRef.current);
1449
+ };
1450
+ }, []);
1451
+ return { feedback };
1452
+ }
1453
+
1454
+ export { useABLoop, useChapters, useFullscreen, useKeyboardShortcuts, useMagnet, useMediaSession, usePictureInPicture, usePlaylist, useProgressPersistence, useSeekPreview, useSubtitles, useTouchGestures, useVideoFilters, useVideoInfo, useVideoPlayback };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightbird/core",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Client-side video player engine. Plays MKV, MP4, WebM with full subtitle, audio track, and chapter support. No server required.",
5
5
  "license": "MIT",
6
6
  "author": "Punyam Singh",