Auto commit Sun Mar 22 12:01:01 AM CDT 2026

This commit is contained in:
Tretzi
2026-03-22 00:01:01 -05:00
parent 869c98a47c
commit 88723bf214
10 changed files with 226 additions and 56 deletions

View File

@@ -53,6 +53,7 @@ import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
@@ -82,7 +83,7 @@ import com.mindmachine.mvp.domain.SessionMode
import com.mindmachine.mvp.session.FlashColor
import com.mindmachine.mvp.session.MainViewModel
import com.mindmachine.mvp.session.SplitFlashFrame
import com.mindmachine.mvp.session.computeSplitFlashFrame
import com.mindmachine.mvp.session.SplitFlashSequencer
import com.mindmachine.mvp.session.flashIntervalMsToSeconds
import com.mindmachine.mvp.session.flashIntervalSecondsToMs
import com.mindmachine.mvp.session.sessionProgressFraction
@@ -204,8 +205,10 @@ fun App(vm: MainViewModel = viewModel()) {
SetupScreen(
vm = vm,
onStart = {
vm.startSession()
nav.navigate("active")
val started = vm.startSession()
if (started) {
nav.navigate("active")
}
},
onHolder = { nav.navigate("holder") },
)
@@ -223,8 +226,10 @@ fun App(vm: MainViewModel = viewModel()) {
ui.selectedPreset.name
) {
Button(onClick = {
vm.startSession()
nav.navigate("active")
val started = vm.startSession()
if (started) {
nav.navigate("active")
}
}) { Text("Repeat Session") }
OutlinedButton(onClick = { nav.navigate("home") { popUpTo(0) } }) { Text("Return Home") }
}
@@ -313,6 +318,7 @@ fun SetupScreen(
SetupTimelineEditor(
state = ui.timeline,
onStateChanged = vm::updateTimeline,
onCurveGranularityChanged = vm::setCurveGranularitySec,
)
TextButton(onClick = onHolder) { Text("Holder Guidance") }
@@ -503,14 +509,27 @@ private fun rememberSplitFlashFrame(
mode: SessionMode,
flashOnMs: Int,
flashOffMs: Int,
) = produceState(initialValue = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK), isRunning, mode, flashOnMs, flashOffMs) {
if (!isRunning || mode == SessionMode.AUDIO_ONLY) {
value = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK)
return@produceState
}
while (true) {
withFrameNanos { frameTimeNanos ->
value = computeSplitFlashFrame(frameTimeNanos, flashOnMs, flashOffMs)
): androidx.compose.runtime.State<SplitFlashFrame> {
val latestFlashOnMs = rememberUpdatedState(flashOnMs)
val latestFlashOffMs = rememberUpdatedState(flashOffMs)
return produceState(initialValue = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK), isRunning, mode) {
if (!isRunning || mode == SessionMode.AUDIO_ONLY) {
value = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK)
return@produceState
}
val sequencer = SplitFlashSequencer(
startTimeNanos = System.nanoTime(),
flashOnMs = latestFlashOnMs.value,
flashOffMs = latestFlashOffMs.value,
)
while (true) {
withFrameNanos { frameTimeNanos ->
sequencer.updateIntervals(latestFlashOnMs.value, latestFlashOffMs.value)
value = sequencer.frameAt(frameTimeNanos)
}
}
}
}

View File

