Auto commit Thu Mar 19 10:01:02 PM CDT 2026
This commit is contained in:
@@ -7,6 +7,7 @@ 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
|
||||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
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
|
||||||
@@ -168,6 +169,13 @@ private fun TimelineEditorContent(
|
|||||||
|
|
||||||
internal enum class Side { LEFT, RIGHT }
|
internal enum class Side { LEFT, RIGHT }
|
||||||
|
|
||||||
|
internal object TimelineCurveColors {
|
||||||
|
val Flash: Color = Color(0xFF00FF00)
|
||||||
|
val Blank: Color = Color(0xFFFF0000)
|
||||||
|
val Carrier: Color = Color(0xFF0000FF)
|
||||||
|
val Binaural: Color = Color(0xFFFFFF00)
|
||||||
|
}
|
||||||
|
|
||||||
private enum class HandleDrag { NONE, START, END }
|
private enum class HandleDrag { NONE, START, END }
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -199,13 +207,13 @@ internal fun TimelineGraph(
|
|||||||
var draggingCurveId by remember { mutableStateOf<ActiveCurve?>(null) }
|
var draggingCurveId by remember { mutableStateOf<ActiveCurve?>(null) }
|
||||||
|
|
||||||
val leftColor = when (leftId) {
|
val leftColor = when (leftId) {
|
||||||
ActiveCurve.VISUAL_LEFT -> Color(0xFF00C853)
|
ActiveCurve.VISUAL_LEFT -> TimelineCurveColors.Flash
|
||||||
ActiveCurve.AUDIO_LEFT -> Color(0xFF2196F3)
|
ActiveCurve.AUDIO_LEFT -> TimelineCurveColors.Carrier
|
||||||
else -> Color.White
|
else -> Color.White
|
||||||
}
|
}
|
||||||
val rightColor = when (rightId) {
|
val rightColor = when (rightId) {
|
||||||
ActiveCurve.VISUAL_RIGHT -> Color(0xFFD50000)
|
ActiveCurve.VISUAL_RIGHT -> TimelineCurveColors.Blank
|
||||||
ActiveCurve.AUDIO_RIGHT -> Color(0xFFFFEB3B)
|
ActiveCurve.AUDIO_RIGHT -> TimelineCurveColors.Binaural
|
||||||
else -> Color.White
|
else -> Color.White
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,8 +234,16 @@ internal fun TimelineGraph(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
Text(leftLabel, color = if (selectedLeft) leftColor else leftColor.copy(alpha = 0.65f))
|
Text(
|
||||||
Text(rightLabel, color = if (selectedRight) rightColor else rightColor.copy(alpha = 0.65f))
|
leftLabel,
|
||||||
|
color = if (selectedLeft) leftColor else leftColor.copy(alpha = 0.65f),
|
||||||
|
modifier = Modifier.clickable { onTapSideSelect(Side.LEFT) }
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
rightLabel,
|
||||||
|
color = if (selectedRight) rightColor else rightColor.copy(alpha = 0.65f),
|
||||||
|
modifier = Modifier.clickable { onTapSideSelect(Side.RIGHT) }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val onCurveEditedHaptic = {
|
val onCurveEditedHaptic = {
|
||||||
@@ -247,19 +263,40 @@ internal fun TimelineGraph(
|
|||||||
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 hits = buildList {
|
||||||
val hitPoint = selectedCurve.hitTestPoint(start, viewport, w, h)
|
curveLeft.hitTestPoint(start, viewport, w, h)?.let { idx ->
|
||||||
if (hitPoint != null) {
|
val point = curveLeft.points[idx]
|
||||||
draggingPointIndex = hitPoint
|
val x = viewport.timeToX(point.tSec, w)
|
||||||
draggingCurveId = selectedCurveId
|
val y = valueToY(point.value01, h)
|
||||||
|
val dx = start.x - x
|
||||||
|
val dy = start.y - y
|
||||||
|
add(Triple(leftId, idx, dx * dx + dy * dy))
|
||||||
|
}
|
||||||
|
curveRight.hitTestPoint(start, viewport, w, h)?.let { idx ->
|
||||||
|
val point = curveRight.points[idx]
|
||||||
|
val x = viewport.timeToX(point.tSec, w)
|
||||||
|
val y = valueToY(point.value01, h)
|
||||||
|
val dx = start.x - x
|
||||||
|
val dy = start.y - y
|
||||||
|
add(Triple(rightId, idx, dx * dx + dy * dy))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val nearestHit = hits.minByOrNull { it.third }
|
||||||
|
if (nearestHit != null) {
|
||||||
|
draggingCurveId = nearestHit.first
|
||||||
|
draggingPointIndex = nearestHit.second
|
||||||
dragMode = HandleDrag.NONE
|
dragMode = HandleDrag.NONE
|
||||||
|
onTapSideSelect(if (nearestHit.first == leftId) Side.LEFT else Side.RIGHT)
|
||||||
} else {
|
} 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
|
||||||
}
|
}
|
||||||
|
draggingPointIndex = null
|
||||||
|
draggingCurveId = null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDragEnd = {
|
onDragEnd = {
|
||||||
@@ -273,19 +310,20 @@ internal fun TimelineGraph(
|
|||||||
dragMode = HandleDrag.NONE
|
dragMode = HandleDrag.NONE
|
||||||
},
|
},
|
||||||
onDrag = { change, dragAmount ->
|
onDrag = { change, dragAmount ->
|
||||||
|
change.consume()
|
||||||
val w = size.width.toFloat()
|
val w = size.width.toFloat()
|
||||||
val h = size.height.toFloat()
|
val h = size.height.toFloat()
|
||||||
val pointIndex = draggingPointIndex
|
val pointIndex = draggingPointIndex
|
||||||
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, change.position.y + dragAmount.y, h)
|
val updated = targetCurve.movePointVertical(pointIndex, change.position.y, h)
|
||||||
onUpdateCurve(curveId, updated)
|
onUpdateCurve(curveId, updated)
|
||||||
onCurveEditedHaptic()
|
onCurveEditedHaptic()
|
||||||
return@detectDragGestures
|
return@detectDragGestures
|
||||||
}
|
}
|
||||||
|
|
||||||
val t = viewport.xToTimeSec(change.position.x + dragAmount.x, w).toInt().coerceAtLeast(0)
|
val t = viewport.xToTimeSec(change.position.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)))
|
||||||
@@ -393,9 +431,10 @@ private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawCurve(
|
|||||||
|
|
||||||
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
|
if (x < -18f || x > size.width + 18f) return@forEach
|
||||||
val y = valueToY(p.value01, size.height)
|
val y = valueToY(p.value01, size.height)
|
||||||
drawCircle(color = color, radius = if (selected) 6.5f else 5f, center = Offset(x, y))
|
drawCircle(color = color.copy(alpha = if (selected) 0.24f else 0.14f), radius = if (selected) 16f else 13f, center = Offset(x, y))
|
||||||
|
drawCircle(color = color, radius = if (selected) 7.5f else 6f, center = Offset(x, y))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -211,16 +211,16 @@ data class TimelineCurve(
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 = 22f
|
val radiusPx = 36f
|
||||||
points.forEachIndexed { idx, p ->
|
return points.mapIndexedNotNull { idx, p ->
|
||||||
val x = viewport.timeToX(p.tSec, widthPx)
|
val x = viewport.timeToX(p.tSec, widthPx)
|
||||||
if (x < -radiusPx || x > widthPx + radiusPx) return@forEachIndexed
|
if (x < -radiusPx || x > widthPx + radiusPx) return@mapIndexedNotNull null
|
||||||
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
|
||||||
if (dx * dx + dy * dy <= radiusPx * radiusPx) return idx
|
val dist2 = dx * dx + dy * dy
|
||||||
}
|
if (dist2 <= radiusPx * radiusPx) idx to dist2 else null
|
||||||
return null
|
}.minByOrNull { it.second }?.first
|
||||||
}
|
}
|
||||||
|
|
||||||
fun extendTo(newDurationSec: Int): TimelineCurve = ensureMinutePoints(newDurationSec)
|
fun extendTo(newDurationSec: Int): TimelineCurve = ensureMinutePoints(newDurationSec)
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.mindmachine.mvp
|
||||||
|
|
||||||
|
import com.mindmachine.mvp.session.TimelineEditorState
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class DurationSliderRangeTest {
|
||||||
|
@Test
|
||||||
|
fun timeline_duration_clamps_to_one_minute_and_eight_hours() {
|
||||||
|
assertEquals(60, TimelineEditorState.default(30).durationSec)
|
||||||
|
assertEquals(8 * 60 * 60, TimelineEditorState.default(9 * 60 * 60).durationSec)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun timeline_duration_snaps_to_whole_minutes() {
|
||||||
|
assertEquals(2 * 60, TimelineEditorState.default(91).durationSec)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.mindmachine.mvp
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import com.mindmachine.mvp.session.TimelineCurveColors
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class TimelineGraphConfigTest {
|
||||||
|
@Test
|
||||||
|
fun curve_colors_match_requested_mapping_exactly() {
|
||||||
|
assertEquals(0xFF00FF00.toInt(), TimelineCurveColors.Flash.toArgb())
|
||||||
|
assertEquals(0xFFFF0000.toInt(), TimelineCurveColors.Blank.toArgb())
|
||||||
|
assertEquals(0xFF0000FF.toInt(), TimelineCurveColors.Carrier.toArgb())
|
||||||
|
assertEquals(0xFFFFFF00.toInt(), TimelineCurveColors.Binaural.toArgb())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
org.gradle.daemon=false
|
||||||
|
kotlin.compiler.execution.strategy=in-process
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
kotlin.code.style=official
|
kotlin.code.style=official
|
||||||
android.nonTransitiveRClass=true
|
android.nonTransitiveRClass=true
|
||||||
|
|||||||
Reference in New Issue
Block a user