1028 lines
32 KiB
TypeScript
1028 lines
32 KiB
TypeScript
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<boolean>(false);
|
||
const [countdown, setCountdown] = useState<number | 'smile'>(countdownDuration);
|
||
const [isCapturing, setIsCapturing] = useState<boolean>(false);
|
||
const [hasStarted, setHasStarted] = useState<boolean>(false);
|
||
const [pictureSize, setPictureSize] = useState<string | undefined>('fallback');
|
||
|
||
// Burst mode state and refs
|
||
const [burstIndex, setBurstIndex] = useState<number>(0);
|
||
const burstIndexRef = useRef<number>(0);
|
||
const burstUrisRef = useRef<string[]>([]);
|
||
const [showBurstIndicator, setShowBurstIndicator] = useState<string>('');
|
||
const [isBurstWaiting, setIsBurstWaiting] = useState<boolean>(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<UsbCameraRef>(null);
|
||
const expoCameraRef = useRef<any>(null);
|
||
const burstTimerRef = useRef<any>(null);
|
||
const countdownTimerRef = useRef<any>(null);
|
||
const completionTimerRef = useRef<any>(null);
|
||
const isCancelledRef = useRef<boolean>(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 (
|
||
<View style={styles.container}>
|
||
<Text style={styles.infoText}>Berechtigungen werden geladen...</Text>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
if (!permission.granted && !isUsbConnected) {
|
||
return (
|
||
<View style={styles.container}>
|
||
<Text style={styles.infoText}>Wir benötigen deine Erlaubnis, um die Tablet-Kamera anzuzeigen</Text>
|
||
<TouchableOpacity style={styles.btn} onPress={requestPermission}>
|
||
<Text style={styles.btnText}>Erlaubnis erteilen</Text>
|
||
</TouchableOpacity>
|
||
<TouchableOpacity style={[styles.btn, styles.cancelBtn]} onPress={handleLocalCancel}>
|
||
<Text style={styles.btnText}>Zurück</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
const renderContent = () => {
|
||
if (isIdle) {
|
||
// Home Overlay (frosty glass box on top of the camera stream)
|
||
return (
|
||
<TouchableWithoutFeedback onPress={onStartBooth}>
|
||
<View style={styles.idleOverlayContainer}>
|
||
{/* Settings button in the top-right corner */}
|
||
<TouchableOpacity
|
||
style={styles.settingsButton}
|
||
onPress={handleSettingsTap}
|
||
activeOpacity={0.7}
|
||
testID="settings-button"
|
||
>
|
||
<FontAwesomeIcon icon={faUserGear} size={22} color={THEME.colors.accent} />
|
||
</TouchableOpacity>
|
||
|
||
<View style={styles.idleContentArea} pointerEvents="none">
|
||
<View style={styles.headerTitleContainer}>
|
||
{/* Glow/Shadow Layer */}
|
||
<Text
|
||
style={[styles.headerTitleBase, { position: 'absolute', color: THEME.colors.accent, textShadowColor: THEME.colors.cyanGlow, textShadowOffset: { width: 0, height: 0 }, textShadowRadius: 25 }]}
|
||
adjustsFontSizeToFit
|
||
numberOfLines={1}
|
||
>
|
||
{welcomeText}
|
||
</Text>
|
||
|
||
{/* 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) => (
|
||
<Text
|
||
key={`outline-${index}`}
|
||
style={[styles.headerTitleBase, { position: 'absolute', color: 'white', top: pos.t, left: pos.l }]}
|
||
adjustsFontSizeToFit
|
||
numberOfLines={1}
|
||
>
|
||
{welcomeText}
|
||
</Text>
|
||
))}
|
||
|
||
{/* Gradient Text Layer over the shadow layer */}
|
||
<MaskedView
|
||
style={StyleSheet.absoluteFill}
|
||
maskElement={
|
||
<Text
|
||
style={[styles.headerTitleBase, { position: 'absolute', color: 'black' }]}
|
||
adjustsFontSizeToFit
|
||
numberOfLines={1}
|
||
>
|
||
{welcomeText}
|
||
</Text>
|
||
}
|
||
>
|
||
<LinearGradient
|
||
colors={THEME.gradient.primary}
|
||
start={{ x: 0, y: 0.5 }}
|
||
end={{ x: 1, y: 0.5 }}
|
||
style={{ flex: 1 }}
|
||
/>
|
||
</MaskedView>
|
||
</View>
|
||
|
||
{/* Footer Instructions */}
|
||
<Text
|
||
style={styles.footerInstructions}
|
||
adjustsFontSizeToFit
|
||
numberOfLines={1}
|
||
>
|
||
{footerText === 'Display tippen! • Lächeln! • Foto schnappen!' ? (
|
||
<Text>
|
||
<FontAwesomeIcon icon={faHandPointer} color={THEME.colors.text} size={32} />ㅤDisplay tippen!ㅤ•ㅤ
|
||
<FontAwesomeIcon icon={faFaceSmile} color={THEME.colors.text} size={32} />ㅤLächeln!ㅤ•ㅤ
|
||
<FontAwesomeIcon icon={faCamera} color={THEME.colors.text} size={32} />ㅤFoto schnappen!
|
||
</Text>
|
||
) : (
|
||
footerText
|
||
)}
|
||
</Text>
|
||
</View>
|
||
|
||
{/* Password Prompt Modal */}
|
||
<Modal
|
||
visible={passwordModalVisible}
|
||
transparent={true}
|
||
animationType="fade"
|
||
onRequestClose={() => setPasswordModalVisible(false)}
|
||
>
|
||
<View style={styles.modalOverlay}>
|
||
<View style={styles.modalContent}>
|
||
<Text style={styles.modalTitle}>Admin-Zugang</Text>
|
||
<Text style={styles.modalSub}>Passwort eingeben, um Einstellungen zu öffnen</Text>
|
||
|
||
<TextInput
|
||
style={styles.input}
|
||
secureTextEntry
|
||
placeholder="Passwort"
|
||
placeholderTextColor={THEME.colors.textMuted}
|
||
value={enteredPassword}
|
||
onChangeText={setEnteredPassword}
|
||
keyboardType="numeric"
|
||
autoFocus
|
||
testID="admin-password-input"
|
||
/>
|
||
|
||
{errorText ? <Text style={styles.error}>{errorText}</Text> : null}
|
||
|
||
<View style={styles.modalButtons}>
|
||
<TouchableOpacity
|
||
style={[styles.button, styles.cancelButton]}
|
||
onPress={() => setPasswordModalVisible(false)}
|
||
>
|
||
<Text style={styles.buttonText}>Abbrechen</Text>
|
||
</TouchableOpacity>
|
||
<TouchableOpacity
|
||
style={[styles.button, styles.confirmButton]}
|
||
onPress={handlePasswordSubmit}
|
||
>
|
||
<LinearGradient
|
||
colors={THEME.gradient.primary}
|
||
start={{ x: 0, y: 0.5 }}
|
||
end={{ x: 1, y: 0.5 }}
|
||
style={styles.confirmGradient}
|
||
>
|
||
<Text style={styles.buttonText}>Öffnen</Text>
|
||
</LinearGradient>
|
||
</TouchableOpacity>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
</Modal>
|
||
</View>
|
||
</TouchableWithoutFeedback>
|
||
);
|
||
}
|
||
|
||
// Capture Mode overlays (Countdown, Burst indicator, and Cancel button)
|
||
const activeFrame = selectedFrameId && burstCount === 1
|
||
? FRAMES.find((f) => f.id === selectedFrameId)
|
||
: null;
|
||
|
||
return (
|
||
<View style={styles.captureOverlayContainer} pointerEvents="box-none">
|
||
{/* Frame Overlay */}
|
||
{frameMode === 'always' && activeFrame && (
|
||
<View style={styles.cameraWrapper} pointerEvents="none">
|
||
<FrameOverlay frameAsset={activeFrame.asset} />
|
||
</View>
|
||
)}
|
||
|
||
{/* Source indicator removed per request */}
|
||
|
||
{/* Burst progress indicator */}
|
||
{(burstCount || 1) > 1 && (
|
||
<View style={styles.burstBadge}>
|
||
<FontAwesomeIcon icon={faCameraRetro} size={12} color={THEME.colors.accent} />
|
||
<Text style={styles.burstBadgeText}>
|
||
Foto {burstIndex + 1} / {burstCount}
|
||
</Text>
|
||
</View>
|
||
)}
|
||
|
||
{/* Burst waiting indicator */}
|
||
{isBurstWaiting && showBurstIndicator !== '' && (
|
||
<View style={styles.countdownOverlay} pointerEvents="none">
|
||
<View style={styles.burstWaitingBox}>
|
||
<Text style={styles.burstWaitingText}>{showBurstIndicator}</Text>
|
||
<Text style={styles.burstWaitingSub}>Nächstes Foto in {burstIntervalSec}s…</Text>
|
||
</View>
|
||
</View>
|
||
)}
|
||
|
||
{/* Countdown overlay */}
|
||
{hasStarted && !isBurstWaiting && (
|
||
<View style={styles.countdownOverlay} pointerEvents="none">
|
||
<Animated.View style={[styles.countdownBox, animatedStyle]}>
|
||
{countdown === 'smile' ? (
|
||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12 }}>
|
||
<Text style={styles.countdownText}>Bitte lächeln!</Text>
|
||
<FontAwesomeIcon icon={faCamera} size={48} color={THEME.colors.accent} />
|
||
</View>
|
||
) : (
|
||
<Text style={styles.countdownText}>{countdown}</Text>
|
||
)}
|
||
</Animated.View>
|
||
</View>
|
||
)}
|
||
|
||
{/* Cancel Button */}
|
||
{!isCapturing && !isBurstWaiting && (
|
||
<TouchableOpacity style={styles.backButton} onPress={handleLocalCancel}>
|
||
<FontAwesomeIcon icon={faXmark} size={16} color={THEME.colors.text} style={{ marginRight: 6 }} />
|
||
<Text style={styles.backButtonText}>ABBRECHEN</Text>
|
||
</TouchableOpacity>
|
||
)}
|
||
</View>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<View style={styles.container}>
|
||
{/* Camera Preview Background */}
|
||
<View style={styles.previewContainer}>
|
||
<View style={styles.cameraWrapper}>
|
||
{Platform.OS === 'android' && !useFrontCamera ? (
|
||
<UsbCameraView
|
||
ref={usbCameraRef}
|
||
style={StyleSheet.absoluteFill}
|
||
/>
|
||
) : (
|
||
<CameraView
|
||
ref={expoCameraRef}
|
||
style={[styles.cameraPreview, { position: 'absolute', opacity: 1, zIndex: 10 }]}
|
||
facing="front"
|
||
pictureSize={pictureSize === 'fallback' ? undefined : pictureSize}
|
||
onCameraReady={handleCameraReady}
|
||
/>
|
||
)}
|
||
</View>
|
||
</View>
|
||
|
||
{/* Render UI controls/overlays on top */}
|
||
{renderContent()}
|
||
|
||
{/* Flash overlay */}
|
||
<Animated.View style={[styles.flashOverlay, flashAnimatedStyle]} pointerEvents="none" />
|
||
</View>
|
||
);
|
||
}
|
||
|
||
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',
|
||
},
|
||
});
|