import React, { useState, useEffect, useRef, useCallback } from 'react'; import { View, StatusBar, Alert, ActivityIndicator, Text, AppState, Platform } from 'react-native'; import { useCameraPermission, usePhotoOutput } from 'react-native-vision-camera'; import * as ImagePicker from 'expo-image-picker'; import * as FileSystem from 'expo-file-system/legacy'; import * as MediaLibrary from 'expo-media-library'; import * as MediaLibraryLegacy from 'expo-media-library/legacy'; import * as Haptics from 'expo-haptics'; import AsyncStorage from '@react-native-async-storage/async-storage'; import TopBar from './src/components/TopBar'; import Viewfinder, { type ViewfinderHandle } from './src/components/Viewfinder'; import AdjustmentPanel from './src/components/AdjustmentPanel'; import CameraControls from './src/components/CameraControls'; import ToolRail, { TabId } from './src/components/ToolRail'; import SettingsModal from './src/components/SettingsModal'; import PhotoViewerModal from './src/components/PhotoViewerModal'; import { Recipe, ColorAdjustments, FrameId, GPSInfo, AspectRatio, MeterMode, PhotoEntry, ShutterSound } from './src/types'; import { getAllRecipes, saveCustomRecipe, deleteCustomRecipe } from './src/utils/storageUtils'; import { DEFAULT_ADJUSTMENTS } from './src/utils/defaultRecipes'; import { getCurrentGPS, requestLocationPermissions } from './src/utils/locationUtils'; import { resolvePhotoGPS } from './src/utils/photoGps'; import { processAndExportPhoto } from './src/utils/exportEngine'; import { PARAM_DEFS } from './src/utils/paramDefs'; // @ts-ignore import './global.css'; const SESSION_KEY = 'saved_session'; export default function App() { const [mode, setMode] = useState<'camera' | 'library'>('camera'); // Imperative handle into the Viewfinder camera controller: flushes the EV to // the hardware AE bias right before each capture (live slider drags never // touch the hardware — that flashed the Xiaomi preview). const viewfinderRef = useRef(null); const [recipes, setRecipes] = useState([]); const [selectedRecipe, setSelectedRecipe] = useState(null); // Custom temporary overrides for the sliders const [adjustments, setAdjustments] = useState({ ...DEFAULT_ADJUSTMENTS }); const [selectedFrame, setSelectedFrame] = useState('none'); const [useGeotag, setUseGeotag] = useState(true); const [gpsInfo, setGpsInfo] = useState(null); // Composition aspect-ratio (FULL / 4:3 / 3:2 upright) + RAW DNG sidecar. const [aspectRatio, setAspectRatio] = useState('full'); const [rawEnabled, setRawEnabled] = useState(false); // Full-frame metering mode (FOCUS → METERING MODE): highlight-weighted meters // the BRIGHTEST area to protect its detail (never blow whites) and pushes the // shadows very deep — extreme contrast (GR/Sony). Realised as: a small EV dip // (capture headroom; NOT a full-frame dim), a highlight roll (keep bright // detail), deep shadow crush and raised contrast/clarity. All constants are a // starting approximation — tune after device testing. const [meterMode, setMeterMode] = useState('evaluative'); const METER_HL_EV = -0.5; const METER_HL_HIGHLIGHT = -3; const METER_HL_SHADOW = -4; const METER_HL_CONTRAST = 3; const METER_HL_CLARITY = 3; const clampAdj = (v: number) => Math.max(-10, Math.min(10, v)); const meteringActive = mode === 'camera' && meterMode === 'highlight'; const meteringAdj: Partial | undefined = meteringActive ? { highlight: clampAdj((adjustments.highlight ?? 0) + METER_HL_HIGHLIGHT), shadow: clampAdj((adjustments.shadow ?? 0) + METER_HL_SHADOW), contrast: clampAdj((adjustments.contrast ?? 0) + METER_HL_CONTRAST), clarity: clampAdj((adjustments.clarity ?? 0) + METER_HL_CLARITY), } : undefined; // Same tonal overlay for the camera EXPORT (the EV dip is not inside — the // hardware AE bias at capture already carries it, evFromCamera skips software // EV, so folding the dip here would darken twice). const renderAdj: ColorAdjustments = meteringAdj ? { ...adjustments, ...meteringAdj } : adjustments; const rawFailRef = useRef(false); // Progressive disclosure: a tool chip opens its panel; null = photo hero only. const [openTab, setOpenTab] = useState(null); // The parameter whose slider row is open inside the panel — lifted here (not // local to AdjustmentPanel) because an open row arms drag-anywhere-on-the- // image adjustment in the Viewfinder. Cleared when the panel/tab closes. const [openParamKey, setOpenParamKey] = useState(null); const [libraryImageUri, setLibraryImageUri] = useState(null); // Session photo history (camera captures + library exports). The camera // thumbnail/preview button shows the newest entry; the swipe viewer walks the // whole list. Not persisted across restarts — entries are app-cache files. const [photoHistory, setPhotoHistory] = useState([]); // Index into photoHistory of the photo shown in the swipe viewer; null = closed. const [viewerIndex, setViewerIndex] = useState(null); // Gear sheet (sound / ratio / metering mode). Camera captures never auto-open // the viewer anymore — the thumbnail button does. const [settingsVisible, setSettingsVisible] = useState(false); // Shutter click: only OFF/ON (no volume API on Android). Applied at capture. const [shutterSound, setShutterSound] = useState('on'); const photoSeq = useRef(0); const [isProcessing, setIsProcessing] = useState(false); // Hardware Permissions (vision-camera) const { hasPermission: cameraPermissionGranted, requestPermission: requestCameraPermission } = useCameraPermission(); // Full-resolution photo output — attached to the live in Viewfinder. // Photo capture is resolution-independent from the Skia preview output. const photoOutput = usePhotoOutput({ qualityPrioritization: 'quality', }); // Second photo output for RAW DNG sidecars (RATIO panel → RAW on). Kept as // a live output only while rawEnabled — CameraX cannot bind two ImageCapture // use cases, so attaching both at once would break the session. Attaching a // DNG output while RAW is on gives ~one exposure per capture: the JPEG below // and this DNG are separate shots taken a few tens of ms apart. const rawPhotoOutput = usePhotoOutput({ containerFormat: 'dng', qualityPrioritization: 'quality', }); // vision-camera may return a raw absolute path on Android; surface a file:// URI. const toUri = (path: string) => (/^(file|content|ph):\/\//.test(path) ? path : `file://${path}`); useEffect(() => { // B5: run a single sequenced init — restore the session first, then fall // back to the first recipe only when no recipe was restored. initSession(); // Do NOT auto-load device GPS here — it would override photo GPS on library mode }, []); // Save/restore session on app lifecycle changes useEffect(() => { const subscription = AppState.addEventListener('change', async (nextAppState) => { if (nextAppState === 'background' || nextAppState === 'inactive') { const session = { mode, libraryImageUri, adjustments, selectedFrame, useGeotag, aspectRatio, meterMode, shutterSound, selectedRecipeId: selectedRecipe?.id || null, gpsInfo, }; try { await AsyncStorage.setItem(SESSION_KEY, JSON.stringify(session)); } catch (e) { console.warn('Failed to save session:', e); } } }); return () => subscription.remove(); }, [mode, libraryImageUri, adjustments, selectedFrame, useGeotag, selectedRecipe, gpsInfo, aspectRatio, meterMode, shutterSound]); const initSession = async () => { const list = await getAllRecipes(); setRecipes(list); let session: any = null; try { const raw = await AsyncStorage.getItem(SESSION_KEY); if (raw) session = JSON.parse(raw); } catch (e) { console.warn('Failed to read session:', e); } let restoredRecipe: Recipe | null = null; if (session) { // B12: the restored library image may no longer exist on disk let imageUri: string | null = session.libraryImageUri || null; if (imageUri) { try { const info = await FileSystem.getInfoAsync(imageUri); if (!info.exists) imageUri = null; } catch { imageUri = null; } } if (imageUri) { setLibraryImageUri(imageUri); setMode('library'); } else { setMode(session.mode || 'camera'); } if (session.adjustments) setAdjustments({ ...DEFAULT_ADJUSTMENTS, ...session.adjustments }); if (session.selectedFrame) setSelectedFrame(session.selectedFrame); if (session.useGeotag !== undefined) setUseGeotag(session.useGeotag); if (session.aspectRatio === '4:3' || session.aspectRatio === '3:2') setAspectRatio(session.aspectRatio); if (session.meterMode === 'evaluative' || session.meterMode === 'highlight') setMeterMode(session.meterMode); if (session.shutterSound === 'on' || session.shutterSound === 'off') setShutterSound(session.shutterSound); if (session.gpsInfo) setGpsInfo(session.gpsInfo); if (session.selectedRecipeId) { restoredRecipe = list.find((r: Recipe) => r.id === session.selectedRecipeId) || null; } } if (restoredRecipe) { applyRecipe(restoredRecipe); } else if (list.length > 0) { applyRecipe(list[0]); } }; const loadGPS = async () => { const info = await getCurrentGPS(); if (info) { setGpsInfo({ ...info, source: 'device' }); } }; const applyRecipe = (recipe: Recipe) => { setSelectedRecipe(recipe); setAdjustments(recipe.adjustments); setSelectedFrame(recipe.frameId || 'none'); setUseGeotag(recipe.useGeotag); }; const handleUpdateAdjustments = (updates: Partial) => { // Storm guard, generic across every param: same-value writes (slider // micro-jitter / repeated pointer-move events) still build a fresh // adjustments object and re-render the whole tree — skip them. Supersedes // the old EV-only dedupe; EV flush at capture reads its own ref, so a // skipped identical EV needs no re-send. const keys = Object.keys(updates) as (keyof ColorAdjustments)[]; if (keys.every((k) => updates[k] === adjustments[k])) return; // B8: never call a setState inside another setState's updater. const next = { ...adjustments, ...updates }; setAdjustments(next); if (selectedRecipe) { setSelectedRecipe({ ...selectedRecipe, adjustments: next, }); } }; const handleUpdateFrame = (frameId: FrameId) => { setSelectedFrame(frameId); if (selectedRecipe) { setSelectedRecipe({ ...selectedRecipe, frameId, }); } }; const handleToggleGeotag = async (enabled: boolean) => { setUseGeotag(enabled); if (selectedRecipe) { setSelectedRecipe({ ...selectedRecipe, useGeotag: enabled, }); } if (enabled && !gpsInfo) { const locationGranted = await requestLocationPermissions(); if (locationGranted) { loadGPS(); } else { Alert.alert('Permission Denied', 'Enable location services to use GPS Watermark.'); setUseGeotag(false); } } }; const handleAspectRatio = (ratio: AspectRatio) => { setAspectRatio(ratio); }; const handleRawEnabled = (enabled: boolean) => { rawFailRef.current = false; // allow one recovery alert per enable setRawEnabled(enabled); if (enabled) { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); } }; // Latest-rawEnabled ref so the stable session-error callback below can read // the live toggle without re-subscribing the Camera session on every render. const rawEnabledRef = useRef(rawEnabled); rawEnabledRef.current = rawEnabled; // Attaching the DNG output while RAW is on adds a second ImageCapture use // case — CameraX historically refuses two per camera. If the session errors // in that state, drop RAW (removing the output reconfigures back to the // single working JPEG session) instead of leaving a dead camera. const handleCameraSessionError = useCallback((error: Error) => { console.error('Camera session error:', error); if (rawEnabledRef.current && !rawFailRef.current) { rawFailRef.current = true; setRawEnabled(false); Alert.alert( 'RAW Unavailable', 'DNG capture could not start on this device, so RAW was turned off. JPEG capture keeps working.' ); } }, []); // Save the freshly captured DNG into an album literally named "RAW" (create // the album on first use). Failure never blocks the JPEG pipeline. const saveRawToAlbum = async (uri: string): Promise => { try { const perm = await MediaLibraryLegacy.requestPermissionsAsync(); if (!perm.granted) { console.warn('RAW save: media library permission denied'); return false; } const asset = await MediaLibraryLegacy.createAssetAsync(uri); const albumName = 'RAW'; let album = await MediaLibraryLegacy.getAlbumAsync(albumName); if (album) { await MediaLibraryLegacy.addAssetsToAlbumAsync(asset, album); } else { await MediaLibraryLegacy.createAlbumAsync(albumName, asset); } return true; } catch (e) { console.error('RAW album save failed:', e); return false; } }; const handleSaveRecipe = async (name: string) => { if (!selectedRecipe) return; const newRecipe = await saveCustomRecipe({ name, baseFilter: selectedRecipe.baseFilter, adjustments, frameId: selectedFrame, useGeotag, }); const list = await getAllRecipes(); setRecipes(list); applyRecipe(newRecipe); Alert.alert('Recipe Saved', `"${name}" has been created.`); }; // PRESETS → CREATE quick-input: build a brand-new recipe from the modal's // typed fields, persist it, and apply it (the user sees the look immediately). const handleCreateRecipe = async (recipe: Omit) => { const newRecipe = await saveCustomRecipe(recipe); const list = await getAllRecipes(); setRecipes(list); applyRecipe(newRecipe); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); }; const handleDeleteRecipe = async (id: string) => { await deleteCustomRecipe(id); const list = await getAllRecipes(); setRecipes(list); if (selectedRecipe?.id === id && list.length > 0) { applyRecipe(list[0]); } }; const handlePickImage = async () => { if (isProcessing) return; // On Android 10+, picking without ACCESS_MEDIA_LOCATION makes the OS hand us // a redacted copy (EXIF GPS stripped). That silently kills Tier-1 EXIF GPS // and the photo's own coordinates (often a different place than where the // user stands now) get replaced by a device-GPS fallback. Request media // read + photo-location BEFORE the pick so the copy keeps its true EXIF GPS. let mediaOk = true; if (useGeotag && Platform.OS === 'android') { try { const perm = await MediaLibrary.requestPermissionsAsync(false, ['photo']); mediaOk = perm.granted; } catch (e) { mediaOk = false; console.warn('Pre-pick media+photo-location permission error:', e); } } const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ImagePicker.MediaTypeOptions.Images, allowsEditing: false, quality: 1, exif: true, // Android 13+ photo picker always returns a redacted cache copy with // assetId=null — EXIF GPS stripped, MediaStore id lost → photo GPS is // unreachable and we'd stamp the device's location instead. The legacy // picker (ACTION_GET_CONTENT → DocumentsUI) hands back a // com.android.providers.media.documents uri, so the MediaStore asset id // survives AND (with ACCESS_MEDIA_LOCATION granted) the file is not // redacted → tier-1 EXIF / tier-2 MediaStore both work. legacy: Platform.OS === 'android' && useGeotag, }); if (!result.canceled && result.assets && result.assets.length > 0) { const asset = result.assets[0]; setLibraryImageUri(asset.uri); setMode('library'); // Clear old GPS — will be replaced if the photo (or device) has location data setGpsInfo(null); // Multi-tier GPS resolution: EXIF → MediaLibrary → device fallback. // Device fallback only when media read was granted (else we cannot know // whether the photo carries its own GPS — stamping the device's current // spot over a photo from somewhere else would be wrong). const { gps, permissionDenied } = await resolvePhotoGPS(asset, { useGeotag, allowDeviceFallback: useGeotag && mediaOk, }); setGpsInfo(gps); if (permissionDenied) { console.warn('Media library read permission denied — photo GPS unavailable.'); } Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); } }; const activeRecipe: Recipe = selectedRecipe || { id: 'temp', name: 'Custom', baseFilter: 'none', adjustments, frameId: selectedFrame, useGeotag, }; // Thumbnail/preview target for the camera-mode right button: newest photo. const lastPhotoUri = photoHistory.length > 0 ? photoHistory[photoHistory.length - 1].uri : null; const handleCapture = async () => { if (isProcessing) return; // Camera mode (Phase 4, option A): real capture through vision-camera. if (mode === 'camera') { if (!cameraPermissionGranted) { Alert.alert('Permission Needed', 'Grant camera permission to take photos.'); return; } if (!photoOutput) { Alert.alert('Camera Not Ready', 'Please wait for the camera to start, then try again.'); return; } setIsProcessing(true); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); try { // A photo taken now is at the device's current spot — resolve device GPS // fresh instead of reusing GPS left over from a previously loaded photo. let captureGps = gpsInfo && gpsInfo.source === 'device' ? gpsInfo : null; if (useGeotag && !captureGps) { const deviceGps = await getCurrentGPS(); if (deviceGps) { captureGps = { ...deviceGps, source: 'device' as const }; setGpsInfo(captureGps); } } const tShot = Date.now(); // Hardware AE bias for this shot (best-effort, bounded retries inside). // Applied here — right before the capture — instead of live during EV // slider drags, which re-locked AE on the Xiaomi and flashed preview. try { await viewfinderRef.current?.flushExposureBias(); } catch (e) { console.warn('EV flush at capture failed:', e); } const { filePath } = await photoOutput.capturePhotoToFile({ enableShutterSound: shutterSound === 'on' }, {}); const sourceUri = toUri(filePath); // RAW DNG sidecar: fire the second exposure immediately after the JPEG // so the two shots stay a few tens of ms apart, then file it into the // "RAW" album without blocking the JPEG export below. if (rawEnabled && rawPhotoOutput) { try { const rawFile = await rawPhotoOutput.capturePhotoToFile({ enableShutterSound: shutterSound === 'on' }, {}); const rawUri = toUri(rawFile.filePath); saveRawToAlbum(rawUri).then((ok) => { if (!ok) { Alert.alert('RAW Not Saved', 'The DNG was captured but could not be added to the RAW album.'); } }); } catch (rawError) { console.error('RAW capture failed:', rawError); } } const tExp = Date.now(); const result = await processAndExportPhoto( sourceUri, { ...activeRecipe, adjustments: renderAdj, frameId: selectedFrame, useGeotag }, selectedFrame, useGeotag, captureGps, // 300dpi header + full-res screen sharpen. evFromCamera: the AE bias // already applied exposure compensation at capture — don't add 2^EV. // aspect: crop to the on-screen composition ratio (full = keep 4:3 native). { dpi: 300, sharpen: true, evFromCamera: true, aspect: aspectRatio === 'full' ? undefined : aspectRatio, } ); if (result) { // No auto preview after a capture — the thumbnail button opens the // swipe viewer. Saved to session history either way. pushPhoto({ uri: result.uri, recipeName: activeRecipe.name, savedToLibrary: result.savedToLibrary, assetId: result.assetId, }); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); } else { Alert.alert('Failed', 'Error occurred while processing the captured photo.'); } } catch (error) { console.error('Camera capture failed:', error); Alert.alert('Capture Failed', 'Could not take the photo. See logs for details.'); } finally { setIsProcessing(false); } return; } // Library mode capture/export: processes selected image with recipe, overlays, text if (!libraryImageUri || !selectedRecipe) { Alert.alert('Error', 'Please select an image from the library first.'); return; } setIsProcessing(true); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); const result = await processAndExportPhoto( libraryImageUri, { ...selectedRecipe, adjustments, frameId: selectedFrame, useGeotag, }, selectedFrame, useGeotag, gpsInfo, // Library export follows the same composition ratio as the viewfinder band. { aspect: aspectRatio === 'full' ? undefined : aspectRatio } ); setIsProcessing(false); if (result) { pushPhoto({ uri: result.uri, recipeName: activeRecipe.name, savedToLibrary: result.savedToLibrary, assetId: result.assetId, }); // Library has no thumbnail button (right button switches back to camera), // so the export result auto-opens the viewer on the newest photo. setViewerIndex(photoHistory.length); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); } else { Alert.alert('Failed', 'Error occurred while processing image.'); } }; // Switching back to camera: drop GPS that belongs to a library photo, so the // live view (and any capture) never stamps a stale location. const handleSetMode = (next: 'camera' | 'library') => { if (next === 'camera' && gpsInfo && gpsInfo.source !== 'device') { setGpsInfo(null); } setMode(next); }; // Append a finished export (camera capture or library export) to the session // photo history. Returned entry id lets callers delete by id. const pushPhoto = (p: Omit): string => { const id = `photo-${Date.now()}-${photoSeq.current++}`; setPhotoHistory((h) => [...h, { id, ...p }]); return id; }; // Thumbnail (camera mode) opens the viewer on the newest photo. const openLatestViewer = () => { if (photoHistory.length > 0) setViewerIndex(photoHistory.length - 1); }; // Viewer trash: delete the photo from the app AND its gallery copy (when it // was saved). Deleting a saved photo goes through Android's per-item system // confirmation (scoped storage — every single delete asks again by design); // if the user cancels THAT dialog the photo is kept in both places. const handleDeletePhoto = async (id: string) => { const idx = photoHistory.findIndex((p) => p.id === id); if (idx < 0) return; const entry = photoHistory[idx]; const remove = () => { const next = photoHistory.filter((p) => p.id !== id); setPhotoHistory(next); setViewerIndex((prev) => { if (prev === null) return null; if (next.length === 0) return null; if (prev > idx) return prev - 1; if (prev >= next.length) return next.length - 1; return prev; }); }; const deleteCache = async () => { try { await FileSystem.deleteAsync(entry.uri, { idempotent: true }); } catch (e) { console.warn('Photo cache delete failed:', e); } }; if (!entry.savedToLibrary || !entry.assetId) { // Never reached the gallery — nothing for the OS to confirm. remove(); deleteCache(); return; } try { // deleteAssetsAsync shows the OS delete confirmation when the photo is // in the gallery. false = user cancelled it → keep everything. const ok = await MediaLibraryLegacy.deleteAssetsAsync([entry.assetId]); if (!ok) { Alert.alert('Photo Kept', 'Android did not confirm the deletion, so the photo stays here and in your gallery.'); return; } remove(); deleteCache(); } catch (e) { console.warn('Gallery delete failed:', e); Alert.alert('Delete Failed', 'Could not remove the photo from your gallery. It stays here too.'); } }; // Viewer SAVE & DONE: photo was exported to cache only — copy it into the // gallery now and remember its assetId so a later trash can remove it. const handleSavePhoto = async (id: string) => { const entry = photoHistory.find((p) => p.id === id); if (!entry || entry.savedToLibrary) return; const perm = await MediaLibraryLegacy.requestPermissionsAsync(); if (!perm.granted) { Alert.alert('Not Saved', 'Media library access was denied, so this photo was not saved to your gallery.'); return; } try { const asset = await MediaLibraryLegacy.createAssetAsync(entry.uri); setPhotoHistory((h) => h.map((p) => (p.id === id ? { ...p, savedToLibrary: true, assetId: asset.id } : p))); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); } catch (e) { console.error('Save to gallery failed:', e); Alert.alert('Save Failed', 'Could not save this photo to your gallery.'); } }; // Tool-rail toggle: closing the panel (or switching tabs) also drops the open // parameter row — otherwise a stale slider row (and its image-drag target) // would survive into the next panel. const handleToggleTab = (tab: TabId) => { setOpenTab((prev) => (prev === tab ? null : tab)); setOpenParamKey(null); }; // While a panel parameter row is open, a vertical drag anywhere on the live // camera image adjusts that same parameter (the open slider follows). Open // row wins over the locked-AE EV drag; a plain tap still locks focus. const imageAdjustTarget = (() => { if (mode !== 'camera' || openTab !== 'iq' && openTab !== 'wb' && openTab !== 'filters') return null; if (!openParamKey) return null; const list = openTab === 'iq' ? PARAM_DEFS.iq : openTab === 'wb' ? PARAM_DEFS.wb : PARAM_DEFS.filters; const def = list.find((d) => d.key === openParamKey); if (!def) return null; return { key: def.key, min: def.min, max: def.max, step: def.step, value: def.get(adjustments), onChange: (v: number) => handleUpdateAdjustments(def.set(v)), }; })(); return ( {/* Fullscreen hero: photo or live preview fills the whole screen */} handleUpdateAdjustments({ exposureCompensation: ev })} meteringEvOffset={meteringActive ? METER_HL_EV : 0} meteringAdjustments={meteringAdj} imageAdjustTarget={imageAdjustTarget} /> {/* Top overlay: mode label + recipe name (left), Save current (right) */} {/* Bottom deck: chip panel (progressive disclosure) -> tool rail -> shutter */} {openTab !== null && ( )} setSettingsVisible(true)} isLibraryMode={mode === 'library'} onSwitchToCamera={() => handleSetMode('camera')} /> {/* Gear sheet: shutter sound / ratio+RAW / metering mode */} setSettingsVisible(false)} aspectRatio={aspectRatio} onAspectRatio={handleAspectRatio} rawEnabled={rawEnabled} onRawEnabled={handleRawEnabled} cameraMode={mode === 'camera'} meterMode={meterMode} onMeterModeChange={setMeterMode} shutterSound={shutterSound} onShutterSound={setShutterSound} /> {/* Session photo viewer (swipe across captured/exported photos) */} 0} photos={photoHistory} initialIndex={viewerIndex ?? 0} onClose={() => setViewerIndex(null)} onDelete={handleDeletePhoto} onSave={handleSavePhoto} /> {/* Global Processing Loader Overlay */} {isProcessing && ( RENDERING PHOTO... )} ); }