diff --git a/App.tsx b/App.tsx index cdb5c52..6d8b7f2 100644 --- a/App.tsx +++ b/App.tsx @@ -39,6 +39,9 @@ const NATIVE_EXPORT = process.env.EXPO_PUBLIC_NATIVE_EXPORT === '1'; export default function App() { const [mode, setMode] = useState<'camera' | 'library'>('camera'); + // Back/front lens the live viewfinder uses; flipping is transient UI state + // (not persisted in sessions — startup always opens on the back camera). + const [cameraFacing, setCameraFacing] = useState<'back' | 'front'>('back'); // 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). @@ -807,6 +810,7 @@ export default function App() { setSettingsVisible(true)} + onFlipCamera={() => setCameraFacing((f) => (f === 'back' ? 'front' : 'back'))} isLibraryMode={mode === 'library'} onSwitchToCamera={() => handleSetMode('camera')} /> diff --git a/src/components/CameraControls.tsx b/src/components/CameraControls.tsx index 85f20bd..5663861 100644 --- a/src/components/CameraControls.tsx +++ b/src/components/CameraControls.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { View, Text, TouchableOpacity, Image, StyleSheet } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { Image as ImageIcon, Eye, Download, Camera, Settings } from 'lucide-react-native'; +import { Image as ImageIcon, Eye, Download, Camera, Settings, RefreshCw } from 'lucide-react-native'; interface CameraControlsProps { lastPhotoUri: string | null; @@ -9,6 +9,7 @@ interface CameraControlsProps { onPickImage: () => void; onOpenPreview: () => void; onOpenSettings: () => void; + onFlipCamera?: () => void; isLibraryMode: boolean; onSwitchToCamera?: () => void; } @@ -19,6 +20,7 @@ export default function CameraControls({ onPickImage, onOpenPreview, onOpenSettings, + onFlipCamera, isLibraryMode, onSwitchToCamera, }: CameraControlsProps) { @@ -69,20 +71,33 @@ export default function CameraControls({ )} - {/* Right Action Button: Eye (preview) or Camera (switch back) */} - - {isLibraryMode ? ( - - ) : lastPhotoUri ? ( - - ) : ( - + {/* Right cluster: flip camera (between shutter & preview) + preview. + Preview doubles as "back to camera" while in library mode; the flip + control is hidden then. */} + + {!isLibraryMode && onFlipCamera && ( + + + )} - + + {isLibraryMode ? ( + + ) : lastPhotoUri ? ( + + ) : ( + + )} + + ); } diff --git a/src/components/Viewfinder.tsx b/src/components/Viewfinder.tsx index fdc1c8c..ff53539 100644 --- a/src/components/Viewfinder.tsx +++ b/src/components/Viewfinder.tsx @@ -31,6 +31,9 @@ import { polaroidLayout, wallframeLayout, WALLFRAME_PNG_W, WALLFRAME_PNG_H } fro interface ViewfinderProps { mode: 'camera' | 'library'; + // Camera lens facing for the live viewfinder; flipping restarts the session + // on the other device. + facing?: 'back' | 'front'; recipe: Recipe; selectedFrame: FrameId; useGeotag: boolean; @@ -130,6 +133,7 @@ const CLIP_INTERSECT = ClipOp.Intersect; const Viewfinder = forwardRef(function Viewfinder( { mode, + facing = 'back', recipe, selectedFrame, useGeotag, @@ -194,7 +198,7 @@ const Viewfinder = forwardRef(function Viewfi // useCameraDevice resolves the back camera fresh on every render, so once the // permission lands (first-run flow: GRANT button → OS dialog) a re-render is // enough to pick the device up — no manual polling needed. - const device = useCameraDevice('back'); + const device = useCameraDevice(facing); // CameraX negotiates the CameraFrameOutput aspect on its own (default 16:9 on // this device), which center-crops the 4:3 sensor — so the ratio band could @@ -312,6 +316,37 @@ const Viewfinder = forwardRef(function Viewfi // Tap-to-focus / AE-AF-AWB lock point (view coords). null = auto continuous. const [focusPoint, setFocusPoint] = useState<{ x: number; y: number } | null>(null); const controller = cameraActive ? skiaCameraRef.current?.controller : undefined; + // Native zoom. CameraX on this class of device exposes one back logical + // camera (1x main physical + digital crop to 10x); the ultra-wide/tele + // logical cameras are not in CameraX's device list, so preset chips snap the + // controller zoom ratio (1x optical, 2x-10x digital). Applied ratio survives + // session restarts (CameraX resets to 1x on each fresh session). + const [zoomRatio, setZoomRatio] = useState(1); + const zoomRef = useRef(1); + useEffect(() => { + zoomRef.current = zoomRatio; + }, [zoomRatio]); + const applyZoom = useCallback((ratio: number) => { + setZoomRatio(ratio); + const ctl = skiaCameraRef.current?.controller; + if (ctl) { + ctl.setZoom(ratio).catch((e) => console.warn('setZoom failed', String(e).slice(0, 120))); + } + }, []); + // Re-apply after the camera session restarts (background resume, camera↔ + // library switch): a fresh session starts back at 1x. + useEffect(() => { + if (!controller) return; + if (zoomRef.current !== 1) { + controller.setZoom(zoomRef.current).catch(() => {}); + } + }, [controller]); + // Switching back/front restarts the session at the new lens: never carry a + // digital zoom across the switch. + useEffect(() => { + zoomRef.current = 1; + setZoomRatio(1); + }, [facing]); const evStops = adjustments.exposureCompensation ?? 0; // Latest slider value, so stale retries from a previous value back off when // the user has already dragged further (dragging fast used to queue 5 retries @@ -1154,6 +1189,39 @@ const Viewfinder = forwardRef(function Viewfi )} {renderCameraOverlays()} + {/* Native zoom presets — snap to a controller zoom ratio. 1x is the + only optical stop on the main back lens; 2x-10x are digital crop + (CameraX exposes no tele/ultra-wide logical device here). Row sits + under the TopBar, clear of the AE-lock chip (top-right) and the EV + strip (right edge). */} + {controller && ( + + + {[1, 2, 3, 5, 10].map((z) => ( + applyZoom(z)} + className={'px-3.5 py-1.5 rounded-full ' + (zoomRatio === z ? 'bg-amber-500' : '')} + activeOpacity={0.7} + > + + {z}x + + + ))} + + + )} + ); }