mirror of
https://github.com/trafficlunar/tomodachi-share.git
synced 2026-03-28 11:13:16 +00:00
239 lines
7.6 KiB
TypeScript
239 lines
7.6 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import jsQR from "jsqr";
|
|
import { Icon } from "@iconify/react";
|
|
|
|
import QrFinder from "./qr-finder";
|
|
import { useSelect } from "downshift";
|
|
|
|
interface Props {
|
|
isOpen: boolean;
|
|
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
|
setImage?: React.Dispatch<React.SetStateAction<string | undefined>>;
|
|
setQrBytesRaw?: React.Dispatch<React.SetStateAction<number[]>>;
|
|
}
|
|
|
|
export default function Camera({ isOpen, setIsOpen, setImage, setQrBytesRaw }: Props) {
|
|
const [isVisible, setIsVisible] = useState(false);
|
|
|
|
const [permissionGranted, setPermissionGranted] = useState<boolean | null>(null);
|
|
|
|
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
|
|
const [selectedDeviceId, setSelectedDeviceId] = useState<string | null>(null);
|
|
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
const requestRef = useRef<number>(null);
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
|
|
const cameraItems = devices.map((device) => ({
|
|
value: device.deviceId,
|
|
label: device.label || `Camera ${devices.indexOf(device) + 1}`,
|
|
}));
|
|
|
|
const {
|
|
isOpen: isDropdownOpen,
|
|
getToggleButtonProps,
|
|
getMenuProps,
|
|
getItemProps,
|
|
highlightedIndex,
|
|
selectedItem,
|
|
} = useSelect({
|
|
items: cameraItems,
|
|
selectedItem: cameraItems.find((item) => item.value === selectedDeviceId) ?? null,
|
|
onSelectedItemChange: ({ selectedItem }) => {
|
|
setSelectedDeviceId(selectedItem?.value ?? null);
|
|
},
|
|
});
|
|
|
|
const takePicture = useCallback(() => {
|
|
if (!isOpen) return;
|
|
|
|
// Continue scanning in a loop
|
|
if (setQrBytesRaw) requestRef.current = requestAnimationFrame(takePicture);
|
|
|
|
const video = videoRef.current;
|
|
const canvas = canvasRef.current;
|
|
if (!video || video.videoWidth === 0 || video.videoHeight === 0 || !canvas) return;
|
|
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) return;
|
|
|
|
canvas.width = video.videoWidth;
|
|
canvas.height = video.videoHeight;
|
|
ctx.drawImage(video, 0, 0, video.videoWidth, video.videoHeight);
|
|
|
|
if (setImage) {
|
|
setImage(canvas.toDataURL());
|
|
close();
|
|
return;
|
|
}
|
|
if (!setQrBytesRaw) return;
|
|
|
|
const imageData = ctx.getImageData(0, 0, video.videoWidth, video.videoHeight);
|
|
const code = jsQR(imageData.data, imageData.width, imageData.height);
|
|
if (!code || !code.binaryData) return;
|
|
|
|
// Cancel animation frame to stop scanning
|
|
if (requestRef.current) {
|
|
cancelAnimationFrame(requestRef.current);
|
|
requestRef.current = null;
|
|
}
|
|
|
|
setQrBytesRaw(code.binaryData);
|
|
close();
|
|
}, [isOpen, setIsOpen, setQrBytesRaw]);
|
|
|
|
const requestPermission = () => {
|
|
if (!navigator.mediaDevices) return;
|
|
|
|
navigator.mediaDevices
|
|
.getUserMedia({ video: true, audio: false })
|
|
.then(() => setPermissionGranted(true))
|
|
.catch((err) => {
|
|
setPermissionGranted(false);
|
|
console.error("An error occurred trying to access the camera", err);
|
|
});
|
|
};
|
|
|
|
const close = () => {
|
|
setIsVisible(false);
|
|
setTimeout(() => {
|
|
setIsOpen(false);
|
|
}, 300);
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
// slight delay to trigger animation
|
|
setTimeout(() => setIsVisible(true), 10);
|
|
requestPermission();
|
|
}
|
|
}, [isOpen]);
|
|
|
|
useEffect(() => {
|
|
if (!isOpen || !permissionGranted) return;
|
|
|
|
navigator.mediaDevices
|
|
.enumerateDevices()
|
|
.then((devices) => {
|
|
const videoDevices = devices.filter((d) => d.kind === "videoinput");
|
|
setDevices(videoDevices);
|
|
|
|
const targetDeviceId = selectedDeviceId || videoDevices[0]?.deviceId;
|
|
if (!targetDeviceId) return;
|
|
setSelectedDeviceId(targetDeviceId);
|
|
|
|
// start camera stream
|
|
return navigator.mediaDevices.getUserMedia({
|
|
video: { deviceId: targetDeviceId },
|
|
audio: false,
|
|
});
|
|
})
|
|
.then((stream) => {
|
|
if (!stream || !videoRef.current) return;
|
|
videoRef.current.srcObject = stream;
|
|
videoRef.current.play();
|
|
})
|
|
.catch((err) => console.error("Camera error", err));
|
|
|
|
if (setQrBytesRaw) requestRef.current = requestAnimationFrame(takePicture);
|
|
|
|
// cleanup
|
|
return () => {
|
|
if (requestRef.current) {
|
|
cancelAnimationFrame(requestRef.current);
|
|
}
|
|
if (videoRef.current?.srcObject) {
|
|
const stream = videoRef.current.srcObject as MediaStream;
|
|
stream.getTracks().forEach((track) => track.stop());
|
|
videoRef.current.srcObject = null;
|
|
}
|
|
};
|
|
}, [isOpen, permissionGranted, selectedDeviceId, takePicture]);
|
|
|
|
return (
|
|
<div className={`fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40 ${!isOpen ? "hidden" : ""}`}>
|
|
<div
|
|
onClick={close}
|
|
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
|
|
/>
|
|
|
|
<div
|
|
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg p-6 w-full max-w-md transition-discrete duration-300 ${
|
|
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
|
|
}`}
|
|
>
|
|
<div className="flex justify-between items-center mb-2">
|
|
<h2 className="text-xl font-bold">{setQrBytesRaw ? "Scan QR Code" : "Take Picture"}</h2>
|
|
<button type="button" aria-label="Close" onClick={close} className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
|
|
<Icon icon="material-symbols:close-rounded" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className={`mb-4 flex flex-col gap-1 ${devices.length <= 1 ? "hidden" : ""}`}>
|
|
<label className="text-sm font-semibold">Camera:</label>
|
|
<div className="relative w-full">
|
|
{/* Toggle button to open the dropdown */}
|
|
<button
|
|
type="button"
|
|
aria-label="Select camera dropdown"
|
|
{...getToggleButtonProps({}, { suppressRefError: true })}
|
|
className="pill input w-full px-2! py-0.5! justify-between! text-sm"
|
|
>
|
|
{selectedItem?.label || "Select a camera"}
|
|
|
|
<Icon icon="tabler:chevron-down" className="ml-2 size-5" />
|
|
</button>
|
|
|
|
{/* Dropdown menu */}
|
|
<ul
|
|
{...getMenuProps({}, { suppressRefError: true })}
|
|
className={`absolute z-50 w-full bg-orange-200 border-2 border-orange-400 rounded-lg mt-1 shadow-lg max-h-60 overflow-y-auto ${
|
|
isDropdownOpen ? "block" : "hidden"
|
|
}`}
|
|
>
|
|
{isDropdownOpen &&
|
|
cameraItems.map((item, index) => (
|
|
<li
|
|
key={item.value}
|
|
{...getItemProps({ item, index })}
|
|
className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}
|
|
>
|
|
{item.label}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={`relative w-full ${setQrBytesRaw ? "aspect-square" : ""}`}>
|
|
{!permissionGranted && (
|
|
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center rounded-2xl bg-amber-50 border-2 border-amber-500 text-center p-8">
|
|
<p className="text-red-400 font-bold text-lg mb-2">Camera access denied</p>
|
|
<p className="text-gray-600">Please allow camera access in your browser settings to {setQrBytesRaw ? "scan QR codes" : "take pictures"}</p>
|
|
<button type="button" onClick={requestPermission} className="pill button text-xs mt-2 py-0.5! px-2!">
|
|
Request Permission
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<video ref={videoRef} className={`size-full rounded-2xl border-2 border-amber-500 max-h-96 ${setQrBytesRaw ? "object-cover" : ""}`} />
|
|
{setQrBytesRaw && <QrFinder />}
|
|
<canvas ref={canvasRef} className="hidden" />
|
|
</div>
|
|
|
|
<div className="mt-4 flex justify-center gap-2">
|
|
<button type="button" onClick={close} className="pill button">
|
|
Cancel
|
|
</button>
|
|
{setImage && (
|
|
<button type="button" onClick={takePicture} className="pill button">
|
|
Take Picture
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|