209 lines
11 KiB
JavaScript
209 lines
11 KiB
JavaScript
|
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|||
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|||
|
|
import { createPortal } from "react-dom";
|
|||
|
|
import { Popover } from "antd";
|
|||
|
|
import QrScanner from "qr-scanner";
|
|||
|
|
import { CloseOutlined } from "@ant-design/icons";
|
|||
|
|
const VideoQRScannerPlugin = ({ player, enabled = true, scanInterval = 200, scanningScale = 1, maxFailedAttempts = 2, }) => {
|
|||
|
|
const canvasRef = useRef(null);
|
|||
|
|
const [detectedQr, setDetectedQr] = useState(null);
|
|||
|
|
const animationFrameRef = useRef(null);
|
|||
|
|
const ignoredQRCodes = useRef(new Set());
|
|||
|
|
const [playerEl, setPlayerEl] = useState(null);
|
|||
|
|
const lastScanTimeRef = useRef(0);
|
|||
|
|
const isScanningRef = useRef(false);
|
|||
|
|
// Счётчик неудачных попыток обнаружения QR, если код уже отображается
|
|||
|
|
const failedAttemptsRef = useRef(0);
|
|||
|
|
// Для получения актуального значения detectedQr внутри асинхронного колбэка
|
|||
|
|
const detectedQrRef = useRef(null);
|
|||
|
|
useEffect(() => {
|
|||
|
|
detectedQrRef.current = detectedQr;
|
|||
|
|
}, [detectedQr]);
|
|||
|
|
// Изначально устанавливаем контейнер для портала как элемент плеера
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (player) {
|
|||
|
|
setPlayerEl(player.el());
|
|||
|
|
}
|
|||
|
|
}, [player]);
|
|||
|
|
// Обновление контейнера при переходе в полноэкранный режим
|
|||
|
|
useEffect(() => {
|
|||
|
|
const handleFullscreenChange = () => {
|
|||
|
|
if (document.fullscreenElement) {
|
|||
|
|
setPlayerEl(document.fullscreenElement);
|
|||
|
|
}
|
|||
|
|
else if (player) {
|
|||
|
|
setPlayerEl(player.el());
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
document.addEventListener("fullscreenchange", handleFullscreenChange);
|
|||
|
|
return () => {
|
|||
|
|
document.removeEventListener("fullscreenchange", handleFullscreenChange);
|
|||
|
|
};
|
|||
|
|
}, [player]);
|
|||
|
|
const scanFrame = useCallback(async () => {
|
|||
|
|
if (!player || player.paused() || !canvasRef.current || !enabled)
|
|||
|
|
return;
|
|||
|
|
const now = performance.now();
|
|||
|
|
if (now - lastScanTimeRef.current < scanInterval) {
|
|||
|
|
animationFrameRef.current = requestAnimationFrame(scanFrame);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
lastScanTimeRef.current = now;
|
|||
|
|
// Предотвращаем параллельное выполнение сканирования
|
|||
|
|
if (isScanningRef.current) {
|
|||
|
|
animationFrameRef.current = requestAnimationFrame(scanFrame);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
isScanningRef.current = true;
|
|||
|
|
const videoEl = player.tech(true).el();
|
|||
|
|
const canvas = canvasRef.current;
|
|||
|
|
const ctx = canvas.getContext("2d");
|
|||
|
|
if (!ctx) {
|
|||
|
|
isScanningRef.current = false;
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
// Получаем оригинальные размеры видео
|
|||
|
|
const originalVideoWidth = videoEl.videoWidth;
|
|||
|
|
const originalVideoHeight = videoEl.videoHeight;
|
|||
|
|
// Устанавливаем canvas с пониженным разрешением для сканирования
|
|||
|
|
canvas.width = originalVideoWidth * scanningScale;
|
|||
|
|
canvas.height = originalVideoHeight * scanningScale;
|
|||
|
|
// Рисуем видео в canvas с пониженным разрешением
|
|||
|
|
ctx.drawImage(videoEl, 0, 0, canvas.width, canvas.height);
|
|||
|
|
try {
|
|||
|
|
const result = await QrScanner.scanImage(canvas, {
|
|||
|
|
returnDetailedScanResult: true,
|
|||
|
|
});
|
|||
|
|
if (result &&
|
|||
|
|
result.data &&
|
|||
|
|
result.cornerPoints?.length === 4 &&
|
|||
|
|
!ignoredQRCodes.current.has(result.data)) {
|
|||
|
|
// Сброс неудачных попыток при успешном обнаружении
|
|||
|
|
failedAttemptsRef.current = 0;
|
|||
|
|
// Преобразуем координаты из масштабированного canvas в координаты оригинального видео
|
|||
|
|
const points = result.cornerPoints.map(p => ({
|
|||
|
|
x: p.x / scanningScale,
|
|||
|
|
y: p.y / scanningScale,
|
|||
|
|
}));
|
|||
|
|
const minX = Math.min(...points.map(p => p.x));
|
|||
|
|
const minY = Math.min(...points.map(p => p.y));
|
|||
|
|
const maxX = Math.max(...points.map(p => p.x));
|
|||
|
|
const maxY = Math.max(...points.map(p => p.y));
|
|||
|
|
// Получаем размеры отображаемого видео
|
|||
|
|
const rect = videoEl.getBoundingClientRect();
|
|||
|
|
const displayScale = Math.min(rect.width / originalVideoWidth, rect.height / originalVideoHeight);
|
|||
|
|
const offsetX = (rect.width - originalVideoWidth * displayScale) / 2;
|
|||
|
|
const offsetY = (rect.height - originalVideoHeight * displayScale) / 2;
|
|||
|
|
const qrWidth = (maxX - minX) * displayScale;
|
|||
|
|
const qrHeight = (maxY - minY) * displayScale;
|
|||
|
|
const padding = 8;
|
|||
|
|
const x = minX * displayScale + offsetX - padding;
|
|||
|
|
const y = minY * displayScale + offsetY - padding;
|
|||
|
|
setDetectedQr({
|
|||
|
|
data: result.data,
|
|||
|
|
position: new DOMRect(x, y, qrWidth + padding * 2, qrHeight + padding * 2),
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
else {
|
|||
|
|
// Если код не найден, и он уже отображался, даем maxFailedAttempts попыток
|
|||
|
|
if (detectedQrRef.current) {
|
|||
|
|
failedAttemptsRef.current += 1;
|
|||
|
|
if (failedAttemptsRef.current >= maxFailedAttempts) {
|
|||
|
|
setDetectedQr(null);
|
|||
|
|
failedAttemptsRef.current = 0;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
catch (error) {
|
|||
|
|
// В случае ошибки аналогичная логика: если код уже отображался — даем maxFailedAttempts попыток
|
|||
|
|
if (detectedQrRef.current) {
|
|||
|
|
failedAttemptsRef.current += 1;
|
|||
|
|
if (failedAttemptsRef.current >= maxFailedAttempts) {
|
|||
|
|
setDetectedQr(null);
|
|||
|
|
failedAttemptsRef.current = 0;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
finally {
|
|||
|
|
isScanningRef.current = false;
|
|||
|
|
}
|
|||
|
|
animationFrameRef.current = requestAnimationFrame(scanFrame);
|
|||
|
|
}, [player, enabled, scanInterval, scanningScale, maxFailedAttempts]);
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (!player || !enabled)
|
|||
|
|
return;
|
|||
|
|
const startScanning = () => {
|
|||
|
|
if (animationFrameRef.current)
|
|||
|
|
cancelAnimationFrame(animationFrameRef.current);
|
|||
|
|
animationFrameRef.current = requestAnimationFrame(scanFrame);
|
|||
|
|
};
|
|||
|
|
const stopScanning = () => {
|
|||
|
|
if (animationFrameRef.current) {
|
|||
|
|
cancelAnimationFrame(animationFrameRef.current);
|
|||
|
|
animationFrameRef.current = null;
|
|||
|
|
}
|
|||
|
|
setDetectedQr(null);
|
|||
|
|
};
|
|||
|
|
player.on("play", startScanning);
|
|||
|
|
player.on("pause", stopScanning);
|
|||
|
|
player.on("ended", stopScanning);
|
|||
|
|
window.addEventListener("resize", startScanning);
|
|||
|
|
return () => {
|
|||
|
|
stopScanning();
|
|||
|
|
player.off("play", startScanning);
|
|||
|
|
player.off("pause", stopScanning);
|
|||
|
|
player.off("ended", stopScanning);
|
|||
|
|
window.removeEventListener("resize", startScanning);
|
|||
|
|
};
|
|||
|
|
}, [player, enabled, scanFrame]);
|
|||
|
|
const ignoreQRCode = () => {
|
|||
|
|
if (detectedQr) {
|
|||
|
|
ignoredQRCodes.current.add(detectedQr.data);
|
|||
|
|
setDetectedQr(null);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
return (_jsxs(_Fragment, { children: [_jsx("canvas", { ref: canvasRef, style: { display: "none" } }), detectedQr &&
|
|||
|
|
playerEl &&
|
|||
|
|
createPortal(_jsx(Popover, { getPopupContainer: () => playerEl, content: _jsx("a", { href: detectedQr.data, target: "_blank", rel: "noreferrer", referrerPolicy: "no-referrer", style: { color: "#1677ff" }, children: detectedQr.data }), placement: "top", children: _jsxs("div", { style: {
|
|||
|
|
position: "absolute",
|
|||
|
|
left: detectedQr.position.x,
|
|||
|
|
top: detectedQr.position.y,
|
|||
|
|
width: detectedQr.position.width,
|
|||
|
|
height: detectedQr.position.height,
|
|||
|
|
pointerEvents: "auto",
|
|||
|
|
cursor: "pointer",
|
|||
|
|
zIndex: 10,
|
|||
|
|
}, children: [["top-left", "top-right", "bottom-left", "bottom-right"].map(corner => (_jsx("div", { style: {
|
|||
|
|
position: "absolute",
|
|||
|
|
width: 20,
|
|||
|
|
height: 20,
|
|||
|
|
border: "3px solid var(--Accent-Primary)",
|
|||
|
|
borderRadius: 4,
|
|||
|
|
...(corner.includes("top") ? { top: 0 } : { bottom: 0 }),
|
|||
|
|
...(corner.includes("left") ? { left: 0 } : { right: 0 }),
|
|||
|
|
borderTop: corner.includes("bottom")
|
|||
|
|
? "none"
|
|||
|
|
: "3px solid var(--Accent-Primary)",
|
|||
|
|
borderBottom: corner.includes("top")
|
|||
|
|
? "none"
|
|||
|
|
: "3px solid var(--Accent-Primary)",
|
|||
|
|
borderLeft: corner.includes("right")
|
|||
|
|
? "none"
|
|||
|
|
: "3px solid var(--Accent-Primary)",
|
|||
|
|
borderRight: corner.includes("left")
|
|||
|
|
? "none"
|
|||
|
|
: "3px solid var(--Accent-Primary)",
|
|||
|
|
} }, corner))), _jsx(CloseOutlined, { style: {
|
|||
|
|
color: "#ff4d4f",
|
|||
|
|
position: "absolute",
|
|||
|
|
top: "-8px",
|
|||
|
|
right: "-8px",
|
|||
|
|
background: "white",
|
|||
|
|
borderRadius: "50%",
|
|||
|
|
padding: "2px",
|
|||
|
|
cursor: "pointer",
|
|||
|
|
}, onClick: ignoreQRCode })] }) }), playerEl)] }));
|
|||
|
|
};
|
|||
|
|
export default VideoQRScannerPlugin;
|
|||
|
|
//# sourceMappingURL=index.js.map
|