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
|
||||
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()
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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