Update mode toggle, split flash visuals, and interval-ms controls

This commit is contained in:
Tretzi
2026-03-10 23:24:49 -05:00
parent fdf3bf01d3
commit bb5d77fea3
9 changed files with 137 additions and 125 deletions

View File

@@ -1,5 +1,7 @@
package com.mindmachine.mvp
import android.app.Activity
import android.content.pm.ActivityInfo
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
@@ -12,6 +14,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -29,14 +32,16 @@ import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
@@ -45,10 +50,10 @@ import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
@@ -63,9 +68,10 @@ import com.mindmachine.mvp.data.SettingsRepository
import com.mindmachine.mvp.domain.CountdownPreference
import com.mindmachine.mvp.domain.RuntimeState
import com.mindmachine.mvp.domain.SessionMode
import com.mindmachine.mvp.domain.VisualPattern
import com.mindmachine.mvp.session.FlashColor
import com.mindmachine.mvp.session.MainViewModel
import com.mindmachine.mvp.session.computeVisualAlpha
import com.mindmachine.mvp.session.SplitFlashFrame
import com.mindmachine.mvp.session.computeSplitFlashFrame
import kotlin.math.roundToInt
private val MindMachineColors = darkColorScheme(
@@ -105,7 +111,7 @@ fun App(vm: MainViewModel = viewModel()) {
val ui by vm.ui.collectAsStateWithLifecycle()
val lifecycle = LocalLifecycleOwner.current.lifecycle
androidx.compose.runtime.DisposableEffect(lifecycle) {
DisposableEffect(lifecycle) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_STOP && ui.runtimeState == RuntimeState.RUNNING) {
vm.pause("Session paused because app moved to background.")
@@ -234,13 +240,23 @@ fun SetupScreen(vm: MainViewModel, onStart: () -> Unit, onHolder: () -> Unit) {
Text("Duration: ${cfg.durationSec / 60} min", color = MaterialTheme.colorScheme.onBackground)
Slider(value = (cfg.durationSec / 60).toFloat(), onValueChange = { vm.setDurationMin(it.roundToInt()) }, valueRange = 1f..30f)
Text("Mode", color = MaterialTheme.colorScheme.onBackground)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SessionMode.values().forEach { mode ->
OutlinedButton(onClick = { vm.setMode(mode) }) { Text(mode.name.replace("_", " ")) }
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
SessionMode.values().forEachIndexed { index, mode ->
SegmentedButton(
shape = androidx.compose.material3.SegmentedButtonDefaults.itemShape(index = index, count = SessionMode.values().size),
selected = cfg.mode == mode,
onClick = { vm.setMode(mode) },
label = { Text(mode.name.replace("_", " ")) }
)
}
}
Text("Blink ${cfg.blinkFrequencyHz} Hz", color = MaterialTheme.colorScheme.onBackground)
Slider(value = cfg.blinkFrequencyHz, onValueChange = vm::setBlinkFrequency, valueRange = 1f..20f)
Text("Flash interval: ${cfg.flashIntervalMs} ms", color = MaterialTheme.colorScheme.onBackground)
Slider(
value = cfg.flashIntervalMs.toFloat(),
onValueChange = { vm.setFlashIntervalMs(it.roundToInt()) },
valueRange = 50f..2000f
)
Text("Controls how long each frame is shown (red/green/black pattern).", color = MaterialTheme.colorScheme.onBackground)
Text("Carrier ${cfg.carrierFrequencyHz.roundToInt()} Hz", color = MaterialTheme.colorScheme.onBackground)
Slider(value = cfg.carrierFrequencyHz, onValueChange = vm::setCarrier, valueRange = 80f..400f)
Text("Difference ${cfg.binauralDifferenceHz} Hz", color = MaterialTheme.colorScheme.onBackground)
@@ -257,22 +273,51 @@ fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit) {
val ui by vm.ui.collectAsStateWithLifecycle()
if (ui.runtimeState == RuntimeState.COMPLETED || ui.runtimeState == RuntimeState.STOPPED) onFinish()
val isVisualMode = ui.config.mode != SessionMode.AUDIO_ONLY
val context = LocalContext.current
DisposableEffect(isVisualMode) {
val activity = context as? Activity
val previous = activity?.requestedOrientation
if (isVisualMode) {
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
}
onDispose {
if (isVisualMode) {
activity?.requestedOrientation = previous ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
}
}
var showOverlay by remember { mutableStateOf(true) }
val visualAlpha by rememberVisualAlpha(
val flashFrame by rememberSplitFlashFrame(
isRunning = ui.runtimeState == RuntimeState.RUNNING,
mode = ui.config.mode,
visualPattern = ui.config.visualPatternType,
blinkFrequencyHz = ui.config.blinkFrequencyHz,
intensityPercent = ui.config.intensityPercent
flashIntervalMs = ui.config.flashIntervalMs,
)
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.background(Color.White.copy(alpha = visualAlpha))
.clickable { showOverlay = !showOverlay }
) {
if (isVisualMode) {
Row(Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.background(flashFrame.left.toComposeColor())
)
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.background(flashFrame.right.toComposeColor())
)
}
}
if (ui.runtimeState == RuntimeState.COUNTDOWN) {
Text(
"${ui.countdownSec}",
@@ -286,7 +331,7 @@ fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit) {
Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.background(Color.Black.copy(alpha = 0.68f))
.background(Color.Black.copy(alpha = 0.72f))
.padding(16.dp)
) {
Text("${ui.selectedPreset.name}${ui.remainingSec}s", color = Color.White)
@@ -309,28 +354,25 @@ fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit) {
}
}
private fun FlashColor.toComposeColor(): Color = when (this) {
FlashColor.RED -> Color.Red
FlashColor.GREEN -> Color.Green
FlashColor.BLACK -> Color.Black
}
@Composable
private fun rememberVisualAlpha(
private fun rememberSplitFlashFrame(
isRunning: Boolean,
mode: SessionMode,
visualPattern: VisualPattern,
blinkFrequencyHz: Float,
intensityPercent: Int,
): androidx.compose.runtime.State<Float> {
return produceState(initialValue = 0f, isRunning, mode, visualPattern, blinkFrequencyHz, intensityPercent) {
if (!isRunning || mode == SessionMode.AUDIO_ONLY) {
value = 0f
return@produceState
}
while (true) {
withFrameNanos { frameTimeNanos ->
value = computeVisualAlpha(
visualPattern = visualPattern,
frameTimeNanos = frameTimeNanos,
blinkFrequencyHz = blinkFrequencyHz,
intensityPercent = intensityPercent
)
}
flashIntervalMs: Int,
) = produceState(initialValue = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK), isRunning, mode, flashIntervalMs) {
if (!isRunning || mode == SessionMode.AUDIO_ONLY) {
value = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK)
return@produceState
}
while (true) {
withFrameNanos { frameTimeNanos ->
value = computeSplitFlashFrame(frameTimeNanos, flashIntervalMs)
}
}
}

View File

@@ -11,7 +11,7 @@ data class SessionPreset(
val description: String,
val defaultDurationSec: Int,
val visualPatternType: VisualPattern,
val blinkFrequencyHz: Float,
val flashIntervalMs: Int,
val intensityPercent: Int,
val carrierFrequencyHz: Float,
val binauralDifferenceHz: Float,
@@ -24,7 +24,7 @@ data class SessionConfig(
val durationSec: Int,
val mode: SessionMode,
val visualPatternType: VisualPattern,
val blinkFrequencyHz: Float,
val flashIntervalMs: Int,
val intensityPercent: Int,
val carrierFrequencyHz: Float,
val binauralDifferenceHz: Float,
@@ -43,17 +43,17 @@ object Presets {
val builtIn = listOf(
SessionPreset(
"relax", "Relax", "Gentle pulse, slower beat", 10 * 60,
VisualPattern.PULSE, 6f, 60, 200f, 6f,
VisualPattern.PULSE, 167, 60, 200f, 6f,
"Stop if discomfort occurs.", 1
),
SessionPreset(
"focus", "Focus", "Steady flash, conservative alert beat", 15 * 60,
VisualPattern.FLASH, 10f, 65, 220f, 10f,
VisualPattern.FLASH, 100, 65, 220f, 10f,
"Use in a safe seated place only.", 2
),
SessionPreset(
"sleep", "Sleep Prep", "Slow pulse, low intensity", 20 * 60,
VisualPattern.PULSE, 3f, 45, 180f, 3f,
VisualPattern.PULSE, 333, 45, 180f, 3f,
"Do not use while doing other activities.", 3
)
)
@@ -64,7 +64,7 @@ fun SessionPreset.toConfig(defaultMode: SessionMode = SessionMode.AUDIO_VISUAL)
durationSec = defaultDurationSec,
mode = defaultMode,
visualPatternType = visualPatternType,
blinkFrequencyHz = blinkFrequencyHz,
flashIntervalMs = flashIntervalMs,
intensityPercent = intensityPercent,
carrierFrequencyHz = carrierFrequencyHz,
binauralDifferenceHz = binauralDifferenceHz,

View File

@@ -67,7 +67,7 @@ class MainViewModel(
fun setMode(mode: SessionMode) = _ui.update { it.copy(config = it.config.copy(mode = mode), error = null) }
fun setDurationMin(min: Int) = _ui.update { it.copy(config = it.config.copy(durationSec = (min.coerceIn(1, 30) * 60)), error = null) }
fun setBlinkFrequency(value: Float) = _ui.update { it.copy(config = it.config.copy(blinkFrequencyHz = value.coerceIn(1f, 20f)), 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 setDifference(value: Float) = _ui.update { it.copy(config = it.config.copy(binauralDifferenceHz = value.coerceIn(0.5f, 20f)), error = null) }

View File

@@ -6,7 +6,7 @@ import com.mindmachine.mvp.domain.SessionMode
object SessionValidator {
fun validate(config: SessionConfig, headsetAvailable: Boolean): String? {
if (config.durationSec !in 60..(30 * 60)) return "Duration must be 1 to 30 minutes."
if (config.blinkFrequencyHz !in 1f..20f) return "Blink frequency must be 1.0 to 20.0 Hz."
if (config.flashIntervalMs !in 50..2000) return "Flash interval must be 50 to 2000 ms."
if (config.carrierFrequencyHz !in 80f..400f) return "Carrier frequency must be 80 to 400 Hz."
if (config.binauralDifferenceHz !in 0.5f..20f) return "Binaural difference must be 0.5 to 20 Hz."
if (config.mode != SessionMode.VISUAL_ONLY && !headsetAvailable) {

View File

@@ -1,30 +1,26 @@
package com.mindmachine.mvp.session
import com.mindmachine.mvp.domain.VisualPattern
import kotlin.math.PI
import kotlin.math.sin
enum class FlashColor {
RED,
GREEN,
BLACK,
}
fun computeVisualAlpha(
visualPattern: VisualPattern,
data class SplitFlashFrame(
val left: FlashColor,
val right: FlashColor,
)
fun computeSplitFlashFrame(
frameTimeNanos: Long,
blinkFrequencyHz: Float,
intensityPercent: Int,
): Float {
val intensity = (intensityPercent.coerceIn(0, 100) / 100f)
if (intensity <= 0f) return 0f
val frequency = blinkFrequencyHz.coerceIn(1f, 20f).toDouble()
val timeSeconds = frameTimeNanos / 1_000_000_000.0
return when (visualPattern) {
VisualPattern.FLASH -> {
val phase = (timeSeconds * frequency) % 1.0
if (phase < 0.5) intensity else 0f
}
VisualPattern.PULSE -> {
val pulse = ((sin(2.0 * PI * frequency * timeSeconds) + 1.0) / 2.0).toFloat()
(intensity * pulse).coerceIn(0f, intensity)
}
flashIntervalMs: Int,
): SplitFlashFrame {
val intervalMs = flashIntervalMs.coerceIn(50, 2000).toLong()
val elapsedMs = frameTimeNanos / 1_000_000L
return when (((elapsedMs / intervalMs) % 4).toInt()) {
0 -> SplitFlashFrame(left = FlashColor.RED, right = FlashColor.GREEN)
1 -> SplitFlashFrame(left = FlashColor.BLACK, right = FlashColor.BLACK)
2 -> SplitFlashFrame(left = FlashColor.GREEN, right = FlashColor.RED)
else -> SplitFlashFrame(left = FlashColor.BLACK, right = FlashColor.BLACK)
}
}