Files
RecipesCam/src/components/Viewfinder.tsx
T

654 lines
26 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, useWindowDimensions, AppState } from 'react-native';
import { useCameraDevice, type CameraPhotoOutput } from 'react-native-vision-camera';
import { SkiaCamera, type SkiaCameraRef } from 'react-native-vision-camera-skia';
import { createSynchronizable } from 'react-native-worklets';
import {
Canvas,
useImage,
useFont,
Text as SkiaText,
rect,
Shader,
Skia,
Group,
Rect,
ColorMatrix,
Image as SkiaImage,
ImageShader,
TileMode,
FilterMode,
MipmapMode,
} from '@shopify/react-native-skia';
import { Lock } from 'lucide-react-native';
import { Recipe, GPSInfo, FrameId } from '../types';
import { getSkiaColorMatrix, applyExposureGain } from '../utils/colorUtils';
import { TONE_SKSL, getToneUniforms } from '../utils/toneShader';
import { formatCoordinate } from '../utils/locationUtils';
interface ViewfinderProps {
mode: 'camera' | 'library';
recipe: Recipe;
selectedFrame: FrameId;
useGeotag: boolean;
gpsInfo: GPSInfo | null;
libraryImageUri: string | null;
cameraPermissionGranted: boolean;
onRequestCameraPermission: () => void;
photoOutput: CameraPhotoOutput;
}
const noiseEffect = Skia.RuntimeEffect.Make(`
vec4 main(vec2 pos) {
float r = fract(sin(dot(pos.xy, vec2(12.9898, 78.233))) * 43758.5453);
return vec4(vec3(r), 1.0);
}
`);
// Tone runtime effect (DR/Highlight/Shadow) for the live camera preview worklet.
// Hoisted at module scope so the worklet runtime can capture it — same pattern
// as the official vision-camera-skia shader examples. Skia caches compiled
// effects by source string, so Make here runs once per app launch.
// Compile failure at module scope throws (JavascriptException → SIGABRT), so
// swallow it into null — callers already fall back to matrix-only.
const toneEffect = (() => {
try {
return Skia.RuntimeEffect.Make(TONE_SKSL);
} catch (e) {
console.error('[camtone] TONE_SKSL compile failed: ' + e);
return null;
}
})();
// Plain numeric primitives (captured by value into the preview worklet — the
// worklet runtime can't read RNSkia's enum namespace objects directly).
const T_CLAMP = TileMode.Clamp;
const F_LINEAR = FilterMode.Linear;
const M_NONE = MipmapMode.None;
export default function Viewfinder({
mode,
recipe,
selectedFrame,
useGeotag,
gpsInfo,
libraryImageUri,
cameraPermissionGranted,
onRequestCameraPermission,
photoOutput,
}: ViewfinderProps) {
// Fullscreen hero: the viewfinder fills the entire screen, every overlay
// rect derives from window size.
const { width: vw, height: vh } = useWindowDimensions();
// 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 skiaImage = useImage(libraryImageUri || '');
const customFont = useFont(
require('../../assets/CourierPrime-Regular.ttf'),
Math.round(vw * 0.032)
);
const adjustments = recipe.adjustments;
// Camera captures apply exposure compensation as a hardware AE bias (see the
// setExposureBias effect below) — never via the matrix here. Library
// stills have no hardware bias, so the preview matrix gains 2^EV.
const colorMatrix =
mode === 'library'
? applyExposureGain(getSkiaColorMatrix(recipe.baseFilter, adjustments), adjustments.exposureCompensation ?? 0)
: getSkiaColorMatrix(recipe.baseFilter, adjustments);
// The Skia onFrame worklet runs on a separate runtime — keep the latest color
// matrix in a cross-thread synchronizable value instead of a Reanimated
// SharedValue (which lives on the UI runtime).
const colorMatrixSync = useMemo(
() => createSynchronizable<number[]>(colorMatrix),
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
// Tone uniforms [dr, hl, sh] for the preview worklet; all-zero = no tone pass.
const toneParams = (() => {
const u = getToneUniforms(adjustments);
return [u.dr, u.hl, u.sh];
})();
const toneSync = useMemo(
() => createSynchronizable<number[]>([0, 0, 0]),
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
useEffect(() => {
colorMatrixSync.setBlocking(colorMatrix);
toneSync.setBlocking(toneParams);
}, [colorMatrix, toneParams, colorMatrixSync, toneSync]);
// Exposure compensation → hardware AE bias. Passed imperatively (never as a
// declarative prop): v5 calls setExposureBias while the session is still
// starting up and throws "Camera is not active" — seen on the Xiaomi at boot.
// The controller only exists after onStarted, so it is safe to bias then.
// Deps on the controller identity (not just cameraActive): a session restart
// swaps the controller and silently resets the bias to 0.
const skiaCameraRef = useRef<SkiaCameraRef>(null);
const [cameraActive, setCameraActive] = useState(false);
// Camera session pauses when the app backgrounds; toggling isActive back on
// resume restarts it. Without this the preview stayed black after unlock.
const [appActive, setAppActive] = useState(AppState.currentState === 'active');
useEffect(() => {
const sub = AppState.addEventListener('change', (s) => setAppActive(s === 'active'));
return () => sub.remove();
}, []);
// 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;
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
// per intermediate value — each one cancelled by the next, spamming [ev]).
const evStopsRef = useRef(evStops);
evStopsRef.current = evStops;
useEffect(() => {
if (!controller) return;
const d = controller.device;
if (d && d.supportsExposureBias) {
console.error(
`[ev] device range=${d.minExposureBias}..${d.maxExposureBias} current=${controller.exposureBias}`
);
} else {
console.error(`[ev] device has no exposure bias support`);
}
}, [controller]);
useEffect(() => {
if (!controller) return;
const d = controller.device;
if (!d || !d.supportsExposureBias) {
console.error(`[ev] device has no exposure bias support`);
return;
}
// CameraX exposes *compensation indexes*, not EV stops: the Android
// implementation runs setExposureCompensationIndex(exposure.toInt()), and
// 1 index = 1/8 EV on this device class (range -24..24 -> ±3 EV). The UI
// slider is real EV, so map EV -> index and clamp to the sensor range.
// Without this, +0.5 EV truncated to index 0 (no change) and +3 EV became
// index 3 = 0.375 EV — the "EV barely changes" report.
const index = Math.round(evStops * 8);
const clamped = Math.max(d.minExposureBias, Math.min(d.maxExposureBias, index));
// CameraX cancels an in-flight bias change whenever another compensation
// request lands (session reconfig, AE restart) — retry a few times, but
// only while this value is still the latest one the user asked for.
const timers: ReturnType<typeof setTimeout>[] = [];
let attempt = 0;
const trySet = () => {
if (evStopsRef.current !== evStops) return; // stale — a newer drag is pending
attempt += 1;
controller
.setExposureBias(clamped)
.then(() => {
console.error(`[ev] ev=${evStops} -> idx=${clamped} ok, read=${controller.exposureBias}`);
})
.catch((e) => {
console.error(`[ev] ev=${evStops} idx=${clamped} attempt=${attempt} failed: ${String(e).slice(0, 100)}`);
if (attempt < 5 && String(e).includes('Cancel')) {
timers.push(setTimeout(trySet, 150));
}
});
};
trySet();
return () => timers.forEach((t) => clearTimeout(t));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [controller, evStops]);
// Tap-to-focus: lock AE/AF/AWB onto the tapped region (SceneAdaptiveness
// 'locked' + autoResetAfter null = stays locked until another tap or unlock).
// CameraX AE otherwise keeps re-metering the scene and cancels out the tone
// preview (lift highlights -> AE underexposes -> looks unchanged), which is
// why HL/SH edits read as "no effect" on the live preview.
const handleTapFocus = async (x: number, y: number) => {
if (!cameraActive) return;
const cam = skiaCameraRef.current;
const d = cam?.controller?.device;
if (!d) return;
if (!d.supportsFocusMetering && !d.supportsExposureMetering && !d.supportsWhiteBalanceMetering) {
console.error('[focus] metering unsupported on this device');
return;
}
setFocusPoint({ x, y });
try {
await cam.focusTo(
{ x, y },
{ responsiveness: 'snappy', adaptiveness: 'locked', autoResetAfter: null }
);
console.error(`[focus] tap=${Math.round(x)},${Math.round(y)} AE/AF/AWB locked`);
} catch (e) {
console.error('[focus] lock failed: ' + String(e).slice(0, 120));
}
};
const handleUnlockFocus = async () => {
const cam = skiaCameraRef.current;
if (!cam) return;
try {
await cam.resetFocus();
setFocusPoint(null);
console.error('[focus] reset to continuous auto');
} catch (e) {
console.error('[focus] reset failed: ' + String(e).slice(0, 120));
}
};
// Instrumentation for device logs (user captures only logcat E level, so
// console.log/warn never show up — these are error-level on purpose).
const lastPreviewLog = useRef(0);
useEffect(() => {
const now = Date.now();
if (now - lastPreviewLog.current < 400) return;
lastPreviewLog.current = now;
console.error(
`[preview] matrix=(${colorMatrix[0].toFixed(3)},${colorMatrix[5].toFixed(3)},${colorMatrix[10].toFixed(3)}) tone=[${toneParams.join(',')}] ev=${evStops}`
);
}, [colorMatrix, toneParams, evStops]);
// Camera frame renderer. Stable identity (empty-ish deps): SkiaCamera re-runs
// its frame pipeline whenever onFrame changes, which during slider drags made
// the preview flicker. All live values flow through the synchronizables.
type SkiaCameraOnFrame = NonNullable<ComponentProps<typeof SkiaCamera>['onFrame']>;
const toneFailLogged = useMemo(() => createSynchronizable<boolean>(false), []);
const handleFrame = useCallback<SkiaCameraOnFrame>(
(frame, render) => {
'worklet';
render(({ canvas, frameTexture }) => {
const paint = Skia.Paint();
paint.setColorFilter(Skia.ColorFilter.MakeMatrix(colorMatrixSync.getBlocking()));
const tone = toneSync.getBlocking();
const hasTone = toneEffect != null && (tone[0] !== 0 || tone[1] !== 0 || tone[2] !== 0);
if (hasTone) {
try {
const imageShader = frameTexture.makeShaderOptions(T_CLAMP, T_CLAMP, F_LINEAR, M_NONE);
const shader = toneEffect.makeShaderWithChildren(tone, [imageShader]);
if (shader != null) {
// drawRect, not drawImage: paint shaders only apply to geometry
// fills — drawImage ignores the paint shader, which is why the
// DR/Highlight/Shadow tone never showed on the live preview.
const w = frameTexture.width();
const h = frameTexture.height();
paint.setShader(shader);
canvas.drawRect(Skia.XYWHRect(0, 0, w, h), paint);
return;
}
} catch (e) {
if (!toneFailLogged.getBlocking()) {
toneFailLogged.setBlocking(true);
console.error('[camtone] shader failed, matrix-only: ' + e);
}
}
}
canvas.drawImage(frameTexture, 0, 0, paint);
});
frame.dispose();
},
[colorMatrixSync, toneSync, toneFailLogged]
);
const grainOpacity = adjustments.grain / 20;
// Library-still viewer: pinch-zoom (2 fingers) + double-tap zoom at the tap
// point / double-tap again to reset to 100%. Transform is a Skia matrix
// P' = scale*P + (tx,ty); gesture math keeps the focal point stationary.
const [libZoom, setLibZoom] = useState({ s: 1, tx: 0, ty: 0 });
const pinchRef = useRef<{ d: number; s: number; tx: number; ty: number; fx: number; fy: number } | null>(null);
const pinchUsedRef = useRef(false);
const lastTapRef = useRef<{ x: number; y: number; t: number } | null>(null);
const clampZoom = (s: number) => Math.max(1, Math.min(6, s));
// Contain-fit rect of the loaded photo in canvas coordinates (image is
// centered with black letterbox above/below on a portrait screen).
const imageFitRect = useMemo(() => {
const img = skiaImage;
if (!img) return null;
const w = img.width();
const h = img.height();
if (!(w > 0 && h > 0)) return null;
const s = Math.min(vw / w, vh / h);
const dw = w * s;
const dh = h * s;
return { dx: (vw - dw) / 2, dy: (vh - dh) / 2, dw, dh };
}, [skiaImage, vw, vh]);
const onLibTouchStart = (e: any) => {
const ts = e.nativeEvent.touches;
if (ts.length >= 2) {
const [a, b] = ts;
pinchUsedRef.current = true;
lastTapRef.current = null;
pinchRef.current = {
d: Math.hypot(a.locationX - b.locationX, a.locationY - b.locationY),
s: libZoom.s,
tx: libZoom.tx,
ty: libZoom.ty,
fx: (a.locationX + b.locationX) / 2,
fy: (a.locationY + b.locationY) / 2,
};
}
};
const onLibTouchMove = (e: any) => {
const ts = e.nativeEvent.touches;
let p = pinchRef.current;
if (ts.length >= 2 && !p) {
// Responder grant fires once on the first finger; a second finger that
// lands later only produces move events, so seed the pinch baseline here.
const [a, b] = ts;
pinchUsedRef.current = true;
lastTapRef.current = null;
p = pinchRef.current = {
d: Math.hypot(a.locationX - b.locationX, a.locationY - b.locationY),
s: libZoom.s,
tx: libZoom.tx,
ty: libZoom.ty,
fx: (a.locationX + b.locationX) / 2,
fy: (a.locationY + b.locationY) / 2,
};
}
if (ts.length >= 2 && p) {
const [a, b] = ts;
const d = Math.hypot(a.locationX - b.locationX, a.locationY - b.locationY);
const s = clampZoom((p.s * d) / Math.max(p.d, 1));
const k = s / p.s;
setLibZoom({
s,
tx: p.fx - k * (p.fx - p.tx),
ty: p.fy - k * (p.fy - p.ty),
});
}
};
const onLibTouchEnd = (e: any) => {
const wasPinch = pinchUsedRef.current;
pinchRef.current = null;
pinchUsedRef.current = false;
if (wasPinch) return; // a double-tap is always a single finger
const x = e.nativeEvent.locationX;
const y = e.nativeEvent.locationY;
const now = Date.now();
const prev = lastTapRef.current;
lastTapRef.current = { x, y, t: now };
if (prev && now - prev.t < 350 && Math.hypot(x - prev.x, y - prev.y) < 50) {
lastTapRef.current = null;
setLibZoom((z) => {
if (z.s > 1.01) return { s: 1, tx: 0, ty: 0 }; // double-tap again -> 100%
// Clamp the focal point into the photo's contain rect so a double-tap
// that lands in the black letterbox cannot fling the photo away.
let fx = x;
let fy = y;
const fit = imageFitRect;
if (fit) {
fx = Math.min(Math.max(fx, fit.dx), fit.dx + fit.dw);
fy = Math.min(Math.max(fy, fit.dy), fit.dy + fit.dh);
}
const s = 2.5;
return { s, tx: fx - s * fx, ty: fy - s * fy }; // zoom into the tapped point
});
}
};
// Skia affine 3x3, row-major per SkMatrix getMatrix():
// [s, 0, tx] x' = s*x + tx
// [0, s, ty] y' = s*y + ty
// [0, 0, 1]
// Deliberately NOT built via Skia.Matrix().translate().scale(): on the
// native Android side both are pre-concats (M = T·M then M = S·M) so the
// final matrix is S·T and the translation gets scaled (P' = s·(P + t)) —
// zooming then flings the photo up/left instead of holding the focal point.
const libZoomMatrix = useMemo(
() => [libZoom.s, 0, libZoom.tx, 0, libZoom.s, libZoom.ty, 0, 0, 1],
[libZoom]
);
const borderWidth = Math.min(vw, vh) * 0.05;
const sideBorder = Math.min(vw, vh) * 0.06;
const bottomBorder = vh * 0.18;
const barHeight = vh * 0.12;
const renderFrameOverlay = () => {
if (selectedFrame === 'none') return null;
if (selectedFrame === 'classic-white') {
return (
<Group color="white">
<Rect x={0} y={0} width={vw} height={borderWidth} />
<Rect x={0} y={vh - borderWidth} width={vw} height={borderWidth} />
<Rect x={0} y={0} width={borderWidth} height={vh} />
<Rect x={vw - borderWidth} y={0} width={borderWidth} height={vh} />
</Group>
);
}
if (selectedFrame === 'polaroid') {
return (
<Group>
<Rect x={0} y={0} width={vw} height={sideBorder} color="#faf9f6" />
<Rect x={0} y={0} width={sideBorder} height={vh} color="#faf9f6" />
<Rect x={vw - sideBorder} y={0} width={sideBorder} height={vh} color="#faf9f6" />
<Rect x={0} y={vh - bottomBorder} width={vw} height={bottomBorder} color="#faf9f6" />
<Rect
x={sideBorder - 1}
y={sideBorder - 1}
width={vw - 2 * sideBorder + 2}
height={vh - sideBorder - bottomBorder + 2}
color="#e5e5e5"
style="stroke"
strokeWidth={1}
/>
</Group>
);
}
if (selectedFrame === 'cinematic') {
return (
<Group color="black">
<Rect x={0} y={0} width={vw} height={barHeight} />
<Rect x={0} y={vh - barHeight} width={vw} height={barHeight} />
</Group>
);
}
return null;
};
const renderGPSWatermark = () => {
if (!useGeotag || !gpsInfo || !customFont) return null;
const latStr = formatCoordinate(gpsInfo.latitude, 'lat');
const lonStr = formatCoordinate(gpsInfo.longitude, 'lon');
const locationName = gpsInfo.locality || 'STREET VIEW';
const timestampStr = new Date(gpsInfo.timestamp).toLocaleDateString('vi-VN');
const sourceTag =
gpsInfo.source === 'exif'
? 'EXIF'
: gpsInfo.source === 'mediaLibrary'
? 'MEDIA LIB'
: gpsInfo.source === 'device'
? 'DEVICE GPS'
: null;
const captionSuffix = sourceTag ? ` | ${sourceTag}` : ' | SS 1/125 f/2.8';
// Baseline above the bottom chrome (shutter rail) so the watermark stays
// readable; polaroid frames push it further up onto the white area edge.
const yOffset = vh - (selectedFrame === 'polaroid' ? vh * 0.18 + 24 : 150);
const xOffset = selectedFrame === 'polaroid' ? vw * 0.08 : 16;
return (
<Group>
<SkiaText x={xOffset} y={yOffset - 32} text={`📍 ${locationName}`} font={customFont} color="#f59e0b" />
<SkiaText x={xOffset} y={yOffset - 16} text={`${latStr}, ${lonStr}`} font={customFont} color="#f59e0b" />
<SkiaText x={xOffset} y={yOffset} text={`${timestampStr}${captionSuffix}`} font={customFont} color="#f59e0b" />
</Group>
);
};
const renderCameraOverlays = () => (
<>
<Canvas style={StyleSheet.absoluteFill} pointerEvents="none">
{grainOpacity > 0 && noiseEffect && (
<Group blendMode="overlay">
<Rect x={0} y={0} width={vw} height={vh} opacity={grainOpacity}>
<Shader source={noiseEffect} />
</Rect>
</Group>
)}
{renderFrameOverlay()}
{renderGPSWatermark()}
</Canvas>
<View className="absolute inset-0 flex-row justify-between pointer-events-none opacity-20">
<View className="w-[1px] h-full bg-white ml-[33%]" />
<View className="w-[1px] h-full bg-white mr-[33%]" />
</View>
<View className="absolute inset-0 flex-col justify-between pointer-events-none opacity-20">
<View className="h-[1px] w-full bg-white mt-[33%]" />
<View className="h-[1px] w-full bg-white mb-[33%]" />
</View>
</>
);
if (mode === 'camera') {
if (!cameraPermissionGranted) {
return (
<View className="flex-1 bg-black items-center justify-center p-8">
<Text className="text-zinc-400 font-mono text-center mb-4">
Camera permission is required to use the viewfinder.
</Text>
<TouchableOpacity
onPress={onRequestCameraPermission}
className="bg-amber-500 px-6 py-3 rounded-full"
activeOpacity={0.7}
>
<Text className="text-black font-mono text-xs font-bold">GRANT PERMISSION</Text>
</TouchableOpacity>
</View>
);
}
if (!device) {
return (
<View className="flex-1 bg-black items-center justify-center p-8">
<Text className="text-zinc-500 font-mono text-center text-xs">
Camera not available on this device
</Text>
</View>
);
}
return (
<View className="flex-1 relative overflow-hidden bg-black">
<SkiaCamera
ref={skiaCameraRef}
style={StyleSheet.absoluteFill}
device={device}
isActive={appActive}
outputs={[photoOutput]}
pixelFormat="yuv"
onStarted={() => setCameraActive(true)}
onFrame={handleFrame}
/>
{/* Tap-to-focus layer: fullscreen responder (all visual overlays above
are pointerEvents none). locationX/Y are relative to this view. */}
<View
className="absolute inset-0"
onStartShouldSetResponder={() => true}
onResponderGrant={(e) => handleTapFocus(e.nativeEvent.locationX, e.nativeEvent.locationY)}
/>
{focusPoint && (
<View
pointerEvents="none"
className="absolute"
style={{ left: focusPoint.x - 26, top: focusPoint.y - 26 }}
>
<View className="w-[52px] h-[52px] rounded-full border-[1.5px] border-amber-400" />
<View className="absolute left-1/2 top-1/2 -ml-px -mt-px w-[2px] h-[2px] rounded-full bg-amber-400" />
</View>
)}
{focusPoint && (
<TouchableOpacity
onPress={handleUnlockFocus}
className="absolute right-4 top-24 bg-black/70 border border-amber-400/70 rounded-full px-3 py-1.5 flex-row items-center space-x-1.5"
activeOpacity={0.7}
>
<Lock size={11} color="#f59e0b" />
<Text className="text-amber-400 font-mono text-[10px] font-bold">AE/AF LOCKED — TAP TO UNLOCK</Text>
</TouchableOpacity>
)}
{renderCameraOverlays()}
</View>
);
}
// Library mode — fullscreen, full-photo (contain), pinch/double-tap zoom.
const toneOn = toneParams[0] !== 0 || toneParams[1] !== 0 || toneParams[2] !== 0;
return (
<View className="flex-1 relative overflow-hidden bg-black items-center justify-center">
{libraryImageUri && skiaImage ? (
<>
<Canvas style={StyleSheet.absoluteFill}>
<Group matrix={libZoomMatrix}>
<ColorMatrix matrix={colorMatrix} />
{toneEffect && toneOn && imageFitRect ? (
// DR/Highlight/Shadow tone curve. The Rect must cover exactly
// the contain-fitted photo area (not the full canvas): a
// fullscreen ImageShader with fit="contain" clamps/smears the
// photo edges across the black letterbox (user-visible as "the
// background is a zoomed copy of the photo").
<Rect x={imageFitRect.dx} y={imageFitRect.dy} width={imageFitRect.dw} height={imageFitRect.dh}>
<Shader
source={toneEffect}
uniforms={{ dr: toneParams[0], hl: toneParams[1], sh: toneParams[2] }}
>
<ImageShader
image={skiaImage}
fit="fill"
tx="clamp"
ty="clamp"
rect={rect(imageFitRect.dx, imageFitRect.dy, imageFitRect.dw, imageFitRect.dh)}
/>
</Shader>
</Rect>
) : (
<SkiaImage
image={skiaImage}
fit="contain"
rect={rect(0, 0, vw, vh)}
/>
)}
</Group>
{grainOpacity > 0 && noiseEffect && (
<Group blendMode="overlay">
<Rect x={0} y={0} width={vw} height={vh} opacity={grainOpacity}>
<Shader source={noiseEffect} />
</Rect>
</Group>
)}
{/* Frame + GPS watermark belong to the photo → zoom with it. Grain
is a fullscreen screen effect → stays fixed above. */}
<Group matrix={libZoomMatrix}>
{renderFrameOverlay()}
{renderGPSWatermark()}
</Group>
</Canvas>
<View
className="absolute inset-0"
onStartShouldSetResponder={() => true}
onMoveShouldSetResponder={() => true}
onResponderGrant={onLibTouchStart}
onResponderMove={onLibTouchMove}
onResponderRelease={onLibTouchEnd}
/>
</>
) : (
<View className="items-center justify-center p-6">
<Text className="text-zinc-500 font-mono text-center text-xs">
{libraryImageUri ? 'LOADING IMAGE...' : 'NO IMAGE SELECTED'}
</Text>
<Text className="text-zinc-600 font-mono text-center text-[10px] mt-1">
Tap the gallery icon below to load a photo
</Text>
</View>
)}
</View>
);
}