Auto commit Sun Mar 22 12:01:01 AM CDT 2026
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user