import React, { useState, useEffect, useRef, useCallback } from 'react'; import { StyleSheet, Text, View, TouchableOpacity, Platform, Modal, TextInput, TouchableWithoutFeedback, } from 'react-native'; import { CameraView, useCameraPermissions } from 'expo-camera'; import * as FileSystem from 'expo-file-system'; import { File, Paths } from 'expo-file-system/next'; import * as ImageManipulator from 'expo-image-manipulator'; import { LinearGradient } from 'expo-linear-gradient'; import Animated, { useSharedValue, useAnimatedStyle, withSpring, withTiming, FadeIn, FadeOut, withSequence, Easing, runOnJS, } from 'react-native-reanimated'; import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'; import { faUserGear, faCameraRetro, faCamera, faXmark, } from '@fortawesome/free-solid-svg-icons'; import { THEME } from '../styles/theme'; import { UsbCameraView, UsbCameraRef } from '../../modules/usb-camera'; import { saveToGallery } from '../services/storage'; import FrameOverlay from '../components/FrameOverlay'; import { FRAMES } from '../data/frames'; import { logger } from '../services/logger'; interface CameraScreenProps { countdownDuration: number; onPhotoCaptured: (uri: string) => void; onBurstComplete: (uris: string[]) => void; onCancel: () => void; isIdle?: boolean; onStartBooth?: () => void; onNavigateToAdmin?: () => void; adminPassword?: string; welcomeText?: string; burstCount?: number; burstIntervalSec?: number; frameMode?: 'off' | 'always' | 'available'; selectedFrameId?: string | null; } export default function CameraScreen({ countdownDuration, onPhotoCaptured, onBurstComplete, onCancel, isIdle = false, onStartBooth, onNavigateToAdmin, adminPassword = '1234', welcomeText = 'Willkommen zu unserer Feier!', burstCount = 1, burstIntervalSec = 5, frameMode = 'off', selectedFrameId = null, }: CameraScreenProps) { const [permission, requestPermission] = useCameraPermissions(); const [isUsbConnected, setIsUsbConnected] = useState(false); const [countdown, setCountdown] = useState(''); const [isCapturing, setIsCapturing] = useState(false); const [hasStarted, setHasStarted] = useState(false); const [pictureSize, setPictureSize] = useState(undefined); // Burst mode state const [burstIndex, setBurstIndex] = useState(0); const [burstUris, setBurstUris] = useState([]); const [showBurstIndicator, setShowBurstIndicator] = useState(''); const [isBurstWaiting, setIsBurstWaiting] = useState(false); // Flash effect const flashOpacity = useSharedValue(0); // Admin Modal States const [passwordModalVisible, setPasswordModalVisible] = useState(false); const [enteredPassword, setEnteredPassword] = useState(''); const [errorText, setErrorText] = useState(''); const usbCameraRef = useRef(null); const expoCameraRef = useRef(null); const burstTimerRef = useRef(null); const countdownTimerRef = useRef(null); const isCancelledRef = useRef(false); // Reanimated countdown pulse animation const scale = useSharedValue(1); useEffect(() => { if (countdown !== '') { scale.value = 0.5; scale.value = withSpring(1.25, { damping: 10, stiffness: 120 }, () => { scale.value = withTiming(1.0, { duration: 150 }); }); } }, [countdown]); const animatedStyle = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }], })); const flashAnimatedStyle = useAnimatedStyle(() => ({ opacity: flashOpacity.value, })); // Trigger white flash effect const triggerFlash = useCallback(() => { flashOpacity.value = withSequence( withTiming(1, { duration: 80 }), withTiming(0, { duration: 400 }) ); }, []); // 1. Request built-in camera permission on mount (just in case we fallback) useEffect(() => { if (!permission || !permission.granted) { requestPermission(); } }, [permission]); // 2. Check if a USB camera is connected. useEffect(() => { let checkInterval: any; if (Platform.OS === 'android') { const checkConnection = async () => { try { if (usbCameraRef.current) { const connected = await usbCameraRef.current.isCameraConnected(); setIsUsbConnected(connected); } } catch (e) { setIsUsbConnected(false); } }; checkConnection(); checkInterval = setInterval(checkConnection, 2000); } return () => { if (checkInterval) clearInterval(checkInterval); }; }, []); // Cleanup burst timer on unmount useEffect(() => { return () => { if (burstTimerRef.current) clearTimeout(burstTimerRef.current); if (countdownTimerRef.current) clearTimeout(countdownTimerRef.current); }; }, []); // Set safe picture size to avoid OOM const handleCameraReady = useCallback(async () => { if (expoCameraRef.current) { try { const sizes = await expoCameraRef.current.getAvailablePictureSizes(); if (sizes && sizes.length > 0) { console.log('Available camera sizes:', sizes); const parsedSizes = sizes.map((s: string) => { const parts = s.split('x'); if (parts.length === 2) { const w = parseInt(parts[0], 10); const h = parseInt(parts[1], 10); return { size: s, pixels: w * h, w, h }; } return { size: s, pixels: 0, w: 0, h: 0 }; }).filter((s: { pixels: number }) => s.pixels > 0); parsedSizes.sort((a, b) => a.pixels - b.pixels); // Find the smallest size that has at least 1920 width or height (approx 2MP) const optimal = parsedSizes.find((s: { w: number; h: number }) => Math.max(s.w, s.h) >= 1920); if (optimal) { console.log('Setting optimal pictureSize to prevent OOM:', optimal.size); setPictureSize(optimal.size); } else if (parsedSizes.length > 0) { setPictureSize(parsedSizes[parsedSizes.length - 1].size); } else { setPictureSize('fallback'); } } else { setPictureSize('fallback'); } } catch (e) { console.warn('Failed to fetch picture sizes:', e); setPictureSize('fallback'); } } }, []); // Cancel capture process const handleLocalCancel = async () => { isCancelledRef.current = true; if (burstTimerRef.current) clearTimeout(burstTimerRef.current); if (countdownTimerRef.current) clearTimeout(countdownTimerRef.current); // Clean up any temp files from a partially completed burst for (const uri of burstUris) { try { await FileSystem.deleteAsync(uri, { idempotent: true }); } catch (e) { console.warn('Failed to delete temp file on cancel:', e); } } setCountdown(countdownDuration); setIsCapturing(false); setIsBurstWaiting(false); setHasStarted(false); setBurstIndex(0); setBurstUris([]); setShowBurstIndicator(''); onCancel(); }; // 3. Start the countdown on screen load (only when NOT idle) useEffect(() => { if (isIdle) { setHasStarted(false); setBurstIndex(0); setBurstUris([]); return; } // Delay start until safe picture size is negotiated (or fallback is set) // Only applies to built-in camera, USB camera ignores pictureSize if (pictureSize === undefined && !isUsbConnected) { console.log('Waiting for pictureSize negotiation...'); return; } startCountdown(); return () => {}; }, [countdownDuration, isIdle, pictureSize, isUsbConnected]); const startCountdown = () => { isCancelledRef.current = false; let count = countdownDuration; setCountdown(count); setHasStarted(true); setIsCapturing(false); setShowBurstIndicator(''); const runTimer = () => { if (count > 1) { count -= 1; setCountdown(count); countdownTimerRef.current = setTimeout(runTimer, 1000); } else if (count === 1) { setCountdown('smile'); setIsCapturing(true); countdownTimerRef.current = setTimeout(() => { capture(); }, 800); } }; countdownTimerRef.current = setTimeout(runTimer, 1000); }; // 4. Capture photo function const capture = async () => { const filename = `photo_${Date.now()}.jpg`; const tempFile = new File(Paths.cache, filename); const tempUri = tempFile.uri; try { triggerFlash(); let capturedUri: string; let rawWidth: number = 0; let rawHeight: number = 0; if (Platform.OS === 'android' && isUsbConnected && usbCameraRef.current) { // USB Camera Capture console.log('Capturing from USB Camera...'); const path = await usbCameraRef.current.takePicture(tempUri); capturedUri = path; } else { // Fallback Camera Capture logger.log(`Capturing from built-in camera fallback. Size constraint: ${pictureSize || 'default'}`); console.log('Capturing from built-in camera fallback...'); if (expoCameraRef.current) { const photo = await expoCameraRef.current.takePictureAsync({ quality: 0.7, // Lower quality slightly to reduce file size skipProcessing: Platform.OS === 'android', // Prevent memory crash on Android }); capturedUri = photo.uri; } else { throw new Error('Kamera-Referenz ist nicht verfügbar.'); } } // If user cancelled while the camera promise was processing, abort before side-effects if (isCancelledRef.current) { console.log('Capture cancelled by user during processing.'); return; } // Instead of manipulating the image and risking an OOM or BitmapFactory crash, // we directly use the captured photo and rely on React Native's Image component // to safely downsample it during rendering via resizeMethod="resize". console.log('Photo captured successfully, proceeding to PreviewScreen.'); // Auto-save individual photo to gallery try { await saveToGallery(capturedUri); console.log('Auto-saved capture to gallery'); } catch (e) { console.error('Failed to auto-save capture:', e); } // Handle burst mode const effectiveBurst = burstCount || 1; const newIndex = burstIndex + 1; const newUris = [...burstUris, capturedUri]; if (effectiveBurst > 1 && newIndex < effectiveBurst) { // More burst photos to take setBurstIndex(newIndex); setBurstUris(newUris); setShowBurstIndicator(`Foto ${newIndex} von ${effectiveBurst} ✓`); setIsCapturing(false); setIsBurstWaiting(true); // Wait burstIntervalSec then start next countdown burstTimerRef.current = setTimeout(() => { setIsBurstWaiting(false); startCountdown(); }, burstIntervalSec * 1000); } else if (effectiveBurst > 1) { // Final burst photo setBurstIndex(0); setBurstUris([]); // Short delay prevents Android expo-camera crash on immediate unmount setTimeout(() => { onBurstComplete(newUris); }, 400); } else { // Single shot mode setTimeout(() => { onPhotoCaptured(capturedUri); }, 400); } } catch (error: any) { console.error('Capture failed:', error); alert('Fehler beim Aufnehmen des Fotos: ' + error.message); onCancel(); } }; const handleSettingsTap = () => { setEnteredPassword(''); setErrorText(''); setPasswordModalVisible(true); }; const handlePasswordSubmit = () => { if (enteredPassword === adminPassword) { setPasswordModalVisible(false); setTimeout(() => { onNavigateToAdmin?.(); }, 350); // Delay navigation on iOS to avoid unmounting a view with an open modal } else { setErrorText('Falsches Passwort'); setEnteredPassword(''); } }; if (!permission) { return ( Berechtigungen werden geladen... ); } if (!permission.granted && !isUsbConnected) { return ( Wir benötigen deine Erlaubnis, um die Kamera anzuzeigen Erlaubnis erteilen Zurück ); } const renderContent = () => { if (isIdle) { // Home Overlay (frosty glass box on top of the camera stream) return ( {/* Settings button in the top-right corner */} {/* Header Event Text */} {welcomeText} {/* Footer Instructions */} ZUM STARTEN TIPPEN • Fotos machen • Collage • Drucken {/* Password Prompt Modal */} setPasswordModalVisible(false)} > Admin-Zugang Passwort eingeben, um Einstellungen zu öffnen {errorText ? {errorText} : null} setPasswordModalVisible(false)} > Abbrechen Öffnen ); } // Capture Mode overlays (Countdown, Burst indicator, and Cancel button) const activeFrame = selectedFrameId && burstCount === 1 ? FRAMES.find((f) => f.id === selectedFrameId) : null; return ( {/* Frame Overlay */} {frameMode === 'always' && activeFrame && ( )} {/* Source indicator */} Kamera: {isUsbConnected ? 'Externe USB-Kamera' : 'Tablet-Frontkamera (Fallback)'} {/* Burst progress indicator */} {(burstCount || 1) > 1 && ( Foto {burstIndex + 1} / {burstCount} )} {/* Burst waiting indicator */} {isBurstWaiting && showBurstIndicator !== '' && ( {showBurstIndicator} Nächstes Foto in {burstIntervalSec}s… )} {/* Countdown overlay */} {hasStarted && !isBurstWaiting && ( {countdown === 'smile' ? ( Bitte lächeln! ) : ( {countdown} )} )} {/* Cancel Button */} {!isCapturing && !isBurstWaiting && ( ABBRECHEN )} ); }; return ( {/* Camera Preview Background */} {Platform.OS === 'android' && isUsbConnected ? ( ) : ( )} {/* Render UI controls/overlays on top */} {renderContent()} {/* White Flash Overlay */} ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#000', }, previewContainer: { width: '100%', height: '100%', position: 'absolute', top: 0, left: 0, justifyContent: 'center', alignItems: 'center', backgroundColor: '#000', }, cameraWrapper: { height: '100%', aspectRatio: 3 / 2, }, cameraPreview: { width: '100%', height: '100%', }, flashOverlay: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: '#FFFFFF', zIndex: 9999, }, idleOverlayContainer: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'transparent', }, captureOverlayContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', }, settingsButton: { position: 'absolute', top: 24, right: 24, width: 50, height: 50, borderRadius: THEME.borderRadius.round, backgroundColor: 'rgba(13, 13, 26, 0.8)', borderWidth: 1, borderColor: THEME.colors.gradientBorder, justifyContent: 'center', alignItems: 'center', zIndex: 999, }, headerTitle: { position: 'absolute', top: 40, width: '100%', textAlign: 'center', fontSize: 48, fontWeight: '900', color: THEME.colors.accent, letterSpacing: 6, textShadowColor: THEME.colors.cyanGlow, textShadowOffset: { width: 0, height: 0 }, textShadowRadius: 25, }, footerInstructions: { position: 'absolute', bottom: 40, width: '100%', textAlign: 'center', fontSize: 20, fontWeight: 'bold', color: THEME.colors.text, letterSpacing: 2, textShadowColor: 'rgba(0,0,0,0.9)', textShadowOffset: { width: 0, height: 2 }, textShadowRadius: 15, }, hudContainer: { position: 'absolute', bottom: 20, backgroundColor: 'rgba(5, 5, 16, 0.8)', paddingVertical: THEME.spacing.xs, paddingHorizontal: THEME.spacing.md, borderRadius: THEME.borderRadius.sm, borderWidth: 0.5, borderColor: THEME.colors.gradientBorder, }, cameraSourceIndicator: { color: THEME.colors.text, fontSize: 14, fontWeight: 'bold', }, burstBadge: { position: 'absolute', top: 24, right: 24, flexDirection: 'row', alignItems: 'center', gap: 6, backgroundColor: 'rgba(5, 5, 16, 0.85)', paddingVertical: 6, paddingHorizontal: 14, borderRadius: THEME.borderRadius.round, borderWidth: 1, borderColor: THEME.colors.accent, }, burstBadgeText: { color: THEME.colors.accent, fontSize: 14, fontWeight: 'bold', }, burstWaitingBox: { backgroundColor: 'rgba(5, 5, 16, 0.9)', paddingVertical: THEME.spacing.xl, paddingHorizontal: THEME.spacing.xxl, borderRadius: THEME.borderRadius.lg, borderWidth: 1, borderColor: THEME.colors.accent, alignItems: 'center', shadowColor: THEME.colors.accent, shadowOffset: { width: 0, height: 0 }, shadowOpacity: 0.4, shadowRadius: 20, elevation: 8, }, burstWaitingText: { fontSize: 28, fontWeight: '900', color: THEME.colors.accent, textAlign: 'center', }, burstWaitingSub: { fontSize: 16, color: THEME.colors.textMuted, marginTop: THEME.spacing.sm, }, countdownOverlay: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, justifyContent: 'center', alignItems: 'center', }, countdownBox: { backgroundColor: 'rgba(5, 5, 16, 0.88)', paddingVertical: THEME.spacing.xl, paddingHorizontal: THEME.spacing.xxl, borderRadius: THEME.borderRadius.lg, borderWidth: 1, borderColor: THEME.colors.gradientBorder, minWidth: 180, alignItems: 'center', justifyContent: 'center', shadowColor: THEME.colors.accent, shadowOffset: { width: 0, height: 0 }, shadowOpacity: 0.4, shadowRadius: 20, elevation: 8, }, countdownText: { fontSize: 64, fontWeight: '900', color: THEME.colors.text, textAlign: 'center', textShadowColor: THEME.colors.cyanGlow, textShadowOffset: { width: 0, height: 0 }, textShadowRadius: 15, }, backButton: { position: 'absolute', top: 24, left: 24, flexDirection: 'row', alignItems: 'center', backgroundColor: 'rgba(13, 13, 26, 0.8)', paddingVertical: THEME.spacing.sm, paddingHorizontal: THEME.spacing.lg, borderRadius: THEME.borderRadius.round, borderWidth: 1, borderColor: THEME.colors.gradientBorder, }, backButtonText: { color: THEME.colors.text, fontSize: 16, fontWeight: 'bold', letterSpacing: 1, }, infoText: { color: THEME.colors.text, fontSize: 18, textAlign: 'center', marginBottom: THEME.spacing.lg, paddingHorizontal: THEME.spacing.xl, }, btn: { backgroundColor: THEME.colors.accent, paddingVertical: THEME.spacing.md, paddingHorizontal: THEME.spacing.xl, borderRadius: THEME.borderRadius.round, marginBottom: THEME.spacing.md, shadowColor: THEME.colors.accent, shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.4, shadowRadius: 10, elevation: 6, }, cancelBtn: { backgroundColor: 'transparent', borderWidth: 1, borderColor: THEME.colors.border, }, btnText: { color: THEME.colors.text, fontSize: 16, fontWeight: 'bold', }, modalOverlay: { flex: 1, backgroundColor: THEME.colors.overlay, justifyContent: 'center', alignItems: 'center', }, modalContent: { width: 340, padding: THEME.spacing.lg, borderRadius: THEME.borderRadius.md, backgroundColor: THEME.colors.surfaceSecondary, borderWidth: 1, borderColor: THEME.colors.border, alignItems: 'center', shadowColor: THEME.colors.primary, shadowOffset: { width: 0, height: 0 }, shadowOpacity: 0.3, shadowRadius: 20, elevation: 10, }, modalTitle: { fontSize: 20, fontWeight: 'bold', color: THEME.colors.text, marginBottom: THEME.spacing.xs, }, modalSub: { fontSize: 13, color: THEME.colors.textMuted, marginBottom: THEME.spacing.md, textAlign: 'center', }, input: { width: '100%', height: 50, backgroundColor: THEME.colors.surface, borderColor: THEME.colors.border, borderWidth: 1, borderRadius: THEME.borderRadius.sm, color: THEME.colors.text, paddingHorizontal: THEME.spacing.md, fontSize: 16, textAlign: 'center', marginBottom: THEME.spacing.sm, }, error: { color: THEME.colors.error, fontSize: 14, marginBottom: THEME.spacing.sm, }, modalButtons: { flexDirection: 'row', justifyContent: 'space-between', width: '100%', marginTop: THEME.spacing.sm, }, button: { flex: 1, height: 45, borderRadius: THEME.borderRadius.sm, justifyContent: 'center', alignItems: 'center', marginHorizontal: THEME.spacing.xs, }, cancelButton: { backgroundColor: 'transparent', borderWidth: 1, borderColor: THEME.colors.border, }, confirmButton: { overflow: 'hidden', borderRadius: THEME.borderRadius.sm, }, confirmGradient: { flex: 1, width: '100%', height: '100%', alignItems: 'center', justifyContent: 'center', }, buttonText: { color: THEME.colors.text, fontSize: 16, fontWeight: '600', }, });