Shoot from the volume keys, while the camera is up

This commit is contained in:
2026-09-16 10:18:25 +07:00
parent 78630d8a2d
commit e1ce7af76f
7 changed files with 136 additions and 0 deletions
+19
View File
@@ -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
@@ -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.
@@ -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"
}
}
@@ -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
}
}
}
@@ -0,0 +1,8 @@
{
"platforms": ["android"],
"android": {
"modules": [
"com.locphamtran.recipescamera.volume.VolumeShutterModule"
]
}
}
+13
View File
@@ -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<VolumeShutterModule>('RecipesCamVolumeShutter');
+8
View File
@@ -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
}