Update mode toggle, split flash visuals, and interval-ms controls
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user