From 3e7b7fd6c18bfe8c77948a3acfb1711acf4fd98a Mon Sep 17 00:00:00 2001 From: Tim Misker Date: Tue, 24 Mar 2026 12:56:54 +0100 Subject: [PATCH] Fix landscape photo orientation and aspect ratio mismatch (v1.1.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The camera was producing portrait-oriented photos when the tablet was held landscape, and the captured photo showed more content than what was visible in the preview. Root cause: SCREEN_WIDTH/SCREEN_HEIGHT were static module-level values that did not update on rotation, causing screenAspectRatio to be < 1 (portrait) — which doesn't match any landscape camera sensor format. - Use useWindowDimensions() for reactive dimensions that update on rotation - Calculate screenAspectRatio as max/min to always be >= 1, matching camera sensor formats (e.g. 4:3 = 1.33, 16:9 = 1.77) - Fix ESLint errors: == → ===, merge duplicate imports in useIsForeground.ts and usePreferredCameraDevice.ts - Fix .eslintrc.js: exclude jest plugin (incompatible with Node 22 / ESLint 8) - Bump version to 1.1.2 Co-Authored-By: Claude Sonnet 4.6 --- .eslintrc.js | 10 +- package.json | 2 +- src/Constants.ts | 26 +- src/components/CameraPage.tsx | 523 +++++++++++++------------- src/hooks/useIsForeground.ts | 26 +- src/hooks/usePreferredCameraDevice.ts | 29 +- 6 files changed, 317 insertions(+), 299 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index a81cc74..ad2376c 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,5 +1,13 @@ const base = require("@mendix/pluggable-widgets-tools/configs/eslint.ts.base.json"); +// Remove jest/globals env and jest plugin — incompatible with Node 22 / ESLint 8 in this setup +const { "jest/globals": _jestGlobals, ...envWithoutJest } = base.env; +const pluginsWithoutJest = (base.plugins || []).filter(p => p !== "jest"); +const rulesWithoutJest = Object.fromEntries(Object.entries(base.rules || {}).filter(([k]) => !k.startsWith("jest/"))); + module.exports = { - ...base + ...base, + env: envWithoutJest, + plugins: pluginsWithoutJest, + rules: rulesWithoutJest }; diff --git a/package.json b/package.json index e6a590d..1d570ec 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "nativevisioncamera", "widgetName": "NativeVisionCamera", - "version": "1.1.1", + "version": "1.1.2", "description": "Designed from the ground up to provide all features a camera app should have. You have full control over what device is used, and can even configure options such as frame rate, colorspace and more.", "copyright": "Groen.dev", "author": "Marcus Groen", diff --git a/src/Constants.ts b/src/Constants.ts index 006d823..692e78a 100644 --- a/src/Constants.ts +++ b/src/Constants.ts @@ -1,30 +1,30 @@ -import { Dimensions, Platform } from 'react-native'; -import StaticSafeAreaInsets from 'react-native-static-safe-area-insets'; +import { Dimensions, Platform } from "react-native"; +import StaticSafeAreaInsets from "react-native-static-safe-area-insets"; export const CONTENT_SPACING = 15; const SAFE_BOTTOM = - Platform.select({ - ios: StaticSafeAreaInsets.safeAreaInsetsBottom, - }) ?? 0; + Platform.select({ + ios: StaticSafeAreaInsets.safeAreaInsetsBottom + }) ?? 0; export const SAFE_AREA_PADDING = { - paddingLeft: StaticSafeAreaInsets.safeAreaInsetsLeft + CONTENT_SPACING, - paddingTop: StaticSafeAreaInsets.safeAreaInsetsTop + CONTENT_SPACING, - paddingRight: StaticSafeAreaInsets.safeAreaInsetsRight + CONTENT_SPACING, - paddingBottom: SAFE_BOTTOM + CONTENT_SPACING, + paddingLeft: StaticSafeAreaInsets.safeAreaInsetsLeft + CONTENT_SPACING, + paddingTop: StaticSafeAreaInsets.safeAreaInsetsTop + CONTENT_SPACING, + paddingRight: StaticSafeAreaInsets.safeAreaInsetsRight + CONTENT_SPACING, + paddingBottom: SAFE_BOTTOM + CONTENT_SPACING }; // The maximum zoom _factor_ you should be able to zoom in export const MAX_ZOOM_FACTOR = 10; -export const SCREEN_WIDTH = Dimensions.get('window').width; +export const SCREEN_WIDTH = Dimensions.get("window").width; export const SCREEN_HEIGHT = Platform.select({ - android: Dimensions.get('screen').height - StaticSafeAreaInsets.safeAreaInsetsBottom, - ios: Dimensions.get('window').height, + android: Dimensions.get("screen").height - StaticSafeAreaInsets.safeAreaInsetsBottom, + ios: Dimensions.get("window").height }) as number; // Button sizes export const BUTTON_SIZE = 40; export const CAPTURE_BUTTON_SIZE = 78; -export const BUTTON_ICON_SIZE = 24; \ No newline at end of file +export const BUTTON_ICON_SIZE = 24; diff --git a/src/components/CameraPage.tsx b/src/components/CameraPage.tsx index 5cee96d..6a699cd 100644 --- a/src/components/CameraPage.tsx +++ b/src/components/CameraPage.tsx @@ -1,285 +1,298 @@ -import React, { createElement, useEffect, useRef, useState, useMemo, useCallback } from 'react'; -import { StyleSheet, Text, View, TouchableOpacity } from 'react-native'; +import React, { createElement, useEffect, useRef, useState, useMemo, useCallback } from "react"; +import { StyleSheet, Text, View, TouchableOpacity, useWindowDimensions } from "react-native"; import { ActionValue, DynamicValue, EditableValue, ValueStatus } from "mendix"; import { - Camera, - useCameraDevice, - useCameraFormat, - CameraRuntimeError, - PhotoFile, - VideoFile, - TakePhotoOptions, - TakeSnapshotOptions, - useLocationPermission, - Orientation -} from 'react-native-vision-camera'; -import { CONTENT_SPACING, SAFE_AREA_PADDING, BUTTON_SIZE, BUTTON_ICON_SIZE, CAPTURE_BUTTON_SIZE, SCREEN_HEIGHT, SCREEN_WIDTH } from '../Constants'; -import { useIsForeground } from '../hooks/useIsForeground'; -import { useIsFocused } from '@react-navigation/core'; -import { usePreferredCameraDevice } from '../hooks/usePreferredCameraDevice'; -import { StatusBarBlurBackground } from './StatusBarBlurBackground'; -import MaterialIcon from 'react-native-vector-icons/MaterialCommunityIcons'; -import IonIcon from 'react-native-vector-icons/Ionicons'; + Camera, + useCameraDevice, + useCameraFormat, + CameraRuntimeError, + PhotoFile, + VideoFile, + TakePhotoOptions, + TakeSnapshotOptions, + useLocationPermission, + Orientation +} from "react-native-vision-camera"; +import { CONTENT_SPACING, SAFE_AREA_PADDING, BUTTON_SIZE, BUTTON_ICON_SIZE, CAPTURE_BUTTON_SIZE } from "../Constants"; +import { useIsForeground } from "../hooks/useIsForeground"; +import { useIsFocused } from "@react-navigation/core"; +import { usePreferredCameraDevice } from "../hooks/usePreferredCameraDevice"; +import { StatusBarBlurBackground } from "./StatusBarBlurBackground"; +import MaterialIcon from "react-native-vector-icons/MaterialCommunityIcons"; +import IonIcon from "react-native-vector-icons/Ionicons"; type CameraPageProps = { - mediaPath: EditableValue; - onCaptureAction?: ActionValue; + mediaPath: EditableValue; + onCaptureAction?: ActionValue; }; export const executeAction = (action?: ActionValue): void => { - if (action && action.canExecute && !action.isExecuting) { - action.execute(); - } + if (action && action.canExecute && !action.isExecuting) { + action.execute(); + } }; export const isAvailable = (property: DynamicValue | EditableValue): boolean => { - return property && property.status === ValueStatus.Available && property.value; + return property && property.status === ValueStatus.Available && property.value; }; export function CameraPage({ mediaPath, onCaptureAction }: CameraPageProps): React.ReactElement { - const camera = useRef(null); - const location = useLocationPermission(); - let zoom = { value: 1.0 }; - - // check if camera page is active - const isFocussed = useIsFocused() - const isForeground = useIsForeground(); - const isActive = isFocussed && isForeground; + const camera = useRef(null); + const location = useLocationPermission(); + const { width: windowWidth, height: windowHeight } = useWindowDimensions(); + const zoom = { value: 1.0 }; - // set states - const [orientation, setOrientation] = useState<"portrait" | "landscape">("portrait"); - const [cameraPosition, setCameraPosition] = useState<'front' | 'back'>('back'); - const [enableHdr, setEnableHdr] = useState(false); - const [flash, setFlash] = useState<'off' | 'on'>('off'); - const [enableNightMode, setEnableNightMode] = useState(false); + // check if camera page is active + const isFocussed = useIsFocused(); + const isForeground = useIsForeground(); + const isActive = isFocussed && isForeground; - // check orientation - const determineAndSetOrientation = (o: Orientation) => { - if (o.includes('portrait')) { - console.debug('set orientation to portrait'); - setOrientation('portrait'); - } else { - console.debug('set orientation to landscape'); - setOrientation('landscape'); - } - } + // set states + const [orientation, setOrientation] = useState<"portrait" | "landscape">("portrait"); + const [cameraPosition, setCameraPosition] = useState<"front" | "back">("back"); + const [enableHdr, setEnableHdr] = useState(false); + const [flash, setFlash] = useState<"off" | "on">("off"); + const [enableNightMode, setEnableNightMode] = useState(false); - // camera device settings - const [preferredDevice] = usePreferredCameraDevice() - let device = useCameraDevice(cameraPosition) + // check orientation + const determineAndSetOrientation = (o: Orientation) => { + if (o.includes("portrait")) { + console.debug("set orientation to portrait"); + setOrientation("portrait"); + } else { + console.debug("set orientation to landscape"); + setOrientation("landscape"); + } + }; - if (preferredDevice != null && preferredDevice.position === cameraPosition) { - // override default device with the one selected by the user in settings - device = preferredDevice - } - const [targetFps, setTargetFps] = useState(60) - const screenAspectRatio = SCREEN_HEIGHT / SCREEN_WIDTH - const format = useCameraFormat(device, [ - { fps: targetFps }, - { videoAspectRatio: screenAspectRatio }, - { videoResolution: 'max' }, - { photoAspectRatio: screenAspectRatio }, - { photoResolution: 'max' }, - ]) - const fps = Math.min(format?.maxFps ?? 1, targetFps) - const supportsCameraFlipping = true; - const supportsFlash = device?.hasFlash ?? false; - const supportsHdr = format?.supportsPhotoHdr; - const supports60Fps = useMemo(() => device?.formats.some((f: any) => f.maxFps >= 60), [device?.formats]) - const canToggleNightMode = device?.supportsLowLightBoost ?? false; - const takePhotoOptions = useMemo( - () => ({ - flash: flash, - quality: 90, - enableAutoStabilization: true, - enableShutterSound: true - }), - [flash] - ); + // camera device settings + const [preferredDevice] = usePreferredCameraDevice(); + let device = useCameraDevice(cameraPosition); - //#region Callbacks - const onError = useCallback((error: CameraRuntimeError) => { - console.error(error); - }, []); - const onInitialized = useCallback(() => { - console.debug('Camera initialized!'); - }, []); - const onMediaCaptured = useCallback( - (media: PhotoFile | VideoFile, type: 'photo' | 'video') => { - console.debug(`Media captured! ${JSON.stringify(media)}`); - console.debug(`type = ${JSON.stringify(type)}`); - try { - console.debug(`setting media path to ${media.path}`); - mediaPath.setValue(`${media.path}`); - } catch (e) { - console.error('Failed to set media path!', e); - } - try { - executeAction(onCaptureAction); - } catch (e) { - console.error('Failed to execute onCaptureAction!', e); - } - }, [mediaPath, onCaptureAction]); - const onFlipCameraPressed = useCallback(() => { - setCameraPosition((p) => (p === 'back' ? 'front' : 'back')); - }, []); - const onFlashPressed = useCallback(() => { - setFlash((f) => (f === 'off' ? 'on' : 'off')); - }, []); - const onCapturePressed = useCallback(async () => { - try { - if (camera.current == null) throw new Error('Camera ref is null!'); - console.debug('Taking photo...'); - const photo = await camera.current.takePhoto(takePhotoOptions); - onMediaCaptured(photo, 'photo'); - } catch (e) { - console.error('Failed to take photo!', e); + if (preferredDevice != null && preferredDevice.position === cameraPosition) { + // override default device with the one selected by the user in settings + device = preferredDevice; } - }, [camera, onMediaCaptured, takePhotoOptions]); - //#endregion + const [targetFps, setTargetFps] = useState(60); + // Always use max/min so the ratio is >= 1, matching landscape camera sensor formats (e.g. 16:9 = 1.77) + const screenAspectRatio = Math.max(windowHeight, windowWidth) / Math.min(windowHeight, windowWidth); + const format = useCameraFormat(device, [ + { fps: targetFps }, + { videoAspectRatio: screenAspectRatio }, + { videoResolution: "max" }, + { photoAspectRatio: screenAspectRatio }, + { photoResolution: "max" } + ]); + const fps = Math.min(format?.maxFps ?? 1, targetFps); + const supportsCameraFlipping = true; + const supportsFlash = device?.hasFlash ?? false; + const supportsHdr = format?.supportsPhotoHdr; + const supports60Fps = useMemo(() => device?.formats.some((f: any) => f.maxFps >= 60), [device?.formats]); + const canToggleNightMode = device?.supportsLowLightBoost ?? false; + const takePhotoOptions = useMemo( + () => ({ + flash, + quality: 90, + enableAutoStabilization: true, + enableShutterSound: true + }), + [flash] + ); - //#region Effects - useEffect(() => { - // Reset zoom to it's default everytime the `device` changes. - zoom.value = device?.neutralZoom ?? 1; - }, [zoom, device]); - useEffect(() => { - location.requestPermission(); - }, [location]); - //#endregion + // #region Callbacks + const onError = useCallback((error: CameraRuntimeError) => { + console.error(error); + }, []); + const onInitialized = useCallback(() => { + console.debug("Camera initialized!"); + }, []); + const onMediaCaptured = useCallback( + (media: PhotoFile | VideoFile, type: "photo" | "video") => { + console.debug(`Media captured! ${JSON.stringify(media)}`); + console.debug(`type = ${JSON.stringify(type)}`); + try { + console.debug(`setting media path to ${media.path}`); + mediaPath.setValue(`${media.path}`); + } catch (e) { + console.error("Failed to set media path!", e); + } + try { + executeAction(onCaptureAction); + } catch (e) { + console.error("Failed to execute onCaptureAction!", e); + } + }, + [mediaPath, onCaptureAction] + ); + const onFlipCameraPressed = useCallback(() => { + setCameraPosition(p => (p === "back" ? "front" : "back")); + }, []); + const onFlashPressed = useCallback(() => { + setFlash(f => (f === "off" ? "on" : "off")); + }, []); + const onCapturePressed = useCallback(async () => { + try { + if (camera.current == null) { + throw new Error("Camera ref is null!"); + } + console.debug("Taking photo..."); + const photo = await camera.current.takePhoto(takePhotoOptions); + onMediaCaptured(photo, "photo"); + } catch (e) { + console.error("Failed to take photo!", e); + } + }, [camera, onMediaCaptured, takePhotoOptions]); + // #endregion - const photoHdr = format?.supportsPhotoHdr && enableHdr; + // #region Effects + useEffect(() => { + // Reset zoom to it's default everytime the `device` changes. + zoom.value = device?.neutralZoom ?? 1; + }, [zoom, device]); + useEffect(() => { + location.requestPermission(); + }, [location]); + // #endregion - const dynamicStyles = (orientation == 'landscape') ? StyleSheet.create({ - captureButtonRing: { - alignSelf: 'flex-end', - right: SAFE_AREA_PADDING.paddingBottom - } - }) : StyleSheet.create({ captureButtonRing: {} }); + const photoHdr = format?.supportsPhotoHdr && enableHdr; - return ( - - {device != null ? ( - console.debug('Camera started!')} - onStopped={() => console.debug('Camera stopped!')} - onPreviewStarted={() => console.debug('Preview started!')} - onPreviewStopped={() => console.debug('Preview stopped!')} - onPreviewOrientationChanged={(o) => { - console.debug(`Preview orientation changed to ${o}!`); - determineAndSetOrientation(o); - }} - outputOrientation="device" - photo={true} - photoHdr={photoHdr} - video={false} - videoHdr={false} - audio={false} - /> - ) : ( - - No camera found. - - )} + const dynamicStyles = + orientation === "landscape" + ? StyleSheet.create({ + captureButtonRing: { + alignSelf: "flex-end", + right: SAFE_AREA_PADDING.paddingBottom + } + }) + : StyleSheet.create({ captureButtonRing: {} }); + + return ( + + {device != null ? ( + console.debug("Camera started!")} + onStopped={() => console.debug("Camera stopped!")} + onPreviewStarted={() => console.debug("Preview started!")} + onPreviewStopped={() => console.debug("Preview stopped!")} + onPreviewOrientationChanged={o => { + console.debug(`Preview orientation changed to ${o}!`); + determineAndSetOrientation(o); + }} + outputOrientation="device" + photo + photoHdr={photoHdr} + video={false} + videoHdr={false} + audio={false} + /> + ) : ( + + No camera found. + + )} - + - - {supportsCameraFlipping && ( - - - - )} - {supportsFlash && ( - - - - )} - {supports60Fps && ( - setTargetFps((t) => (t === 30 ? 60 : 30))}> - {`${targetFps}\nFPS`} - - )} - {supportsHdr && ( - setEnableHdr((h) => !h)}> - - - )} - {canToggleNightMode && ( - setEnableNightMode(!enableNightMode)}> - - - )} - + + {supportsCameraFlipping && ( + + + + )} + {supportsFlash && ( + + + + )} + {supports60Fps && ( + setTargetFps(t => (t === 30 ? 60 : 30))}> + {`${targetFps}\nFPS`} + + )} + {supportsHdr && ( + setEnableHdr(h => !h)}> + + + )} + {canToggleNightMode && ( + setEnableNightMode(!enableNightMode)}> + + + )} + - {device != null && ( - - + {device != null && ( + + + + )} - )} - - ); + ); } const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: 'black' - }, - captureButtonRing: { - justifyContent: 'center', - position: 'absolute', - alignSelf: 'center', - bottom: SAFE_AREA_PADDING.paddingBottom, - padding: 2, - width: CAPTURE_BUTTON_SIZE, - height: CAPTURE_BUTTON_SIZE, - borderRadius: CAPTURE_BUTTON_SIZE / 2, - borderWidth: 4, - borderColor: 'white' - }, - captureButton: { - flex: 1, - borderRadius: CAPTURE_BUTTON_SIZE / 2, - backgroundColor: 'white' - }, - button: { - marginBottom: CONTENT_SPACING, - width: BUTTON_SIZE, - height: BUTTON_SIZE, - borderRadius: BUTTON_SIZE / 2, - backgroundColor: 'rgba(140, 140, 140, 0.3)', - justifyContent: 'center', - alignItems: 'center' - }, - rightButtonRow: { - position: 'absolute', - right: SAFE_AREA_PADDING.paddingRight, - top: SAFE_AREA_PADDING.paddingTop - }, - text: { - color: 'white', - fontSize: 11, - fontWeight: 'bold', - textAlign: 'center' - }, - emptyContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - } -}); \ No newline at end of file + container: { + flex: 1, + backgroundColor: "black" + }, + captureButtonRing: { + justifyContent: "center", + position: "absolute", + alignSelf: "center", + bottom: SAFE_AREA_PADDING.paddingBottom, + padding: 2, + width: CAPTURE_BUTTON_SIZE, + height: CAPTURE_BUTTON_SIZE, + borderRadius: CAPTURE_BUTTON_SIZE / 2, + borderWidth: 4, + borderColor: "white" + }, + captureButton: { + flex: 1, + borderRadius: CAPTURE_BUTTON_SIZE / 2, + backgroundColor: "white" + }, + button: { + marginBottom: CONTENT_SPACING, + width: BUTTON_SIZE, + height: BUTTON_SIZE, + borderRadius: BUTTON_SIZE / 2, + backgroundColor: "rgba(140, 140, 140, 0.3)", + justifyContent: "center", + alignItems: "center" + }, + rightButtonRow: { + position: "absolute", + right: SAFE_AREA_PADDING.paddingRight, + top: SAFE_AREA_PADDING.paddingTop + }, + text: { + color: "white", + fontSize: 11, + fontWeight: "bold", + textAlign: "center" + }, + emptyContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center" + } +}); diff --git a/src/hooks/useIsForeground.ts b/src/hooks/useIsForeground.ts index b449871..5524bdf 100644 --- a/src/hooks/useIsForeground.ts +++ b/src/hooks/useIsForeground.ts @@ -1,18 +1,16 @@ -import { useState } from 'react'; -import { useEffect } from 'react'; -import type { AppStateStatus } from 'react-native'; -import { AppState } from 'react-native'; +import { useState, useEffect } from "react"; +import { AppState, type AppStateStatus } from "react-native"; export const useIsForeground = (): boolean => { - const [isForeground, setIsForeground] = useState(true); + const [isForeground, setIsForeground] = useState(true); - useEffect(() => { - const onChange = (state: AppStateStatus): void => { - setIsForeground(state === 'active'); - }; - const myEventListener = AppState.addEventListener('change', onChange); - return () => myEventListener.remove(); - }, [setIsForeground]); + useEffect(() => { + const onChange = (state: AppStateStatus): void => { + setIsForeground(state === "active"); + }; + const myEventListener = AppState.addEventListener("change", onChange); + return () => myEventListener.remove(); + }, [setIsForeground]); - return isForeground; -}; \ No newline at end of file + return isForeground; +}; diff --git a/src/hooks/usePreferredCameraDevice.ts b/src/hooks/usePreferredCameraDevice.ts index fbf6142..86dc162 100644 --- a/src/hooks/usePreferredCameraDevice.ts +++ b/src/hooks/usePreferredCameraDevice.ts @@ -1,20 +1,19 @@ -import { useMMKVString } from 'react-native-mmkv'; -import { useCallback, useMemo } from 'react'; -import type { CameraDevice } from 'react-native-vision-camera'; -import { useCameraDevices } from 'react-native-vision-camera'; +import { useMMKVString } from "react-native-mmkv"; +import { useCallback, useMemo } from "react"; +import { useCameraDevices, type CameraDevice } from "react-native-vision-camera"; export function usePreferredCameraDevice(): [CameraDevice | undefined, (device: CameraDevice) => void] { - const [preferredDeviceId, setPreferredDeviceId] = useMMKVString('camera.preferredDeviceId'); + const [preferredDeviceId, setPreferredDeviceId] = useMMKVString("camera.preferredDeviceId"); - const set = useCallback( - (device: CameraDevice) => { - setPreferredDeviceId(device.id); - }, - [setPreferredDeviceId] - ); + const set = useCallback( + (device: CameraDevice) => { + setPreferredDeviceId(device.id); + }, + [setPreferredDeviceId] + ); - const devices = useCameraDevices(); - const device = useMemo(() => devices.find((d) => d.id === preferredDeviceId), [devices, preferredDeviceId]); + const devices = useCameraDevices(); + const device = useMemo(() => devices.find(d => d.id === preferredDeviceId), [devices, preferredDeviceId]); - return [device, set]; -} \ No newline at end of file + return [device, set]; +}