@@ -7,6 +7,7 @@ import androidx.datastore.preferences.preferencesDataStore
import com.mindmachine.mvp.session.TimelineCurve
import com.mindmachine.mvp.session.TimelineEditorState
import com.mindmachine.mvp.session.TimelinePoint
import com.mindmachine.mvp.session.TIMELINE_DEFAULT_GRANULARITY_SEC
import com.mindmachine.mvp.session.TimelineSelection
import com.mindmachine.mvp.session.TimelineViewport
import kotlinx.coroutines.flow.Flow
@@ -81,6 +82,9 @@ class UserProgramRepository(private val context: Context) {
return TimelineEditorState(
durationSec = o.getInt("durationSec"),
curveGranularitySec = TimelineEditorState.normalizeGranularitySec(
o.optInt("curveGranularitySec", TIMELINE_DEFAULT_GRANULARITY_SEC)
),
selection = TimelineSelection(
startSec = o.optInt("selectionStartSec", 0),
endSec = o.optInt("selectionEndSec", o.getInt("durationSec")),
@@ -110,6 +114,7 @@ class UserProgramRepository(private val context: Context) {
val timeline = JSONObject()
timeline.put("durationSec", program.timeline.durationSec)
timeline.put("curveGranularitySec", program.timeline.curveGranularitySec)
timeline.put("selectionStartSec", program.timeline.selection.startSec)
timeline.put("selectionEndSec", program.timeline.selection.endSec)
timeline.put("viewportStartSec", program.timeline.viewport.startSec)

View File

@@ -210,6 +210,13 @@ class MainViewModel(
)
}
fun setCurveGranularitySec(seconds: Int) = _ui.update {
it.copy(
timeline = it.timeline.setCurveGranularitySec(seconds),
error = null,
)
}
fun updateTimeline(newState: TimelineEditorState) = _ui.update {
it.copy(
timeline = newState,
@@ -227,21 +234,22 @@ class MainViewModel(
fun updateGuidance(value: Boolean) = viewModelScope.launch { settingsRepository.updateGuidance(value) }
fun updateShowImmersiveProgressBar(value: Boolean) = viewModelScope.launch { settingsRepository.updateShowImmersiveProgressBar(value) }
fun startSession() {
fun startSession(): Boolean {
val state = _ui.value
if (!(state.settings.safetyAcknowledged && state.settings.safetyAcknowledgedVersion >= SAFETY_VERSION)) {
_ui.update { it.copy(error = "You must acknowledge safety before starting sessions.") }
return
return false
}
val headset = headsetMonitor.isStereoHeadsetAvailable()
val validation = SessionValidator.validate(state.config, headset)
if (validation != null) {
_ui.update { it.copy(error = validation) }
return
return false
}
elapsedBeforePauseSec = 0f
runProgram(startElapsedSec = 0f, includeCountdown = true)
return true
}
fun pause(reason: String? = null) {

View File

@@ -6,20 +6,24 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlin.math.max
import kotlin.math.roundToInt
@Composable
fun SetupTimelineEditor(
state: TimelineEditorState,
onStateChanged: (TimelineEditorState) -> Unit,
onCurveGranularityChanged: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
var local by remember(state) { mutableStateOf(state) }
@@ -34,11 +38,35 @@ fun SetupTimelineEditor(
color = MaterialTheme.colorScheme.onBackground,
)
Text(
"Each dot is one minute. 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. Pinch to zoom for precision.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.75f),
)
val granularitySec = local.curveGranularitySec
Text(
"Curve granularity: ${granularitySec}s per dot",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground,
)
var granularitySliderValue by remember(state.curveGranularitySec) {
mutableFloatStateOf(state.curveGranularitySec.toFloat())
}
Slider(
value = granularitySliderValue,
onValueChange = { granularitySliderValue = it },
onValueChangeFinished = {
val snapped = TimelineEditorState.normalizeGranularitySec(granularitySliderValue.roundToInt())
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,
)
TimelineGraph(
title = "Visual",
leftLabel = "Flash Interval",

View File

@@ -506,7 +506,7 @@ internal fun TimelineGraph(
}
Text(
"One dot per minute • Drag selected curve dots up/down • Pinch zoom • Drag background pan • Drag handles to set range",
"Dots follow selected granularity • Drag selected curve dots up/down • Pinch zoom • Drag background pan • Drag handles to set range",
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f),
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
@@ -519,13 +519,7 @@ internal fun TimelineGraph(
private fun formatTimeLabel(totalSec: Float): String {
val totalMinutes = (totalSec / 60f).toInt().coerceAtLeast(0)
val hours = totalMinutes / 60
val minutes = totalMinutes % 60
return if (hours > 0) {
if (minutes == 0) "${hours}h" else "${hours}h ${minutes}m"
} else {
"${minutes}m"
}
return "${totalMinutes} min"
}
private fun formatMsLabel(value: Float): String = "${value.toInt()} ms"

View File

@@ -1,15 +1,17 @@
package com.mindmachine.mvp.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
private const val TIMELINE_STEP_SEC = 60
const val TIMELINE_MIN_GRANULARITY_SEC = 5
const val TIMELINE_MAX_GRANULARITY_SEC = 300
const val TIMELINE_DEFAULT_GRANULARITY_SEC = 60
data class TimelineEditorState(
val durationSec: Int,
val curveGranularitySec: Int,
val selection: TimelineSelection,
val viewport: TimelineViewport,
val activeCurve: ActiveCurve,
@@ -30,9 +32,11 @@ data class TimelineEditorState(
companion object {
fun default(durationSec: Int = 20 * 60): TimelineEditorState {
val dur = snapDuration(durationSec)
val initial = TimelineCurve.constant(0.5f, dur)
val granularitySec = TIMELINE_DEFAULT_GRANULARITY_SEC
val initial = TimelineCurve.constant(0.5f, dur, granularitySec)
return TimelineEditorState(
durationSec = dur,
curveGranularitySec = granularitySec,
selection = TimelineSelection(0, dur),
viewport = TimelineViewport(
startSec = 0f,
@@ -50,6 +54,9 @@ data class TimelineEditorState(
private 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 curveFor(id: ActiveCurve): TimelineCurve = when (id) {
@@ -60,7 +67,7 @@ data class TimelineEditorState(
}
fun withCurve(id: ActiveCurve, curve: TimelineCurve): TimelineEditorState {
val normalized = curve.ensureMinutePoints(durationSec)
val normalized = curve.ensureTimelinePoints(durationSec, curveGranularitySec)
return when (id) {
ActiveCurve.VISUAL_LEFT -> copy(visualLeft = normalized)
ActiveCurve.VISUAL_RIGHT -> copy(visualRight = normalized)
@@ -76,10 +83,10 @@ data class TimelineEditorState(
return copy(
durationSec = newDur,
selection = selection.copy(endSec = max(selection.endSec, newDur)),
visualLeft = visualLeft.extendTo(newDur),
visualRight = visualRight.extendTo(newDur),
audioLeft = audioLeft.extendTo(newDur),
audioRight = audioRight.extendTo(newDur),
visualLeft = visualLeft.extendTo(newDur, curveGranularitySec),
visualRight = visualRight.extendTo(newDur, curveGranularitySec),
audioLeft = audioLeft.extendTo(newDur, curveGranularitySec),
audioRight = audioRight.extendTo(newDur, curveGranularitySec),
)
}
@@ -94,16 +101,29 @@ data class TimelineEditorState(
copy(
durationSec = clamped,
selection = TimelineSelection(0, clamped),
visualLeft = visualLeft.trimTo(clamped),
visualRight = visualRight.trimTo(clamped),
audioLeft = audioLeft.trimTo(clamped),
audioRight = audioRight.trimTo(clamped),
visualLeft = visualLeft.trimTo(clamped, curveGranularitySec),
visualRight = visualRight.trimTo(clamped, curveGranularitySec),
audioLeft = audioLeft.trimTo(clamped, curveGranularitySec),
audioRight = audioRight.trimTo(clamped, curveGranularitySec),
viewport = newViewport,
playheadSec = playheadSec.coerceIn(0f, clamped.toFloat()),
)
}
}
fun setCurveGranularitySec(newGranularitySec: Int): TimelineEditorState {
val normalized = normalizeGranularitySec(newGranularitySec)
if (normalized == curveGranularitySec) return this
return copy(
curveGranularitySec = normalized,
visualLeft = visualLeft.ensureTimelinePoints(durationSec, normalized),
visualRight = visualRight.ensureTimelinePoints(durationSec, normalized),
audioLeft = audioLeft.ensureTimelinePoints(durationSec, normalized),
audioRight = audioRight.ensureTimelinePoints(durationSec, normalized),
)
}
fun clampSelection(): TimelineEditorState {
val start = selection.startSec.coerceIn(0, durationSec)
var end = selection.endSec.coerceIn(0, durationSec)
@@ -169,10 +189,11 @@ data class TimelineCurve(
val points: List<TimelinePoint>
) {
companion object {
fun constant(value01: Float, durationSec: Int): TimelineCurve {
fun constant(value01: Float, durationSec: Int, granularitySec: Int = TIMELINE_DEFAULT_GRANULARITY_SEC): TimelineCurve {
val v = value01.coerceIn(0f, 1f)
val stepSec = TimelineEditorState.normalizeGranularitySec(granularitySec)
return TimelineCurve(
points = (0..durationSec step TIMELINE_STEP_SEC).map { TimelinePoint(it, v) }
points = (0..durationSec step stepSec).map { TimelinePoint(it, v) }
)
}
}
@@ -192,15 +213,16 @@ data class TimelineCurve(
return (left.value01 + (right.value01 - left.value01) * u).coerceIn(0f, 1f)
}
fun ensureMinutePoints(durationSec: Int): TimelineCurve {
fun ensureTimelinePoints(durationSec: Int, granularitySec: Int): TimelineCurve {
val snappedDuration = ((durationSec.coerceIn(MIN_DURATION_SEC, MAX_DURATION_SEC) + 30) / 60) * 60
val normalized = (0..snappedDuration step TIMELINE_STEP_SEC).map { minuteSec ->
TimelinePoint(minuteSec, valueAt(minuteSec.toFloat()))
val stepSec = TimelineEditorState.normalizeGranularitySec(granularitySec)
val normalized = (0..snappedDuration step stepSec).map { tSec ->
TimelinePoint(tSec, valueAt(tSec.toFloat()))
}
return copy(points = normalized)
}
fun trimTo(newDurationSec: Int): TimelineCurve = ensureMinutePoints(newDurationSec)
fun trimTo(newDurationSec: Int, granularitySec: Int): TimelineCurve = ensureTimelinePoints(newDurationSec, granularitySec)
fun movePointVertical(index: Int, newY: Float, heightPx: Float): TimelineCurve {
if (index !in points.indices) return this
@@ -214,7 +236,6 @@ data class TimelineCurve(
if (index !in points.indices) return this
val v = (1f - (newY / heightPx)).coerceIn(0f, 1f)
val updated = points.toMutableList()
val draggedTime = updated[index].tSec
// 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)
@@ -235,7 +256,7 @@ data class TimelineCurve(
}.minByOrNull { it.second }?.first
}
fun extendTo(newDurationSec: Int): TimelineCurve = ensureMinutePoints(newDurationSec)
fun extendTo(newDurationSec: Int, granularitySec: Int): TimelineCurve = ensureTimelinePoints(newDurationSec, granularitySec)
}
data class TimelinePoint(

View File

@@ -73,6 +73,7 @@ object TimelineProgramFactory {
return TimelineEditorState(
durationSec = dur,
curveGranularitySec = TIMELINE_DEFAULT_GRANULARITY_SEC,
selection = TimelineSelection(0, dur),
viewport = TimelineViewport(startSec = 0f, secondsPerScreen = minOf(10f * 60f, dur.toFloat())),
activeCurve = TimelineEditorState.ActiveCurve.VISUAL_LEFT,
@@ -122,6 +123,7 @@ object TimelineProgramFactory {
return TimelineEditorState(
durationSec = durationSec,
curveGranularitySec = TIMELINE_DEFAULT_GRANULARITY_SEC,
selection = TimelineSelection(0, durationSec),
viewport = TimelineViewport(startSec = 0f, secondsPerScreen = minOf(10f * 60f, durationSec.toFloat())),
activeCurve = TimelineEditorState.ActiveCurve.VISUAL_LEFT,

View File

@@ -11,13 +11,72 @@ data class SplitFlashFrame(
val right: FlashColor,
)
private const val MIN_INTERVAL_MS = 50
private const val MAX_INTERVAL_MS = 2000
private fun clampIntervalMs(value: Int): Long = value.coerceIn(MIN_INTERVAL_MS, MAX_INTERVAL_MS).toLong()
class SplitFlashSequencer(
startTimeNanos: Long,
flashOnMs: Int,
flashOffMs: Int,
) {
private var onMs: Long = clampIntervalMs(flashOnMs)
private var offMs: Long = clampIntervalMs(flashOffMs)
private var pendingOnMs: Long = onMs
private var pendingOffMs: Long = offMs
private var phase: Int = 0
private var phaseStartNanos: Long = startTimeNanos
fun updateIntervals(flashOnMs: Int, flashOffMs: Int) {
pendingOnMs = clampIntervalMs(flashOnMs)
pendingOffMs = clampIntervalMs(flashOffMs)
}
fun frameAt(frameTimeNanos: Long): SplitFlashFrame {
if (frameTimeNanos < phaseStartNanos) {
phaseStartNanos = frameTimeNanos
phase = 0
}
while (frameTimeNanos - phaseStartNanos >= currentPhaseDurationNanos()) {
phaseStartNanos += currentPhaseDurationNanos()
phase = (phase + 1) % 4
applyPendingIfBoundary()
}
return frameForPhase(phase)
}
private fun applyPendingIfBoundary() {
if (phase == 0 || phase == 2) {
onMs = pendingOnMs
} else {
offMs = pendingOffMs
}
}
private fun currentPhaseDurationNanos(): Long {
val durationMs = if (phase == 0 || phase == 2) onMs else offMs
return durationMs * 1_000_000L
}
private fun frameForPhase(phase: Int): SplitFlashFrame = when (phase) {
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)
}
}
fun computeSplitFlashFrame(
frameTimeNanos: Long,
flashOnMs: Int,
flashOffMs: Int,
): SplitFlashFrame {
val onMs = flashOnMs.coerceIn(50, 2000).toLong()
val offMs = flashOffMs.coerceIn(50, 2000).toLong()
val onMs = clampIntervalMs(flashOnMs)
val offMs = clampIntervalMs(flashOffMs)
val elapsedMs = frameTimeNanos / 1_000_000L
// Four phases: on, off, on(swapped), off