/** * @file CameraViewModel.kt * @brief UI/recording state machine and pose-data buffering for the bowling camera screen. */ package com.example.jnicpp.bowling import android.app.Application import androidx.lifecycle.AndroidViewModel 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 import kotlin.time.Duration.Companion.seconds /** * @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]. */ class CameraViewModel(application: Application) : AndroidViewModel(application) { /** @brief The camera screen's overall recording state. */ sealed interface RecordingState { /** @brief No recording in progress or starting. */ data object Idle : RecordingState /** @brief A recording has been requested but hasn't started writing yet. */ data object Starting : RecordingState /** @brief A recording is actively being written. @param elapsedSeconds Seconds elapsed since recording started. */ data class Recording(val elapsedSeconds: Long) : RecordingState } private val _recordingState = MutableStateFlow(RecordingState.Idle) /** @brief Current recording state, observed by the UI to drive button/timer/indicator visibility. */ val recordingState: StateFlow = _recordingState.asStateFlow() // 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. private val _poseEnabled = MutableStateFlow(value = false) /** @brief Whether pose detection/overlay is currently enabled. */ val poseEnabled: StateFlow = _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(null) /** @brief Joint angles computed for the most recent analyzed frame, or null if none available. */ @Suppress("unused") val poseAngles: StateFlow = _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() /** @brief Time-ordered pose samples buffered for the current/most recent recording session. */ val poseFrames: List get() = stepCountingSession.poseFrames // Live delivery-phase classification (starting stance, approach, etc) private val posePhaseDetector = PosePhaseDetector() private val _posePhase = MutableStateFlow(null) //The bowler's current delivery phase, or null if no phase currently validates. val posePhase: StateFlow = _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(null) //This frame's torso/knee/elbow angle readings, or null if pose detection is off. val poseMetrics: StateFlow = _poseMetrics.asStateFlow() /** @brief Steps detected so far in the current attempt, since the last reset. */ val stepEvents: StateFlow> get() = stepCountingSession.stepEvents // 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(null) /** @brief Live form feedback for the current step, or null if there's nothing to say yet. */ val poseStageFeedback: StateFlow = _poseStageFeedback.asStateFlow() private val _permissionsGranted = MutableStateFlow(value = false) /** @brief Whether all required camera/microphone/storage permissions are currently granted. */ @Suppress("unused") val permissionsGranted: StateFlow = _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(extraBufferCapacity = 4) /** @brief One-shot user-facing error messages, e.g. for a Toast/Snackbar. */ val errorEvents: SharedFlow = _errorEvents.asSharedFlow() private var timerJob: Job? = null /** * @brief Records the result of a runtime permission request. * @param granted true if all required permissions were granted. */ fun onPermissionsResult(granted: Boolean) { _permissionsGranted.value = granted } /** * @brief Toggles live pose detection/overlay on or off. * @param enabled true to enable pose detection/overlay, false to disable. */ fun onPoseToggled(enabled: Boolean) { _poseEnabled.value = enabled if (!enabled) { _poseAngles.value = null posePhaseDetector.reset() _posePhase.value = null _poseMetrics.value = null } } /** * @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, angles: PoseAngles) { _poseAngles.value = angles // Update pose phase detector with this frame's landmarks and angles (independent of step counter) val phaseResult = posePhaseDetector.update(landmarks, angles) _posePhase.value = phaseResult.phase _poseMetrics.value = phaseResult.metrics // Check if the current pose matches the Starting Stance (pure posture query) val isStartingStance = (phaseResult.phase == BowlingPhase.STARTING_STANCE) || posePhaseDetector.isStartingStanceValid(phaseResult.metrics) if (_recordingState.value is RecordingState.Recording) { 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, ) } } /** @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. */ fun onRecordingStarting() { _recordingState.value = RecordingState.Starting stepCountingSession.startNewSession(DetectorSettings.load(getApplication())) /*poseFrameBuffer.clear() ankleHipSmoother.reset() liveStepDetector.reset() _stepEvents.value = emptyList() _poseStageFeedback.value = null*/ } /** @brief Marks a recording as actively writing and starts the elapsed-time timer. */ fun onRecordingStarted() { timerJob?.cancel() timerJob = viewModelScope.launch { var seconds = 0L while (isActive) { _recordingState.value = RecordingState.Recording(seconds) delay(1.seconds) seconds++ } } } /** * @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. */ fun onRecordingStopped() { timerJob?.cancel() timerJob = null _recordingState.value = RecordingState.Idle } /** * @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. */ fun postError(message: String) { _errorEvents.tryEmit(message) if (_recordingState.value != RecordingState.Idle) { onRecordingStopped() } } /** @brief Cancels the elapsed-time timer when this ViewModel is destroyed. */ override fun onCleared() { timerJob?.cancel() } }