Fire the LED on the 0.5x still

0.5x is a bare Camera2 session, so CameraX never saw the flash setting and the ultra-wide still came off unlit. The native module now reads FLASH_INFO_AVAILABLE off the lens, maps off/auto/on to CONTROL_AE_MODE plus FLASH_MODE_SINGLE, and starts an AE precapture so the HAL can meter the flash before the frame. The viewfinder hands its flashMode down beside the shutter, with a Zap control that only appears where a unit exists.
This commit is contained in:
2026-09-16 18:11:17 +07:00
parent 12d58ff810
commit c40b5565d3
5 changed files with 242 additions and 99 deletions
+42 -8
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { View, StatusBar, Alert, ActivityIndicator, Text, AppState, Platform, Keyboard } from 'react-native';
import { useCameraPermission, usePhotoOutput } from 'react-native-vision-camera';
import { useCameraDevice, useCameraPermission, usePhotoOutput } from 'react-native-vision-camera';
import * as DocumentPicker from 'expo-document-picker';
import * as Sharing from 'expo-sharing';
import * as FileSystem from 'expo-file-system/legacy';
@@ -18,7 +18,7 @@ import PhotoViewerModal from './src/components/PhotoViewerModal';
import LibraryPickerModal from './src/components/LibraryPickerModal';
import ProModal from './src/components/ProModal';
import { Recipe, ColorAdjustments, CustomWatermark, GpsWatermark, FrameId, GPSInfo, AspectRatio, MeterMode, PhotoEntry, ShutterSound, StartupMode, CropRatio, CropRect, CROP_W_H, DEFAULT_CROP_RECT } from './src/types';
import { Recipe, ColorAdjustments, CustomWatermark, GpsWatermark, FrameId, GPSInfo, AspectRatio, MeterMode, PhotoEntry, ShutterSound, FlashMode, StartupMode, CropRatio, CropRect, CROP_W_H, DEFAULT_CROP_RECT } from './src/types';
import { getAllRecipes, saveCustomRecipe, updateCustomRecipe, deleteCustomRecipe, getFavoriteIds, setFavoriteId } from './src/utils/storageUtils';
import { DEFAULT_ADJUSTMENTS, FILM_SIMS, filmSimToRecipe } from './src/utils/defaultRecipes';
import { getCurrentGPS, requestLocationPermissions, reverseGeocode } from './src/utils/locationUtils';
@@ -269,6 +269,10 @@ export default function App() {
const [settingsVisible, setSettingsVisible] = useState(false);
// Shutter click: only OFF/ON (no volume API on Android). Applied at capture.
const [shutterSound, setShutterSound] = useState<ShutterSound>('on');
// LED flash mode. Capture-time only (no live torch): 'off' keeps today's
// behaviour, 'auto' lets the pipeline decide, 'on' always fires. Applied to
// the still, so the preview itself never changes brightness.
const [flashMode, setFlashMode] = useState<FlashMode>('off');
// Touch-to-shutter (gear sheet): a plain tap on the live viewfinder locks
// AE/AF/AWB on the tapped point and, once focus has settled, fires a capture.
// OFF = a tap only focuses (the historical behaviour).
@@ -297,6 +301,10 @@ export default function App() {
// Hardware Permissions (vision-camera)
const { hasPermission: cameraPermissionGranted, requestPermission: requestCameraPermission } = useCameraPermission();
// The lens the flash control belongs to. CameraX throws when asked for
// 'on'/'auto' on a device with no flash unit (the front lens, most tablets),
// so the control is only offered while this reads true.
const flashAvailable = useCameraDevice(cameraFacing)?.hasFlash ?? false;
// Full-resolution photo output — attached to the live <Camera> in Viewfinder.
// Photo capture is resolution-independent from the Skia preview output.
const photoOutput = usePhotoOutput({
@@ -393,6 +401,7 @@ export default function App() {
if (raw) {
const s = JSON.parse(raw);
if (s.shutterSound === 'on' || s.shutterSound === 'off') setShutterSound(s.shutterSound);
if (s.flashMode === 'off' || s.flashMode === 'auto' || s.flashMode === 'on') setFlashMode(s.flashMode);
if (s.meterMode === 'evaluative' || s.meterMode === 'highlight') setMeterMode(s.meterMode);
if (typeof s.rawEnabled === 'boolean') setRawEnabled(s.rawEnabled);
if (typeof s.gpsEnabled === 'boolean') setGpsEnabled(s.gpsEnabled);
@@ -891,9 +900,9 @@ export default function App() {
if (!settingsHydratedRef.current) return;
AsyncStorage.setItem(
CAMERA_SETTINGS_KEY,
JSON.stringify({ shutterSound, meterMode, rawEnabled, gpsEnabled, touchToShutter })
JSON.stringify({ shutterSound, flashMode, meterMode, rawEnabled, gpsEnabled, touchToShutter })
).catch(() => {});
}, [shutterSound, meterMode, rawEnabled, gpsEnabled, touchToShutter]);
}, [shutterSound, flashMode, meterMode, rawEnabled, gpsEnabled, touchToShutter]);
// Latest-rawEnabled ref so the stable session-error callback below can read
// the live toggle without re-subscribing the Camera session on every render.
@@ -1319,16 +1328,23 @@ export default function App() {
// 0.5x is a native Camera2 session on the hidden ultra-wide lens, not a
// CameraX zoom ratio: no CameraX session is bound while it is live, so
// the still has to come from the native module — and Camera2 fires no
// shutter sound of its own.
// shutter sound of its own. The LED is a CameraX capture setting, so the
// native module re-applies the viewfinder's flashMode on its own still
// request, and drops it back to 'off' on a lens with no flash unit.
const ultraWideShot = viewfinderRef.current?.isUltraWide?.() === true;
let filePath: string;
if (ultraWideShot) {
filePath = await UltraWide.captureAsync();
filePath = await UltraWide.captureAsync(flashMode);
tSensor = Date.now();
} else {
filePath = (
await photoOutput.capturePhotoToFile(
{ enableShutterSound: shutterSound === 'on' && !opts?.burst },
{
enableShutterSound: shutterSound === 'on' && !opts?.burst,
// Capture-time LED flash. Forced back to 'off' on a lens with no
// flash unit: the spec throws for anything else.
flashMode: flashAvailable ? flashMode : 'off',
},
{ onDidCapturePhoto: () => { tSensor = Date.now(); } },
)
).filePath;
@@ -1340,7 +1356,16 @@ export default function App() {
// "RAW" album without blocking the JPEG export below.
if (rawEnabled && rawPhotoOutput && !opts?.burst && !ultraWideShot) {
try {
const rawFile = await rawPhotoOutput.capturePhotoToFile({ enableShutterSound: shutterSound === 'on' }, {});
const rawFile = await rawPhotoOutput.capturePhotoToFile(
{
enableShutterSound: shutterSound === 'on',
// Same light as the JPEG it follows: the flash unit may still be
// recharging tens of ms later, in which case the DNG just lands
// darker than its JPEG — a silent raw, never a broken capture.
flashMode: flashAvailable ? flashMode : 'off',
},
{},
);
const rawUri = toUri(rawFile.filePath);
saveRawToAlbum(rawUri).then((ok) => {
if (!ok) {
@@ -1876,6 +1901,15 @@ export default function App() {
onPickImage={handlePickImage}
onOpenPreview={openLatestViewer}
onOpenSettings={() => setSettingsVisible(true)}
// Capture-time LED flash: offered only on a lens that carries a flash
// unit, and only in camera mode (the library save never fires the
// camera). Tap cycles OFF -> AUTO -> ON -> OFF.
flashMode={mode === 'camera' && flashAvailable ? flashMode : undefined}
onCycleFlash={
mode === 'camera' && flashAvailable
? () => setFlashMode((m) => (m === 'off' ? 'auto' : m === 'auto' ? 'on' : 'off'))
: undefined
}
onFlipCamera={() => setCameraFacing((f) => (f === 'back' ? 'front' : 'back'))}
isLibraryMode={mode === 'library'}
onSwitchToCamera={() => handleSetMode('camera')}
@@ -9,6 +9,8 @@ import android.hardware.camera2.CameraCharacteristics
import android.hardware.camera2.CameraDevice
import android.hardware.camera2.CameraManager
import android.hardware.camera2.CaptureRequest
import android.hardware.camera2.CaptureResult
import android.hardware.camera2.TotalCaptureResult
import android.hardware.camera2.params.MeteringRectangle
import android.media.ImageReader
import android.os.Handler
@@ -160,6 +162,11 @@ class UltraWidePreviewView(context: Context, appContext: AppContext) : ExpoView(
// Region of the last tap-to-focus, so an EV change can re-issue the repeating
// request without dropping the lock.
private var lockedRegion: Rect? = null
// Whether this lens may drive the flash unit at all. The LED physically
// belongs to the back flash unit the main camera owns, and the spec rejects a
// flash request on a camera whose FLASH_INFO_AVAILABLE is false, so the still
// request clamps to OFF rather than failing the shot on such a lens.
private var flashAvailable = false
private var busy = false
private var pending: Promise? = null
// Held by closeAsync() until the HAL has really let go of the lens; see
@@ -399,6 +406,8 @@ class UltraWidePreviewView(context: Context, appContext: AppContext) : ExpoView(
val ps = pick4x3(map?.getOutputSizes(SurfaceTexture::class.java), 1920 * 1440)
val fs = pick4x3(map?.getOutputSizes(ImageFormat.JPEG), Int.MAX_VALUE)
activeArray = cs.get(CameraCharacteristics.SENSOR_INFO_ACTIVE_ARRAY_SIZE)
flashAvailable = cs.get(CameraCharacteristics.FLASH_INFO_AVAILABLE) == true
Log.i(TAG, "ultra-wide $id flash=$flashAvailable")
cs.get(CameraCharacteristics.CONTROL_AE_COMPENSATION_RANGE)?.let {
evRange = it.lower..it.upper
}
@@ -501,7 +510,29 @@ class UltraWidePreviewView(context: Context, appContext: AppContext) : ExpoView(
}
}
fun capture(promise: Promise) {
/**
* The still request's flash settings for the JS 'off' | 'auto' | 'on'.
* Camera2 has no FLASH_MODE_AUTO: the auto behaviour lives in
* CONTROL_AE_MODE, where the HAL owns the unit and fires it only when AE
* comes back with CONTROL_AE_STATE_FLASH_REQUIRED; 'on' is the same knob with
* the firing forced. Both ride next to FLASH_MODE_SINGLE so a HAL that
* ignores the AE override still fires on this one request; the spec has the
* AE flash modes win over FLASH_MODE, so the pair cannot double-fire.
* A lens with no unit reports FLASH_INFO_AVAILABLE false, and the flash
* controls only exist where it is true, so every mode collapses to OFF there.
*/
private fun flashAeMode(mode: String): Int = when {
!flashAvailable -> CaptureRequest.CONTROL_AE_MODE_ON
mode == "on" -> CaptureRequest.CONTROL_AE_MODE_ON_ALWAYS_FLASH
mode == "auto" -> CaptureRequest.CONTROL_AE_MODE_ON_AUTO_FLASH
else -> CaptureRequest.CONTROL_AE_MODE_ON
}
private fun flashMode(mode: String): Int =
if (flashAeMode(mode) == CaptureRequest.CONTROL_AE_MODE_ON) CaptureRequest.FLASH_MODE_OFF
else CaptureRequest.FLASH_MODE_SINGLE
fun capture(promise: Promise, flash: String) {
val dev = device
val s = session
val ir = reader
@@ -521,17 +552,34 @@ class UltraWidePreviewView(context: Context, appContext: AppContext) : ExpoView(
req.addTarget(ir.surface)
req.set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE)
req.set(CaptureRequest.JPEG_ORIENTATION, sensorOrientation)
req.set(CaptureRequest.FLASH_MODE, flashMode(flash))
req.set(CaptureRequest.CONTROL_AE_MODE, flashAeMode(flash))
// The live preview carries the EV as a hardware AE bias (see setEv); the
// file must not, or the lens bias and the export's software gain would
// stack into a double EV. Pin the bias off and let the precapture trigger
// settle AE on it before the frame is taken — without the trigger the
// first frames can still come off the biased exposure.
// first frames can still come off the biased exposure. That same trigger
// is what lets a flash mode on this request be metered at all: the
// precapture sequence is what returns AE_STATE_FLASH_REQUIRED, the answer
// the HAL fires on in 'auto'.
req.set(CaptureRequest.CONTROL_AE_EXPOSURE_COMPENSATION, 0)
req.set(
CaptureRequest.CONTROL_AE_PRECAPTURE_TRIGGER,
CaptureRequest.CONTROL_AE_PRECAPTURE_TRIGGER_START
)
s.capture(req.build(), null, camHandler)
s.capture(req.build(), object : CameraCaptureSession.CaptureCallback() {
override fun onCaptureCompleted(
s2: CameraCaptureSession,
request: CaptureRequest,
result: TotalCaptureResult
) {
Log.i(
TAG,
"still flash=${result.get(CaptureResult.FLASH_STATE)} ae=" +
"${result.get(CaptureResult.CONTROL_AE_STATE)}"
)
}
}, camHandler)
} catch (e: Throwable) {
fail("ERR_CAPTURE", "Capture request failed: ${e.message}")
return
@@ -729,12 +777,15 @@ class UltraWideModule : Module() {
Prop("cover") { view: UltraWidePreviewView, cover: Boolean -> view.setCover(cover) }
}
AsyncFunction("captureAsync") { promise: Promise ->
// flashMode is the JS 'off' | 'auto' | 'on' the viewfinder is showing; the
// view clamps it to what this lens can actually drive. Expo only recognises
// the trailing Promise as the resolve handle, so it goes last.
AsyncFunction("captureAsync") { flashMode: String, promise: Promise ->
val view = UltraWidePreviewView.current()
if (view == null) {
promise.reject("ERR_NO_VIEW", "Ultra-wide preview is not mounted", null)
} else {
view.capture(promise)
view.capture(promise, flashMode)
}
}
+8 -2
View File
@@ -1,12 +1,18 @@
import { requireNativeModule, requireNativeViewManager } from 'expo-modules-core';
import type { ComponentType } from 'react';
import type { ViewProps } from 'react-native';
import type { FlashMode } from '../../src/types';
export interface UltraWideModule {
/** Hidden back ultra-wide camera id ('' when this device has none). */
availableAsync(): Promise<string>;
/** Still capture from the ultra-wide lens; resolves to an absolute file path. */
captureAsync(): Promise<string>;
/**
* Still capture from the ultra-wide lens; resolves to an absolute file path.
* `flashMode` is the viewfinder's setting, driven straight into the still
* request's FLASH_MODE — the lens falls back to 'off' on its own when it has
* no flash unit to fire.
*/
captureAsync(flashMode: FlashMode): Promise<string>;
/**
* Tap-to-focus + spot metering on the ultra-wide lens, at a point in the
* preview view (React Native dp, same space as a touch's locationX/Y). The
+133 -84
View File
@@ -1,7 +1,18 @@
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, RefreshCw } from 'lucide-react-native';
import { Image as ImageIcon, Eye, Download, Camera, Settings, RefreshCw, Zap, ZapOff } from 'lucide-react-native';
import type { FlashMode } from '../types';
// "A" badge tucked into the corner of the flash button's AUTO state.
const FLASH_A_BADGE = {
position: 'absolute' as const,
bottom: 6,
right: 12,
fontSize: 9,
fontWeight: '700' as const,
color: '#f4f4f5',
};
interface CameraControlsProps {
lastPhotoUri: string | null;
@@ -13,6 +24,10 @@ interface CameraControlsProps {
onPickImage: () => void;
onOpenPreview: () => void;
onOpenSettings: () => void;
// LED flash state for the next still. Absent = no flash control to show
// (library mode, or a lens with no flash unit).
flashMode?: FlashMode;
onCycleFlash?: () => void;
onFlipCamera?: () => void;
isLibraryMode: boolean;
onSwitchToCamera?: () => void;
@@ -26,6 +41,8 @@ export default function CameraControls({
onPickImage,
onOpenPreview,
onOpenSettings,
flashMode,
onCycleFlash,
onFlipCamera,
isLibraryMode,
onSwitchToCamera,
@@ -33,100 +50,132 @@ export default function CameraControls({
const insets = useSafeAreaInsets();
return (
<View
style={{ paddingBottom: insets.bottom + 10 }}
className="flex-row items-center px-7 pt-2"
>
{/* Left cluster: Pick Image + gear (gear sits between library & shutter).
flex-1 side zones are equal width, so the shutter between them is
exactly centered on the screen regardless of button counts. */}
<View className="flex-1 flex-row items-center gap-x-3">
<TouchableOpacity
onPress={onPickImage}
className="w-12 h-12 rounded-full bg-black/40 border border-zinc-700/70 flex items-center justify-center"
activeOpacity={0.7}
>
<ImageIcon size={20} color="#e4e4e7" strokeWidth={1.75} />
</TouchableOpacity>
<TouchableOpacity
onPress={onOpenSettings}
className="w-12 h-12 rounded-full bg-black/40 border border-zinc-700/70 flex items-center justify-center"
activeOpacity={0.7}
>
<Settings size={20} color="#e4e4e7" strokeWidth={1.75} />
</TouchableOpacity>
</View>
{/* Dynamic Center Button: Shutter or Save */}
<TouchableOpacity
onPress={onCapture}
// Pressability drops onPress once onLongPress has fired, so releasing a
// hold never appends a stray single shot after the burst.
onLongPress={onBurstStart}
onPressOut={onBurstStop}
delayLongPress={300}
className="relative items-center justify-center"
activeOpacity={0.8}
>
{isLibraryMode ? (
<View className="w-20 h-20 rounded-full bg-black/25 items-center justify-center">
<View className="w-16 h-16 rounded-full bg-amber-500 items-center justify-center border-2 border-black/20">
<Download size={28} color="#000000" />
</View>
</View>
) : (
<View className="w-20 h-20 rounded-full bg-black/25 items-center justify-center">
<View className="w-14 h-14 rounded-full bg-white items-center justify-center border-2 border-black/10">
<View className="w-11 h-11 rounded-full bg-zinc-100 border border-zinc-300" />
</View>
</View>
)}
</TouchableOpacity>
{/* Right cluster: flip camera (between shutter & preview) + preview.
Preview doubles as "back to camera" while in library mode; the flip
control is hidden then. Anchored to the right edge inside the flex-1
side zone; small gap keeps it clear of the preview thumbnail without
drifting toward the shutter.
Exports land in the session library, so once library mode has one its
thumbnail takes the slot the flip control would have used — that keeps
"back to camera" untouched while the finished photo stays reachable
(review or trash) after the auto-opened viewer is closed. */}
<View className="flex-1 flex-row items-center justify-end gap-x-3">
{isLibraryMode ? (
lastPhotoUri ? (
<TouchableOpacity
onPress={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}
>
<Image source={{ uri: lastPhotoUri }} style={StyleSheet.absoluteFill} className="object-cover" />
</TouchableOpacity>
) : null
) : onFlipCamera ? (
<View>
{/* Capture-time LED flash: a button of its own above the control row
rather than a third button inside it. The two side zones are sized to
hold two 48dp buttons next to the shutter — a third would slide under
the shutter's hit area on a 360dp screen. Absent on a lens with no
flash unit, so the front camera shows no dead control. */}
{flashMode && onCycleFlash ? (
<View className="px-7 pb-3">
<TouchableOpacity
onPress={onFlipCamera}
onPress={onCycleFlash}
accessibilityRole="button"
accessibilityLabel={`Flash ${flashMode}`}
accessibilityHint="Cycles flash off, auto and on"
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} />
{flashMode === 'off' ? (
<ZapOff size={20} color="#e4e4e7" strokeWidth={1.75} />
) : (
// AUTO keeps the bolt dim and adds the badge below — a lit bolt
// alone would read as ON.
<Zap size={20} color={flashMode === 'on' ? '#fbbf24' : '#e4e4e7'} strokeWidth={1.75} />
)}
{flashMode === 'auto' ? (
<Text style={FLASH_A_BADGE}>A</Text>
) : null}
</TouchableOpacity>
) : null}
</View>
) : null}
<View
style={{ paddingBottom: insets.bottom + 10 }}
className="flex-row items-center px-7 pt-2"
>
{/* Left cluster: Pick Image + gear (gear sits between library & shutter).
flex-1 side zones are equal width, so the shutter between them is
exactly centered on the screen regardless of button counts. */}
<View className="flex-1 flex-row items-center gap-x-3">
<TouchableOpacity
onPress={onPickImage}
className="w-12 h-12 rounded-full bg-black/40 border border-zinc-700/70 flex items-center justify-center"
activeOpacity={0.7}
>
<ImageIcon size={20} color="#e4e4e7" strokeWidth={1.75} />
</TouchableOpacity>
<TouchableOpacity
onPress={onOpenSettings}
className="w-12 h-12 rounded-full bg-black/40 border border-zinc-700/70 flex items-center justify-center"
activeOpacity={0.7}
>
<Settings size={20} color="#e4e4e7" strokeWidth={1.75} />
</TouchableOpacity>
</View>
{/* Dynamic Center Button: Shutter or Save */}
<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}
onPress={onCapture}
// Pressability drops onPress once onLongPress has fired, so releasing a
// hold never appends a stray single shot after the burst.
onLongPress={onBurstStart}
onPressOut={onBurstStop}
delayLongPress={300}
className="relative items-center justify-center"
activeOpacity={0.8}
>
{isLibraryMode ? (
<Camera size={20} color="#e4e4e7" strokeWidth={1.75} />
) : lastPhotoUri ? (
<Image source={{ uri: lastPhotoUri }} style={StyleSheet.absoluteFill} className="object-cover" />
<View className="w-20 h-20 rounded-full bg-black/25 items-center justify-center">
<View className="w-16 h-16 rounded-full bg-amber-500 items-center justify-center border-2 border-black/20">
<Download size={28} color="#000000" />
</View>
</View>
) : (
<Eye size={20} color="#a1a1aa" strokeWidth={1.75} />
<View className="w-20 h-20 rounded-full bg-black/25 items-center justify-center">
<View className="w-14 h-14 rounded-full bg-white items-center justify-center border-2 border-black/10">
<View className="w-11 h-11 rounded-full bg-zinc-100 border border-zinc-300" />
</View>
</View>
)}
</TouchableOpacity>
{/* Right cluster: flip camera (between shutter & preview) + preview.
Preview doubles as "back to camera" while in library mode; the flip
control is hidden then. Anchored to the right edge inside the flex-1
side zone; small gap keeps it clear of the preview thumbnail without
drifting toward the shutter.
Exports land in the session library, so once library mode has one its
thumbnail takes the slot the flip control would have used — that keeps
"back to camera" untouched while the finished photo stays reachable
(review or trash) after the auto-opened viewer is closed. */}
<View className="flex-1 flex-row items-center justify-end gap-x-3">
{isLibraryMode ? (
lastPhotoUri ? (
<TouchableOpacity
onPress={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}
>
<Image source={{ uri: lastPhotoUri }} style={StyleSheet.absoluteFill} className="object-cover" />
</TouchableOpacity>
) : null
) : 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>
) : null}
<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>
</View>
);
+3
View File
@@ -13,6 +13,9 @@ export type MeterMode = 'evaluative' | 'highlight';
// Camera shutter sound. OS policy may force the click on in some regions;
// there is no native volume control (OFF/ON only).
export type ShutterSound = 'on' | 'off';
// LED flash for photo capture (never a live torch): 'auto' lets the capture
// pipeline decide from the metered scene, 'on' always fires, 'off' never does.
export type FlashMode = 'off' | 'auto' | 'on';
export type StartupMode = 'default' | 'last';
// One photo in the in-app session history (recent captures + library exports).
// uri = app-cache JPEG; assetId (when savedToLibrary) = the MediaLibrary asset