import React, { useState, useEffect, useRef, useCallback } from 'react'; import { StyleSheet, Text, View, TouchableOpacity, Platform, Modal, TextInput, TouchableWithoutFeedback, PermissionsAndroid, } from 'react-native'; import { CameraView, useCameraPermissions } from 'expo-camera'; import * as FileSystem from 'expo-file-system/legacy'; import { File, Paths } from 'expo-file-system/next'; import * as ImageManipulator from 'expo-image-manipulator'; import { LinearGradient } from 'expo-linear-gradient'; import MaskedView from '@react-native-masked-view/masked-view'; import Animated, { useSharedValue, useAnimatedStyle, withSpring, withTiming, FadeIn, FadeOut, withSequence, runOnJS, } from 'react-native-reanimated'; import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'; import { faUserGear, faCameraRetro, faCamera, faXmark, faHandPointer, faFaceSmile, } 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; footerText: string; useFrontCamera: boolean; isObscured: boolean; eventText: string; } export default function CameraScreen({ countdownDuration, onPhotoCaptured, onBurstComplete, onCancel, isIdle = false, onStartBooth, onNavigateToAdmin, adminPassword = '1234', welcomeText = 'Zeit für ein Erinnerungsfoto!', burstCount = 1, burstIntervalSec, frameMode, selectedFrameId, footerText, useFrontCamera, isObscured, eventText, }: CameraScreenProps) { const [permission, requestPermission] = useCameraPermissions(); const [isUsbConnected, setIsUsbConnected] = useState(false); const [countdown, setCountdown] = useState(countdownDuration); const [isCapturing, setIsCapturing] = useState(false); const [hasStarted, setHasStarted] = useState(false); const [pictureSize, setPictureSize] = useState('fallback'); // Burst mode state and refs const [burstIndex, setBurstIndex] = useState(0); const burstIndexRef = useRef(0); const burstUrisRef = useRef([]); 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 completionTimerRef = useRef(null); const isCancelledRef = useRef(false); // Reanimated countdown pulse animation const scale = useSharedValue(1); useEffect(() => { if (countdown !== null) { scale.value = 1; scale.value = withSequence( withTiming(1.5, { duration: 100 }), withSpring(1, { damping: 10, stiffness: 100 }) ); } }, [countdown]); const animatedStyle = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }], })); const flashAnimatedStyle = useAnimatedStyle(() => ({ opacity: flashOpacity.value, })); // Turn on white flash effect const turnFlashOn = useCallback(() => { flashOpacity.value = withTiming(1, { duration: 50 }); }, []); // Turn off white flash effect const turnFlashOff = useCallback(() => { flashOpacity.value = withTiming(0, { duration: 800 }); }, []); // 1. Request built-in camera permission on mount (just in case we fallback) useEffect(() => { if (permission && !permission.granted && permission.canAskAgain) { requestPermission(); } // Specifically for ausbc native library which needs these at runtime if (Platform.OS === 'android') { const requestAndroidPerms = async () => { try { const granted = await PermissionsAndroid.requestMultiple([ PermissionsAndroid.PERMISSIONS.CAMERA, PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE, PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE, ]); logger.log('Android Permissions: ' + JSON.stringify(granted)); } catch (err) { console.warn('Failed to request Android permissions', err); } }; requestAndroidPerms(); } }, [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); if (completionTimerRef.current) clearTimeout(completionTimerRef.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: any, b: any) => a.pixels - b.pixels); // Find the LARGEST size available to satisfy user request for original size const optimal = parsedSizes[parsedSizes.length - 1]; if (optimal) { console.log('Setting max pictureSize for high-res original:', optimal.size); setPictureSize(optimal.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 () => { logger.log('Action: Cancelled camera burst capture'); isCancelledRef.current = true; if (burstTimerRef.current) clearTimeout(burstTimerRef.current); if (countdownTimerRef.current) clearTimeout(countdownTimerRef.current); if (completionTimerRef.current) clearTimeout(completionTimerRef.current); const urisToDelete = [...burstUrisRef.current]; setCountdown(countdownDuration); setIsCapturing(false); setIsBurstWaiting(false); setHasStarted(false); setBurstIndex(0); burstIndexRef.current = 0; burstUrisRef.current = []; setShowBurstIndicator(''); onCancel(); // Clean up any temp files from a partially completed burst in the background Promise.all( urisToDelete.map(uri => FileSystem.deleteAsync(uri, { idempotent: true }).catch(e => { console.warn('Failed to delete temp file on cancel:', e); }) ) ); }; // 3. Start the countdown on screen load (only when NOT idle) useEffect(() => { if (isIdle) { setHasStarted(false); setBurstIndex(0); burstIndexRef.current = 0; burstUrisRef.current = []; 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 () => { if (countdownTimerRef.current) clearTimeout(countdownTimerRef.current); }; }, [countdownDuration, isIdle, pictureSize, isUsbConnected, burstCount, burstIntervalSec]); 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 { turnFlashOn(); // Wait for UI to render the flash before blocking main thread with capture await new Promise(resolve => setTimeout(resolve, 150)); let capturedUri: string; 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. Bitte stelle sicher, dass die externe Kamera verbunden ist, oder aktiviere den Front-Kamera-Fallback in den Admin-Einstellungen.'); } } turnFlashOff(); // 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; } // Auto-save the high-res original raw photo to the gallery try { await saveToGallery(capturedUri, 'Raw', eventText); logger.log('Auto-saved high-res raw capture to gallery'); } catch (e: any) { logger.error(`Failed to auto-save raw capture: ${e?.message || e}`); } // Downscale for the UI to prevent OutOfMemoryError in React Native Image try { console.log('Downscaling photo for PreviewScreen UI...'); const manipResult = await ImageManipulator.manipulateAsync( capturedUri, [{ resize: { width: 1920 } }], { compress: 0.8, format: ImageManipulator.SaveFormat.JPEG } ); capturedUri = manipResult.uri; console.log('Successfully downscaled UI photo.'); } catch (e) { console.error('Failed to downscale photo, UI might crash:', e); } console.log('Photo captured successfully, proceeding to PreviewScreen.'); // Handle burst mode const effectiveBurst = burstCount || 1; const newIndex = burstIndexRef.current + 1; const newUris = [...burstUrisRef.current, capturedUri]; if (effectiveBurst > 1 && newIndex < effectiveBurst) { // More burst photos to take burstIndexRef.current = newIndex; burstUrisRef.current = newUris; setBurstIndex(newIndex); 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 burstIndexRef.current = 0; burstUrisRef.current = []; setBurstIndex(0); // Short delay prevents Android expo-camera crash on immediate unmount completionTimerRef.current = setTimeout(() => { onBurstComplete(newUris); }, 400); } else { // Single shot mode completionTimerRef.current = setTimeout(() => { onPhotoCaptured(capturedUri); }, 400); } } catch (error: any) { turnFlashOff(); console.error('Capture failed:', error); alert('Fehler beim Aufnehmen des Fotos: ' + error.message); onCancel(); } }; const handleSettingsTap = () => { logger.log('Action: Tapped settings button'); setEnteredPassword(''); setErrorText(''); setPasswordModalVisible(true); }; const handlePasswordSubmit = () => { logger.log(`Action: Submitted admin password (correct: ${enteredPassword === adminPassword})`); 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(''); } }; // Show permission block if built-in camera has no permission AND USB is not connected if (!permission) { return ( Berechtigungen werden geladen... ); } if (!permission.granted && !isUsbConnected) { return ( Wir benötigen deine Erlaubnis, um die Tablet-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 */} {/* Glow/Shadow Layer */} {welcomeText} {/* White Outline Layers */} {[ { t: -2, l: -2 }, { t: -2, l: 0 }, { t: -2, l: 2 }, { t: 0, l: -2 }, { t: 0, l: 2 }, { t: 2, l: -2 }, { t: 2, l: 0 }, { t: 2, l: 2 } ].map((pos, index) => ( {welcomeText} ))} {/* Gradient Text Layer over the shadow layer */} {welcomeText} } > {/* Footer Instructions */} {footerText === 'Display tippen! • Lächeln! • Foto schnappen!' ? ( ㅤDisplay tippen!ㅤ•ㅤ ㅤLächeln!ㅤ•ㅤ ㅤFoto schnappen! ) : ( footerText )} {/* 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 removed per request */} {/* 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' && !useFrontCamera ? ( ) : ( )} {/* Render UI controls/overlays on top */} {renderContent()} {/* 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', justifyContent: 'center', alignItems: 'center', zIndex: 100, }, idleContentArea: { height: '100%', aspectRatio: 3 / 2, position: 'relative', }, captureOverlayContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, zIndex: 100, }, 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, }, headerTitleContainer: { position: 'absolute', top: 40, left: '2%', width: '96%', height: 100, }, headerTitleBase: { width: '100%', textAlign: 'center', fontSize: 80, fontWeight: '900', letterSpacing: 6, }, footerInstructions: { position: 'absolute', bottom: 40, left: '2%', width: '96%', textAlign: 'center', fontSize: 40, 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: 'left', 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', }, });