Auto commit Sun Mar 22 01:01:14 AM CDT 2026
This commit is contained in:
@@ -79,7 +79,6 @@ import com.mindmachine.mvp.audio.HeadsetMonitor
|
||||
import com.mindmachine.mvp.data.SettingsRepository
|
||||
import com.mindmachine.mvp.data.UserProgramRepository
|
||||
import com.mindmachine.mvp.domain.RuntimeState
|
||||
import com.mindmachine.mvp.domain.SessionMode
|
||||
import com.mindmachine.mvp.session.FlashColor
|
||||
import com.mindmachine.mvp.session.MainViewModel
|
||||
import com.mindmachine.mvp.session.SplitFlashFrame
|
||||
@@ -144,6 +143,15 @@ fun App(vm: MainViewModel = viewModel()) {
|
||||
onDispose { lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
if (!ui.settingsInitialized) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val startRoute = if (ui.settings.safetyAcknowledged) "home" else "welcome"
|
||||
NavHost(navController = nav, startDestination = startRoute, modifier = Modifier.background(MaterialTheme.colorScheme.background)) {
|
||||
composable("welcome") {
|
||||
@@ -235,10 +243,10 @@ fun App(vm: MainViewModel = viewModel()) {
|
||||
}
|
||||
}
|
||||
composable("settings") {
|
||||
SettingsScreen(vm) { nav.popBackStack() }
|
||||
SettingsScreen(vm)
|
||||
}
|
||||
composable("holder") {
|
||||
HolderScreen { nav.popBackStack() }
|
||||
HolderScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -281,7 +289,6 @@ fun SetupScreen(
|
||||
onHolder: () -> Unit,
|
||||
) {
|
||||
val ui by vm.ui.collectAsStateWithLifecycle()
|
||||
val cfg = ui.config
|
||||
val scrollState = rememberScrollState()
|
||||
val containerModifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -292,6 +299,12 @@ fun SetupScreen(
|
||||
|
||||
var programName by remember(ui.selectedPreset.id) { mutableStateOf(ui.selectedPreset.name) }
|
||||
|
||||
val hasUnsavedChanges = setupScreenHasUnsavedChanges(
|
||||
hasUnsavedTimelineChanges = ui.hasUnsavedChanges,
|
||||
editedProgramName = programName,
|
||||
savedProgramName = ui.savedProgramName,
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = containerModifier,
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
@@ -323,10 +336,13 @@ fun SetupScreen(
|
||||
|
||||
TextButton(onClick = onHolder) { Text("Holder Guidance") }
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
OutlinedButton(onClick = { vm.saveCurrentProgram(programName) }, modifier = Modifier.weight(1f).height(52.dp)) {
|
||||
Text("Save")
|
||||
if (hasUnsavedChanges) {
|
||||
OutlinedButton(onClick = { vm.saveCurrentProgram(programName) }, modifier = Modifier.weight(1f).height(52.dp)) {
|
||||
Text("Save")
|
||||
}
|
||||
} else {
|
||||
Button(onClick = onStart, modifier = Modifier.weight(1f).height(52.dp)) { Text("Start") }
|
||||
}
|
||||
Button(onClick = onStart, modifier = Modifier.weight(1f).height(52.dp)) { Text("Start") }
|
||||
}
|
||||
if (ui.error != null) Text(ui.error!!, color = if (ui.error!!.startsWith("Saved")) Color(0xFF72E39A) else MaterialTheme.colorScheme.error)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
@@ -336,6 +352,15 @@ fun SetupScreen(
|
||||
internal fun setupScreenFlashIntervalLabel(flashIntervalMs: Int): String =
|
||||
"Flash interval: ${"%.2f".format(flashIntervalMsToSeconds(flashIntervalMs))} s"
|
||||
|
||||
internal fun setupScreenHasUnsavedChanges(
|
||||
hasUnsavedTimelineChanges: Boolean,
|
||||
editedProgramName: String,
|
||||
savedProgramName: String,
|
||||
): Boolean {
|
||||
val nameChanged = editedProgramName.trim() != savedProgramName.trim()
|
||||
return hasUnsavedTimelineChanges || nameChanged
|
||||
}
|
||||
|
||||
internal fun setupScreenUsesScrollableContainer(): Boolean = true
|
||||
|
||||
@Composable
|
||||
@@ -348,7 +373,7 @@ fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit, onBackToSetup:
|
||||
onBackToSetup()
|
||||
}
|
||||
|
||||
val isVisualMode = ui.config.mode != SessionMode.AUDIO_ONLY
|
||||
val isVisualMode = true
|
||||
val context = LocalContext.current
|
||||
DisposableEffect(isVisualMode) {
|
||||
val activity = context as? Activity
|
||||
@@ -376,7 +401,6 @@ fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit, onBackToSetup:
|
||||
}
|
||||
val flashFrame by rememberSplitFlashFrame(
|
||||
isRunning = ui.runtimeState == RuntimeState.RUNNING,
|
||||
mode = ui.config.mode,
|
||||
flashOnMs = ui.runtimeParams.flashOnMs,
|
||||
flashOffMs = ui.runtimeParams.flashOffMs,
|
||||
)
|
||||
@@ -506,15 +530,14 @@ private fun FlashColor.toComposeColor(): Color = when (this) {
|
||||
@Composable
|
||||
private fun rememberSplitFlashFrame(
|
||||
isRunning: Boolean,
|
||||
mode: SessionMode,
|
||||
flashOnMs: Int,
|
||||
flashOffMs: Int,
|
||||
): androidx.compose.runtime.State<SplitFlashFrame> {
|
||||
val latestFlashOnMs = rememberUpdatedState(flashOnMs)
|
||||
val latestFlashOffMs = rememberUpdatedState(flashOffMs)
|
||||
|
||||
return produceState(initialValue = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK), isRunning, mode) {
|
||||
if (!isRunning || mode == SessionMode.AUDIO_ONLY) {
|
||||
return produceState(initialValue = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK), isRunning) {
|
||||
if (!isRunning) {
|
||||
value = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK)
|
||||
return@produceState
|
||||
}
|
||||
@@ -535,7 +558,7 @@ private fun rememberSplitFlashFrame(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) {
|
||||
fun SettingsScreen(vm: MainViewModel) {
|
||||
val ui by vm.ui.collectAsStateWithLifecycle()
|
||||
Column(
|
||||
Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).padding(16.dp),
|
||||
@@ -551,16 +574,12 @@ fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) {
|
||||
steps = 9,
|
||||
)
|
||||
|
||||
Text("Default mode", color = MaterialTheme.colorScheme.onBackground)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SessionMode.values().forEach { mode ->
|
||||
OutlinedButton(onClick = { vm.updateDefaultMode(mode) }) { Text(mode.name) }
|
||||
}
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = ui.settings.showHolderGuidanceBeforeSession, onCheckedChange = vm::updateGuidance)
|
||||
Text("Show holder guidance before session", color = MaterialTheme.colorScheme.onBackground)
|
||||
Checkbox(
|
||||
checked = ui.settings.forceAudioWithoutHeadphones,
|
||||
onCheckedChange = vm::updateForceAudioWithoutHeadphones,
|
||||
)
|
||||
Text("force audio without headphones", color = MaterialTheme.colorScheme.onBackground)
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
@@ -570,12 +589,11 @@ fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) {
|
||||
|
||||
HorizontalDivider()
|
||||
Text("About/Disclaimer: MindMachine is a prototype and not a medical device.", color = MaterialTheme.colorScheme.onBackground)
|
||||
OutlinedButton(onClick = onBack) { Text("Back") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HolderScreen(onBack: () -> Unit) {
|
||||
fun HolderScreen() {
|
||||
Column(
|
||||
Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
@@ -587,6 +605,5 @@ fun HolderScreen(onBack: () -> Unit) {
|
||||
Text("• Allow airflow and comfort.", color = MaterialTheme.colorScheme.onBackground)
|
||||
Text("• Test fit before session.", color = MaterialTheme.colorScheme.onBackground)
|
||||
Text("• Sit or lie down in a safe place.", color = MaterialTheme.colorScheme.onBackground)
|
||||
OutlinedButton(onClick = onBack) { Text("Back") }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.mindmachine.mvp.domain.AppSettings
|
||||
import com.mindmachine.mvp.domain.SessionMode
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
@@ -19,8 +18,7 @@ class SettingsRepository(private val context: Context) {
|
||||
val safetyVersion = intPreferencesKey("safety_version")
|
||||
val countdownPref = stringPreferencesKey("countdown_pref")
|
||||
val countdownSeconds = intPreferencesKey("countdown_seconds")
|
||||
val defaultMode = stringPreferencesKey("default_mode")
|
||||
val showGuidance = booleanPreferencesKey("show_guidance")
|
||||
val forceAudioWithoutHeadphones = booleanPreferencesKey("force_audio_without_headphones")
|
||||
val showImmersiveProgressBar = booleanPreferencesKey("show_immersive_progress_bar")
|
||||
val lastPresetId = stringPreferencesKey("last_preset")
|
||||
}
|
||||
@@ -32,8 +30,7 @@ class SettingsRepository(private val context: Context) {
|
||||
countdownSeconds = p[Keys.countdownSeconds]
|
||||
?: legacyCountdownStringToSeconds(p[Keys.countdownPref])
|
||||
?: 5,
|
||||
defaultModePreference = runCatching { SessionMode.valueOf(p[Keys.defaultMode] ?: "AUDIO_VISUAL") }.getOrDefault(SessionMode.AUDIO_VISUAL),
|
||||
showHolderGuidanceBeforeSession = p[Keys.showGuidance] ?: false,
|
||||
forceAudioWithoutHeadphones = p[Keys.forceAudioWithoutHeadphones] ?: false,
|
||||
showImmersiveProgressBar = p[Keys.showImmersiveProgressBar] ?: true,
|
||||
lastPresetId = p[Keys.lastPresetId],
|
||||
)
|
||||
@@ -50,9 +47,14 @@ class SettingsRepository(private val context: Context) {
|
||||
it[Keys.countdownSeconds] = value.coerceIn(0, 10)
|
||||
}
|
||||
|
||||
suspend fun updateDefaultMode(value: SessionMode) = context.dataStore.edit { it[Keys.defaultMode] = value.name }
|
||||
suspend fun updateGuidance(value: Boolean) = context.dataStore.edit { it[Keys.showGuidance] = value }
|
||||
suspend fun updateShowImmersiveProgressBar(value: Boolean) = context.dataStore.edit { it[Keys.showImmersiveProgressBar] = value }
|
||||
suspend fun updateForceAudioWithoutHeadphones(value: Boolean) = context.dataStore.edit {
|
||||
it[Keys.forceAudioWithoutHeadphones] = value
|
||||
}
|
||||
|
||||
suspend fun updateShowImmersiveProgressBar(value: Boolean) = context.dataStore.edit {
|
||||
it[Keys.showImmersiveProgressBar] = value
|
||||
}
|
||||
|
||||
suspend fun updateLastPreset(id: String) = context.dataStore.edit { it[Keys.lastPresetId] = id }
|
||||
|
||||
private fun legacyCountdownStringToSeconds(raw: String?): Int? = when (raw) {
|
||||
|
||||
@@ -34,8 +34,7 @@ data class AppSettings(
|
||||
val safetyAcknowledged: Boolean = false,
|
||||
val safetyAcknowledgedVersion: Int = 0,
|
||||
val countdownSeconds: Int = 5,
|
||||
val defaultModePreference: SessionMode = SessionMode.AUDIO_VISUAL,
|
||||
val showHolderGuidanceBeforeSession: Boolean = false,
|
||||
val forceAudioWithoutHeadphones: Boolean = false,
|
||||
val showImmersiveProgressBar: Boolean = true,
|
||||
val lastPresetId: String? = null,
|
||||
)
|
||||
|
||||
@@ -28,6 +28,7 @@ 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(),
|
||||
@@ -35,6 +36,9 @@ data class UiState(
|
||||
|
||||
// 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,
|
||||
@@ -47,6 +51,30 @@ data class UiState(
|
||||
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,
|
||||
@@ -93,12 +121,19 @@ class MainViewModel(
|
||||
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 = settings.defaultModePreference),
|
||||
timeline = if (sameSelection) current.timeline else timelineForProgramId(selectedPreset.id, mergedPrograms),
|
||||
config = current.config.copy(mode = SessionMode.AUDIO_VISUAL),
|
||||
timeline = nextTimeline,
|
||||
savedProgramName = savedName,
|
||||
savedTimelineSignature = savedSignature,
|
||||
hasUnsavedChanges = isDirty(selectedPreset.name, nextTimeline, savedName, savedSignature),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -112,11 +147,16 @@ class MainViewModel(
|
||||
val preset = state.presets.first { it.id == id }
|
||||
settingsRepository.updateLastPreset(id)
|
||||
_ui.update {
|
||||
val cfg = preset.toConfig(it.settings.defaultModePreference)
|
||||
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 = timelineForProgramId(preset.id, cachedPrograms),
|
||||
timeline = timeline,
|
||||
savedProgramName = preset.name,
|
||||
savedTimelineSignature = savedSignature,
|
||||
hasUnsavedChanges = false,
|
||||
error = null,
|
||||
)
|
||||
}
|
||||
@@ -136,10 +176,14 @@ class MainViewModel(
|
||||
isUserProgram = true,
|
||||
)
|
||||
_ui.update {
|
||||
val savedSignature = timelineProgramSignature(timeline)
|
||||
it.copy(
|
||||
selectedPreset = preset,
|
||||
config = preset.toConfig(it.settings.defaultModePreference),
|
||||
config = preset.toConfig(SessionMode.AUDIO_VISUAL),
|
||||
timeline = timeline,
|
||||
savedProgramName = displayName,
|
||||
savedTimelineSignature = savedSignature,
|
||||
hasUnsavedChanges = false,
|
||||
error = null,
|
||||
)
|
||||
}
|
||||
@@ -176,8 +220,12 @@ class MainViewModel(
|
||||
sortOrder = sortOrder,
|
||||
isUserProgram = true,
|
||||
)
|
||||
val savedSignature = timelineProgramSignature(it.timeline)
|
||||
it.copy(
|
||||
selectedPreset = savedPreset,
|
||||
savedProgramName = trimmed,
|
||||
savedTimelineSignature = savedSignature,
|
||||
hasUnsavedChanges = false,
|
||||
error = "Saved \"$trimmed\"",
|
||||
)
|
||||
}
|
||||
@@ -189,10 +237,15 @@ class MainViewModel(
|
||||
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(it.settings.defaultModePreference),
|
||||
timeline = timelineForProgramId(fallback.id, cachedPrograms),
|
||||
config = fallback.toConfig(SessionMode.AUDIO_VISUAL),
|
||||
timeline = timeline,
|
||||
savedProgramName = fallback.name,
|
||||
savedTimelineSignature = savedSignature,
|
||||
hasUnsavedChanges = false,
|
||||
error = null,
|
||||
)
|
||||
}
|
||||
@@ -203,16 +256,20 @@ class MainViewModel(
|
||||
|
||||
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 = it.timeline.setDurationSec(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 = it.timeline.setCurveGranularitySec(seconds),
|
||||
timeline = nextTimeline,
|
||||
hasUnsavedChanges = isDirty(it.selectedPreset.name, nextTimeline, it.savedProgramName, it.savedTimelineSignature),
|
||||
error = null,
|
||||
)
|
||||
}
|
||||
@@ -221,17 +278,22 @@ class MainViewModel(
|
||||
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 { it.copy(config = it.config.copy(carrierFrequencyHz = value.coerceIn(80f, 400f)), 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 updateDefaultMode(mode: SessionMode) = viewModelScope.launch { settingsRepository.updateDefaultMode(mode) }
|
||||
fun updateGuidance(value: Boolean) = viewModelScope.launch { settingsRepository.updateGuidance(value) }
|
||||
fun updateForceAudioWithoutHeadphones(value: Boolean) = viewModelScope.launch {
|
||||
settingsRepository.updateForceAudioWithoutHeadphones(value)
|
||||
}
|
||||
fun updateShowImmersiveProgressBar(value: Boolean) = viewModelScope.launch { settingsRepository.updateShowImmersiveProgressBar(value) }
|
||||
|
||||
fun startSession(): Boolean {
|
||||
@@ -240,8 +302,7 @@ class MainViewModel(
|
||||
_ui.update { it.copy(error = "You must acknowledge safety before starting sessions.") }
|
||||
return false
|
||||
}
|
||||
val headset = headsetMonitor.isStereoHeadsetAvailable()
|
||||
val validation = SessionValidator.validate(state.config, headset)
|
||||
val validation = SessionValidator.validate(state.config)
|
||||
if (validation != null) {
|
||||
_ui.update { it.copy(error = validation) }
|
||||
return false
|
||||
@@ -268,10 +329,6 @@ class MainViewModel(
|
||||
fun resume() {
|
||||
val state = _ui.value
|
||||
if (state.runtimeState != RuntimeState.PAUSED && state.runtimeState != RuntimeState.INTERRUPTED) return
|
||||
if (state.config.mode != SessionMode.VISUAL_ONLY && !headsetMonitor.isStereoHeadsetAvailable()) {
|
||||
_ui.update { it.copy(error = "Headphones are required to resume audio mode.") }
|
||||
return
|
||||
}
|
||||
runProgram(startElapsedSec = elapsedBeforePauseSec, includeCountdown = true)
|
||||
}
|
||||
|
||||
@@ -283,7 +340,13 @@ class MainViewModel(
|
||||
}
|
||||
|
||||
fun switchToVisualOnlyAndResume() {
|
||||
_ui.update { it.copy(config = it.config.copy(mode = SessionMode.VISUAL_ONLY), error = null) }
|
||||
_ui.update {
|
||||
it.copy(
|
||||
settings = it.settings.copy(forceAudioWithoutHeadphones = false),
|
||||
error = null,
|
||||
)
|
||||
}
|
||||
viewModelScope.launch { settingsRepository.updateForceAudioWithoutHeadphones(false) }
|
||||
resume()
|
||||
}
|
||||
|
||||
@@ -331,8 +394,10 @@ class MainViewModel(
|
||||
)
|
||||
}
|
||||
|
||||
if (_ui.value.config.mode != SessionMode.VISUAL_ONLY) {
|
||||
var isAudioPlaying = false
|
||||
if (shouldPlayAudio(_ui.value.settings, headsetMonitor.isStereoHeadsetAvailable())) {
|
||||
audioEngine.start(initialParams.carrierHz, initialParams.binauralHz)
|
||||
isAudioPlaying = true
|
||||
}
|
||||
|
||||
val tStart = System.currentTimeMillis()
|
||||
@@ -343,23 +408,22 @@ class MainViewModel(
|
||||
elapsedBeforePauseSec = elapsedSec.coerceAtMost(durationSec.toFloat())
|
||||
val remaining = (durationSec - kotlin.math.ceil(elapsedBeforePauseSec).toInt()).coerceAtLeast(0)
|
||||
val currentUi = _ui.value
|
||||
|
||||
if (currentUi.config.mode != SessionMode.VISUAL_ONLY && !headsetMonitor.isStereoHeadsetAvailable()) {
|
||||
audioEngine.stop()
|
||||
_ui.update {
|
||||
it.copy(
|
||||
runtimeState = RuntimeState.INTERRUPTED,
|
||||
interruptionReason = "Headphones disconnected. Session paused.",
|
||||
)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
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 (currentUi.config.mode != SessionMode.VISUAL_ONLY) {
|
||||
audioEngine.setFrequencies(params.carrierHz, params.binauralHz)
|
||||
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
|
||||
@@ -371,6 +435,10 @@ class MainViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun shouldPlayAudio(settings: AppSettings, headsetAvailable: Boolean): Boolean {
|
||||
return settings.forceAudioWithoutHeadphones || headsetAvailable
|
||||
}
|
||||
|
||||
private fun currentElapsedSec(): Float {
|
||||
val state = _ui.value
|
||||
val total = state.timeline.durationSec.toFloat().coerceAtLeast(1f)
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
package com.mindmachine.mvp.session
|
||||
|
||||
import com.mindmachine.mvp.domain.SessionConfig
|
||||
import com.mindmachine.mvp.domain.SessionMode
|
||||
|
||||
object SessionValidator {
|
||||
fun validate(config: SessionConfig, headsetAvailable: Boolean): String? {
|
||||
fun validate(config: SessionConfig): String? {
|
||||
if (config.durationSec !in 60..(8 * 60 * 60)) return "Duration must be 1 minute to 8 hours."
|
||||
if (config.flashIntervalMs !in 50..2000) return "Flash interval must be 0.05 to 2.00 seconds."
|
||||
if (config.carrierFrequencyHz !in 80f..400f) return "Carrier frequency must be between 80 and 400."
|
||||
if (config.binauralDifferenceHz !in 0.5f..20f) return "Binaural difference must be between 0.5 and 20."
|
||||
if (config.mode != SessionMode.VISUAL_ONLY && !headsetAvailable) {
|
||||
return "Stereo headphones are required for binaural audio. Connect headphones or switch to Visual-only."
|
||||
val carrier = config.carrierFrequencyHz
|
||||
if (!carrier.isFinite() || carrier < ParameterRanges.CARRIER_HZ_MIN || carrier > ParameterRanges.CARRIER_HZ_MAX) {
|
||||
return "Carrier frequency must be between ${ParameterRanges.CARRIER_HZ_MIN.toInt()} and ${ParameterRanges.CARRIER_HZ_MAX.toInt()} Hz."
|
||||
}
|
||||
if (config.binauralDifferenceHz !in 0.5f..20f) return "Binaural difference must be between 0.5 and 20."
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user