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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user