From 936825c657c493748b43c1d15255beb3bf42d9cb Mon Sep 17 00:00:00 2001
From: KonTy <9524513+KonTy@users.noreply.github.com>
Date: Wed, 24 Jun 2026 02:49:51 -0700
Subject: [PATCH] add hold shutter to record video in photo mode
Press and hold the shutter button or a volume key while in photo mode to
record a video, and release to stop. A short tap still takes a photo.
Audio is always captured for these clips and the existing recording UI is
reused. The behavior can be turned off from a new toggle in More settings
and defaults to on.
The quick video state machine is kept in a small QuickVideoController so
the changes to MainActivity stay minimal, and the on-screen button and the
volume keys share the same code path.
---
.../java/app/grapheneos/camera/CamConfig.kt | 27 ++++
.../camera/capturer/QuickVideoController.kt | 125 ++++++++++++++++++
.../camera/capturer/VideoCapturer.kt | 26 +++-
.../camera/ui/activities/MainActivity.kt | 44 ++++++
.../camera/ui/activities/MoreSettings.kt | 11 ++
app/src/main/res/layout/more_settings.xml | 58 ++++++++
app/src/main/res/values/strings.xml | 2 +
7 files changed, 290 insertions(+), 3 deletions(-)
create mode 100644 app/src/main/java/app/grapheneos/camera/capturer/QuickVideoController.kt
diff --git a/app/src/main/java/app/grapheneos/camera/CamConfig.kt b/app/src/main/java/app/grapheneos/camera/CamConfig.kt
index ccb4f081d..203fb68e6 100644
--- a/app/src/main/java/app/grapheneos/camera/CamConfig.kt
+++ b/app/src/main/java/app/grapheneos/camera/CamConfig.kt
@@ -113,6 +113,8 @@ class CamConfig(private val mActivity: MainActivity) {
const val CAMERA_SOUNDS = "camera_sounds"
+ const val QUICK_VIDEO_HOLD = "quick_video_hold"
+
const val ENABLE_ZSL = "enable_zsl"
const val SELECT_HIGHEST_RESOLUTION = "select_highest_resolution"
@@ -160,6 +162,8 @@ class CamConfig(private val mActivity: MainActivity) {
const val CAMERA_SOUNDS = true
+ const val QUICK_VIDEO_HOLD = true
+
const val ENABLE_ZSL = false
const val SELECT_HIGHEST_RESOLUTION = false
@@ -297,6 +301,9 @@ class CamConfig(private val mActivity: MainActivity) {
private var currentMode: CameraMode = DEFAULT_CAMERA_MODE
+ val mode: CameraMode
+ get() = currentMode
+
var aspectRatio: Int
get() {
return when {
@@ -410,6 +417,19 @@ class CamConfig(private val mActivity: MainActivity) {
editor.apply()
}
+ var quickVideoHold: Boolean
+ get() {
+ return commonPref.getBoolean(
+ SettingValues.Key.QUICK_VIDEO_HOLD,
+ SettingValues.Default.QUICK_VIDEO_HOLD
+ )
+ }
+ set(value) {
+ val editor = commonPref.edit()
+ editor.putBoolean(SettingValues.Key.QUICK_VIDEO_HOLD, value)
+ editor.apply()
+ }
+
var scanAllCodes: Boolean
get() {
return commonPref.getBoolean(
@@ -761,6 +781,13 @@ class CamConfig(private val mActivity: MainActivity) {
editor.putBoolean(SettingValues.Key.CAMERA_SOUNDS, SettingValues.Default.CAMERA_SOUNDS)
}
+ if (!commonPref.contains(SettingValues.Key.QUICK_VIDEO_HOLD)) {
+ editor.putBoolean(
+ SettingValues.Key.QUICK_VIDEO_HOLD,
+ SettingValues.Default.QUICK_VIDEO_HOLD
+ )
+ }
+
// Note: This is a workaround to keep save image/video as previewed 'on' by
// default starting from v73 and 'off' by default for versions before that
//
diff --git a/app/src/main/java/app/grapheneos/camera/capturer/QuickVideoController.kt b/app/src/main/java/app/grapheneos/camera/capturer/QuickVideoController.kt
new file mode 100644
index 000000000..e863a6a82
--- /dev/null
+++ b/app/src/main/java/app/grapheneos/camera/capturer/QuickVideoController.kt
@@ -0,0 +1,125 @@
+package app.grapheneos.camera.capturer
+
+import app.grapheneos.camera.CameraMode
+import app.grapheneos.camera.ui.activities.CaptureActivity
+import app.grapheneos.camera.ui.activities.MainActivity
+
+class QuickVideoController(private val mActivity: MainActivity) {
+
+ private val camConfig get() = mActivity.camConfig
+ private val videoCapturer get() = mActivity.videoCapturer
+
+ private var pressActive = false
+ private var startPending = false
+ private var sourceMode: CameraMode? = null
+ private var hardwareKeyCode: Int? = null
+
+ val isEngaged: Boolean
+ get() = startPending || sourceMode != null
+
+ private val isEligible: Boolean
+ get() = mActivity !is CaptureActivity
+ && !mActivity.requiresVideoModeOnly
+ && !camConfig.isVideoMode
+ && !camConfig.isQRMode
+ && camConfig.quickVideoHold
+ && mActivity.timerDuration == 0
+ && !videoCapturer.isRecording
+
+ fun onPressDown() {
+ pressActive = true
+ }
+
+ fun start(): Boolean {
+ pressActive = true
+ if (!isEligible) {
+ pressActive = false
+ return false
+ }
+
+ sourceMode = camConfig.mode
+ startPending = true
+
+ camConfig.switchMode(CameraMode.VIDEO)
+ startIfPending()
+ return true
+ }
+
+ fun startFromHardwareKey(keyCode: Int): Boolean {
+ if (!start()) {
+ return false
+ }
+ hardwareKeyCode = keyCode
+ return true
+ }
+
+ fun onCameraReady() {
+ startIfPending()
+ }
+
+ fun release(): Boolean {
+ pressActive = false
+
+ if (startPending) {
+ startPending = false
+ restoreSourceMode()
+ return true
+ }
+
+ val source = sourceMode ?: return false
+
+ if (videoCapturer.isRecording) {
+ videoCapturer.stopRecording {
+ if (sourceMode == source) {
+ restoreSourceMode()
+ }
+ }
+ } else {
+ restoreSourceMode()
+ }
+
+ return true
+ }
+
+ fun onHardwareKeyRelease(keyCode: Int): Boolean {
+ if (hardwareKeyCode != keyCode) {
+ return false
+ }
+ hardwareKeyCode = null
+ return release()
+ }
+
+ fun reset() {
+ pressActive = false
+ startPending = false
+ sourceMode = null
+ hardwareKeyCode = null
+ }
+
+ private fun startIfPending() {
+ if (!startPending || !camConfig.isVideoMode) {
+ return
+ }
+ startPending = false
+
+ if (!pressActive) {
+ restoreSourceMode()
+ return
+ }
+
+ videoCapturer.startRecording(forceAudio = true)
+ if (!videoCapturer.isRecording) {
+ restoreSourceMode()
+ }
+ }
+
+ private fun restoreSourceMode() {
+ val source = sourceMode ?: return
+ sourceMode = null
+ startPending = false
+
+ if (!videoCapturer.isRecording && camConfig.mode != source) {
+ camConfig.switchMode(source)
+ }
+ }
+}
diff --git a/app/src/main/java/app/grapheneos/camera/capturer/VideoCapturer.kt b/app/src/main/java/app/grapheneos/camera/capturer/VideoCapturer.kt
index 6ba33c407..ee552bd9f 100644
--- a/app/src/main/java/app/grapheneos/camera/capturer/VideoCapturer.kt
+++ b/app/src/main/java/app/grapheneos/camera/capturer/VideoCapturer.kt
@@ -49,6 +49,8 @@ class VideoCapturer(private val mActivity: MainActivity) {
private val videoFileFormat = ".mp4"
private var recording: Recording? = null
+ private var onFinalizeCallback: (() -> Unit)? = null
+ private var forceAudioOnNextStart = false
var isMuted = false
private set
@@ -149,7 +151,11 @@ class VideoCapturer(private val mActivity: MainActivity) {
return null
}
- fun startRecording() {
+ fun startRecording(forceAudio: Boolean = false) {
+ if (forceAudio) {
+ forceAudioOnNextStart = true
+ }
+
if (camConfig.camera == null) return
val recorder = camConfig.videoCapture?.output ?: return
if (isRecording) return
@@ -161,8 +167,9 @@ class VideoCapturer(private val mActivity: MainActivity) {
includeAudio = false
val ctx = mActivity
+ val shouldIncludeAudio = forceAudioOnNextStart || ctx.settingsDialog.includeAudioToggle.isChecked
- if (ctx.settingsDialog.includeAudioToggle.isChecked) {
+ if (shouldIncludeAudio) {
if (ctx.checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PERMISSION_GRANTED) {
includeAudio = true
} else {
@@ -171,6 +178,7 @@ class VideoCapturer(private val mActivity: MainActivity) {
return
}
}
+ forceAudioOnNextStart = false
val recordingCtx = try {
createRecordingContext(recorder, fileName)!!
@@ -205,6 +213,9 @@ class VideoCapturer(private val mActivity: MainActivity) {
}
if (event is VideoRecordEvent.Finalize) {
+ val finalizeCallback = onFinalizeCallback
+ onFinalizeCallback = null
+
afterRecordingStops()
camConfig.mPlayer.playVRStopSound()
@@ -213,12 +224,14 @@ class VideoCapturer(private val mActivity: MainActivity) {
when (event.error) {
VideoRecordEvent.Finalize.ERROR_NO_VALID_DATA -> {
ctx.showMessage(R.string.recording_too_short_to_be_saved)
+ finalizeCallback?.invoke()
return@start
}
VideoRecordEvent.Finalize.ERROR_ENCODING_FAILED,
VideoRecordEvent.Finalize.ERROR_RECORDER_ERROR,
VideoRecordEvent.Finalize.ERROR_UNKNOWN -> {
ctx.showMessage(ctx.getString(R.string.unable_to_save_video_verbose, event.error))
+ finalizeCallback?.invoke()
return@start
}
else -> {
@@ -251,6 +264,8 @@ class VideoCapturer(private val mActivity: MainActivity) {
if (ctx is VideoCaptureActivity) {
ctx.afterRecording(uri)
}
+
+ finalizeCallback?.invoke()
}
}
@@ -394,11 +409,16 @@ class VideoCapturer(private val mActivity: MainActivity) {
recording?.mute(false)
}
- fun stopRecording() {
+ fun stopRecording(onStopped: (() -> Unit)? = null) {
+ onFinalizeCallback = onStopped
recording?.stop()
recording?.close()
recording = null
}
+
+ fun clearForceAudioForNextStart() {
+ forceAudioOnNextStart = false
+ }
}
@Throws(Exception::class)
diff --git a/app/src/main/java/app/grapheneos/camera/ui/activities/MainActivity.kt b/app/src/main/java/app/grapheneos/camera/ui/activities/MainActivity.kt
index 6895accce..9a74eee0b 100644
--- a/app/src/main/java/app/grapheneos/camera/ui/activities/MainActivity.kt
+++ b/app/src/main/java/app/grapheneos/camera/ui/activities/MainActivity.kt
@@ -71,6 +71,7 @@ import app.grapheneos.camera.ITEM_TYPE_IMAGE
import app.grapheneos.camera.ITEM_TYPE_VIDEO
import app.grapheneos.camera.R
import app.grapheneos.camera.capturer.ImageCapturer
+import app.grapheneos.camera.capturer.QuickVideoController
import app.grapheneos.camera.capturer.VideoCapturer
import app.grapheneos.camera.capturer.getVideoThumbnail
import app.grapheneos.camera.databinding.ActivityMainBinding
@@ -247,6 +248,7 @@ open class MainActivity : AppCompatActivity(),
lateinit var micOffIcon: ImageView
private var shouldRestartRecording = false
+ private val quickVideo = QuickVideoController(this)
fun startFocusTimer() {
handler.postDelayed(runnable, autoCenterFocusDuration)
@@ -265,6 +267,7 @@ open class MainActivity : AppCompatActivity(),
return@registerForActivityResult
}
showAudioPermissionDeniedDialog {
+ videoCapturer.clearForceAudioForNextStart()
videoCapturer.startRecording()
}
}
@@ -508,6 +511,10 @@ open class MainActivity : AppCompatActivity(),
}
override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean {
+ if (quickVideo.onHardwareKeyRelease(keyCode)) {
+ return true
+ }
+
// there are no camera controls in qr mode
if (camConfig.isQRMode) {
return super.onKeyUp(keyCode, event)
@@ -536,12 +543,24 @@ open class MainActivity : AppCompatActivity(),
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN || keyCode == KeyEvent.KEYCODE_VOLUME_UP) {
+ if (event?.repeatCount == 0) {
+ event.startTracking()
+ }
// Pretend as if the event was handled by the app (avoid volume bar from appearing)
return true
}
return super.onKeyDown(keyCode, event)
}
+ override fun onKeyLongPress(keyCode: Int, event: KeyEvent): Boolean {
+ if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN || keyCode == KeyEvent.KEYCODE_VOLUME_UP) {
+ quickVideo.startFromHardwareKey(keyCode)
+ return true
+ }
+
+ return super.onKeyLongPress(keyCode, event)
+ }
+
override fun onResume() {
super.onResume()
resumeOrientationSensor()
@@ -659,6 +678,7 @@ open class MainActivity : AppCompatActivity(),
}
restartRecordingIfPermissionsWasUnavailable()
+ quickVideo.onCameraReady()
} else {
previewGrid.visibility = View.INVISIBLE
val lastFrame = lastFrame
@@ -756,7 +776,30 @@ open class MainActivity : AppCompatActivity(),
}
captureButton = binding.captureButton
+ captureButton.setOnLongClickListener {
+ quickVideo.start()
+ }
+ captureButton.setOnTouchListener { _, event ->
+ when (event.action) {
+ MotionEvent.ACTION_DOWN -> {
+ quickVideo.onPressDown()
+ }
+
+ MotionEvent.ACTION_UP,
+ MotionEvent.ACTION_CANCEL -> {
+ if (quickVideo.release()) {
+ return@setOnTouchListener true
+ }
+ }
+ }
+
+ false
+ }
captureButton.setOnClickListener {
+ if (quickVideo.isEngaged) {
+ return@setOnClickListener
+ }
+
resetAutoSleep()
if (camConfig.isVideoMode) {
if (videoCapturer.isRecording) {
@@ -1776,6 +1819,7 @@ open class MainActivity : AppCompatActivity(),
override fun onStop() {
super.onStop()
isStarted = false
+ quickVideo.reset()
if (this::videoCapturer.isInitialized && videoCapturer.isRecording) {
videoCapturer.stopRecording()
}
diff --git a/app/src/main/java/app/grapheneos/camera/ui/activities/MoreSettings.kt b/app/src/main/java/app/grapheneos/camera/ui/activities/MoreSettings.kt
index dfa76fc43..aa677497f 100644
--- a/app/src/main/java/app/grapheneos/camera/ui/activities/MoreSettings.kt
+++ b/app/src/main/java/app/grapheneos/camera/ui/activities/MoreSettings.kt
@@ -204,6 +204,17 @@ open class MoreSettings : AppCompatActivity(), TextView.OnEditorActionListener {
csSwitch.performClick()
}
+ val qvhSwitch = binding.quickVideoHoldSwitch
+ qvhSwitch.isChecked = camConfig.quickVideoHold
+ qvhSwitch.setOnClickListener {
+ camConfig.quickVideoHold = qvhSwitch.isChecked
+ }
+
+ val qvhSetting = binding.quickVideoHoldSetting
+ qvhSetting.setOnClickListener {
+ qvhSwitch.performClick()
+ }
+
val sIAPSetting = binding.saveImageAsPreviewSetting
sIAPSetting.setOnClickListener {
sIAPToggle.performClick()
diff --git a/app/src/main/res/layout/more_settings.xml b/app/src/main/res/layout/more_settings.xml
index fe2bd7bdf..72aeb1cef 100644
--- a/app/src/main/res/layout/more_settings.xml
+++ b/app/src/main/res/layout/more_settings.xml
@@ -165,6 +165,64 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Camera Sounds
Shutter sound and other audio cues
+ Hold shutter for video
+ Press and hold the shutter button or volume keys in photo mode to record a video until release.
Storage
Storage Location