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
+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>
);