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