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

110 lines
4.0 KiB
Kotlin
Raw Normal View History

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()
}
}