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>
201 lines
6.2 KiB
TypeScript
201 lines
6.2 KiB
TypeScript
/**
|
|
* @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;
|