@lightbird/core 0.3.1 → 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.
@@ -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;
@@ -95,6 +95,18 @@ interface SubtitleTrackMeta {
95
95
  format: string | null;
96
96
  language: string | null;
97
97
  }
98
+ /** A single HLS quality rendition (e.g. 1080p, 720p). */
99
+ interface QualityLevel {
100
+ index: number;
101
+ height: number;
102
+ bitrate: number;
103
+ name: string;
104
+ }
105
+ /** Result of initialising an HLS stream via `HLSPlayer`. */
106
+ interface HLSPlayerFile {
107
+ url: string;
108
+ qualityLevels: QualityLevel[];
109
+ }
98
110
 
99
111
  declare function useVideoFilters(videoRef: RefObject<HTMLVideoElement | null>): {
100
112
  filters: VideoFilters;
@@ -236,7 +248,7 @@ interface MKVPlayerFile {
236
248
  activeSubtitleTrack: string;
237
249
  }
238
250
 
239
- type ProcessedFile = SimplePlayerFile | MKVPlayerFile;
251
+ type ProcessedFile = SimplePlayerFile | MKVPlayerFile | HLSPlayerFile;
240
252
  interface VideoPlayer {
241
253
  initialize(videoElement: HTMLVideoElement): Promise<ProcessedFile>;
242
254
  getAudioTracks(): AudioTrack[];
@@ -272,4 +284,104 @@ interface UseMagnetReturn {
272
284
  }
273
285
  declare function useMagnet(): UseMagnetReturn;
274
286
 
275
- 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 };
@@ -95,6 +95,18 @@ interface SubtitleTrackMeta {
95
95
  format: string | null;
96
96
  language: string | null;
97
97
  }
98
+ /** A single HLS quality rendition (e.g. 1080p, 720p). */
99
+ interface QualityLevel {
100
+ index: number;
101
+ height: number;
102
+ bitrate: number;
103
+ name: string;
104
+ }
105
+ /** Result of initialising an HLS stream via `HLSPlayer`. */
106
+ interface HLSPlayerFile {
107
+ url: string;
108
+ qualityLevels: QualityLevel[];
109
+ }
98
110
 
99
111
  declare function useVideoFilters(videoRef: RefObject<HTMLVideoElement | null>): {
100
112
  filters: VideoFilters;
@@ -236,7 +248,7 @@ interface MKVPlayerFile {
236
248
  activeSubtitleTrack: string;
237
249
  }
238
250
 
239
- type ProcessedFile = SimplePlayerFile | MKVPlayerFile;
251
+ type ProcessedFile = SimplePlayerFile | MKVPlayerFile | HLSPlayerFile;
240
252
  interface VideoPlayer {
241
253
  initialize(videoElement: HTMLVideoElement): Promise<ProcessedFile>;
242
254
  getAudioTracks(): AudioTrack[];
@@ -272,4 +284,104 @@ interface UseMagnetReturn {
272
284
  }
273
285
  declare function useMagnet(): UseMagnetReturn;
274
286
 
275
- 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 };