Files
bookshelf/books/screens/Scanner.tsx
Yuriy Panov 3004f712f3 initial
2026-02-02 17:12:25 +06:00

166 lines
6.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
};