Auto commit Sat Mar 21 03:02:09 AM CDT 2026
This commit is contained in:
@@ -354,7 +354,7 @@ internal fun TimelineGraph(
|
|||||||
val curveId = draggingCurveId
|
val curveId = draggingCurveId
|
||||||
if (pointIndex != null && curveId != null) {
|
if (pointIndex != null && curveId != null) {
|
||||||
val targetCurve = if (curveId == leftId) curveLeft else curveRight
|
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)
|
onUpdateCurve(curveId, updated)
|
||||||
onCurveEditedHaptic()
|
onCurveEditedHaptic()
|
||||||
primary.consume()
|
primary.consume()
|
||||||
|
|||||||
@@ -210,6 +210,18 @@ data class TimelineCurve(
|
|||||||
return copy(points = updated)
|
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? {
|
fun hitTestPoint(offset: androidx.compose.ui.geometry.Offset, viewport: TimelineViewport, widthPx: Float, heightPx: Float): Int? {
|
||||||
val radiusPx = 36f
|
val radiusPx = 36f
|
||||||
return points.mapIndexedNotNull { idx, p ->
|
return points.mapIndexedNotNull { idx, p ->
|
||||||
|
|||||||
@@ -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<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 {
|
||||||
|
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,
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user