diff --git a/App.tsx b/App.tsx index 3d777b5..8a44fce 100644 --- a/App.tsx +++ b/App.tsx @@ -6,6 +6,7 @@ import * as FileSystem from 'expo-file-system/legacy'; import * as MediaLibrary from 'expo-media-library'; import * as MediaLibraryLegacy from 'expo-media-library/legacy'; import * as Haptics from 'expo-haptics'; +import { Accelerometer } from 'expo-sensors'; import AsyncStorage from '@react-native-async-storage/async-storage'; import TopBar from './src/components/TopBar'; @@ -71,6 +72,35 @@ export default function App() { // export alike, the straighten angle applied on top of the turn. const [photoRotation, setPhotoRotation] = useState<0 | 90 | 180 | 270>(0); const [photoStraighten, setPhotoStraighten] = useState(0); + // FRAME-tab AUTO: level the photo to the horizon. The accelerometer feeds the + // very same straighten angle the hand slider does (as an offset on top of + // it), so preview and export share one number. Library only — a live camera + // frame has no horizon to bake in. + const [autoHorizon, setAutoHorizon] = useState(false); + const [horizonRoll, setHorizonRoll] = useState(0); + useEffect(() => { + if (!autoHorizon || mode !== 'library') return; + Accelerometer.setUpdateInterval(100); + const sub = Accelerometer.addListener(({ x, y }) => { + // atan2(x, y) is the lean about the screen normal: 0 upright in portrait. + // Skip a near-flat hold — x/y are noise there and no horizon is readable. + // Whole degrees only: a 12 MP re-rotation per sensor tick would burn the + // battery for a sub-pixel move. + if (Math.hypot(x, y) < 0.2) return; + const deg = (Math.atan2(x, y) * 180) / Math.PI; + // Past 45 the phone is not being held to shoot: the level line and the + // angle both stop meaning anything, so keep the last good reading. + if (Math.abs(deg) > 45) return; + setHorizonRoll(Math.round(deg)); + }); + return () => sub.remove(); + }, [autoHorizon, mode]); + // Lean the phone and everything painted on its screen leans with it, so the + // photo gains exactly the lean the STRAIGHTEN slider would have to undo: the + // measured roll is added, never negated. An emulator tilt of +10° puts the + // export 10° clockwise of the untilted one. + const effectiveStraighten = + photoStraighten + (autoHorizon && mode === 'library' ? horizonRoll : 0); useEffect(() => { const show = Keyboard.addListener('keyboardDidShow', (e) => setKeyboardHeight(e.endCoordinates.height)); const hide = Keyboard.addListener('keyboardDidHide', () => setKeyboardHeight(0)); @@ -539,6 +569,8 @@ export default function App() { setWmRotation(0); setPhotoRotation(0); setPhotoStraighten(0); + setAutoHorizon(false); + setHorizonRoll(0); setWallLandscape(false); setCropRatio('none'); setCropRect(DEFAULT_CROP_RECT); @@ -559,6 +591,7 @@ export default function App() { wmRotation !== 0 || photoRotation !== 0 || photoStraighten !== 0 || + autoHorizon || wallLandscape || cropRatio !== 'none' || differs(cropRect, DEFAULT_CROP_RECT); @@ -1086,7 +1119,7 @@ export default function App() { // save the same crop the preview showed (null = centered 1x). frameWindowZoom: viewfinderRef.current?.getFrameWindowZoom?.() ?? null, photoRotation, - photoStraighten, + photoStraighten: effectiveStraighten, } ); @@ -1264,7 +1297,8 @@ export default function App() { libraryImageUri={libraryImageUri} availableHeight={deckTop} photoRotation={photoRotation} - photoStraighten={photoStraighten} + photoStraighten={effectiveStraighten} + autoRoll={autoHorizon && mode === 'library' ? horizonRoll : null} cameraPermissionGranted={cameraPermissionGranted} onRequestCameraPermission={requestCameraPermission} photoOutput={photoOutput} @@ -1345,6 +1379,8 @@ export default function App() { photoStraighten={photoStraighten} onPickRotation={setPhotoRotation} onStraighten={setPhotoStraighten} + autoHorizon={autoHorizon} + onAutoHorizon={setAutoHorizon} wallLandscape={wallLandscape} onToggleWallLandscape={() => setWallLandscape((v) => !v)} useGeotag={useGeotag} diff --git a/package-lock.json b/package-lock.json index eea29a2..d6342dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "recipescamera", - "version": "1.0.0", + "version": "1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "recipescamera", - "version": "1.0.0", + "version": "1.2", "dependencies": { "@react-native-async-storage/async-storage": "2.2.0", "@react-native-community/slider": "5.2.0", @@ -19,8 +19,10 @@ "expo-font": "~57.0.1", "expo-haptics": "~57.0.1", "expo-image-picker": "~57.0.4", + "expo-keep-awake": "~57.0.1", "expo-location": "~57.0.4", "expo-media-library": "~57.0.2", + "expo-sensors": "~57.0.2", "expo-sharing": "~57.0.18", "expo-status-bar": "~57.0.1", "lucide-react-native": "^1.24.0", @@ -3593,6 +3595,19 @@ "react-native": "*" } }, + "node_modules/expo-sensors": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-sensors/-/expo-sensors-57.0.2.tgz", + "integrity": "sha512-5TjX3b207CZWqMhbV8PMbvRCq+oLLEpnHCzB9TOGqGvcWx6LUx50vuGdPIGMPhuvBzlrPm4jvZik1U712j9BuQ==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4" + }, + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo-server": { "version": "57.0.1", "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-57.0.1.tgz", diff --git a/package.json b/package.json index 7e60021..ce4cdfd 100644 --- a/package.json +++ b/package.json @@ -8,15 +8,16 @@ "@shopify/react-native-skia": "2.6.2", "babel-preset-expo": "~57.0.0", "expo": "~57.0.7", - "expo-keep-awake": "~57.0.1", "expo-asset": "~57.0.5", "expo-dev-client": "~57.0.6", "expo-file-system": "~57.0.1", "expo-font": "~57.0.1", "expo-haptics": "~57.0.1", "expo-image-picker": "~57.0.4", + "expo-keep-awake": "~57.0.1", "expo-location": "~57.0.4", "expo-media-library": "~57.0.2", + "expo-sensors": "~57.0.2", "expo-sharing": "~57.0.18", "expo-status-bar": "~57.0.1", "lucide-react-native": "^1.24.0", diff --git a/src/components/AdjustmentPanel.tsx b/src/components/AdjustmentPanel.tsx index 2612cbf..c747d24 100644 --- a/src/components/AdjustmentPanel.tsx +++ b/src/components/AdjustmentPanel.tsx @@ -89,6 +89,8 @@ interface AdjustmentPanelProps { photoStraighten: number; onPickRotation: (deg: 0 | 90 | 180 | 270) => void; onStraighten: (deg: number) => void; + autoHorizon: boolean; + onAutoHorizon: (on: boolean) => void; // FRAME tab (WALL FRAME only): hang the artwork landscape instead of the // default portrait rotation. wallLandscape: boolean; @@ -220,6 +222,8 @@ export default function AdjustmentPanel({ photoStraighten, onPickRotation, onStraighten, + autoHorizon, + onAutoHorizon, wallLandscape, onToggleWallLandscape, onToggleGeotag, @@ -566,14 +570,15 @@ export default function AdjustmentPanel({ ], onPick: (v) => onUpdateCropRatio(v as CropRatio), }, - // ROTATE: the quarter turns plus the fine STRAIGHTEN slider in one strip. - // Its RESET undoes this parameter only (quarter turn + fine angle). + // ROTATE: the quarter turns, the fine STRAIGHTEN slider and AUTO horizon in + // one strip. Its RESET undoes this parameter only (turn + angle + auto). rotate: { label: 'ROTATE', off: '0', // 'straighten' while its slider row is open, so that chip highlights. - value: openParam === 'straighten' ? 'straighten' : String(photoRotation), + value: openParam === 'straighten' ? 'straighten' : autoHorizon ? 'auto' : String(photoRotation), options: [ { v: 'reset', d: 'RESET' }, + { v: 'auto', d: 'AUTO' }, { v: '0', d: '0' }, { v: '90', d: '90' }, { v: '180', d: '180' }, @@ -584,8 +589,11 @@ export default function AdjustmentPanel({ if (v === 'reset') { onPickRotation(0); onStraighten(0); + onAutoHorizon(false); } else if (v === 'straighten') { toggleParam('straighten'); + } else if (v === 'auto') { + onAutoHorizon(!autoHorizon); } else { onPickRotation(Number(v) as 0 | 90 | 180 | 270); } @@ -636,16 +644,19 @@ export default function AdjustmentPanel({ label: wbTempLabel(), }); - // ROTATE's chip names the applied turn — and the fine angle once it is set - // (the strip holds the options; nothing else names the angle). + // ROTATE's chip names every part of the angle it applies: the turn (dropped + // when it is 0 and AUTO is doing the work), the horizon mode, the fine angle. const rotateChip = (): ChipDef => { - const label = `ROTATE ${photoRotation}`; + const parts = [ + photoRotation !== 0 || !autoHorizon ? `ROTATE ${photoRotation}` : 'ROTATE', + autoHorizon ? 'AUTO' : null, + photoStraighten ? `${photoStraighten > 0 ? '+' : ''}${photoStraighten}°` : null, + ]; return { ...groupChip('rotate'), - label: photoStraighten - ? `${label} · ${photoStraighten > 0 ? '+' : ''}${photoStraighten}°` - : label, - amberValue: openGroup !== 'rotate' && (photoRotation !== 0 || photoStraighten !== 0), + label: parts.filter(Boolean).join(' · '), + amberValue: + openGroup !== 'rotate' && (photoRotation !== 0 || photoStraighten !== 0 || autoHorizon), }; }; diff --git a/src/components/Viewfinder.tsx b/src/components/Viewfinder.tsx index 68f7bb1..1717b40 100644 --- a/src/components/Viewfinder.tsx +++ b/src/components/Viewfinder.tsx @@ -54,6 +54,9 @@ interface ViewfinderProps { // on top of that turn. photoRotation?: 0 | 90 | 180 | 270; photoStraighten?: number; + // FRAME-tab AUTO: the device's lean in degrees, null while AUTO is off. Only + // drives the level line — the photo arrives already counter-rotated. + autoRoll?: number | null; // WATERMARK tab: double-tap ON the mark re-opens the text editor (App bumps // a nonce so the panel puts the caret back in its input). onWmEdit?: () => void; @@ -234,6 +237,7 @@ const Viewfinder = forwardRef(function Viewfi wmEditing = false, photoRotation = 0, photoStraighten = 0, + autoRoll = null, wmRotation = 0, wallLandscape = false, }: ViewfinderProps, @@ -2253,6 +2257,24 @@ const Viewfinder = forwardRef(function Viewfi const cinemaOn = cinemaEffect != null && !!cinemaParams; const useShaderPass = !!imageFitRect && ((toneEffect != null && toneOn) || cinemaOn); + // FRAME-tab AUTO: a bubble level that follows the device's own lean. The photo + // under it is already counter-rotated by the same angle, so this line is the + // only place the raw tilt is visible. Amber once within a degree of level. + const levelOverlay = autoRoll == null ? null : ( + + + + + + + ); + // RETRO INSTANT (library): the whole picture is contain-fitted into the // card's photo window — nothing is masked or cropped, and the card is // centered on the black background like a physical print. The window keeps @@ -2328,6 +2350,7 @@ const Viewfinder = forwardRef(function Viewfi onResponderMove={onLibTouchMove} onResponderRelease={onLibTouchEnd} /> + {levelOverlay} ); } @@ -2404,6 +2427,7 @@ const Viewfinder = forwardRef(function Viewfi onResponderMove={onLibTouchMove} onResponderRelease={onLibTouchEnd} /> + {levelOverlay} ); } @@ -2546,6 +2570,7 @@ const Viewfinder = forwardRef(function Viewfi onResponderMove={onLibTouchMove} onResponderRelease={onLibTouchEnd} /> + {levelOverlay} ) : (