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 = Presets.builtIn, val selectedPreset: SessionPreset = Presets.builtIn.first(), val config: SessionConfig = Presets.builtIn.first().toConfig(), // 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 visualLeft: List, val visualRight: List, val audioLeft: List, val audioRight: List, ) private fun timelineProgramSignature(timeline: TimelineEditorState): TimelineProgramSignature = TimelineProgramSignature( durationSec = timeline.durationSec, curveGranularitySec = timeline.curveGranularitySec, 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 = _ui.asStateFlow() private var runJob: Job? = null private var elapsedBeforePauseSec: Float = 0f private var cachedPrograms: List = 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 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, isUserProgram = true, ) } _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), ) } } } } 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), 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), 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, 8 * 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, 8 * 60 * 60)), 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 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 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, 8 * 60 * 60)), 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): 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 create(modelClass: Class): T = MainViewModel(settingsRepository, userProgramRepository, headsetMonitor, audioEngine, billingManager) as T } }