This commit is contained in:
Yuriy Panov
2026-02-02 17:12:25 +06:00
commit 3004f712f3
19 changed files with 1157 additions and 0 deletions

165
books/screens/Scanner.tsx Normal file
View File

@@ -0,0 +1,165 @@
import React, { useRef, useState, useEffect } from 'react';
import { extractBookData } from '../services/geminiService';
import { Book } from '../types';
interface ScannerProps {
onCancel: () => void;
onDetected: (data: Partial<Book>) => void;
}
export const Scanner: React.FC<ScannerProps> = ({ onCancel, onDetected }) => {
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let stream: MediaStream | null = null;
async function startCamera() {
try {
// Try preferred environment camera first
try {
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment' }
});
} catch (innerErr) {
console.warn("Environment camera not found, falling back to default camera...");
// Fallback to any available video source
stream = await navigator.mediaDevices.getUserMedia({
video: true
});
}
if (videoRef.current) {
videoRef.current.srcObject = stream;
}
} catch (err) {
setError('Не удалось получить доступ к камере. Убедитесь, что разрешения предоставлены.');
console.error("Camera access error:", err);
}
}
startCamera();
return () => {
if (stream) {
stream.getTracks().forEach(track => track.stop());
}
};
}, []);
const handleCapture = async () => {
if (!videoRef.current || !canvasRef.current || isProcessing) return;
// Ensure the video is actually playing and has dimensions
if (videoRef.current.readyState < 2 || videoRef.current.videoWidth === 0) {
setError('Камера еще не готова. Пожалуйста, подождите.');
return;
}
setIsProcessing(true);
setError(null);
const canvas = canvasRef.current;
const video = videoRef.current;
// Scale down large images to avoid API limits/errors while keeping readability
const MAX_WIDTH = 1024;
const scale = Math.min(1, MAX_WIDTH / video.videoWidth);
canvas.width = video.videoWidth * scale;
canvas.height = video.videoHeight * scale;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
// Use standard quality for reliable extraction
const base64Image = canvas.toDataURL('image/jpeg', 0.85).split(',')[1];
try {
const bookData = await extractBookData(base64Image);
onDetected({
...bookData,
coverUrl: canvas.toDataURL('image/jpeg') // Local preview for the form
});
} catch (err: any) {
console.error("OCR Error:", err);
setError('Не удалось распознать книгу. Попробуйте еще раз с лучшим освещением.');
setIsProcessing(false);
}
}
};
return (
<div className="relative h-screen w-full overflow-hidden bg-black flex flex-col">
<video
ref={videoRef}
autoPlay
playsInline
muted
className="absolute inset-0 w-full h-full object-cover opacity-80"
/>
<canvas ref={canvasRef} className="hidden" />
<div className="absolute inset-0 flex flex-col justify-between z-10">
<div className="flex items-center justify-between p-4 pt-12 bg-gradient-to-b from-black/70 to-transparent">
<button onClick={onCancel} className="flex items-center justify-center w-10 h-10 rounded-full bg-black/40 text-white backdrop-blur-md">
<span className="material-symbols-outlined text-2xl">close</span>
</button>
<div className="px-4 py-1.5 rounded-full bg-black/40 backdrop-blur-md border border-white/10">
<span className="text-white text-xs font-medium tracking-wide">СКАНЕР</span>
</div>
<div className="w-10"></div>
</div>
<div className="flex-1 flex flex-col items-center justify-center relative w-full">
<div className="relative w-[75%] aspect-[2/3] rounded-2xl border border-white/20 shadow-2xl">
<div className="absolute inset-0 rounded-2xl shadow-[0_0_0_9999px_rgba(0,0,0,0.65)] pointer-events-none"></div>
<div className="absolute -top-1 -left-1 w-8 h-8 border-t-4 border-l-4 border-primary rounded-tl-xl z-20"></div>
<div className="absolute -top-1 -right-1 w-8 h-8 border-t-4 border-r-4 border-primary rounded-tr-xl z-20"></div>
<div className="absolute -bottom-1 -left-1 w-8 h-8 border-b-4 border-l-4 border-primary rounded-bl-xl z-20"></div>
<div className="absolute -bottom-1 -right-1 w-8 h-8 border-b-4 border-r-4 border-primary rounded-br-xl z-20"></div>
<div className="absolute inset-0 overflow-hidden rounded-2xl z-10">
<div className="w-full h-1/2 bg-gradient-to-b from-transparent via-primary/30 to-transparent opacity-50 absolute top-0 animate-scan"></div>
<div className="w-full h-0.5 bg-primary shadow-[0_0_15px_rgba(23,207,84,0.8)] absolute top-1/2 animate-scan"></div>
</div>
</div>
<div className="mt-8 text-center px-8">
<p className="text-white font-medium drop-shadow-md">
Поместите обложку в рамку
</p>
{isProcessing && (
<p className="text-primary text-sm font-medium mt-2 animate-pulse">
Распознавание...
</p>
)}
{error && (
<p className="text-red-400 text-sm font-medium mt-2 bg-black/50 p-2 rounded-lg backdrop-blur-sm">
{error}
</p>
)}
</div>
</div>
<div className="w-full bg-gradient-to-t from-black/90 via-black/80 to-transparent pb-10 pt-12 px-6">
<div className="flex items-center justify-center max-w-md mx-auto">
<button
disabled={isProcessing}
onClick={handleCapture}
className="relative group cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
<div className={`w-20 h-20 rounded-full border-4 ${isProcessing ? 'border-primary' : 'border-white/30'} flex items-center justify-center transition-all duration-300`}>
<div className={`w-16 h-16 ${isProcessing ? 'bg-primary animate-pulse' : 'bg-white'} rounded-full transition-all duration-200 group-active:scale-90 shadow-[0_0_20px_rgba(255,255,255,0.3)]`}></div>
</div>
</button>
</div>
</div>
</div>
</div>
);
};