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:
@@ -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')}
|
||||
|
||||
+56
-5
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user