503 lines
21 KiB
Kotlin
503 lines
21 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(),
|
|
|
|
// 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<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,
|
|
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 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<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
|
|
}
|
|
}
|