feat: add camera capture component (refs #66)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m14s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 33s
Deploy to Staging / Verify Staging (pull_request) Successful in 2m19s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m14s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 33s
Deploy to Staging / Verify Staging (pull_request) Successful in 2m19s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
Implements a reusable React camera capture component with: - getUserMedia API for camera access on mobile and desktop - Translucent aspect-ratio guidance overlays (VIN ~6:1, receipt ~2:3) - Post-capture crop tool with draggable handles - File input fallback for desktop and unsupported browsers - Support for HEIC, JPEG, PNG (sent as-is to server) - Full mobile responsiveness (320px - 1920px) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* @ai-summary Hook for managing camera permission and media stream
|
||||
* @ai-context Handles getUserMedia API, permission state, and camera switching
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import type { PermissionState, FacingMode } from './types';
|
||||
|
||||
interface UseCameraPermissionOptions {
|
||||
/** Initial facing mode preference */
|
||||
initialFacingMode?: FacingMode;
|
||||
}
|
||||
|
||||
interface UseCameraPermissionReturn {
|
||||
/** Current permission state */
|
||||
permissionState: PermissionState;
|
||||
/** Active media stream */
|
||||
stream: MediaStream | null;
|
||||
/** Error message if any */
|
||||
error: string | null;
|
||||
/** Current camera facing mode */
|
||||
facingMode: FacingMode;
|
||||
/** Whether the device has multiple cameras */
|
||||
hasMultipleCameras: boolean;
|
||||
/** Request camera access */
|
||||
requestPermission: () => Promise<void>;
|
||||
/** Switch between front and rear camera */
|
||||
switchCamera: () => Promise<void>;
|
||||
/** Stop the media stream */
|
||||
stopStream: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing camera permissions and media streams
|
||||
*/
|
||||
export function useCameraPermission(
|
||||
options: UseCameraPermissionOptions = {}
|
||||
): UseCameraPermissionReturn {
|
||||
const { initialFacingMode = 'environment' } = options;
|
||||
|
||||
const [permissionState, setPermissionState] = useState<PermissionState>('prompt');
|
||||
const [stream, setStream] = useState<MediaStream | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [facingMode, setFacingMode] = useState<FacingMode>(initialFacingMode);
|
||||
const [hasMultipleCameras, setHasMultipleCameras] = useState(false);
|
||||
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
|
||||
// Check for multiple cameras on mount
|
||||
useEffect(() => {
|
||||
const checkCameras = async () => {
|
||||
try {
|
||||
if (!navigator.mediaDevices?.enumerateDevices) {
|
||||
return;
|
||||
}
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
const videoInputs = devices.filter((d) => d.kind === 'videoinput');
|
||||
setHasMultipleCameras(videoInputs.length > 1);
|
||||
} catch {
|
||||
// Silently fail - camera enumeration is not critical
|
||||
}
|
||||
};
|
||||
checkCameras();
|
||||
}, []);
|
||||
|
||||
// Clean up stream on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((track) => track.stop());
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const stopStream = useCallback(() => {
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((track) => track.stop());
|
||||
streamRef.current = null;
|
||||
setStream(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const requestPermission = useCallback(async () => {
|
||||
// Check if getUserMedia is available
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
setPermissionState('unavailable');
|
||||
setError('Camera access is not supported in this browser');
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop any existing stream
|
||||
stopStream();
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const constraints: MediaStreamConstraints = {
|
||||
video: {
|
||||
facingMode: { ideal: facingMode },
|
||||
width: { ideal: 1920 },
|
||||
height: { ideal: 1080 },
|
||||
},
|
||||
audio: false,
|
||||
};
|
||||
|
||||
const mediaStream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
streamRef.current = mediaStream;
|
||||
setStream(mediaStream);
|
||||
setPermissionState('granted');
|
||||
|
||||
// Re-check for multiple cameras after permission granted
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
const videoInputs = devices.filter((d) => d.kind === 'videoinput');
|
||||
setHasMultipleCameras(videoInputs.length > 1);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||
|
||||
if (
|
||||
err instanceof DOMException &&
|
||||
(err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError')
|
||||
) {
|
||||
setPermissionState('denied');
|
||||
setError('Camera permission was denied. Please enable camera access in your browser settings.');
|
||||
} else if (
|
||||
err instanceof DOMException &&
|
||||
(err.name === 'NotFoundError' || err.name === 'DevicesNotFoundError')
|
||||
) {
|
||||
setPermissionState('unavailable');
|
||||
setError('No camera found on this device');
|
||||
} else if (
|
||||
err instanceof DOMException &&
|
||||
(err.name === 'NotReadableError' || err.name === 'TrackStartError')
|
||||
) {
|
||||
setPermissionState('unavailable');
|
||||
setError('Camera is already in use by another application');
|
||||
} else {
|
||||
setPermissionState('unavailable');
|
||||
setError(`Failed to access camera: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
}, [facingMode, stopStream]);
|
||||
|
||||
const switchCamera = useCallback(async () => {
|
||||
const newFacingMode: FacingMode = facingMode === 'environment' ? 'user' : 'environment';
|
||||
setFacingMode(newFacingMode);
|
||||
|
||||
// If we have an active stream, restart with new facing mode
|
||||
if (stream) {
|
||||
stopStream();
|
||||
|
||||
try {
|
||||
const constraints: MediaStreamConstraints = {
|
||||
video: {
|
||||
facingMode: { exact: newFacingMode },
|
||||
width: { ideal: 1920 },
|
||||
height: { ideal: 1080 },
|
||||
},
|
||||
audio: false,
|
||||
};
|
||||
|
||||
const mediaStream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
streamRef.current = mediaStream;
|
||||
setStream(mediaStream);
|
||||
} catch {
|
||||
// If exact fails, try ideal
|
||||
try {
|
||||
const constraints: MediaStreamConstraints = {
|
||||
video: {
|
||||
facingMode: { ideal: newFacingMode },
|
||||
width: { ideal: 1920 },
|
||||
height: { ideal: 1080 },
|
||||
},
|
||||
audio: false,
|
||||
};
|
||||
|
||||
const mediaStream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
streamRef.current = mediaStream;
|
||||
setStream(mediaStream);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||
setError(`Failed to switch camera: ${errorMessage}`);
|
||||
// Revert facing mode
|
||||
setFacingMode(facingMode);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [facingMode, stream, stopStream]);
|
||||
|
||||
return {
|
||||
permissionState,
|
||||
stream,
|
||||
error,
|
||||
facingMode,
|
||||
hasMultipleCameras,
|
||||
requestPermission,
|
||||
switchCamera,
|
||||
stopStream,
|
||||
};
|
||||
}
|
||||
|
||||
export default useCameraPermission;
|
||||
Reference in New Issue
Block a user