MindMachine: add timeline editor screen with pinch zoom and duration slider

This commit is contained in:
Tretzi
2026-03-19 15:56:50 -05:00
parent d441ead98b
commit a2669d6f59
5 changed files with 937 additions and 31 deletions

View File

@@ -8,6 +8,9 @@ import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.background
import com.mindmachine.mvp.session.TimelineEditorScreen
import com.mindmachine.mvp.session.TimelineModel
import com.mindmachine.mvp.session.TimelineParameterControl
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -184,10 +187,21 @@ fun App(vm: MainViewModel = viewModel()) {
}
}
composable("setup") {
SetupScreen(vm = vm, onStart = {
vm.startSession()
nav.navigate("active")
}, onHolder = { nav.navigate("holder") })
SetupScreen(
vm = vm,
onStart = {
vm.startSession()
nav.navigate("active")
},
onHolder = { nav.navigate("holder") },
onOpenTimelineEditor = { nav.navigate("timeline") },
)
}
composable("timeline") {
TimelineEditorScreen(
vm = vm,
onBack = { nav.popBackStack() }
)
}
composable("active") {
ActiveSessionScreen(
@@ -249,9 +263,17 @@ fun SafetyScreen(onAck: () -> Unit, onHolder: () -> Unit) {
}
@Composable
fun SetupScreen(vm: MainViewModel, onStart: () -> Unit, onHolder: () -> Unit) {
fun SetupScreen(
vm: MainViewModel,
onStart: () -> Unit,
onHolder: () -> Unit,
onOpenTimelineEditor: () -> Unit,
) {
val ui by vm.ui.collectAsStateWithLifecycle()
val cfg = ui.config
var model by remember(ui.selectedPreset.id, cfg.durationSec) {
mutableStateOf(TimelineModel.createForPreset(ui.selectedPreset, cfg.durationSec))
}
val scrollState = rememberScrollState()
val containerModifier = Modifier
.fillMaxSize()
@@ -260,36 +282,32 @@ fun SetupScreen(vm: MainViewModel, onStart: () -> Unit, onHolder: () -> Unit) {
.safeDrawingPadding()
.padding(horizontal = 16.dp, vertical = 12.dp)
TimelineParameterControl(
model = model,
onParameterChanged = { id: String, newValue: Float ->
model = model.updateParameterValue(id, newValue)
when (id) {
"flash_interval" -> vm.setFlashIntervalMs((newValue * 1000f).roundToInt())
"carrier_frequency" -> vm.setCarrier(newValue)
"binaural_difference" -> vm.setDifference(newValue)
}
},
modifier = containerModifier,
showTimeline = true,
onTimelineChanged = { newDuration ->
vm.setDurationMin((newDuration / 60f).roundToInt())
}
)
// Additional controls
Column(
containerModifier,
Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(ui.selectedPreset.name, style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onBackground)
Text("Duration: ${cfg.durationSec / 60} min", color = MaterialTheme.colorScheme.onBackground)
Slider(value = (cfg.durationSec / 60).toFloat(), onValueChange = { vm.setDurationMin(it.roundToInt()) }, valueRange = 1f..30f)
Text("Mode", color = MaterialTheme.colorScheme.onBackground)
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
SessionMode.values().forEachIndexed { index, mode ->
SegmentedButton(
shape = androidx.compose.material3.SegmentedButtonDefaults.itemShape(index = index, count = SessionMode.values().size),
selected = cfg.mode == mode,
onClick = { vm.setMode(mode) },
label = { Text(mode.name.replace("_", " ")) }
)
}
OutlinedButton(onClick = onOpenTimelineEditor, modifier = Modifier.fillMaxWidth()) {
Text("Open Timeline Editor")
}
Text(setupScreenFlashIntervalLabel(cfg.flashIntervalMs), color = MaterialTheme.colorScheme.onBackground)
Slider(
value = flashIntervalMsToSeconds(cfg.flashIntervalMs),
onValueChange = { vm.setFlashIntervalMs(flashIntervalSecondsToMs(it)) },
valueRange = 0.05f..2.0f
)
Text("Controls how long each frame is shown in the red/green/black pattern.", color = MaterialTheme.colorScheme.onBackground)
Text("Carrier frequency: ${cfg.carrierFrequencyHz.roundToInt()}", color = MaterialTheme.colorScheme.onBackground)
Slider(value = cfg.carrierFrequencyHz, onValueChange = vm::setCarrier, valueRange = 80f..400f)
Text("Binaural difference: ${cfg.binauralDifferenceHz}", color = MaterialTheme.colorScheme.onBackground)
Slider(value = cfg.binauralDifferenceHz, onValueChange = vm::setDifference, valueRange = 0.5f..20f)
Text("Brightness recommendation: keep screen comfortable and avoid eye strain.", color = MaterialTheme.colorScheme.onBackground)
TextButton(onClick = onHolder) { Text("Holder Guidance") }
if (ui.error != null) Text(ui.error!!, color = MaterialTheme.colorScheme.error)
Button(onClick = onStart, modifier = Modifier.fillMaxWidth().height(52.dp)) { Text("Start") }

View File

@@ -0,0 +1,470 @@
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
import androidx.compose.foundation.gestures.detectTransformGestures
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.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import com.mindmachine.mvp.session.TimelineEditorState.ActiveCurve
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
@Composable
fun TimelineEditorScreen(
vm: MainViewModel,
onBack: () -> Unit,
) {
BackHandler { onBack() }
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
WindowCompat.setDecorFitsSystemWindows(window, false)
val controller = WindowInsetsControllerCompat(window, window.decorView)
controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
controller.hide(WindowInsetsCompat.Type.systemBars())
}
}
var revealSignal by remember { mutableIntStateOf(0) }
TimelineEditorRoot(
modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background),
onRevealSystemBars = { revealSignal += 1 },
revealSignal = revealSignal,
) {
TimelineEditorContent(onBack = onBack)
}
}
@Composable
private fun TimelineEditorRoot(
modifier: Modifier,
onRevealSystemBars: () -> Unit,
revealSignal: Int,
content: @Composable () -> Unit,
) {
val context = LocalContext.current
val activity = context as? android.app.Activity
val window = activity?.window
val controller = window?.let { remember(it) { WindowInsetsControllerCompat(it, it.decorView) } }
LaunchedEffect(revealSignal) {
if (window == null || controller == null) return@LaunchedEffect
if (revealSignal == 0) return@LaunchedEffect
controller.show(WindowInsetsCompat.Type.systemBars())
kotlinx.coroutines.delay(2500)
controller.hide(WindowInsetsCompat.Type.systemBars())
}
Box(
modifier = modifier.pointerInput(Unit) {
detectTapGestures(onTap = { onRevealSystemBars() })
}
) {
content()
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun TimelineEditorContent(
onBack: () -> Unit,
) {
var state by remember { mutableStateOf(TimelineEditorState.default()) }
Column(Modifier.fillMaxSize().padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Text("Timeline Editor", style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onBackground)
OutlinedButton(onClick = onBack) { Text("Back") }
}
DurationSlider(
durationSec = state.durationSec,
onDurationChanged = { state = state.copy(durationSec = it) },
)
CurveSelector(
active = state.activeCurve,
onSelect = { state = state.copy(activeCurve = it) }
)
TimelineGraph(
title = "Visual",
leftLabel = "Brightness",
rightLabel = "Blink Rate",
curveLeft = state.visualLeft,
curveRight = state.visualRight,
activeCurve = state.activeCurve,
viewport = state.viewport,
onViewportChanged = { state = state.copy(viewport = it) },
onUpdateCurve = { curveId, newCurve ->
state = when (curveId) {
ActiveCurve.VISUAL_LEFT -> state.copy(visualLeft = newCurve)
ActiveCurve.VISUAL_RIGHT -> state.copy(visualRight = newCurve)
ActiveCurve.AUDIO_LEFT -> state // ignored
ActiveCurve.AUDIO_RIGHT -> state // ignored
}
},
onTapSideSelect = { side ->
state = state.copy(activeCurve = if (side == Side.LEFT) ActiveCurve.VISUAL_LEFT else ActiveCurve.VISUAL_RIGHT)
}
)
TimelineGraph(
title = "Audio",
leftLabel = "Carrier",
rightLabel = "Binaural",
curveLeft = state.audioLeft,
curveRight = state.audioRight,
activeCurve = state.activeCurve,
viewport = state.viewport,
onViewportChanged = { state = state.copy(viewport = it) },
onUpdateCurve = { curveId, newCurve ->
state = when (curveId) {
ActiveCurve.AUDIO_LEFT -> state.copy(audioLeft = newCurve)
ActiveCurve.AUDIO_RIGHT -> state.copy(audioRight = newCurve)
ActiveCurve.VISUAL_LEFT -> state // ignored
ActiveCurve.VISUAL_RIGHT -> state // ignored
}
},
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")
}
}
}
}
@Composable
private fun DurationSlider(
durationSec: Int,
onDurationChanged: (Int) -> Unit,
) {
val minSec = 60
val maxSec = 8 * 60 * 60
val clamped = durationSec.coerceIn(minSec, maxSec)
val value = clamped.toFloat()
Column(Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surface, RoundedCornerShape(12.dp)).padding(12.dp)) {
Text("Duration", color = MaterialTheme.colorScheme.onSurface)
Text(formatDuration(clamped), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface)
Slider(
value = value,
onValueChange = { onDurationChanged(it.roundToInt().coerceIn(minSec, maxSec)) },
valueRange = minSec.toFloat()..maxSec.toFloat(),
)
Text("1 minute  8 hours", color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.bodySmall)
}
}
private enum class Side { LEFT, RIGHT }
@Composable
private fun CurveSelector(
active: ActiveCurve,
onSelect: (ActiveCurve) -> Unit,
) {
Column(Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surface, RoundedCornerShape(12.dp)).padding(12.dp)) {
Text("Curve selection", color = MaterialTheme.colorScheme.onSurface)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Column(Modifier.weight(1f)) {
Text("Visual", color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.titleSmall)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SelectorChip("Brightness", active == ActiveCurve.VISUAL_LEFT) { onSelect(ActiveCurve.VISUAL_LEFT) }
SelectorChip("Blink", active == ActiveCurve.VISUAL_RIGHT) { onSelect(ActiveCurve.VISUAL_RIGHT) }
}
}
Column(Modifier.weight(1f)) {
Text("Audio", color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.titleSmall)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SelectorChip("Carrier", active == ActiveCurve.AUDIO_LEFT) { onSelect(ActiveCurve.AUDIO_LEFT) }
SelectorChip("Binaural", active == ActiveCurve.AUDIO_RIGHT) { onSelect(ActiveCurve.AUDIO_RIGHT) }
}
}
}
Text(
"Tip: you can also tap left/right side of a graph to select the corresponding curve.",
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodySmall,
)
}
}
@Composable
private fun SelectorChip(
label: String,
selected: Boolean,
onClick: () -> Unit,
) {
val bg = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant
val fg = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant
Box(
modifier = Modifier
.background(bg, RoundedCornerShape(999.dp))
.padding(horizontal = 10.dp, vertical = 6.dp)
.pointerInput(Unit) {
detectTapGestures(onTap = { onClick() })
}
) {
Text(label, color = fg, style = MaterialTheme.typography.labelLarge)
}
}
@Composable
private fun TimelineGraph(
title: String,
leftLabel: String,
rightLabel: String,
curveLeft: TimelineCurve,
curveRight: TimelineCurve,
activeCurve: ActiveCurve,
viewport: TimelineViewport,
onViewportChanged: (TimelineViewport) -> Unit,
onUpdateCurve: (ActiveCurve, TimelineCurve) -> Unit,
onTapSideSelect: (Side) -> Unit,
) {
val isVisual = title == "Visual"
val leftId = if (isVisual) ActiveCurve.VISUAL_LEFT else ActiveCurve.AUDIO_LEFT
val rightId = if (isVisual) ActiveCurve.VISUAL_RIGHT else ActiveCurve.AUDIO_RIGHT
val selectedLeft = activeCurve == leftId
val selectedRight = activeCurve == rightId
val context = LocalContext.current
Column(Modifier.fillMaxWidth().height(240.dp).background(MaterialTheme.colorScheme.surface, RoundedCornerShape(12.dp)).padding(10.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Text(title, color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.titleMedium)
Text(
"Zoom: ${"%.1f".format(viewport.secondsPerScreen / 60f)} min/screen",
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodySmall,
)
}
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))
}
val onCurveEditedHaptic = {
(context as? android.app.Activity)?.window?.decorView?.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(180.dp)
.background(Color(0xFF0A0E18), RoundedCornerShape(10.dp))
.pointerInput(Unit) {
detectDragGestures(
onDrag = { change, dragAmount ->
// If user drags without grabbing a point, treat as pan.
val didHitLeft = curveLeft.hitTestPoint(change.position, viewport, size.width.toFloat(), size.height.toFloat()) != null
val didHitRight = curveRight.hitTestPoint(change.position, viewport, size.width.toFloat(), size.height.toFloat()) != null
if (!didHitLeft && !didHitRight) {
val newViewport = viewport.applyZoomPan(
zoomChange = 1f,
panPx = dragAmount.x,
widthPx = size.width.toFloat(),
centroidPx = size.width.toFloat() / 2f,
)
onViewportChanged(newViewport)
}
}
)
}
.pointerInput(activeCurve, viewport, curveLeft, curveRight) {
detectTransformGestures { centroid, pan, zoom, _ ->
val newViewport = viewport.applyZoomPan(
zoomChange = zoom,
panPx = pan.x,
widthPx = size.width.toFloat(),
centroidPx = centroid.x,
)
onViewportChanged(newViewport)
}
}
.pointerInput(activeCurve, viewport, curveLeft, curveRight) {
detectTapGestures(
onTap = { offset ->
val side = if (offset.x < size.width / 2f) Side.LEFT else Side.RIGHT
onTapSideSelect(side)
},
onDoubleTap = { /* TODO smooth */ },
onLongPress = { offset ->
val curveId = if (activeCurve == leftId || activeCurve == 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(
onDragStart = { /* no-op */ },
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)
}
}
)
}
) {
Canvas(modifier = Modifier.fillMaxSize()) {
drawGrid()
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)
}
}
Text(
"Tap to add • Drag to move • Long-press to delete • Pinch to zoom • Drag background to pan (WIP)",
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f),
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(top = 6.dp)
)
}
}
private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawGrid() {
val stepX = size.width / 8f
val stepY = size.height / 4f
val grid = Color.White.copy(alpha = 0.10f)
for (i in 1..7) {
drawLine(grid, start = Offset(i * stepX, 0f), end = Offset(i * stepX, size.height))
}
for (j in 1..3) {
drawLine(grid, start = Offset(0f, j * stepY), end = Offset(size.width, j * stepY))
}
}
private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawCurve(
curve: TimelineCurve,
selected: Boolean,
color: Color,
viewport: TimelineViewport,
) {
if (curve.points.isEmpty()) return
val pts = curve.points.sortedBy { it.tSec }
val path = Path()
pts.forEachIndexed { idx, p ->
val x = viewport.timeToX(p.tSec, size.width)
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))
pts.forEach { p ->
val x = viewport.timeToX(p.tSec, size.width)
val y = valueToY(p.value01, size.height)
drawCircle(color = Color.White, radius = 8f, center = Offset(x, y))
}
}
private fun valueToY(value01: Float, height: Float): Float {
val v = value01.coerceIn(0f, 1f)
return (1f - v) * height
}
private fun formatDuration(totalSec: Int): String {
val h = totalSec / 3600
val m = (totalSec % 3600) / 60
val s = totalSec % 60
return when {
h > 0 -> String.format("%dh %02dm", h, m)
m > 0 -> String.format("%dm %02ds", m, s)
else -> String.format("%ds", s)
}
}

