From ee60bf0c0f26df9fa02702ee3422c4bb98a528af Mon Sep 17 00:00:00 2001 From: Tretzi Date: Thu, 19 Mar 2026 21:01:01 -0500 Subject: [PATCH] Auto commit Thu Mar 19 09:01:01 PM CDT 2026 --- .../mvp/session/DurationSliderCard.kt | 3 +- .../mindmachine/mvp/session/MainViewModel.kt | 182 ++++++------ .../mvp/session/SetupTimelineEditor.kt | 2 +- .../mvp/session/TimelineEditorScreen.kt | 262 ++++++------------ .../mvp/session/TimelineEditorState.kt | 155 ++++------- .../mvp/session/TimelineProgramFactory.kt | 18 +- 6 files changed, 242 insertions(+), 380 deletions(-) diff --git a/app/src/main/java/com/mindmachine/mvp/session/DurationSliderCard.kt b/app/src/main/java/com/mindmachine/mvp/session/DurationSliderCard.kt index 5737552..3d9c7ba 100644 --- a/app/src/main/java/com/mindmachine/mvp/session/DurationSliderCard.kt +++ b/app/src/main/java/com/mindmachine/mvp/session/DurationSliderCard.kt @@ -51,7 +51,6 @@ fun DurationSliderCard( Slider( value = clamped.toFloat(), onValueChange = { - // Keep it sane: snap to whole minutes for UX. val snapped = (it / 60f).roundToInt().coerceIn(1, 8 * 60) * 60 onDurationSecChanged(snapped) }, @@ -59,7 +58,7 @@ fun DurationSliderCard( ) Text( - "1 min  8 hours", + "Range: 1 minute to 8 hours", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), ) diff --git a/app/src/main/java/com/mindmachine/mvp/session/MainViewModel.kt b/app/src/main/java/com/mindmachine/mvp/session/MainViewModel.kt index 4e0543e..63cc80c 100644 --- a/app/src/main/java/com/mindmachine/mvp/session/MainViewModel.kt +++ b/app/src/main/java/com/mindmachine/mvp/session/MainViewModel.kt @@ -53,6 +53,7 @@ class MainViewModel( val ui: StateFlow = _ui.asStateFlow() private var runJob: Job? = null + private var elapsedBeforePauseSec: Float = 0f init { viewModelScope.launch { @@ -78,6 +79,7 @@ class MainViewModel( error = null, ) } + elapsedBeforePauseSec = 0f } 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) } 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 { - it.copy( - 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) } - } + elapsedBeforePauseSec = 0f + runProgram(startElapsedSec = 0f, includeCountdown = true) } fun pause(reason: String? = null) { if (_ui.value.runtimeState != RuntimeState.RUNNING) return + elapsedBeforePauseSec = currentElapsedSec() runJob?.cancel() 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() { @@ -196,35 +147,13 @@ class MainViewModel( _ui.update { it.copy(error = "Headphones are required to resume audio mode.") } return } - runJob = viewModelScope.launch { - 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) } - } + runProgram(startElapsedSec = elapsedBeforePauseSec, includeCountdown = true) } fun stop() { runJob?.cancel() audioEngine.stop() + elapsedBeforePauseSec = 0f _ui.update { it.copy(runtimeState = RuntimeState.STOPPED, endedEarly = true) } } @@ -238,6 +167,91 @@ class MainViewModel( 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( private val settingsRepository: SettingsRepository, private val headsetMonitor: HeadsetMonitor, diff --git a/app/src/main/java/com/mindmachine/mvp/session/SetupTimelineEditor.kt b/app/src/main/java/com/mindmachine/mvp/session/SetupTimelineEditor.kt index 22e875e..7c95cba 100644 --- a/app/src/main/java/com/mindmachine/mvp/session/SetupTimelineEditor.kt +++ b/app/src/main/java/com/mindmachine/mvp/session/SetupTimelineEditor.kt @@ -34,7 +34,7 @@ fun SetupTimelineEditor( color = MaterialTheme.colorScheme.onBackground, ) 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, color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.75f), ) diff --git a/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorScreen.kt b/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorScreen.kt index 8f493b6..2478c6e 100644 --- a/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorScreen.kt +++ b/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorScreen.kt @@ -3,7 +3,6 @@ package com.mindmachine.mvp.session import android.view.HapticFeedbackConstants import androidx.activity.compose.BackHandler import androidx.compose.foundation.Canvas -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectDragGestures 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.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -56,7 +52,6 @@ fun TimelineEditorScreen( val context = LocalContext.current val activity = context as? android.app.Activity - // Timeline editor is always immersive per spec. LaunchedEffect(Unit) { if (activity != null) { val window = activity.window @@ -108,74 +103,32 @@ private fun TimelineEditorRoot( } } -@OptIn(ExperimentalFoundationApi::class) @Composable private fun TimelineEditorContent( onBack: () -> Unit, ) { 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( Modifier .fillMaxSize() .padding(12.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( - formatDuration(state.selection.durationSec.coerceAtLeast(0)), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface - ) - } - 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( - "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.", + "Minute-based timeline editor", + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.titleLarge, + ) + Text( + "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), style = MaterialTheme.typography.bodySmall, ) - TimelineGraph( title = "Visual", - leftLabel = "Brightness", - rightLabel = "Blink Rate", + leftLabel = "Flash Interval", + rightLabel = "Blank Interval", curveLeft = state.visualLeft, curveRight = state.visualRight, activeCurve = state.activeCurve, @@ -184,20 +137,15 @@ private fun TimelineEditorContent( playheadSec = state.playheadSec, onViewportChanged = { state = state.copy(viewport = it) }, onSelectionChanged = { sel -> state = state.copy(selection = sel).clampSelection() }, - onUpdateCurve = { 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))) - }, + onUpdateCurve = { curveId, newCurve -> state = state.withCurve(curveId, newCurve) }, onTapSideSelect = { side -> state = state.copy(activeCurve = if (side == Side.LEFT) ActiveCurve.VISUAL_LEFT else ActiveCurve.VISUAL_RIGHT) } ) - TimelineGraph( title = "Audio", leftLabel = "Carrier Frequency", - rightLabel = "Binaural Beat", + rightLabel = "Binaural Frequency", curveLeft = state.audioLeft, curveRight = state.audioRight, activeCurve = state.activeCurve, @@ -206,31 +154,15 @@ private fun TimelineEditorContent( playheadSec = state.playheadSec, onViewportChanged = { state = state.copy(viewport = it) }, onSelectionChanged = { sel -> state = state.copy(selection = sel).clampSelection() }, - onUpdateCurve = { curveId, newCurve -> - val candidate = state.withCurve(curveId, newCurve) - val maxT = newCurve.points.maxOfOrNull { it.tSec } ?: 0 - state = candidate.extendDurationTo(maxT) - }, + onUpdateCurve = { curveId, newCurve -> state = state.withCurve(curveId, newCurve) }, onTapSideSelect = { side -> state = state.copy(activeCurve = if (side == Side.LEFT) ActiveCurve.AUDIO_LEFT else ActiveCurve.AUDIO_RIGHT) } ) - - Spacer(Modifier.height(4.dp)) - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { - 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") - } - } + Text( + "Back to return.", + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.75f), + ) } } @@ -262,8 +194,20 @@ internal fun TimelineGraph( val selectedRight = activeCurve == rightId val context = LocalContext.current - var dragMode by remember { mutableStateOf(HandleDrag.NONE) } + var draggingPointIndex by remember { mutableStateOf(null) } + var draggingCurveId by remember { mutableStateOf(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( Modifier @@ -282,8 +226,8 @@ internal fun TimelineGraph( } Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Text(leftLabel, color = if (selectedLeft) Color.White else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f)) - Text(rightLabel, color = if (selectedRight) 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) rightColor else rightColor.copy(alpha = 0.65f)) } val onCurveEditedHaptic = { @@ -295,40 +239,64 @@ internal fun TimelineGraph( .fillMaxWidth() .height(190.dp) .background(Color(0xFF0A0E18), RoundedCornerShape(10.dp)) - .pointerInput(viewport, selection) { + .pointerInput(viewport, selection, curveLeft, curveRight, activeCurve) { detectDragGestures( onDragStart = { start -> val w = size.width.toFloat() + val h = size.height.toFloat() val startX = viewport.timeToX(selection.startSec, w) val endX = viewport.timeToX(selection.endSec, w) val hitPx = 32f - dragMode = when { - abs(start.x - startX) <= hitPx -> HandleDrag.START - abs(start.x - endX) <= hitPx -> HandleDrag.END - else -> HandleDrag.NONE + 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 { + abs(start.x - startX) <= hitPx -> HandleDrag.START + abs(start.x - endX) <= hitPx -> HandleDrag.END + else -> HandleDrag.NONE + } } }, - onDragEnd = { dragMode = HandleDrag.NONE }, - onDragCancel = { dragMode = HandleDrag.NONE }, + onDragEnd = { + draggingPointIndex = null + draggingCurveId = null + dragMode = HandleDrag.NONE + }, + onDragCancel = { + draggingPointIndex = null + draggingCurveId = null + dragMode = HandleDrag.NONE + }, onDrag = { change, dragAmount -> 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) when (dragMode) { 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.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( - zoomChange = 1f, - panPx = dragAmount.x, - widthPx = w, - centroidPx = w / 2f, - ) - onViewportChanged(newViewport) - } + val newViewport = viewport.applyZoomPan( + zoomChange = 1f, + panPx = dragAmount.x, + widthPx = w, + centroidPx = w / 2f, + ) + onViewportChanged(newViewport) } } } @@ -345,74 +313,11 @@ internal fun TimelineGraph( onViewportChanged(newViewport) } } - .pointerInput(activeCurve, viewport, curveLeft, curveRight) { + .pointerInput(activeCurve) { detectTapGestures( onTap = { offset -> val side = if (offset.x < size.width / 2f) Side.LEFT else Side.RIGHT 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()) { drawGrid() - // selection range overlay + handles val startX = viewport.timeToX(selection.startSec, size.width) val endX = viewport.timeToX(selection.endSec, size.width) drawRect( @@ -435,17 +339,16 @@ internal fun TimelineGraph( drawCircle(handleColor, radius = 10f, center = Offset(startX, size.height - 14f)) drawCircle(handleColor, radius = 10f, center = Offset(endX, size.height - 14f)) - // playhead 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(curveRight, selected = selectedRight, color = if (isVisual) Color(0xFF7AB8FF) else Color(0xFF2FCB7C), viewport = viewport) + drawCurve(curveLeft, selected = selectedLeft, color = leftColor, viewport = viewport) + drawCurve(curveRight, selected = selectedRight, color = rightColor, viewport = viewport) } } 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), style = MaterialTheme.typography.bodySmall, textAlign = TextAlign.Center, @@ -482,17 +385,17 @@ private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawCurve( val y = valueToY(p.value01, size.height) if (idx == 0) path.moveTo(x, y) else path.lineTo(x, y) } - val stroke = if (selected) 6f else 3f drawPath( path, - color = if (selected) color.copy(alpha = 1f) else color.copy(alpha = 0.55f), - style = androidx.compose.ui.graphics.drawscope.Stroke(width = stroke) + color = if (selected) color else color.copy(alpha = 0.6f), + style = androidx.compose.ui.graphics.drawscope.Stroke(width = if (selected) 5f else 3f) ) pts.forEach { p -> val x = viewport.timeToX(p.tSec, size.width) + if (x < -12f || x > size.width + 12f) return@forEach 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) return (1f - v) * height } - diff --git a/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorState.kt b/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorState.kt index 642a89b..f775734 100644 --- a/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorState.kt +++ b/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorState.kt @@ -4,6 +4,10 @@ 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 + data class TimelineEditorState( val durationSec: Int, val selection: TimelineSelection, @@ -25,13 +29,8 @@ data class TimelineEditorState( companion object { fun default(durationSec: Int = 20 * 60): TimelineEditorState { - val dur = durationSec.coerceIn(60, 8 * 60 * 60) - val initial = TimelineCurve( - points = listOf( - TimelinePoint(0, 0.5f), - TimelinePoint(dur, 0.5f), - ) - ) + val dur = snapDuration(durationSec) + val initial = TimelineCurve.constant(0.5f, dur) return TimelineEditorState( durationSec = dur, selection = TimelineSelection(0, dur), @@ -48,6 +47,9 @@ data class TimelineEditorState( 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) { @@ -57,55 +59,45 @@ data class TimelineEditorState( ActiveCurve.AUDIO_RIGHT -> audioRight } - fun withCurve(id: ActiveCurve, curve: TimelineCurve): TimelineEditorState = when (id) { - ActiveCurve.VISUAL_LEFT -> copy(visualLeft = curve) - ActiveCurve.VISUAL_RIGHT -> copy(visualRight = curve) - ActiveCurve.AUDIO_LEFT -> copy(audioLeft = curve) - ActiveCurve.AUDIO_RIGHT -> copy(audioRight = curve) + fun withCurve(id: ActiveCurve, curve: TimelineCurve): TimelineEditorState { + val normalized = curve.ensureMinutePoints(durationSec) + return when (id) { + ActiveCurve.VISUAL_LEFT -> copy(visualLeft = normalized) + 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 { - 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 - fun extendCurve(c: TimelineCurve): TimelineCurve = c.extendTo(newDur) - - val updated = copy( + return copy( durationSec = newDur, selection = selection.copy(endSec = max(selection.endSec, newDur)), - visualLeft = extendCurve(visualLeft), - visualRight = extendCurve(visualRight), - audioLeft = extendCurve(audioLeft), - audioRight = extendCurve(audioRight), + visualLeft = visualLeft.extendTo(newDur), + visualRight = visualRight.extendTo(newDur), + audioLeft = audioLeft.extendTo(newDur), + audioRight = audioRight.extendTo(newDur), ) - - // Keep viewport zoom but ensure end is reachable by panning. - return updated } 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 return if (clamped > durationSec) { - extendDurationTo(clamped).copy( - selection = selection.copy(startSec = 0, endSec = clamped), - ) + extendDurationTo(clamped).copy(selection = selection.copy(startSec = 0, endSec = clamped)) } 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())) copy( durationSec = clamped, - selection = selection.copy(startSec = 0, endSec = clamped), - visualLeft = trimCurve(visualLeft), - visualRight = trimCurve(visualRight), - audioLeft = trimCurve(audioLeft), - audioRight = trimCurve(audioRight), + selection = TimelineSelection(0, clamped), + visualLeft = visualLeft.trimTo(clamped), + visualRight = visualRight.trimTo(clamped), + audioLeft = audioLeft.trimTo(clamped), + audioRight = audioRight.trimTo(clamped), viewport = newViewport, playheadSec = playheadSec.coerceIn(0f, clamped.toFloat()), ) @@ -113,15 +105,12 @@ data class TimelineEditorState( } fun clampSelection(): TimelineEditorState { - val minDur = 60 - val maxDur = 8 * 60 * 60 val start = selection.startSec.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) - // 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 { - 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, centroidPx: Float, ): TimelineViewport { - // Spec: Minimum zoom = 30 seconds per screen width; maximum zoom = 24 hours visible. val minSecondsPerScreen = 30f val maxSecondsPerScreen = 24f * 60f * 60f @@ -180,6 +168,15 @@ data class TimelineViewport( data class TimelineCurve( val points: List ) { + 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 { val sorted = points.sortedBy { it.tSec } if (sorted.isEmpty()) return 0.5f @@ -195,41 +192,29 @@ data class TimelineCurve( return (left.value01 + (right.value01 - left.value01) * u).coerceIn(0f, 1f) } - fun trimTo(newDurationSec: Int): TimelineCurve { - if (points.isEmpty()) return this - val sorted = points.sortedBy { it.tSec } - val filtered = sorted.filter { it.tSec <= newDurationSec } - if (filtered.isEmpty()) return copy(points = listOf(TimelinePoint(0, 0.5f), TimelinePoint(newDurationSec, 0.5f))) - val last = filtered.last() - 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 ensureMinutePoints(durationSec: 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())) + } + return copy(points = normalized) } - fun addPointAt(offset: androidx.compose.ui.geometry.Offset, viewport: TimelineViewport, widthPx: Float, heightPx: Float): TimelineCurve { - 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 trimTo(newDurationSec: Int): TimelineCurve = ensureMinutePoints(newDurationSec) - fun removePointAt(index: Int): TimelineCurve { - if (points.size <= 2) return this - return copy(points = points.filterIndexed { i, _ -> i != index }) - } - - 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) + fun movePointVertical(index: Int, newY: Float, heightPx: Float): TimelineCurve { + if (index !in points.indices) return this + val v = (1f - (newY / heightPx)).coerceIn(0f, 1f) val updated = points.toMutableList() - updated[index] = updated[index].copy(tSec = t, value01 = v) - return copy(points = updated.sortedBy { it.tSec }) + updated[index] = updated[index].copy(value01 = v) + return copy(points = updated) } 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 -> val x = viewport.timeToX(p.tSec, widthPx) + if (x < -radiusPx || x > widthPx + radiusPx) return@forEachIndexed val y = (1f - p.value01) * heightPx val dx = offset.x - x val dy = offset.y - y @@ -238,35 +223,7 @@ data class TimelineCurve( return null } - fun extendTo(newDurationSec: Int): TimelineCurve { - 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 }) - } + fun extendTo(newDurationSec: Int): TimelineCurve = ensureMinutePoints(newDurationSec) } data class TimelinePoint( diff --git a/app/src/main/java/com/mindmachine/mvp/session/TimelineProgramFactory.kt b/app/src/main/java/com/mindmachine/mvp/session/TimelineProgramFactory.kt index b8d7579..3993658 100644 --- a/app/src/main/java/com/mindmachine/mvp/session/TimelineProgramFactory.kt +++ b/app/src/main/java/com/mindmachine/mvp/session/TimelineProgramFactory.kt @@ -6,7 +6,6 @@ object TimelineProgramFactory { fun fromPreset(preset: SessionPreset): TimelineEditorState { val dur = preset.defaultDurationSec.coerceIn(60, 8 * 60 * 60) - // Map preset fixed params into normalized 0..1 values. val flash01 = ParameterMapping.invLog01( value = preset.flashIntervalMs.toFloat(), min = ParameterRanges.FLASH_INTERVAL_MS_MIN, @@ -24,24 +23,15 @@ object TimelineProgramFactory { 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( durationSec = dur, selection = TimelineSelection(0, dur), viewport = TimelineViewport(startSec = 0f, secondsPerScreen = minOf(10f * 60f, dur.toFloat())), activeCurve = TimelineEditorState.ActiveCurve.VISUAL_LEFT, - // Visual curves: Flash Interval (left), Blank Interval (right) - visualLeft = constantCurve(flash01), - visualRight = constantCurve(blank01), - // Audio curves: Carrier (left), Binaural (right) - audioLeft = constantCurve(carrier01), - audioRight = constantCurve(binaural01), + visualLeft = TimelineCurve.constant(flash01, dur), + visualRight = TimelineCurve.constant(blank01, dur), + audioLeft = TimelineCurve.constant(carrier01, dur), + audioRight = TimelineCurve.constant(binaural01, dur), isPlaying = false, playheadSec = 0f, )