2026-08-11 19:46:54 +08:00
|
|
|
package com.example.jnicpp.bowling
|
|
|
|
|
|
|
|
|
|
import androidx.lifecycle.ViewModel
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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).
|
|
|
|
|
*/
|
|
|
|
|
class CameraViewModel : ViewModel() {
|
|
|
|
|
|
|
|
|
|
sealed interface RecordingState {
|
|
|
|
|
data object Idle : RecordingState
|
|
|
|
|
data object Starting : RecordingState
|
|
|
|
|
data class Recording(val elapsedSeconds: Long) : RecordingState
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private val _recordingState = MutableStateFlow<RecordingState>(RecordingState.Idle)
|
|
|
|
|
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.
|
|
|
|
|
private val _poseEnabled = MutableStateFlow(false)
|
|
|
|
|
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)
|
|
|
|
|
val poseAngles: StateFlow<PoseAngles?> = _poseAngles.asStateFlow()
|
|
|
|
|
|
2026-08-11 19:46:54 +08:00
|
|
|
private val _permissionsGranted = MutableStateFlow(false)
|
|
|
|
|
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)
|
|
|
|
|
val errorEvents: SharedFlow<String> = _errorEvents.asSharedFlow()
|
|
|
|
|
|
|
|
|
|
private var timerJob: Job? = null
|
|
|
|
|
|
|
|
|
|
fun onPermissionsResult(granted: Boolean) {
|
|
|
|
|
_permissionsGranted.value = granted
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-12 12:46:33 +08:00
|
|
|
fun onPoseToggled(enabled: Boolean) {
|
|
|
|
|
_poseEnabled.value = enabled
|
|
|
|
|
if (!enabled) _poseAngles.value = null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fun onPoseAnglesUpdated(angles: PoseAngles) {
|
|
|
|
|
_poseAngles.value = angles
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-11 19:46:54 +08:00
|
|
|
fun onRecordingStarting() {
|
|
|
|
|
_recordingState.value = RecordingState.Starting
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fun onRecordingStarted() {
|
|
|
|
|
timerJob?.cancel()
|
|
|
|
|
timerJob = viewModelScope.launch {
|
|
|
|
|
var seconds = 0L
|
|
|
|
|
while (isActive) {
|
|
|
|
|
_recordingState.value = RecordingState.Recording(seconds)
|
|
|
|
|
delay(1000)
|
|
|
|
|
seconds++
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fun onRecordingStopped() {
|
|
|
|
|
timerJob?.cancel()
|
|
|
|
|
timerJob = null
|
|
|
|
|
_recordingState.value = RecordingState.Idle
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fun postError(message: String) {
|
|
|
|
|
_errorEvents.tryEmit(message)
|
|
|
|
|
// A failed start/stop shouldn't leave the UI stuck showing a
|
|
|
|
|
// recording indicator that no longer reflects reality.
|
|
|
|
|
if (_recordingState.value != RecordingState.Idle) {
|
|
|
|
|
onRecordingStopped()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
override fun onCleared() {
|
|
|
|
|
super.onCleared()
|
|
|
|
|
timerJob?.cancel()
|
|
|
|
|
}
|
|
|
|
|
}
|