Auto commit Sun Mar 22 12:01:01 AM CDT 2026
This commit is contained in:
@@ -53,6 +53,7 @@ import androidx.compose.runtime.mutableIntStateOf
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.produceState
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.withFrameNanos
|
import androidx.compose.runtime.withFrameNanos
|
||||||
import androidx.compose.ui.Alignment
|
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.FlashColor
|
||||||
import com.mindmachine.mvp.session.MainViewModel
|
import com.mindmachine.mvp.session.MainViewModel
|
||||||
import com.mindmachine.mvp.session.SplitFlashFrame
|
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.flashIntervalMsToSeconds
|
||||||
import com.mindmachine.mvp.session.flashIntervalSecondsToMs
|
import com.mindmachine.mvp.session.flashIntervalSecondsToMs
|
||||||
import com.mindmachine.mvp.session.sessionProgressFraction
|
import com.mindmachine.mvp.session.sessionProgressFraction
|
||||||
@@ -204,8 +205,10 @@ fun App(vm: MainViewModel = viewModel()) {
|
|||||||
SetupScreen(
|
SetupScreen(
|
||||||
vm = vm,
|
vm = vm,
|
||||||
onStart = {
|
onStart = {
|
||||||
vm.startSession()
|
val started = vm.startSession()
|
||||||
|
if (started) {
|
||||||
nav.navigate("active")
|
nav.navigate("active")
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onHolder = { nav.navigate("holder") },
|
onHolder = { nav.navigate("holder") },
|
||||||
)
|
)
|
||||||
@@ -223,8 +226,10 @@ fun App(vm: MainViewModel = viewModel()) {
|
|||||||
ui.selectedPreset.name
|
ui.selectedPreset.name
|
||||||
) {
|
) {
|
||||||
Button(onClick = {
|
Button(onClick = {
|
||||||
vm.startSession()
|
val started = vm.startSession()
|
||||||
|
if (started) {
|
||||||
nav.navigate("active")
|
nav.navigate("active")
|
||||||
|
}
|
||||||
}) { Text("Repeat Session") }
|
}) { Text("Repeat Session") }
|
||||||
OutlinedButton(onClick = { nav.navigate("home") { popUpTo(0) } }) { Text("Return Home") }
|
OutlinedButton(onClick = { nav.navigate("home") { popUpTo(0) } }) { Text("Return Home") }
|
||||||
}
|
}
|
||||||
@@ -313,6 +318,7 @@ fun SetupScreen(
|
|||||||
SetupTimelineEditor(
|
SetupTimelineEditor(
|
||||||
state = ui.timeline,
|
state = ui.timeline,
|
||||||
onStateChanged = vm::updateTimeline,
|
onStateChanged = vm::updateTimeline,
|
||||||
|
onCurveGranularityChanged = vm::setCurveGranularitySec,
|
||||||
)
|
)
|
||||||
|
|
||||||
TextButton(onClick = onHolder) { Text("Holder Guidance") }
|
TextButton(onClick = onHolder) { Text("Holder Guidance") }
|
||||||
@@ -503,14 +509,27 @@ private fun rememberSplitFlashFrame(
|
|||||||
mode: SessionMode,
|
mode: SessionMode,
|
||||||
flashOnMs: Int,
|
flashOnMs: Int,
|
||||||
flashOffMs: Int,
|
flashOffMs: Int,
|
||||||
) = produceState(initialValue = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK), isRunning, mode, 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) {
|
if (!isRunning || mode == SessionMode.AUDIO_ONLY) {
|
||||||
value = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK)
|
value = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK)
|
||||||
return@produceState
|
return@produceState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val sequencer = SplitFlashSequencer(
|
||||||
|
startTimeNanos = System.nanoTime(),
|
||||||
|
flashOnMs = latestFlashOnMs.value,
|
||||||
|
flashOffMs = latestFlashOffMs.value,
|
||||||
|
)
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
withFrameNanos { frameTimeNanos ->
|
withFrameNanos { frameTimeNanos ->
|
||||||
value = computeSplitFlashFrame(frameTimeNanos, flashOnMs, flashOffMs)
|
sequencer.updateIntervals(latestFlashOnMs.value, latestFlashOffMs.value)
|
||||||
|
value = sequencer.frameAt(frameTimeNanos)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import androidx.datastore.preferences.preferencesDataStore
|
|||||||
import com.mindmachine.mvp.session.TimelineCurve
|
import com.mindmachine.mvp.session.TimelineCurve
|
||||||
import com.mindmachine.mvp.session.TimelineEditorState
|
import com.mindmachine.mvp.session.TimelineEditorState
|
||||||
import com.mindmachine.mvp.session.TimelinePoint
|
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.TimelineSelection
|
||||||
import com.mindmachine.mvp.session.TimelineViewport
|
import com.mindmachine.mvp.session.TimelineViewport
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -81,6 +82,9 @@ class UserProgramRepository(private val context: Context) {
|
|||||||
|
|
||||||
return TimelineEditorState(
|
return TimelineEditorState(
|
||||||
durationSec = o.getInt("durationSec"),
|
durationSec = o.getInt("durationSec"),
|
||||||
|
curveGranularitySec = TimelineEditorState.normalizeGranularitySec(
|
||||||
|
o.optInt("curveGranularitySec", TIMELINE_DEFAULT_GRANULARITY_SEC)
|
||||||
|
),
|
||||||
selection = TimelineSelection(
|
selection = TimelineSelection(
|
||||||
startSec = o.optInt("selectionStartSec", 0),
|
startSec = o.optInt("selectionStartSec", 0),
|
||||||
endSec = o.optInt("selectionEndSec", o.getInt("durationSec")),
|
endSec = o.optInt("selectionEndSec", o.getInt("durationSec")),
|
||||||
@@ -110,6 +114,7 @@ class UserProgramRepository(private val context: Context) {
|
|||||||
|
|
||||||
val timeline = JSONObject()
|
val timeline = JSONObject()
|
||||||
timeline.put("durationSec", program.timeline.durationSec)
|
timeline.put("durationSec", program.timeline.durationSec)
|
||||||
|
timeline.put("curveGranularitySec", program.timeline.curveGranularitySec)
|
||||||
timeline.put("selectionStartSec", program.timeline.selection.startSec)
|
timeline.put("selectionStartSec", program.timeline.selection.startSec)
|
||||||
timeline.put("selectionEndSec", program.timeline.selection.endSec)
|
timeline.put("selectionEndSec", program.timeline.selection.endSec)
|
||||||
timeline.put("viewportStartSec", program.timeline.viewport.startSec)
|
timeline.put("viewportStartSec", program.timeline.viewport.startSec)
|
||||||
|
|||||||
@@ -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 {
|
fun updateTimeline(newState: TimelineEditorState) = _ui.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
timeline = newState,
|
timeline = newState,
|
||||||
@@ -227,21 +234,22 @@ class MainViewModel(
|
|||||||
fun updateGuidance(value: Boolean) = viewModelScope.launch { settingsRepository.updateGuidance(value) }
|
fun updateGuidance(value: Boolean) = viewModelScope.launch { settingsRepository.updateGuidance(value) }
|
||||||
fun updateShowImmersiveProgressBar(value: Boolean) = viewModelScope.launch { settingsRepository.updateShowImmersiveProgressBar(value) }
|
fun updateShowImmersiveProgressBar(value: Boolean) = viewModelScope.launch { settingsRepository.updateShowImmersiveProgressBar(value) }
|
||||||
|
|
||||||
fun startSession() {
|
fun startSession(): Boolean {
|
||||||
val state = _ui.value
|
val state = _ui.value
|
||||||
if (!(state.settings.safetyAcknowledged && state.settings.safetyAcknowledgedVersion >= SAFETY_VERSION)) {
|
if (!(state.settings.safetyAcknowledged && state.settings.safetyAcknowledgedVersion >= SAFETY_VERSION)) {
|
||||||
_ui.update { it.copy(error = "You must acknowledge safety before starting sessions.") }
|
_ui.update { it.copy(error = "You must acknowledge safety before starting sessions.") }
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
val headset = headsetMonitor.isStereoHeadsetAvailable()
|
val headset = headsetMonitor.isStereoHeadsetAvailable()
|
||||||
val validation = SessionValidator.validate(state.config, headset)
|
val validation = SessionValidator.validate(state.config, headset)
|
||||||
if (validation != null) {
|
if (validation != null) {
|
||||||
_ui.update { it.copy(error = validation) }
|
_ui.update { it.copy(error = validation) }
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
elapsedBeforePauseSec = 0f
|
elapsedBeforePauseSec = 0f
|
||||||
runProgram(startElapsedSec = 0f, includeCountdown = true)
|
runProgram(startElapsedSec = 0f, includeCountdown = true)
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun pause(reason: String? = null) {
|
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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Slider
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SetupTimelineEditor(
|
fun SetupTimelineEditor(
|
||||||
state: TimelineEditorState,
|
state: TimelineEditorState,
|
||||||
onStateChanged: (TimelineEditorState) -> Unit,
|
onStateChanged: (TimelineEditorState) -> Unit,
|
||||||
|
onCurveGranularityChanged: (Int) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
var local by remember(state) { mutableStateOf(state) }
|
var local by remember(state) { mutableStateOf(state) }
|
||||||
@@ -34,11 +38,35 @@ fun SetupTimelineEditor(
|
|||||||
color = MaterialTheme.colorScheme.onBackground,
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
)
|
)
|
||||||
Text(
|
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,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.75f),
|
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(
|
TimelineGraph(
|
||||||
title = "Visual",
|
title = "Visual",
|
||||||
leftLabel = "Flash Interval",
|
leftLabel = "Flash Interval",
|
||||||
|
|||||||
@@ -506,7 +506,7 @@ internal fun TimelineGraph(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Text(
|
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),
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
@@ -519,13 +519,7 @@ internal fun TimelineGraph(
|
|||||||
|
|
||||||
private fun formatTimeLabel(totalSec: Float): String {
|
private fun formatTimeLabel(totalSec: Float): String {
|
||||||
val totalMinutes = (totalSec / 60f).toInt().coerceAtLeast(0)
|
val totalMinutes = (totalSec / 60f).toInt().coerceAtLeast(0)
|
||||||
val hours = totalMinutes / 60
|
return "${totalMinutes} min"
|
||||||
val minutes = totalMinutes % 60
|
|
||||||
return if (hours > 0) {
|
|
||||||
if (minutes == 0) "${hours}h" else "${hours}h ${minutes}m"
|
|
||||||
} else {
|
|
||||||
"${minutes}m"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatMsLabel(value: Float): String = "${value.toInt()} ms"
|
private fun formatMsLabel(value: Float): String = "${value.toInt()} ms"
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
package com.mindmachine.mvp.session
|
package com.mindmachine.mvp.session
|
||||||
|
|
||||||
import kotlin.math.abs
|
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
|
|
||||||
private const val MIN_DURATION_SEC = 60
|
private const val MIN_DURATION_SEC = 60
|
||||||
private const val MAX_DURATION_SEC = 8 * 60 * 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(
|
data class TimelineEditorState(
|
||||||
val durationSec: Int,
|
val durationSec: Int,
|
||||||
|
val curveGranularitySec: Int,
|
||||||
val selection: TimelineSelection,
|
val selection: TimelineSelection,
|
||||||
val viewport: TimelineViewport,
|
val viewport: TimelineViewport,
|
||||||
val activeCurve: ActiveCurve,
|
val activeCurve: ActiveCurve,
|
||||||
@@ -30,9 +32,11 @@ data class TimelineEditorState(
|
|||||||
companion object {
|
companion object {
|
||||||
fun default(durationSec: Int = 20 * 60): TimelineEditorState {
|
fun default(durationSec: Int = 20 * 60): TimelineEditorState {
|
||||||
val dur = snapDuration(durationSec)
|
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(
|
return TimelineEditorState(
|
||||||
durationSec = dur,
|
durationSec = dur,
|
||||||
|
curveGranularitySec = granularitySec,
|
||||||
selection = TimelineSelection(0, dur),
|
selection = TimelineSelection(0, dur),
|
||||||
viewport = TimelineViewport(
|
viewport = TimelineViewport(
|
||||||
startSec = 0f,
|
startSec = 0f,
|
||||||
@@ -50,6 +54,9 @@ data class TimelineEditorState(
|
|||||||
|
|
||||||
private fun snapDuration(durationSec: Int): Int =
|
private fun snapDuration(durationSec: Int): Int =
|
||||||
((durationSec.coerceIn(MIN_DURATION_SEC, MAX_DURATION_SEC) + 30) / 60) * 60
|
((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) {
|
fun curveFor(id: ActiveCurve): TimelineCurve = when (id) {
|
||||||
@@ -60,7 +67,7 @@ data class TimelineEditorState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun withCurve(id: ActiveCurve, curve: TimelineCurve): TimelineEditorState {
|
fun withCurve(id: ActiveCurve, curve: TimelineCurve): TimelineEditorState {
|
||||||
val normalized = curve.ensureMinutePoints(durationSec)
|
val normalized = curve.ensureTimelinePoints(durationSec, curveGranularitySec)
|
||||||
return when (id) {
|
return when (id) {
|
||||||
ActiveCurve.VISUAL_LEFT -> copy(visualLeft = normalized)
|
ActiveCurve.VISUAL_LEFT -> copy(visualLeft = normalized)
|
||||||
ActiveCurve.VISUAL_RIGHT -> copy(visualRight = normalized)
|
ActiveCurve.VISUAL_RIGHT -> copy(visualRight = normalized)
|
||||||
@@ -76,10 +83,10 @@ data class TimelineEditorState(
|
|||||||
return copy(
|
return copy(
|
||||||
durationSec = newDur,
|
durationSec = newDur,
|
||||||
selection = selection.copy(endSec = max(selection.endSec, newDur)),
|
selection = selection.copy(endSec = max(selection.endSec, newDur)),
|
||||||
visualLeft = visualLeft.extendTo(newDur),
|
visualLeft = visualLeft.extendTo(newDur, curveGranularitySec),
|
||||||
visualRight = visualRight.extendTo(newDur),
|
visualRight = visualRight.extendTo(newDur, curveGranularitySec),
|
||||||
audioLeft = audioLeft.extendTo(newDur),
|
audioLeft = audioLeft.extendTo(newDur, curveGranularitySec),
|
||||||
audioRight = audioRight.extendTo(newDur),
|
audioRight = audioRight.extendTo(newDur, curveGranularitySec),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,16 +101,29 @@ data class TimelineEditorState(
|
|||||||
copy(
|
copy(
|
||||||
durationSec = clamped,
|
durationSec = clamped,
|
||||||
selection = TimelineSelection(0, clamped),
|
selection = TimelineSelection(0, clamped),
|
||||||
visualLeft = visualLeft.trimTo(clamped),
|
visualLeft = visualLeft.trimTo(clamped, curveGranularitySec),
|
||||||
visualRight = visualRight.trimTo(clamped),
|
visualRight = visualRight.trimTo(clamped, curveGranularitySec),
|
||||||
audioLeft = audioLeft.trimTo(clamped),
|
audioLeft = audioLeft.trimTo(clamped, curveGranularitySec),
|
||||||
audioRight = audioRight.trimTo(clamped),
|
audioRight = audioRight.trimTo(clamped, curveGranularitySec),
|
||||||
viewport = newViewport,
|
viewport = newViewport,
|
||||||
playheadSec = playheadSec.coerceIn(0f, clamped.toFloat()),
|
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 {
|
fun clampSelection(): TimelineEditorState {
|
||||||
val start = selection.startSec.coerceIn(0, durationSec)
|
val start = selection.startSec.coerceIn(0, durationSec)
|
||||||
var end = selection.endSec.coerceIn(0, durationSec)
|
var end = selection.endSec.coerceIn(0, durationSec)
|
||||||
@@ -169,10 +189,11 @@ data class TimelineCurve(
|
|||||||
val points: List<TimelinePoint>
|
val points: List<TimelinePoint>
|
||||||
) {
|
) {
|
||||||
companion object {
|
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 v = value01.coerceIn(0f, 1f)
|
||||||
|
val stepSec = TimelineEditorState.normalizeGranularitySec(granularitySec)
|
||||||
return TimelineCurve(
|
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)
|
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 snappedDuration = ((durationSec.coerceIn(MIN_DURATION_SEC, MAX_DURATION_SEC) + 30) / 60) * 60
|
||||||
val normalized = (0..snappedDuration step TIMELINE_STEP_SEC).map { minuteSec ->
|
val stepSec = TimelineEditorState.normalizeGranularitySec(granularitySec)
|
||||||
TimelinePoint(minuteSec, valueAt(minuteSec.toFloat()))
|
val normalized = (0..snappedDuration step stepSec).map { tSec ->
|
||||||
|
TimelinePoint(tSec, valueAt(tSec.toFloat()))
|
||||||
}
|
}
|
||||||
return copy(points = normalized)
|
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 {
|
fun movePointVertical(index: Int, newY: Float, heightPx: Float): TimelineCurve {
|
||||||
if (index !in points.indices) return this
|
if (index !in points.indices) return this
|
||||||
@@ -214,7 +236,6 @@ data class TimelineCurve(
|
|||||||
if (index !in points.indices) return this
|
if (index !in points.indices) return this
|
||||||
val v = (1f - (newY / heightPx)).coerceIn(0f, 1f)
|
val v = (1f - (newY / heightPx)).coerceIn(0f, 1f)
|
||||||
val updated = points.toMutableList()
|
val updated = points.toMutableList()
|
||||||
val draggedTime = updated[index].tSec
|
|
||||||
// Update the dragged point and all points after it (same or later time)
|
// Update the dragged point and all points after it (same or later time)
|
||||||
for (i in index until updated.size) {
|
for (i in index until updated.size) {
|
||||||
updated[i] = updated[i].copy(value01 = v)
|
updated[i] = updated[i].copy(value01 = v)
|
||||||
@@ -235,7 +256,7 @@ data class TimelineCurve(
|
|||||||
}.minByOrNull { it.second }?.first
|
}.minByOrNull { it.second }?.first
|
||||||
}
|
}
|
||||||
|
|
||||||
fun extendTo(newDurationSec: Int): TimelineCurve = ensureMinutePoints(newDurationSec)
|
fun extendTo(newDurationSec: Int, granularitySec: Int): TimelineCurve = ensureTimelinePoints(newDurationSec, granularitySec)
|
||||||
}
|
}
|
||||||
|
|
||||||
data class TimelinePoint(
|
data class TimelinePoint(
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ object TimelineProgramFactory {
|
|||||||
|
|
||||||
return TimelineEditorState(
|
return TimelineEditorState(
|
||||||
durationSec = dur,
|
durationSec = dur,
|
||||||
|
curveGranularitySec = TIMELINE_DEFAULT_GRANULARITY_SEC,
|
||||||
selection = TimelineSelection(0, dur),
|
selection = TimelineSelection(0, dur),
|
||||||
viewport = TimelineViewport(startSec = 0f, secondsPerScreen = minOf(10f * 60f, dur.toFloat())),
|
viewport = TimelineViewport(startSec = 0f, secondsPerScreen = minOf(10f * 60f, dur.toFloat())),
|
||||||
activeCurve = TimelineEditorState.ActiveCurve.VISUAL_LEFT,
|
activeCurve = TimelineEditorState.ActiveCurve.VISUAL_LEFT,
|
||||||
@@ -122,6 +123,7 @@ object TimelineProgramFactory {
|
|||||||
|
|
||||||
return TimelineEditorState(
|
return TimelineEditorState(
|
||||||
durationSec = durationSec,
|
durationSec = durationSec,
|
||||||
|
curveGranularitySec = TIMELINE_DEFAULT_GRANULARITY_SEC,
|
||||||
selection = TimelineSelection(0, durationSec),
|
selection = TimelineSelection(0, durationSec),
|
||||||
viewport = TimelineViewport(startSec = 0f, secondsPerScreen = minOf(10f * 60f, durationSec.toFloat())),
|
viewport = TimelineViewport(startSec = 0f, secondsPerScreen = minOf(10f * 60f, durationSec.toFloat())),
|
||||||
activeCurve = TimelineEditorState.ActiveCurve.VISUAL_LEFT,
|
activeCurve = TimelineEditorState.ActiveCurve.VISUAL_LEFT,
|
||||||
|
|||||||
@@ -11,13 +11,72 @@ data class SplitFlashFrame(
|
|||||||
val right: FlashColor,
|
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(
|
fun computeSplitFlashFrame(
|
||||||
frameTimeNanos: Long,
|
frameTimeNanos: Long,
|
||||||
flashOnMs: Int,
|
flashOnMs: Int,
|
||||||
flashOffMs: Int,
|
flashOffMs: Int,
|
||||||
): SplitFlashFrame {
|
): SplitFlashFrame {
|
||||||
val onMs = flashOnMs.coerceIn(50, 2000).toLong()
|
val onMs = clampIntervalMs(flashOnMs)
|
||||||
val offMs = flashOffMs.coerceIn(50, 2000).toLong()
|
val offMs = clampIntervalMs(flashOffMs)
|
||||||
val elapsedMs = frameTimeNanos / 1_000_000L
|
val elapsedMs = frameTimeNanos / 1_000_000L
|
||||||
|
|
||||||
// Four phases: on, off, on(swapped), off
|
// Four phases: on, off, on(swapped), off
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ class TimelineCurveGestureTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `ensureMinutePoints snaps points to minute boundaries`() {
|
fun `ensureTimelinePoints snaps points to selected boundaries`() {
|
||||||
val points = listOf(
|
val points = listOf(
|
||||||
TimelinePoint(0, 0.0f),
|
TimelinePoint(0, 0.0f),
|
||||||
TimelinePoint(30, 0.5f), // 30 seconds - not a minute boundary
|
TimelinePoint(30, 0.5f), // 30 seconds - not a minute boundary
|
||||||
@@ -179,26 +179,26 @@ class TimelineCurveGestureTest {
|
|||||||
)
|
)
|
||||||
val curve = TimelineCurve(points)
|
val curve = TimelineCurve(points)
|
||||||
|
|
||||||
val result = curve.ensureMinutePoints(180)
|
val result = curve.ensureTimelinePoints(180, granularitySec = 30)
|
||||||
|
|
||||||
// Check that all points are on minute boundaries (multiples of 60)
|
// Check that all points are on selected boundaries (multiples of 30)
|
||||||
result.points.forEach { point ->
|
result.points.forEach { point ->
|
||||||
assertTrue("Point at ${point.tSec} should be on minute boundary", point.tSec % 60 == 0)
|
assertTrue("Point at ${point.tSec} should be on 30s boundary", point.tSec % 30 == 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should have points at 0, 60, 120, 180 seconds
|
// Should have points at 0, 30, 60, 90, 120, 150, 180 seconds
|
||||||
assertEquals(4, result.points.size)
|
assertEquals(7, result.points.size)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `ensureMinutePoints preserves interpolated values`() {
|
fun `ensureTimelinePoints preserves interpolated values`() {
|
||||||
val points = listOf(
|
val points = listOf(
|
||||||
TimelinePoint(0, 0.0f),
|
TimelinePoint(0, 0.0f),
|
||||||
TimelinePoint(120, 1.0f),
|
TimelinePoint(120, 1.0f),
|
||||||
)
|
)
|
||||||
val curve = TimelineCurve(points)
|
val curve = TimelineCurve(points)
|
||||||
|
|
||||||
val result = curve.ensureMinutePoints(180)
|
val result = curve.ensureTimelinePoints(180, granularitySec = 60)
|
||||||
|
|
||||||
// Value at 60 seconds should interpolate to 0.5
|
// Value at 60 seconds should interpolate to 0.5
|
||||||
val valueAt60 = result.valueAt(60f)
|
val valueAt60 = result.valueAt(60f)
|
||||||
@@ -209,7 +209,7 @@ class TimelineCurveGestureTest {
|
|||||||
fun `extendTo adds points for extended duration`() {
|
fun `extendTo adds points for extended duration`() {
|
||||||
val curve = TimelineCurve.constant(0.5f, 60)
|
val curve = TimelineCurve.constant(0.5f, 60)
|
||||||
|
|
||||||
val extended = curve.extendTo(120)
|
val extended = curve.extendTo(120, granularitySec = 60)
|
||||||
|
|
||||||
// Should have points covering up to 120 seconds
|
// Should have points covering up to 120 seconds
|
||||||
assertTrue(extended.points.any { it.tSec == 120 })
|
assertTrue(extended.points.any { it.tSec == 120 })
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.mindmachine.mvp
|
package com.mindmachine.mvp
|
||||||
|
|
||||||
import com.mindmachine.mvp.session.FlashColor
|
import com.mindmachine.mvp.session.FlashColor
|
||||||
|
import com.mindmachine.mvp.session.SplitFlashSequencer
|
||||||
import com.mindmachine.mvp.session.computeSplitFlashFrame
|
import com.mindmachine.mvp.session.computeSplitFlashFrame
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -39,4 +40,37 @@ class VisualSignalTest {
|
|||||||
val validAt2000ms = computeSplitFlashFrame(frameTimeNanos = 2_100_000_000L, flashOnMs = 2000, flashOffMs = 2000)
|
val validAt2000ms = computeSplitFlashFrame(frameTimeNanos = 2_100_000_000L, flashOnMs = 2000, flashOffMs = 2000)
|
||||||
assertEquals(validAt2000ms, tooSlow)
|
assertEquals(validAt2000ms, tooSlow)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sequencer_keeps_blank_duration_constant_when_flash_duration_changes_mid_blank() {
|
||||||
|
val sequencer = SplitFlashSequencer(startTimeNanos = 0L, flashOnMs = 100, flashOffMs = 1000)
|
||||||
|
|
||||||
|
assertEquals(FlashColor.BLACK, sequencer.frameAt(100_000_000L).left)
|
||||||
|
|
||||||
|
sequencer.updateIntervals(flashOnMs = 200, flashOffMs = 1000)
|
||||||
|
|
||||||
|
// Still blank before the configured 1000ms blank phase is complete.
|
||||||
|
assertEquals(FlashColor.BLACK, sequencer.frameAt(600_000_000L).left)
|
||||||
|
assertEquals(FlashColor.BLACK, sequencer.frameAt(1_099_000_000L).left)
|
||||||
|
|
||||||
|
// At 1100ms total, blank ends and next on-phase starts.
|
||||||
|
assertEquals(FlashColor.GREEN, sequencer.frameAt(1_100_000_000L).left)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sequencer_applies_new_blank_interval_on_next_blank_boundary() {
|
||||||
|
val sequencer = SplitFlashSequencer(startTimeNanos = 0L, flashOnMs = 100, flashOffMs = 100)
|
||||||
|
|
||||||
|
// Enter second on phase.
|
||||||
|
assertEquals(FlashColor.GREEN, sequencer.frameAt(200_000_000L).left)
|
||||||
|
|
||||||
|
// Change blank interval during on phase; current on should finish uninterrupted.
|
||||||
|
sequencer.updateIntervals(flashOnMs = 100, flashOffMs = 1000)
|
||||||
|
assertEquals(FlashColor.GREEN, sequencer.frameAt(250_000_000L).left)
|
||||||
|
|
||||||
|
// Next phase is blank and should use new 1000ms off interval.
|
||||||
|
assertEquals(FlashColor.BLACK, sequencer.frameAt(300_000_000L).left)
|
||||||
|
assertEquals(FlashColor.BLACK, sequencer.frameAt(1_299_000_000L).left)
|
||||||
|
assertEquals(FlashColor.RED, sequencer.frameAt(1_300_000_000L).left)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user