diff --git a/frontend/src/components/PodcastMaker/RobustCamera.tsx b/frontend/src/components/PodcastMaker/RobustCamera.tsx index 0a2108a5..7cc01e02 100644 --- a/frontend/src/components/PodcastMaker/RobustCamera.tsx +++ b/frontend/src/components/PodcastMaker/RobustCamera.tsx @@ -26,155 +26,231 @@ interface RobustCameraProps { } export const RobustCamera: React.FC = ({ onCapture, onClose, open }) => { - const [stream, setStream] = useState(null); const [facingMode, setFacingMode] = useState<'user' | 'environment'>('user'); const [loading, setLoading] = useState(false); const [error, setError] = useState(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(null); + + // DOM refs only const videoElementRef = useRef(null); const canvasRef = useRef(null); - const streamRef = useRef(null); - const cameraInitRef = useRef(false); - const retryCountRef = useRef(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 = ({ 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 = ({ 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 ( = ({ onCapture, onClose, )} - {!loading && !error && ( - -