Files
PinPoint/app/src/main/java/com/example/jnicpp/bowling/CameraViewModel.kt
T

250 lines
11 KiB
Kotlin
Raw Normal View History

2026-08-14 18:16:55 +08:00
/**
* @file CameraViewModel.kt
* @brief UI/recording state machine and pose-data buffering for the bowling camera screen.
*/
2026-08-11 19:46:54 +08:00
package com.example.jnicpp.bowling
import android.app.Application
import androidx.lifecycle.AndroidViewModel
2026-08-11 19:46:54 +08:00
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
2026-09-12 21:43:10 +08:00
import kotlin.time.Duration.Companion.seconds
2026-08-11 19:46:54 +08:00
/**
2026-08-14 18:16:55 +08:00
* @brief Holds camera/recording UI state so it survives configuration
* changes and so the state machine lives outside the Activity.
*
* This class knows nothing about CameraX or ML Kit APIs directly --
* [BowlingCameraActivity] and [CameraXController] report events into it,
* and the UI observes it back out. That keeps this class trivially
* unit-testable (no Android camera framework involved).
*
* Extends [AndroidViewModel] rather than a plain ViewModel solely to reach
* an Application [android.content.Context] for [DetectorSettings.load] --
* see [onRecordingStarting].
2026-08-11 19:46:54 +08:00
*/
class CameraViewModel(application: Application) : AndroidViewModel(application) {
2026-08-11 19:46:54 +08:00
2026-08-14 18:16:55 +08:00
/** @brief The camera screen's overall recording state. */
2026-08-11 19:46:54 +08:00
sealed interface RecordingState {
2026-08-14 18:16:55 +08:00
/** @brief No recording in progress or starting. */
2026-08-11 19:46:54 +08:00
data object Idle : RecordingState
2026-08-14 18:16:55 +08:00
/** @brief A recording has been requested but hasn't started writing yet. */
2026-08-11 19:46:54 +08:00
data object Starting : RecordingState
2026-08-14 18:16:55 +08:00
/** @brief A recording is actively being written. @param elapsedSeconds Seconds elapsed since recording started. */
2026-08-11 19:46:54 +08:00
data class Recording(val elapsedSeconds: Long) : RecordingState
}
private val _recordingState = MutableStateFlow<RecordingState>(RecordingState.Idle)
2026-08-14 18:16:55 +08:00
/** @brief Current recording state, observed by the UI to drive button/timer/indicator visibility. */
2026-08-11 19:46:54 +08:00
val recordingState: StateFlow<RecordingState> = _recordingState.asStateFlow()
2026-08-12 12:46:33 +08:00
// Whether live pose detection/overlay is on. Only meant to change while
// recordingState is Idle -- the UI disables the toggle otherwise (see
// BowlingCameraActivity#renderRecordingState) since the recording
// pipeline picks its pose mode once at start.
2026-09-12 21:43:10 +08:00
private val _poseEnabled = MutableStateFlow(value = false)
2026-08-14 18:16:55 +08:00
/** @brief Whether pose detection/overlay is currently enabled. */
2026-08-12 12:46:33 +08:00
val poseEnabled: StateFlow<Boolean> = _poseEnabled.asStateFlow()
// Latest per-frame joint angles, for the overlay's angle-label text now
// and for frame-by-frame swing analysis/logging later. Null whenever
// there's no current pose result to derive them from (pose off, no
// frame processed yet, or a frame with no landmarks that met the
// confidence bar -- see PoseAngleCalculator).
private val _poseAngles = MutableStateFlow<PoseAngles?>(null)
2026-08-14 18:16:55 +08:00
/** @brief Joint angles computed for the most recent analyzed frame, or null if none available. */
2026-09-12 21:43:10 +08:00
@Suppress("unused")
2026-08-12 12:46:33 +08:00
val poseAngles: StateFlow<PoseAngles?> = _poseAngles.asStateFlow()
// Pose-frame buffering and live step counting for the current/most
// recent recording session -- see StepCountingSession's class doc for
// why this lives in its own class rather than inline here. Frames only
// flow into it while actually recording (see onPoseFrameUpdated) -- a
// live preview with pose overlay on but not recording doesn't feed it.
private val stepCountingSession = StepCountingSession()
2026-08-14 18:16:55 +08:00
/** @brief Time-ordered pose samples buffered for the current/most recent recording session. */
2026-09-08 11:42:22 +08:00
val poseFrames: List<PoseFrame> get() = stepCountingSession.poseFrames
2026-08-14 18:16:55 +08:00
2026-09-05 22:33:13 +08:00
// Live delivery-phase classification (starting stance, approach, etc)
private val posePhaseDetector = PosePhaseDetector()
private val _posePhase = MutableStateFlow<BowlingPhase?>(null)
//The bowler's current delivery phase, or null if no phase currently validates.
val posePhase: StateFlow<BowlingPhase?> = _posePhase.asStateFlow()
// Raw torso/knee/elbow angle readings behind posePhase above, for
// showing the bowler the actual numbers rather than just a pass/fail
// signal -- see PosePhaseDetector.Metrics.
private val _poseMetrics = MutableStateFlow<PosePhaseDetector.Metrics?>(null)
//This frame's torso/knee/elbow angle readings, or null if pose detection is off.
val poseMetrics: StateFlow<PosePhaseDetector.Metrics?> = _poseMetrics.asStateFlow()
2026-08-14 18:16:55 +08:00
/** @brief Steps detected so far in the current attempt, since the last reset. */
val stepEvents: StateFlow<List<StepEvent>> get() = stepCountingSession.stepEvents
2026-08-14 18:16:55 +08:00
2026-09-12 21:43:10 +08:00
// Live "how's my form right now" cue for whichever step is currently in
// progress -- see PoseStageAdvisor. Recomputed every frame alongside
// stepEvents so it's always tied to the same step count the UI already
// shows, and cleared on the same resets stepEvents is.
private val _poseStageFeedback = MutableStateFlow<String?>(null)
/** @brief Live form feedback for the current step, or null if there's nothing to say yet. */
val poseStageFeedback: StateFlow<String?> = _poseStageFeedback.asStateFlow()
private val _permissionsGranted = MutableStateFlow(value = false)
2026-08-14 18:16:55 +08:00
/** @brief Whether all required camera/microphone/storage permissions are currently granted. */
2026-09-12 21:43:10 +08:00
@Suppress("unused")
2026-08-11 19:46:54 +08:00
val permissionsGranted: StateFlow<Boolean> = _permissionsGranted.asStateFlow()
// One-shot user-facing error messages (camera unavailable, detector
// failure, storage failure, ...). SharedFlow, not StateFlow, so the same
// error doesn't get replayed and re-shown after a config change.
private val _errorEvents = MutableSharedFlow<String>(extraBufferCapacity = 4)
2026-08-14 18:16:55 +08:00
/** @brief One-shot user-facing error messages, e.g. for a Toast/Snackbar. */
2026-08-11 19:46:54 +08:00
val errorEvents: SharedFlow<String> = _errorEvents.asSharedFlow()
private var timerJob: Job? = null
2026-08-14 18:16:55 +08:00
/**
* @brief Records the result of a runtime permission request.
* @param granted true if all required permissions were granted.
*/
2026-08-11 19:46:54 +08:00
fun onPermissionsResult(granted: Boolean) {
_permissionsGranted.value = granted
}
2026-08-14 18:16:55 +08:00
/**
* @brief Toggles live pose detection/overlay on or off.
* @param enabled true to enable pose detection/overlay, false to disable.
*/
2026-08-12 12:46:33 +08:00
fun onPoseToggled(enabled: Boolean) {
_poseEnabled.value = enabled
2026-09-05 22:33:13 +08:00
if (!enabled) {
_poseAngles.value = null
posePhaseDetector.reset()
_posePhase.value = null
_poseMetrics.value = null
}
2026-08-12 12:46:33 +08:00
}
2026-08-14 18:16:55 +08:00
/**
* @brief Reports one analyzed frame's landmarks and joint angles.
*
* Always updates the live angle overlay. Buffering into [poseFrames]
* and live step counting are scoped to an actual recording (see
* [poseFrames]'s doc), so a preview with pose overlay on but not
* recording doesn't silently accumulate frames outside any session.
*
* @param landmarks EMA-smoothed landmarks for this frame, keyed by ML Kit's `PoseLandmark` type constant.
* @param angles Joint angles computed for this same frame.
*/
fun onPoseFrameUpdated(landmarks: Map<Int, SmoothedLandmark>, angles: PoseAngles) {
2026-08-12 12:46:33 +08:00
_poseAngles.value = angles
2026-09-12 21:43:10 +08:00
// Update pose phase detector with this frame's landmarks and angles (independent of step counter)
val phaseResult = posePhaseDetector.update(landmarks, angles)
2026-09-05 22:33:13 +08:00
_posePhase.value = phaseResult.phase
_poseMetrics.value = phaseResult.metrics
2026-09-12 21:43:10 +08:00
// Check if the current pose matches the Starting Stance (pure posture query)
val isStartingStance = (phaseResult.phase == BowlingPhase.STARTING_STANCE)
|| posePhaseDetector.isStartingStanceValid(phaseResult.metrics)
2026-08-14 18:16:55 +08:00
if (_recordingState.value is RecordingState.Recording) {
2026-09-12 21:43:10 +08:00
stepCountingSession.onFrame(
landmarks = landmarks,
angles = angles,
timestampMs = System.currentTimeMillis(),
isStartingPosition = isStartingStance,
)
val currentStepCount = stepEvents.value.size
_poseStageFeedback.value = PoseStageAdvisor.feedback(
stepNumber = currentStepCount.takeIf { it > 0 },
angles = angles,
)
2026-08-14 18:16:55 +08:00
}
2026-08-12 12:46:33 +08:00
}
2026-09-12 21:43:10 +08:00
/** @brief Manually resets step counting state for the current recording session. */
fun resetStepCounter() {
stepCountingSession.resetStepCounter()
_poseStageFeedback.value = null
}
/**
* @brief Marks a recording as being requested and resets all
* per-session buffering/detection state.
*
* Reloads [DetectorSettings] fresh here (rather than once at
* construction) so a tuning change made in [ParameterEditorActivity]
* takes effect on the very next recording, without needing to
* restart this screen.
*/
2026-08-11 19:46:54 +08:00
fun onRecordingStarting() {
_recordingState.value = RecordingState.Starting
stepCountingSession.startNewSession(DetectorSettings.load(getApplication()))
2026-09-12 21:43:10 +08:00
/*poseFrameBuffer.clear()
ankleHipSmoother.reset()
liveStepDetector.reset()
_stepEvents.value = emptyList()
_poseStageFeedback.value = null*/
2026-08-11 19:46:54 +08:00
}
2026-08-14 18:16:55 +08:00
/** @brief Marks a recording as actively writing and starts the elapsed-time timer. */
2026-08-11 19:46:54 +08:00
fun onRecordingStarted() {
timerJob?.cancel()
timerJob = viewModelScope.launch {
var seconds = 0L
while (isActive) {
_recordingState.value = RecordingState.Recording(seconds)
2026-09-12 21:43:10 +08:00
delay(1.seconds)
2026-08-11 19:46:54 +08:00
seconds++
}
}
}
2026-08-14 18:16:55 +08:00
/**
* @brief Marks the current recording as finished and stops the elapsed-time timer.
*
* [stepEvents] is left as whatever [LiveStepDetector] had already
* counted live -- it already reflects the last in-progress attempt's
* steps, and a fresh [StepDetector.detect] batch pass over the whole
* buffer here would ignore any mid-recording resets and overcount
* across separate attempts.
*/
2026-08-11 19:46:54 +08:00
fun onRecordingStopped() {
timerJob?.cancel()
timerJob = null
_recordingState.value = RecordingState.Idle
}
2026-08-14 18:16:55 +08:00
/**
* @brief Surfaces a one-shot error message to the UI, and clears a
* stuck recording indicator if one was in progress.
*
* A failed start/stop shouldn't leave the UI stuck showing a recording
* indicator that no longer reflects reality.
*
* @param message Human-readable error message to surface.
*/
2026-08-11 19:46:54 +08:00
fun postError(message: String) {
_errorEvents.tryEmit(message)
if (_recordingState.value != RecordingState.Idle) {
onRecordingStopped()
}
}
2026-08-14 18:16:55 +08:00
/** @brief Cancels the elapsed-time timer when this ViewModel is destroyed. */
2026-08-11 19:46:54 +08:00
override fun onCleared() {
timerJob?.cancel()
}
}