2026-08-11 19:46:54 +08:00
|
|
|
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 {
|
|
|
|
|
|
|
|
|
|
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 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)
|
|
|
|
|
}
|
2026-08-12 12:46:33 +08:00
|
|
|
binding.btnRecord.setOnClickListener { onRecordClicked() }
|
|
|
|
|
binding.switchPose.setOnCheckedChangeListener { _, isChecked -> viewModel.onPoseToggled(isChecked) }
|
2026-08-11 19:46:54 +08:00
|
|
|
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
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-12 12:46:33 +08:00
|
|
|
private fun onRecordClicked() {
|
2026-08-11 19:46:54 +08:00
|
|
|
if (cameraXController.isRecording) {
|
|
|
|
|
cameraXController.stopRecording()
|
|
|
|
|
} else {
|
2026-08-12 12:46:33 +08:00
|
|
|
// Pose mode for this recording is whatever switch_pose is
|
|
|
|
|
// already set to -- see the poseEnabled collector in
|
|
|
|
|
// observeViewModel(), which keeps cameraXController's live pose
|
|
|
|
|
// state in sync with the toggle as it's flipped.
|
2026-08-11 19:46:54 +08:00
|
|
|
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) }
|
|
|
|
|
}
|
2026-08-12 12:46:33 +08:00
|
|
|
launch {
|
|
|
|
|
viewModel.poseEnabled.collect { enabled ->
|
|
|
|
|
// Also drives the switch's checked state (idempotent
|
|
|
|
|
// if the user just flipped it themselves), so it
|
|
|
|
|
// reflects viewModel state after an Activity
|
|
|
|
|
// recreation, e.g. on rotation.
|
|
|
|
|
if (binding.switchPose.isChecked != enabled) {
|
|
|
|
|
binding.switchPose.isChecked = enabled
|
|
|
|
|
}
|
|
|
|
|
cameraXController.setPoseDetectionEnabled(enabled)
|
|
|
|
|
if (!enabled) binding.poseOverlay.clear()
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-11 19:46:54 +08:00
|
|
|
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 -> {
|
|
|
|
|
binding.layoutRecordingIndicator.visibility = View.GONE
|
2026-08-12 12:46:33 +08:00
|
|
|
binding.btnRecord.isEnabled = true
|
|
|
|
|
binding.btnRecord.setText(R.string.record)
|
|
|
|
|
// Pose mode can only be changed between recordings, not
|
|
|
|
|
// mid-flight -- see setPoseDetectionEnabled()'s doc comment.
|
|
|
|
|
binding.switchPose.isEnabled = true
|
2026-08-11 19:46:54 +08:00
|
|
|
}
|
|
|
|
|
is CameraViewModel.RecordingState.Starting -> {
|
2026-08-12 12:46:33 +08:00
|
|
|
// Can't stop a recording that hasn't started yet, and pose
|
|
|
|
|
// mode for it is already locked in.
|
|
|
|
|
binding.btnRecord.isEnabled = false
|
|
|
|
|
binding.switchPose.isEnabled = false
|
2026-08-11 19:46:54 +08:00
|
|
|
}
|
|
|
|
|
is CameraViewModel.RecordingState.Recording -> {
|
2026-08-12 12:46:33 +08:00
|
|
|
binding.btnRecord.isEnabled = true
|
|
|
|
|
binding.btnRecord.setText(R.string.stop_recording)
|
|
|
|
|
binding.switchPose.isEnabled = false
|
2026-08-11 19:46:54 +08:00
|
|
|
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)
|
2026-08-12 12:46:33 +08:00
|
|
|
viewModel.onPoseAnglesUpdated(result.angles)
|
2026-08-11 19:46:54 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
override fun onDestroy() {
|
|
|
|
|
super.onDestroy()
|
|
|
|
|
cameraXController.release()
|
|
|
|
|
cameraExecutor.shutdown()
|
|
|
|
|
}
|
|
|
|
|
}
|