Auto commit Thu Mar 19 09:01:01 PM CDT 2026
This commit is contained in:
@@ -51,7 +51,6 @@ fun DurationSliderCard(
|
|||||||
Slider(
|
Slider(
|
||||||
value = clamped.toFloat(),
|
value = clamped.toFloat(),
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
// Keep it sane: snap to whole minutes for UX.
|
|
||||||
val snapped = (it / 60f).roundToInt().coerceIn(1, 8 * 60) * 60
|
val snapped = (it / 60f).roundToInt().coerceIn(1, 8 * 60) * 60
|
||||||
onDurationSecChanged(snapped)
|
onDurationSecChanged(snapped)
|
||||||
},
|
},
|
||||||
@@ -59,7 +58,7 @@ fun DurationSliderCard(
|
|||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
"1 min 8 hours",
|
"Range: 1 minute to 8 hours",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ class MainViewModel(
|
|||||||
val ui: StateFlow<UiState> = _ui.asStateFlow()
|
val ui: StateFlow<UiState> = _ui.asStateFlow()
|
||||||
|
|
||||||
private var runJob: Job? = null
|
private var runJob: Job? = null
|
||||||
|
private var elapsedBeforePauseSec: Float = 0f
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -78,6 +79,7 @@ class MainViewModel(
|
|||||||
error = null,
|
error = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
elapsedBeforePauseSec = 0f
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setMode(mode: SessionMode) = _ui.update { it.copy(config = it.config.copy(mode = mode), error = null) }
|
fun setMode(mode: SessionMode) = _ui.update { it.copy(config = it.config.copy(mode = mode), error = null) }
|
||||||
@@ -120,73 +122,22 @@ class MainViewModel(
|
|||||||
_ui.update { it.copy(error = validation) }
|
_ui.update { it.copy(error = validation) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
runJob?.cancel()
|
|
||||||
runJob = viewModelScope.launch {
|
|
||||||
val count = state.settings.countdownPreference.seconds
|
|
||||||
if (count > 0) {
|
|
||||||
for (i in count downTo 1) {
|
|
||||||
_ui.update { it.copy(runtimeState = RuntimeState.COUNTDOWN, countdownSec = i, remainingSec = state.config.durationSec, endedEarly = false, interruptionReason = null) }
|
|
||||||
delay(1000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Use the timeline curves as the actual runtime program.
|
|
||||||
val program = _ui.value.timeline
|
|
||||||
val durationSec = program.durationSec
|
|
||||||
|
|
||||||
_ui.update {
|
elapsedBeforePauseSec = 0f
|
||||||
it.copy(
|
runProgram(startElapsedSec = 0f, includeCountdown = true)
|
||||||
runtimeState = RuntimeState.RUNNING,
|
|
||||||
remainingSec = durationSec,
|
|
||||||
countdownSec = 0,
|
|
||||||
error = null,
|
|
||||||
runtimeParams = TimelineRuntimeEvaluator.evaluate(program, 0f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.config.mode != SessionMode.VISUAL_ONLY) {
|
|
||||||
val p0 = TimelineRuntimeEvaluator.evaluate(program, 0f)
|
|
||||||
audioEngine.start(p0.carrierHz, p0.binauralHz)
|
|
||||||
}
|
|
||||||
|
|
||||||
val tStart = System.currentTimeMillis()
|
|
||||||
var lastWholeSec = durationSec
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
delay(50)
|
|
||||||
|
|
||||||
val elapsedSec = (System.currentTimeMillis() - tStart) / 1000f
|
|
||||||
val remaining = (durationSec - elapsedSec.toInt()).coerceAtLeast(0)
|
|
||||||
|
|
||||||
if (state.config.mode != SessionMode.VISUAL_ONLY && !headsetMonitor.isStereoHeadsetAvailable()) {
|
|
||||||
audioEngine.stop()
|
|
||||||
_ui.update { it.copy(runtimeState = RuntimeState.INTERRUPTED, interruptionReason = "Headphones disconnected. Session paused.") }
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
val params = TimelineRuntimeEvaluator.evaluate(program, elapsedSec)
|
|
||||||
_ui.update {
|
|
||||||
val nextRemaining = if (remaining != lastWholeSec) remaining else it.remainingSec
|
|
||||||
it.copy(runtimeParams = params, remainingSec = nextRemaining)
|
|
||||||
}
|
|
||||||
lastWholeSec = remaining
|
|
||||||
|
|
||||||
if (state.config.mode != SessionMode.VISUAL_ONLY) {
|
|
||||||
audioEngine.setFrequencies(params.carrierHz, params.binauralHz)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (elapsedSec >= durationSec.toFloat()) break
|
|
||||||
}
|
|
||||||
|
|
||||||
audioEngine.stop()
|
|
||||||
_ui.update { it.copy(runtimeState = RuntimeState.COMPLETED, endedEarly = false) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun pause(reason: String? = null) {
|
fun pause(reason: String? = null) {
|
||||||
if (_ui.value.runtimeState != RuntimeState.RUNNING) return
|
if (_ui.value.runtimeState != RuntimeState.RUNNING) return
|
||||||
|
elapsedBeforePauseSec = currentElapsedSec()
|
||||||
runJob?.cancel()
|
runJob?.cancel()
|
||||||
audioEngine.stop()
|
audioEngine.stop()
|
||||||
_ui.update { it.copy(runtimeState = if (reason == null) RuntimeState.PAUSED else RuntimeState.INTERRUPTED, interruptionReason = reason) }
|
_ui.update {
|
||||||
|
it.copy(
|
||||||
|
runtimeState = if (reason == null) RuntimeState.PAUSED else RuntimeState.INTERRUPTED,
|
||||||
|
interruptionReason = reason,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun resume() {
|
fun resume() {
|
||||||
@@ -196,35 +147,13 @@ class MainViewModel(
|
|||||||
_ui.update { it.copy(error = "Headphones are required to resume audio mode.") }
|
_ui.update { it.copy(error = "Headphones are required to resume audio mode.") }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
runJob = viewModelScope.launch {
|
runProgram(startElapsedSec = elapsedBeforePauseSec, includeCountdown = true)
|
||||||
for (i in 3 downTo 1) {
|
|
||||||
_ui.update { it.copy(runtimeState = RuntimeState.COUNTDOWN, countdownSec = i) }
|
|
||||||
delay(1000)
|
|
||||||
}
|
|
||||||
_ui.update { it.copy(runtimeState = RuntimeState.RUNNING, countdownSec = 0) }
|
|
||||||
if (state.config.mode != SessionMode.VISUAL_ONLY) {
|
|
||||||
val p = _ui.value.runtimeParams
|
|
||||||
audioEngine.start(p.carrierHz, p.binauralHz)
|
|
||||||
}
|
|
||||||
var remaining = state.remainingSec
|
|
||||||
while (remaining > 0) {
|
|
||||||
delay(1000)
|
|
||||||
if (state.config.mode != SessionMode.VISUAL_ONLY && !headsetMonitor.isStereoHeadsetAvailable()) {
|
|
||||||
audioEngine.stop()
|
|
||||||
_ui.update { it.copy(runtimeState = RuntimeState.INTERRUPTED, interruptionReason = "Headphones disconnected. Session paused.") }
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
remaining -= 1
|
|
||||||
_ui.update { it.copy(remainingSec = remaining) }
|
|
||||||
}
|
|
||||||
audioEngine.stop()
|
|
||||||
_ui.update { it.copy(runtimeState = RuntimeState.COMPLETED, endedEarly = false) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
runJob?.cancel()
|
runJob?.cancel()
|
||||||
audioEngine.stop()
|
audioEngine.stop()
|
||||||
|
elapsedBeforePauseSec = 0f
|
||||||
_ui.update { it.copy(runtimeState = RuntimeState.STOPPED, endedEarly = true) }
|
_ui.update { it.copy(runtimeState = RuntimeState.STOPPED, endedEarly = true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,6 +167,91 @@ class MainViewModel(
|
|||||||
super.onCleared()
|
super.onCleared()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun runProgram(startElapsedSec: Float, includeCountdown: Boolean) {
|
||||||
|
runJob?.cancel()
|
||||||
|
runJob = viewModelScope.launch {
|
||||||
|
val program = _ui.value.timeline
|
||||||
|
val durationSec = program.durationSec
|
||||||
|
val clampedStartSec = startElapsedSec.coerceIn(0f, durationSec.toFloat())
|
||||||
|
|
||||||
|
if (includeCountdown) {
|
||||||
|
val count = _ui.value.settings.countdownPreference.seconds
|
||||||
|
if (count > 0) {
|
||||||
|
for (i in count downTo 1) {
|
||||||
|
_ui.update {
|
||||||
|
it.copy(
|
||||||
|
runtimeState = RuntimeState.COUNTDOWN,
|
||||||
|
countdownSec = i,
|
||||||
|
remainingSec = (durationSec - clampedStartSec.toInt()).coerceAtLeast(0),
|
||||||
|
endedEarly = false,
|
||||||
|
interruptionReason = null,
|
||||||
|
runtimeParams = TimelineRuntimeEvaluator.evaluate(program, clampedStartSec),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
delay(1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val initialParams = TimelineRuntimeEvaluator.evaluate(program, clampedStartSec)
|
||||||
|
_ui.update {
|
||||||
|
it.copy(
|
||||||
|
runtimeState = RuntimeState.RUNNING,
|
||||||
|
remainingSec = (durationSec - clampedStartSec.toInt()).coerceAtLeast(0),
|
||||||
|
countdownSec = 0,
|
||||||
|
error = null,
|
||||||
|
endedEarly = false,
|
||||||
|
interruptionReason = null,
|
||||||
|
runtimeParams = initialParams,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_ui.value.config.mode != SessionMode.VISUAL_ONLY) {
|
||||||
|
audioEngine.start(initialParams.carrierHz, initialParams.binauralHz)
|
||||||
|
}
|
||||||
|
|
||||||
|
val tStart = System.currentTimeMillis()
|
||||||
|
while (true) {
|
||||||
|
delay(50)
|
||||||
|
|
||||||
|
val elapsedSec = clampedStartSec + (System.currentTimeMillis() - tStart) / 1000f
|
||||||
|
elapsedBeforePauseSec = elapsedSec.coerceAtMost(durationSec.toFloat())
|
||||||
|
val remaining = (durationSec - kotlin.math.ceil(elapsedBeforePauseSec).toInt()).coerceAtLeast(0)
|
||||||
|
val currentUi = _ui.value
|
||||||
|
|
||||||
|
if (currentUi.config.mode != SessionMode.VISUAL_ONLY && !headsetMonitor.isStereoHeadsetAvailable()) {
|
||||||
|
audioEngine.stop()
|
||||||
|
_ui.update {
|
||||||
|
it.copy(
|
||||||
|
runtimeState = RuntimeState.INTERRUPTED,
|
||||||
|
interruptionReason = "Headphones disconnected. Session paused.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
val params = TimelineRuntimeEvaluator.evaluate(program, elapsedBeforePauseSec)
|
||||||
|
_ui.update { it.copy(runtimeParams = params, remainingSec = remaining) }
|
||||||
|
|
||||||
|
if (currentUi.config.mode != SessionMode.VISUAL_ONLY) {
|
||||||
|
audioEngine.setFrequencies(params.carrierHz, params.binauralHz)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (elapsedBeforePauseSec >= durationSec.toFloat()) break
|
||||||
|
}
|
||||||
|
|
||||||
|
audioEngine.stop()
|
||||||
|
elapsedBeforePauseSec = 0f
|
||||||
|
_ui.update { it.copy(runtimeState = RuntimeState.COMPLETED, endedEarly = false, remainingSec = 0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun currentElapsedSec(): Float {
|
||||||
|
val state = _ui.value
|
||||||
|
val total = state.timeline.durationSec.toFloat().coerceAtLeast(1f)
|
||||||
|
return (total - state.remainingSec).coerceIn(0f, total)
|
||||||
|
}
|
||||||
|
|
||||||
class Factory(
|
class Factory(
|
||||||
private val settingsRepository: SettingsRepository,
|
private val settingsRepository: SettingsRepository,
|
||||||
private val headsetMonitor: HeadsetMonitor,
|
private val headsetMonitor: HeadsetMonitor,
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ fun SetupTimelineEditor(
|
|||||||
color = MaterialTheme.colorScheme.onBackground,
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
"Visual: Flash Interval (L) / Blank Interval (R). Audio: Carrier (L) / Binaural (R).",
|
"Each dot is one minute. Drag dots up/down to shape the session. Pinch to zoom for precision.",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.75f),
|
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.75f),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package com.mindmachine.mvp.session
|
|||||||
import android.view.HapticFeedbackConstants
|
import android.view.HapticFeedbackConstants
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
|
||||||
import androidx.compose.foundation.background
|
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
|
||||||
@@ -12,15 +11,12 @@ 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
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -56,7 +52,6 @@ fun TimelineEditorScreen(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val activity = context as? android.app.Activity
|
val activity = context as? android.app.Activity
|
||||||
|
|
||||||
// Timeline editor is always immersive per spec.
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
if (activity != null) {
|
if (activity != null) {
|
||||||
val window = activity.window
|
val window = activity.window
|
||||||
@@ -108,74 +103,32 @@ private fun TimelineEditorRoot(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TimelineEditorContent(
|
private fun TimelineEditorContent(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var state by remember { mutableStateOf(TimelineEditorState.default()) }
|
var state by remember { mutableStateOf(TimelineEditorState.default()) }
|
||||||
|
|
||||||
// Simple preview playhead (UI-only for now).
|
|
||||||
LaunchedEffect(state.isPlaying, state.durationSec) {
|
|
||||||
if (!state.isPlaying) return@LaunchedEffect
|
|
||||||
val start = state.playheadSec
|
|
||||||
val t0 = System.currentTimeMillis()
|
|
||||||
while (state.isPlaying) {
|
|
||||||
val elapsed = (System.currentTimeMillis() - t0) / 1000f
|
|
||||||
val next = (start + elapsed)
|
|
||||||
val wrapped = if (next > state.durationSec) 0f else next
|
|
||||||
state = state.copy(playheadSec = wrapped)
|
|
||||||
kotlinx.coroutines.delay(16)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(12.dp),
|
.padding(12.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
) {
|
) {
|
||||||
Row(
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(MaterialTheme.colorScheme.surface, RoundedCornerShape(12.dp))
|
|
||||||
.padding(12.dp),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
Text("Selected duration", color = MaterialTheme.colorScheme.onSurface)
|
|
||||||
Text(
|
Text(
|
||||||
formatDuration(state.selection.durationSec.coerceAtLeast(0)),
|
"Minute-based timeline editor",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
style = MaterialTheme.typography.titleLarge,
|
||||||
)
|
)
|
||||||
}
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
|
||||||
OutlinedButton(onClick = { state = state.copy(isPlaying = !state.isPlaying) }) {
|
|
||||||
Text(if (state.isPlaying) "Pause" else "Play")
|
|
||||||
}
|
|
||||||
OutlinedButton(onClick = { state = state.copy(playheadSec = 0f) }) {
|
|
||||||
Text("Rewind")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DurationSliderCard(
|
|
||||||
durationSec = state.durationSec,
|
|
||||||
onDurationSecChanged = { state = state.setDurationSec(it) },
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
"Tap left/right side of a graph to select a curve. Pinch to zoom, drag empty space to pan. Long-press a point to delete.",
|
"Every minute has a control dot. Tap left/right to choose a curve, drag dots up or down, pinch to zoom, and drag empty space to pan.",
|
||||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.75f),
|
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.75f),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
|
|
||||||
TimelineGraph(
|
TimelineGraph(
|
||||||
title = "Visual",
|
title = "Visual",
|
||||||
leftLabel = "Brightness",
|
leftLabel = "Flash Interval",
|
||||||
rightLabel = "Blink Rate",
|
rightLabel = "Blank Interval",
|
||||||
curveLeft = state.visualLeft,
|
curveLeft = state.visualLeft,
|
||||||
curveRight = state.visualRight,
|
curveRight = state.visualRight,
|
||||||
activeCurve = state.activeCurve,
|
activeCurve = state.activeCurve,
|
||||||
@@ -184,20 +137,15 @@ private fun TimelineEditorContent(
|
|||||||
playheadSec = state.playheadSec,
|
playheadSec = state.playheadSec,
|
||||||
onViewportChanged = { state = state.copy(viewport = it) },
|
onViewportChanged = { state = state.copy(viewport = it) },
|
||||||
onSelectionChanged = { sel -> state = state.copy(selection = sel).clampSelection() },
|
onSelectionChanged = { sel -> state = state.copy(selection = sel).clampSelection() },
|
||||||
onUpdateCurve = { curveId, newCurve ->
|
onUpdateCurve = { curveId, newCurve -> state = state.withCurve(curveId, newCurve) },
|
||||||
val candidate = state.withCurve(curveId, newCurve)
|
|
||||||
val maxT = newCurve.points.maxOfOrNull { it.tSec } ?: 0
|
|
||||||
state = candidate.extendDurationTo(maxT).copy(selection = candidate.selection.copy(endSec = max(candidate.selection.endSec, state.durationSec)))
|
|
||||||
},
|
|
||||||
onTapSideSelect = { side ->
|
onTapSideSelect = { side ->
|
||||||
state = state.copy(activeCurve = if (side == Side.LEFT) ActiveCurve.VISUAL_LEFT else ActiveCurve.VISUAL_RIGHT)
|
state = state.copy(activeCurve = if (side == Side.LEFT) ActiveCurve.VISUAL_LEFT else ActiveCurve.VISUAL_RIGHT)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
TimelineGraph(
|
TimelineGraph(
|
||||||
title = "Audio",
|
title = "Audio",
|
||||||
leftLabel = "Carrier Frequency",
|
leftLabel = "Carrier Frequency",
|
||||||
rightLabel = "Binaural Beat",
|
rightLabel = "Binaural Frequency",
|
||||||
curveLeft = state.audioLeft,
|
curveLeft = state.audioLeft,
|
||||||
curveRight = state.audioRight,
|
curveRight = state.audioRight,
|
||||||
activeCurve = state.activeCurve,
|
activeCurve = state.activeCurve,
|
||||||
@@ -206,31 +154,15 @@ private fun TimelineEditorContent(
|
|||||||
playheadSec = state.playheadSec,
|
playheadSec = state.playheadSec,
|
||||||
onViewportChanged = { state = state.copy(viewport = it) },
|
onViewportChanged = { state = state.copy(viewport = it) },
|
||||||
onSelectionChanged = { sel -> state = state.copy(selection = sel).clampSelection() },
|
onSelectionChanged = { sel -> state = state.copy(selection = sel).clampSelection() },
|
||||||
onUpdateCurve = { curveId, newCurve ->
|
onUpdateCurve = { curveId, newCurve -> state = state.withCurve(curveId, newCurve) },
|
||||||
val candidate = state.withCurve(curveId, newCurve)
|
|
||||||
val maxT = newCurve.points.maxOfOrNull { it.tSec } ?: 0
|
|
||||||
state = candidate.extendDurationTo(maxT)
|
|
||||||
},
|
|
||||||
onTapSideSelect = { side ->
|
onTapSideSelect = { side ->
|
||||||
state = state.copy(activeCurve = if (side == Side.LEFT) ActiveCurve.AUDIO_LEFT else ActiveCurve.AUDIO_RIGHT)
|
state = state.copy(activeCurve = if (side == Side.LEFT) ActiveCurve.AUDIO_LEFT else ActiveCurve.AUDIO_RIGHT)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
Text(
|
||||||
Spacer(Modifier.height(4.dp))
|
"Back to return.",
|
||||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.75f),
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,8 +194,20 @@ internal fun TimelineGraph(
|
|||||||
val selectedRight = activeCurve == rightId
|
val selectedRight = activeCurve == rightId
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
var dragMode by remember { mutableStateOf(HandleDrag.NONE) }
|
var dragMode by remember { mutableStateOf(HandleDrag.NONE) }
|
||||||
|
var draggingPointIndex by remember { mutableStateOf<Int?>(null) }
|
||||||
|
var draggingCurveId by remember { mutableStateOf<ActiveCurve?>(null) }
|
||||||
|
|
||||||
|
val leftColor = when (leftId) {
|
||||||
|
ActiveCurve.VISUAL_LEFT -> Color(0xFF00C853)
|
||||||
|
ActiveCurve.AUDIO_LEFT -> Color(0xFF2196F3)
|
||||||
|
else -> Color.White
|
||||||
|
}
|
||||||
|
val rightColor = when (rightId) {
|
||||||
|
ActiveCurve.VISUAL_RIGHT -> Color(0xFFD50000)
|
||||||
|
ActiveCurve.AUDIO_RIGHT -> Color(0xFFFFEB3B)
|
||||||
|
else -> Color.White
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
@@ -282,8 +226,8 @@ internal fun TimelineGraph(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
Text(leftLabel, color = if (selectedLeft) Color.White else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f))
|
Text(leftLabel, color = if (selectedLeft) leftColor else leftColor.copy(alpha = 0.65f))
|
||||||
Text(rightLabel, color = if (selectedRight) Color.White else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f))
|
Text(rightLabel, color = if (selectedRight) rightColor else rightColor.copy(alpha = 0.65f))
|
||||||
}
|
}
|
||||||
|
|
||||||
val onCurveEditedHaptic = {
|
val onCurveEditedHaptic = {
|
||||||
@@ -295,32 +239,57 @@ internal fun TimelineGraph(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(190.dp)
|
.height(190.dp)
|
||||||
.background(Color(0xFF0A0E18), RoundedCornerShape(10.dp))
|
.background(Color(0xFF0A0E18), RoundedCornerShape(10.dp))
|
||||||
.pointerInput(viewport, selection) {
|
.pointerInput(viewport, selection, curveLeft, curveRight, activeCurve) {
|
||||||
detectDragGestures(
|
detectDragGestures(
|
||||||
onDragStart = { start ->
|
onDragStart = { start ->
|
||||||
val w = size.width.toFloat()
|
val w = size.width.toFloat()
|
||||||
|
val h = size.height.toFloat()
|
||||||
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 hitPoint = selectedCurve.hitTestPoint(start, viewport, w, h)
|
||||||
|
if (hitPoint != null) {
|
||||||
|
draggingPointIndex = hitPoint
|
||||||
|
draggingCurveId = selectedCurveId
|
||||||
|
dragMode = HandleDrag.NONE
|
||||||
|
} 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
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDragEnd = {
|
||||||
|
draggingPointIndex = null
|
||||||
|
draggingCurveId = null
|
||||||
|
dragMode = HandleDrag.NONE
|
||||||
|
},
|
||||||
|
onDragCancel = {
|
||||||
|
draggingPointIndex = null
|
||||||
|
draggingCurveId = null
|
||||||
|
dragMode = HandleDrag.NONE
|
||||||
},
|
},
|
||||||
onDragEnd = { dragMode = HandleDrag.NONE },
|
|
||||||
onDragCancel = { dragMode = HandleDrag.NONE },
|
|
||||||
onDrag = { change, dragAmount ->
|
onDrag = { change, dragAmount ->
|
||||||
val w = size.width.toFloat()
|
val w = size.width.toFloat()
|
||||||
|
val h = size.height.toFloat()
|
||||||
|
val pointIndex = draggingPointIndex
|
||||||
|
val curveId = draggingCurveId
|
||||||
|
if (pointIndex != null && curveId != null) {
|
||||||
|
val targetCurve = if (curveId == leftId) curveLeft else curveRight
|
||||||
|
val updated = targetCurve.movePointVertical(pointIndex, change.position.y + dragAmount.y, h)
|
||||||
|
onUpdateCurve(curveId, updated)
|
||||||
|
onCurveEditedHaptic()
|
||||||
|
return@detectDragGestures
|
||||||
|
}
|
||||||
|
|
||||||
val t = viewport.xToTimeSec(change.position.x + dragAmount.x, w).toInt().coerceAtLeast(0)
|
val t = viewport.xToTimeSec(change.position.x + dragAmount.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)))
|
||||||
HandleDrag.NONE -> {
|
HandleDrag.NONE -> {
|
||||||
// background pan (when not dragging a point)
|
|
||||||
val didHitLeft = curveLeft.hitTestPoint(change.position, viewport, w, size.height.toFloat()) != null
|
|
||||||
val didHitRight = curveRight.hitTestPoint(change.position, viewport, w, size.height.toFloat()) != null
|
|
||||||
if (!didHitLeft && !didHitRight) {
|
|
||||||
val newViewport = viewport.applyZoomPan(
|
val newViewport = viewport.applyZoomPan(
|
||||||
zoomChange = 1f,
|
zoomChange = 1f,
|
||||||
panPx = dragAmount.x,
|
panPx = dragAmount.x,
|
||||||
@@ -331,7 +300,6 @@ internal fun TimelineGraph(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.pointerInput(activeCurve, viewport, curveLeft, curveRight) {
|
.pointerInput(activeCurve, viewport, curveLeft, curveRight) {
|
||||||
@@ -345,74 +313,11 @@ internal fun TimelineGraph(
|
|||||||
onViewportChanged(newViewport)
|
onViewportChanged(newViewport)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.pointerInput(activeCurve, viewport, curveLeft, curveRight) {
|
.pointerInput(activeCurve) {
|
||||||
detectTapGestures(
|
detectTapGestures(
|
||||||
onTap = { offset ->
|
onTap = { offset ->
|
||||||
val side = if (offset.x < size.width / 2f) Side.LEFT else Side.RIGHT
|
val side = if (offset.x < size.width / 2f) Side.LEFT else Side.RIGHT
|
||||||
onTapSideSelect(side)
|
onTapSideSelect(side)
|
||||||
},
|
|
||||||
onDoubleTap = { offset ->
|
|
||||||
val curveId = when (activeCurve) {
|
|
||||||
leftId, rightId -> activeCurve
|
|
||||||
else -> leftId
|
|
||||||
}
|
|
||||||
val targetCurve = if (curveId == leftId) curveLeft else curveRight
|
|
||||||
val updated = targetCurve.insertSmoothedPointAt(offset, viewport, size.width.toFloat(), size.height.toFloat())
|
|
||||||
onCurveEditedHaptic()
|
|
||||||
onUpdateCurve(curveId, updated)
|
|
||||||
},
|
|
||||||
onLongPress = { offset ->
|
|
||||||
val curveId = when (activeCurve) {
|
|
||||||
leftId, 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(
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -420,7 +325,6 @@ internal fun TimelineGraph(
|
|||||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||||
drawGrid()
|
drawGrid()
|
||||||
|
|
||||||
// selection range overlay + handles
|
|
||||||
val startX = viewport.timeToX(selection.startSec, size.width)
|
val startX = viewport.timeToX(selection.startSec, size.width)
|
||||||
val endX = viewport.timeToX(selection.endSec, size.width)
|
val endX = viewport.timeToX(selection.endSec, size.width)
|
||||||
drawRect(
|
drawRect(
|
||||||
@@ -435,17 +339,16 @@ internal fun TimelineGraph(
|
|||||||
drawCircle(handleColor, radius = 10f, center = Offset(startX, size.height - 14f))
|
drawCircle(handleColor, radius = 10f, center = Offset(startX, size.height - 14f))
|
||||||
drawCircle(handleColor, radius = 10f, center = Offset(endX, size.height - 14f))
|
drawCircle(handleColor, radius = 10f, center = Offset(endX, size.height - 14f))
|
||||||
|
|
||||||
// playhead
|
|
||||||
val playX = viewport.timeToX(playheadSec.toInt(), size.width)
|
val playX = viewport.timeToX(playheadSec.toInt(), size.width)
|
||||||
drawLine(Color(0xFFFFD54F).copy(alpha = 0.9f), Offset(playX, 0f), Offset(playX, size.height), strokeWidth = 2f)
|
drawLine(Color(0xFFFFFFFF).copy(alpha = 0.6f), Offset(playX, 0f), Offset(playX, size.height), strokeWidth = 2f)
|
||||||
|
|
||||||
drawCurve(curveLeft, selected = selectedLeft, color = if (isVisual) Color(0xFF4D8BFF) else Color(0xFF55E39A), viewport = viewport)
|
drawCurve(curveLeft, selected = selectedLeft, color = leftColor, viewport = viewport)
|
||||||
drawCurve(curveRight, selected = selectedRight, color = if (isVisual) Color(0xFF7AB8FF) else Color(0xFF2FCB7C), viewport = viewport)
|
drawCurve(curveRight, selected = selectedRight, color = rightColor, viewport = viewport)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
"Tap add • Drag point move • Long-press delete • Double-tap smooth • Pinch zoom • Drag background pan • Drag handles to set range",
|
"One dot per minute • Drag selected curve dots up/down • Pinch zoom • Drag background pan • Drag handles to set range",
|
||||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f),
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
@@ -482,17 +385,17 @@ private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawCurve(
|
|||||||
val y = valueToY(p.value01, size.height)
|
val y = valueToY(p.value01, size.height)
|
||||||
if (idx == 0) path.moveTo(x, y) else path.lineTo(x, y)
|
if (idx == 0) path.moveTo(x, y) else path.lineTo(x, y)
|
||||||
}
|
}
|
||||||
val stroke = if (selected) 6f else 3f
|
|
||||||
drawPath(
|
drawPath(
|
||||||
path,
|
path,
|
||||||
color = if (selected) color.copy(alpha = 1f) else color.copy(alpha = 0.55f),
|
color = if (selected) color else color.copy(alpha = 0.6f),
|
||||||
style = androidx.compose.ui.graphics.drawscope.Stroke(width = stroke)
|
style = androidx.compose.ui.graphics.drawscope.Stroke(width = if (selected) 5f else 3f)
|
||||||
)
|
)
|
||||||
|
|
||||||
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
|
||||||
val y = valueToY(p.value01, size.height)
|
val y = valueToY(p.value01, size.height)
|
||||||
drawCircle(color = Color.White, radius = 8f, center = Offset(x, y))
|
drawCircle(color = color, radius = if (selected) 6.5f else 5f, center = Offset(x, y))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -500,4 +403,3 @@ private fun valueToY(value01: Float, height: Float): Float {
|
|||||||
val v = value01.coerceIn(0f, 1f)
|
val v = value01.coerceIn(0f, 1f)
|
||||||
return (1f - v) * height
|
return (1f - v) * height
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import kotlin.math.abs
|
|||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlin.math.min
|
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(
|
data class TimelineEditorState(
|
||||||
val durationSec: Int,
|
val durationSec: Int,
|
||||||
val selection: TimelineSelection,
|
val selection: TimelineSelection,
|
||||||
@@ -25,13 +29,8 @@ data class TimelineEditorState(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun default(durationSec: Int = 20 * 60): TimelineEditorState {
|
fun default(durationSec: Int = 20 * 60): TimelineEditorState {
|
||||||
val dur = durationSec.coerceIn(60, 8 * 60 * 60)
|
val dur = snapDuration(durationSec)
|
||||||
val initial = TimelineCurve(
|
val initial = TimelineCurve.constant(0.5f, dur)
|
||||||
points = listOf(
|
|
||||||
TimelinePoint(0, 0.5f),
|
|
||||||
TimelinePoint(dur, 0.5f),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return TimelineEditorState(
|
return TimelineEditorState(
|
||||||
durationSec = dur,
|
durationSec = dur,
|
||||||
selection = TimelineSelection(0, dur),
|
selection = TimelineSelection(0, dur),
|
||||||
@@ -48,6 +47,9 @@ data class TimelineEditorState(
|
|||||||
playheadSec = 0f,
|
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) {
|
fun curveFor(id: ActiveCurve): TimelineCurve = when (id) {
|
||||||
@@ -57,55 +59,45 @@ data class TimelineEditorState(
|
|||||||
ActiveCurve.AUDIO_RIGHT -> audioRight
|
ActiveCurve.AUDIO_RIGHT -> audioRight
|
||||||
}
|
}
|
||||||
|
|
||||||
fun withCurve(id: ActiveCurve, curve: TimelineCurve): TimelineEditorState = when (id) {
|
fun withCurve(id: ActiveCurve, curve: TimelineCurve): TimelineEditorState {
|
||||||
ActiveCurve.VISUAL_LEFT -> copy(visualLeft = curve)
|
val normalized = curve.ensureMinutePoints(durationSec)
|
||||||
ActiveCurve.VISUAL_RIGHT -> copy(visualRight = curve)
|
return when (id) {
|
||||||
ActiveCurve.AUDIO_LEFT -> copy(audioLeft = curve)
|
ActiveCurve.VISUAL_LEFT -> copy(visualLeft = normalized)
|
||||||
ActiveCurve.AUDIO_RIGHT -> copy(audioRight = curve)
|
ActiveCurve.VISUAL_RIGHT -> copy(visualRight = normalized)
|
||||||
|
ActiveCurve.AUDIO_LEFT -> copy(audioLeft = normalized)
|
||||||
|
ActiveCurve.AUDIO_RIGHT -> copy(audioRight = normalized)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Spec: auto-extend timeline when user creates/moves points beyond current duration.
|
|
||||||
* Also preserve the last value to the new end of the session.
|
|
||||||
*/
|
|
||||||
fun extendDurationTo(atLeastSec: Int): TimelineEditorState {
|
fun extendDurationTo(atLeastSec: Int): TimelineEditorState {
|
||||||
val newDur = atLeastSec.coerceIn(60, 8 * 60 * 60)
|
val newDur = ((atLeastSec.coerceIn(MIN_DURATION_SEC, MAX_DURATION_SEC) + 30) / 60) * 60
|
||||||
if (newDur <= durationSec) return this
|
if (newDur <= durationSec) return this
|
||||||
|
|
||||||
fun extendCurve(c: TimelineCurve): TimelineCurve = c.extendTo(newDur)
|
return copy(
|
||||||
|
|
||||||
val updated = copy(
|
|
||||||
durationSec = newDur,
|
durationSec = newDur,
|
||||||
selection = selection.copy(endSec = max(selection.endSec, newDur)),
|
selection = selection.copy(endSec = max(selection.endSec, newDur)),
|
||||||
visualLeft = extendCurve(visualLeft),
|
visualLeft = visualLeft.extendTo(newDur),
|
||||||
visualRight = extendCurve(visualRight),
|
visualRight = visualRight.extendTo(newDur),
|
||||||
audioLeft = extendCurve(audioLeft),
|
audioLeft = audioLeft.extendTo(newDur),
|
||||||
audioRight = extendCurve(audioRight),
|
audioRight = audioRight.extendTo(newDur),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Keep viewport zoom but ensure end is reachable by panning.
|
|
||||||
return updated
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setDurationSec(newDurationSec: Int): TimelineEditorState {
|
fun setDurationSec(newDurationSec: Int): TimelineEditorState {
|
||||||
val clamped = newDurationSec.coerceIn(60, 8 * 60 * 60)
|
val clamped = ((newDurationSec.coerceIn(MIN_DURATION_SEC, MAX_DURATION_SEC) + 30) / 60) * 60
|
||||||
if (clamped == durationSec) return this
|
if (clamped == durationSec) return this
|
||||||
|
|
||||||
return if (clamped > durationSec) {
|
return if (clamped > durationSec) {
|
||||||
extendDurationTo(clamped).copy(
|
extendDurationTo(clamped).copy(selection = selection.copy(startSec = 0, endSec = clamped))
|
||||||
selection = selection.copy(startSec = 0, endSec = clamped),
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
// Shrink: trim curves and clamp playhead/viewport.
|
|
||||||
fun trimCurve(c: TimelineCurve) = c.trimTo(clamped)
|
|
||||||
val newViewport = viewport.copy(startSec = viewport.startSec.coerceAtMost(clamped.toFloat()))
|
val newViewport = viewport.copy(startSec = viewport.startSec.coerceAtMost(clamped.toFloat()))
|
||||||
copy(
|
copy(
|
||||||
durationSec = clamped,
|
durationSec = clamped,
|
||||||
selection = selection.copy(startSec = 0, endSec = clamped),
|
selection = TimelineSelection(0, clamped),
|
||||||
visualLeft = trimCurve(visualLeft),
|
visualLeft = visualLeft.trimTo(clamped),
|
||||||
visualRight = trimCurve(visualRight),
|
visualRight = visualRight.trimTo(clamped),
|
||||||
audioLeft = trimCurve(audioLeft),
|
audioLeft = audioLeft.trimTo(clamped),
|
||||||
audioRight = trimCurve(audioRight),
|
audioRight = audioRight.trimTo(clamped),
|
||||||
viewport = newViewport,
|
viewport = newViewport,
|
||||||
playheadSec = playheadSec.coerceIn(0f, clamped.toFloat()),
|
playheadSec = playheadSec.coerceIn(0f, clamped.toFloat()),
|
||||||
)
|
)
|
||||||
@@ -113,15 +105,12 @@ data class TimelineEditorState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun clampSelection(): TimelineEditorState {
|
fun clampSelection(): TimelineEditorState {
|
||||||
val minDur = 60
|
|
||||||
val maxDur = 8 * 60 * 60
|
|
||||||
val start = selection.startSec.coerceIn(0, durationSec)
|
val start = selection.startSec.coerceIn(0, durationSec)
|
||||||
var end = selection.endSec.coerceIn(0, durationSec)
|
var end = selection.endSec.coerceIn(0, durationSec)
|
||||||
if (end - start < minDur) end = (start + minDur).coerceAtMost(durationSec)
|
if (end - start < MIN_DURATION_SEC) end = (start + MIN_DURATION_SEC).coerceAtMost(durationSec)
|
||||||
val clamped = TimelineSelection(start, end)
|
val clamped = TimelineSelection(start, end)
|
||||||
// For now we keep selection anchored at 0; if start moved, reflect it but don't shift curves.
|
|
||||||
return copy(durationSec = max(durationSec, clamped.endSec), selection = clamped).let {
|
return copy(durationSec = max(durationSec, clamped.endSec), selection = clamped).let {
|
||||||
it.copy(durationSec = it.durationSec.coerceIn(minDur, maxDur))
|
it.copy(durationSec = it.durationSec.coerceIn(MIN_DURATION_SEC, MAX_DURATION_SEC))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,7 +136,6 @@ data class TimelineViewport(
|
|||||||
widthPx: Float,
|
widthPx: Float,
|
||||||
centroidPx: Float,
|
centroidPx: Float,
|
||||||
): TimelineViewport {
|
): TimelineViewport {
|
||||||
// Spec: Minimum zoom = 30 seconds per screen width; maximum zoom = 24 hours visible.
|
|
||||||
val minSecondsPerScreen = 30f
|
val minSecondsPerScreen = 30f
|
||||||
val maxSecondsPerScreen = 24f * 60f * 60f
|
val maxSecondsPerScreen = 24f * 60f * 60f
|
||||||
|
|
||||||
@@ -180,6 +168,15 @@ data class TimelineViewport(
|
|||||||
data class TimelineCurve(
|
data class TimelineCurve(
|
||||||
val points: List<TimelinePoint>
|
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 {
|
fun valueAt(tSec: Float): Float {
|
||||||
val sorted = points.sortedBy { it.tSec }
|
val sorted = points.sortedBy { it.tSec }
|
||||||
if (sorted.isEmpty()) return 0.5f
|
if (sorted.isEmpty()) return 0.5f
|
||||||
@@ -195,41 +192,29 @@ data class TimelineCurve(
|
|||||||
return (left.value01 + (right.value01 - left.value01) * u).coerceIn(0f, 1f)
|
return (left.value01 + (right.value01 - left.value01) * u).coerceIn(0f, 1f)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun trimTo(newDurationSec: Int): TimelineCurve {
|
fun ensureMinutePoints(durationSec: Int): TimelineCurve {
|
||||||
if (points.isEmpty()) return this
|
val snappedDuration = ((durationSec.coerceIn(MIN_DURATION_SEC, MAX_DURATION_SEC) + 30) / 60) * 60
|
||||||
val sorted = points.sortedBy { it.tSec }
|
val normalized = (0..snappedDuration step TIMELINE_STEP_SEC).map { minuteSec ->
|
||||||
val filtered = sorted.filter { it.tSec <= newDurationSec }
|
TimelinePoint(minuteSec, valueAt(minuteSec.toFloat()))
|
||||||
if (filtered.isEmpty()) return copy(points = listOf(TimelinePoint(0, 0.5f), TimelinePoint(newDurationSec, 0.5f)))
|
}
|
||||||
val last = filtered.last()
|
return copy(points = normalized)
|
||||||
val withEnd = if (last.tSec == newDurationSec) filtered else (filtered + last.copy(tSec = newDurationSec))
|
|
||||||
// Ensure we keep at least 2 points.
|
|
||||||
val ensured = if (withEnd.size >= 2) withEnd else listOf(withEnd.first(), withEnd.first().copy(tSec = newDurationSec))
|
|
||||||
return copy(points = ensured)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addPointAt(offset: androidx.compose.ui.geometry.Offset, viewport: TimelineViewport, widthPx: Float, heightPx: Float): TimelineCurve {
|
fun trimTo(newDurationSec: Int): TimelineCurve = ensureMinutePoints(newDurationSec)
|
||||||
val t = viewport.xToTimeSec(offset.x, widthPx).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 {
|
fun movePointVertical(index: Int, newY: Float, heightPx: Float): TimelineCurve {
|
||||||
if (points.size <= 2) return this
|
if (index !in points.indices) return this
|
||||||
return copy(points = points.filterIndexed { i, _ -> i != index })
|
val v = (1f - (newY / heightPx)).coerceIn(0f, 1f)
|
||||||
}
|
|
||||||
|
|
||||||
fun movePoint(index: Int, newPosition: androidx.compose.ui.geometry.Offset, viewport: TimelineViewport, widthPx: Float, heightPx: Float): TimelineCurve {
|
|
||||||
val t = viewport.xToTimeSec(newPosition.x, widthPx).toInt().coerceAtLeast(0)
|
|
||||||
val v = (1f - (newPosition.y / heightPx)).coerceIn(0f, 1f)
|
|
||||||
val updated = points.toMutableList()
|
val updated = points.toMutableList()
|
||||||
updated[index] = updated[index].copy(tSec = t, value01 = v)
|
updated[index] = updated[index].copy(value01 = v)
|
||||||
return copy(points = updated.sortedBy { it.tSec })
|
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 = 24f
|
val radiusPx = 22f
|
||||||
points.forEachIndexed { idx, p ->
|
points.forEachIndexed { idx, p ->
|
||||||
val x = viewport.timeToX(p.tSec, widthPx)
|
val x = viewport.timeToX(p.tSec, widthPx)
|
||||||
|
if (x < -radiusPx || x > widthPx + radiusPx) return@forEachIndexed
|
||||||
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
|
||||||
@@ -238,35 +223,7 @@ data class TimelineCurve(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun extendTo(newDurationSec: Int): TimelineCurve {
|
fun extendTo(newDurationSec: Int): TimelineCurve = ensureMinutePoints(newDurationSec)
|
||||||
if (points.isEmpty()) return this
|
|
||||||
val sorted = points.sortedBy { it.tSec }
|
|
||||||
val last = sorted.last()
|
|
||||||
if (last.tSec >= newDurationSec) return this
|
|
||||||
return copy(points = (sorted + last.copy(tSec = newDurationSec)).sortedBy { it.tSec })
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Double-tap gesture: "smooth" by inserting an interpolated point in the nearest segment.
|
|
||||||
*/
|
|
||||||
fun insertSmoothedPointAt(offset: androidx.compose.ui.geometry.Offset, viewport: TimelineViewport, widthPx: Float, heightPx: Float): TimelineCurve {
|
|
||||||
val t = viewport.xToTimeSec(offset.x, widthPx).toInt().coerceAtLeast(0)
|
|
||||||
val sorted = points.sortedBy { it.tSec }
|
|
||||||
if (sorted.size < 2) return this
|
|
||||||
|
|
||||||
val rightIndex = sorted.indexOfFirst { it.tSec >= t }.let { if (it == -1) sorted.lastIndex else it }
|
|
||||||
val leftIndex = (rightIndex - 1).coerceAtLeast(0)
|
|
||||||
if (leftIndex == rightIndex) return this
|
|
||||||
|
|
||||||
val a = sorted[leftIndex]
|
|
||||||
val b = sorted[rightIndex]
|
|
||||||
val tt = if (b.tSec == a.tSec) 0f else (t - a.tSec).toFloat() / (b.tSec - a.tSec).toFloat()
|
|
||||||
val v = a.value01 + (b.value01 - a.value01) * tt
|
|
||||||
|
|
||||||
// Only insert if not extremely close to an existing point.
|
|
||||||
if (abs(a.tSec - t) < 1 || abs(b.tSec - t) < 1) return this
|
|
||||||
return copy(points = (sorted + TimelinePoint(t, v.coerceIn(0f, 1f))).sortedBy { it.tSec })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
data class TimelinePoint(
|
data class TimelinePoint(
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ object TimelineProgramFactory {
|
|||||||
fun fromPreset(preset: SessionPreset): TimelineEditorState {
|
fun fromPreset(preset: SessionPreset): TimelineEditorState {
|
||||||
val dur = preset.defaultDurationSec.coerceIn(60, 8 * 60 * 60)
|
val dur = preset.defaultDurationSec.coerceIn(60, 8 * 60 * 60)
|
||||||
|
|
||||||
// Map preset fixed params into normalized 0..1 values.
|
|
||||||
val flash01 = ParameterMapping.invLog01(
|
val flash01 = ParameterMapping.invLog01(
|
||||||
value = preset.flashIntervalMs.toFloat(),
|
value = preset.flashIntervalMs.toFloat(),
|
||||||
min = ParameterRanges.FLASH_INTERVAL_MS_MIN,
|
min = ParameterRanges.FLASH_INTERVAL_MS_MIN,
|
||||||
@@ -24,24 +23,15 @@ object TimelineProgramFactory {
|
|||||||
max = ParameterRanges.BINAURAL_HZ_MAX,
|
max = ParameterRanges.BINAURAL_HZ_MAX,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun constantCurve(v01: Float) = TimelineCurve(
|
|
||||||
points = listOf(
|
|
||||||
TimelinePoint(0, v01.coerceIn(0f, 1f)),
|
|
||||||
TimelinePoint(dur, v01.coerceIn(0f, 1f)),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return TimelineEditorState(
|
return TimelineEditorState(
|
||||||
durationSec = dur,
|
durationSec = dur,
|
||||||
selection = TimelineSelection(0, dur),
|
selection = TimelineSelection(0, dur),
|
||||||
viewport = TimelineViewport(startSec = 0f, secondsPerScreen = minOf(10f * 60f, dur.toFloat())),
|
viewport = TimelineViewport(startSec = 0f, secondsPerScreen = minOf(10f * 60f, dur.toFloat())),
|
||||||
activeCurve = TimelineEditorState.ActiveCurve.VISUAL_LEFT,
|
activeCurve = TimelineEditorState.ActiveCurve.VISUAL_LEFT,
|
||||||
// Visual curves: Flash Interval (left), Blank Interval (right)
|
visualLeft = TimelineCurve.constant(flash01, dur),
|
||||||
visualLeft = constantCurve(flash01),
|
visualRight = TimelineCurve.constant(blank01, dur),
|
||||||
visualRight = constantCurve(blank01),
|
audioLeft = TimelineCurve.constant(carrier01, dur),
|
||||||
// Audio curves: Carrier (left), Binaural (right)
|
audioRight = TimelineCurve.constant(binaural01, dur),
|
||||||
audioLeft = constantCurve(carrier01),
|
|
||||||
audioRight = constantCurve(binaural01),
|
|
||||||
isPlaying = false,
|
isPlaying = false,
|
||||||
playheadSec = 0f,
|
playheadSec = 0f,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user