Auto commit (MindMachine) Mon Apr 13 07:01:01 PM CDT 2026

This commit is contained in:
Tretzi
2026-04-13 19:01:01 -05:00
parent de6e9d6230
commit e3ac06b047
5 changed files with 105 additions and 7 deletions

View File

@@ -519,6 +519,14 @@ fun SetupScreen(
onCurveGranularityChanged = vm::setCurveGranularitySec, onCurveGranularityChanged = vm::setCurveGranularitySec,
) )
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = ui.config.monochromeFlashMode,
onCheckedChange = vm::setMonochromeFlashMode,
)
Text("Use black/white flashes for this session", color = MaterialTheme.colorScheme.onBackground)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
if (hasUnsavedChanges) { if (hasUnsavedChanges) {
OutlinedButton(onClick = { vm.saveCurrentProgram(programName) }, modifier = Modifier.weight(1f).height(52.dp)) { OutlinedButton(onClick = { vm.saveCurrentProgram(programName) }, modifier = Modifier.weight(1f).height(52.dp)) {
@@ -587,6 +595,7 @@ fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit, onBackToEntry:
} }
var brightnessOverlayVisible by remember { mutableStateOf(false) } var brightnessOverlayVisible by remember { mutableStateOf(false) }
var brightnessOverlayToken by remember { mutableIntStateOf(0) } var brightnessOverlayToken by remember { mutableIntStateOf(0) }
val timingLogTag = "MindMachineTiming"
LaunchedEffect(brightnessOverlayToken) { LaunchedEffect(brightnessOverlayToken) {
if (!brightnessOverlayVisible) return@LaunchedEffect if (!brightnessOverlayVisible) return@LaunchedEffect
@@ -594,6 +603,17 @@ fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit, onBackToEntry:
brightnessOverlayVisible = false brightnessOverlayVisible = false
} }
LaunchedEffect(ui.runtimeState) {
when (ui.runtimeState) {
RuntimeState.COUNTDOWN -> Log.d(timingLogTag, "COUNTDOWN_START")
RuntimeState.RUNNING -> Log.d(timingLogTag, "IMMERSIVE_START")
RuntimeState.STOPPED, RuntimeState.COMPLETED, RuntimeState.PAUSED, RuntimeState.INTERRUPTED, RuntimeState.ERROR -> {
Log.d(timingLogTag, "IMMERSIVE_STOP state=${ui.runtimeState}")
}
else -> Unit
}
}
ApplyKeepScreenOn(enabled = true) ApplyKeepScreenOn(enabled = true)
ApplyScreenBrightnessOverride( ApplyScreenBrightnessOverride(
@@ -601,6 +621,11 @@ fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit, onBackToEntry:
brightnessPercent = ui.settings.immersiveBrightnessPercent, brightnessPercent = ui.settings.immersiveBrightnessPercent,
) )
ApplyPreferredRefreshRate(
enabled = true,
preferLowest = ui.settings.preferLowestRefreshRate,
)
val progress = sessionProgressFraction( val progress = sessionProgressFraction(
durationSec = ui.timeline.durationSec, durationSec = ui.timeline.durationSec,
remainingSec = ui.remainingSec, remainingSec = ui.remainingSec,
@@ -628,10 +653,12 @@ fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit, onBackToEntry:
SplitFlashRenderView(ctx).apply { SplitFlashRenderView(ctx).apply {
setRunning(ui.runtimeState == RuntimeState.RUNNING) setRunning(ui.runtimeState == RuntimeState.RUNNING)
updateIntervals(ui.runtimeParams.flashOnMs, ui.runtimeParams.flashOffMs) updateIntervals(ui.runtimeParams.flashOnMs, ui.runtimeParams.flashOffMs)
setMonochrome(ui.config.monochromeFlashMode)
} }
}, },
update = { view -> update = { view ->
view.updateIntervals(ui.runtimeParams.flashOnMs, ui.runtimeParams.flashOffMs) view.updateIntervals(ui.runtimeParams.flashOnMs, ui.runtimeParams.flashOffMs)
view.setMonochrome(ui.config.monochromeFlashMode)
view.setRunning(ui.runtimeState == RuntimeState.RUNNING) view.setRunning(ui.runtimeState == RuntimeState.RUNNING)
} }
) )
@@ -759,6 +786,40 @@ private fun ApplyScreenBrightnessOverride(enabled: Boolean, brightnessPercent: I
} }
} }
@Composable
private fun ApplyPreferredRefreshRate(enabled: Boolean, preferLowest: Boolean) {
val activity = LocalContext.current as? Activity ?: return
val window = activity.window
val timingLogTag = "MindMachineTiming"
DisposableEffect(window) {
onDispose {
val params = window.attributes
params.preferredRefreshRate = 0f
params.preferredDisplayModeId = 0
window.attributes = params
}
}
LaunchedEffect(enabled, preferLowest) {
val params = window.attributes
if (enabled && preferLowest) {
val display = activity.display
val chosenMode = display?.supportedModes?.minByOrNull { it.refreshRate }
params.preferredDisplayModeId = chosenMode?.modeId ?: 0
params.preferredRefreshRate = chosenMode?.refreshRate ?: 0f
Log.d(
timingLogTag,
"DISPLAY_MODE_REQUEST modeId=${chosenMode?.modeId ?: 0} refreshHz=${chosenMode?.refreshRate ?: 0f} supported=${display?.supportedModes?.joinToString { "${it.modeId}:${it.refreshRate}" }}"
)
} else {
params.preferredDisplayModeId = 0
params.preferredRefreshRate = 0f
}
window.attributes = params
}
}
@Composable @Composable
private fun ApplyImmersiveMode(enabled: Boolean, revealSignal: Int) { private fun ApplyImmersiveMode(enabled: Boolean, revealSignal: Int) {
val context = LocalContext.current val context = LocalContext.current
@@ -806,6 +867,7 @@ private class SplitFlashRenderView(context: Context) : View(context) {
private var rawOffMs = 167L private var rawOffMs = 167L
private var onMs = 167L private var onMs = 167L
private var offMs = 167L private var offMs = 167L
private var monochrome = false
private var started = false private var started = false
private var startUptimeMs = 0L private var startUptimeMs = 0L
private var nextToggleUptimeMs = 0L private var nextToggleUptimeMs = 0L
@@ -855,10 +917,12 @@ private class SplitFlashRenderView(context: Context) : View(context) {
onMs = snapToFrameMs(rawOnMs, frameMs) onMs = snapToFrameMs(rawOnMs, frameMs)
offMs = snapToFrameMs(rawOffMs, frameMs) offMs = snapToFrameMs(rawOffMs, frameMs)
if (isRunning) {
Log.d( Log.d(
timingLogTag, timingLogTag,
"config onMs=$rawOnMs offMs=$rawOffMs refreshHz=${"%.2f".format(refreshRate())} frameMs=$frameMs snappedOnMs=$onMs snappedOffMs=$offMs" "config onMs=$rawOnMs offMs=$rawOffMs refreshHz=${"%.2f".format(refreshRate())} frameMs=$frameMs snappedOnMs=$onMs snappedOffMs=$offMs"
) )
}
if (isRunning && started) { if (isRunning && started) {
scheduleFromNow() scheduleFromNow()
@@ -886,12 +950,18 @@ private class SplitFlashRenderView(context: Context) : View(context) {
maxLatenessMs = 0L maxLatenessMs = 0L
Log.d( Log.d(
timingLogTag, timingLogTag,
"session-start phase=${phaseName(phase)} refreshHz=${"%.2f".format(refreshRate())} frameMs=${framePeriodMs()} nowMs=$startUptimeMs" "session-start phase=${phaseName(phase)} modeId=${display?.mode?.modeId ?: 0} refreshHz=${"%.2f".format(refreshRate())} frameMs=${framePeriodMs()} nowMs=$startUptimeMs"
) )
invalidate() invalidate()
scheduleNext() scheduleNext()
} }
fun setMonochrome(enabled: Boolean) {
if (monochrome == enabled) return
monochrome = enabled
invalidate()
}
override fun onDetachedFromWindow() { override fun onDetachedFromWindow() {
handler.removeCallbacks(toggleRunnable) handler.removeCallbacks(toggleRunnable)
super.onDetachedFromWindow() super.onDetachedFromWindow()
@@ -919,8 +989,8 @@ private class SplitFlashRenderView(context: Context) : View(context) {
} }
private fun colorsForPhase(phase: Int): Pair<Int, Int> = when (phase) { private fun colorsForPhase(phase: Int): Pair<Int, Int> = when (phase) {
0 -> Pair(android.graphics.Color.RED, android.graphics.Color.GREEN) 0 -> if (monochrome) Pair(android.graphics.Color.WHITE, android.graphics.Color.WHITE) else Pair(android.graphics.Color.RED, android.graphics.Color.GREEN)
2 -> Pair(android.graphics.Color.GREEN, android.graphics.Color.RED) 2 -> if (monochrome) Pair(android.graphics.Color.WHITE, android.graphics.Color.WHITE) else Pair(android.graphics.Color.GREEN, android.graphics.Color.RED)
else -> Pair(android.graphics.Color.BLACK, android.graphics.Color.BLACK) else -> Pair(android.graphics.Color.BLACK, android.graphics.Color.BLACK)
} }
@@ -1121,6 +1191,11 @@ fun SettingsScreen(vm: MainViewModel, onRemoveAds: () -> Unit) {
Text("Show immersive bottom progress bar", color = MaterialTheme.colorScheme.onBackground) Text("Show immersive bottom progress bar", color = MaterialTheme.colorScheme.onBackground)
} }
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = ui.settings.preferLowestRefreshRate, onCheckedChange = vm::updatePreferLowestRefreshRate)
Text("Prefer lowest display refresh rate during sessions", color = MaterialTheme.colorScheme.onBackground)
}
Button(onClick = onRemoveAds) { Button(onClick = onRemoveAds) {
Text(if (ui.settings.noAds) "Ads removed" else "Remove ads") Text(if (ui.settings.noAds) "Ads removed" else "Remove ads")
} }

View File

@@ -20,6 +20,8 @@ class SettingsRepository(private val context: Context) {
val countdownSeconds = intPreferencesKey("countdown_seconds") val countdownSeconds = intPreferencesKey("countdown_seconds")
val forceAudioWithoutHeadphones = booleanPreferencesKey("force_audio_without_headphones") val forceAudioWithoutHeadphones = booleanPreferencesKey("force_audio_without_headphones")
val showImmersiveProgressBar = booleanPreferencesKey("show_immersive_progress_bar") val showImmersiveProgressBar = booleanPreferencesKey("show_immersive_progress_bar")
val preferLowestRefreshRate = booleanPreferencesKey("prefer_lowest_refresh_rate")
val monochromeFlashMode = booleanPreferencesKey("monochrome_flash_mode")
val immersiveBrightnessPercent = intPreferencesKey("immersive_brightness_percent") val immersiveBrightnessPercent = intPreferencesKey("immersive_brightness_percent")
val lastPresetId = stringPreferencesKey("last_preset") val lastPresetId = stringPreferencesKey("last_preset")
val noAds = booleanPreferencesKey("no_ads") val noAds = booleanPreferencesKey("no_ads")
@@ -34,6 +36,8 @@ class SettingsRepository(private val context: Context) {
?: 5, ?: 5,
forceAudioWithoutHeadphones = p[Keys.forceAudioWithoutHeadphones] ?: false, forceAudioWithoutHeadphones = p[Keys.forceAudioWithoutHeadphones] ?: false,
showImmersiveProgressBar = p[Keys.showImmersiveProgressBar] ?: true, showImmersiveProgressBar = p[Keys.showImmersiveProgressBar] ?: true,
preferLowestRefreshRate = p[Keys.preferLowestRefreshRate] ?: true,
monochromeFlashMode = p[Keys.monochromeFlashMode] ?: false,
immersiveBrightnessPercent = (p[Keys.immersiveBrightnessPercent] ?: 50).coerceIn(5, 100), immersiveBrightnessPercent = (p[Keys.immersiveBrightnessPercent] ?: 50).coerceIn(5, 100),
lastPresetId = p[Keys.lastPresetId], lastPresetId = p[Keys.lastPresetId],
noAds = p[Keys.noAds] ?: false, noAds = p[Keys.noAds] ?: false,
@@ -51,6 +55,9 @@ class SettingsRepository(private val context: Context) {
if (prefs[Keys.immersiveBrightnessPercent] == null) { if (prefs[Keys.immersiveBrightnessPercent] == null) {
prefs[Keys.immersiveBrightnessPercent] = 50 prefs[Keys.immersiveBrightnessPercent] = 50
} }
if (prefs[Keys.preferLowestRefreshRate] == null) {
prefs[Keys.preferLowestRefreshRate] = true
}
} }
} }
@@ -73,6 +80,14 @@ class SettingsRepository(private val context: Context) {
it[Keys.showImmersiveProgressBar] = value it[Keys.showImmersiveProgressBar] = value
} }
suspend fun updatePreferLowestRefreshRate(value: Boolean) = context.dataStore.edit {
it[Keys.preferLowestRefreshRate] = value
}
suspend fun updateMonochromeFlashMode(value: Boolean) = context.dataStore.edit {
it[Keys.monochromeFlashMode] = value
}
suspend fun updateImmersiveBrightnessPercent(value: Int) = context.dataStore.edit { suspend fun updateImmersiveBrightnessPercent(value: Int) = context.dataStore.edit {
it[Keys.immersiveBrightnessPercent] = value.coerceIn(5, 100) it[Keys.immersiveBrightnessPercent] = value.coerceIn(5, 100)
} }

View File

@@ -25,6 +25,7 @@ data class SessionConfig(
val mode: SessionMode, val mode: SessionMode,
val visualPatternType: VisualPattern, val visualPatternType: VisualPattern,
val flashIntervalMs: Int, val flashIntervalMs: Int,
val monochromeFlashMode: Boolean = false,
val intensityPercent: Int, val intensityPercent: Int,
val carrierFrequencyHz: Float, val carrierFrequencyHz: Float,
val binauralDifferenceHz: Float, val binauralDifferenceHz: Float,
@@ -36,6 +37,8 @@ data class AppSettings(
val countdownSeconds: Int = 5, val countdownSeconds: Int = 5,
val forceAudioWithoutHeadphones: Boolean = false, val forceAudioWithoutHeadphones: Boolean = false,
val showImmersiveProgressBar: Boolean = true, val showImmersiveProgressBar: Boolean = true,
val preferLowestRefreshRate: Boolean = true,
val monochromeFlashMode: Boolean = false,
// Default used only before DataStore emits (and as a fallback). // Default used only before DataStore emits (and as a fallback).
// We want the first-run experience to be less blinding. // We want the first-run experience to be less blinding.
val immersiveBrightnessPercent: Int = 50, val immersiveBrightnessPercent: Int = 50,
@@ -94,6 +97,7 @@ fun SessionPreset.toConfig(defaultMode: SessionMode = SessionMode.AUDIO_VISUAL)
mode = defaultMode, mode = defaultMode,
visualPatternType = visualPatternType, visualPatternType = visualPatternType,
flashIntervalMs = flashIntervalMs, flashIntervalMs = flashIntervalMs,
monochromeFlashMode = false,
intensityPercent = intensityPercent, intensityPercent = intensityPercent,
carrierFrequencyHz = carrierFrequencyHz, carrierFrequencyHz = carrierFrequencyHz,
binauralDifferenceHz = binauralDifferenceHz, binauralDifferenceHz = binauralDifferenceHz,

View File

@@ -280,6 +280,7 @@ class MainViewModel(
} }
fun setFlashIntervalMs(value: Int) = _ui.update { it.copy(config = it.config.copy(flashIntervalMs = value.coerceIn(50, 2000)), 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 { fun setCarrier(value: Float) = _ui.update {
val stableCarrier = value.coerceIn(ParameterRanges.CARRIER_HZ_MIN, ParameterRanges.CARRIER_HZ_MAX) val stableCarrier = value.coerceIn(ParameterRanges.CARRIER_HZ_MIN, ParameterRanges.CARRIER_HZ_MAX)
it.copy(config = it.config.copy(carrierFrequencyHz = stableCarrier), error = null) it.copy(config = it.config.copy(carrierFrequencyHz = stableCarrier), error = null)
@@ -291,6 +292,8 @@ class MainViewModel(
settingsRepository.updateForceAudioWithoutHeadphones(value) settingsRepository.updateForceAudioWithoutHeadphones(value)
} }
fun updateShowImmersiveProgressBar(value: Boolean) = viewModelScope.launch { settingsRepository.updateShowImmersiveProgressBar(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 { fun updateImmersiveBrightnessPercent(value: Int) = viewModelScope.launch {
settingsRepository.updateImmersiveBrightnessPercent(value) settingsRepository.updateImmersiveBrightnessPercent(value)
} }

View File

@@ -84,6 +84,7 @@ data class TimelineModel(
mode = mode, mode = mode,
visualPatternType = preset.visualPatternType, visualPatternType = preset.visualPatternType,
flashIntervalMs = flashIntervalMs, flashIntervalMs = flashIntervalMs,
monochromeFlashMode = false,
intensityPercent = preset.intensityPercent, intensityPercent = preset.intensityPercent,
carrierFrequencyHz = carrier, carrierFrequencyHz = carrier,
binauralDifferenceHz = diff binauralDifferenceHz = diff