Files
MindMachine/app/src/main/java/com/mindmachine/mvp/session/MainViewModel.kt
2026-04-15 10:26:46 -05:00

521 lines
22 KiB
Kotlin

package solutions.tretter.mindmachine.session
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import solutions.tretter.mindmachine.audio.BinauralAudioEngine
import solutions.tretter.mindmachine.audio.HeadsetMonitor
import solutions.tretter.mindmachine.data.SettingsRepository
import solutions.tretter.mindmachine.data.UserProgramEntity
import solutions.tretter.mindmachine.data.UserProgramRepository
import solutions.tretter.mindmachine.domain.AppSettings
import solutions.tretter.mindmachine.domain.Presets
import solutions.tretter.mindmachine.domain.RuntimeState
import solutions.tretter.mindmachine.domain.SessionConfig
import solutions.tretter.mindmachine.domain.SessionMode
import solutions.tretter.mindmachine.domain.SessionPreset
import solutions.tretter.mindmachine.domain.toConfig
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.util.UUID
const val SAFETY_VERSION = 1
data class UiState(
val settingsInitialized: Boolean = false,
val settings: AppSettings = AppSettings(),
val presets: List<SessionPreset> = Presets.builtIn,
val selectedPreset: SessionPreset = Presets.builtIn.first(),
val config: SessionConfig = Presets.builtIn.first().toConfig(),
// Only user-saved programs (including customizations of built-ins) are deletable.
val deletableProgramIds: Set<String> = emptySet(),
// Timeline program (curves) used for both editing + runtime modulation.
val timeline: TimelineEditorState = TimelineProgramFactory.fromPreset(Presets.builtIn.first()),
val savedProgramName: String = Presets.builtIn.first().name,
val savedTimelineSignature: TimelineProgramSignature = timelineProgramSignature(TimelineProgramFactory.fromPreset(Presets.builtIn.first())),
val hasUnsavedChanges: Boolean = false,
val runtimeState: RuntimeState = RuntimeState.IDLE,
val error: String? = null,
val remainingSec: Int = 0,
val countdownSec: Int = 0,
val endedEarly: Boolean = false,
val interruptionReason: String? = null,
// Current runtime-evaluated parameters (updated while running).
val runtimeParams: RuntimeParams = RuntimeParams(),
)
data class TimelineProgramSignature(
val durationSec: Int,
val curveGranularitySec: Int,
val flashSequence: List<FlashSequenceRow>,
val visualLeft: List<TimelinePoint>,
val visualRight: List<TimelinePoint>,
val audioLeft: List<TimelinePoint>,
val audioRight: List<TimelinePoint>,
)
private fun timelineProgramSignature(timeline: TimelineEditorState): TimelineProgramSignature = TimelineProgramSignature(
durationSec = timeline.durationSec,
curveGranularitySec = timeline.curveGranularitySec,
flashSequence = timeline.flashSequence,
visualLeft = timeline.visualLeft.points,
visualRight = timeline.visualRight.points,
audioLeft = timeline.audioLeft.points,
audioRight = timeline.audioRight.points,
)
private fun isDirty(name: String, timeline: TimelineEditorState, savedName: String, savedTimelineSignature: TimelineProgramSignature): Boolean {
val timelineChanged = timelineProgramSignature(timeline) != savedTimelineSignature
val nameChanged = name.trim() != savedName.trim()
return timelineChanged || nameChanged
}
class MainViewModel(
private val settingsRepository: SettingsRepository,
private val userProgramRepository: UserProgramRepository,
private val headsetMonitor: HeadsetMonitor,
private val audioEngine: BinauralAudioEngine,
private val billingManager: solutions.tretter.mindmachine.billing.BillingManager,
) : ViewModel() {
private val _ui = MutableStateFlow(UiState())
val ui: StateFlow<UiState> = _ui.asStateFlow()
private var runJob: Job? = null
private var elapsedBeforePauseSec: Float = 0f
private var cachedPrograms: List<UserProgramEntity> = emptyList()
init {
billingManager.start()
// One-time first-run defaults.
viewModelScope.launch {
settingsRepository.ensureFirstRunDefaults()
}
viewModelScope.launch {
combine(settingsRepository.settings, userProgramRepository.userPrograms) { settings, userPrograms ->
settings to userPrograms
}.collect { (settings, userPrograms) ->
val userProgramIds = userPrograms.map { it.id }.toSet()
val builtInPrograms = Presets.builtIn.map { preset ->
userPrograms.firstOrNull { it.id == preset.id } ?: UserProgramEntity(
id = preset.id,
name = preset.name,
description = preset.description,
timeline = TimelineProgramFactory.fromPreset(preset),
sortOrder = preset.sortOrder,
)
}
val mergedPrograms = (builtInPrograms + userPrograms.filterNot { program -> Presets.builtIn.any { it.id == program.id } })
.sortedBy { it.sortOrder }
cachedPrograms = mergedPrograms
val allPresets = mergedPrograms.map {
TimelineProgramFactory.toPreset(
id = it.id,
name = it.name,
description = it.description,
timeline = it.timeline,
sortOrder = it.sortOrder,
// Deletable only when the user explicitly saved it (either custom program,
// or a customized built-in stored in the user program store).
isUserProgram = it.id in userProgramIds,
)
}
_ui.update { current ->
val selectedId = settings.lastPresetId ?: current.selectedPreset.id
val selectedPreset = allPresets.find { it.id == selectedId } ?: allPresets.first()
val sameSelection = selectedPreset.id == current.selectedPreset.id
val nextTimeline = if (sameSelection) current.timeline else timelineForProgramId(selectedPreset.id, mergedPrograms)
val savedName = if (sameSelection) current.savedProgramName else selectedPreset.name
val savedSignature = if (sameSelection) current.savedTimelineSignature else timelineProgramSignature(nextTimeline)
current.copy(
settingsInitialized = true,
settings = settings,
presets = allPresets,
selectedPreset = selectedPreset,
config = current.config.copy(mode = SessionMode.AUDIO_VISUAL),
timeline = nextTimeline,
savedProgramName = savedName,
savedTimelineSignature = savedSignature,
hasUnsavedChanges = isDirty(selectedPreset.name, nextTimeline, savedName, savedSignature),
deletableProgramIds = userProgramIds,
)
}
}
}
}
fun acknowledgeSafety() = viewModelScope.launch { settingsRepository.acknowledgeSafety(SAFETY_VERSION) }
fun choosePreset(id: String) {
applyPresetSelection(id)
}
fun startSessionFromProgramSelection(id: String): Boolean {
applyPresetSelection(id)
return startSession()
}
fun createNewProgramDraft() {
val draftId = "user-${UUID.randomUUID()}"
val displayName = nextUntitledName()
val timeline = TimelineEditorState.default(20 * 60)
val preset = TimelineProgramFactory.toPreset(
id = draftId,
name = displayName,
description = "Custom program",
timeline = timeline,
sortOrder = nextSortOrder(),
isUserProgram = true,
)
_ui.update {
val savedSignature = timelineProgramSignature(timeline)
it.copy(
selectedPreset = preset,
config = preset.toConfig(SessionMode.AUDIO_VISUAL).copy(flashSequence = timeline.flashSequence),
timeline = timeline,
savedProgramName = displayName,
savedTimelineSignature = savedSignature,
hasUnsavedChanges = false,
error = null,
)
}
}
fun saveCurrentProgram(name: String) = viewModelScope.launch {
val trimmed = name.trim()
if (trimmed.isBlank()) {
_ui.update { it.copy(error = "Enter a name before saving.") }
return@launch
}
val state = _ui.value
val id = state.selectedPreset.id
val sortOrder = cachedPrograms.find { it.id == id }?.sortOrder ?: nextSortOrder()
userProgramRepository.upsert(
UserProgramEntity(
id = id,
name = trimmed,
description = "Custom program",
timeline = state.timeline,
sortOrder = sortOrder,
)
)
settingsRepository.updateLastPreset(id)
_ui.update {
val savedPreset = TimelineProgramFactory.toPreset(
id = id,
name = trimmed,
description = "Custom program",
timeline = it.timeline,
sortOrder = sortOrder,
isUserProgram = true,
)
val savedSignature = timelineProgramSignature(it.timeline)
it.copy(
selectedPreset = savedPreset,
savedProgramName = trimmed,
savedTimelineSignature = savedSignature,
hasUnsavedChanges = false,
error = "Saved \"$trimmed\"",
)
}
}
fun deleteProgram(id: String) = viewModelScope.launch {
userProgramRepository.delete(id)
if (_ui.value.selectedPreset.id == id) {
val fallback = _ui.value.presets.firstOrNull { it.id != id } ?: Presets.builtIn.first()
settingsRepository.updateLastPreset(fallback.id)
_ui.update {
val timeline = timelineForProgramId(fallback.id, cachedPrograms)
val savedSignature = timelineProgramSignature(timeline)
it.copy(
selectedPreset = fallback,
config = fallback.toConfig(SessionMode.AUDIO_VISUAL).copy(flashSequence = timeline.flashSequence),
timeline = timeline,
savedProgramName = fallback.name,
savedTimelineSignature = savedSignature,
hasUnsavedChanges = false,
error = null,
)
}
}
}
fun setMode(mode: SessionMode) = _ui.update { it.copy(config = it.config.copy(mode = mode), error = null) }
fun setDurationSec(seconds: Int) = _ui.update {
val clamped = seconds.coerceIn(60, 60 * 60)
val nextTimeline = it.timeline.setDurationSec(clamped)
it.copy(
config = it.config.copy(durationSec = clamped),
timeline = nextTimeline,
hasUnsavedChanges = isDirty(it.selectedPreset.name, nextTimeline, it.savedProgramName, it.savedTimelineSignature),
error = null,
)
}
fun setCurveGranularitySec(seconds: Int) = _ui.update {
val nextTimeline = it.timeline.setCurveGranularitySec(seconds)
it.copy(
timeline = nextTimeline,
hasUnsavedChanges = isDirty(it.selectedPreset.name, nextTimeline, it.savedProgramName, it.savedTimelineSignature),
error = null,
)
}
fun updateTimeline(newState: TimelineEditorState) = _ui.update {
it.copy(
timeline = newState,
config = it.config.copy(
durationSec = newState.durationSec.coerceIn(60, 60 * 60),
flashSequence = newState.flashSequence,
),
hasUnsavedChanges = isDirty(it.selectedPreset.name, newState, it.savedProgramName, it.savedTimelineSignature),
error = null,
)
}
fun setFlashIntervalMs(value: Int) = _ui.update { it.copy(config = it.config.copy(flashIntervalMs = value.coerceIn(50, 2000)), error = null) }
fun setMonochromeFlashMode(value: Boolean) = _ui.update { it.copy(config = it.config.copy(monochromeFlashMode = value), error = null) }
fun setCarrier(value: Float) = _ui.update {
val stableCarrier = value.coerceIn(ParameterRanges.CARRIER_HZ_MIN, ParameterRanges.CARRIER_HZ_MAX)
it.copy(config = it.config.copy(carrierFrequencyHz = stableCarrier), error = null)
}
fun setDifference(value: Float) = _ui.update { it.copy(config = it.config.copy(binauralDifferenceHz = value.coerceIn(0.5f, 20f)), error = null) }
fun updateCountdownSeconds(seconds: Int) = viewModelScope.launch { settingsRepository.updateCountdownSeconds(seconds) }
fun updateForceAudioWithoutHeadphones(value: Boolean) = viewModelScope.launch {
settingsRepository.updateForceAudioWithoutHeadphones(value)
}
fun updateShowImmersiveProgressBar(value: Boolean) = viewModelScope.launch { settingsRepository.updateShowImmersiveProgressBar(value) }
fun updatePreferLowestRefreshRate(value: Boolean) = viewModelScope.launch { settingsRepository.updatePreferLowestRefreshRate(value) }
fun updateMonochromeFlashMode(value: Boolean) = viewModelScope.launch { settingsRepository.updateMonochromeFlashMode(value) }
fun updateImmersiveBrightnessPercent(value: Int) = viewModelScope.launch {
settingsRepository.updateImmersiveBrightnessPercent(value)
}
fun startSession(): Boolean {
val state = _ui.value
if (!(state.settings.safetyAcknowledged && state.settings.safetyAcknowledgedVersion >= SAFETY_VERSION)) {
_ui.update { it.copy(error = "You must acknowledge safety before starting sessions.") }
return false
}
val validation = SessionValidator.validate(state.config)
if (validation != null) {
_ui.update { it.copy(error = validation) }
return false
}
elapsedBeforePauseSec = 0f
runProgram(startElapsedSec = 0f, includeCountdown = true)
return true
}
fun pause(reason: String? = null) {
if (_ui.value.runtimeState != RuntimeState.RUNNING) return
elapsedBeforePauseSec = currentElapsedSec()
runJob?.cancel()
audioEngine.stop()
_ui.update {
it.copy(
runtimeState = if (reason == null) RuntimeState.PAUSED else RuntimeState.INTERRUPTED,
interruptionReason = reason,
)
}
}
fun resume() {
val state = _ui.value
if (state.runtimeState != RuntimeState.PAUSED && state.runtimeState != RuntimeState.INTERRUPTED) return
runProgram(startElapsedSec = elapsedBeforePauseSec, includeCountdown = true)
}
fun stop() {
runJob?.cancel()
audioEngine.stop()
elapsedBeforePauseSec = 0f
_ui.update { it.copy(runtimeState = RuntimeState.STOPPED, endedEarly = true) }
}
fun switchToVisualOnlyAndResume() {
_ui.update {
it.copy(
settings = it.settings.copy(forceAudioWithoutHeadphones = false),
error = null,
)
}
viewModelScope.launch { settingsRepository.updateForceAudioWithoutHeadphones(false) }
resume()
}
override fun onCleared() {
audioEngine.stop()
super.onCleared()
}
private fun runProgram(startElapsedSec: Float, includeCountdown: Boolean) {
runJob?.cancel()
runJob = viewModelScope.launch {
val program = _ui.value.timeline
val durationSec = program.durationSec
val clampedStartSec = startElapsedSec.coerceIn(0f, durationSec.toFloat())
if (includeCountdown) {
val count = _ui.value.settings.countdownSeconds.coerceIn(0, 10)
if (count > 0) {
for (i in count downTo 1) {
_ui.update {
it.copy(
runtimeState = RuntimeState.COUNTDOWN,
countdownSec = i,
remainingSec = (durationSec - clampedStartSec.toInt()).coerceAtLeast(0),
endedEarly = false,
interruptionReason = null,
runtimeParams = TimelineRuntimeEvaluator.evaluate(program, clampedStartSec),
)
}
delay(1000)
}
}
}
val initialParams = TimelineRuntimeEvaluator.evaluate(program, clampedStartSec)
_ui.update {
it.copy(
runtimeState = RuntimeState.RUNNING,
remainingSec = (durationSec - clampedStartSec.toInt()).coerceAtLeast(0),
countdownSec = 0,
error = null,
endedEarly = false,
interruptionReason = null,
runtimeParams = initialParams,
)
}
var isAudioPlaying = false
if (shouldPlayAudio(_ui.value.settings, headsetMonitor.isStereoHeadsetAvailable())) {
audioEngine.start(initialParams.carrierHz, initialParams.binauralHz)
isAudioPlaying = true
}
val tStart = System.currentTimeMillis()
while (true) {
delay(50)
val elapsedSec = clampedStartSec + (System.currentTimeMillis() - tStart) / 1000f
elapsedBeforePauseSec = elapsedSec.coerceAtMost(durationSec.toFloat())
val remaining = (durationSec - kotlin.math.ceil(elapsedBeforePauseSec).toInt()).coerceAtLeast(0)
val currentUi = _ui.value
val headsetAvailable = headsetMonitor.isStereoHeadsetAvailable()
val shouldPlayAudioNow = shouldPlayAudio(currentUi.settings, headsetAvailable)
val params = TimelineRuntimeEvaluator.evaluate(program, elapsedBeforePauseSec)
_ui.update { it.copy(runtimeParams = params, remainingSec = remaining) }
if (shouldPlayAudioNow) {
if (!isAudioPlaying) {
audioEngine.start(params.carrierHz, params.binauralHz)
isAudioPlaying = true
} else {
audioEngine.setFrequencies(params.carrierHz, params.binauralHz)
}
} else if (isAudioPlaying) {
audioEngine.stop()
isAudioPlaying = false
}
if (elapsedBeforePauseSec >= durationSec.toFloat()) break
}
audioEngine.stop()
elapsedBeforePauseSec = 0f
_ui.update { it.copy(runtimeState = RuntimeState.COMPLETED, endedEarly = false, remainingSec = 0) }
}
}
private fun shouldPlayAudio(settings: AppSettings, headsetAvailable: Boolean): Boolean {
return settings.forceAudioWithoutHeadphones || headsetAvailable
}
private fun applyPresetSelection(id: String) {
val state = _ui.value
val preset = state.presets.firstOrNull { it.id == id } ?: return
_ui.update {
val cfg = preset.toConfig(SessionMode.AUDIO_VISUAL)
val timeline = timelineForProgramId(preset.id, cachedPrograms)
val savedSignature = timelineProgramSignature(timeline)
it.copy(
selectedPreset = preset,
config = cfg.copy(
durationSec = cfg.durationSec.coerceIn(60, 60 * 60),
flashSequence = timeline.flashSequence,
),
timeline = timeline,
savedProgramName = preset.name,
savedTimelineSignature = savedSignature,
hasUnsavedChanges = false,
error = null,
)
}
elapsedBeforePauseSec = 0f
viewModelScope.launch {
settingsRepository.updateLastPreset(id)
}
}
private fun currentElapsedSec(): Float {
val state = _ui.value
val total = state.timeline.durationSec.toFloat().coerceAtLeast(1f)
return (total - state.remainingSec).coerceIn(0f, total)
}
private fun timelineForProgramId(programId: String, programs: List<UserProgramEntity>): TimelineEditorState {
val fromSaved = programs.firstOrNull { it.id == programId }?.timeline
if (fromSaved != null) return fromSaved
val builtInPreset = Presets.builtIn.firstOrNull { it.id == programId }
return if (builtInPreset != null) TimelineProgramFactory.fromPreset(builtInPreset) else TimelineEditorState.default()
}
private fun nextSortOrder(): Int = (cachedPrograms.maxOfOrNull { it.sortOrder } ?: 999) + 1
private fun nextUntitledName(): String {
val names = cachedPrograms.map { it.name.lowercase() }.toSet()
var i = 1
while (true) {
val candidate = "Custom $i"
if (candidate.lowercase() !in names) return candidate
i++
}
}
class Factory(
private val settingsRepository: SettingsRepository,
private val userProgramRepository: UserProgramRepository,
private val headsetMonitor: HeadsetMonitor,
private val audioEngine: BinauralAudioEngine,
private val billingManager: solutions.tretter.mindmachine.billing.BillingManager,
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T =
MainViewModel(settingsRepository, userProgramRepository, headsetMonitor, audioEngine, billingManager) as T
}
}