Auto commit (MindMachine) Mon Apr 13 08:01:01 PM CDT 2026
This commit is contained in:
@@ -18,6 +18,7 @@ import androidx.activity.compose.setContent
|
|||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import solutions.tretter.mindmachine.session.DurationSliderCard
|
import solutions.tretter.mindmachine.session.DurationSliderCard
|
||||||
|
import solutions.tretter.mindmachine.session.FlashSequenceEditor
|
||||||
import solutions.tretter.mindmachine.session.SetupTimelineEditor
|
import solutions.tretter.mindmachine.session.SetupTimelineEditor
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
@@ -519,13 +520,10 @@ fun SetupScreen(
|
|||||||
onCurveGranularityChanged = vm::setCurveGranularitySec,
|
onCurveGranularityChanged = vm::setCurveGranularitySec,
|
||||||
)
|
)
|
||||||
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
FlashSequenceEditor(
|
||||||
Checkbox(
|
sequence = ui.timeline.flashSequence,
|
||||||
checked = ui.config.monochromeFlashMode,
|
onSequenceChanged = { sequence -> vm.updateTimeline(ui.timeline.withFlashSequence(sequence)) },
|
||||||
onCheckedChange = vm::setMonochromeFlashMode,
|
)
|
||||||
)
|
|
||||||
Text("Use black/white flashes for this session", color = MaterialTheme.colorScheme.onBackground)
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
if (hasUnsavedChanges) {
|
if (hasUnsavedChanges) {
|
||||||
@@ -653,11 +651,13 @@ fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit, onBackToEntry:
|
|||||||
SplitFlashRenderView(ctx).apply {
|
SplitFlashRenderView(ctx).apply {
|
||||||
setRunning(ui.runtimeState == RuntimeState.RUNNING)
|
setRunning(ui.runtimeState == RuntimeState.RUNNING)
|
||||||
updateIntervals(ui.runtimeParams.flashOnMs, ui.runtimeParams.flashOffMs)
|
updateIntervals(ui.runtimeParams.flashOnMs, ui.runtimeParams.flashOffMs)
|
||||||
|
setFlashSequence(ui.config.flashSequence)
|
||||||
setMonochrome(ui.config.monochromeFlashMode)
|
setMonochrome(ui.config.monochromeFlashMode)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
update = { view ->
|
update = { view ->
|
||||||
view.updateIntervals(ui.runtimeParams.flashOnMs, ui.runtimeParams.flashOffMs)
|
view.updateIntervals(ui.runtimeParams.flashOnMs, ui.runtimeParams.flashOffMs)
|
||||||
|
view.setFlashSequence(ui.config.flashSequence)
|
||||||
view.setMonochrome(ui.config.monochromeFlashMode)
|
view.setMonochrome(ui.config.monochromeFlashMode)
|
||||||
view.setRunning(ui.runtimeState == RuntimeState.RUNNING)
|
view.setRunning(ui.runtimeState == RuntimeState.RUNNING)
|
||||||
}
|
}
|
||||||
@@ -868,6 +868,7 @@ private class SplitFlashRenderView(context: Context) : View(context) {
|
|||||||
private var onMs = 167L
|
private var onMs = 167L
|
||||||
private var offMs = 167L
|
private var offMs = 167L
|
||||||
private var monochrome = false
|
private var monochrome = false
|
||||||
|
private var flashSequence = solutions.tretter.mindmachine.session.defaultFlashSequence()
|
||||||
private var started = false
|
private var started = false
|
||||||
private var startUptimeMs = 0L
|
private var startUptimeMs = 0L
|
||||||
private var nextToggleUptimeMs = 0L
|
private var nextToggleUptimeMs = 0L
|
||||||
@@ -888,11 +889,11 @@ private class SplitFlashRenderView(context: Context) : View(context) {
|
|||||||
maxLatenessMs = maxOf(maxLatenessMs, latenessMs)
|
maxLatenessMs = maxOf(maxLatenessMs, latenessMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
phase = (phase + 1) % 4
|
phase = (phase + 1) % phaseCount()
|
||||||
toggleCount += 1
|
toggleCount += 1
|
||||||
invalidate()
|
invalidate()
|
||||||
|
|
||||||
val nextDuration = if (phase == 0 || phase == 2) onMs else offMs
|
val nextDuration = if (isVisiblePhase(phase)) onMs else offMs
|
||||||
nextToggleUptimeMs += nextDuration
|
nextToggleUptimeMs += nextDuration
|
||||||
|
|
||||||
val shouldLogToggle = toggleCount <= 20 || latenessMs > framePeriodMs() || toggleCount % 60L == 0L
|
val shouldLogToggle = toggleCount <= 20 || latenessMs > framePeriodMs() || toggleCount % 60L == 0L
|
||||||
@@ -911,11 +912,19 @@ private class SplitFlashRenderView(context: Context) : View(context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun updateIntervals(flashOnMs: Int, flashOffMs: Int) {
|
fun updateIntervals(flashOnMs: Int, flashOffMs: Int) {
|
||||||
rawOnMs = flashOnMs.coerceIn(50, 2000).toLong()
|
val newRawOnMs = flashOnMs.coerceIn(50, 2000).toLong()
|
||||||
rawOffMs = flashOffMs.coerceIn(50, 2000).toLong()
|
val newRawOffMs = flashOffMs.coerceIn(50, 2000).toLong()
|
||||||
val frameMs = framePeriodMs().coerceAtLeast(1L)
|
val frameMs = framePeriodMs().coerceAtLeast(1L)
|
||||||
onMs = snapToFrameMs(rawOnMs, frameMs)
|
val newOnMs = snapToFrameMs(newRawOnMs, frameMs)
|
||||||
offMs = snapToFrameMs(rawOffMs, frameMs)
|
val newOffMs = snapToFrameMs(newRawOffMs, frameMs)
|
||||||
|
|
||||||
|
val changed = newRawOnMs != rawOnMs || newRawOffMs != rawOffMs || newOnMs != onMs || newOffMs != offMs
|
||||||
|
if (!changed) return
|
||||||
|
|
||||||
|
rawOnMs = newRawOnMs
|
||||||
|
rawOffMs = newRawOffMs
|
||||||
|
onMs = newOnMs
|
||||||
|
offMs = newOffMs
|
||||||
|
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
Log.d(
|
Log.d(
|
||||||
@@ -925,7 +934,14 @@ private class SplitFlashRenderView(context: Context) : View(context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isRunning && started) {
|
if (isRunning && started) {
|
||||||
scheduleFromNow()
|
val now = SystemClock.uptimeMillis()
|
||||||
|
val nextDuration = if (isVisiblePhase(phase)) onMs else offMs
|
||||||
|
nextToggleUptimeMs = maxOf(nextToggleUptimeMs, now + 1L)
|
||||||
|
val remaining = (nextToggleUptimeMs - now).coerceAtLeast(1L)
|
||||||
|
if (remaining > nextDuration) {
|
||||||
|
nextToggleUptimeMs = now + nextDuration
|
||||||
|
}
|
||||||
|
scheduleNext()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -962,6 +978,16 @@ private class SplitFlashRenderView(context: Context) : View(context) {
|
|||||||
invalidate()
|
invalidate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setFlashSequence(sequence: List<solutions.tretter.mindmachine.session.FlashSequenceRow>) {
|
||||||
|
val normalized = sequence.ifEmpty { solutions.tretter.mindmachine.session.defaultFlashSequence() }
|
||||||
|
if (flashSequence == normalized) return
|
||||||
|
flashSequence = normalized
|
||||||
|
if (started) {
|
||||||
|
phase %= phaseCount()
|
||||||
|
}
|
||||||
|
invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
override fun onDetachedFromWindow() {
|
override fun onDetachedFromWindow() {
|
||||||
handler.removeCallbacks(toggleRunnable)
|
handler.removeCallbacks(toggleRunnable)
|
||||||
super.onDetachedFromWindow()
|
super.onDetachedFromWindow()
|
||||||
@@ -981,24 +1007,35 @@ private class SplitFlashRenderView(context: Context) : View(context) {
|
|||||||
handler.removeCallbacks(toggleRunnable)
|
handler.removeCallbacks(toggleRunnable)
|
||||||
handler.postAtTime(toggleRunnable, nextToggleUptimeMs)
|
handler.postAtTime(toggleRunnable, nextToggleUptimeMs)
|
||||||
}
|
}
|
||||||
|
private fun colorsForPhase(phase: Int): Pair<Int, Int> {
|
||||||
private fun scheduleFromNow() {
|
if (!isVisiblePhase(phase)) return Pair(android.graphics.Color.BLACK, android.graphics.Color.BLACK)
|
||||||
val now = SystemClock.uptimeMillis()
|
if (monochrome) return Pair(android.graphics.Color.WHITE, android.graphics.Color.WHITE)
|
||||||
nextToggleUptimeMs = now + if (phase == 0 || phase == 2) onMs else offMs
|
val row = flashSequence[(phase / 2).mod(flashSequence.size)]
|
||||||
scheduleNext()
|
val left = paletteColor(row.leftColor)
|
||||||
|
val right = if (row.splitScreen) paletteColor(row.rightColor) else left
|
||||||
|
return Pair(left, right)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun colorsForPhase(phase: Int): Pair<Int, Int> = when (phase) {
|
private fun phaseName(phase: Int): String {
|
||||||
0 -> if (monochrome) Pair(android.graphics.Color.WHITE, android.graphics.Color.WHITE) else Pair(android.graphics.Color.RED, android.graphics.Color.GREEN)
|
if (!isVisiblePhase(phase)) return "BLACK"
|
||||||
2 -> if (monochrome) Pair(android.graphics.Color.WHITE, android.graphics.Color.WHITE) else Pair(android.graphics.Color.GREEN, android.graphics.Color.RED)
|
val row = flashSequence[(phase / 2).mod(flashSequence.size)]
|
||||||
else -> Pair(android.graphics.Color.BLACK, android.graphics.Color.BLACK)
|
return if (row.splitScreen) {
|
||||||
|
"${row.leftColor.name}_${row.rightColor.name}"
|
||||||
|
} else {
|
||||||
|
row.leftColor.name
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun phaseName(phase: Int): String = when (phase) {
|
private fun isVisiblePhase(phase: Int): Boolean = phase % 2 == 0
|
||||||
0 -> "RED_GREEN"
|
|
||||||
1 -> "BLACK"
|
private fun phaseCount(): Int = (flashSequence.size.coerceAtLeast(1)) * 2
|
||||||
2 -> "GREEN_RED"
|
|
||||||
else -> "BLACK"
|
private fun paletteColor(color: solutions.tretter.mindmachine.session.FlashPaletteColor): Int = when (color) {
|
||||||
|
solutions.tretter.mindmachine.session.FlashPaletteColor.BLACK -> android.graphics.Color.BLACK
|
||||||
|
solutions.tretter.mindmachine.session.FlashPaletteColor.RED -> android.graphics.Color.RED
|
||||||
|
solutions.tretter.mindmachine.session.FlashPaletteColor.GREEN -> android.graphics.Color.GREEN
|
||||||
|
solutions.tretter.mindmachine.session.FlashPaletteColor.YELLOW -> android.graphics.Color.YELLOW
|
||||||
|
solutions.tretter.mindmachine.session.FlashPaletteColor.BLUE -> android.graphics.Color.BLUE
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun refreshRate(): Float = display?.refreshRate?.takeIf { it > 0f } ?: 60f
|
private fun refreshRate(): Float = display?.refreshRate?.takeIf { it > 0f } ?: 60f
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import solutions.tretter.mindmachine.session.TimelinePoint
|
|||||||
import solutions.tretter.mindmachine.session.TIMELINE_DEFAULT_GRANULARITY_SEC
|
import solutions.tretter.mindmachine.session.TIMELINE_DEFAULT_GRANULARITY_SEC
|
||||||
import solutions.tretter.mindmachine.session.TimelineSelection
|
import solutions.tretter.mindmachine.session.TimelineSelection
|
||||||
import solutions.tretter.mindmachine.session.TimelineViewport
|
import solutions.tretter.mindmachine.session.TimelineViewport
|
||||||
|
import solutions.tretter.mindmachine.session.FlashPaletteColor
|
||||||
|
import solutions.tretter.mindmachine.session.FlashSequenceRow
|
||||||
|
import solutions.tretter.mindmachine.session.defaultFlashSequence
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
@@ -83,8 +86,10 @@ class UserProgramRepository(private val context: Context) {
|
|||||||
return TimelineEditorState(
|
return TimelineEditorState(
|
||||||
durationSec = o.getInt("durationSec"),
|
durationSec = o.getInt("durationSec"),
|
||||||
curveGranularitySec = TimelineEditorState.normalizeGranularitySec(
|
curveGranularitySec = TimelineEditorState.normalizeGranularitySec(
|
||||||
o.optInt("curveGranularitySec", TIMELINE_DEFAULT_GRANULARITY_SEC)
|
o.optInt("curveGranularitySec", TIMELINE_DEFAULT_GRANULARITY_SEC),
|
||||||
|
o.getInt("durationSec")
|
||||||
),
|
),
|
||||||
|
flashSequence = parseFlashSequence(o.optJSONArray("flashSequence")),
|
||||||
selection = TimelineSelection(
|
selection = TimelineSelection(
|
||||||
startSec = o.optInt("selectionStartSec", 0),
|
startSec = o.optInt("selectionStartSec", 0),
|
||||||
endSec = o.optInt("selectionEndSec", o.getInt("durationSec")),
|
endSec = o.optInt("selectionEndSec", o.getInt("durationSec")),
|
||||||
@@ -115,6 +120,7 @@ class UserProgramRepository(private val context: Context) {
|
|||||||
val timeline = JSONObject()
|
val timeline = JSONObject()
|
||||||
timeline.put("durationSec", program.timeline.durationSec)
|
timeline.put("durationSec", program.timeline.durationSec)
|
||||||
timeline.put("curveGranularitySec", program.timeline.curveGranularitySec)
|
timeline.put("curveGranularitySec", program.timeline.curveGranularitySec)
|
||||||
|
timeline.put("flashSequence", flashSequenceArray(program.timeline.flashSequence))
|
||||||
timeline.put("selectionStartSec", program.timeline.selection.startSec)
|
timeline.put("selectionStartSec", program.timeline.selection.startSec)
|
||||||
timeline.put("selectionEndSec", program.timeline.selection.endSec)
|
timeline.put("selectionEndSec", program.timeline.selection.endSec)
|
||||||
timeline.put("viewportStartSec", program.timeline.viewport.startSec)
|
timeline.put("viewportStartSec", program.timeline.viewport.startSec)
|
||||||
@@ -138,6 +144,35 @@ class UserProgramRepository(private val context: Context) {
|
|||||||
}
|
}
|
||||||
return arr
|
return arr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun parseFlashSequence(arr: JSONArray?): List<FlashSequenceRow> {
|
||||||
|
if (arr == null || arr.length() == 0) return defaultFlashSequence()
|
||||||
|
return buildList {
|
||||||
|
for (i in 0 until arr.length()) {
|
||||||
|
val row = arr.getJSONObject(i)
|
||||||
|
add(
|
||||||
|
FlashSequenceRow(
|
||||||
|
splitScreen = row.optBoolean("splitScreen", true),
|
||||||
|
leftColor = row.optString("leftColor", FlashPaletteColor.RED.name).let(FlashPaletteColor::valueOf),
|
||||||
|
rightColor = row.optString("rightColor", FlashPaletteColor.GREEN.name).let(FlashPaletteColor::valueOf),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.ifEmpty { defaultFlashSequence() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun flashSequenceArray(sequence: List<FlashSequenceRow>): JSONArray {
|
||||||
|
val arr = JSONArray()
|
||||||
|
sequence.forEach { row ->
|
||||||
|
arr.put(
|
||||||
|
JSONObject()
|
||||||
|
.put("splitScreen", row.splitScreen)
|
||||||
|
.put("leftColor", row.leftColor.name)
|
||||||
|
.put("rightColor", row.rightColor.name)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return arr
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class UserProgramEntity(
|
data class UserProgramEntity(
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package solutions.tretter.mindmachine.domain
|
package solutions.tretter.mindmachine.domain
|
||||||
|
|
||||||
|
import solutions.tretter.mindmachine.session.FlashSequenceRow
|
||||||
|
import solutions.tretter.mindmachine.session.defaultFlashSequence
|
||||||
|
|
||||||
enum class VisualPattern { FLASH, PULSE }
|
enum class VisualPattern { FLASH, PULSE }
|
||||||
enum class SessionMode { AUDIO_VISUAL, AUDIO_ONLY, VISUAL_ONLY }
|
enum class SessionMode { AUDIO_VISUAL, AUDIO_ONLY, VISUAL_ONLY }
|
||||||
enum class RuntimeState { IDLE, COUNTDOWN, RUNNING, PAUSED, INTERRUPTED, COMPLETED, STOPPED, ERROR }
|
enum class RuntimeState { IDLE, COUNTDOWN, RUNNING, PAUSED, INTERRUPTED, COMPLETED, STOPPED, ERROR }
|
||||||
@@ -17,6 +20,7 @@ data class SessionPreset(
|
|||||||
val cautionNote: String,
|
val cautionNote: String,
|
||||||
val sortOrder: Int,
|
val sortOrder: Int,
|
||||||
val isUserProgram: Boolean = false,
|
val isUserProgram: Boolean = false,
|
||||||
|
val flashSequence: List<FlashSequenceRow> = defaultFlashSequence(),
|
||||||
)
|
)
|
||||||
|
|
||||||
data class SessionConfig(
|
data class SessionConfig(
|
||||||
@@ -25,6 +29,7 @@ data class SessionConfig(
|
|||||||
val mode: SessionMode,
|
val mode: SessionMode,
|
||||||
val visualPatternType: VisualPattern,
|
val visualPatternType: VisualPattern,
|
||||||
val flashIntervalMs: Int,
|
val flashIntervalMs: Int,
|
||||||
|
val flashSequence: List<FlashSequenceRow> = defaultFlashSequence(),
|
||||||
val monochromeFlashMode: Boolean = false,
|
val monochromeFlashMode: Boolean = false,
|
||||||
val intensityPercent: Int,
|
val intensityPercent: Int,
|
||||||
val carrierFrequencyHz: Float,
|
val carrierFrequencyHz: Float,
|
||||||
@@ -97,6 +102,7 @@ fun SessionPreset.toConfig(defaultMode: SessionMode = SessionMode.AUDIO_VISUAL)
|
|||||||
mode = defaultMode,
|
mode = defaultMode,
|
||||||
visualPatternType = visualPatternType,
|
visualPatternType = visualPatternType,
|
||||||
flashIntervalMs = flashIntervalMs,
|
flashIntervalMs = flashIntervalMs,
|
||||||
|
flashSequence = flashSequence,
|
||||||
monochromeFlashMode = false,
|
monochromeFlashMode = false,
|
||||||
intensityPercent = intensityPercent,
|
intensityPercent = intensityPercent,
|
||||||
carrierFrequencyHz = carrierFrequencyHz,
|
carrierFrequencyHz = carrierFrequencyHz,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
private const val MIN_DURATION_SEC = 60
|
private const val MIN_DURATION_SEC = 60
|
||||||
private const val MAX_DURATION_SEC = 8 * 60 * 60
|
private const val MAX_DURATION_SEC = 60 * 60
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DurationSliderCard(
|
fun DurationSliderCard(
|
||||||
@@ -51,14 +51,15 @@ fun DurationSliderCard(
|
|||||||
Slider(
|
Slider(
|
||||||
value = clamped.toFloat(),
|
value = clamped.toFloat(),
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
val snapped = (it / 60f).roundToInt().coerceIn(1, 8 * 60) * 60
|
val snapped = (it / 60f).roundToInt().coerceIn(1, 60) * 60
|
||||||
onDurationSecChanged(snapped)
|
onDurationSecChanged(snapped)
|
||||||
},
|
},
|
||||||
valueRange = MIN_DURATION_SEC.toFloat()..MAX_DURATION_SEC.toFloat(),
|
valueRange = MIN_DURATION_SEC.toFloat()..MAX_DURATION_SEC.toFloat(),
|
||||||
|
steps = 58,
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
"Range: 1 minute to 8 hours",
|
"Range: 1 minute to 60 minutes",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package solutions.tretter.mindmachine.session
|
||||||
|
|
||||||
|
enum class FlashPaletteColor {
|
||||||
|
BLACK,
|
||||||
|
RED,
|
||||||
|
GREEN,
|
||||||
|
YELLOW,
|
||||||
|
BLUE,
|
||||||
|
}
|
||||||
|
|
||||||
|
data class FlashSequenceRow(
|
||||||
|
val splitScreen: Boolean = true,
|
||||||
|
val leftColor: FlashPaletteColor = FlashPaletteColor.RED,
|
||||||
|
val rightColor: FlashPaletteColor = FlashPaletteColor.GREEN,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun defaultFlashSequence(): List<FlashSequenceRow> = listOf(
|
||||||
|
FlashSequenceRow(splitScreen = true, leftColor = FlashPaletteColor.RED, rightColor = FlashPaletteColor.GREEN)
|
||||||
|
)
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package solutions.tretter.mindmachine.session
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
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.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FlashSequenceEditor(
|
||||||
|
sequence: List<FlashSequenceRow>,
|
||||||
|
onSequenceChanged: (List<FlashSequenceRow>) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val rows = sequence.ifEmpty { defaultFlashSequence() }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.surface, RoundedCornerShape(12.dp))
|
||||||
|
.padding(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
Text("Color sequence", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface)
|
||||||
|
Text(
|
||||||
|
"Each row is one visible color state. Split screen uses separate left/right colors. Non-split rows fill the whole screen with one color.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.72f),
|
||||||
|
)
|
||||||
|
|
||||||
|
rows.forEachIndexed { index, row ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
|
||||||
|
Checkbox(
|
||||||
|
checked = row.splitScreen,
|
||||||
|
onCheckedChange = { checked ->
|
||||||
|
onSequenceChanged(rows.toMutableList().also { it[index] = row.copy(splitScreen = checked) })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Text("Split screen", color = MaterialTheme.colorScheme.onSurface)
|
||||||
|
}
|
||||||
|
|
||||||
|
ColorCycleButton(
|
||||||
|
label = if (row.splitScreen) "Left" else "Color",
|
||||||
|
color = row.leftColor,
|
||||||
|
onClick = {
|
||||||
|
onSequenceChanged(rows.toMutableList().also {
|
||||||
|
it[index] = row.copy(leftColor = row.leftColor.next())
|
||||||
|
})
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (row.splitScreen) {
|
||||||
|
ColorCycleButton(
|
||||||
|
label = "Right",
|
||||||
|
color = row.rightColor,
|
||||||
|
onClick = {
|
||||||
|
onSequenceChanged(rows.toMutableList().also {
|
||||||
|
it[index] = row.copy(rightColor = row.rightColor.next())
|
||||||
|
})
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Box(modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
onSequenceChanged(rows.filterIndexed { i, _ -> i != index }.ifEmpty { defaultFlashSequence() })
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Delete, contentDescription = "Remove row", tint = MaterialTheme.colorScheme.onSurface)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { onSequenceChanged(rows + FlashSequenceRow()) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Add, contentDescription = null)
|
||||||
|
Text(" Add color row")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ColorCycleButton(
|
||||||
|
label: String,
|
||||||
|
color: FlashPaletteColor,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
OutlinedButton(onClick = onClick, modifier = modifier) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(end = 8.dp)
|
||||||
|
.background(color.previewColor(), RoundedCornerShape(6.dp))
|
||||||
|
.padding(horizontal = 10.dp, vertical = 10.dp)
|
||||||
|
)
|
||||||
|
Text("$label: ${color.displayName}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val FlashPaletteColor.displayName: String
|
||||||
|
get() = name.lowercase().replaceFirstChar { it.uppercase() }
|
||||||
|
|
||||||
|
private fun FlashPaletteColor.next(): FlashPaletteColor {
|
||||||
|
val values = FlashPaletteColor.entries
|
||||||
|
return values[(ordinal + 1) % values.size]
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun FlashPaletteColor.previewColor(): Color = when (this) {
|
||||||
|
FlashPaletteColor.BLACK -> Color.Black
|
||||||
|
FlashPaletteColor.RED -> Color(0xFFD32F2F)
|
||||||
|
FlashPaletteColor.GREEN -> Color(0xFF2E7D32)
|
||||||
|
FlashPaletteColor.YELLOW -> Color(0xFFF9A825)
|
||||||
|
FlashPaletteColor.BLUE -> Color(0xFF1565C0)
|
||||||
|
}
|
||||||
@@ -175,7 +175,7 @@ class MainViewModel(
|
|||||||
val savedSignature = timelineProgramSignature(timeline)
|
val savedSignature = timelineProgramSignature(timeline)
|
||||||
it.copy(
|
it.copy(
|
||||||
selectedPreset = preset,
|
selectedPreset = preset,
|
||||||
config = preset.toConfig(SessionMode.AUDIO_VISUAL),
|
config = preset.toConfig(SessionMode.AUDIO_VISUAL).copy(flashSequence = timeline.flashSequence),
|
||||||
timeline = timeline,
|
timeline = timeline,
|
||||||
savedProgramName = displayName,
|
savedProgramName = displayName,
|
||||||
savedTimelineSignature = savedSignature,
|
savedTimelineSignature = savedSignature,
|
||||||
@@ -237,7 +237,7 @@ class MainViewModel(
|
|||||||
val savedSignature = timelineProgramSignature(timeline)
|
val savedSignature = timelineProgramSignature(timeline)
|
||||||
it.copy(
|
it.copy(
|
||||||
selectedPreset = fallback,
|
selectedPreset = fallback,
|
||||||
config = fallback.toConfig(SessionMode.AUDIO_VISUAL),
|
config = fallback.toConfig(SessionMode.AUDIO_VISUAL).copy(flashSequence = timeline.flashSequence),
|
||||||
timeline = timeline,
|
timeline = timeline,
|
||||||
savedProgramName = fallback.name,
|
savedProgramName = fallback.name,
|
||||||
savedTimelineSignature = savedSignature,
|
savedTimelineSignature = savedSignature,
|
||||||
@@ -251,7 +251,7 @@ class MainViewModel(
|
|||||||
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) }
|
||||||
|
|
||||||
fun setDurationSec(seconds: Int) = _ui.update {
|
fun setDurationSec(seconds: Int) = _ui.update {
|
||||||
val clamped = seconds.coerceIn(60, 8 * 60 * 60)
|
val clamped = seconds.coerceIn(60, 60 * 60)
|
||||||
val nextTimeline = it.timeline.setDurationSec(clamped)
|
val nextTimeline = it.timeline.setDurationSec(clamped)
|
||||||
it.copy(
|
it.copy(
|
||||||
config = it.config.copy(durationSec = clamped),
|
config = it.config.copy(durationSec = clamped),
|
||||||
@@ -273,7 +273,10 @@ class MainViewModel(
|
|||||||
fun updateTimeline(newState: TimelineEditorState) = _ui.update {
|
fun updateTimeline(newState: TimelineEditorState) = _ui.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
timeline = newState,
|
timeline = newState,
|
||||||
config = it.config.copy(durationSec = newState.durationSec.coerceIn(60, 8 * 60 * 60)),
|
config = it.config.copy(
|
||||||
|
durationSec = newState.durationSec.coerceIn(60, 60 * 60),
|
||||||
|
flashSequence = newState.flashSequence,
|
||||||
|
),
|
||||||
hasUnsavedChanges = isDirty(it.selectedPreset.name, newState, it.savedProgramName, it.savedTimelineSignature),
|
hasUnsavedChanges = isDirty(it.selectedPreset.name, newState, it.savedProgramName, it.savedTimelineSignature),
|
||||||
error = null,
|
error = null,
|
||||||
)
|
)
|
||||||
@@ -451,7 +454,10 @@ class MainViewModel(
|
|||||||
val savedSignature = timelineProgramSignature(timeline)
|
val savedSignature = timelineProgramSignature(timeline)
|
||||||
it.copy(
|
it.copy(
|
||||||
selectedPreset = preset,
|
selectedPreset = preset,
|
||||||
config = cfg.copy(durationSec = cfg.durationSec.coerceIn(60, 8 * 60 * 60)),
|
config = cfg.copy(
|
||||||
|
durationSec = cfg.durationSec.coerceIn(60, 60 * 60),
|
||||||
|
flashSequence = timeline.flashSequence,
|
||||||
|
),
|
||||||
timeline = timeline,
|
timeline = timeline,
|
||||||
savedProgramName = preset.name,
|
savedProgramName = preset.name,
|
||||||
savedTimelineSignature = savedSignature,
|
savedTimelineSignature = savedSignature,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package solutions.tretter.mindmachine.session
|
|||||||
import solutions.tretter.mindmachine.domain.SessionConfig
|
import solutions.tretter.mindmachine.domain.SessionConfig
|
||||||
object SessionValidator {
|
object SessionValidator {
|
||||||
fun validate(config: SessionConfig): String? {
|
fun validate(config: SessionConfig): String? {
|
||||||
if (config.durationSec !in 60..(8 * 60 * 60)) return "Duration must be 1 minute to 8 hours."
|
if (config.durationSec !in 60..(60 * 60)) return "Duration must be 1 minute to 60 minutes."
|
||||||
if (config.flashIntervalMs !in 50..2000) return "Flash interval must be 0.05 to 2.00 seconds."
|
if (config.flashIntervalMs !in 50..2000) return "Flash interval must be 0.05 to 2.00 seconds."
|
||||||
val carrier = config.carrierFrequencyHz
|
val carrier = config.carrierFrequencyHz
|
||||||
if (!carrier.isFinite() || carrier < ParameterRanges.CARRIER_HZ_MIN || carrier > ParameterRanges.CARRIER_HZ_MAX) {
|
if (!carrier.isFinite() || carrier < ParameterRanges.CARRIER_HZ_MIN || carrier > ParameterRanges.CARRIER_HZ_MAX) {
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ fun SetupTimelineEditor(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
var local by remember(state) { mutableStateOf(state) }
|
var local by remember(state) { mutableStateOf(state) }
|
||||||
|
val allowedGranularity = remember(local.durationSec) { TimelineEditorState.allowedGranularitySteps(local.durationSec) }
|
||||||
|
val sliderMin = allowedGranularity.first().toFloat()
|
||||||
|
val sliderMax = allowedGranularity.last().toFloat()
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth(),
|
||||||
@@ -38,7 +41,7 @@ fun SetupTimelineEditor(
|
|||||||
color = MaterialTheme.colorScheme.onBackground,
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
"Adjust curve granularity, then drag dots up/down to shape the session. Pinch to zoom for precision.",
|
"Adjust curve granularity, then drag dots up/down to shape the session. Granularity always divides the full duration evenly.",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.75f),
|
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.75f),
|
||||||
)
|
)
|
||||||
@@ -49,22 +52,22 @@ fun SetupTimelineEditor(
|
|||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
)
|
)
|
||||||
var granularitySliderValue by remember(state.curveGranularitySec) {
|
var granularitySliderValue by remember(state.curveGranularitySec, state.durationSec) {
|
||||||
mutableFloatStateOf(state.curveGranularitySec.toFloat())
|
mutableFloatStateOf(state.curveGranularitySec.toFloat())
|
||||||
}
|
}
|
||||||
Slider(
|
Slider(
|
||||||
value = granularitySliderValue,
|
value = granularitySliderValue.coerceIn(sliderMin, sliderMax),
|
||||||
onValueChange = { granularitySliderValue = it },
|
onValueChange = { granularitySliderValue = it },
|
||||||
onValueChangeFinished = {
|
onValueChangeFinished = {
|
||||||
val snapped = TimelineEditorState.normalizeGranularitySec(granularitySliderValue.roundToInt())
|
val snapped = TimelineEditorState.normalizeGranularitySec(granularitySliderValue.roundToInt(), local.durationSec)
|
||||||
if (snapped != local.curveGranularitySec) {
|
if (snapped != local.curveGranularitySec) {
|
||||||
local = local.setCurveGranularitySec(snapped).also(onStateChanged)
|
local = local.setCurveGranularitySec(snapped).also(onStateChanged)
|
||||||
onCurveGranularityChanged(snapped)
|
onCurveGranularityChanged(snapped)
|
||||||
}
|
}
|
||||||
granularitySliderValue = local.curveGranularitySec.toFloat()
|
granularitySliderValue = local.curveGranularitySec.toFloat()
|
||||||
},
|
},
|
||||||
valueRange = TIMELINE_MIN_GRANULARITY_SEC.toFloat()..TIMELINE_MAX_GRANULARITY_SEC.toFloat(),
|
valueRange = sliderMin..sliderMax,
|
||||||
steps = ((TIMELINE_MAX_GRANULARITY_SEC - TIMELINE_MIN_GRANULARITY_SEC) / 5) - 1,
|
steps = max(0, allowedGranularity.size - 2),
|
||||||
)
|
)
|
||||||
|
|
||||||
TimelineGraph(
|
TimelineGraph(
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
package solutions.tretter.mindmachine.session
|
package solutions.tretter.mindmachine.session
|
||||||
|
|
||||||
|
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 MIN_DURATION_SEC = 60
|
||||||
private const val MAX_DURATION_SEC = 8 * 60 * 60
|
private const val MAX_DURATION_SEC = 60 * 60
|
||||||
const val TIMELINE_MIN_GRANULARITY_SEC = 5
|
const val TIMELINE_MIN_GRANULARITY_SEC = 30
|
||||||
const val TIMELINE_MAX_GRANULARITY_SEC = 300
|
const val TIMELINE_MAX_GRANULARITY_SEC = 30 * 60
|
||||||
const val TIMELINE_DEFAULT_GRANULARITY_SEC = 60
|
const val TIMELINE_DEFAULT_GRANULARITY_SEC = 60
|
||||||
|
|
||||||
data class TimelineEditorState(
|
data class TimelineEditorState(
|
||||||
val durationSec: Int,
|
val durationSec: Int,
|
||||||
val curveGranularitySec: Int,
|
val curveGranularitySec: Int,
|
||||||
|
val flashSequence: List<FlashSequenceRow>,
|
||||||
val selection: TimelineSelection,
|
val selection: TimelineSelection,
|
||||||
val viewport: TimelineViewport,
|
val viewport: TimelineViewport,
|
||||||
val activeCurve: ActiveCurve,
|
val activeCurve: ActiveCurve,
|
||||||
@@ -32,11 +34,12 @@ data class TimelineEditorState(
|
|||||||
companion object {
|
companion object {
|
||||||
fun default(durationSec: Int = 20 * 60): TimelineEditorState {
|
fun default(durationSec: Int = 20 * 60): TimelineEditorState {
|
||||||
val dur = snapDuration(durationSec)
|
val dur = snapDuration(durationSec)
|
||||||
val granularitySec = TIMELINE_DEFAULT_GRANULARITY_SEC
|
val granularitySec = normalizeGranularitySec(TIMELINE_DEFAULT_GRANULARITY_SEC, dur)
|
||||||
val initial = TimelineCurve.constant(0.5f, dur, granularitySec)
|
val initial = TimelineCurve.constant(0.5f, dur, granularitySec)
|
||||||
return TimelineEditorState(
|
return TimelineEditorState(
|
||||||
durationSec = dur,
|
durationSec = dur,
|
||||||
curveGranularitySec = granularitySec,
|
curveGranularitySec = granularitySec,
|
||||||
|
flashSequence = defaultFlashSequence(),
|
||||||
selection = TimelineSelection(0, dur),
|
selection = TimelineSelection(0, dur),
|
||||||
viewport = TimelineViewport(
|
viewport = TimelineViewport(
|
||||||
startSec = 0f,
|
startSec = 0f,
|
||||||
@@ -52,11 +55,21 @@ data class TimelineEditorState(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun snapDuration(durationSec: Int): Int =
|
fun snapDuration(durationSec: Int): Int =
|
||||||
((durationSec.coerceIn(MIN_DURATION_SEC, MAX_DURATION_SEC) + 30) / 60) * 60
|
((durationSec.coerceIn(MIN_DURATION_SEC, MAX_DURATION_SEC) + 30) / 60) * 60
|
||||||
|
|
||||||
fun normalizeGranularitySec(granularitySec: Int): Int =
|
fun allowedGranularitySteps(durationSec: Int): List<Int> {
|
||||||
((granularitySec.coerceIn(TIMELINE_MIN_GRANULARITY_SEC, TIMELINE_MAX_GRANULARITY_SEC) + 2) / 5) * 5
|
val duration = snapDuration(durationSec)
|
||||||
|
val maxStep = min(TIMELINE_MAX_GRANULARITY_SEC, duration)
|
||||||
|
return (TIMELINE_MIN_GRANULARITY_SEC..maxStep step TIMELINE_MIN_GRANULARITY_SEC)
|
||||||
|
.filter { duration % it == 0 }
|
||||||
|
.ifEmpty { listOf(duration) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun normalizeGranularitySec(granularitySec: Int, durationSec: Int): Int {
|
||||||
|
val allowed = allowedGranularitySteps(durationSec)
|
||||||
|
return allowed.minByOrNull { abs(it - granularitySec) } ?: allowed.first()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun curveFor(id: ActiveCurve): TimelineCurve = when (id) {
|
fun curveFor(id: ActiveCurve): TimelineCurve = when (id) {
|
||||||
@@ -76,35 +89,45 @@ data class TimelineEditorState(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun withFlashSequence(sequence: List<FlashSequenceRow>): TimelineEditorState =
|
||||||
|
copy(flashSequence = sequence.ifEmpty { defaultFlashSequence() })
|
||||||
|
|
||||||
fun extendDurationTo(atLeastSec: Int): TimelineEditorState {
|
fun extendDurationTo(atLeastSec: Int): TimelineEditorState {
|
||||||
val newDur = ((atLeastSec.coerceIn(MIN_DURATION_SEC, MAX_DURATION_SEC) + 30) / 60) * 60
|
val newDur = snapDuration(atLeastSec)
|
||||||
if (newDur <= durationSec) return this
|
if (newDur <= durationSec) return this
|
||||||
|
|
||||||
|
val normalizedGranularity = normalizeGranularitySec(curveGranularitySec, newDur)
|
||||||
return copy(
|
return copy(
|
||||||
durationSec = newDur,
|
durationSec = newDur,
|
||||||
|
curveGranularitySec = normalizedGranularity,
|
||||||
selection = selection.copy(endSec = max(selection.endSec, newDur)),
|
selection = selection.copy(endSec = max(selection.endSec, newDur)),
|
||||||
visualLeft = visualLeft.extendTo(newDur, curveGranularitySec),
|
visualLeft = visualLeft.extendTo(newDur, normalizedGranularity),
|
||||||
visualRight = visualRight.extendTo(newDur, curveGranularitySec),
|
visualRight = visualRight.extendTo(newDur, normalizedGranularity),
|
||||||
audioLeft = audioLeft.extendTo(newDur, curveGranularitySec),
|
audioLeft = audioLeft.extendTo(newDur, normalizedGranularity),
|
||||||
audioRight = audioRight.extendTo(newDur, curveGranularitySec),
|
audioRight = audioRight.extendTo(newDur, normalizedGranularity),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setDurationSec(newDurationSec: Int): TimelineEditorState {
|
fun setDurationSec(newDurationSec: Int): TimelineEditorState {
|
||||||
val clamped = ((newDurationSec.coerceIn(MIN_DURATION_SEC, MAX_DURATION_SEC) + 30) / 60) * 60
|
val clamped = snapDuration(newDurationSec)
|
||||||
if (clamped == durationSec) return this
|
if (clamped == durationSec) return this
|
||||||
|
val normalizedGranularity = normalizeGranularitySec(curveGranularitySec, clamped)
|
||||||
|
|
||||||
return if (clamped > durationSec) {
|
return if (clamped > durationSec) {
|
||||||
extendDurationTo(clamped).copy(selection = selection.copy(startSec = 0, endSec = clamped))
|
extendDurationTo(clamped).copy(
|
||||||
|
curveGranularitySec = normalizedGranularity,
|
||||||
|
selection = selection.copy(startSec = 0, endSec = clamped),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
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,
|
||||||
|
curveGranularitySec = normalizedGranularity,
|
||||||
selection = TimelineSelection(0, clamped),
|
selection = TimelineSelection(0, clamped),
|
||||||
visualLeft = visualLeft.trimTo(clamped, curveGranularitySec),
|
visualLeft = visualLeft.trimTo(clamped, normalizedGranularity),
|
||||||
visualRight = visualRight.trimTo(clamped, curveGranularitySec),
|
visualRight = visualRight.trimTo(clamped, normalizedGranularity),
|
||||||
audioLeft = audioLeft.trimTo(clamped, curveGranularitySec),
|
audioLeft = audioLeft.trimTo(clamped, normalizedGranularity),
|
||||||
audioRight = audioRight.trimTo(clamped, curveGranularitySec),
|
audioRight = audioRight.trimTo(clamped, normalizedGranularity),
|
||||||
viewport = newViewport,
|
viewport = newViewport,
|
||||||
playheadSec = playheadSec.coerceIn(0f, clamped.toFloat()),
|
playheadSec = playheadSec.coerceIn(0f, clamped.toFloat()),
|
||||||
)
|
)
|
||||||
@@ -112,7 +135,7 @@ data class TimelineEditorState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun setCurveGranularitySec(newGranularitySec: Int): TimelineEditorState {
|
fun setCurveGranularitySec(newGranularitySec: Int): TimelineEditorState {
|
||||||
val normalized = normalizeGranularitySec(newGranularitySec)
|
val normalized = normalizeGranularitySec(newGranularitySec, durationSec)
|
||||||
if (normalized == curveGranularitySec) return this
|
if (normalized == curveGranularitySec) return this
|
||||||
|
|
||||||
return copy(
|
return copy(
|
||||||
@@ -191,7 +214,7 @@ data class TimelineCurve(
|
|||||||
companion object {
|
companion object {
|
||||||
fun constant(value01: Float, durationSec: Int, granularitySec: Int = TIMELINE_DEFAULT_GRANULARITY_SEC): TimelineCurve {
|
fun constant(value01: Float, durationSec: Int, granularitySec: Int = TIMELINE_DEFAULT_GRANULARITY_SEC): TimelineCurve {
|
||||||
val v = value01.coerceIn(0f, 1f)
|
val v = value01.coerceIn(0f, 1f)
|
||||||
val stepSec = TimelineEditorState.normalizeGranularitySec(granularitySec)
|
val stepSec = TimelineEditorState.normalizeGranularitySec(granularitySec, durationSec)
|
||||||
return TimelineCurve(
|
return TimelineCurve(
|
||||||
points = (0..durationSec step stepSec).map { TimelinePoint(it, v) }
|
points = (0..durationSec step stepSec).map { TimelinePoint(it, v) }
|
||||||
)
|
)
|
||||||
@@ -214,8 +237,8 @@ data class TimelineCurve(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun ensureTimelinePoints(durationSec: Int, granularitySec: Int): TimelineCurve {
|
fun ensureTimelinePoints(durationSec: Int, granularitySec: Int): TimelineCurve {
|
||||||
val snappedDuration = ((durationSec.coerceIn(MIN_DURATION_SEC, MAX_DURATION_SEC) + 30) / 60) * 60
|
val snappedDuration = TimelineEditorState.snapDuration(durationSec)
|
||||||
val stepSec = TimelineEditorState.normalizeGranularitySec(granularitySec)
|
val stepSec = TimelineEditorState.normalizeGranularitySec(granularitySec, snappedDuration)
|
||||||
val normalized = (0..snappedDuration step stepSec).map { tSec ->
|
val normalized = (0..snappedDuration step stepSec).map { tSec ->
|
||||||
TimelinePoint(tSec, valueAt(tSec.toFloat()))
|
TimelinePoint(tSec, valueAt(tSec.toFloat()))
|
||||||
}
|
}
|
||||||
@@ -236,7 +259,6 @@ data class TimelineCurve(
|
|||||||
if (index !in points.indices) return this
|
if (index !in points.indices) return this
|
||||||
val v = (1f - (newY / heightPx)).coerceIn(0f, 1f)
|
val v = (1f - (newY / heightPx)).coerceIn(0f, 1f)
|
||||||
val updated = points.toMutableList()
|
val updated = points.toMutableList()
|
||||||
// Update the dragged point and all points after it (same or later time)
|
|
||||||
for (i in index until updated.size) {
|
for (i in index until updated.size) {
|
||||||
updated[i] = updated[i].copy(value01 = v)
|
updated[i] = updated[i].copy(value01 = v)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ data class TimelineModel(
|
|||||||
mode = mode,
|
mode = mode,
|
||||||
visualPatternType = preset.visualPatternType,
|
visualPatternType = preset.visualPatternType,
|
||||||
flashIntervalMs = flashIntervalMs,
|
flashIntervalMs = flashIntervalMs,
|
||||||
|
flashSequence = preset.flashSequence,
|
||||||
monochromeFlashMode = false,
|
monochromeFlashMode = false,
|
||||||
intensityPercent = preset.intensityPercent,
|
intensityPercent = preset.intensityPercent,
|
||||||
carrierFrequencyHz = carrier,
|
carrierFrequencyHz = carrier,
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ object TimelineProgramFactory {
|
|||||||
cautionNote = "Stop if discomfort occurs.",
|
cautionNote = "Stop if discomfort occurs.",
|
||||||
sortOrder = sortOrder,
|
sortOrder = sortOrder,
|
||||||
isUserProgram = isUserProgram,
|
isUserProgram = isUserProgram,
|
||||||
|
flashSequence = timeline.flashSequence,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +75,7 @@ object TimelineProgramFactory {
|
|||||||
return TimelineEditorState(
|
return TimelineEditorState(
|
||||||
durationSec = dur,
|
durationSec = dur,
|
||||||
curveGranularitySec = TIMELINE_DEFAULT_GRANULARITY_SEC,
|
curveGranularitySec = TIMELINE_DEFAULT_GRANULARITY_SEC,
|
||||||
|
flashSequence = preset.flashSequence,
|
||||||
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,
|
||||||
@@ -124,6 +126,7 @@ object TimelineProgramFactory {
|
|||||||
return TimelineEditorState(
|
return TimelineEditorState(
|
||||||
durationSec = durationSec,
|
durationSec = durationSec,
|
||||||
curveGranularitySec = TIMELINE_DEFAULT_GRANULARITY_SEC,
|
curveGranularitySec = TIMELINE_DEFAULT_GRANULARITY_SEC,
|
||||||
|
flashSequence = defaultFlashSequence(),
|
||||||
selection = TimelineSelection(0, durationSec),
|
selection = TimelineSelection(0, durationSec),
|
||||||
viewport = TimelineViewport(startSec = 0f, secondsPerScreen = minOf(10f * 60f, durationSec.toFloat())),
|
viewport = TimelineViewport(startSec = 0f, secondsPerScreen = minOf(10f * 60f, durationSec.toFloat())),
|
||||||
activeCurve = TimelineEditorState.ActiveCurve.VISUAL_LEFT,
|
activeCurve = TimelineEditorState.ActiveCurve.VISUAL_LEFT,
|
||||||
|
|||||||
Reference in New Issue
Block a user