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 6658530..02acc69 100644 --- a/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorScreen.kt +++ b/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorScreen.kt @@ -354,7 +354,7 @@ internal fun TimelineGraph( val curveId = draggingCurveId if (pointIndex != null && curveId != null) { val targetCurve = if (curveId == leftId) curveLeft else curveRight - val updated = targetCurve.movePointVertical(pointIndex, primary.position.y, h) + val updated = targetCurve.movePointVerticalWithPropagation(pointIndex, primary.position.y, h) onUpdateCurve(curveId, updated) onCurveEditedHaptic() primary.consume() 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 6250741..432d9eb 100644 --- a/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorState.kt +++ b/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorState.kt @@ -210,6 +210,18 @@ data class TimelineCurve( return copy(points = updated) } + fun movePointVerticalWithPropagation(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() + val draggedTime = updated[index].tSec + // Update the dragged point and all points after it (same or later time) + for (i in index until updated.size) { + updated[i] = updated[i].copy(value01 = v) + } + return copy(points = updated) + } + fun hitTestPoint(offset: androidx.compose.ui.geometry.Offset, viewport: TimelineViewport, widthPx: Float, heightPx: Float): Int? { val radiusPx = 36f return points.mapIndexedNotNull { idx, p -> diff --git a/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorState.kt.backup b/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorState.kt.backup new file mode 100644 index 0000000..6250741 --- /dev/null +++ b/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorState.kt.backup @@ -0,0 +1,232 @@ +package com.mindmachine.mvp.session + +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + +private const val MIN_DURATION_SEC = 60 +private const val MAX_DURATION_SEC = 8 * 60 * 60 +private const val TIMELINE_STEP_SEC = 60 + +data class TimelineEditorState( + val durationSec: Int, + val selection: TimelineSelection, + val viewport: TimelineViewport, + val activeCurve: ActiveCurve, + val visualLeft: TimelineCurve, + val visualRight: TimelineCurve, + val audioLeft: TimelineCurve, + val audioRight: TimelineCurve, + val isPlaying: Boolean, + val playheadSec: Float, +) { + enum class ActiveCurve { + VISUAL_LEFT, + VISUAL_RIGHT, + AUDIO_LEFT, + AUDIO_RIGHT, + } + + companion object { + fun default(durationSec: Int = 20 * 60): TimelineEditorState { + val dur = snapDuration(durationSec) + val initial = TimelineCurve.constant(0.5f, dur) + return TimelineEditorState( + durationSec = dur, + selection = TimelineSelection(0, dur), + viewport = TimelineViewport( + startSec = 0f, + secondsPerScreen = min(10f * 60f, dur.toFloat()) + ), + activeCurve = ActiveCurve.VISUAL_LEFT, + visualLeft = initial, + visualRight = initial, + audioLeft = initial, + audioRight = initial, + isPlaying = false, + 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) { + ActiveCurve.VISUAL_LEFT -> visualLeft + ActiveCurve.VISUAL_RIGHT -> visualRight + ActiveCurve.AUDIO_LEFT -> audioLeft + ActiveCurve.AUDIO_RIGHT -> audioRight + } + + 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) + } + } + + fun extendDurationTo(atLeastSec: Int): TimelineEditorState { + val newDur = ((atLeastSec.coerceIn(MIN_DURATION_SEC, MAX_DURATION_SEC) + 30) / 60) * 60 + if (newDur <= durationSec) return this + + return copy( + durationSec = newDur, + selection = selection.copy(endSec = max(selection.endSec, newDur)), + visualLeft = visualLeft.extendTo(newDur), + visualRight = visualRight.extendTo(newDur), + audioLeft = audioLeft.extendTo(newDur), + audioRight = audioRight.extendTo(newDur), + ) + } + + fun setDurationSec(newDurationSec: Int): TimelineEditorState { + 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)) + } else { + val newViewport = viewport.copy(startSec = viewport.startSec.coerceAtMost(clamped.toFloat())) + copy( + durationSec = clamped, + 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()), + ) + } + } + + fun clampSelection(): TimelineEditorState { + val start = selection.startSec.coerceIn(0, durationSec) + var end = selection.endSec.coerceIn(0, durationSec) + if (end - start < MIN_DURATION_SEC) end = (start + MIN_DURATION_SEC).coerceAtMost(durationSec) + val clamped = TimelineSelection(start, end) + return copy(durationSec = max(durationSec, clamped.endSec), selection = clamped).let { + it.copy(durationSec = it.durationSec.coerceIn(MIN_DURATION_SEC, MAX_DURATION_SEC)) + } + } +} + +data class TimelineSelection( + val startSec: Int, + val endSec: Int, +) { + init { + require(endSec >= startSec) { "endSec must be >= startSec" } + } + + val durationSec: Int get() = endSec - startSec +} + +data class TimelineViewport( + val startSec: Float, + val secondsPerScreen: Float, +) { + fun applyZoomPan( + zoomChange: Float, + panPx: Float, + widthPx: Float, + centroidPx: Float, + ): TimelineViewport { + val minSecondsPerScreen = 30f + val maxSecondsPerScreen = 24f * 60f * 60f + + val current = secondsPerScreen + val newSecondsPerScreen = (current / zoomChange).coerceIn(minSecondsPerScreen, maxSecondsPerScreen) + + val secondsPerPxBefore = current / widthPx + val secondsPerPxAfter = newSecondsPerScreen / widthPx + + val timeAtCentroidBefore = startSec + centroidPx * secondsPerPxBefore + val newStart = timeAtCentroidBefore - centroidPx * secondsPerPxAfter + + val panSec = -panPx * secondsPerPxAfter + return copy( + startSec = (newStart + panSec).coerceAtLeast(0f), + secondsPerScreen = newSecondsPerScreen, + ) + } + + fun timeToX(tSec: Int, widthPx: Float): Float { + val rel = tSec.toFloat() - startSec + return (rel / secondsPerScreen) * widthPx + } + + fun xToTimeSec(xPx: Float, widthPx: Float): Float { + return startSec + (xPx / widthPx) * secondsPerScreen + } +} + +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 + if (sorted.size == 1) return sorted.first().value01 + val t = tSec.coerceAtLeast(0f) + if (t <= sorted.first().tSec) return sorted.first().value01 + if (t >= sorted.last().tSec) return sorted.last().value01 + val rightIdx = sorted.indexOfFirst { it.tSec.toFloat() >= t }.coerceAtLeast(1) + val left = sorted[rightIdx - 1] + val right = sorted[rightIdx] + val dt = (right.tSec - left.tSec).toFloat().coerceAtLeast(1e-3f) + val u = ((t - left.tSec) / dt).coerceIn(0f, 1f) + return (left.value01 + (right.value01 - left.value01) * u).coerceIn(0f, 1f) + } + + 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 trimTo(newDurationSec: Int): TimelineCurve = ensureMinutePoints(newDurationSec) + + 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(value01 = v) + return copy(points = updated) + } + + fun hitTestPoint(offset: androidx.compose.ui.geometry.Offset, viewport: TimelineViewport, widthPx: Float, heightPx: Float): Int? { + val radiusPx = 36f + return points.mapIndexedNotNull { idx, p -> + val x = viewport.timeToX(p.tSec, widthPx) + if (x < -radiusPx || x > widthPx + radiusPx) return@mapIndexedNotNull null + val y = (1f - p.value01) * heightPx + val dx = offset.x - x + val dy = offset.y - y + val dist2 = dx * dx + dy * dy + if (dist2 <= radiusPx * radiusPx) idx to dist2 else null + }.minByOrNull { it.second }?.first + } + + fun extendTo(newDurationSec: Int): TimelineCurve = ensureMinutePoints(newDurationSec) +} + +data class TimelinePoint( + val tSec: Int, + val value01: Float, +)