diff --git a/pages/utilities/cam.tsx b/pages/utilities/cam.tsx new file mode 100644 index 0000000..7107397 --- /dev/null +++ b/pages/utilities/cam.tsx @@ -0,0 +1,479 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import PageHeader from "@/components/PageHeader"; +import Header from "@/components/Header"; +import { CMDK } from "@/components/CMDK"; +import CallToActionGrid from "@/components/CallToActionGrid"; +import Meta from "@/components/Meta"; +import { Card } from "@/components/ds/CardComponent"; +import { Button } from "@/components/ds/ButtonComponent"; +import { cn } from "@/lib/utils"; + +type CameraStatus = "idle" | "requesting" | "active" | "pip" | "error"; + +type ErrorType = + | "permission_denied" + | "not_supported" + | "pip_not_supported" + | "stream_error"; + +interface ErrorInfo { + type: ErrorType; + title: string; + message: string; + instructions: string[]; +} + +const getPermissionDeniedError = (): ErrorInfo => ({ + type: "permission_denied", + title: "Camera Access Denied", + message: + "You've denied camera access. To use this feature, you'll need to enable camera permissions in your browser.", + instructions: [ + "Look for the camera icon in your browser's address bar and click it to change permissions", + "Alternatively, go to your browser's Settings > Privacy & Security > Site Settings > Camera", + "Find this website and change the camera permission to 'Allow'", + "Refresh this page after enabling camera access", + ], +}); + +const getNotSupportedError = (): ErrorInfo => ({ + type: "not_supported", + title: "Camera Not Supported", + message: + "Your browser doesn't support camera access. This feature requires a modern browser with MediaDevices API support.", + instructions: [ + "Try using a modern browser like Chrome, Edge, Safari, or Firefox", + "Make sure your browser is up to date", + "If you're on iOS, use Safari as other browsers have limited camera support", + ], +}); + +const getPipNotSupportedError = (): ErrorInfo => ({ + type: "pip_not_supported", + title: "Picture-in-Picture Not Supported", + message: + "Your browser doesn't support Picture-in-Picture mode. You can still use the camera preview on this page.", + instructions: [ + "Picture-in-Picture works best in Chrome, Edge, and Safari", + "Firefox has limited PIP support for camera streams", + "Try updating your browser to the latest version", + ], +}); + +const getStreamError = (errorMessage?: string): ErrorInfo => ({ + type: "stream_error", + title: "Camera Error", + message: + errorMessage || + "An error occurred while accessing your camera. Please try again.", + instructions: [ + "Make sure no other application is using your camera", + "Check that your camera is properly connected", + "Try refreshing the page and granting camera access again", + ], +}); + +export default function CameraUtility() { + const [status, setStatus] = useState("idle"); + const [errorInfo, setErrorInfo] = useState(null); + const [isPipSupported, setIsPipSupported] = useState(true); + const [isMediaSupported, setIsMediaSupported] = useState(true); + + const videoRef = useRef(null); + const sourceVideoRef = useRef(null); + const canvasRef = useRef(null); + const streamRef = useRef(null); + const mirroredStreamRef = useRef(null); + const animationFrameRef = useRef(null); + + useEffect(() => { + setIsPipSupported( + typeof document !== "undefined" && + "pictureInPictureEnabled" in document && + document.pictureInPictureEnabled + ); + setIsMediaSupported( + typeof navigator !== "undefined" && + "mediaDevices" in navigator && + "getUserMedia" in navigator.mediaDevices + ); + }, []); + + useEffect(() => { + return () => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + if (streamRef.current) { + streamRef.current.getTracks().forEach((track) => track.stop()); + } + if (mirroredStreamRef.current) { + mirroredStreamRef.current.getTracks().forEach((track) => track.stop()); + } + if (sourceVideoRef.current) { + sourceVideoRef.current.srcObject = null; + sourceVideoRef.current = null; + } + if (canvasRef.current) { + canvasRef.current = null; + } + }; + }, []); + + useEffect(() => { + const video = videoRef.current; + if (!video) return; + + const handlePipExit = () => { + if (status === "pip") { + setStatus("active"); + } + }; + + video.addEventListener("leavepictureinpicture", handlePipExit); + return () => { + video.removeEventListener("leavepictureinpicture", handlePipExit); + }; + }, [status]); + + useEffect(() => { + const video = videoRef.current; + const mirroredStream = mirroredStreamRef.current; + + if (video && mirroredStream && (status === "active" || status === "pip")) { + if (video.srcObject !== mirroredStream) { + video.srcObject = mirroredStream; + video.play().catch(() => { + // Ignore play errors - browser may block autoplay + }); + } + } + }, [status]); + + const createMirroredStream = useCallback( + (originalStream: MediaStream): MediaStream => { + const videoTrack = originalStream.getVideoTracks()[0]; + const settings = videoTrack.getSettings(); + const width = settings.width || 1280; + const height = settings.height || 720; + + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + canvasRef.current = canvas; + + const ctx = canvas.getContext("2d"); + if (!ctx) { + return originalStream; + } + + const sourceVideo = document.createElement("video"); + sourceVideo.srcObject = originalStream; + sourceVideo.muted = true; + sourceVideo.playsInline = true; + sourceVideoRef.current = sourceVideo; + + const drawMirroredFrame = () => { + if (!sourceVideoRef.current || !canvasRef.current) return; + + ctx.save(); + ctx.scale(-1, 1); + ctx.drawImage(sourceVideo, -width, 0, width, height); + ctx.restore(); + + animationFrameRef.current = requestAnimationFrame(drawMirroredFrame); + }; + + sourceVideo.onloadedmetadata = () => { + sourceVideo.play(); + drawMirroredFrame(); + }; + + const mirroredStream = canvas.captureStream(30); + mirroredStreamRef.current = mirroredStream; + + return mirroredStream; + }, + [] + ); + + const startCamera = useCallback(async () => { + if (!isMediaSupported) { + setErrorInfo(getNotSupportedError()); + setStatus("error"); + return; + } + + setStatus("requesting"); + setErrorInfo(null); + + try { + const stream = await navigator.mediaDevices.getUserMedia({ + video: { + facingMode: "user", + width: { ideal: 1280 }, + height: { ideal: 720 }, + }, + audio: false, + }); + + streamRef.current = stream; + + const mirroredStream = createMirroredStream(stream); + + if (videoRef.current) { + videoRef.current.srcObject = mirroredStream; + await videoRef.current.play(); + } + + setStatus("active"); + } catch (err) { + const error = err as Error; + + if ( + error.name === "NotAllowedError" || + error.name === "PermissionDeniedError" + ) { + setErrorInfo(getPermissionDeniedError()); + } else if ( + error.name === "NotFoundError" || + error.name === "DevicesNotFoundError" + ) { + setErrorInfo( + getStreamError( + "No camera found. Please connect a camera and try again." + ) + ); + } else if ( + error.name === "NotReadableError" || + error.name === "TrackStartError" + ) { + setErrorInfo( + getStreamError( + "Camera is in use by another application. Please close other apps using the camera and try again." + ) + ); + } else { + setErrorInfo(getStreamError(error.message)); + } + + setStatus("error"); + } + }, [isMediaSupported, createMirroredStream]); + + const enablePip = useCallback(async () => { + if (!videoRef.current) return; + + if (!isPipSupported) { + setErrorInfo(getPipNotSupportedError()); + return; + } + + try { + await videoRef.current.requestPictureInPicture(); + setStatus("pip"); + } catch (err) { + const error = err as Error; + if (error.name === "NotAllowedError") { + setErrorInfo({ + type: "pip_not_supported", + title: "PIP Request Denied", + message: + "Picture-in-Picture was blocked. This usually happens if the request wasn't triggered by a user action.", + instructions: ["Try clicking the button again"], + }); + } + } + }, [isPipSupported]); + + const exitPip = useCallback(async () => { + try { + if (document.pictureInPictureElement) { + await document.exitPictureInPicture(); + } + setStatus("active"); + } catch { + setStatus("active"); + } + }, []); + + const stopCamera = useCallback(async () => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = null; + } + + if (streamRef.current) { + streamRef.current.getTracks().forEach((track) => track.stop()); + streamRef.current = null; + } + + if (mirroredStreamRef.current) { + mirroredStreamRef.current.getTracks().forEach((track) => track.stop()); + mirroredStreamRef.current = null; + } + + if (sourceVideoRef.current) { + sourceVideoRef.current.srcObject = null; + sourceVideoRef.current = null; + } + + canvasRef.current = null; + + try { + if (document.pictureInPictureElement) { + await document.exitPictureInPicture(); + } + } catch { + // Ignore errors when exiting PIP + } + + if (videoRef.current) { + videoRef.current.srcObject = null; + } + + setStatus("idle"); + setErrorInfo(null); + }, []); + + const retry = useCallback(() => { + setErrorInfo(null); + setStatus("idle"); + }, []); + + return ( +
+ +
+ + +
+ +
+ +
+ + {status === "idle" && ( +
+

+ Click the button below to start your camera and enable + Picture-in-Picture mode. +

+ +
+ )} + + {status === "requesting" && ( +
+
+
+

+ Requesting camera access... +

+
+
+ )} + + {status === "error" && errorInfo && ( +
+
+

+ {errorInfo.title} +

+

+ {errorInfo.message} +

+
+

How to fix this:

+
    + {errorInfo.instructions.map((instruction, index) => ( +
  • {instruction}
  • + ))} +
+
+
+
+ +
+
+ )} + + {(status === "active" || status === "pip") && ( +
+
+
+ +
+ {status === "active" && ( + <> + + + + )} + + {status === "pip" && ( + <> + + + + )} +
+ + {!isPipSupported && status === "active" && ( +

+ Picture-in-Picture is not supported in your browser. Try using + Chrome, Edge, or Safari. +

+ )} +
+ )} + +
+ + +
+ ); +}