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 }) => {
const [stream, setStream] = useState<MediaStream | null>(null);
const [facingMode, setFacingMode] = useState<'user' | 'environment'>('user');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
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 canvasRef = useRef<HTMLCanvasElement>(null);
const streamRef = useRef<MediaStream | null>(null);
const cameraInitRef = useRef<boolean>(false);
const retryCountRef = useRef<number>(0);
// Track initialization to prevent double-init
const isInitializingRef = useRef(false);
const isMountedRef = useRef(true);
// Clean up stream
const cleanupStream = useCallback(() => {
console.log('[RobustCamera] Cleaning up stream');
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
if (videoElementRef.current && videoElementRef.current.srcObject) {
// Cleanup function - stops all tracks and clears video
const cleanupCamera = useCallback(() => {
console.log('[RobustCamera] Cleaning up camera');
// Stop video playback
if (videoElementRef.current) {
videoElementRef.current.pause();
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);
setCameraReady(false);
cameraInitRef.current = false;
retryCountRef.current = 0;
}, []);
setError(null);
isInitializingRef.current = false;
}, [stream]);
// Initialize camera
// Initialize camera - only gets the stream, doesn't attach to video
const initializeCamera = useCallback(async () => {
if (cameraInitRef.current || loading) {
console.log('[RobustCamera] Camera already initializing or loading');
// Prevent double initialization
if (isInitializingRef.current) {
console.log('[RobustCamera] Already initializing, skipping');
return;
}
if (!isMountedRef.current) {
console.log('[RobustCamera] Component not mounted, skipping');
return;
}
console.log('[RobustCamera] Starting camera initialization');
cameraInitRef.current = true;
isInitializingRef.current = true;
setLoading(true);
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 {
const constraints = {
video: {
facingMode: facingMode,
width: { ideal: 1280 },
height: { ideal: 720 },
width: { ideal: 1280, min: 640 },
height: { ideal: 720, min: 480 },
},
audio: false,
};
console.log('[RobustCamera] Requesting camera with constraints:', constraints);
const mediaStream = await navigator.mediaDevices.getUserMedia(constraints);
console.log('[RobustCamera] Camera stream obtained successfully');
streamRef.current = mediaStream;
setStream(mediaStream);
// Attach to video element
if (videoElementRef.current) {
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
if (!isMountedRef.current) {
// Component unmounted while awaiting, clean up
console.log('[RobustCamera] Component unmounted, stopping stream');
mediaStream.getTracks().forEach(track => track.stop());
return;
}
console.log('[RobustCamera] Camera stream obtained:', mediaStream.id, 'Tracks:', mediaStream.getTracks().length);
setStream(mediaStream);
setLoading(false);
} catch (err) {
console.error('[RobustCamera] Camera access error:', err);
cleanupStream();
if (!isMountedRef.current) return;
setLoading(false);
isInitializingRef.current = false;
if (err instanceof Error) {
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') {
setError('No camera found on this device.');
} 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 {
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
const attachStreamToVideo = useCallback(() => {
if (videoElementRef.current && streamRef.current && !cameraReady) {
console.log('[RobustCamera] Attaching stream to video element');
const video = videoElementRef.current;
const stream = streamRef.current;
// SINGLE useEffect to handle stream attachment to video
// This runs whenever stream changes or video element becomes available
useEffect(() => {
const video = videoElementRef.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
if (video.srcObject) {
const oldStream = video.srcObject as MediaStream;
oldStream.getTracks().forEach(track => track.stop());
}
// Attach new stream
video.srcObject = stream;
video.onloadedmetadata = () => {
console.log('[RobustCamera] Video metadata loaded after attachment');
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.');
video.play()
.then(() => {
console.log('[RobustCamera] Video playing successfully');
if (isMountedRef.current) {
setCameraReady(true);
}
})
.catch(err => {
console.error('[RobustCamera] Video play error:', err);
if (isMountedRef.current) {
setError('Camera stream ready but video playback 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) => {
console.error('[RobustCamera] Video error after attachment:', err);
setError('Failed to display camera feed.');
setLoading(false);
// Initialize camera when dialog opens
useEffect(() => {
if (open) {
console.log('[RobustCamera] Dialog opened');
isMountedRef.current = true;
// Small delay to ensure DOM is ready
const timer = setTimeout(() => {
initializeCamera();
}, 100);
return () => {
clearTimeout(timer);
};
} else {
console.log('[RobustCamera] Cannot attach stream:', {
videoExists: !!videoElementRef.current,
streamExists: !!streamRef.current,
cameraReady
});
// Dialog closed - cleanup
console.log('[RobustCamera] Dialog closed, cleaning up');
cleanupCamera();
}
}, [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
const capturePhoto = useCallback(() => {
if (!videoElementRef.current || !canvasRef.current || !cameraReady) {
console.log('[RobustCamera] Cannot capture: video or canvas not ready');
console.log('[RobustCamera] Cannot capture: not ready');
return;
}
@@ -182,21 +258,30 @@ export const RobustCamera: React.FC<RobustCameraProps> = ({ onCapture, onClose,
const canvas = canvasRef.current;
// Set canvas dimensions to match video
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.width = video.videoWidth || 1280;
canvas.height = video.videoHeight || 720;
console.log('[RobustCamera] Capturing photo:', canvas.width, 'x', canvas.height);
// Draw video frame to canvas
const context = canvas.getContext('2d');
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);
// Convert to data URL
const imageDataUrl = canvas.toDataURL('image/jpeg', 0.9);
console.log('[RobustCamera] Photo captured successfully');
console.log('[RobustCamera] Photo captured');
onCapture(imageDataUrl);
onClose();
}
}, [cameraReady, onCapture, onClose]);
}, [cameraReady, facingMode, onCapture, onClose]);
// Flip camera
const flipCamera = useCallback(() => {
@@ -204,51 +289,6 @@ export const RobustCamera: React.FC<RobustCameraProps> = ({ onCapture, onClose,
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 (
<Dialog
open={open}
@@ -301,71 +341,84 @@ export const RobustCamera: React.FC<RobustCameraProps> = ({ onCapture, onClose,
</Box>
)}
{!loading && !error && (
<Box sx={{
position: 'relative',
width: '100%',
maxWidth: 600,
mx: 'auto',
bgcolor: 'black',
borderRadius: 2,
overflow: 'hidden'
}}>
<video
ref={videoElementRef}
autoPlay
playsInline
muted
style={{
width: '100%',
height: 'auto',
display: cameraReady ? 'block' : 'none',
transform: facingMode === 'user' ? 'scaleX(-1)' : 'none'
}}
/>
{!cameraReady && stream && (
<Box sx={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'rgba(0,0,0,0.8)',
color: 'white'
}}>
<Typography variant="body1">
Camera stream ready, attaching to display...
</Typography>
</Box>
)}
<Box sx={{
position: 'relative',
width: '100%',
maxWidth: 600,
mx: 'auto',
bgcolor: 'black',
borderRadius: 2,
overflow: 'hidden',
minHeight: 300,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
{/* Video element - always rendered but styled based on readiness */}
<video
ref={videoElementRef}
autoPlay
playsInline
muted
disablePictureInPicture
controls={false}
style={{
width: '100%',
height: 'auto',
maxHeight: '60vh',
objectFit: 'contain',
display: cameraReady ? 'block' : 'none',
transform: facingMode === 'user' ? 'scaleX(-1)' : 'none'
}}
/>
{/* Loading overlay - shown when stream exists but camera not ready */}
{!cameraReady && stream && (
<Box sx={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
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 && (
<Box sx={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'rgba(0,0,0,0.8)',
color: 'white',
gap: 2
}}>
<Camera sx={{ fontSize: 60 }} />
<Typography variant="body1" textAlign="center">
Camera not ready
</Typography>
</Box>
)}
</Box>
)}
{/* Initial state - no stream yet */}
{!cameraReady && !stream && !loading && !error && (
<Box sx={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'rgba(0,0,0,0.8)',
color: 'white',
gap: 2
}}>
<Camera sx={{ fontSize: 60 }} />
<Typography variant="body1" textAlign="center">
Camera initializing...
</Typography>
</Box>
)}
</Box>
<canvas
ref={canvasRef}