243 lines
9.8 KiB
Kotlin
243 lines
9.8 KiB
Kotlin
package com.example.jnicpp.bowling
|
|||
|
|
|
||
|
|
import android.content.pm.ActivityInfo
|
||
|
|
import android.net.Uri
|
||
|
|
import android.os.Bundle
|
||
|
|
import android.view.View
|
||
|
|
import android.widget.Toast
|
||
|
|
import androidx.activity.result.contract.ActivityResultContracts
|
||
|
|
import androidx.activity.viewModels
|
||
|
|
import androidx.appcompat.app.AppCompatActivity
|
||
|
|
import androidx.camera.core.CameraSelector
|
||
|
|
import androidx.lifecycle.Lifecycle
|
||
|
|
import androidx.lifecycle.lifecycleScope
|
||
|
|
import androidx.lifecycle.repeatOnLifecycle
|
||
|
|
import com.example.jnicpp.R
|
||
|
|
import com.example.jnicpp.databinding.ActivityBowlingCameraBinding
|
||
|
|
import kotlinx.coroutines.launch
|
||
|
|
import java.util.Locale
|
||
|
|
import java.util.concurrent.ExecutorService
|
||
|
|
import java.util.concurrent.Executors
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Screen that records video and runs real-time pose detection on the same
|
||
|
|
* camera stream. This is a standalone entry point for now (see the plan
|
||
|
|
* this was built from) -- it isn't wired into a menu/game-state system yet.
|
||
|
|
*
|
||
|
|
* This class intentionally does *not* contain any CameraX/ML Kit binding
|
||
|
|
* logic itself -- that lives in [CameraXController] (use-case binding) and
|
||
|
|
* [PoseAnalyzer] (per-frame inference), both plain classes that don't touch
|
||
|
|
* Android component lifecycle. This class's job is just: own the executor,
|
||
|
|
* own the ViewModel, wire user actions to the controller, and reflect
|
||
|
|
* [CameraViewModel]'s state back onto the views.
|
||
|
|
*/
|
||
|
|
class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
|
||
|
|
|
||
|
|
/** Which button a recording in progress was started from. */
|
||
|
|
private enum class RecordMode { VIDEO, POSE }
|
||
|
|
|
||
|
|
private lateinit var binding: ActivityBowlingCameraBinding
|
||
|
|
private val viewModel: CameraViewModel by viewModels()
|
||
|
|
|
||
|
|
private lateinit var cameraExecutor: ExecutorService
|
||
|
|
private lateinit var cameraXController: CameraXController
|
||
|
|
private var lensFacing = CameraSelector.LENS_FACING_BACK
|
||
|
|
private var activeRecordMode: RecordMode? = null
|
||
|
|
|
||
|
|
private val permissionLauncher =
|
||
|
|
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { _ ->
|
||
|
|
val granted = CameraPermissions.allGranted(this)
|
||
|
|
viewModel.onPermissionsResult(granted)
|
||
|
|
if (granted) {
|
||
|
|
showCameraUi()
|
||
|
|
startCamera()
|
||
|
|
} else {
|
||
|
|
showPermissionRationale(showAsDenied = true)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||
|
|
super.onCreate(savedInstanceState)
|
||
|
|
binding = ActivityBowlingCameraBinding.inflate(layoutInflater)
|
||
|
|
setContentView(binding.root)
|
||
|
|
|
||
|
|
cameraExecutor = Executors.newSingleThreadExecutor()
|
||
|
|
cameraXController = CameraXController(applicationContext, cameraExecutor)
|
||
|
|
|
||
|
|
binding.btnGrantPermissions.setOnClickListener {
|
||
|
|
permissionLauncher.launch(CameraPermissions.REQUIRED)
|
||
|
|
}
|
||
|
|
binding.btnRecordVideo.setOnClickListener { onRecordClicked(RecordMode.VIDEO) }
|
||
|
|
binding.btnRecordWithPose.setOnClickListener { onRecordClicked(RecordMode.POSE) }
|
||
|
|
binding.btnSwitchCamera.setOnClickListener { onSwitchCameraClicked() }
|
||
|
|
binding.btnBack.setOnClickListener { finish() }
|
||
|
|
|
||
|
|
observeViewModel()
|
||
|
|
|
||
|
|
val granted = CameraPermissions.allGranted(this)
|
||
|
|
viewModel.onPermissionsResult(granted)
|
||
|
|
if (granted) {
|
||
|
|
showCameraUi()
|
||
|
|
startCamera()
|
||
|
|
} else {
|
||
|
|
showPermissionRationale(showAsDenied = false)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private fun startCamera() {
|
||
|
|
cameraXController.bindToLifecycle(
|
||
|
|
lifecycleOwner = this,
|
||
|
|
previewView = binding.cameraPreview,
|
||
|
|
callback = this,
|
||
|
|
lensFacing = lensFacing
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
private fun onRecordClicked(mode: RecordMode) {
|
||
|
|
if (cameraXController.isRecording) {
|
||
|
|
cameraXController.stopRecording()
|
||
|
|
} else {
|
||
|
|
activeRecordMode = mode
|
||
|
|
val withPose = mode == RecordMode.POSE
|
||
|
|
cameraXController.setPoseDetectionEnabled(withPose)
|
||
|
|
if (!withPose) {
|
||
|
|
// Clear any skeleton left over from a previous "with pose" recording.
|
||
|
|
binding.poseOverlay.clear()
|
||
|
|
}
|
||
|
|
viewModel.onRecordingStarting()
|
||
|
|
cameraXController.startRecording()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private fun onSwitchCameraClicked() {
|
||
|
|
if (cameraXController.isRecording) {
|
||
|
|
Toast.makeText(this, R.string.switch_camera_while_recording, Toast.LENGTH_SHORT).show()
|
||
|
|
return
|
||
|
|
}
|
||
|
|
lensFacing = if (lensFacing == CameraSelector.LENS_FACING_BACK) {
|
||
|
|
CameraSelector.LENS_FACING_FRONT
|
||
|
|
} else {
|
||
|
|
CameraSelector.LENS_FACING_BACK
|
||
|
|
}
|
||
|
|
// CameraXController.bindToLifecycle() unbinds all use cases before
|
||
|
|
// rebinding, so calling it again with the flipped lensFacing is
|
||
|
|
// enough to switch cameras cleanly.
|
||
|
|
if (CameraPermissions.allGranted(this)) {
|
||
|
|
startCamera()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private fun observeViewModel() {
|
||
|
|
lifecycleScope.launch {
|
||
|
|
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||
|
|
launch {
|
||
|
|
viewModel.recordingState.collect { state -> renderRecordingState(state) }
|
||
|
|
}
|
||
|
|
launch {
|
||
|
|
viewModel.errorEvents.collect { message ->
|
||
|
|
Toast.makeText(this@BowlingCameraActivity, message, Toast.LENGTH_LONG).show()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private fun renderRecordingState(state: CameraViewModel.RecordingState) {
|
||
|
|
// The screen now rotates freely (see res/layout-land/), which
|
||
|
|
// recreates this Activity on rotation - that would orphan an
|
||
|
|
// in-progress recording, since the CameraXController instance
|
||
|
|
// holding the active Recording gets torn down with it. Locking to
|
||
|
|
// whichever orientation we're already in while Starting/Recording
|
||
|
|
// (and releasing the lock once Idle again) keeps recordings from
|
||
|
|
// being interrupted by a rotation mid-shot.
|
||
|
|
requestedOrientation = if (state is CameraViewModel.RecordingState.Idle) {
|
||
|
|
ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||
|
|
} else {
|
||
|
|
ActivityInfo.SCREEN_ORIENTATION_LOCKED
|
||
|
|
}
|
||
|
|
when (state) {
|
||
|
|
is CameraViewModel.RecordingState.Idle -> {
|
||
|
|
activeRecordMode = null
|
||
|
|
binding.layoutRecordingIndicator.visibility = View.GONE
|
||
|
|
binding.btnRecordVideo.isEnabled = true
|
||
|
|
binding.btnRecordVideo.setText(R.string.record_video)
|
||
|
|
binding.btnRecordWithPose.isEnabled = true
|
||
|
|
binding.btnRecordWithPose.setText(R.string.record_with_pose)
|
||
|
|
}
|
||
|
|
is CameraViewModel.RecordingState.Starting -> {
|
||
|
|
// Neither button switches mode mid-flight, and neither can
|
||
|
|
// stop a recording that hasn't started yet.
|
||
|
|
binding.btnRecordVideo.isEnabled = false
|
||
|
|
binding.btnRecordWithPose.isEnabled = false
|
||
|
|
}
|
||
|
|
is CameraViewModel.RecordingState.Recording -> {
|
||
|
|
// Only the button that started this recording stays enabled
|
||
|
|
// (now as the stop trigger); the other is disabled rather
|
||
|
|
// than switching mode mid-recording.
|
||
|
|
val videoActive = activeRecordMode == RecordMode.VIDEO
|
||
|
|
binding.btnRecordVideo.isEnabled = videoActive
|
||
|
|
binding.btnRecordVideo.setText(if (videoActive) R.string.stop_recording else R.string.record_video)
|
||
|
|
binding.btnRecordWithPose.isEnabled = !videoActive
|
||
|
|
binding.btnRecordWithPose.setText(if (!videoActive) R.string.stop_recording else R.string.record_with_pose)
|
||
|
|
binding.layoutRecordingIndicator.visibility = View.VISIBLE
|
||
|
|
val minutes = state.elapsedSeconds / 60
|
||
|
|
val seconds = state.elapsedSeconds % 60
|
||
|
|
binding.textTimer.text = String.format(Locale.US, "%02d:%02d", minutes, seconds)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private fun showCameraUi() {
|
||
|
|
binding.layoutPermissionRationale.visibility = View.GONE
|
||
|
|
}
|
||
|
|
|
||
|
|
private fun showPermissionRationale(showAsDenied: Boolean) {
|
||
|
|
binding.layoutPermissionRationale.visibility = View.VISIBLE
|
||
|
|
binding.textPermissionMessage.setText(
|
||
|
|
if (showAsDenied) R.string.permission_denied_message else R.string.permission_rationale_message
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
// ----- CameraXController.Callback -----
|
||
|
|
|
||
|
|
override fun onCameraReady() {
|
||
|
|
showCameraUi()
|
||
|
|
}
|
||
|
|
|
||
|
|
override fun onCameraError(message: String) {
|
||
|
|
viewModel.postError(getString(R.string.error_camera_unavailable, message))
|
||
|
|
}
|
||
|
|
|
||
|
|
override fun onRecordingStarted() {
|
||
|
|
viewModel.onRecordingStarted()
|
||
|
|
}
|
||
|
|
|
||
|
|
override fun onRecordingFinalized(outputUri: Uri) {
|
||
|
|
viewModel.onRecordingStopped()
|
||
|
|
Toast.makeText(this, outputUri.lastPathSegment ?: outputUri.toString(), Toast.LENGTH_LONG).show()
|
||
|
|
}
|
||
|
|
|
||
|
|
override fun onRecordingError(message: String) {
|
||
|
|
viewModel.postError(getString(R.string.error_recording_failed, message))
|
||
|
|
}
|
||
|
|
|
||
|
|
override fun onPoseDetectorError(message: String) {
|
||
|
|
// Detector hiccups on a single frame shouldn't interrupt an
|
||
|
|
// in-progress recording -- just surface it, don't reset state.
|
||
|
|
Toast.makeText(this, getString(R.string.error_pose_detector, message), Toast.LENGTH_SHORT).show()
|
||
|
|
}
|
||
|
|
|
||
|
|
override fun onPoseResult(result: PoseAnalyzer.PoseFrameResult) {
|
||
|
|
// Safe to touch the view directly here: ML Kit's Task listeners
|
||
|
|
// (see PoseAnalyzer) deliver on the main thread by default even
|
||
|
|
// though inference itself runs on the background camera executor.
|
||
|
|
binding.poseOverlay.update(result)
|
||
|
|
}
|
||
|
|
|
||
|
|
override fun onDestroy() {
|
||
|
|
super.onDestroy()
|
||
|
|
cameraXController.release()
|
||
|
|
cameraExecutor.shutdown()
|
||
|
|
}
|
||
|
|
}
|