diff --git a/App.tsx b/App.tsx index 6f0467d..cdb5c52 100644 --- a/App.tsx +++ b/App.tsx @@ -16,9 +16,9 @@ import ToolRail, { TabId } from './src/components/ToolRail'; import SettingsModal from './src/components/SettingsModal'; import PhotoViewerModal from './src/components/PhotoViewerModal'; -import { Recipe, ColorAdjustments, FrameId, GPSInfo, AspectRatio, MeterMode, PhotoEntry, ShutterSound } from './src/types'; +import { Recipe, ColorAdjustments, FrameId, GPSInfo, AspectRatio, MeterMode, PhotoEntry, ShutterSound, StartupMode } from './src/types'; import { getAllRecipes, saveCustomRecipe, deleteCustomRecipe } from './src/utils/storageUtils'; -import { DEFAULT_ADJUSTMENTS } from './src/utils/defaultRecipes'; +import { DEFAULT_ADJUSTMENTS, FILM_SIMS, filmSimToRecipe } from './src/utils/defaultRecipes'; import { getCurrentGPS, requestLocationPermissions } from './src/utils/locationUtils'; import { resolvePhotoGPS } from './src/utils/photoGps'; import { processAndExportPhoto } from './src/utils/exportEngine'; @@ -29,6 +29,7 @@ import { PARAM_DEFS } from './src/utils/paramDefs'; import './global.css'; const SESSION_KEY = 'saved_session'; +const STARTUP_MODE_KEY = 'startup_mode'; // P5: native Kotlin export path. EXPO_PUBLIC_NATIVE_EXPORT=1 swaps the Skia // render engine for the RecipesCamExport native pipeline (same look contract, @@ -100,6 +101,9 @@ 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('on'); + // Startup behavior: 'default' = PROVIA neutral at 4:3 camera; 'last' = restore + // previous session from AsyncStorage. Default is 'default'. + const [startupMode, setStartupMode] = useState('default'); const photoSeq = useRef(0); const [isProcessing, setIsProcessing] = useState(false); // Short shutter-burst lock: guards the capturePhotoToFile calls from @@ -195,6 +199,34 @@ export default function App() { const list = await getAllRecipes(); setRecipes(list); + // Startup mode: 'default' opens camera + PROVIA film sim (neutral + // adjustments, DEFAULT_ADJUSTMENTS) at 4:3. 'last' restores the previous + // session from AsyncStorage; with no saved session it falls back to the + // same clean default state. + let startup: StartupMode = 'default'; + try { + const raw = await AsyncStorage.getItem(STARTUP_MODE_KEY); + if (raw === 'last') startup = 'last'; + } catch (e) { + console.warn('Failed to read startup mode:', e); + } + setStartupMode(startup); + + const applyDefaultStartup = () => { + setMode('camera'); + setLibraryImageUri(null); + setAspectRatio('4:3'); + const provia = FILM_SIMS.find((f) => f.id === 'sim-provia'); + if (provia) applyRecipe(filmSimToRecipe(provia)); + // Clean camera start: GPS watermark stays OFF until the user opts in. + setUseGeotag(false); + }; + + if (startup === 'default') { + applyDefaultStartup(); + return; + } + let session: any = null; try { const raw = await AsyncStorage.getItem(SESSION_KEY); @@ -230,14 +262,22 @@ export default function App() { if (session.shutterSound === 'on' || session.shutterSound === 'off') setShutterSound(session.shutterSound); if (session.gpsInfo) setGpsInfo(session.gpsInfo); if (session.selectedRecipeId) { - restoredRecipe = list.find((r: Recipe) => r.id === session.selectedRecipeId) || null; + // Film sims are never persisted in the recipe list (sims applied on the + // fly) — resolve sim ids via FILM_SIMS, real recipes via the list. + const sim = FILM_SIMS.find((f) => f.id === session.selectedRecipeId); + restoredRecipe = sim + ? filmSimToRecipe(sim) + : list.find((r: Recipe) => r.id === session.selectedRecipeId) || null; } } if (restoredRecipe) { - applyRecipe(restoredRecipe); - } else if (list.length > 0) { - applyRecipe(list[0]); + // Recipe identity restored; the look (adjustments/frame/geotag/ratio) + // stays exactly as saved in the session above — applyRecipe would reset + // them to the recipe's canonical values and drop the user's tweaks. + setSelectedRecipe(restoredRecipe); + } else { + applyDefaultStartup(); } }; @@ -315,6 +355,11 @@ export default function App() { } }; + const handleStartupMode = (mode: StartupMode) => { + setStartupMode(mode); + AsyncStorage.setItem(STARTUP_MODE_KEY, mode).catch(() => {}); + }; + // Latest-rawEnabled ref so the stable session-error callback below can read // the live toggle without re-subscribing the Camera session on every render. const rawEnabledRef = useRef(rawEnabled); @@ -851,6 +896,8 @@ export default function App() { onMeterModeChange={setMeterMode} shutterSound={shutterSound} onShutterSound={setShutterSound} + startupMode={startupMode} + onStartupMode={handleStartupMode} /> {/* Session photo viewer (swipe across captured/exported photos) */} diff --git a/PLAN.md b/PLAN.md index 47d7bde..f8b75fa 100644 --- a/PLAN.md +++ b/PLAN.md @@ -79,7 +79,9 @@ Exit: user QA đạt → mặc định native; legacy chỉ fallback. ### P7 — Dọn & ship - Xoá probe/dev code, xoá env gate; typecheck; commit + push (KHÔNG stage `.kilo/`, `.expo/`); `assembleRelease`; báo MD5 APK. -- Nhắc: nếu npm install chạy lại → áp lại patch node_modules vision-camera (`HybridPhotoOutput.kt` "RecipesCam patch"). +- Nhắc: nếu npm install chạy lại → node_modules vision-camera về pristine, áp lại patch: + `cd RecipesCam && git apply patches/vision-camera-shutter-silent.patch` + (artifact = diff `HybridPhotoOutput.kt`: shutter OFF thật silent — bỏ OR mustPlayShutterSound). ## Rủi ro / ghi chú - Local module phải bắt chước đúng cấu trúc expo package (đã đọc expo-media-library: `plugins { id 'com.android.library'; id 'expo-module-gradle-plugin' }`, expo-module.config.json khai báo class Kotlin; autolinking quét `./modules`). @@ -101,3 +103,9 @@ Exit: user QA đạt → mặc định native; legacy chỉ fallback. - [x] P5 hook export + flag - [ ] P6 parity/perf gate — probe chạy xong trên Xiaomi (1fdf765): classic-neg / retro-amber-frame / cinema-summer trên ảnh real 2304x3072; native off-thread 2.7-2.9s (JS ticks 65-70 vẫn chạy) vs legacy GPU 0.36-0.5s (JS-block); diff meanAbs 7.1/13.1/13.3, gt12% 16.8/42.1/42.7, gt32% 0.14/3.7/4.0 (grain procedural + JPEG encoder + tone approx; retro thấp nhất — mist + frame classic). CHỜ user QA ảnh ghép DCIM/parity_*.jpg → flip default native + quyết GPU - [ ] P7 dọn + ship + +## Ngoài P-series (feature branch) +- [x] Startup mode DEFAULT/LAST SESSION (Settings chip + AsyncStorage `startup_mode`; restore session qua FILM_SIMS; fix clobber ratio khi sim selected — 3 file LF) +- [x] GPS watermark mặc định OFF khi startup DEFAULT (applyDefaultStartup setUseGeotag(false)) — verify UI Xiaomi: cold start DEFAULT → FRAME tab GPS WATERMARK OFF +- [x] Shutter OFF thật silent (patch HybridPhotoOutput.kt, 2 path, artifact patches/) — verify log ON/OFF trên Xiaomi +- [ ] Zoom camera native: probe physical zoom + zoom UI (đang làm) diff --git a/patches/vision-camera-shutter-silent.patch b/patches/vision-camera-shutter-silent.patch new file mode 100644 index 0000000..09f78bc --- /dev/null +++ b/patches/vision-camera-shutter-silent.patch @@ -0,0 +1,43 @@ +diff --git a/node_modules/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/hybrids/outputs/HybridPhotoOutput.kt b/node_modules/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/hybrids/outputs/HybridPhotoOutput.kt +index f3bdfe3..883fa50 100644 +--- a/node_modules/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/hybrids/outputs/HybridPhotoOutput.kt ++++ b/node_modules/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/hybrids/outputs/HybridPhotoOutput.kt +@@ -2,7 +2,6 @@ package com.margelo.nitro.camera.hybrids.outputs + + import android.media.MediaActionSound + import android.util.Log +-import androidx.camera.core.CameraInfo + import androidx.camera.core.CameraSelector + import androidx.camera.core.ImageCapture + import androidx.camera.core.resolutionselector.ResolutionSelector +@@ -161,7 +160,11 @@ class HybridPhotoOutput( + + // 1. Get settings + val isMirrored = shouldMirror() +- val enableShutterSound = (settings.enableShutterSound ?: true) || CameraInfo.mustPlayShutterSound() ++ // The click is played by THIS app via MediaActionSound below — ++ // CameraInfo.mustPlayShutterSound() only reports device/region policy, it ++ // does not make the OS play anything. Honor the user toggle: OFF stays ++ // silent even on devices CameraX flags as must-play. ++ val enableShutterSound = settings.enableShutterSound != false + imageCapture.flashMode = settings.flashMode?.toFlashMode() ?: ImageCapture.FLASH_MODE_OFF + val location = + if (settings.location != null) { +@@ -170,7 +173,6 @@ class HybridPhotoOutput( + } else { + null + } +- + // 2. Perform Capture + var didFireOnDidCapturePhoto = false + val image = +@@ -228,8 +230,7 @@ class HybridPhotoOutput( + + // 1. Get settings + val isMirrored = shouldMirror() +- val enableShutterSound = +- (settings.enableShutterSound ?: true) || CameraInfo.mustPlayShutterSound() ++ val enableShutterSound = settings.enableShutterSound != false + imageCapture.flashMode = settings.flashMode?.toFlashMode() ?: ImageCapture.FLASH_MODE_OFF + val location = + if (settings.location != null) { diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 949ad51..6c34de5 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { View, Text, Modal, TouchableOpacity } from 'react-native'; import * as Haptics from 'expo-haptics'; import { X, Volume2, Settings as SettingsIcon } from 'lucide-react-native'; -import { AspectRatio, MeterMode, ShutterSound } from '../types'; +import { AspectRatio, MeterMode, ShutterSound, StartupMode } from '../types'; // Gear-sheet (gear button between library picker and shutter). Groups the // capture/composition choices that are NOT look-adjustments, so the tool rail @@ -30,6 +30,8 @@ interface SettingsModalProps { onMeterModeChange: (mode: MeterMode) => void; shutterSound: ShutterSound; onShutterSound: (sound: ShutterSound) => void; + startupMode: StartupMode; + onStartupMode: (mode: StartupMode) => void; } function ChipRow({ chips }: { chips: Chip[] }) { @@ -85,6 +87,8 @@ export default function SettingsModal({ onMeterModeChange, shutterSound, onShutterSound, + startupMode, + onStartupMode, }: SettingsModalProps) { return ( @@ -115,6 +119,22 @@ export default function SettingsModal({ + {/* Startup mode */} + + STARTUP MODE + onStartupMode('default') }, + { key: 'startup-last', label: 'LAST SESSION', active: startupMode === 'last', onPress: () => onStartupMode('last') }, + ]} + /> + + + DEFAULT: camera + PROVIA (neutral adjustments) at 4:3. LAST SESSION: restore your previous look on startup. + + + + {/* Shutter sound */} SHUTTER SOUND diff --git a/src/types/index.ts b/src/types/index.ts index 9dee659..721b0af 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -13,6 +13,7 @@ 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'; +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 // so the trash button can remove the gallery copy too.