Auto commit (MindMachine) Mon Apr 13 08:01:01 PM CDT 2026
This commit is contained in:
@@ -18,6 +18,7 @@ import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.background
|
||||
import solutions.tretter.mindmachine.session.DurationSliderCard
|
||||
import solutions.tretter.mindmachine.session.FlashSequenceEditor
|
||||
import solutions.tretter.mindmachine.session.SetupTimelineEditor
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
@@ -519,13 +520,10 @@ fun SetupScreen(
|
||||
onCurveGranularityChanged = vm::setCurveGranularitySec,
|
||||
)
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(
|
||||
checked = ui.config.monochromeFlashMode,
|
||||
onCheckedChange = vm::setMonochromeFlashMode,
|
||||
FlashSequenceEditor(
|
||||
sequence = ui.timeline.flashSequence,
|
||||
onSequenceChanged = { sequence -> vm.updateTimeline(ui.timeline.withFlashSequence(sequence)) },
|
||||
)
|
||||
Text("Use black/white flashes for this session", color = MaterialTheme.colorScheme.onBackground)
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
if (hasUnsavedChanges) {
|
||||
@@ -653,11 +651,13 @@ fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit, onBackToEntry:
|
||||
SplitFlashRenderView(ctx).apply {
|
||||
setRunning(ui.runtimeState == RuntimeState.RUNNING)
|
||||
updateIntervals(ui.runtimeParams.flashOnMs, ui.runtimeParams.flashOffMs)
|
||||
setFlashSequence(ui.config.flashSequence)
|
||||
setMonochrome(ui.config.monochromeFlashMode)
|
||||
}
|
||||
},
|
||||
update = { view ->
|
||||
view.updateIntervals(ui.runtimeParams.flashOnMs, ui.runtimeParams.flashOffMs)
|
||||
view.setFlashSequence(ui.config.flashSequence)
|
||||
view.setMonochrome(ui.config.monochromeFlashMode)
|
||||
view.setRunning(ui.runtimeState == RuntimeState.RUNNING)
|
||||
}
|
||||
@@ -868,6 +868,7 @@ private class SplitFlashRenderView(context: Context) : View(context) {
|
||||
private var onMs = 167L
|
||||
private var offMs = 167L
|
||||
private var monochrome = false
|
||||
private var flashSequence = solutions.tretter.mindmachine.session.defaultFlashSequence()
|
||||
private var started = false
|
||||
private var startUptimeMs = 0L
|
||||
private var nextToggleUptimeMs = 0L
|
||||
@@ -888,11 +889,11 @@ private class SplitFlashRenderView(context: Context) : View(context) {
|
||||
maxLatenessMs = maxOf(maxLatenessMs, latenessMs)
|
||||
}
|
||||
|
||||
phase = (phase + 1) % 4
|
||||
phase = (phase + 1) % phaseCount()
|
||||
toggleCount += 1
|
||||
invalidate()
|
||||
|
||||
val nextDuration = if (phase == 0 || phase == 2) onMs else offMs
|
||||
val nextDuration = if (isVisiblePhase(phase)) onMs else offMs
|
||||
nextToggleUptimeMs += nextDuration
|
||||
|
||||
val shouldLogToggle = toggleCount <= 20 || latenessMs > framePeriodMs() || toggleCount % 60L == 0L
|
||||
@@ -911,11 +912,19 @@ private class SplitFlashRenderView(context: Context) : View(context) {
|
||||
}
|
||||
|
||||
fun updateIntervals(flashOnMs: Int, flashOffMs: Int) {
|
||||
rawOnMs = flashOnMs.coerceIn(50, 2000).toLong()
|
||||
rawOffMs = flashOffMs.coerceIn(50, 2000).toLong()
|
||||
val newRawOnMs = flashOnMs.coerceIn(50, 2000).toLong()
|
||||
val newRawOffMs = flashOffMs.coerceIn(50, 2000).toLong()
|
||||
val frameMs = framePeriodMs().coerceAtLeast(1L)
|
||||
onMs = snapToFrameMs(rawOnMs, frameMs)
|
||||
offMs = snapToFrameMs(rawOffMs, frameMs)
|
||||
val newOnMs = snapToFrameMs(newRawOnMs, frameMs)
|
||||
val newOffMs = snapToFrameMs(newRawOffMs, frameMs)
|
||||
|
||||
val changed = newRawOnMs != rawOnMs || newRawOffMs != rawOffMs || newOnMs != onMs || newOffMs != offMs
|
||||
if (!changed) return
|
||||
|
||||
rawOnMs = newRawOnMs
|
||||
rawOffMs = newRawOffMs
|
||||
onMs = newOnMs
|
||||
offMs = newOffMs
|
||||
|
||||
if (isRunning) {
|
||||
Log.d(
|
||||
@@ -925,7 +934,14 @@ private class SplitFlashRenderView(context: Context) : View(context) {
|
||||
}
|
||||
|
||||
if (isRunning && started) {
|
||||
scheduleFromNow()
|
||||
val now = SystemClock.uptimeMillis()
|
||||
val nextDuration = if (isVisiblePhase(phase)) onMs else offMs
|
||||
nextToggleUptimeMs = maxOf(nextToggleUptimeMs, now + 1L)
|
||||
val remaining = (nextToggleUptimeMs - now).coerceAtLeast(1L)
|
||||
if (remaining > nextDuration) {
|
||||
nextToggleUptimeMs = now + nextDuration
|
||||
}
|
||||
scheduleNext()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -962,6 +978,16 @@ private class SplitFlashRenderView(context: Context) : View(context) {
|
||||
invalidate()
|
||||
}
|
||||
|
||||
fun setFlashSequence(sequence: List<solutions.tretter.mindmachine.session.FlashSequenceRow>) {
|
||||
val normalized = sequence.ifEmpty { solutions.tretter.mindmachine.session.defaultFlashSequence() }
|
||||
if (flashSequence == normalized) return
|
||||
flashSequence = normalized
|
||||
if (started) {
|
||||
phase %= phaseCount()
|
||||
}
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
handler.removeCallbacks(toggleRunnable)
|
||||
super.onDetachedFromWindow()
|
||||
@@ -981,24 +1007,35 @@ private class SplitFlashRenderView(context: Context) : View(context) {
|
||||
handler.removeCallbacks(toggleRunnable)
|
||||
handler.postAtTime(toggleRunnable, nextToggleUptimeMs)
|
||||
}
|
||||
|
||||
private fun scheduleFromNow() {
|
||||
val now = SystemClock.uptimeMillis()
|
||||
nextToggleUptimeMs = now + if (phase == 0 || phase == 2) onMs else offMs
|
||||
scheduleNext()
|
||||
private fun colorsForPhase(phase: Int): Pair<Int, Int> {
|
||||
if (!isVisiblePhase(phase)) return Pair(android.graphics.Color.BLACK, android.graphics.Color.BLACK)
|
||||
if (monochrome) return Pair(android.graphics.Color.WHITE, android.graphics.Color.WHITE)
|
||||
val row = flashSequence[(phase / 2).mod(flashSequence.size)]
|
||||
val left = paletteColor(row.leftColor)
|
||||
val right = if (row.splitScreen) paletteColor(row.rightColor) else left
|
||||
return Pair(left, right)
|
||||
}
|
||||
|
||||
private fun colorsForPhase(phase: Int): Pair<Int, Int> = when (phase) {
|
||||
0 -> if (monochrome) Pair(android.graphics.Color.WHITE, android.graphics.Color.WHITE) else Pair(android.graphics.Color.RED, android.graphics.Color.GREEN)
|
||||
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)
|
||||
private fun phaseName(phase: Int): String {
|
||||
if (!isVisiblePhase(phase)) return "BLACK"
|
||||
val row = flashSequence[(phase / 2).mod(flashSequence.size)]
|
||||
return if (row.splitScreen) {
|
||||
"${row.leftColor.name}_${row.rightColor.name}"
|
||||
} else {
|
||||
row.leftColor.name
|
||||
}
|
||||
}
|
||||
|
||||
private fun phaseName(phase: Int): String = when (phase) {
|
||||
0 -> "RED_GREEN"
|
||||
1 -> "BLACK"
|
||||
2 -> "GREEN_RED"
|
||||
else -> "BLACK"
|
||||
private fun isVisiblePhase(phase: Int): Boolean = phase % 2 == 0
|
||||
|
||||
private fun phaseCount(): Int = (flashSequence.size.coerceAtLeast(1)) * 2
|
||||
|
||||
private fun paletteColor(color: solutions.tretter.mindmachine.session.FlashPaletteColor): Int = when (color) {
|
||||
solutions.tretter.mindmachine.session.FlashPaletteColor.BLACK -> android.graphics.Color.BLACK
|
||||
solutions.tretter.mindmachine.session.FlashPaletteColor.RED -> android.graphics.Color.RED
|
||||
solutions.tretter.mindmachine.session.FlashPaletteColor.GREEN -> android.graphics.Color.GREEN
|
||||
solutions.tretter.mindmachine.session.FlashPaletteColor.YELLOW -> android.graphics.Color.YELLOW
|
||||
solutions.tretter.mindmachine.session.FlashPaletteColor.BLUE -> android.graphics.Color.BLUE
|
||||
}
|
||||
|
||||
private fun refreshRate(): Float = display?.refreshRate?.takeIf { it > 0f } ?: 60f
|
||||
|
||||
@@ -10,6 +10,9 @@ import solutions.tretter.mindmachine.session.TimelinePoint
|
||||
import solutions.tretter.mindmachine.session.TIMELINE_DEFAULT_GRANULARITY_SEC
|
||||
import solutions.tretter.mindmachine.session.TimelineSelection
|
||||
import solutions.tretter.mindmachine.session.TimelineViewport
|
||||
import solutions.tretter.mindmachine.session.FlashPaletteColor
|
||||
import solutions.tretter.mindmachine.session.FlashSequenceRow
|
||||
import solutions.tretter.mindmachine.session.defaultFlashSequence
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.json.JSONArray
|
||||
@@ -83,8 +86,10 @@ class UserProgramRepository(private val context: Context) {
|
||||
return TimelineEditorState(
|
||||
durationSec = o.getInt("durationSec"),
|
||||
curveGranularitySec = TimelineEditorState.normalizeGranularitySec(
|
||||
o.optInt("curveGranularitySec", TIMELINE_DEFAULT_GRANULARITY_SEC)
|
||||
o.optInt("curveGranularitySec", TIMELINE_DEFAULT_GRANULARITY_SEC),
|
||||
o.getInt("durationSec")
|
||||
),
|
||||
flashSequence = parseFlashSequence(o.optJSONArray("flashSequence")),
|
||||
selection = TimelineSelection(
|
||||
startSec = o.optInt("selectionStartSec", 0),
|
||||
endSec = o.optInt("selectionEndSec", o.getInt("durationSec")),
|
||||
@@ -115,6 +120,7 @@ class UserProgramRepository(private val context: Context) {
|
||||
val timeline = JSONObject()
|
||||
timeline.put("durationSec", program.timeline.durationSec)
|
||||
timeline.put("curveGranularitySec", program.timeline.curveGranularitySec)
|
||||
timeline.put("flashSequence", flashSequenceArray(program.timeline.flashSequence))
|
||||
timeline.put("selectionStartSec", program.timeline.selection.startSec)
|
||||
timeline.put("selectionEndSec", program.timeline.selection.endSec)
|
||||
timeline.put("viewportStartSec", program.timeline.viewport.startSec)
|
||||
@@ -138,6 +144,35 @@ class UserProgramRepository(private val context: Context) {
|
||||
}
|
||||
return arr
|
||||
}
|
||||
|
||||
private fun parseFlashSequence(arr: JSONArray?): List<FlashSequenceRow> {
|
||||
if (arr == null || arr.length() == 0) return defaultFlashSequence()
|
||||
return buildList {
|
||||
for (i in 0 until arr.length()) {
|
||||
val row = arr.getJSONObject(i)
|
||||
add(
|
||||
FlashSequenceRow(
|
||||
splitScreen = row.optBoolean("splitScreen", true),
|
||||
leftColor = row.optString("leftColor", FlashPaletteColor.RED.name).let(FlashPaletteColor::valueOf),
|
||||
rightColor = row.optString("rightColor", FlashPaletteColor.GREEN.name).let(FlashPaletteColor::valueOf),
|
||||
)
|
||||
)
|
||||
}
|
||||
}.ifEmpty { defaultFlashSequence() }
|
||||
}
|
||||
|
||||
private fun flashSequenceArray(sequence: List<FlashSequenceRow>): JSONArray {
|
||||
val arr = JSONArray()
|
||||
sequence.forEach { row ->
|
||||
arr.put(
|
||||
JSONObject()
|
||||
.put("splitScreen", row.splitScreen)
|
||||
.put("leftColor", row.leftColor.name)
|
||||
.put("rightColor", row.rightColor.name)
|
||||
)
|
||||
}
|
||||
return arr
|
||||
}
|
||||
}
|
||||
|
||||
data class UserProgramEntity(
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package solutions.tretter.mindmachine.domain
|
||||
|
||||
import solutions.tretter.mindmachine.session.FlashSequenceRow
|
||||
import solutions.tretter.mindmachine.session.defaultFlashSequence
|
||||
|
||||
enum class VisualPattern { FLASH, PULSE }
|
||||
enum class SessionMode { AUDIO_VISUAL, AUDIO_ONLY, VISUAL_ONLY }
|
||||
enum class RuntimeState { IDLE, COUNTDOWN, RUNNING, PAUSED, INTERRUPTED, COMPLETED, STOPPED, ERROR }
|
||||
@@ -17,6 +20,7 @@ data class SessionPreset(
|
||||
val cautionNote: String,
|
||||
val sortOrder: Int,
|
||||
val isUserProgram: Boolean = false,
|
||||
val flashSequence: List<FlashSequenceRow> = defaultFlashSequence(),
|
||||
)
|
||||
|
||||
data class SessionConfig(
|
||||
@@ -25,6 +29,7 @@ data class SessionConfig(
|
||||
val mode: SessionMode,
|
||||
val visualPatternType: VisualPattern,
|
||||
val flashIntervalMs: Int,
|
||||
val flashSequence: List<FlashSequenceRow> = defaultFlashSequence(),
|
||||
val monochromeFlashMode: Boolean = false,
|
||||
val intensityPercent: Int,
|
||||
val carrierFrequencyHz: Float,
|
||||
@@ -97,6 +102,7 @@ fun SessionPreset.toConfig(defaultMode: SessionMode = SessionMode.AUDIO_VISUAL)
|
||||
mode = defaultMode,
|
||||
visualPatternType = visualPatternType,
|
||||
flashIntervalMs = flashIntervalMs,
|
||||
flashSequence = flashSequence,
|
||||
monochromeFlashMode = false,
|
||||
intensityPercent = intensityPercent,
|
||||
carrierFrequencyHz = carrierFrequencyHz,
|
||||
|
||||
@@ -18,7 +18,7 @@ import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private const val MIN_DURATION_SEC = 60
|
||||
private const val MAX_DURATION_SEC = 8 * 60 * 60
|
||||
private const val MAX_DURATION_SEC = 60 * 60
|
||||
|
||||
@Composable
|
||||
fun DurationSliderCard(
|
||||
@@ -51,14 +51,15 @@ fun DurationSliderCard(
|
||||
Slider(
|
||||
value = clamped.toFloat(),
|
||||
onValueChange = {
|
||||
val snapped = (it / 60f).roundToInt().coerceIn(1, 8 * 60) * 60
|
||||
val snapped = (it / 60f).roundToInt().coerceIn(1, 60) * 60
|
||||
onDurationSecChanged(snapped)
|
||||
},
|
||||
valueRange = MIN_DURATION_SEC.toFloat()..MAX_DURATION_SEC.toFloat(),
|
||||
steps = 58,
|
||||
)
|
||||
|
||||
Text(
|
||||
"Range: 1 minute to 8 hours",
|
||||
"Range: 1 minute to 60 minutes",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package solutions.tretter.mindmachine.session
|
||||
|
||||
enum class FlashPaletteColor {
|
||||
BLACK,
|
||||
RED,
|
||||
GREEN,
|
||||
YELLOW,
|
||||
BLUE,
|
||||
}
|
||||
|
||||
data class FlashSequenceRow(
|
||||
val splitScreen: Boolean = true,
|
||||
val leftColor: FlashPaletteColor = FlashPaletteColor.RED,
|
||||
val rightColor: FlashPaletteColor = FlashPaletteColor.GREEN,
|
||||
)
|
||||
|
||||
fun defaultFlashSequence(): List<FlashSequenceRow> = listOf(
|
||||
FlashSequenceRow(splitScreen = true, leftColor = FlashPaletteColor.RED, rightColor = FlashPaletteColor.GREEN)
|
||||
)
|
||||
@@ -0,0 +1,142 @@
|
||||
package solutions.tretter.mindmachine.session
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun FlashSequenceEditor(
|
||||
sequence: List<FlashSequenceRow>,
|
||||
onSequenceChanged: (List<FlashSequenceRow>) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val rows = sequence.ifEmpty { defaultFlashSequence() }
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface, RoundedCornerShape(12.dp))
|
||||
.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text("Color sequence", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface)
|
||||
Text(
|
||||
"Each row is one visible color state. Split screen uses separate left/right colors. Non-split rows fill the whole screen with one color.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.72f),
|
||||
)
|
||||
|
||||
rows.forEachIndexed { index, row ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
|
||||
Checkbox(
|
||||
checked = row.splitScreen,
|
||||
onCheckedChange = { checked ->
|
||||
onSequenceChanged(rows.toMutableList().also { it[index] = row.copy(splitScreen = checked) })
|
||||
}
|
||||
)
|
||||
Text("Split screen", color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
|
||||
ColorCycleButton(
|
||||
label = if (row.splitScreen) "Left" else "Color",
|
||||
color = row.leftColor,
|
||||
onClick = {
|
||||
onSequenceChanged(rows.toMutableList().also {
|
||||
it[index] = row.copy(leftColor = row.leftColor.next())
|
||||
})
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
|
||||
if (row.splitScreen) {
|
||||
ColorCycleButton(
|
||||
label = "Right",
|
||||
color = row.rightColor,
|
||||
onClick = {
|
||||
onSequenceChanged(rows.toMutableList().also {
|
||||
it[index] = row.copy(rightColor = row.rightColor.next())
|
||||
})
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
} else {
|
||||
Box(modifier = Modifier.weight(1f))
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
onSequenceChanged(rows.filterIndexed { i, _ -> i != index }.ifEmpty { defaultFlashSequence() })
|
||||
}
|
||||
) {
|
||||
Icon(Icons.Default.Delete, contentDescription = "Remove row", tint = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { onSequenceChanged(rows + FlashSequenceRow()) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(Icons.Default.Add, contentDescription = null)
|
||||
Text(" Add color row")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColorCycleButton(
|
||||
label: String,
|
||||
color: FlashPaletteColor,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
OutlinedButton(onClick = onClick, modifier = modifier) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(end = 8.dp)
|
||||
.background(color.previewColor(), RoundedCornerShape(6.dp))
|
||||
.padding(horizontal = 10.dp, vertical = 10.dp)
|
||||
)
|
||||
Text("$label: ${color.displayName}")
|
||||
}
|
||||
}
|
||||
|
||||
private val FlashPaletteColor.displayName: String
|
||||
get() = name.lowercase().replaceFirstChar { it.uppercase() }
|
||||
|
||||
private fun FlashPaletteColor.next(): FlashPaletteColor {
|
||||
val values = FlashPaletteColor.entries
|
||||
return values[(ordinal + 1) % values.size]
|
||||
}
|
||||
|
||||
private fun FlashPaletteColor.previewColor(): Color = when (this) {
|
||||
FlashPaletteColor.BLACK -> Color.Black
|
||||
FlashPaletteColor.RED -> Color(0xFFD32F2F)
|
||||
FlashPaletteColor.GREEN -> Color(0xFF2E7D32)
|
||||
FlashPaletteColor.YELLOW -> Color(0xFFF9A825)
|
||||
FlashPaletteColor.BLUE -> Color(0xFF1565C0)
|
||||
}
|
||||
@@ -175,7 +175,7 @@ class MainViewModel(
|
||||
val savedSignature = timelineProgramSignature(timeline)
|
||||
it.copy(
|
||||
selectedPreset = preset,
|
||||
config = preset.toConfig(SessionMode.AUDIO_VISUAL),
|
||||
config = preset.toConfig(SessionMode.AUDIO_VISUAL).copy(flashSequence = timeline.flashSequence),
|
||||
timeline = timeline,
|
||||
savedProgramName = displayName,
|
||||
savedTimelineSignature = savedSignature,
|
||||
@@ -237,7 +237,7 @@ class MainViewModel(
|
||||
val savedSignature = timelineProgramSignature(timeline)
|
||||
it.copy(
|
||||
selectedPreset = fallback,
|
||||
config = fallback.toConfig(SessionMode.AUDIO_VISUAL),
|
||||
config = fallback.toConfig(SessionMode.AUDIO_VISUAL).copy(flashSequence = timeline.flashSequence),
|
||||
timeline = timeline,
|
||||
savedProgramName = fallback.name,
|
||||
savedTimelineSignature = savedSignature,
|
||||
@@ -251,7 +251,7 @@ class MainViewModel(
|
||||
fun setMode(mode: SessionMode) = _ui.update { it.copy(config = it.config.copy(mode = mode), error = null) }
|
||||
|
||||
fun setDurationSec(seconds: Int) = _ui.update {
|
||||
val clamped = seconds.coerceIn(60, 8 * 60 * 60)
|
||||
val clamped = seconds.coerceIn(60, 60 * 60)
|
||||
val nextTimeline = it.timeline.setDurationSec(clamped)
|
||||
it.copy(
|
||||
config = it.config.copy(durationSec = clamped),
|
||||
@@ -273,7 +273,10 @@ class MainViewModel(
|
||||
fun updateTimeline(newState: TimelineEditorState) = _ui.update {
|
||||
it.copy(
|
||||
timeline = newState,
|
||||
config = it.config.copy(durationSec = newState.durationSec.coerceIn(60, 8 * 60 * 60)),
|
||||
config = it.config.copy(
|
||||
durationSec = newState.durationSec.coerceIn(60, 60 * 60),
|
||||
flashSequence = newState.flashSequence,
|
||||
),
|
||||
hasUnsavedChanges = isDirty(it.selectedPreset.name, newState, it.savedProgramName, it.savedTimelineSignature),
|
||||
error = null,
|
||||
)
|
||||
@@ -451,7 +454,10 @@ class MainViewModel(
|
||||
val savedSignature = timelineProgramSignature(timeline)
|
||||
it.copy(
|
||||
selectedPreset = preset,
|
||||
config = cfg.copy(durationSec = cfg.durationSec.coerceIn(60, 8 * 60 * 60)),
|
||||
config = cfg.copy(
|
||||
durationSec = cfg.durationSec.coerceIn(60, 60 * 60),
|
||||
flashSequence = timeline.flashSequence,
|
||||
),
|
||||
timeline = timeline,
|
||||
savedProgramName = preset.name,
|
||||
savedTimelineSignature = savedSignature,
|
||||
|
||||
@@ -3,7 +3,7 @@ package solutions.tretter.mindmachine.session
|
||||
import solutions.tretter.mindmachine.domain.SessionConfig
|
||||
object SessionValidator {
|
||||
fun validate(config: SessionConfig): String? {
|
||||
if (config.durationSec !in 60..(8 * 60 * 60)) return "Duration must be 1 minute to 8 hours."
|
||||
if (config.durationSec !in 60..(60 * 60)) return "Duration must be 1 minute to 60 minutes."
|
||||
if (config.flashIntervalMs !in 50..2000) return "Flash interval must be 0.05 to 2.00 seconds."
|
||||
val carrier = config.carrierFrequencyHz
|
||||
if (!carrier.isFinite() || carrier < ParameterRanges.CARRIER_HZ_MIN || carrier > ParameterRanges.CARRIER_HZ_MAX) {
|
||||
|
||||
@@ -27,6 +27,9 @@ fun SetupTimelineEditor(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var local by remember(state) { mutableStateOf(state) }
|
||||
val allowedGranularity = remember(local.durationSec) { TimelineEditorState.allowedGranularitySteps(local.durationSec) }
|
||||
val sliderMin = allowedGranularity.first().toFloat()
|
||||
val sliderMax = allowedGranularity.last().toFloat()
|
||||
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
@@ -38,7 +41,7 @@ fun SetupTimelineEditor(
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Text(
|
||||
"Adjust curve granularity, then drag dots up/down to shape the session. Pinch to zoom for precision.",
|
||||
"Adjust curve granularity, then drag dots up/down to shape the session. Granularity always divides the full duration evenly.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.75f),
|
||||
)
|
||||
@@ -49,22 +52,22 @@ fun SetupTimelineEditor(
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
var granularitySliderValue by remember(state.curveGranularitySec) {
|
||||
var granularitySliderValue by remember(state.curveGranularitySec, state.durationSec) {
|
||||
mutableFloatStateOf(state.curveGranularitySec.toFloat())
|
||||
}
|
||||
Slider(
|
||||
value = granularitySliderValue,
|
||||
value = granularitySliderValue.coerceIn(sliderMin, sliderMax),
|
||||
onValueChange = { granularitySliderValue = it },
|
||||
onValueChangeFinished = {
|
||||
val snapped = TimelineEditorState.normalizeGranularitySec(granularitySliderValue.roundToInt())
|
||||
val snapped = TimelineEditorState.normalizeGranularitySec(granularitySliderValue.roundToInt(), local.durationSec)
|
||||
if (snapped != local.curveGranularitySec) {
|
||||
local = local.setCurveGranularitySec(snapped).also(onStateChanged)
|
||||
onCurveGranularityChanged(snapped)
|
||||
}
|
||||
granularitySliderValue = local.curveGranularitySec.toFloat()
|
||||
},
|
||||
valueRange = TIMELINE_MIN_GRANULARITY_SEC.toFloat()..TIMELINE_MAX_GRANULARITY_SEC.toFloat(),
|
||||
steps = ((TIMELINE_MAX_GRANULARITY_SEC - TIMELINE_MIN_GRANULARITY_SEC) / 5) - 1,
|
||||
valueRange = sliderMin..sliderMax,
|
||||
steps = max(0, allowedGranularity.size - 2),
|
||||
)
|
||||
|
||||
TimelineGraph(
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
package solutions.tretter.mindmachine.session
|
||||
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
private const val MIN_DURATION_SEC = 60
|
||||
private const val MAX_DURATION_SEC = 8 * 60 * 60
|
||||
const val TIMELINE_MIN_GRANULARITY_SEC = 5
|
||||
const val TIMELINE_MAX_GRANULARITY_SEC = 300
|
||||
private const val MAX_DURATION_SEC = 60 * 60
|
||||
const val TIMELINE_MIN_GRANULARITY_SEC = 30
|
||||
const val TIMELINE_MAX_GRANULARITY_SEC = 30 * 60
|
||||
const val TIMELINE_DEFAULT_GRANULARITY_SEC = 60
|
||||
|
||||
data class TimelineEditorState(
|
||||
val durationSec: Int,
|
||||
val curveGranularitySec: Int,
|
||||
val flashSequence: List<FlashSequenceRow>,
|
||||
val selection: TimelineSelection,
|
||||
val viewport: TimelineViewport,
|
||||
val activeCurve: ActiveCurve,
|
||||
@@ -32,11 +34,12 @@ data class TimelineEditorState(
|
||||
companion object {
|
||||
fun default(durationSec: Int = 20 * 60): TimelineEditorState {
|
||||
val dur = snapDuration(durationSec)
|
||||
val granularitySec = TIMELINE_DEFAULT_GRANULARITY_SEC
|
||||
val granularitySec = normalizeGranularitySec(TIMELINE_DEFAULT_GRANULARITY_SEC, dur)
|
||||
val initial = TimelineCurve.constant(0.5f, dur, granularitySec)
|
||||
return TimelineEditorState(
|
||||
durationSec = dur,
|
||||
curveGranularitySec = granularitySec,
|
||||
flashSequence = defaultFlashSequence(),
|
||||
selection = TimelineSelection(0, dur),
|
||||
viewport = TimelineViewport(
|
||||
startSec = 0f,
|
||||
@@ -52,11 +55,21 @@ data class TimelineEditorState(
|
||||
)
|
||||
}
|
||||
|
||||
private fun snapDuration(durationSec: Int): Int =
|
||||
fun snapDuration(durationSec: Int): Int =
|
||||
((durationSec.coerceIn(MIN_DURATION_SEC, MAX_DURATION_SEC) + 30) / 60) * 60
|
||||
|
||||
fun normalizeGranularitySec(granularitySec: Int): Int =
|
||||
((granularitySec.coerceIn(TIMELINE_MIN_GRANULARITY_SEC, TIMELINE_MAX_GRANULARITY_SEC) + 2) / 5) * 5
|
||||
fun allowedGranularitySteps(durationSec: Int): List<Int> {
|
||||
val duration = snapDuration(durationSec)
|
||||
val maxStep = min(TIMELINE_MAX_GRANULARITY_SEC, duration)
|
||||
return (TIMELINE_MIN_GRANULARITY_SEC..maxStep step TIMELINE_MIN_GRANULARITY_SEC)
|
||||
.filter { duration % it == 0 }
|
||||
.ifEmpty { listOf(duration) }
|
||||
}
|
||||
|
||||
fun normalizeGranularitySec(granularitySec: Int, durationSec: Int): Int {
|
||||
val allowed = allowedGranularitySteps(durationSec)
|
||||
return allowed.minByOrNull { abs(it - granularitySec) } ?: allowed.first()
|
||||
}
|
||||
}
|
||||
|
||||
fun curveFor(id: ActiveCurve): TimelineCurve = when (id) {
|
||||
@@ -76,35 +89,45 @@ data class TimelineEditorState(
|
||||
}
|
||||
}
|
||||
|
||||
fun withFlashSequence(sequence: List<FlashSequenceRow>): TimelineEditorState =
|
||||
copy(flashSequence = sequence.ifEmpty { defaultFlashSequence() })
|
||||
|
||||
fun extendDurationTo(atLeastSec: Int): TimelineEditorState {
|
||||
val newDur = ((atLeastSec.coerceIn(MIN_DURATION_SEC, MAX_DURATION_SEC) + 30) / 60) * 60
|
||||
val newDur = snapDuration(atLeastSec)
|
||||
if (newDur <= durationSec) return this
|
||||
|
||||
val normalizedGranularity = normalizeGranularitySec(curveGranularitySec, newDur)
|
||||
return copy(
|
||||
durationSec = newDur,
|
||||
curveGranularitySec = normalizedGranularity,
|
||||
selection = selection.copy(endSec = max(selection.endSec, newDur)),
|
||||
visualLeft = visualLeft.extendTo(newDur, curveGranularitySec),
|
||||
visualRight = visualRight.extendTo(newDur, curveGranularitySec),
|
||||
audioLeft = audioLeft.extendTo(newDur, curveGranularitySec),
|
||||
audioRight = audioRight.extendTo(newDur, curveGranularitySec),
|
||||
visualLeft = visualLeft.extendTo(newDur, normalizedGranularity),
|
||||
visualRight = visualRight.extendTo(newDur, normalizedGranularity),
|
||||
audioLeft = audioLeft.extendTo(newDur, normalizedGranularity),
|
||||
audioRight = audioRight.extendTo(newDur, normalizedGranularity),
|
||||
)
|
||||
}
|
||||
|
||||
fun setDurationSec(newDurationSec: Int): TimelineEditorState {
|
||||
val clamped = ((newDurationSec.coerceIn(MIN_DURATION_SEC, MAX_DURATION_SEC) + 30) / 60) * 60
|
||||
val clamped = snapDuration(newDurationSec)
|
||||
if (clamped == durationSec) return this
|
||||
val normalizedGranularity = normalizeGranularitySec(curveGranularitySec, clamped)
|
||||
|
||||
return if (clamped > durationSec) {
|
||||
extendDurationTo(clamped).copy(selection = selection.copy(startSec = 0, endSec = clamped))
|
||||
extendDurationTo(clamped).copy(
|
||||
curveGranularitySec = normalizedGranularity,
|
||||
selection = selection.copy(startSec = 0, endSec = clamped),
|
||||
)
|
||||
} else {
|
||||
val newViewport = viewport.copy(startSec = viewport.startSec.coerceAtMost(clamped.toFloat()))
|
||||
copy(
|
||||
durationSec = clamped,
|
||||
curveGranularitySec = normalizedGranularity,
|
||||
selection = TimelineSelection(0, clamped),
|
||||
visualLeft = visualLeft.trimTo(clamped, curveGranularitySec),
|
||||
visualRight = visualRight.trimTo(clamped, curveGranularitySec),
|
||||
audioLeft = audioLeft.trimTo(clamped, curveGranularitySec),
|
||||
audioRight = audioRight.trimTo(clamped, curveGranularitySec),
|
||||
visualLeft = visualLeft.trimTo(clamped, normalizedGranularity),
|
||||
visualRight = visualRight.trimTo(clamped, normalizedGranularity),
|
||||
audioLeft = audioLeft.trimTo(clamped, normalizedGranularity),
|
||||
audioRight = audioRight.trimTo(clamped, normalizedGranularity),
|
||||
viewport = newViewport,
|
||||
playheadSec = playheadSec.coerceIn(0f, clamped.toFloat()),
|
||||
)
|
||||
@@ -112,7 +135,7 @@ data class TimelineEditorState(
|
||||
}
|
||||
|
||||
fun setCurveGranularitySec(newGranularitySec: Int): TimelineEditorState {
|
||||
val normalized = normalizeGranularitySec(newGranularitySec)
|
||||
val normalized = normalizeGranularitySec(newGranularitySec, durationSec)
|
||||
if (normalized == curveGranularitySec) return this
|
||||
|
||||
return copy(
|
||||
@@ -191,7 +214,7 @@ data class TimelineCurve(
|
||||
companion object {
|
||||
fun constant(value01: Float, durationSec: Int, granularitySec: Int = TIMELINE_DEFAULT_GRANULARITY_SEC): TimelineCurve {
|
||||
val v = value01.coerceIn(0f, 1f)
|
||||
val stepSec = TimelineEditorState.normalizeGranularitySec(granularitySec)
|
||||
val stepSec = TimelineEditorState.normalizeGranularitySec(granularitySec, durationSec)
|
||||
return TimelineCurve(
|
||||
points = (0..durationSec step stepSec).map { TimelinePoint(it, v) }
|
||||
)
|
||||
@@ -214,8 +237,8 @@ data class TimelineCurve(
|
||||
}
|
||||
|
||||
fun ensureTimelinePoints(durationSec: Int, granularitySec: Int): TimelineCurve {
|
||||
val snappedDuration = ((durationSec.coerceIn(MIN_DURATION_SEC, MAX_DURATION_SEC) + 30) / 60) * 60
|
||||
val stepSec = TimelineEditorState.normalizeGranularitySec(granularitySec)
|
||||
val snappedDuration = TimelineEditorState.snapDuration(durationSec)
|
||||
val stepSec = TimelineEditorState.normalizeGranularitySec(granularitySec, snappedDuration)
|
||||
val normalized = (0..snappedDuration step stepSec).map { tSec ->
|
||||
TimelinePoint(tSec, valueAt(tSec.toFloat()))
|
||||
}
|
||||
@@ -236,7 +259,6 @@ data class TimelineCurve(
|
||||
if (index !in points.indices) return this
|
||||
val v = (1f - (newY / heightPx)).coerceIn(0f, 1f)
|
||||
val updated = points.toMutableList()
|
||||
// Update the dragged point and all points after it (same or later time)
|
||||
for (i in index until updated.size) {
|
||||
updated[i] = updated[i].copy(value01 = v)
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ data class TimelineModel(
|
||||
mode = mode,
|
||||
visualPatternType = preset.visualPatternType,
|
||||
flashIntervalMs = flashIntervalMs,
|
||||
flashSequence = preset.flashSequence,
|
||||
monochromeFlashMode = false,
|
||||
intensityPercent = preset.intensityPercent,
|
||||
carrierFrequencyHz = carrier,
|
||||
|
||||
@@ -48,6 +48,7 @@ object TimelineProgramFactory {
|
||||
cautionNote = "Stop if discomfort occurs.",
|
||||
sortOrder = sortOrder,
|
||||
isUserProgram = isUserProgram,
|
||||
flashSequence = timeline.flashSequence,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -74,6 +75,7 @@ object TimelineProgramFactory {
|
||||
return TimelineEditorState(
|
||||
durationSec = dur,
|
||||
curveGranularitySec = TIMELINE_DEFAULT_GRANULARITY_SEC,
|
||||
flashSequence = preset.flashSequence,
|
||||
selection = TimelineSelection(0, dur),
|
||||
viewport = TimelineViewport(startSec = 0f, secondsPerScreen = minOf(10f * 60f, dur.toFloat())),
|
||||
activeCurve = TimelineEditorState.ActiveCurve.VISUAL_LEFT,
|
||||
@@ -124,6 +126,7 @@ object TimelineProgramFactory {
|
||||
return TimelineEditorState(
|
||||
durationSec = durationSec,
|
||||
curveGranularitySec = TIMELINE_DEFAULT_GRANULARITY_SEC,
|
||||
flashSequence = defaultFlashSequence(),
|
||||
selection = TimelineSelection(0, durationSec),
|
||||
viewport = TimelineViewport(startSec = 0f, secondsPerScreen = minOf(10f * 60f, durationSec.toFloat())),
|
||||
activeCurve = TimelineEditorState.ActiveCurve.VISUAL_LEFT,
|
||||
|
||||
Reference in New Issue
Block a user