View File

@@ -0,0 +1,119 @@
package com.mindmachine.mvp.session
import kotlin.math.max
import kotlin.math.min
data class TimelineEditorState(
val durationSec: Int,
val viewport: TimelineViewport,
val activeCurve: ActiveCurve,
val visualLeft: TimelineCurve,
val visualRight: TimelineCurve,
val audioLeft: TimelineCurve,
val audioRight: TimelineCurve,
) {
enum class ActiveCurve {
VISUAL_LEFT,
VISUAL_RIGHT,
AUDIO_LEFT,
AUDIO_RIGHT,
}
companion object {
fun default(durationSec: Int = 20 * 60): TimelineEditorState {
val initial = TimelineCurve(
points = listOf(
TimelinePoint(0, 0.5f),
TimelinePoint(durationSec, 0.5f),
)
)
return TimelineEditorState(
durationSec = durationSec,
viewport = TimelineViewport(
startSec = 0f,
secondsPerScreen = min(10f * 60f, durationSec.toFloat())
),
activeCurve = ActiveCurve.VISUAL_LEFT,
visualLeft = initial,
visualRight = initial,
audioLeft = initial,
audioRight = initial,
)
}
}
}
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
}
}
data class TimelineCurve(
val points: List<TimelinePoint>
) {
fun addPointAt(offset: androidx.compose.ui.geometry.Offset, viewport: TimelineViewport, widthPx: Float, heightPx: Float): TimelineCurve {
val t = (viewport.startSec + (offset.x / widthPx) * viewport.secondsPerScreen).toInt().coerceAtLeast(0)
val v = (1f - (offset.y / heightPx)).coerceIn(0f, 1f)
return copy(points = (points + TimelinePoint(t, v)).sortedBy { it.tSec })
}
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.startSec + (newPosition.x / widthPx) * viewport.secondsPerScreen).toInt().coerceAtLeast(0)
val v = (1f - (newPosition.y / heightPx)).coerceIn(0f, 1f)
val updated = points.toMutableList()
updated[index] = updated[index].copy(tSec = t, value01 = v)
return copy(points = updated.sortedBy { it.tSec })
}
fun hitTestPoint(offset: androidx.compose.ui.geometry.Offset, viewport: TimelineViewport, widthPx: Float, heightPx: Float): Int? {
val radiusPx = 24f
points.forEachIndexed { idx, p ->
val x = viewport.timeToX(p.tSec, widthPx)
val y = (1f - p.value01) * heightPx
val dx = offset.x - x
val dy = offset.y - y
if (dx * dx + dy * dy <= radiusPx * radiusPx) return idx
}
return null
}
}
data class TimelinePoint(
val tSec: Int,
val value01: Float,
)