Native zoom presets 1x-10x; back/front camera flip

This commit is contained in:
2026-09-09 18:21:08 +07:00
parent ca3532023a
commit 55f4184e74
3 changed files with 103 additions and 15 deletions
+5
View File
@@ -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() {
<Viewfinder
ref={viewfinderRef}
mode={mode}
facing={cameraFacing}
recipe={
selectedRecipe || {
id: 'temp',
@@ -878,6 +882,7 @@ export default function App() {
onPickImage={handlePickImage}
onOpenPreview={openLatestViewer}
onOpenSettings={() => setSettingsVisible(true)}
onFlipCamera={() => setCameraFacing((f) => (f === 'back' ? 'front' : 'back'))}
isLibraryMode={mode === 'library'}
onSwitchToCamera={() => handleSetMode('camera')}
/>
+29 -14
View File
@@ -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({
)}
</TouchableOpacity>
{/* Right Action Button: Eye (preview) or Camera (switch back) */}
<TouchableOpacity
onPress={isLibraryMode && onSwitchToCamera ? onSwitchToCamera : onOpenPreview}
className="w-12 h-12 rounded-full bg-black/40 border border-zinc-700/70 flex items-center justify-center overflow-hidden"
activeOpacity={0.7}
>
{isLibraryMode ? (
<Camera size={20} color="#e4e4e7" strokeWidth={1.75} />
) : lastPhotoUri ? (
<Image source={{ uri: lastPhotoUri }} style={StyleSheet.absoluteFill} className="object-cover" />
) : (
<Eye size={20} color="#a1a1aa" strokeWidth={1.75} />
{/* Right cluster: flip camera (between shutter & preview) + preview.
Preview doubles as "back to camera" while in library mode; the flip
control is hidden then. */}
<View className="flex-row items-center space-x-3">
{!isLibraryMode && onFlipCamera && (
<TouchableOpacity
onPress={onFlipCamera}
className="w-12 h-12 rounded-full bg-black/40 border border-zinc-700/70 flex items-center justify-center"
activeOpacity={0.7}
>
<RefreshCw size={20} color="#e4e4e7" strokeWidth={1.75} />
</TouchableOpacity>
)}
</TouchableOpacity>
<TouchableOpacity
onPress={isLibraryMode && onSwitchToCamera ? onSwitchToCamera : onOpenPreview}
className="w-12 h-12 rounded-full bg-black/40 border border-zinc-700/70 flex items-center justify-center overflow-hidden"
activeOpacity={0.7}
>
{isLibraryMode ? (
<Camera size={20} color="#e4e4e7" strokeWidth={1.75} />
) : lastPhotoUri ? (
<Image source={{ uri: lastPhotoUri }} style={StyleSheet.absoluteFill} className="object-cover" />
) : (
<Eye size={20} color="#a1a1aa" strokeWidth={1.75} />
)}
</TouchableOpacity>
</View>
</View>
);
}
+69 -1
View File
@@ -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<ViewfinderHandle, ViewfinderProps>(function Viewfinder(
{
mode,
facing = 'back',
recipe,
selectedFrame,
useGeotag,
@@ -194,7 +198,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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 && (
<View
pointerEvents="box-none"
className="absolute left-0 right-0 items-center"
style={{ top: insets.top + 62 }}
>
<View className="flex-row rounded-full border border-zinc-700/70 bg-black/50 p-1">
{[1, 2, 3, 5, 10].map((z) => (
<TouchableOpacity
key={z}
onPress={() => applyZoom(z)}
className={'px-3.5 py-1.5 rounded-full ' + (zoomRatio === z ? 'bg-amber-500' : '')}
activeOpacity={0.7}
>
<Text
className={
'font-mono text-xs font-bold ' +
(zoomRatio === z ? 'text-black' : 'text-white')
}
>
{z}x
</Text>
</TouchableOpacity>
))}
</View>
</View>
)}
</View>
);
}