582 lines
25 KiB
Kotlin
582 lines
25 KiB
Kotlin
/**
|
|
* @file CameraXController.kt
|
|
* @brief Owns CameraX use-case binding, recording control, and pose-overlay compositing.
|
|
*/
|
|
package com.example.jnicpp.bowling
|
|
|
|
import android.Manifest
|
|
import android.content.ContentValues
|
|
import android.content.Context
|
|
import android.content.pm.PackageManager
|
|
import android.graphics.Canvas
|
|
import android.graphics.Color
|
|
import android.graphics.Paint
|
|
import android.graphics.PorterDuff
|
|
import android.graphics.RectF
|
|
import android.net.Uri
|
|
import android.os.Build
|
|
import android.os.Environment
|
|
import android.os.Handler
|
|
import android.os.HandlerThread
|
|
import android.provider.MediaStore
|
|
import android.text.TextPaint
|
|
import androidx.camera.core.CameraEffect
|
|
import androidx.camera.core.CameraSelector
|
|
import androidx.camera.core.ImageAnalysis
|
|
import androidx.camera.core.Preview
|
|
import androidx.camera.core.UseCaseGroup
|
|
import androidx.camera.effects.OverlayEffect
|
|
import androidx.camera.lifecycle.ProcessCameraProvider
|
|
import androidx.camera.video.FallbackStrategy
|
|
import androidx.camera.video.MediaStoreOutputOptions
|
|
import androidx.camera.video.Quality
|
|
import androidx.camera.video.QualitySelector
|
|
import androidx.camera.video.Recorder
|
|
import androidx.camera.video.Recording
|
|
import androidx.camera.video.VideoCapture
|
|
import androidx.camera.video.VideoRecordEvent
|
|
import androidx.camera.view.PreviewView
|
|
import androidx.core.content.ContextCompat
|
|
import androidx.lifecycle.LifecycleOwner
|
|
import com.example.jnicpp.R
|
|
import java.text.SimpleDateFormat
|
|
import java.util.Locale
|
|
import java.util.concurrent.Executor
|
|
|
|
/**
|
|
* @brief Owns all CameraX use-case binding and recording control.
|
|
*
|
|
* Deliberately not an Activity/Fragment/View: it only needs a [Context], a
|
|
* [LifecycleOwner] and a couple of Android views handed to it, which keeps
|
|
* the CameraX wiring isolated from Android component lifecycle boilerplate
|
|
* and easy to reason about (and to fake out behind [Callback] in tests that
|
|
* don't need a real camera).
|
|
*
|
|
* @param appContext Application context used for camera/provider/content-resolver access.
|
|
* @param cameraExecutor Background executor the pose analyzer runs inference on.
|
|
*/
|
|
class CameraXController(
|
|
private val appContext: Context,
|
|
private val cameraExecutor: Executor,
|
|
) {
|
|
|
|
/** @brief Callbacks through which [CameraXController] reports camera, recording, and pose-detection events. */
|
|
interface Callback {
|
|
/** @brief Called once the camera use cases are bound and the preview is live. */
|
|
fun onCameraReady() {}
|
|
/** @brief Called if binding the camera use cases fails. @param message Human-readable failure reason. */
|
|
fun onCameraError(message: String)
|
|
/** @brief Called once an in-progress recording has actually started writing. */
|
|
fun onRecordingStarted() {}
|
|
/** @brief Called once a recording finishes successfully. @param outputUri Location of the saved video. */
|
|
fun onRecordingFinalized(outputUri: Uri) {}
|
|
/** @brief Called if starting or finishing a recording fails. @param message Human-readable failure reason. */
|
|
fun onRecordingError(message: String)
|
|
/** @brief Called if the pose detector fails on a given frame. @param message Human-readable failure reason. */
|
|
fun onPoseDetectorError(message: String) {}
|
|
/** @brief Called with each frame's pose detection result while pose detection is enabled. @param result The frame's landmarks, angles, and image metadata. */
|
|
fun onPoseResult(result: PoseAnalyzer.PoseFrameResult)
|
|
}
|
|
|
|
private var cameraProvider: ProcessCameraProvider? = null
|
|
private var videoCapture: VideoCapture<Recorder>? = null
|
|
private var imageAnalysis: ImageAnalysis? = null
|
|
private var poseAnalyzer: PoseAnalyzer? = null
|
|
private var activeRecording: Recording? = null
|
|
private var currentLensFacing: Int = CameraSelector.LENS_FACING_BACK
|
|
private var callback: Callback? = null
|
|
|
|
// Whether the pose analyzer should be attached to the analysis stream,
|
|
// and whether the skeleton should be composited into recorded frames.
|
|
// Survives across bindToLifecycle() calls (e.g. switching cameras) so
|
|
// the mode chosen via setPoseDetectionEnabled() isn't lost on rebind.
|
|
private var poseDetectionEnabled = false
|
|
|
|
// Latest pose result, consumed by the OverlayEffect draw listener (see
|
|
// below) to composite the skeleton into recorded video frames. Read on
|
|
// the effect's GL/handler thread, written on the main thread from
|
|
// PoseAnalyzer's callback -- both just replace the whole reference, so
|
|
// no extra locking is needed.
|
|
@Volatile
|
|
private var latestPoseFrame: PoseAnalyzer.PoseFrameResult? = null
|
|
|
|
data class OverlayState(
|
|
val stepCount: Int = 0,
|
|
val phase: BowlingPhase? = null,
|
|
val stageFeedback: String? = null,
|
|
val metrics: PosePhaseDetector.Metrics? = null,
|
|
)
|
|
|
|
@Volatile
|
|
var recordingOverlayStateProvider: (() -> OverlayState)? = null
|
|
|
|
// Bakes the skeleton and UI into VIDEO_CAPTURE output only (not PREVIEW) --
|
|
// the live preview keeps using PoseOverlayView, which is already proven
|
|
// to draw correctly; this only needs to affect what actually gets
|
|
// encoded into the saved file.
|
|
private var overlayEffect: OverlayEffect? = null
|
|
private var overlayHandlerThread: HandlerThread? = null
|
|
private val overlayBonePaint by lazy {
|
|
Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
color = ContextCompat.getColor(appContext, R.color.skeleton_bone)
|
|
style = Paint.Style.STROKE
|
|
strokeWidth = PoseSkeletonRenderer.STROKE_WIDTH
|
|
}
|
|
}
|
|
private val overlayJointPaint by lazy {
|
|
Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
color = ContextCompat.getColor(appContext, R.color.skeleton_joint)
|
|
style = Paint.Style.FILL
|
|
}
|
|
}
|
|
private val overlayAnglePaint by lazy {
|
|
Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
color = ContextCompat.getColor(appContext, R.color.white)
|
|
textSize = 28f
|
|
style = Paint.Style.FILL
|
|
isFakeBoldText = true
|
|
}
|
|
}
|
|
private val overlayTextPaint by lazy {
|
|
TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
color = ContextCompat.getColor(appContext, R.color.white)
|
|
textSize = 24f
|
|
isFakeBoldText = true
|
|
}
|
|
}
|
|
private val overlayScrimPaint by lazy {
|
|
Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
color = ContextCompat.getColor(appContext, R.color.overlay_scrim)
|
|
style = Paint.Style.FILL
|
|
}
|
|
}
|
|
private val overlayCardBorderPaint by lazy {
|
|
Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
color = ContextCompat.getColor(appContext, R.color.step_counter_accent)
|
|
style = Paint.Style.STROKE
|
|
strokeWidth = 3f
|
|
}
|
|
}
|
|
private val overlayAccentTextPaint by lazy {
|
|
TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
color = ContextCompat.getColor(appContext, R.color.step_counter_accent)
|
|
textSize = 14f
|
|
isFakeBoldText = true
|
|
}
|
|
}
|
|
|
|
private var feedbackUI: FeedbackUI? = null
|
|
|
|
/** @brief Whether a video recording is currently in progress. */
|
|
val isRecording: Boolean
|
|
get() = activeRecording != null
|
|
|
|
/**
|
|
* @brief Binds Preview + VideoCapture (Recorder) + ImageAnalysis to a
|
|
* single camera lifecycle, all at once, so the same camera
|
|
* stream feeds the on-screen preview, the video file being
|
|
* recorded, and the pose detector simultaneously.
|
|
*
|
|
* Also attaches an `OverlayEffect` to the VideoCapture output so
|
|
* [setPoseDetectionEnabled] can bake the skeleton into the recorded
|
|
* file, not just the live preview.
|
|
*
|
|
* @param lifecycleOwner Lifecycle the camera use cases are bound to;
|
|
* they're torn down automatically when it's destroyed.
|
|
* @param previewView View the live camera preview is drawn into.
|
|
* @param callback Receives camera/recording/pose-detection events from this point on.
|
|
* @param lensFacing Which physical camera to bind, e.g. `CameraSelector.LENS_FACING_BACK`.
|
|
*/
|
|
fun bindToLifecycle(
|
|
lifecycleOwner: LifecycleOwner,
|
|
previewView: PreviewView,
|
|
callback: Callback,
|
|
lensFacing: Int = CameraSelector.LENS_FACING_BACK,
|
|
feedbackUi: FeedbackUI,
|
|
) {
|
|
this.callback = callback
|
|
this.currentLensFacing = lensFacing
|
|
this.feedbackUI = feedbackUi
|
|
|
|
val providerFuture = ProcessCameraProvider.getInstance(appContext)
|
|
providerFuture.addListener(
|
|
{
|
|
try {
|
|
val provider = providerFuture.get()
|
|
cameraProvider = provider
|
|
|
|
val preview = Preview.Builder().build().also {
|
|
it.surfaceProvider = previewView.surfaceProvider
|
|
}
|
|
|
|
val recorder = Recorder.Builder()
|
|
.setQualitySelector(
|
|
QualitySelector.from(
|
|
Quality.FHD,
|
|
FallbackStrategy.higherQualityOrLowerThan(Quality.SD)
|
|
)
|
|
)
|
|
.build()
|
|
val videoCapture = VideoCapture.withOutput(recorder)
|
|
this.videoCapture = videoCapture
|
|
|
|
poseAnalyzer?.close()
|
|
val analyzer = PoseAnalyzer(
|
|
isFrontCamera = { currentLensFacing == CameraSelector.LENS_FACING_FRONT },
|
|
onResult = { result ->
|
|
// Inference is async (see PoseAnalyzer): a call already
|
|
// in flight when setPoseDetectionEnabled(false) runs
|
|
// clearAnalyzer() can still land here afterward. Drop
|
|
// it, or it redraws a stale skeleton right after the
|
|
// live overlay was cleared.
|
|
if (poseDetectionEnabled) {
|
|
latestPoseFrame = result
|
|
callback.onPoseResult(result)
|
|
}
|
|
},
|
|
onError = { e -> callback.onPoseDetectorError(e.message ?: "Pose detector error") },
|
|
)
|
|
poseAnalyzer = analyzer
|
|
|
|
// Analyzer is deliberately not attached here -- whether it's
|
|
// attached at all is controlled by setPoseDetectionEnabled()
|
|
// below, so the analysis stream stays idle (no inference
|
|
// cost) whenever pose detection isn't the active mode.
|
|
val imageAnalysis = ImageAnalysis.Builder()
|
|
// We only ever care about the freshest frame; if the
|
|
// detector falls behind, drop stale frames instead of
|
|
// queueing them, so pose overlay latency can't grow
|
|
// unbounded relative to what's on screen.
|
|
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
|
.build()
|
|
this.imageAnalysis = imageAnalysis
|
|
|
|
val cameraSelector = CameraSelector.Builder()
|
|
.requireLensFacing(lensFacing)
|
|
.build()
|
|
|
|
val useCaseGroup = UseCaseGroup.Builder()
|
|
.addUseCase(preview)
|
|
.addUseCase(videoCapture)
|
|
.addUseCase(imageAnalysis)
|
|
.addEffect(getOrCreateOverlayEffect())
|
|
.build()
|
|
|
|
provider.unbindAll()
|
|
provider.bindToLifecycle(lifecycleOwner, cameraSelector, useCaseGroup)
|
|
applyPoseDetectionEnabled()
|
|
callback.onCameraReady()
|
|
} catch (e: Exception) {
|
|
callback.onCameraError(e.message ?: "Camera unavailable")
|
|
}
|
|
}, ContextCompat.getMainExecutor(appContext))
|
|
}
|
|
|
|
/**
|
|
* @brief Returns the lazily-created [OverlayEffect] that composites the
|
|
* pose skeleton into recorded video frames, creating it (and its
|
|
* backing [HandlerThread]) on first use.
|
|
* @return The overlay effect to attach to the VideoCapture use case.
|
|
*/
|
|
private fun getOrCreateOverlayEffect(): OverlayEffect {
|
|
overlayEffect?.let { return it }
|
|
|
|
val handlerThread = HandlerThread("PoseOverlayEffect").apply { start() }
|
|
overlayHandlerThread = handlerThread
|
|
|
|
val effect = OverlayEffect(
|
|
CameraEffect.VIDEO_CAPTURE,
|
|
/* queueDepth = */ 2,
|
|
Handler(handlerThread.looper)
|
|
) { throwable ->
|
|
callback?.onPoseDetectorError(throwable.message ?: "Pose overlay compositing error")
|
|
}
|
|
effect.setOnDrawListener { frame ->
|
|
val poseFrame = latestPoseFrame
|
|
val canvas = frame.overlayCanvas
|
|
// Always clear first: with no pose mode active (or no pose
|
|
// result yet), this leaves the canvas fully transparent, so the
|
|
// recorded frame passes through untouched.
|
|
canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
|
|
if ((poseDetectionEnabled && poseFrame != null)) {
|
|
val frameSize = frame.size
|
|
// Mirrors the same raw-buffer-dimensions-plus-rotation-degrees
|
|
// convention CameraX uses for ImageAnalysis/ImageProxy (see
|
|
// PoseSkeletonRenderer.computeTransform's source-side handling).
|
|
val targetWidth: Int
|
|
val targetHeight: Int
|
|
if (frame.rotationDegrees == 90 || frame.rotationDegrees == 270) {
|
|
targetWidth = frameSize.height
|
|
targetHeight = frameSize.width
|
|
} else {
|
|
targetWidth = frameSize.width
|
|
targetHeight = frameSize.height
|
|
}
|
|
val transform = PoseSkeletonRenderer.computeTransform(
|
|
sourceWidth = poseFrame.imageWidth,
|
|
sourceHeight = poseFrame.imageHeight,
|
|
sourceRotationDegrees = poseFrame.rotationDegrees,
|
|
targetWidth = targetWidth,
|
|
targetHeight = targetHeight,
|
|
mirror = frame.isMirroring
|
|
)
|
|
PoseSkeletonRenderer.draw(canvas, poseFrame.landmarks, transform, overlayBonePaint, overlayJointPaint)
|
|
PoseSkeletonRenderer.drawAngleLabels(canvas, poseFrame.landmarks, poseFrame.angles, transform, overlayAnglePaint)
|
|
|
|
val state = recordingOverlayStateProvider?.invoke()
|
|
val scale = feedbackUI?.computeCamScale(canvas) ?: (minOf(targetWidth, targetHeight) / 1080f)
|
|
|
|
// 1. Step Counter Card (Top Center)
|
|
val stepCount = state?.stepCount ?: 0
|
|
drawStepCounterOverlay(canvas, stepCount, scale)
|
|
|
|
// 2. Delivery Phase Badge (Top Right)
|
|
state?.phase?.let { phase ->
|
|
drawPhaseBadgeOverlay(canvas, phase, scale)
|
|
}
|
|
|
|
// 3. Body Angle Metrics Readout (Bottom Left)
|
|
state?.metrics?.let { metrics ->
|
|
drawMetricsOverlay(canvas, metrics, scale)
|
|
}
|
|
|
|
// 4. Live Coaching Tip Banner (Center below Step Counter)
|
|
val feedback = state?.stageFeedback
|
|
if (!feedback.isNullOrBlank()) {
|
|
feedbackUI?.showBanner(canvas, feedback, forRecord = true)
|
|
}
|
|
}
|
|
true
|
|
}
|
|
overlayEffect = effect
|
|
return effect
|
|
}
|
|
|
|
private fun drawStepCounterOverlay(canvas: Canvas, stepCount: Int, scale: Float) {
|
|
val numberText = stepCount.toString()
|
|
val labelText = "STEPS"
|
|
|
|
val numPaint = overlayTextPaint.apply { textSize = 32f * scale }
|
|
val labelPaint = overlayAccentTextPaint.apply {
|
|
textSize = 11f * scale
|
|
strokeWidth = 0f
|
|
style = Paint.Style.FILL
|
|
}
|
|
|
|
val numWidth = numPaint.measureText(numberText)
|
|
val labelWidth = labelPaint.measureText(labelText)
|
|
val contentWidth = maxOf(numWidth, labelWidth)
|
|
|
|
val paddingX = 20f * scale
|
|
val paddingY = 6f * scale
|
|
val cardWidth = contentWidth + (paddingX * 2f)
|
|
val cardHeight = (32f * scale) + (11f * scale) + (paddingY * 2f)
|
|
|
|
val left = (canvas.width - cardWidth) / 2f
|
|
val top = 104f * scale
|
|
val right = left + cardWidth
|
|
val bottom = top + cardHeight
|
|
|
|
val rect = RectF(left, top, right, bottom)
|
|
val radius = 16f * scale
|
|
|
|
// Draw card background
|
|
canvas.drawRoundRect(rect, radius, radius, overlayScrimPaint)
|
|
|
|
// Draw card border
|
|
overlayCardBorderPaint.strokeWidth = 2f * scale
|
|
canvas.drawRoundRect(rect, radius, radius, overlayCardBorderPaint)
|
|
|
|
// Draw big number text (centered)
|
|
val numX = left + ((cardWidth - numWidth) / 2f)
|
|
val numY = top + paddingY + (28f * scale)
|
|
canvas.drawText(numberText, numX, numY, numPaint)
|
|
|
|
// Draw "STEPS" label text (centered below number)
|
|
val labelX = left + ((cardWidth - labelWidth) / 2f)
|
|
val labelY = numY + (14f * scale)
|
|
canvas.drawText(labelText, labelX, labelY, labelPaint)
|
|
}
|
|
|
|
private fun drawPhaseBadgeOverlay(canvas: Canvas, phase: BowlingPhase, scale: Float) {
|
|
val label = appContext.getString(
|
|
when (phase) {
|
|
BowlingPhase.STARTING_STANCE -> R.string.pose_phase_starting_stance
|
|
BowlingPhase.APPROACH -> R.string.pose_phase_approach
|
|
BowlingPhase.PUSHAWAY -> R.string.pose_phase_pushaway
|
|
BowlingPhase.BACK_SWING -> R.string.pose_phase_back_swing
|
|
BowlingPhase.POWER_STEP -> R.string.pose_phase_power_step
|
|
BowlingPhase.SLIDE_AND_RELEASE -> R.string.pose_phase_slide_and_release
|
|
}
|
|
)
|
|
val colorRes = when (phase) {
|
|
BowlingPhase.STARTING_STANCE -> R.color.Starting_stance_ready
|
|
BowlingPhase.APPROACH -> R.color.Approach_ready
|
|
BowlingPhase.PUSHAWAY -> R.color.Pushaway_ready
|
|
BowlingPhase.BACK_SWING -> R.color.Back_swing_ready
|
|
BowlingPhase.POWER_STEP -> R.color.Power_step_ready
|
|
BowlingPhase.SLIDE_AND_RELEASE -> R.color.Slide_and_release_ready
|
|
}
|
|
val textPaint = overlayTextPaint.apply { textSize = 14f * scale }
|
|
val textWidth = textPaint.measureText(label)
|
|
val paddingX = 10f * scale
|
|
val paddingY = 4f * scale
|
|
val badgeWidth = textWidth + (paddingX * 2f)
|
|
val badgeHeight = (14f * scale) + (paddingY * 2f)
|
|
|
|
val right = canvas.width - (16f * scale)
|
|
val left = right - badgeWidth
|
|
val top = 112f * scale
|
|
val bottom = top + badgeHeight
|
|
|
|
val badgePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
color = ContextCompat.getColor(appContext, colorRes)
|
|
style = Paint.Style.FILL
|
|
}
|
|
|
|
val rect = RectF(left, top, right, bottom)
|
|
canvas.drawRoundRect(rect, 10f * scale, 10f * scale, badgePaint)
|
|
canvas.drawText(label, left + paddingX, top + paddingY + (12f * scale), textPaint)
|
|
}
|
|
|
|
private fun drawMetricsOverlay(canvas: Canvas, metrics: PosePhaseDetector.Metrics, scale: Float) {
|
|
fun angleText(degrees: Float?): String = if (degrees == null) "--" else "${degrees.toInt()}°"
|
|
val line1 = "Torso: ${angleText(metrics.torsoTiltDegrees)} · Knee L: ${angleText(metrics.leftKneeAngleDegrees)} R: ${angleText(metrics.rightKneeAngleDegrees)}"
|
|
val line2 = "Elbow L: ${angleText(metrics.leftElbowAngleDegrees)} R: ${angleText(metrics.rightElbowAngleDegrees)}"
|
|
|
|
val textPaint = overlayTextPaint.apply { textSize = 12f * scale }
|
|
val w1 = textPaint.measureText(line1)
|
|
val w2 = textPaint.measureText(line2)
|
|
val maxWidth = maxOf(w1, w2)
|
|
val paddingX = 8f * scale
|
|
val paddingY = 4f * scale
|
|
val cardWidth = maxWidth + (paddingX * 2f)
|
|
val cardHeight = (12f * 2f * scale) + (paddingY * 2f) + (4f * scale)
|
|
|
|
val left = 16f * scale
|
|
val bottom = canvas.height - (80f * scale)
|
|
val top = bottom - cardHeight
|
|
val right = left + cardWidth
|
|
|
|
val rect = RectF(left, top, right, bottom)
|
|
canvas.drawRoundRect(rect, 8f * scale, 8f * scale, overlayScrimPaint)
|
|
canvas.drawText(line1, left + paddingX, top + paddingY + (11f * scale), textPaint)
|
|
canvas.drawText(line2, left + paddingX, top + paddingY + (11f * 2f * scale) + (4f * scale), textPaint)
|
|
}
|
|
|
|
/**
|
|
* @brief Attaches/detaches the pose analyzer from the analysis stream,
|
|
* and turns skeleton compositing into the recorded video on/off,
|
|
* without needing a full unbind/rebind of the camera use cases.
|
|
*
|
|
* Cheap and synchronous, so it's safe to call live from a preview-time
|
|
* toggle; whatever this is set to when [startRecording] is called
|
|
* becomes that recording's pose mode (plain video vs. with pose baked
|
|
* in) and stays fixed for its duration -- callers are expected to stop
|
|
* offering the toggle while a recording is in progress.
|
|
*
|
|
* @param enabled true to run pose detection and draw/bake the skeleton, false to stop.
|
|
*/
|
|
fun setPoseDetectionEnabled(enabled: Boolean) {
|
|
poseDetectionEnabled = enabled
|
|
if (!enabled) {
|
|
latestPoseFrame = null
|
|
}
|
|
applyPoseDetectionEnabled()
|
|
}
|
|
|
|
/** @brief Attaches or clears the pose analyzer on [imageAnalysis] to match [poseDetectionEnabled]. */
|
|
private fun applyPoseDetectionEnabled() {
|
|
val analysis = imageAnalysis ?: return
|
|
val analyzer = poseAnalyzer ?: return
|
|
if (poseDetectionEnabled) {
|
|
analysis.setAnalyzer(cameraExecutor, analyzer)
|
|
} else {
|
|
analysis.clearAnalyzer()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @brief Starts recording video (with audio, if permission is granted)
|
|
* to a new file in the shared Movies/bowling collection.
|
|
*
|
|
* No-op if the camera isn't bound yet or a recording is already in
|
|
* progress. Reports [Callback.onRecordingStarted]/[Callback.onRecordingFinalized]/
|
|
* [Callback.onRecordingError] asynchronously as the recording progresses.
|
|
*/
|
|
fun startRecording() {
|
|
val cb = callback ?: return
|
|
val capture = videoCapture
|
|
if (capture == null) {
|
|
cb.onRecordingError("Camera is not ready yet")
|
|
return
|
|
}
|
|
if (activeRecording != null) return
|
|
|
|
try {
|
|
val fileName = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(java.util.Date())
|
|
val contentValues = ContentValues().apply {
|
|
put(MediaStore.Video.Media.DISPLAY_NAME, "bowling_$fileName.mp4")
|
|
put(MediaStore.Video.Media.MIME_TYPE, "video/mp4")
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
// Public Movies/bowling collection so the recording shows up in the
|
|
// Gallery/Photos app rather than app-private storage.
|
|
put(MediaStore.Video.Media.RELATIVE_PATH, "${Environment.DIRECTORY_MOVIES}/bowling")
|
|
}
|
|
}
|
|
val outputOptions = MediaStoreOutputOptions.Builder(
|
|
appContext.contentResolver,
|
|
MediaStore.Video.Media.EXTERNAL_CONTENT_URI
|
|
)
|
|
.setContentValues(contentValues)
|
|
.build()
|
|
|
|
var pendingRecording = capture.output.prepareRecording(appContext, outputOptions)
|
|
if (ContextCompat.checkSelfPermission(appContext, Manifest.permission.RECORD_AUDIO)
|
|
== PackageManager.PERMISSION_GRANTED
|
|
) {
|
|
pendingRecording = pendingRecording.withAudioEnabled()
|
|
}
|
|
|
|
activeRecording = pendingRecording.start(ContextCompat.getMainExecutor(appContext)) { event ->
|
|
when (event) {
|
|
is VideoRecordEvent.Start -> cb.onRecordingStarted()
|
|
is VideoRecordEvent.Finalize -> {
|
|
activeRecording = null
|
|
if (event.hasError()) {
|
|
cb.onRecordingError(
|
|
event.cause?.message ?: "Recording error code=${event.error}"
|
|
)
|
|
} else {
|
|
cb.onRecordingFinalized(event.outputResults.outputUri)
|
|
}
|
|
}
|
|
else -> Unit
|
|
}
|
|
}
|
|
} catch (e: Exception) {
|
|
cb.onRecordingError(e.message ?: "Failed to start recording")
|
|
}
|
|
}
|
|
|
|
/** @brief Stops the in-progress recording, if any; [Callback.onRecordingFinalized] follows asynchronously. */
|
|
fun stopRecording() {
|
|
activeRecording?.stop()
|
|
activeRecording = null
|
|
}
|
|
|
|
/** @brief Unbinds all use cases and releases the pose detector. Call from onDestroy. */
|
|
fun release() {
|
|
activeRecording?.stop()
|
|
activeRecording = null
|
|
cameraProvider?.unbindAll()
|
|
poseAnalyzer?.close()
|
|
poseAnalyzer = null
|
|
overlayEffect?.close()
|
|
overlayEffect = null
|
|
overlayHandlerThread?.quitSafely()
|
|
overlayHandlerThread = null
|
|
callback = null
|
|
}
|
|
}
|