Auto commit Thu Mar 19 09:01:01 PM CDT 2026

This commit is contained in:
Tretzi
2026-03-19 21:01:01 -05:00
parent 3846f97fb2
commit ee60bf0c0f
6 changed files with 242 additions and 380 deletions

View File

@@ -51,7 +51,6 @@ fun DurationSliderCard(
Slider( Slider(
value = clamped.toFloat(), value = clamped.toFloat(),
onValueChange = { onValueChange = {
// Keep it sane: snap to whole minutes for UX.
val snapped = (it / 60f).roundToInt().coerceIn(1, 8 * 60) * 60 val snapped = (it / 60f).roundToInt().coerceIn(1, 8 * 60) * 60
onDurationSecChanged(snapped) onDurationSecChanged(snapped)
}, },
@@ -59,7 +58,7 @@ fun DurationSliderCard(
) )
Text( Text(
"1 min  8 hours", "Range: 1 minute to 8 hours",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
) )

View File

@@ -53,6 +53,7 @@ class MainViewModel(
val ui: StateFlow<UiState> = _ui.asStateFlow() val ui: StateFlow<UiState> = _ui.asStateFlow()
private var runJob: Job? = null private var runJob: Job? = null
private var elapsedBeforePauseSec: Float = 0f
init { init {
viewModelScope.launch { viewModelScope.launch {
@@ -78,6 +79,7 @@ class MainViewModel(
error = null, error = null,
) )
} }
elapsedBeforePauseSec = 0f
} }
fun setMode(mode: SessionMode) = _ui.update { it.copy(config = it.config.copy(mode = mode), error = null) } fun setMode(mode: SessionMode) = _ui.update { it.copy(config = it.config.copy(mode = mode), error = null) }
@@ -120,73 +122,22 @@ class MainViewModel(
_ui.update { it.copy(error = validation) } _ui.update { it.copy(error = validation) }
return return
} }
runJob?.cancel()
runJob = viewModelScope.launch {
val count = state.settings.countdownPreference.seconds
if (count > 0) {
for (i in count downTo 1) {
_ui.update { it.copy(runtimeState = RuntimeState.COUNTDOWN, countdownSec = i, remainingSec = state.config.durationSec, endedEarly = false, interruptionReason = null) }
delay(1000)
}
}
// Use the timeline curves as the actual runtime program.
val program = _ui.value.timeline
val durationSec = program.durationSec
_ui.update { elapsedBeforePauseSec = 0f
it.copy( runProgram(startElapsedSec = 0f, includeCountdown = true)
runtimeState = RuntimeState.RUNNING,
remainingSec = durationSec,
countdownSec = 0,
error = null,
runtimeParams = TimelineRuntimeEvaluator.evaluate(program, 0f),
)
}
if (state.config.mode != SessionMode.VISUAL_ONLY) {
val p0 = TimelineRuntimeEvaluator.evaluate(program, 0f)
audioEngine.start(p0.carrierHz, p0.binauralHz)
}
val tStart = System.currentTimeMillis()
var lastWholeSec = durationSec
while (true) {
delay(50)
val elapsedSec = (System.currentTimeMillis() - tStart) / 1000f
val remaining = (durationSec - elapsedSec.toInt()).coerceAtLeast(0)
if (state.config.mode != SessionMode.VISUAL_ONLY && !headsetMonitor.isStereoHeadsetAvailable()) {
audioEngine.stop()
_ui.update { it.copy(runtimeState = RuntimeState.INTERRUPTED, interruptionReason = "Headphones disconnected. Session paused.") }
return@launch
}
val params = TimelineRuntimeEvaluator.evaluate(program, elapsedSec)
_ui.update {
val nextRemaining = if (remaining != lastWholeSec) remaining else it.remainingSec
it.copy(runtimeParams = params, remainingSec = nextRemaining)
}
lastWholeSec = remaining
if (state.config.mode != SessionMode.VISUAL_ONLY) {
audioEngine.setFrequencies(params.carrierHz, params.binauralHz)
}
if (elapsedSec >= durationSec.toFloat()) break
}
audioEngine.stop()
_ui.update { it.copy(runtimeState = RuntimeState.COMPLETED, endedEarly = false) }
}
} }
fun pause(reason: String? = null) { fun pause(reason: String? = null) {
if (_ui.value.runtimeState != RuntimeState.RUNNING) return if (_ui.value.runtimeState != RuntimeState.RUNNING) return
elapsedBeforePauseSec = currentElapsedSec()
runJob?.cancel() runJob?.cancel()
audioEngine.stop() audioEngine.stop()
_ui.update { it.copy(runtimeState = if (reason == null) RuntimeState.PAUSED else RuntimeState.INTERRUPTED, interruptionReason = reason) } _ui.update {
it.copy(
runtimeState = if (reason == null) RuntimeState.PAUSED else RuntimeState.INTERRUPTED,
interruptionReason = reason,
)
}
} }
fun resume() { fun resume() {
@@ -196,35 +147,13 @@ class MainViewModel(
_ui.update { it.copy(error = "Headphones are required to resume audio mode.") } _ui.update { it.copy(error = "Headphones are required to resume audio mode.") }
return return
} }
runJob = viewModelScope.launch { runProgram(startElapsedSec = elapsedBeforePauseSec, includeCountdown = true)
for (i in 3 downTo 1) {
_ui.update { it.copy(runtimeState = RuntimeState.COUNTDOWN, countdownSec = i) }
delay(1000)
}
_ui.update { it.copy(runtimeState = RuntimeState.RUNNING, countdownSec = 0) }
if (state.config.mode != SessionMode.VISUAL_ONLY) {
val p = _ui.value.runtimeParams
audioEngine.start(p.carrierHz, p.binauralHz)
}
var remaining = state.remainingSec
while (remaining > 0) {
delay(1000)
if (state.config.mode != SessionMode.VISUAL_ONLY && !headsetMonitor.isStereoHeadsetAvailable()) {
audioEngine.stop()
_ui.update { it.copy(runtimeState = RuntimeState.INTERRUPTED, interruptionReason = "Headphones disconnected. Session paused.") }
return@launch
}
remaining -= 1
_ui.update { it.copy(remainingSec = remaining) }
}
audioEngine.stop()
_ui.update { it.copy(runtimeState = RuntimeState.COMPLETED, endedEarly = false) }
}
} }
fun stop() { fun stop() {
runJob?.cancel() runJob?.cancel()
audioEngine.stop() audioEngine.stop()
elapsedBeforePauseSec = 0f
_ui.update { it.copy(runtimeState = RuntimeState.STOPPED, endedEarly = true) } _ui.update { it.copy(runtimeState = RuntimeState.STOPPED, endedEarly = true) }
} }
@@ -238,6 +167,91 @@ class MainViewModel(
super.onCleared() super.onCleared()
} }
private fun runProgram(startElapsedSec: Float, includeCountdown: Boolean) {
runJob?.cancel()
runJob = viewModelScope.launch {
val program = _ui.value.timeline
val durationSec = program.durationSec
val clampedStartSec = startElapsedSec.coerceIn(0f, durationSec.toFloat())
if (includeCountdown) {
val count = _ui.value.settings.countdownPreference.seconds
if (count > 0) {
for (i in count downTo 1) {
_ui.update {
it.copy(
runtimeState = RuntimeState.COUNTDOWN,
countdownSec = i,
remainingSec = (durationSec - clampedStartSec.toInt()).coerceAtLeast(0),
endedEarly = false,
interruptionReason = null,
runtimeParams = TimelineRuntimeEvaluator.evaluate(program, clampedStartSec),
)
}
delay(1000)
}
}
}
val initialParams = TimelineRuntimeEvaluator.evaluate(program, clampedStartSec)
_ui.update {
it.copy(
runtimeState = RuntimeState.RUNNING,
remainingSec = (durationSec - clampedStartSec.toInt()).coerceAtLeast(0),
countdownSec = 0,
error = null,
endedEarly = false,
interruptionReason = null,
runtimeParams = initialParams,
)
}
if (_ui.value.config.mode != SessionMode.VISUAL_ONLY) {
audioEngine.start(initialParams.carrierHz, initialParams.binauralHz)
}
val tStart = System.currentTimeMillis()
while (true) {
delay(50)
val elapsedSec = clampedStartSec + (System.currentTimeMillis() - tStart) / 1000f
elapsedBeforePauseSec = elapsedSec.coerceAtMost(durationSec.toFloat())
val remaining = (durationSec - kotlin.math.ceil(elapsedBeforePauseSec).toInt()).coerceAtLeast(0)
val currentUi = _ui.value
if (currentUi.config.mode != SessionMode.VISUAL_ONLY && !headsetMonitor.isStereoHeadsetAvailable()) {
audioEngine.stop()
_ui.update {
it.copy(
runtimeState = RuntimeState.INTERRUPTED,
interruptionReason = "Headphones disconnected. Session paused.",
)
}
return@launch
}
val params = TimelineRuntimeEvaluator.evaluate(program, elapsedBeforePauseSec)
_ui.update { it.copy(runtimeParams = params, remainingSec = remaining) }
if (currentUi.config.mode != SessionMode.VISUAL_ONLY) {
audioEngine.setFrequencies(params.carrierHz, params.binauralHz)
}
if (elapsedBeforePauseSec >= durationSec.toFloat()) break
}
audioEngine.stop()
elapsedBeforePauseSec = 0f
_ui.update { it.copy(runtimeState = RuntimeState.COMPLETED, endedEarly = false, remainingSec = 0) }
}
}
private fun currentElapsedSec(): Float {
val state = _ui.value
val total = state.timeline.durationSec.toFloat().coerceAtLeast(1f)
return (total - state.remainingSec).coerceIn(0f, total)
}
class Factory( class Factory(
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
private val headsetMonitor: HeadsetMonitor, private val headsetMonitor: HeadsetMonitor,

View File

@@ -34,7 +34,7 @@ fun SetupTimelineEditor(
color = MaterialTheme.colorScheme.onBackground, color = MaterialTheme.colorScheme.onBackground,
) )
Text( Text(
"Visual: Flash Interval (L) / Blank Interval (R). Audio: Carrier (L) / Binaural (R).", "Each dot is one minute. 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),
) )

View File

@@ -3,7 +3,6 @@ package com.mindmachine.mvp.session
import android.view.HapticFeedbackConstants import android.view.HapticFeedbackConstants
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
@@ -12,15 +11,12 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
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.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -56,7 +52,6 @@ fun TimelineEditorScreen(
val context = LocalContext.current val context = LocalContext.current
val activity = context as? android.app.Activity val activity = context as? android.app.Activity
// Timeline editor is always immersive per spec.
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (activity != null) { if (activity != null) {
val window = activity.window val window = activity.window
@@ -108,74 +103,32 @@ private fun TimelineEditorRoot(
} }
} }
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
private fun TimelineEditorContent( private fun TimelineEditorContent(
onBack: () -> Unit, onBack: () -> Unit,
) { ) {
var state by remember { mutableStateOf(TimelineEditorState.default()) } var state by remember { mutableStateOf(TimelineEditorState.default()) }
// Simple preview playhead (UI-only for now).
LaunchedEffect(state.isPlaying, state.durationSec) {
if (!state.isPlaying) return@LaunchedEffect
val start = state.playheadSec
val t0 = System.currentTimeMillis()
while (state.isPlaying) {
val elapsed = (System.currentTimeMillis() - t0) / 1000f
val next = (start + elapsed)
val wrapped = if (next > state.durationSec) 0f else next
state = state.copy(playheadSec = wrapped)
kotlinx.coroutines.delay(16)
}
}
Column( Column(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.padding(12.dp), .padding(12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp) verticalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
Row(
Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface, RoundedCornerShape(12.dp))
.padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text("Selected duration", color = MaterialTheme.colorScheme.onSurface)
Text( Text(
formatDuration(state.selection.durationSec.coerceAtLeast(0)), "Minute-based timeline editor",
style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onBackground,
color = MaterialTheme.colorScheme.onSurface style = MaterialTheme.typography.titleLarge,
) )
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
OutlinedButton(onClick = { state = state.copy(isPlaying = !state.isPlaying) }) {
Text(if (state.isPlaying) "Pause" else "Play")
}
OutlinedButton(onClick = { state = state.copy(playheadSec = 0f) }) {
Text("Rewind")
}
}
}
DurationSliderCard(
durationSec = state.durationSec,
onDurationSecChanged = { state = state.setDurationSec(it) },
)
Text( Text(
"Tap left/right side of a graph to select a curve. Pinch to zoom, drag empty space to pan. Long-press a point to delete.", "Every minute has a control dot. Tap left/right to choose a curve, drag dots up or down, pinch to zoom, and drag empty space to pan.",
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.75f), color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.75f),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
TimelineGraph( TimelineGraph(
title = "Visual", title = "Visual",
leftLabel = "Brightness", leftLabel = "Flash Interval",
rightLabel = "Blink Rate", rightLabel = "Blank Interval",
curveLeft = state.visualLeft, curveLeft = state.visualLeft,
curveRight = state.visualRight, curveRight = state.visualRight,
activeCurve = state.activeCurve, activeCurve = state.activeCurve,
@@ -184,20 +137,15 @@ private fun TimelineEditorContent(
playheadSec = state.playheadSec, playheadSec = state.playheadSec,
onViewportChanged = { state = state.copy(viewport = it) }, onViewportChanged = { state = state.copy(viewport = it) },
onSelectionChanged = { sel -> state = state.copy(selection = sel).clampSelection() }, onSelectionChanged = { sel -> state = state.copy(selection = sel).clampSelection() },
onUpdateCurve = { curveId, newCurve -> onUpdateCurve = { curveId, newCurve -> state = state.withCurve(curveId, newCurve) },
val candidate = state.withCurve(curveId, newCurve)
val maxT = newCurve.points.maxOfOrNull { it.tSec } ?: 0
state = candidate.extendDurationTo(maxT).copy(selection = candidate.selection.copy(endSec = max(candidate.selection.endSec, state.durationSec)))
},
onTapSideSelect = { side -> onTapSideSelect = { side ->
state = state.copy(activeCurve = if (side == Side.LEFT) ActiveCurve.VISUAL_LEFT else ActiveCurve.VISUAL_RIGHT) state = state.copy(activeCurve = if (side == Side.LEFT) ActiveCurve.VISUAL_LEFT else ActiveCurve.VISUAL_RIGHT)
} }
) )
TimelineGraph( TimelineGraph(
title = "Audio", title = "Audio",
leftLabel = "Carrier Frequency", leftLabel = "Carrier Frequency",
rightLabel = "Binaural Beat", rightLabel = "Binaural Frequency",
curveLeft = state.audioLeft, curveLeft = state.audioLeft,
curveRight = state.audioRight, curveRight = state.audioRight,
activeCurve = state.activeCurve, activeCurve = state.activeCurve,
@@ -206,31 +154,15 @@ private fun TimelineEditorContent(
playheadSec = state.playheadSec, playheadSec = state.playheadSec,
onViewportChanged = { state = state.copy(viewport = it) }, onViewportChanged = { state = state.copy(viewport = it) },
onSelectionChanged = { sel -> state = state.copy(selection = sel).clampSelection() }, onSelectionChanged = { sel -> state = state.copy(selection = sel).clampSelection() },
onUpdateCurve = { curveId, newCurve -> onUpdateCurve = { curveId, newCurve -> state = state.withCurve(curveId, newCurve) },
val candidate = state.withCurve(curveId, newCurve)
val maxT = newCurve.points.maxOfOrNull { it.tSec } ?: 0
state = candidate.extendDurationTo(maxT)
},
onTapSideSelect = { side -> onTapSideSelect = { side ->
state = state.copy(activeCurve = if (side == Side.LEFT) ActiveCurve.AUDIO_LEFT else ActiveCurve.AUDIO_RIGHT) state = state.copy(activeCurve = if (side == Side.LEFT) ActiveCurve.AUDIO_LEFT else ActiveCurve.AUDIO_RIGHT)
} }
) )
Text(
Spacer(Modifier.height(4.dp)) "Back to return.",
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.75f),
Button( )
onClick = { state = TimelineEditorState.default(durationSec = state.durationSec) },
modifier = Modifier.weight(1f)
) {
Text("Reset")
}
OutlinedButton(
onClick = { /* TODO persist into session config */ },
modifier = Modifier.weight(1f)
) {
Text("Save")
}
}
} }
} }
@@ -262,8 +194,20 @@ internal fun TimelineGraph(
val selectedRight = activeCurve == rightId val selectedRight = activeCurve == rightId
val context = LocalContext.current val context = LocalContext.current
var dragMode by remember { mutableStateOf(HandleDrag.NONE) } var dragMode by remember { mutableStateOf(HandleDrag.NONE) }
var draggingPointIndex by remember { mutableStateOf<Int?>(null) }
var draggingCurveId by remember { mutableStateOf<ActiveCurve?>(null) }
val leftColor = when (leftId) {
ActiveCurve.VISUAL_LEFT -> Color(0xFF00C853)
ActiveCurve.AUDIO_LEFT -> Color(0xFF2196F3)
else -> Color.White
}
val rightColor = when (rightId) {
ActiveCurve.VISUAL_RIGHT -> Color(0xFFD50000)
ActiveCurve.AUDIO_RIGHT -> Color(0xFFFFEB3B)
else -> Color.White
}
Column( Column(
Modifier Modifier
@@ -282,8 +226,8 @@ internal fun TimelineGraph(
} }
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(leftLabel, color = if (selectedLeft) Color.White else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f)) Text(leftLabel, color = if (selectedLeft) leftColor else leftColor.copy(alpha = 0.65f))
Text(rightLabel, color = if (selectedRight) Color.White else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f)) Text(rightLabel, color = if (selectedRight) rightColor else rightColor.copy(alpha = 0.65f))
} }
val onCurveEditedHaptic = { val onCurveEditedHaptic = {
@@ -295,32 +239,57 @@ internal fun TimelineGraph(
.fillMaxWidth() .fillMaxWidth()
.height(190.dp) .height(190.dp)
.background(Color(0xFF0A0E18), RoundedCornerShape(10.dp)) .background(Color(0xFF0A0E18), RoundedCornerShape(10.dp))
.pointerInput(viewport, selection) { .pointerInput(viewport, selection, curveLeft, curveRight, activeCurve) {
detectDragGestures( detectDragGestures(
onDragStart = { start -> onDragStart = { start ->
val w = size.width.toFloat() val w = size.width.toFloat()
val h = size.height.toFloat()
val startX = viewport.timeToX(selection.startSec, w) val startX = viewport.timeToX(selection.startSec, w)
val endX = viewport.timeToX(selection.endSec, w) val endX = viewport.timeToX(selection.endSec, w)
val hitPx = 32f val hitPx = 32f
val selectedCurveId = if (activeCurve == rightId) rightId else leftId
val selectedCurve = if (selectedCurveId == leftId) curveLeft else curveRight
val hitPoint = selectedCurve.hitTestPoint(start, viewport, w, h)
if (hitPoint != null) {
draggingPointIndex = hitPoint
draggingCurveId = selectedCurveId
dragMode = HandleDrag.NONE
} else {
dragMode = when { dragMode = when {
abs(start.x - startX) <= hitPx -> HandleDrag.START abs(start.x - startX) <= hitPx -> HandleDrag.START
abs(start.x - endX) <= hitPx -> HandleDrag.END abs(start.x - endX) <= hitPx -> HandleDrag.END
else -> HandleDrag.NONE else -> HandleDrag.NONE
} }
}
},
onDragEnd = {
draggingPointIndex = null
draggingCurveId = null
dragMode = HandleDrag.NONE
},
onDragCancel = {
draggingPointIndex = null
draggingCurveId = null
dragMode = HandleDrag.NONE
}, },
onDragEnd = { dragMode = HandleDrag.NONE },
onDragCancel = { dragMode = HandleDrag.NONE },
onDrag = { change, dragAmount -> onDrag = { change, dragAmount ->
val w = size.width.toFloat() val w = size.width.toFloat()
val h = size.height.toFloat()
val pointIndex = draggingPointIndex
val curveId = draggingCurveId
if (pointIndex != null && curveId != null) {
val targetCurve = if (curveId == leftId) curveLeft else curveRight
val updated = targetCurve.movePointVertical(pointIndex, change.position.y + dragAmount.y, h)
onUpdateCurve(curveId, updated)
onCurveEditedHaptic()
return@detectDragGestures
}
val t = viewport.xToTimeSec(change.position.x + dragAmount.x, w).toInt().coerceAtLeast(0) val t = viewport.xToTimeSec(change.position.x + dragAmount.x, w).toInt().coerceAtLeast(0)
when (dragMode) { when (dragMode) {
HandleDrag.START -> onSelectionChanged(TimelineSelection(startSec = min(t, selection.endSec - 60), endSec = selection.endSec)) HandleDrag.START -> onSelectionChanged(TimelineSelection(startSec = min(t, selection.endSec - 60), endSec = selection.endSec))
HandleDrag.END -> onSelectionChanged(TimelineSelection(startSec = selection.startSec, endSec = max(t, selection.startSec + 60))) HandleDrag.END -> onSelectionChanged(TimelineSelection(startSec = selection.startSec, endSec = max(t, selection.startSec + 60)))
HandleDrag.NONE -> { HandleDrag.NONE -> {
// background pan (when not dragging a point)
val didHitLeft = curveLeft.hitTestPoint(change.position, viewport, w, size.height.toFloat()) != null
val didHitRight = curveRight.hitTestPoint(change.position, viewport, w, size.height.toFloat()) != null
if (!didHitLeft && !didHitRight) {
val newViewport = viewport.applyZoomPan( val newViewport = viewport.applyZoomPan(
zoomChange = 1f, zoomChange = 1f,
panPx = dragAmount.x, panPx = dragAmount.x,
@@ -331,7 +300,6 @@ internal fun TimelineGraph(
} }
} }
} }
}
) )
} }
.pointerInput(activeCurve, viewport, curveLeft, curveRight) { .pointerInput(activeCurve, viewport, curveLeft, curveRight) {
@@ -345,74 +313,11 @@ internal fun TimelineGraph(
onViewportChanged(newViewport) onViewportChanged(newViewport)
} }
} }
.pointerInput(activeCurve, viewport, curveLeft, curveRight) { .pointerInput(activeCurve) {
detectTapGestures( detectTapGestures(
onTap = { offset -> onTap = { offset ->
val side = if (offset.x < size.width / 2f) Side.LEFT else Side.RIGHT val side = if (offset.x < size.width / 2f) Side.LEFT else Side.RIGHT
onTapSideSelect(side) onTapSideSelect(side)
},
onDoubleTap = { offset ->
val curveId = when (activeCurve) {
leftId, rightId -> activeCurve
else -> leftId
}
val targetCurve = if (curveId == leftId) curveLeft else curveRight
val updated = targetCurve.insertSmoothedPointAt(offset, viewport, size.width.toFloat(), size.height.toFloat())
onCurveEditedHaptic()
onUpdateCurve(curveId, updated)
},
onLongPress = { offset ->
val curveId = when (activeCurve) {
leftId, rightId -> activeCurve
else -> leftId
}
val targetCurve = if (curveId == leftId) curveLeft else curveRight
val hit = targetCurve.hitTestPoint(offset, viewport, size.width.toFloat(), size.height.toFloat())
if (hit != null) {
onCurveEditedHaptic()
val updated = targetCurve.removePointAt(hit)
onUpdateCurve(curveId, updated)
}
}
)
}
.pointerInput(activeCurve, viewport, curveLeft, curveRight) {
detectTapGestures(onTap = { offset ->
val curveId = when (activeCurve) {
leftId, rightId -> activeCurve
else -> leftId
}
val side = if (offset.x < size.width / 2f) Side.LEFT else Side.RIGHT
// side tap selects curve; second tap adds point if already selected.
if ((side == Side.LEFT && curveId != leftId) || (side == Side.RIGHT && curveId != rightId)) {
onTapSideSelect(side)
return@detectTapGestures
}
val targetCurve = if (curveId == leftId) curveLeft else curveRight
val updated = targetCurve.addPointAt(offset, viewport, size.width.toFloat(), size.height.toFloat())
onCurveEditedHaptic()
onUpdateCurve(curveId, updated)
})
}
.pointerInput(activeCurve, viewport, curveLeft, curveRight) {
detectDragGestures(
onDrag = { change, dragAmount ->
val curveId = when (activeCurve) {
leftId, rightId -> activeCurve
else -> leftId
}
val targetCurve = if (curveId == leftId) curveLeft else curveRight
val hitIndex = targetCurve.hitTestPoint(change.position, viewport, size.width.toFloat(), size.height.toFloat())
if (hitIndex != null) {
val updated = targetCurve.movePoint(
index = hitIndex,
newPosition = change.position + dragAmount,
viewport = viewport,
widthPx = size.width.toFloat(),
heightPx = size.height.toFloat(),
)
onUpdateCurve(curveId, updated)
}
} }
) )
} }
@@ -420,7 +325,6 @@ internal fun TimelineGraph(
Canvas(modifier = Modifier.fillMaxSize()) { Canvas(modifier = Modifier.fillMaxSize()) {
drawGrid() drawGrid()
// selection range overlay + handles
val startX = viewport.timeToX(selection.startSec, size.width) val startX = viewport.timeToX(selection.startSec, size.width)
val endX = viewport.timeToX(selection.endSec, size.width) val endX = viewport.timeToX(selection.endSec, size.width)
drawRect( drawRect(
@@ -435,17 +339,16 @@ internal fun TimelineGraph(
drawCircle(handleColor, radius = 10f, center = Offset(startX, size.height - 14f)) drawCircle(handleColor, radius = 10f, center = Offset(startX, size.height - 14f))
drawCircle(handleColor, radius = 10f, center = Offset(endX, size.height - 14f)) drawCircle(handleColor, radius = 10f, center = Offset(endX, size.height - 14f))
// playhead
val playX = viewport.timeToX(playheadSec.toInt(), size.width) val playX = viewport.timeToX(playheadSec.toInt(), size.width)
drawLine(Color(0xFFFFD54F).copy(alpha = 0.9f), Offset(playX, 0f), Offset(playX, size.height), strokeWidth = 2f) drawLine(Color(0xFFFFFFFF).copy(alpha = 0.6f), Offset(playX, 0f), Offset(playX, size.height), strokeWidth = 2f)
drawCurve(curveLeft, selected = selectedLeft, color = if (isVisual) Color(0xFF4D8BFF) else Color(0xFF55E39A), viewport = viewport) drawCurve(curveLeft, selected = selectedLeft, color = leftColor, viewport = viewport)
drawCurve(curveRight, selected = selectedRight, color = if (isVisual) Color(0xFF7AB8FF) else Color(0xFF2FCB7C), viewport = viewport) drawCurve(curveRight, selected = selectedRight, color = rightColor, viewport = viewport)
} }
} }
Text( Text(
"Tap add • Drag point move • Long-press delete • Double-tap smooth • Pinch zoom • Drag background pan • Drag handles to set range", "One dot per minute • 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,
@@ -482,17 +385,17 @@ private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawCurve(
val y = valueToY(p.value01, size.height) val y = valueToY(p.value01, size.height)
if (idx == 0) path.moveTo(x, y) else path.lineTo(x, y) if (idx == 0) path.moveTo(x, y) else path.lineTo(x, y)
} }
val stroke = if (selected) 6f else 3f
drawPath( drawPath(
path, path,
color = if (selected) color.copy(alpha = 1f) else color.copy(alpha = 0.55f), color = if (selected) color else color.copy(alpha = 0.6f),
style = androidx.compose.ui.graphics.drawscope.Stroke(width = stroke) style = androidx.compose.ui.graphics.drawscope.Stroke(width = if (selected) 5f else 3f)
) )
pts.forEach { p -> pts.forEach { p ->
val x = viewport.timeToX(p.tSec, size.width) val x = viewport.timeToX(p.tSec, size.width)
if (x < -12f || x > size.width + 12f) return@forEach
val y = valueToY(p.value01, size.height) val y = valueToY(p.value01, size.height)
drawCircle(color = Color.White, radius = 8f, center = Offset(x, y)) drawCircle(color = color, radius = if (selected) 6.5f else 5f, center = Offset(x, y))
} }
} }
@@ -500,4 +403,3 @@ private fun valueToY(value01: Float, height: Float): Float {
val v = value01.coerceIn(0f, 1f) val v = value01.coerceIn(0f, 1f)
return (1f - v) * height return (1f - v) * height
} }

View File

@@ -4,6 +4,10 @@ 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 MAX_DURATION_SEC = 8 * 60 * 60
private const val TIMELINE_STEP_SEC = 60
data class TimelineEditorState( data class TimelineEditorState(
val durationSec: Int, val durationSec: Int,
val selection: TimelineSelection, val selection: TimelineSelection,
@@ -25,13 +29,8 @@ data class TimelineEditorState(
companion object { companion object {
fun default(durationSec: Int = 20 * 60): TimelineEditorState { fun default(durationSec: Int = 20 * 60): TimelineEditorState {
val dur = durationSec.coerceIn(60, 8 * 60 * 60) val dur = snapDuration(durationSec)
val initial = TimelineCurve( val initial = TimelineCurve.constant(0.5f, dur)
points = listOf(
TimelinePoint(0, 0.5f),
TimelinePoint(dur, 0.5f),
)
)
return TimelineEditorState( return TimelineEditorState(
durationSec = dur, durationSec = dur,
selection = TimelineSelection(0, dur), selection = TimelineSelection(0, dur),
@@ -48,6 +47,9 @@ data class TimelineEditorState(
playheadSec = 0f, playheadSec = 0f,
) )
} }
private fun snapDuration(durationSec: Int): Int =
((durationSec.coerceIn(MIN_DURATION_SEC, MAX_DURATION_SEC) + 30) / 60) * 60
} }
fun curveFor(id: ActiveCurve): TimelineCurve = when (id) { fun curveFor(id: ActiveCurve): TimelineCurve = when (id) {
@@ -57,55 +59,45 @@ data class TimelineEditorState(
ActiveCurve.AUDIO_RIGHT -> audioRight ActiveCurve.AUDIO_RIGHT -> audioRight
} }
fun withCurve(id: ActiveCurve, curve: TimelineCurve): TimelineEditorState = when (id) { fun withCurve(id: ActiveCurve, curve: TimelineCurve): TimelineEditorState {
ActiveCurve.VISUAL_LEFT -> copy(visualLeft = curve) val normalized = curve.ensureMinutePoints(durationSec)
ActiveCurve.VISUAL_RIGHT -> copy(visualRight = curve) return when (id) {
ActiveCurve.AUDIO_LEFT -> copy(audioLeft = curve) ActiveCurve.VISUAL_LEFT -> copy(visualLeft = normalized)
ActiveCurve.AUDIO_RIGHT -> copy(audioRight = curve) ActiveCurve.VISUAL_RIGHT -> copy(visualRight = normalized)
ActiveCurve.AUDIO_LEFT -> copy(audioLeft = normalized)
ActiveCurve.AUDIO_RIGHT -> copy(audioRight = normalized)
}
} }
/**
* Spec: auto-extend timeline when user creates/moves points beyond current duration.
* Also preserve the last value to the new end of the session.
*/
fun extendDurationTo(atLeastSec: Int): TimelineEditorState { fun extendDurationTo(atLeastSec: Int): TimelineEditorState {
val newDur = atLeastSec.coerceIn(60, 8 * 60 * 60) val newDur = ((atLeastSec.coerceIn(MIN_DURATION_SEC, MAX_DURATION_SEC) + 30) / 60) * 60
if (newDur <= durationSec) return this if (newDur <= durationSec) return this
fun extendCurve(c: TimelineCurve): TimelineCurve = c.extendTo(newDur) return copy(
val updated = copy(
durationSec = newDur, durationSec = newDur,
selection = selection.copy(endSec = max(selection.endSec, newDur)), selection = selection.copy(endSec = max(selection.endSec, newDur)),
visualLeft = extendCurve(visualLeft), visualLeft = visualLeft.extendTo(newDur),
visualRight = extendCurve(visualRight), visualRight = visualRight.extendTo(newDur),
audioLeft = extendCurve(audioLeft), audioLeft = audioLeft.extendTo(newDur),
audioRight = extendCurve(audioRight), audioRight = audioRight.extendTo(newDur),
) )
// Keep viewport zoom but ensure end is reachable by panning.
return updated
} }
fun setDurationSec(newDurationSec: Int): TimelineEditorState { fun setDurationSec(newDurationSec: Int): TimelineEditorState {
val clamped = newDurationSec.coerceIn(60, 8 * 60 * 60) val clamped = ((newDurationSec.coerceIn(MIN_DURATION_SEC, MAX_DURATION_SEC) + 30) / 60) * 60
if (clamped == durationSec) return this if (clamped == durationSec) return this
return if (clamped > durationSec) { return if (clamped > durationSec) {
extendDurationTo(clamped).copy( extendDurationTo(clamped).copy(selection = selection.copy(startSec = 0, endSec = clamped))
selection = selection.copy(startSec = 0, endSec = clamped),
)
} else { } else {
// Shrink: trim curves and clamp playhead/viewport.
fun trimCurve(c: TimelineCurve) = c.trimTo(clamped)
val newViewport = viewport.copy(startSec = viewport.startSec.coerceAtMost(clamped.toFloat())) val newViewport = viewport.copy(startSec = viewport.startSec.coerceAtMost(clamped.toFloat()))
copy( copy(
durationSec = clamped, durationSec = clamped,
selection = selection.copy(startSec = 0, endSec = clamped), selection = TimelineSelection(0, clamped),
visualLeft = trimCurve(visualLeft), visualLeft = visualLeft.trimTo(clamped),
visualRight = trimCurve(visualRight), visualRight = visualRight.trimTo(clamped),
audioLeft = trimCurve(audioLeft), audioLeft = audioLeft.trimTo(clamped),
audioRight = trimCurve(audioRight), audioRight = audioRight.trimTo(clamped),
viewport = newViewport, viewport = newViewport,
playheadSec = playheadSec.coerceIn(0f, clamped.toFloat()), playheadSec = playheadSec.coerceIn(0f, clamped.toFloat()),
) )
@@ -113,15 +105,12 @@ data class TimelineEditorState(
} }
fun clampSelection(): TimelineEditorState { fun clampSelection(): TimelineEditorState {
val minDur = 60
val maxDur = 8 * 60 * 60
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)
if (end - start < minDur) end = (start + minDur).coerceAtMost(durationSec) if (end - start < MIN_DURATION_SEC) end = (start + MIN_DURATION_SEC).coerceAtMost(durationSec)
val clamped = TimelineSelection(start, end) val clamped = TimelineSelection(start, end)
// For now we keep selection anchored at 0; if start moved, reflect it but don't shift curves.
return copy(durationSec = max(durationSec, clamped.endSec), selection = clamped).let { return copy(durationSec = max(durationSec, clamped.endSec), selection = clamped).let {
it.copy(durationSec = it.durationSec.coerceIn(minDur, maxDur)) it.copy(durationSec = it.durationSec.coerceIn(MIN_DURATION_SEC, MAX_DURATION_SEC))
} }
} }
} }
@@ -147,7 +136,6 @@ data class TimelineViewport(
widthPx: Float, widthPx: Float,
centroidPx: Float, centroidPx: Float,
): TimelineViewport { ): TimelineViewport {
// Spec: Minimum zoom = 30 seconds per screen width; maximum zoom = 24 hours visible.
val minSecondsPerScreen = 30f val minSecondsPerScreen = 30f
val maxSecondsPerScreen = 24f * 60f * 60f val maxSecondsPerScreen = 24f * 60f * 60f
@@ -180,6 +168,15 @@ data class TimelineViewport(
data class TimelineCurve( data class TimelineCurve(
val points: List<TimelinePoint> val points: List<TimelinePoint>
) { ) {
companion object {
fun constant(value01: Float, durationSec: Int): TimelineCurve {
val v = value01.coerceIn(0f, 1f)
return TimelineCurve(
points = (0..durationSec step TIMELINE_STEP_SEC).map { TimelinePoint(it, v) }
)
}
}
fun valueAt(tSec: Float): Float { fun valueAt(tSec: Float): Float {
val sorted = points.sortedBy { it.tSec } val sorted = points.sortedBy { it.tSec }
if (sorted.isEmpty()) return 0.5f if (sorted.isEmpty()) return 0.5f
@@ -195,41 +192,29 @@ 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 trimTo(newDurationSec: Int): TimelineCurve { fun ensureMinutePoints(durationSec: Int): TimelineCurve {
if (points.isEmpty()) return this val snappedDuration = ((durationSec.coerceIn(MIN_DURATION_SEC, MAX_DURATION_SEC) + 30) / 60) * 60
val sorted = points.sortedBy { it.tSec } val normalized = (0..snappedDuration step TIMELINE_STEP_SEC).map { minuteSec ->
val filtered = sorted.filter { it.tSec <= newDurationSec } TimelinePoint(minuteSec, valueAt(minuteSec.toFloat()))
if (filtered.isEmpty()) return copy(points = listOf(TimelinePoint(0, 0.5f), TimelinePoint(newDurationSec, 0.5f))) }
val last = filtered.last() return copy(points = normalized)
val withEnd = if (last.tSec == newDurationSec) filtered else (filtered + last.copy(tSec = newDurationSec))
// Ensure we keep at least 2 points.
val ensured = if (withEnd.size >= 2) withEnd else listOf(withEnd.first(), withEnd.first().copy(tSec = newDurationSec))
return copy(points = ensured)
} }
fun addPointAt(offset: androidx.compose.ui.geometry.Offset, viewport: TimelineViewport, widthPx: Float, heightPx: Float): TimelineCurve { fun trimTo(newDurationSec: Int): TimelineCurve = ensureMinutePoints(newDurationSec)
val t = viewport.xToTimeSec(offset.x, widthPx).toInt().coerceAtLeast(0)
val v = (1f - (offset.y / heightPx)).coerceIn(0f, 1f)
return copy(points = (points + TimelinePoint(t, v)).sortedBy { it.tSec })
}
fun removePointAt(index: Int): TimelineCurve { fun movePointVertical(index: Int, newY: Float, heightPx: Float): TimelineCurve {
if (points.size <= 2) return this if (index !in points.indices) return this
return copy(points = points.filterIndexed { i, _ -> i != index }) val v = (1f - (newY / heightPx)).coerceIn(0f, 1f)
}
fun movePoint(index: Int, newPosition: androidx.compose.ui.geometry.Offset, viewport: TimelineViewport, widthPx: Float, heightPx: Float): TimelineCurve {
val t = viewport.xToTimeSec(newPosition.x, widthPx).toInt().coerceAtLeast(0)
val v = (1f - (newPosition.y / heightPx)).coerceIn(0f, 1f)
val updated = points.toMutableList() val updated = points.toMutableList()
updated[index] = updated[index].copy(tSec = t, value01 = v) updated[index] = updated[index].copy(value01 = v)
return copy(points = updated.sortedBy { it.tSec }) return copy(points = updated)
} }
fun hitTestPoint(offset: androidx.compose.ui.geometry.Offset, viewport: TimelineViewport, widthPx: Float, heightPx: Float): Int? { fun hitTestPoint(offset: androidx.compose.ui.geometry.Offset, viewport: TimelineViewport, widthPx: Float, heightPx: Float): Int? {
val radiusPx = 24f val radiusPx = 22f
points.forEachIndexed { idx, p -> points.forEachIndexed { idx, p ->
val x = viewport.timeToX(p.tSec, widthPx) val x = viewport.timeToX(p.tSec, widthPx)
if (x < -radiusPx || x > widthPx + radiusPx) return@forEachIndexed
val y = (1f - p.value01) * heightPx val y = (1f - p.value01) * heightPx
val dx = offset.x - x val dx = offset.x - x
val dy = offset.y - y val dy = offset.y - y
@@ -238,35 +223,7 @@ data class TimelineCurve(
return null return null
} }
fun extendTo(newDurationSec: Int): TimelineCurve { fun extendTo(newDurationSec: Int): TimelineCurve = ensureMinutePoints(newDurationSec)
if (points.isEmpty()) return this
val sorted = points.sortedBy { it.tSec }
val last = sorted.last()
if (last.tSec >= newDurationSec) return this
return copy(points = (sorted + last.copy(tSec = newDurationSec)).sortedBy { it.tSec })
}
/**
* Double-tap gesture: "smooth" by inserting an interpolated point in the nearest segment.
*/
fun insertSmoothedPointAt(offset: androidx.compose.ui.geometry.Offset, viewport: TimelineViewport, widthPx: Float, heightPx: Float): TimelineCurve {
val t = viewport.xToTimeSec(offset.x, widthPx).toInt().coerceAtLeast(0)
val sorted = points.sortedBy { it.tSec }
if (sorted.size < 2) return this
val rightIndex = sorted.indexOfFirst { it.tSec >= t }.let { if (it == -1) sorted.lastIndex else it }
val leftIndex = (rightIndex - 1).coerceAtLeast(0)
if (leftIndex == rightIndex) return this
val a = sorted[leftIndex]
val b = sorted[rightIndex]
val tt = if (b.tSec == a.tSec) 0f else (t - a.tSec).toFloat() / (b.tSec - a.tSec).toFloat()
val v = a.value01 + (b.value01 - a.value01) * tt
// Only insert if not extremely close to an existing point.
if (abs(a.tSec - t) < 1 || abs(b.tSec - t) < 1) return this
return copy(points = (sorted + TimelinePoint(t, v.coerceIn(0f, 1f))).sortedBy { it.tSec })
}
} }
data class TimelinePoint( data class TimelinePoint(

View File

@@ -6,7 +6,6 @@ object TimelineProgramFactory {
fun fromPreset(preset: SessionPreset): TimelineEditorState { fun fromPreset(preset: SessionPreset): TimelineEditorState {
val dur = preset.defaultDurationSec.coerceIn(60, 8 * 60 * 60) val dur = preset.defaultDurationSec.coerceIn(60, 8 * 60 * 60)
// Map preset fixed params into normalized 0..1 values.
val flash01 = ParameterMapping.invLog01( val flash01 = ParameterMapping.invLog01(
value = preset.flashIntervalMs.toFloat(), value = preset.flashIntervalMs.toFloat(),
min = ParameterRanges.FLASH_INTERVAL_MS_MIN, min = ParameterRanges.FLASH_INTERVAL_MS_MIN,
@@ -24,24 +23,15 @@ object TimelineProgramFactory {
max = ParameterRanges.BINAURAL_HZ_MAX, max = ParameterRanges.BINAURAL_HZ_MAX,
) )
fun constantCurve(v01: Float) = TimelineCurve(
points = listOf(
TimelinePoint(0, v01.coerceIn(0f, 1f)),
TimelinePoint(dur, v01.coerceIn(0f, 1f)),
)
)
return TimelineEditorState( return TimelineEditorState(
durationSec = dur, durationSec = dur,
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,
// Visual curves: Flash Interval (left), Blank Interval (right) visualLeft = TimelineCurve.constant(flash01, dur),
visualLeft = constantCurve(flash01), visualRight = TimelineCurve.constant(blank01, dur),
visualRight = constantCurve(blank01), audioLeft = TimelineCurve.constant(carrier01, dur),
// Audio curves: Carrier (left), Binaural (right) audioRight = TimelineCurve.constant(binaural01, dur),
audioLeft = constantCurve(carrier01),
audioRight = constantCurve(binaural01),
isPlaying = false, isPlaying = false,
playheadSec = 0f, playheadSec = 0f,
) )