@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.
- package/dist/index.cjs +144 -6
- package/dist/index.d.cts +51 -3
- package/dist/index.d.ts +51 -3
- package/dist/index.js +142 -7
- package/dist/react/index.cjs +313 -0
- package/dist/react/index.d.cts +114 -2
- package/dist/react/index.d.ts +114 -2
- package/dist/react/index.js +311 -1
- package/package.json +2 -1
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.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",
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
"@openfeature/unleash-web-provider": "^0.1.1",
|
|
58
58
|
"@openfeature/web-sdk": "^1.6.0",
|
|
59
59
|
"ass-compiler": "^0.1.16",
|
|
60
|
+
"hls.js": "^1.6.16",
|
|
60
61
|
"webtorrent": "^2.8.5"
|
|
61
62
|
},
|
|
62
63
|
"optionalDependencies": {
|