diff --git a/App.tsx b/App.tsx index 34e34e8..fa09541 100644 --- a/App.tsx +++ b/App.tsx @@ -31,6 +31,7 @@ import { LITE_FAVORITE_LIMIT, LITE_MARK, LITE_RECIPE_LIMIT, proLookInUse } from import { initProUnlock, useProUnlock } from './src/utils/proUnlock'; import UltraWide from './modules/recipescam-ultrawide'; import RecipescamExport from './modules/recipescam-export'; +import VolumeShutter from './modules/recipescam-volume'; // @ts-ignore import './global.css'; @@ -1532,6 +1533,24 @@ export default function App() { } }; + // Volume keys as a shutter release. `handleCapture` is rebuilt every render, + // so the listener is bound once and reaches the current one through a ref. + const captureFromKeyRef = useRef(handleCapture); + captureFromKeyRef.current = handleCapture; + useEffect(() => { + const sub = VolumeShutter.addListener('onShutter', () => { + void captureFromKeyRef.current(); + }); + return () => sub.remove(); + }, []); + // Only the camera screen claims the keys; everywhere else (the library) + // they stay volume keys. + // ponytail: keyed off the mode alone, so a press behind a modal still + // takes a frame - add the overlay flags here if that ever bites. + useEffect(() => { + VolumeShutter.setEnabled(mode === 'camera'); + }, [mode]); + // Held-shutter burst: press and hold the shutter, frames keep coming until // the finger lifts. Every frame is a normal capture (GPS resolve, RAW rules, // background render enqueue), so a burst photo is indistinguishable from a diff --git a/android/app/src/main/java/com/locphamtran/recipescamera/MainActivity.kt b/android/app/src/main/java/com/locphamtran/recipescamera/MainActivity.kt index a715ff7..336507d 100644 --- a/android/app/src/main/java/com/locphamtran/recipescamera/MainActivity.kt +++ b/android/app/src/main/java/com/locphamtran/recipescamera/MainActivity.kt @@ -3,12 +3,15 @@ package com.locphamtran.recipescamera import android.content.Intent import android.os.Build import android.os.Bundle +import android.view.KeyEvent import com.facebook.react.ReactActivity import com.facebook.react.ReactActivityDelegate import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled import com.facebook.react.defaults.DefaultReactActivityDelegate +import com.locphamtran.recipescamera.volume.VolumeShutterModule + import expo.modules.ReactActivityDelegateWrapper class MainActivity : ReactActivity() { @@ -31,6 +34,17 @@ class MainActivity : ReactActivity() { super.onNewIntent(intent) } + /** + * Volume keys double as a shutter release while the camera screen is up. + * This is the only place that runs before the window turns them into a + * stream-volume change, so the module gets first refusal and the keys it + * does not claim (everything, outside the camera) fall through to the + * default handling unchanged. + */ + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + return VolumeShutterModule.onKeyEvent(event) || super.dispatchKeyEvent(event) + } + /** * Returns the name of the main component registered from JavaScript. This is used to schedule * rendering of the component. diff --git a/modules/recipescam-volume/android/build.gradle b/modules/recipescam-volume/android/build.gradle new file mode 100644 index 0000000..82a611a --- /dev/null +++ b/modules/recipescam-volume/android/build.gradle @@ -0,0 +1,15 @@ +plugins { + id 'com.android.library' + id 'expo-module-gradle-plugin' +} + +group = 'com.locphamtran.recipescamera' +version = '0.1.0' + +android { + namespace "com.locphamtran.recipescamera.volume" + defaultConfig { + versionCode 1 + versionName "0.1.0" + } +} diff --git a/modules/recipescam-volume/android/src/main/java/com/locphamtran/recipescamera/volume/VolumeShutterModule.kt b/modules/recipescam-volume/android/src/main/java/com/locphamtran/recipescamera/volume/VolumeShutterModule.kt new file mode 100644 index 0000000..9ed9ea6 --- /dev/null +++ b/modules/recipescam-volume/android/src/main/java/com/locphamtran/recipescamera/volume/VolumeShutterModule.kt @@ -0,0 +1,59 @@ +package com.locphamtran.recipescamera.volume + +import android.view.KeyEvent +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +/** + * Volume keys as a shutter release. + * + * Android exposes no key stream to JS, and the volume keys never reach a view - + * the window (PhoneWindow.OnKeyListener) turns them into a stream-volume change + * before any view sees them. The one place that runs first is the activity's + * dispatchKeyEvent, so MainActivity hands every key here and only passes on + * what comes back false. + * + * Enabled from JS only while the camera screen is up: with the flag off the keys + * go straight back to the window, so volume still works in the library. + */ +class VolumeShutterModule : Module() { + override fun definition() = ModuleDefinition { + Name("RecipesCamVolumeShutter") + + Events("onShutter") + + // The activity is the one dispatching, but it does not own the module - + // publish the single live instance so it can be reached from there. + OnCreate { instance = this@VolumeShutterModule } + OnDestroy { if (instance === this@VolumeShutterModule) instance = null } + + Function("setEnabled") { enabled: Boolean -> VolumeShutterModule.enabled = enabled } + } + + companion object { + @Volatile private var instance: VolumeShutterModule? = null + + @Volatile private var enabled = false + + /** + * Every key event of the activity goes through here first. Returns true when + * the key is ours and has been consumed - that consumption is what keeps the + * press from also changing the media volume, the way a stock camera app + * behaves. + * + * Only ACTION_DOWN with repeatCount 0 fires: the UP and the auto-repeat of a + * held button are still swallowed (so holding it does not leak a volume + * change on release) but produce no second frame. + */ + fun onKeyEvent(event: KeyEvent): Boolean { + if (!enabled) return false + if (event.keyCode != KeyEvent.KEYCODE_VOLUME_UP && event.keyCode != KeyEvent.KEYCODE_VOLUME_DOWN) { + return false + } + if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) { + instance?.sendEvent("onShutter") + } + return true + } + } +} diff --git a/modules/recipescam-volume/expo-module.config.json b/modules/recipescam-volume/expo-module.config.json new file mode 100644 index 0000000..e4f3132 --- /dev/null +++ b/modules/recipescam-volume/expo-module.config.json @@ -0,0 +1,8 @@ +{ + "platforms": ["android"], + "android": { + "modules": [ + "com.locphamtran.recipescamera.volume.VolumeShutterModule" + ] + } +} diff --git a/modules/recipescam-volume/index.ts b/modules/recipescam-volume/index.ts new file mode 100644 index 0000000..81e77cf --- /dev/null +++ b/modules/recipescam-volume/index.ts @@ -0,0 +1,13 @@ +import { requireNativeModule } from 'expo-modules-core'; + +export interface VolumeShutterModule { + /** + * Claims the volume keys while true; while false every key is handed back to + * the window, so they stay volume keys everywhere but the camera screen. + */ + setEnabled(enabled: boolean): void; + /** Fired once per press (a held key does not auto-repeat). */ + addListener(event: 'onShutter', listener: () => void): { remove(): void }; +} + +export default requireNativeModule('RecipesCamVolumeShutter'); diff --git a/modules/recipescam-volume/package.json b/modules/recipescam-volume/package.json new file mode 100644 index 0000000..5b65a4e --- /dev/null +++ b/modules/recipescam-volume/package.json @@ -0,0 +1,8 @@ +{ + "name": "recipescam-volume", + "version": "0.1.0", + "description": "Volume keys as a shutter release for Recipescam.", + "main": "index.ts", + "license": "MIT", + "private": true +}