feedbackui

can show in recording
This commit is contained in:
2026-09-06 22:31:36 +08:00
parent afb9d800ff
commit de45eab0f5
7 changed files with 225 additions and 2 deletions
@@ -61,6 +61,18 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
// doc. Open only while a recording is in progress.
private lateinit var debugSessionLogger: DebugSessionLogger
// class for FeedbackUI
private lateinit var feedbackUI: FeedbackUI
private val stepLabels = listOf(
R.string.starting_position,
R.string.first_step,
R.string.second_step,
R.string.third_step,
R.string.fourth_step,
R.string.end_position
)
private var currentStepIndex = 0
private val permissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { _ ->
val granted = CameraPermissions.allGranted(this)
@@ -87,7 +99,9 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
cameraExecutor = Executors.newSingleThreadExecutor()
cameraXController = CameraXController(applicationContext, cameraExecutor)
debugSessionLogger = DebugSessionLogger(applicationContext)
feedbackUI = FeedbackUI(this, binding.root)
binding.poseOverlay.attachFeedback(feedbackUI)
binding.btnGrantPermissions.setOnClickListener {
permissionLauncher.launch(CameraPermissions.REQUIRED)
}
@@ -95,6 +109,7 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
binding.switchPose.setOnCheckedChangeListener { _, isChecked -> viewModel.onPoseToggled(isChecked) }
binding.btnSwitchCamera.setOnClickListener { onSwitchCameraClicked() }
binding.btnBack.setOnClickListener { finish() }
binding.btnShowStep.setOnClickListener { onStepIncrease() }
observeViewModel()
@@ -114,7 +129,8 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
lifecycleOwner = this,
previewView = binding.cameraPreview,
callback = this,
lensFacing = lensFacing
lensFacing = lensFacing,
feedbackUi = feedbackUI
)
}
@@ -216,12 +232,16 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
// Pose mode can only be changed between recordings, not
// mid-flight -- see setPoseDetectionEnabled()'s doc comment.
binding.switchPose.isEnabled = true
// Feedback UI - buttons only shown when recording
binding.btnShowStep.isEnabled = false
}
is CameraViewModel.RecordingState.Starting -> {
// 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
binding.btnShowStep.isEnabled = true
binding.btnShowStep.setText(R.string.starting_position)
}
is CameraViewModel.RecordingState.Recording -> {
binding.btnRecord.isEnabled = true
@@ -231,6 +251,7 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
val minutes = state.elapsedSeconds / 60
val seconds = state.elapsedSeconds % 60
binding.textTimer.text = String.format(Locale.US, "%02d:%02d", minutes, seconds)
binding.btnShowStep.isEnabled = true
}
}
}
@@ -347,4 +368,9 @@ class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
cameraExecutor.shutdown()
debugSessionLogger.stop()
}
fun onStepIncrease() {
currentStepIndex = (currentStepIndex + 1) % stepLabels.size
binding.btnShowStep.setText(stepLabels[currentStepIndex])
}
}
@@ -116,6 +116,8 @@ class CameraXController(
}
}
private var feedbackUI: FeedbackUI? = null
/** @brief Whether a video recording is currently in progress. */
val isRecording: Boolean
get() = activeRecording != null
@@ -140,10 +142,12 @@ class CameraXController(
lifecycleOwner: LifecycleOwner,
previewView: PreviewView,
callback: Callback,
lensFacing: Int = CameraSelector.LENS_FACING_BACK
lensFacing: Int = CameraSelector.LENS_FACING_BACK,
feedbackUi: FeedbackUI
) {
this.callback = callback
this.currentLensFacing = lensFacing
this.feedbackUI = feedbackUi
val providerFuture = ProcessCameraProvider.getInstance(appContext)
providerFuture.addListener({
@@ -267,6 +271,8 @@ class CameraXController(
mirror = frame.isMirroring
)
PoseSkeletonRenderer.draw(canvas, poseFrame.landmarks, transform, overlayBonePaint, overlayJointPaint)
feedbackUI?.drawCircles(canvas, poseFrame.landmarks, transform, true)
feedbackUI?.showBanner(canvas, "Body too upright, take a larger 1st step", true)
}
true
}
@@ -0,0 +1,140 @@
package com.example.jnicpp.bowling
import android.text.Layout
import android.text.TextPaint
import android.text.StaticLayout
import android.graphics.Canvas
import android.graphics.RectF
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.content.Context
import android.graphics.Paint
import android.view.View
import android.widget.TextView
import com.example.jnicpp.R
import android.graphics.BlurMaskFilter
import android.graphics.Matrix
import kotlin.math.min
import androidx.constraintlayout.widget.ConstraintLayout
class FeedbackUI(private val context: Context, private val rootView: View) {
private var landmarks: Map<Int, SmoothedLandmark>? = null
private var CIRCLE_RADIUS = 128f
private val uiTextSize = 32f
private val uiStrokeWidth = 12f
private val bannerPaddingY = 24
private val bannerPaddingX = 24
private val camScale = 0.5f
private var liveUiWidth: Int = 0
private var liveUiHeight: Int = 0
private val glowPaint = Paint().apply {
color = Color.RED
style = Paint.Style.STROKE
isAntiAlias = true
strokeWidth = uiStrokeWidth // thicker stroke so glow is visible
maskFilter = BlurMaskFilter(25f, BlurMaskFilter.Blur.OUTER)
}
private val circlePaint = Paint().apply {
color = Color.WHITE
style = Paint.Style.STROKE
isAntiAlias = true
strokeWidth = uiStrokeWidth
}
private val textPaint = TextPaint().apply {
color = Color.WHITE
textSize = uiTextSize
isAntiAlias = true
}
private val bgPaint = Paint().apply {
color = Color.argb(64, 255, 255, 255)
isAntiAlias = true
}
init {
rootView.viewTreeObserver.addOnGlobalLayoutListener {
setLiveUiSize(rootView)
}
}
fun showBanner(canvas: Canvas, message: String, forRecord: Boolean = false) {
val scale = if (forRecord) computeCamScale(canvas) else 1f
val paddingX = bannerPaddingX * scale
val paddingY = bannerPaddingY * scale
val maxWidth = (canvas.width * 0.8f).toInt()
// Build StaticLayout for multi-line text
textPaint.textSize = uiTextSize * scale
val staticLayout = StaticLayout.Builder
.obtain(message, 0, message.length, textPaint, maxWidth)
.setAlignment(Layout.Alignment.ALIGN_CENTER) // center text horizontally
.setLineSpacing(0f, 1f)
.setIncludePad(false)
.build()
// Use StaticLayout dimensions
val textWidth = staticLayout.width.toFloat()
val textHeight = staticLayout.height.toFloat()
val bannerWidth = textWidth + paddingX * 2
val bannerHeight = textHeight + paddingY * 2
// Center horizontally
val left = (canvas.width - bannerWidth) / 2f
val top = 172f * scale
val right = left + bannerWidth
val bottom = top + bannerHeight
// Draw background
val rect = RectF(left, top, right, bottom)
// Scale corner radius too
val cornerRadius = 24f * scale
canvas.drawRoundRect(rect, cornerRadius, cornerRadius, bgPaint)
// Draw text layout inside background
canvas.save()
canvas.translate(left + paddingX, top + paddingY)
staticLayout.draw(canvas)
canvas.restore()
}
// --- Shape overlay (circle) ---
fun drawCircles(canvas: Canvas, landmarks: Map<Int, SmoothedLandmark>, transform: Matrix, forRecord: Boolean = false) {
for (landmark in landmarks.values) {
val point = floatArrayOf(landmark.x, landmark.y)
transform.mapPoints(point)
drawCircle(canvas, point[0], point[1], 0f, forRecord)
}
}
fun drawCircle(canvas: Canvas, x: Float, y: Float, radius: Float = 0f, forRecord: Boolean = false) {
val scale = if (forRecord) camScale else 1f
var rad = (if (radius == 0f) CIRCLE_RADIUS else radius) * scale
circlePaint.strokeWidth = uiStrokeWidth * scale
glowPaint.strokeWidth = uiStrokeWidth * scale
canvas.drawCircle(x, y, rad, circlePaint)
canvas.drawCircle(x, y, rad, glowPaint)
}
// Store live UI size once
fun setLiveUiSize(rootView: View) {
liveUiWidth = rootView.width
liveUiHeight = rootView.height
}
// Compute scale when drawing to recording canvas
private fun computeCamScale(recordCanvas: Canvas): Float {
if (liveUiWidth == 0 || liveUiHeight == 0) return 1f
val scaleX = recordCanvas.width.toFloat() / liveUiWidth.toFloat()
val scaleY = recordCanvas.height.toFloat() / liveUiHeight.toFloat()
return min(scaleX, scaleY)
}
}
@@ -12,6 +12,7 @@ import android.util.AttributeSet
import android.view.View
import androidx.core.content.ContextCompat
import com.example.jnicpp.R
import com.google.mlkit.vision.pose.PoseLandmark // for testing
/**
* @brief Draws the 33 ML Kit pose landmarks and connecting skeleton lines
@@ -63,6 +64,8 @@ class PoseOverlayView @JvmOverloads constructor(
private var transform = Matrix()
private var feedbackUI: FeedbackUI? = null
/**
* @brief Updates the view with the latest analyzer result and triggers a redraw.
* @param frame The latest analyzer result to draw, or null to clear the overlay.
@@ -123,5 +126,25 @@ class PoseOverlayView @JvmOverloads constructor(
val currentLandmarks = landmarks ?: return
PoseSkeletonRenderer.draw(canvas, currentLandmarks, transform, bonePaint, jointPaint)
angles?.let { PoseSkeletonRenderer.drawAngleLabels(canvas, currentLandmarks, it, transform, anglePaint) }
// currentLandmarks to change to landmarks that require highlighting
// test code to contain only left wrist in currentLandmarks to not clutter the screen
val leftWrist = currentLandmarks[PoseLandmark.LEFT_WRIST]
// Build a singleitem map if it exists
val singleLandmark = if (leftWrist != null) {
mapOf(PoseLandmark.LEFT_WRIST to leftWrist)
} else {
emptyMap()
}
// end test code
feedbackUI?.drawCircles(canvas, singleLandmark, transform)
// Trigger feedback advice for this step
feedbackUI?.showBanner(canvas, "Body too upright, take a larger 1st step efsdfdg d dg df gdgdfg df ")
}
fun attachFeedback(feedbackUI: FeedbackUI) {
this.feedbackUI = feedbackUI
}
}
@@ -125,6 +125,17 @@
app:layout_constraintBottom_toTopOf="@id/btn_record"
app:layout_constraintEnd_toEndOf="@id/btn_record" />
<!-- to only show when recording-->
<Button
android:id="@+id/btn_show_step"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/starting_position"
android:layout_marginBottom="32dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<!-- Shown instead of the camera UI when CAMERA/RECORD_AUDIO aren't granted -->
<LinearLayout
android:id="@+id/layout_permission_rationale"
@@ -85,6 +85,17 @@
android:textStyle="bold" />
</LinearLayout>
<!-- to only show when recording-->
<Button
android:id="@+id/btn_show_step"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/starting_position"
android:layout_marginBottom="128dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<!-- Live pose overlay + baked-in-recording toggle. Only togglable while
not recording (see BowlingCameraActivity#renderRecordingState) since
the recording pipeline picks its pose mode once at start. -->
+6
View File
@@ -18,4 +18,10 @@
<string name="error_camera_unavailable">Camera unavailable: %1$s</string>
<string name="error_recording_failed">Recording failed: %1$s</string>
<string name="error_pose_detector">Pose detector error: %1$s</string>
<string name="starting_position">Starting Position</string>
<string name="first_step">1st Step</string>
<string name="second_step">2nd Step</string>
<string name="third_step">3rd Step</string>
<string name="fourth_step">4th Step</string>
<string name="end_position">Ending Position</string>
</resources>