Native zoom presets 1x-10x; back/front camera flip
This commit is contained in:
@@ -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')}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user