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)
}
}
}