Fix: Resolve camera display issues in selfie component

- Consolidate stream attachment into single useEffect
- Remove race conditions from multiple competing effects
- Add proper cleanup with video element reset
- Simplify state management using single stream state
- Add isMountedRef to prevent state updates after unmount
- Improve error handling with specific error messages
- Add canvas flip correction for front camera mirror effect
This commit is contained in:
ajaysi
2026-04-07 11:14:49 +05:30
parent 8dd1c13f85
commit cf2d3a51e8

View File

@@ -26,155 +26,231 @@ interface RobustCameraProps {
} }
export const RobustCamera: React.FC<RobustCameraProps> = ({ onCapture, onClose, open }) => { export const RobustCamera: React.FC<RobustCameraProps> = ({ onCapture, onClose, open }) => {
const [stream, setStream] = useState<MediaStream | null>(null);
const [facingMode, setFacingMode] = useState<'user' | 'environment'>('user'); const [facingMode, setFacingMode] = useState<'user' | 'environment'>('user');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [cameraReady, setCameraReady] = useState(false); const [cameraReady, setCameraReady] = useState(false);
// Use multiple refs for different purposes // Single source of truth - only use state for stream, not ref
const [stream, setStream] = useState<MediaStream | null>(null);
// DOM refs only
const videoElementRef = useRef<HTMLVideoElement>(null); const videoElementRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const streamRef = useRef<MediaStream | null>(null);
const cameraInitRef = useRef<boolean>(false); // Track initialization to prevent double-init
const retryCountRef = useRef<number>(0); const isInitializingRef = useRef(false);
const isMountedRef = useRef(true);
// Clean up stream // Cleanup function - stops all tracks and clears video
const cleanupStream = useCallback(() => { const cleanupCamera = useCallback(() => {
console.log('[RobustCamera] Cleaning up stream'); console.log('[RobustCamera] Cleaning up camera');
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop()); // Stop video playback
streamRef.current = null; if (videoElementRef.current) {
} videoElementRef.current.pause();
if (videoElementRef.current && videoElementRef.current.srcObject) {
videoElementRef.current.srcObject = null; videoElementRef.current.srcObject = null;
videoElementRef.current.load(); // Reset video element
} }
// Stop all tracks in the stream
if (stream) {
stream.getTracks().forEach(track => {
console.log('[RobustCamera] Stopping track:', track.kind, track.label);
track.stop();
});
}
// Clear state
setStream(null); setStream(null);
setCameraReady(false); setCameraReady(false);
cameraInitRef.current = false; setError(null);
retryCountRef.current = 0; isInitializingRef.current = false;
}, []); }, [stream]);
// Initialize camera // Initialize camera - only gets the stream, doesn't attach to video
const initializeCamera = useCallback(async () => { const initializeCamera = useCallback(async () => {
if (cameraInitRef.current || loading) { // Prevent double initialization
console.log('[RobustCamera] Camera already initializing or loading'); if (isInitializingRef.current) {
console.log('[RobustCamera] Already initializing, skipping');
return; return;
} }
if (!isMountedRef.current) {
console.log('[RobustCamera] Component not mounted, skipping');
return;
}
console.log('[RobustCamera] Starting camera initialization'); console.log('[RobustCamera] Starting camera initialization');
cameraInitRef.current = true; isInitializingRef.current = true;
setLoading(true); setLoading(true);
setError(null); setError(null);
cleanupStream(); setCameraReady(false);
// Clean up any existing stream first
if (stream) {
console.log('[RobustCamera] Cleaning up existing stream first');
stream.getTracks().forEach(track => track.stop());
}
try { try {
const constraints = { const constraints = {
video: { video: {
facingMode: facingMode, facingMode: facingMode,
width: { ideal: 1280 }, width: { ideal: 1280, min: 640 },
height: { ideal: 720 }, height: { ideal: 720, min: 480 },
}, },
audio: false, audio: false,
}; };
console.log('[RobustCamera] Requesting camera with constraints:', constraints); console.log('[RobustCamera] Requesting camera with constraints:', constraints);
const mediaStream = await navigator.mediaDevices.getUserMedia(constraints); const mediaStream = await navigator.mediaDevices.getUserMedia(constraints);
console.log('[RobustCamera] Camera stream obtained successfully');
streamRef.current = mediaStream; if (!isMountedRef.current) {
setStream(mediaStream); // Component unmounted while awaiting, clean up
console.log('[RobustCamera] Component unmounted, stopping stream');
// Attach to video element mediaStream.getTracks().forEach(track => track.stop());
if (videoElementRef.current) { return;
console.log('[RobustCamera] Video element found, attaching stream');
videoElementRef.current.srcObject = mediaStream;
videoElementRef.current.onloadedmetadata = () => {
console.log('[RobustCamera] Video metadata loaded');
setCameraReady(true);
setLoading(false);
videoElementRef.current?.play().catch(err => {
console.error('[RobustCamera] Video play error:', err);
setError('Camera stream obtained but video display failed. Please try again.');
});
};
videoElementRef.current.onerror = (err) => {
console.error('[RobustCamera] Video error:', err);
setError('Failed to display camera feed.');
setLoading(false);
};
} else {
console.log('[RobustCamera] Video element not found, will attach when ready');
setCameraReady(false);
setLoading(false);
// Stream will be attached when video element mounts
} }
console.log('[RobustCamera] Camera stream obtained:', mediaStream.id, 'Tracks:', mediaStream.getTracks().length);
setStream(mediaStream);
setLoading(false);
} catch (err) { } catch (err) {
console.error('[RobustCamera] Camera access error:', err); console.error('[RobustCamera] Camera access error:', err);
cleanupStream();
if (!isMountedRef.current) return;
setLoading(false); setLoading(false);
isInitializingRef.current = false;
if (err instanceof Error) { if (err instanceof Error) {
if (err.name === 'NotAllowedError') { if (err.name === 'NotAllowedError') {
setError('Camera access denied. Please allow camera permissions to take a selfie.'); setError('Camera access denied. Please allow camera permissions in your browser settings.');
} else if (err.name === 'NotFoundError') { } else if (err.name === 'NotFoundError') {
setError('No camera found on this device.'); setError('No camera found on this device.');
} else if (err.name === 'NotReadableError') { } else if (err.name === 'NotReadableError') {
setError('Camera is already in use by another application.'); setError('Camera is already in use by another application. Please close other apps using the camera.');
} else if (err.name === 'OverconstrainedError') {
setError('Camera does not support the requested resolution. Please try again.');
} else { } else {
setError('Failed to access camera. Please try again.'); setError(`Failed to access camera: ${err.message}`);
} }
} else {
setError('Failed to access camera. Please try again.');
} }
} }
}, [facingMode, loading, cleanupStream]); }, [facingMode, stream]);
// Attach stream when video element is available // SINGLE useEffect to handle stream attachment to video
const attachStreamToVideo = useCallback(() => { // This runs whenever stream changes or video element becomes available
if (videoElementRef.current && streamRef.current && !cameraReady) { useEffect(() => {
console.log('[RobustCamera] Attaching stream to video element'); const video = videoElementRef.current;
const video = videoElementRef.current;
const stream = streamRef.current; if (!video || !stream) {
console.log('[RobustCamera] Cannot attach - video:', !!video, 'stream:', !!stream);
return;
}
if (video.srcObject === stream) {
console.log('[RobustCamera] Stream already attached to video');
return;
}
console.log('[RobustCamera] Attaching stream to video element');
// Set up event handlers before attaching
const handleLoadedMetadata = () => {
console.log('[RobustCamera] Video metadata loaded, playing...');
if (!isMountedRef.current) return;
// Clear any existing stream video.play()
if (video.srcObject) { .then(() => {
const oldStream = video.srcObject as MediaStream; console.log('[RobustCamera] Video playing successfully');
oldStream.getTracks().forEach(track => track.stop()); if (isMountedRef.current) {
} setCameraReady(true);
}
// Attach new stream })
video.srcObject = stream; .catch(err => {
console.error('[RobustCamera] Video play error:', err);
video.onloadedmetadata = () => { if (isMountedRef.current) {
console.log('[RobustCamera] Video metadata loaded after attachment'); setError('Camera stream ready but video playback failed. Please try again.');
setCameraReady(true); }
setLoading(false);
video.play().catch(err => {
console.error('[RobustCamera] Video play error after attachment:', err);
setError('Camera stream obtained but video display failed. Please try again.');
}); });
}; };
const handleError = (err: Event) => {
console.error('[RobustCamera] Video error:', err);
if (isMountedRef.current) {
setError('Failed to display camera feed. Please try again.');
}
};
// Attach event listeners
video.addEventListener('loadedmetadata', handleLoadedMetadata);
video.addEventListener('error', handleError);
// Attach the stream
video.srcObject = stream;
// Cleanup function
return () => {
console.log('[RobustCamera] Cleaning up video event listeners');
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
video.removeEventListener('error', handleError);
};
}, [stream]);
video.onerror = (err) => { // Initialize camera when dialog opens
console.error('[RobustCamera] Video error after attachment:', err); useEffect(() => {
setError('Failed to display camera feed.'); if (open) {
setLoading(false); console.log('[RobustCamera] Dialog opened');
isMountedRef.current = true;
// Small delay to ensure DOM is ready
const timer = setTimeout(() => {
initializeCamera();
}, 100);
return () => {
clearTimeout(timer);
}; };
} else { } else {
console.log('[RobustCamera] Cannot attach stream:', { // Dialog closed - cleanup
videoExists: !!videoElementRef.current, console.log('[RobustCamera] Dialog closed, cleaning up');
streamExists: !!streamRef.current, cleanupCamera();
cameraReady
});
} }
}, [cameraReady]); }, [open, initializeCamera, cleanupCamera]);
// Handle facing mode changes
useEffect(() => {
if (!open || !stream) return;
console.log('[RobustCamera] Facing mode changed, reinitializing');
cleanupCamera();
const timer = setTimeout(() => {
isInitializingRef.current = false;
initializeCamera();
}, 300);
return () => clearTimeout(timer);
}, [facingMode, open]); // Only re-run when facingMode actually changes
// Cleanup on unmount
useEffect(() => {
return () => {
console.log('[RobustCamera] Component unmounting');
isMountedRef.current = false;
cleanupCamera();
};
}, [cleanupCamera]);
// Capture photo // Capture photo
const capturePhoto = useCallback(() => { const capturePhoto = useCallback(() => {
if (!videoElementRef.current || !canvasRef.current || !cameraReady) { if (!videoElementRef.current || !canvasRef.current || !cameraReady) {
console.log('[RobustCamera] Cannot capture: video or canvas not ready'); console.log('[RobustCamera] Cannot capture: not ready');
return; return;
} }
@@ -182,21 +258,30 @@ export const RobustCamera: React.FC<RobustCameraProps> = ({ onCapture, onClose,
const canvas = canvasRef.current; const canvas = canvasRef.current;
// Set canvas dimensions to match video // Set canvas dimensions to match video
canvas.width = video.videoWidth; canvas.width = video.videoWidth || 1280;
canvas.height = video.videoHeight; canvas.height = video.videoHeight || 720;
console.log('[RobustCamera] Capturing photo:', canvas.width, 'x', canvas.height);
// Draw video frame to canvas // Draw video frame to canvas
const context = canvas.getContext('2d'); const context = canvas.getContext('2d');
if (context) { if (context) {
// Flip context if using front camera for mirror effect correction
if (facingMode === 'user') {
context.translate(canvas.width, 0);
context.scale(-1, 1);
}
context.drawImage(video, 0, 0, canvas.width, canvas.height); context.drawImage(video, 0, 0, canvas.width, canvas.height);
// Convert to data URL // Convert to data URL
const imageDataUrl = canvas.toDataURL('image/jpeg', 0.9); const imageDataUrl = canvas.toDataURL('image/jpeg', 0.9);
console.log('[RobustCamera] Photo captured successfully'); console.log('[RobustCamera] Photo captured');
onCapture(imageDataUrl); onCapture(imageDataUrl);
onClose(); onClose();
} }
}, [cameraReady, onCapture, onClose]); }, [cameraReady, facingMode, onCapture, onClose]);
// Flip camera // Flip camera
const flipCamera = useCallback(() => { const flipCamera = useCallback(() => {
@@ -204,51 +289,6 @@ export const RobustCamera: React.FC<RobustCameraProps> = ({ onCapture, onClose,
setFacingMode(prev => prev === 'user' ? 'environment' : 'user'); setFacingMode(prev => prev === 'user' ? 'environment' : 'user');
}, []); }, []);
// Initialize camera when dialog opens
useEffect(() => {
if (open) {
console.log('[RobustCamera] Dialog opened, initializing camera');
// Small delay to ensure DOM is ready
const timer = setTimeout(() => {
initializeCamera();
}, 300);
return () => {
clearTimeout(timer);
cleanupStream();
};
}
}, [open]); // Remove initializeCamera and cleanupStream from dependencies
// Re-initialize when facing mode changes
useEffect(() => {
if (open && cameraReady) {
console.log('[RobustCamera] Facing mode changed, re-initializing camera');
cleanupStream();
const timer = setTimeout(() => {
initializeCamera();
}, 500);
return () => clearTimeout(timer);
}
}, [facingMode]); // Remove other dependencies to prevent loops
// Attach stream when video element is available
useEffect(() => {
if (videoElementRef.current && streamRef.current && !cameraReady) {
console.log('[RobustCamera] Video element available, attaching stream');
attachStreamToVideo();
}
}, [stream, cameraReady]); // Trigger when stream changes
// Attach stream when component mounts or stream changes
useEffect(() => {
if (open && stream && !cameraReady && videoElementRef.current) {
console.log('[RobustCamera] Stream available, attaching to video element');
attachStreamToVideo();
}
}, [open, stream]); // Remove cameraReady and attachStreamToVideo to prevent loops
return ( return (
<Dialog <Dialog
open={open} open={open}
@@ -301,71 +341,84 @@ export const RobustCamera: React.FC<RobustCameraProps> = ({ onCapture, onClose,
</Box> </Box>
)} )}
{!loading && !error && ( <Box sx={{
<Box sx={{ position: 'relative',
position: 'relative', width: '100%',
width: '100%', maxWidth: 600,
maxWidth: 600, mx: 'auto',
mx: 'auto', bgcolor: 'black',
bgcolor: 'black', borderRadius: 2,
borderRadius: 2, overflow: 'hidden',
overflow: 'hidden' minHeight: 300,
}}> display: 'flex',
<video alignItems: 'center',
ref={videoElementRef} justifyContent: 'center'
autoPlay }}>
playsInline {/* Video element - always rendered but styled based on readiness */}
muted <video
style={{ ref={videoElementRef}
width: '100%', autoPlay
height: 'auto', playsInline
display: cameraReady ? 'block' : 'none', muted
transform: facingMode === 'user' ? 'scaleX(-1)' : 'none' disablePictureInPicture
}} controls={false}
/> style={{
width: '100%',
{!cameraReady && stream && ( height: 'auto',
<Box sx={{ maxHeight: '60vh',
position: 'absolute', objectFit: 'contain',
top: 0, display: cameraReady ? 'block' : 'none',
left: 0, transform: facingMode === 'user' ? 'scaleX(-1)' : 'none'
right: 0, }}
bottom: 0, />
display: 'flex',
alignItems: 'center', {/* Loading overlay - shown when stream exists but camera not ready */}
justifyContent: 'center', {!cameraReady && stream && (
bgcolor: 'rgba(0,0,0,0.8)', <Box sx={{
color: 'white' position: 'absolute',
}}> top: 0,
<Typography variant="body1"> left: 0,
Camera stream ready, attaching to display... right: 0,
</Typography> bottom: 0,
</Box> display: 'flex',
)} flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'rgba(0,0,0,0.9)',
color: 'white',
gap: 2,
zIndex: 1
}}>
<CircularProgress size={40} sx={{ color: 'white' }} />
<Typography variant="body1">
Starting camera feed...
</Typography>
</Box>
)}
{!cameraReady && !stream && !loading && ( {/* Initial state - no stream yet */}
<Box sx={{ {!cameraReady && !stream && !loading && !error && (
position: 'absolute', <Box sx={{
top: 0, position: 'absolute',
left: 0, top: 0,
right: 0, left: 0,
bottom: 0, right: 0,
display: 'flex', bottom: 0,
flexDirection: 'column', display: 'flex',
alignItems: 'center', flexDirection: 'column',
justifyContent: 'center', alignItems: 'center',
bgcolor: 'rgba(0,0,0,0.8)', justifyContent: 'center',
color: 'white', bgcolor: 'rgba(0,0,0,0.8)',
gap: 2 color: 'white',
}}> gap: 2
<Camera sx={{ fontSize: 60 }} /> }}>
<Typography variant="body1" textAlign="center"> <Camera sx={{ fontSize: 60 }} />
Camera not ready <Typography variant="body1" textAlign="center">
</Typography> Camera initializing...
</Box> </Typography>
)} </Box>
</Box> )}
)} </Box>
<canvas <canvas
ref={canvasRef} ref={canvasRef}