Initial commit

This commit is contained in:
2026-08-11 19:46:54 +08:00
commit 8d4535ff00
60 changed files with 2794 additions and 0 deletions
@@ -0,0 +1,242 @@
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()
}
}