@lightbird/core 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +246 -43
- package/dist/index.d.cts +25 -1
- package/dist/index.d.ts +25 -1
- package/dist/index.js +245 -44
- package/dist/react/index.cjs +313 -0
- package/dist/react/index.d.cts +101 -1
- package/dist/react/index.d.ts +101 -1
- package/dist/react/index.js +311 -1
- package/package.json +1 -1
package/dist/react/index.d.cts
CHANGED
|
@@ -284,4 +284,104 @@ interface UseMagnetReturn {
|
|
|
284
284
|
}
|
|
285
285
|
declare function useMagnet(): UseMagnetReturn;
|
|
286
286
|
|
|
287
|
-
|
|
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 };
|
package/dist/react/index.d.ts
CHANGED
|
@@ -284,4 +284,104 @@ interface UseMagnetReturn {
|
|
|
284
284
|
}
|
|
285
285
|
declare function useMagnet(): UseMagnetReturn;
|
|
286
286
|
|
|
287
|
-
|
|
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 };
|
package/dist/react/index.js
CHANGED
|
@@ -1141,4 +1141,314 @@ function useMagnet() {
|
|
|
1141
1141
|
return { torrentStatus, addMagnet, destroyMagnet };
|
|
1142
1142
|
}
|
|
1143
1143
|
|
|
1144
|
-
|
|
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.
|
|
3
|
+
"version": "0.6.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",
|