import React, { useRef, useState, useEffect, useCallback } from "react";
import {
View,
Text,
StyleSheet,
Dimensions,
TouchableOpacity,
Alert,
Vibration,
Animated,
Platform,
ScrollView,
} from "react-native";
import { CameraView, CameraType, useCameraPermissions } from "expo-camera";
import * as MediaLibrary from "expo-media-library";
import Svg, {
Circle,
Rect,
Line,
Text as SvgText,
Path,
} from "react-native-svg";
import {
PitchDetector,
AngleMetrics,
calculateAngleColor,
getAngleMessage,
getAngleMessageTranslated,
} from "../utils/pitchDetector";
import {
YawDetector,
YawMetrics,
getYawColor,
getYawMessage,
getYawMessageTranslated,
} from "../utils/yawDetector";
import {
RealtimeBrightnessDetector,
LightingMetrics,
} from "../utils/realtimeBrightnessDetectorV2";
import {
MotionDetector,
MotionMetrics,
getMotionStabilityMessage,
} from "../utils/motionDetectorV2";
import {
FallbackSpeedDetector as SpeedDetector,
SpeedMetrics,
getSpeedColor,
getSpeedMessage,
getSpeedRecommendationMessage,
shouldAllowRecordingSpeed,
getSpeedIcon,
} from "../utils/fallbackSpeedDetector";
import {
VideoData,
SupportedLanguage,
InstructionEvent,
GuidedCameraViewProps,
} from "../types";
import { getTranslations } from "../utils/translations";
// SVG Icon Component
const CubeIcon = ({
size = 22,
color = "#FFFFFF",
}: {
size?: number;
color?: string;
}) => (
);
const SwitchIcon = ({
size = 22,
color = "#FFFFFF",
}: {
size?: number;
color?: string;
}) => (
);
const XCloseIcon = ({
size = 22,
color = "#FFFFFF",
}: {
size?: number;
color?: string;
}) => (
);
const SaveIcon = ({
size = 22,
color = "#FFFFFF",
}: {
size?: number;
color?: string;
}) => (
);
const DeleteIcon = ({
size = 22,
color = "#FFFFFF",
}: {
size?: number;
color?: string;
}) => (
);
// Helper functions for motion detection
const getMotionColor = (stability: string): string => {
switch (stability) {
case "excellent":
return "#4CAF50";
case "good":
return "#8BC34A";
case "fair":
return "#FFC107";
case "poor":
return "#FF9800";
case "very_poor":
return "#F44336";
default:
return "#FFC107";
}
};
const getMotionMessage = (metrics: MotionMetrics): string => {
return metrics.recommendation;
};
const getLightingColor = (quality: string): string => {
switch (quality) {
case "excellent":
return "#4CAF50";
case "good":
return "#8BC34A";
case "fair":
return "#FFC107";
case "poor":
return "#FF9800";
case "very_poor":
return "#F44336";
default:
return "#FFC107";
}
};
const shouldAllowRecording = (metrics: MotionMetrics): boolean => {
return metrics.isStable;
};
const { width: screenWidth, height: screenHeight } = Dimensions.get("window");
// Helper function to generate guidance messages based on current vs target angle
const generateGuidanceMessage = (
current: AngleMetrics,
yawMetrics: YawMetrics,
target: { roll: number; pitch: number; yaw?: number },
translations: ReturnType
): string => {
const rollDiff = current.roll - target.roll;
const pitchDiff = current.pitch - target.pitch;
const tolerance = 5; // degrees
// Check if we're close enough to target
if (
Math.abs(rollDiff) <= tolerance &&
Math.abs(pitchDiff) <= tolerance &&
yawMetrics.isOnTarget
) {
return translations.perfectHoldSteady;
}
// Generate directional guidance
const messages: string[] = [];
// Yaw guidance (compass direction) - highest priority
if (!yawMetrics.isOnTarget && target.yaw !== undefined) {
messages.push(getYawMessageTranslated(yawMetrics, translations));
}
// Roll guidance (left/right rotation)
if (Math.abs(rollDiff) > tolerance) {
if (rollDiff > 0) {
messages.push(translations.rotateLeft);
} else {
messages.push(translations.rotateRight);
}
}
// Pitch guidance (up/down tilt)
if (Math.abs(pitchDiff) > tolerance) {
if (pitchDiff > 0) {
messages.push(translations.tiltDown);
} else {
messages.push(translations.tiltUp);
}
}
return messages.join(" • ");
};
// Helper function to check if current angle matches target
const isAngleOnTarget = (
current: AngleMetrics,
yawMetrics: YawMetrics,
target: { roll: number; pitch: number; yaw?: number },
tolerance: number = 5
): boolean => {
const rollDiff = Math.abs(current.roll - target.roll);
const pitchDiff = Math.abs(current.pitch - target.pitch);
return (
rollDiff <= tolerance &&
pitchDiff <= tolerance &&
(target.yaw === undefined || yawMetrics.isOnTarget)
);
};
const GuidedCameraView: React.FC = ({
onCameraClose,
onScreen = false,
terminalLogs = false,
onVideoSave,
language = "english",
metricsUpdateInterval = 100, // Default 100ms update interval
includeSeverityLevels = ["info", "warning", "error"], // Default: include all severity levels
// Metrics activation props with defaults
enableAngleMetrics = true,
enableMotionMetrics = true,
enableSpeedMetrics = true,
enableLightingMetrics = true,
enableYawMetrics = true,
enableGuidanceMode = true,
// Configuration props for each detector
pitchDetectorConfig = {},
yawDetectorConfig = {},
motionDetectorConfig = {},
speedDetectorConfig = {},
brightnessDetectorConfig = {},
}) => {
const translations = getTranslations(language);
const isRTL = language === "arabic";
// Helper function to get text style with appropriate font
const getTextStyle = (weight: "regular" | "bold" = "regular") => {
return {
fontWeight: (weight === "bold" ? "bold" : "normal") as "normal" | "bold",
...(isRTL && {
textAlign: "right" as const,
writingDirection: "rtl" as const,
}),
};
};
// Helper function to get translated quality text
const getQualityTranslation = (quality: string): string => {
const qualityKey = quality.replace("_", "") as keyof typeof translations;
return (
(translations as any)[qualityKey] ||
(translations as any)[quality] ||
quality
);
};
const [facing, setFacing] = useState("back");
const [permission, requestPermission] = useCameraPermissions();
const [hasMediaLibraryPermission, setHasMediaLibraryPermission] =
useState(false);
// Camera mode state for dynamic switching
const [cameraMode, setCameraMode] = useState<"picture" | "video">("picture");
// Recording state - exactly like VideoRecorderApp
const [isRecording, setIsRecording] = useState(false);
const [recordedVideo, setRecordedVideo] = useState(null);
const [recordingDuration, setRecordingDuration] = useState(0);
const [instructionEvents, setInstructionEvents] = useState<
InstructionEvent[]
>([]);
const [lastInstructionTime, setLastInstructionTime] = useState<
Record
>({});
const [motionMetrics, setMotionMetrics] = useState({
score: 100,
isStable: true,
stability: "excellent",
accelerationMagnitude: 0,
rotationMagnitude: 0,
recommendation: "Perfect stability!",
source: "gyroscope",
});
const [speedMetrics, setSpeedMetrics] = useState({
speed: 0,
speedKmh: 0,
speedMph: 0,
accuracy: 0,
isMoving: false,
movementType: "stationary",
recommendation: "Device is stationary - perfect for stable recording",
source: "gps",
});
const [lightingMetrics, setLightingMetrics] = useState({
meanLuminance: 128,
contrastRatio: 3.0,
shadowDetail: 20,
highlightClipping: 0,
colorTemperature: 5500,
quality: "good",
isOptimal: true,
recommendation: "Lighting looks good",
score: 85,
source: "estimated",
});
const [pulseAnim] = useState(new Animated.Value(1));
const [angleMetrics, setAngleMetrics] = useState({
roll: 0,
pitch: 0,
isLevel: true,
direction: "level",
severity: "good",
});
const [isCameraReady, setIsCameraReady] = useState(false);
const [isCameraInitialized, setIsCameraInitialized] = useState(false);
// Target angle state - the desired orientation we want to guide users to
const [targetAngle, setTargetAngle] = useState({
roll: 0,
pitch: 0,
yaw: undefined as number | undefined,
});
const [isGuidanceMode, setIsGuidanceMode] = useState(false);
const [guidanceMessage, setGuidanceMessage] = useState("");
// Metrics logging state
const [metricsLogs, setMetricsLogs] = useState([]);
const maxLogs = 50; // Keep only the last 50 log entries
// Helper function to add log entry
const addLog = (message: string) => {
const timestamp = new Date().toLocaleTimeString();
const logEntry = `[${timestamp}] ${message}`;
// Terminal logs
if (terminalLogs) {
console.log(`📊 ${logEntry}`);
}
// On-screen logs
if (onScreen) {
setMetricsLogs((prev) => {
const newLogs = [logEntry, ...prev];
return newLogs.slice(0, maxLogs); // Keep only the latest entries
});
}
};
// Helper function to record instruction events during recording
const recordInstructionEvent = (
category: InstructionEvent["category"],
severity: InstructionEvent["severity"],
message: string,
throttleMs: number = 2000 // Don't record same category more than once every 2 seconds
) => {
if (!isRecording || recordingStartTime.current === 0) {
return; // Only record during active recording
}
// Check if this severity level should be included
if (!includeSeverityLevels.includes(severity)) {
return; // Skip if severity not included in filter
}
// Throttling: don't record the same category too frequently
const now = Date.now();
const lastTime = lastInstructionTime[category] || 0;
if (now - lastTime < throttleMs) {
return;
}
const elapsedMs = Date.now() - recordingStartTime.current;
const minutes = Math.floor(elapsedMs / 60000);
const seconds = Math.floor((elapsedMs % 60000) / 1000);
const timestamp = `${minutes.toString().padStart(2, "0")}:${seconds
.toString()
.padStart(2, "0")}`;
const instructionEvent: InstructionEvent = {
timestamp,
timestampMs: elapsedMs,
category,
severity,
message,
metrics: {
pitch: angleMetrics.pitch,
roll: angleMetrics.roll,
yaw: yawMetrics.yaw,
motionScore: motionMetrics.score,
speedKmh: speedMetrics.speedKmh,
brightness: lightingMetrics.meanLuminance,
},
};
setInstructionEvents((prev) => [...prev, instructionEvent]);
setLastInstructionTime((prev) => ({ ...prev, [category]: now }));
};
// Yaw tracking state
const [yawMetrics, setYawMetrics] = useState({
yaw: 0,
isOnTarget: true,
deviation: 0,
direction: "on_target",
severity: "good",
});
// Refs
const cameraRef = useRef(null);
const pitchDetectorRef = useRef(null);
const recordingStartTime = useRef(0);
const durationInterval = useRef(null);
const lastVibrationRef = useRef(0);
const motionDetectorRef = useRef(null);
const yawDetectorRef = useRef(null);
const speedDetectorRef = useRef(null);
// Request permissions on mount
useEffect(() => {
(async () => {
const mediaLibraryStatus = await MediaLibrary.requestPermissionsAsync();
setHasMediaLibraryPermission(mediaLibraryStatus.status === "granted");
})();
}, []);
// YawDetector effect for compass tracking
useEffect(() => {
if (!enableYawMetrics) return;
const handleYawChange = (metrics: YawMetrics) => {
setYawMetrics(metrics);
addLog(
`Yaw: ${metrics.direction} (${Math.round(
metrics.yaw
)}°, Dev: ${metrics.deviation.toFixed(1)}°)`
);
};
// Merge global update interval with yaw detector specific config
const yawConfig = {
updateInterval: metricsUpdateInterval,
yawTolerance: 5,
smoothingFactor: 0.8,
...yawDetectorConfig, // Override with specific config
};
yawDetectorRef.current = new YawDetector(handleYawChange, yawConfig);
yawDetectorRef.current.start();
return () => {
if (yawDetectorRef.current) {
yawDetectorRef.current.stop();
}
};
}, [metricsUpdateInterval, enableYawMetrics, yawDetectorConfig]);
// Update guidance message when yaw metrics change
useEffect(() => {
if (isGuidanceMode && enableGuidanceMode) {
const guidance = generateGuidanceMessage(
angleMetrics,
yawMetrics,
targetAngle,
translations
);
setGuidanceMessage(guidance);
}
}, [
yawMetrics,
isGuidanceMode,
angleMetrics,
targetAngle,
translations,
enableGuidanceMode,
]);
useEffect(() => {
if (!enableMotionMetrics) return;
const handleMotionChange = (metrics: MotionMetrics) => {
setMotionMetrics(metrics);
addLog(
`Motion: ${metrics.stability} (Score: ${metrics.score}, ${metrics.source})`
);
// Optional: Vibration feedback for very poor stability
const now = Date.now();
if (
metrics.stability === "very_poor" &&
now - lastVibrationRef.current > 3000
) {
Vibration.vibrate([200, 100, 200]);
lastVibrationRef.current = now;
}
};
// Merge global update interval with motion detector specific config
const motionConfig = {
updateInterval: metricsUpdateInterval,
historySize: 8,
excellentThreshold: 75, // Lowered from 85 - easier to get "excellent"
goodThreshold: 60, // Lowered from 70 - easier to get "good"
fairThreshold: 40, // Lowered from 50 - easier to get "fair"
poorThreshold: 20, // Lowered from 30 - easier to get "poor"
accelerationWeight: 0.6,
rotationWeight: 0.4,
smoothingFactor: 0.7,
...motionDetectorConfig, // Override with specific config
};
motionDetectorRef.current = new MotionDetector(
handleMotionChange,
motionConfig
);
motionDetectorRef.current.start();
return () => {
if (motionDetectorRef.current) {
motionDetectorRef.current.stop();
}
};
}, [metricsUpdateInterval, enableMotionMetrics, motionDetectorConfig]);
// SpeedDetector effect for movement tracking
useEffect(() => {
if (!enableSpeedMetrics) return;
const handleSpeedChange = (metrics: SpeedMetrics) => {
setSpeedMetrics(metrics);
addLog(
`Speed: ${metrics.movementType} (${metrics.speedKmh.toFixed(1)} km/h, ${
metrics.source
})`
);
};
// Use only the accelerometer-based speed detector (no GPS required)
const initSpeedDetector = async () => {
try {
// Merge global update interval with speed detector specific config
const speedConfig = {
updateInterval: Math.max(metricsUpdateInterval, 1000), // Speed detection minimum 1 second
enableSensorFusion: true,
movingThreshold: 0.3,
smoothingFactor: 0.8,
...speedDetectorConfig, // Override with specific config
};
speedDetectorRef.current = new SpeedDetector(
handleSpeedChange,
speedConfig
);
await speedDetectorRef.current.start();
} catch (error) {
console.error("Failed to start speed detector:", error);
}
};
initSpeedDetector();
return () => {
if (speedDetectorRef.current) {
speedDetectorRef.current.stop();
}
};
}, [metricsUpdateInterval, enableSpeedMetrics, speedDetectorConfig]);
const brightnessDetectorRef = useRef(null);
useEffect(() => {
if (!enableLightingMetrics) return;
const handleLightingChange = (metrics: LightingMetrics) => {
setLightingMetrics(metrics);
addLog(
`Lighting: ${metrics.quality} (Luminance: ${Math.round(
metrics.meanLuminance
)}, Score: ${metrics.score})`
);
};
// Merge global update interval with brightness detector specific config
const brightnessConfig = {
updateInterval: Math.max(metricsUpdateInterval * 4, 1000), // Brightness detection at least 1 second, typically 4x slower than other metrics
enableTimeBasedEstimation: true,
enableAmbientLightSensor: true, // Use ambient light sensor for better accuracy
smoothingFactor: 0.9, // More smoothing for stable readings
...brightnessDetectorConfig, // Override with specific config
};
brightnessDetectorRef.current = new RealtimeBrightnessDetector(
handleLightingChange,
brightnessConfig,
translations // Pass translations for localized messages
);
// Start without camera reference - uses ambient light sensor instead
brightnessDetectorRef.current.start();
return () => {
if (brightnessDetectorRef.current) {
brightnessDetectorRef.current.stop();
}
};
}, [
translations,
metricsUpdateInterval,
enableLightingMetrics,
brightnessDetectorConfig,
]); // Include translations and update interval to recreate detector when they change
// Initialize pitch detector
useEffect(() => {
if (!enableAngleMetrics) return;
const handleAngleChange = (metrics: AngleMetrics) => {
setAngleMetrics(metrics);
addLog(
`Angle: ${metrics.direction} (Roll: ${metrics.roll.toFixed(
1
)}°, Pitch: ${metrics.pitch.toFixed(1)}°, ${metrics.severity})`
);
// Generate guidance message if in guidance mode
if (isGuidanceMode && enableGuidanceMode) {
const guidance = generateGuidanceMessage(
metrics,
yawMetrics,
targetAngle,
translations
);
setGuidanceMessage(guidance);
}
// Vibration feedback for major tilts
const now = Date.now();
if (
!metrics.isLevel &&
metrics.severity === "major" &&
now - lastVibrationRef.current > 2000
) {
Vibration.vibrate([100, 50, 100]);
lastVibrationRef.current = now;
}
};
// Merge global update interval with pitch detector specific config
const pitchConfig = {
rollTolerance: 15,
pitchTolerance: 15,
pitchVertical: 90,
updateInterval: metricsUpdateInterval,
...pitchDetectorConfig, // Override with specific config
};
pitchDetectorRef.current = new PitchDetector(
handleAngleChange,
pitchConfig
);
pitchDetectorRef.current.start();
return () => {
if (pitchDetectorRef.current) {
pitchDetectorRef.current.stop();
}
if (durationInterval.current) {
clearInterval(durationInterval.current);
}
};
}, [
metricsUpdateInterval,
enableAngleMetrics,
enableGuidanceMode,
isGuidanceMode,
yawMetrics,
targetAngle,
translations,
pitchDetectorConfig,
]);
// Pulse animation for recording button
useEffect(() => {
let pulseAnimation: Animated.CompositeAnimation;
if (isRecording) {
pulseAnimation = Animated.loop(
Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1.1,
duration: 1000,
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
duration: 1000,
useNativeDriver: true,
}),
])
);
pulseAnimation.start();
} else {
pulseAnim.setValue(1);
}
return () => {
if (pulseAnimation) {
pulseAnimation.stop();
}
};
}, [isRecording]);
// Monitor and record guidance messages during recording
useEffect(() => {
if (!isRecording) return;
// Record speed guidance messages
if (
enableSpeedMetrics &&
speedMetrics.isMoving &&
speedMetrics.movementType !== "stationary"
) {
recordInstructionEvent(
"speed",
"warning",
getSpeedRecommendationMessage(
speedMetrics.speed,
speedMetrics.isMoving,
translations
)
);
}
// Record motion guidance messages
if (enableMotionMetrics && !motionMetrics.isStable) {
recordInstructionEvent(
"motion",
motionMetrics.stability === "very_poor" ? "error" : "warning",
getMotionStabilityMessage(motionMetrics.stability, translations)
);
}
// Record angle guidance messages
if (enableAngleMetrics && !angleMetrics.isLevel) {
recordInstructionEvent(
"angle",
angleMetrics.severity === "major" ? "warning" : "info",
getAngleMessageTranslated(angleMetrics, translations)
);
}
// Record target angle guidance (only in guidance mode)
if (
enableGuidanceMode &&
isGuidanceMode &&
guidanceMessage &&
guidanceMessage !== translations.perfectHoldSteady
) {
recordInstructionEvent("guidance", "info", guidanceMessage);
}
// Record yaw guidance (only in guidance mode with target)
if (
enableGuidanceMode &&
enableYawMetrics &&
isGuidanceMode &&
!yawMetrics.isOnTarget &&
targetAngle.yaw !== undefined
) {
recordInstructionEvent(
"yaw",
"warning",
getYawMessageTranslated(yawMetrics, translations)
);
}
}, [
isRecording,
enableSpeedMetrics,
speedMetrics.isMoving,
speedMetrics.movementType,
speedMetrics.speed,
enableMotionMetrics,
motionMetrics.isStable,
motionMetrics.stability,
enableAngleMetrics,
angleMetrics.isLevel,
angleMetrics.severity,
enableGuidanceMode,
isGuidanceMode,
guidanceMessage,
enableYawMetrics,
yawMetrics.isOnTarget,
targetAngle.yaw,
translations,
]);
// Recording duration tracking
useEffect(() => {
if (isRecording && recordingStartTime.current > 0) {
durationInterval.current = setInterval(() => {
const elapsed = Math.floor(
(Date.now() - recordingStartTime.current) / 1000
);
setRecordingDuration(elapsed);
}, 1000);
} else if (durationInterval.current) {
clearInterval(durationInterval.current);
durationInterval.current = null;
}
return () => {
if (durationInterval.current) {
clearInterval(durationInterval.current);
}
};
}, [isRecording]);
// Start recording - exactly like VideoRecorderApp
const startRecording = async () => {
if (
cameraRef.current &&
!isRecording &&
isCameraReady &&
isCameraInitialized
) {
try {
// Set current angle as target when recording starts (only if guidance mode is enabled)
if (enableGuidanceMode) {
const currentYaw = enableYawMetrics
? yawDetectorRef.current?.getCurrentYaw()
: undefined;
console.log("DEBUG: Auto-setting target on record start");
console.log("DEBUG: Current yaw from detector:", currentYaw);
console.log("DEBUG: Current angle metrics:", angleMetrics);
setTargetAngle({
roll: enableAngleMetrics ? angleMetrics.roll : 0,
pitch: enableAngleMetrics ? angleMetrics.pitch : 0,
yaw: currentYaw,
});
// Set the target in the yaw detector
if (
currentYaw !== undefined &&
yawDetectorRef.current &&
enableYawMetrics
) {
yawDetectorRef.current.setTarget(currentYaw);
console.log("DEBUG: Set yaw target to:", currentYaw);
}
setIsGuidanceMode(true);
setGuidanceMessage(translations.targetSet);
}
// Clear any previous instruction events and throttling state
setInstructionEvents([]);
setLastInstructionTime({});
// Switch to video mode for recording
setCameraMode("video");
// Longer delay on iOS to ensure camera is fully ready for video recording
const delay = Platform.OS === "ios" ? 500 : 100;
await new Promise((resolve) => setTimeout(resolve, delay));
// Additional check for iOS to ensure camera is ready
if (Platform.OS === "ios") {
// Wait a bit more and verify the camera ref is still valid
await new Promise((resolve) => setTimeout(resolve, 200));
if (!cameraRef.current) {
throw new Error("Camera reference lost during initialization");
}
}
setIsRecording(true);
recordingStartTime.current = Date.now();
setRecordingDuration(0);
console.log("Starting recording...");
const video = await cameraRef.current.recordAsync();
setRecordedVideo(video);
console.log("Video recorded:", video?.uri);
} catch (error) {
console.error("Error recording video:", error);
// More specific error handling for iOS camera readiness
const errorMessage =
error instanceof Error ? error.message : String(error);
if (
errorMessage.includes("Camera is not ready yet") ||
errorMessage.includes("not ready")
) {
Alert.alert(
translations.errorRecording,
Platform.OS === "ios"
? "Camera is still initializing. Please wait a moment and try again."
: translations.failedToRecord
);
} else {
Alert.alert(translations.errorRecording, translations.failedToRecord);
}
} finally {
setIsRecording(false);
recordingStartTime.current = 0;
setRecordingDuration(0);
// Turn off guidance mode when recording stops (only if it was enabled)
if (enableGuidanceMode) {
setIsGuidanceMode(false);
setGuidanceMessage("");
// Clear the yaw target
if (yawDetectorRef.current && enableYawMetrics) {
yawDetectorRef.current.clearTarget();
}
}
// Switch back to picture mode for brightness analysis
setCameraMode("picture");
}
}
};
// Stop recording - exactly like VideoRecorderApp
const stopRecording = async () => {
if (cameraRef.current && isRecording) {
try {
console.log("Stopping recording...");
cameraRef.current.stopRecording();
// Mode will be switched back to picture in the finally block of startRecording
} catch (error) {
console.error("Error stopping recording:", error);
// Ensure we switch back to picture mode even if stopping fails
setCameraMode("picture");
}
}
};
// Save video to gallery or pass to callback - exactly like VideoRecorderApp
const saveVideoToGallery = async () => {
if (recordedVideo?.uri) {
try {
console.log("Processing video:", recordedVideo.uri);
// If onVideoSave callback is provided, use it instead of saving to gallery
if (onVideoSave) {
const videoData: VideoData = {
uri: recordedVideo.uri,
duration: recordingDuration,
instructionEvents: instructionEvents, // Include all recorded instruction events
};
onVideoSave(videoData);
setRecordedVideo(null);
setInstructionEvents([]); // Clear instruction events after saving
setLastInstructionTime({}); // Clear throttling state
return;
}
// Original gallery saving logic (fallback)
if (!hasMediaLibraryPermission) {
Alert.alert(
"Permission Required",
"Please grant media library permission to save videos"
);
return;
}
// Create asset
const asset = await MediaLibrary.createAssetAsync(recordedVideo.uri);
// Try to add to album
const album = await MediaLibrary.getAlbumAsync("Videos");
if (album == null) {
await MediaLibrary.createAlbumAsync("Videos", asset, false);
} else {
await MediaLibrary.addAssetsToAlbumAsync([asset], album, false);
}
Alert.alert(translations.success, translations.videoSaved);
setRecordedVideo(null);
} catch (error) {
console.error("Error saving video:", error);
Alert.alert(translations.errorRecording, translations.failedToSave);
}
} else {
Alert.alert(translations.errorRecording, translations.noVideoToSave);
}
};
const discardVideo = () => {
setRecordedVideo(null);
setInstructionEvents([]); // Clear instruction events when discarding video
setLastInstructionTime({}); // Clear throttling state
};
// Combined toggle function - like VideoRecorderApp
const toggleRecording = () => {
if (isRecording) {
stopRecording();
} else {
// Additional camera readiness check for iOS
if (!isCameraReady || !isCameraInitialized) {
Alert.alert(
translations.errorRecording,
Platform.OS === "ios"
? "Camera is still initializing. Please wait a moment and try again."
: "Camera is not ready yet. Please wait for camera to initialize."
);
return;
}
// Ensure camera ref is valid
if (!cameraRef.current) {
Alert.alert(
translations.errorRecording,
"Camera reference is not available. Please restart the camera."
);
return;
}
// Check motion stability before starting recording (only if enabled)
if (enableMotionMetrics && !shouldAllowRecording(motionMetrics)) {
Alert.alert(translations.motionTooHigh, translations.stabilizePhone, [
{ text: "OK" },
]);
return;
}
// Check speed before starting recording (only if enabled)
if (enableSpeedMetrics && !shouldAllowRecordingSpeed(speedMetrics)) {
Alert.alert(
translations.movementTooFast,
`${
speedMetrics.recommendation
}\nCurrent speed: ${speedMetrics.speedKmh.toFixed(1)} km/h`,
[{ text: "OK" }]
);
return;
}
startRecording();
}
};
const onCameraReady = useCallback(() => {
console.log("Camera is ready");
setIsCameraInitialized(true);
// On iOS, add a small delay to ensure camera is fully initialized
if (Platform.OS === "ios") {
setTimeout(() => {
setIsCameraReady(true);
console.log("iOS camera ready state set after delay");
}, 300);
} else {
setIsCameraReady(true);
}
}, []);
const toggleCameraFacing = useCallback(() => {
if (!isRecording) {
setFacing((current) => (current === "back" ? "front" : "back"));
}
}, [isRecording]);
// Function to handle camera close
const handleCameraClose = useCallback(() => {
console.log("Camera close requested");
if (onCameraClose) {
onCameraClose();
}
}, [onCameraClose]);
// Function to set current angle as target
const setCurrentAsTarget = useCallback(() => {
if (!enableGuidanceMode) return;
console.log("DEBUG: setCurrentAsTarget called");
const currentYaw = enableYawMetrics
? yawDetectorRef.current?.getCurrentYaw()
: undefined;
console.log("DEBUG: Current yaw from detector:", currentYaw);
console.log("DEBUG: Current angle metrics:", angleMetrics);
setTargetAngle({
roll: enableAngleMetrics ? angleMetrics.roll : 0,
pitch: enableAngleMetrics ? angleMetrics.pitch : 0,
yaw: currentYaw,
});
// Set the target in the yaw detector
if (
currentYaw !== undefined &&
yawDetectorRef.current &&
enableYawMetrics
) {
yawDetectorRef.current.setTarget(currentYaw);
console.log("DEBUG: Set yaw target to:", currentYaw);
}
setIsGuidanceMode(true);
setGuidanceMessage(translations.targetAngleSet);
console.log("DEBUG: Target angle set to:", {
roll: enableAngleMetrics ? angleMetrics.roll : 0,
pitch: enableAngleMetrics ? angleMetrics.pitch : 0,
yaw: currentYaw,
});
}, [
angleMetrics.roll,
angleMetrics.pitch,
translations,
enableGuidanceMode,
enableAngleMetrics,
enableYawMetrics,
]);
// Function to toggle guidance mode
const toggleGuidanceMode = useCallback(() => {
if (!enableGuidanceMode) return;
console.log(
"DEBUG: toggleGuidanceMode called, current state:",
isGuidanceMode
);
setIsGuidanceMode(!isGuidanceMode);
if (!isGuidanceMode) {
setGuidanceMessage(translations.guidanceModeEnabled);
console.log("DEBUG: Guidance mode enabled");
} else {
setGuidanceMessage("");
console.log("DEBUG: Guidance mode disabled");
}
}, [isGuidanceMode, translations, enableGuidanceMode]);
// Function to reset to level target (0,0)
const setLevelTarget = useCallback(() => {
if (!enableGuidanceMode) return;
console.log("DEBUG: setLevelTarget called");
setTargetAngle({ roll: 0, pitch: 0, yaw: undefined });
// Clear the target in the yaw detector
if (yawDetectorRef.current && enableYawMetrics) {
yawDetectorRef.current.clearTarget();
console.log("DEBUG: Cleared yaw target");
}
setIsGuidanceMode(true);
setGuidanceMessage(translations.targetSetToLevel);
console.log("DEBUG: Target set to level (0, 0, undefined)");
}, [translations, enableGuidanceMode, enableYawMetrics]);
// Permission check
if (!permission) {
return ;
}
if (!permission.granted) {
return (
{translations.cameraPermissionMessage}
{translations.grantPermission}
);
}
const formatRecordingTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, "0")}:${secs
.toString()
.padStart(2, "0")}`;
};
const renderAngleIndicator = () => {
const centerX = screenWidth / 2;
const centerY = 80;
const radius = 24;
const bubbleRadius = 5;
const maxOffset = radius - bubbleRadius;
const offsetX = (angleMetrics.roll / 60) * maxOffset;
const offsetY = (-angleMetrics.pitch / 60) * maxOffset;
const bubbleX =
centerX + Math.max(-maxOffset, Math.min(maxOffset, offsetX));
const bubbleY =
centerY + Math.max(-maxOffset, Math.min(maxOffset, offsetY));
return (
);
};
const renderBalanceIndicator = () => {
const centerX = screenWidth / 2;
const indicatorY = 100;
const barWidth = 120;
const barHeight = 4;
const rollRatio = angleMetrics.roll / 60;
const indicatorX = centerX + rollRatio * (barWidth / 2);
const pitchRatio = angleMetrics.pitch / 60;
const indicatorY2 = indicatorY + 20;
const indicatorY2Bar = indicatorY2;
const indicatorY2Circle = indicatorY2Bar + barHeight / 2;
const indicatorX2 = centerX + pitchRatio * (barWidth / 2);
// Target indicators
const targetRollRatio = targetAngle.roll / 60;
const targetIndicatorX = centerX + targetRollRatio * (barWidth / 2);
const targetPitchRatio = targetAngle.pitch / 60;
const targetIndicatorX2 = centerX + targetPitchRatio * (barWidth / 2);
return (
);
};
const renderCompassIndicator = () => {
if (!isGuidanceMode || targetAngle.yaw === undefined) {
return null;
}
const centerX = screenWidth / 2;
const centerY = 200;
const radius = 30;
const needleLength = 20;
// Calculate current yaw needle position
const currentRadians = (yawMetrics.yaw * Math.PI) / 180;
const currentNeedleX = centerX + Math.sin(currentRadians) * needleLength;
const currentNeedleY = centerY - Math.cos(currentRadians) * needleLength;
// Calculate target yaw needle position
const targetRadians = (targetAngle.yaw * Math.PI) / 180;
const targetNeedleX = centerX + Math.sin(targetRadians) * needleLength;
const targetNeedleY = centerY - Math.cos(targetRadians) * needleLength;
return (
);
};
return (
{/* Overlay content - only show when recording */}
{isRecording && (
{enableAngleMetrics && renderAngleIndicator()}
{enableAngleMetrics && renderBalanceIndicator()}
{enableGuidanceMode && enableYawMetrics && renderCompassIndicator()}
)}
{/* Recording indicator */}
{isRecording && (
{translations.recording} {formatRecordingTime(recordingDuration)}
)}
{/* Camera ready indicator */}
{!isCameraReady && (
{Platform.OS === "ios"
? "Initializing camera..."
: translations.preparing}
)}
{/* Status bar - only show when recording */}
{isRecording && (
{enableAngleMetrics && (
{translations.pitch}
{angleMetrics.isLevel
? translations.level
: translations.tilted}
{Math.abs(angleMetrics.pitch).toFixed(1)}°
)}
{enableMotionMetrics && (
{translations.motionScore}
{getQualityTranslation(motionMetrics.stability)}
{motionMetrics.score}
)}
{/* Distance indicator commented out */}
{/*
{translations.distance}
{translations.bad}
4.32m
*/}
{enableGuidanceMode &&
enableYawMetrics &&
isGuidanceMode &&
targetAngle.yaw !== undefined && (
{translations.compass}
{yawMetrics.isOnTarget
? translations.onTrack
: translations.turnBody}
{`${Math.round(yawMetrics.yaw)}°`}
)}
{enableSpeedMetrics && (
{translations.speed}
{speedMetrics.movementType === "stationary"
? translations.stationary
: speedMetrics.movementType}
{speedMetrics.speedKmh.toFixed(1)} km/h
)}
{enableLightingMetrics && (
{translations.brightness}
{getQualityTranslation(lightingMetrics.quality)}
{Math.round(lightingMetrics.meanLuminance)}
)}
)}
{/* Detection Frame */}
{/*
*/}
{/* Guidance message - only show when recording */}
{isRecording && (
{/* Speed guidance - appears at top when active (most urgent) */}
{enableSpeedMetrics &&
speedMetrics.isMoving &&
speedMetrics.movementType !== "stationary" && (
{getSpeedRecommendationMessage(
speedMetrics.speed,
speedMetrics.isMoving,
translations
)}
)}
{/* Motion guidance - appears above angle guidance when unstable */}
{enableMotionMetrics && !motionMetrics.isStable && (
{getMotionStabilityMessage(
motionMetrics.stability,
translations
)}
)}
{/* Target angle guidance - main guidance when in guidance mode */}
{enableGuidanceMode && isGuidanceMode && guidanceMessage && (
{guidanceMessage}
)}
{/* Angle guidance - basic level guidance when not in guidance mode */}
{enableAngleMetrics && !isGuidanceMode && (
{getAngleMessageTranslated(angleMetrics, translations)}
)}
)}
{/* Bottom Controls */}
{/* Left side - Close button */}
{/* Center - Record button or video actions */}
{recordedVideo ? (
{/* Discard */}
{/* Save */}
) : (
)}
{/* Right side - Camera flip button */}
{/* Metrics Logs Overlay */}
{onScreen && (
{translations.metricsLogs}
setMetricsLogs([])}
>
{translations.clear}
{metricsLogs.length === 0 ? (
{translations.noLogsYet}
) : (
metricsLogs.slice(0, 15).map((log, index) => (
{log}
))
)}
)}
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: "center",
},
message: {
textAlign: "center",
paddingBottom: 10,
fontSize: 16,
color: "#333",
},
camera: {
flex: 1,
},
recordingIndicator: {
position: "absolute",
top: 60,
left: 16,
flexDirection: "row",
alignItems: "center",
backgroundColor: "rgba(255, 68, 68, 0.9)",
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 20,
},
recordingDot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: "#FFFFFF",
marginRight: 8,
},
recordingText: {
color: "#FFFFFF",
fontSize: 12,
fontWeight: "bold",
},
cameraNotReady: {
position: "absolute",
top: 60,
right: 16,
backgroundColor: "rgba(255, 165, 0, 0.8)",
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 20,
},
cameraNotReadyText: {
color: "#FFFFFF",
fontSize: 12,
fontWeight: "bold",
},
topBar: {
position: "absolute",
top: screenHeight * 0.12,
left: 16,
right: 16,
flexDirection: "row",
justifyContent: "space-between",
},
statusItem: {
borderRadius: 8,
},
statusLabel: {
color: "#FFF",
fontSize: 12,
textTransform: "uppercase",
fontWeight: "bold",
},
statusComment: {
color: "#FFF",
textTransform: "capitalize",
fontSize: 14,
fontWeight: "bold",
},
statusValue: {
color: "#FFF",
fontSize: 14,
fontWeight: "bold",
},
guidanceContainer: {
position: "absolute",
top: screenHeight * 0.72,
left: 16,
right: 16,
flexDirection: "column-reverse",
},
bottomControllsButton: {
width: 48,
height: 48,
borderRadius: 48,
backgroundColor: "rgba(0,0,0,0.2)",
alignItems: "center",
justifyContent: "center",
},
guidanceItem: {
paddingHorizontal: 16,
paddingVertical: 8,
marginVertical: 2,
borderRadius: 8,
alignItems: "center",
},
guidanceText: {
color: "#FFFFFF",
fontSize: 14,
fontWeight: "bold",
},
button: {
backgroundColor: "#2196F3",
paddingHorizontal: 20,
paddingVertical: 12,
borderRadius: 8,
marginHorizontal: 10,
},
buttonText: {
color: "#FFFFFF",
fontSize: 16,
fontWeight: "bold",
},
angleIndicator: {
position: "absolute",
top: -10,
left: 0,
right: 0,
},
balanceIndicator: {
position: "absolute",
top: 100,
left: 0,
right: 0,
},
compassIndicator: {
position: "absolute",
top: 50,
left: 100,
right: 0,
},
detectionFrame: {
position: "absolute",
top: screenHeight * 0.2,
left: 32,
right: 32,
bottom: screenHeight * 0.25,
borderWidth: 2,
borderColor: "rgba(255, 255, 255, 0.5)",
borderStyle: "dashed",
},
frameCorner: {
position: "absolute",
width: 20,
height: 20,
borderLeftWidth: 3,
borderTopWidth: 3,
borderColor: "#FFFFFF",
top: -2,
left: -2,
},
frameCornerTopRight: {
transform: [{ rotate: "90deg" }],
top: -2,
right: -2,
left: undefined,
},
frameCornerBottomLeft: {
transform: [{ rotate: "-90deg" }],
bottom: -2,
top: undefined,
left: -2,
},
frameCornerBottomRight: {
transform: [{ rotate: "180deg" }],
bottom: -2,
right: -2,
top: undefined,
left: undefined,
},
bottomControls: {
position: "absolute",
bottom: 50,
left: 0,
right: 0,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 50,
},
videoActions: {
flexDirection: "row",
alignItems: "center",
gap: 30,
},
actionButton: {
alignItems: "center",
backgroundColor: "rgba(0, 0, 0, 0.5)",
paddingVertical: 15,
paddingHorizontal: 20,
borderRadius: 25,
},
actionText: {
color: "white",
fontSize: 12,
marginTop: 5,
},
recordButton: {
width: 70,
height: 70,
borderRadius: 35,
borderWidth: 4,
borderColor: "#FFFFFF",
alignItems: "center",
justifyContent: "center",
},
recordButtonInner: {
width: 50,
height: 50,
borderRadius: 25,
},
logsContainer: {
position: "absolute",
top: 60,
left: 16,
right: 16,
bottom: 120,
backgroundColor: "rgba(0, 0, 0, 0.85)",
borderRadius: 12,
padding: 16,
},
logsHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 12,
paddingBottom: 8,
borderBottomWidth: 1,
borderBottomColor: "rgba(255, 255, 255, 0.2)",
},
logsTitle: {
color: "#FFFFFF",
fontSize: 16,
fontWeight: "bold",
},
clearLogsButton: {
backgroundColor: "rgba(255, 255, 255, 0.2)",
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 6,
},
clearLogsText: {
color: "#FFFFFF",
fontSize: 12,
fontWeight: "bold",
},
logsList: {
flex: 1,
},
noLogsText: {
color: "rgba(255, 255, 255, 0.6)",
fontSize: 14,
textAlign: "center",
marginTop: 20,
fontStyle: "italic",
},
logEntry: {
color: "#FFFFFF",
fontSize: 11,
marginBottom: 4,
paddingVertical: 2,
paddingHorizontal: 8,
backgroundColor: "rgba(255, 255, 255, 0.1)",
borderRadius: 4,
fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
},
// RTL styles
topBarRTL: {
flexDirection: "row-reverse",
},
textRTL: {
textAlign: "right",
writingDirection: "rtl",
},
});
export default GuidedCameraView;