diff --git a/app/src/main/java/com/mindmachine/mvp/MainActivity.kt b/app/src/main/java/com/mindmachine/mvp/MainActivity.kt index 6f83681..7ea16e4 100644 --- a/app/src/main/java/com/mindmachine/mvp/MainActivity.kt +++ b/app/src/main/java/com/mindmachine/mvp/MainActivity.kt @@ -18,6 +18,7 @@ import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.compose.foundation.background import solutions.tretter.mindmachine.session.DurationSliderCard +import solutions.tretter.mindmachine.session.FlashSequenceEditor import solutions.tretter.mindmachine.session.SetupTimelineEditor import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures @@ -519,13 +520,10 @@ fun SetupScreen( onCurveGranularityChanged = vm::setCurveGranularitySec, ) - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox( - checked = ui.config.monochromeFlashMode, - onCheckedChange = vm::setMonochromeFlashMode, - ) - Text("Use black/white flashes for this session", color = MaterialTheme.colorScheme.onBackground) - } + FlashSequenceEditor( + sequence = ui.timeline.flashSequence, + onSequenceChanged = { sequence -> vm.updateTimeline(ui.timeline.withFlashSequence(sequence)) }, + ) Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { if (hasUnsavedChanges) { @@ -653,11 +651,13 @@ fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit, onBackToEntry: SplitFlashRenderView(ctx).apply { setRunning(ui.runtimeState == RuntimeState.RUNNING) updateIntervals(ui.runtimeParams.flashOnMs, ui.runtimeParams.flashOffMs) + setFlashSequence(ui.config.flashSequence) setMonochrome(ui.config.monochromeFlashMode) } }, update = { view -> view.updateIntervals(ui.runtimeParams.flashOnMs, ui.runtimeParams.flashOffMs) + view.setFlashSequence(ui.config.flashSequence) view.setMonochrome(ui.config.monochromeFlashMode) view.setRunning(ui.runtimeState == RuntimeState.RUNNING) } @@ -868,6 +868,7 @@ private class SplitFlashRenderView(context: Context) : View(context) { private var onMs = 167L private var offMs = 167L private var monochrome = false + private var flashSequence = solutions.tretter.mindmachine.session.defaultFlashSequence() private var started = false private var startUptimeMs = 0L private var nextToggleUptimeMs = 0L @@ -888,11 +889,11 @@ private class SplitFlashRenderView(context: Context) : View(context) { maxLatenessMs = maxOf(maxLatenessMs, latenessMs) } - phase = (phase + 1) % 4 + phase = (phase + 1) % phaseCount() toggleCount += 1 invalidate() - val nextDuration = if (phase == 0 || phase == 2) onMs else offMs + val nextDuration = if (isVisiblePhase(phase)) onMs else offMs nextToggleUptimeMs += nextDuration 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) { - rawOnMs = flashOnMs.coerceIn(50, 2000).toLong() - rawOffMs = flashOffMs.coerceIn(50, 2000).toLong() + val newRawOnMs = flashOnMs.coerceIn(50, 2000).toLong() + val newRawOffMs = flashOffMs.coerceIn(50, 2000).toLong() val frameMs = framePeriodMs().coerceAtLeast(1L) - onMs = snapToFrameMs(rawOnMs, frameMs) - offMs = snapToFrameMs(rawOffMs, frameMs) + val newOnMs = snapToFrameMs(newRawOnMs, 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) { Log.d( @@ -925,7 +934,14 @@ private class SplitFlashRenderView(context: Context) : View(context) { } 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() } + fun setFlashSequence(sequence: List) { + val normalized = sequence.ifEmpty { solutions.tretter.mindmachine.session.defaultFlashSequence() } + if (flashSequence == normalized) return + flashSequence = normalized + if (started) { + phase %= phaseCount() + } + invalidate() + } + override fun onDetachedFromWindow() { handler.removeCallbacks(toggleRunnable) super.onDetachedFromWindow() @@ -981,24 +1007,35 @@ private class SplitFlashRenderView(context: Context) : View(context) { handler.removeCallbacks(toggleRunnable) handler.postAtTime(toggleRunnable, nextToggleUptimeMs) } - - private fun scheduleFromNow() { - val now = SystemClock.uptimeMillis() - nextToggleUptimeMs = now + if (phase == 0 || phase == 2) onMs else offMs - scheduleNext() + private fun colorsForPhase(phase: Int): Pair { + if (!isVisiblePhase(phase)) return Pair(android.graphics.Color.BLACK, android.graphics.Color.BLACK) + if (monochrome) return Pair(android.graphics.Color.WHITE, android.graphics.Color.WHITE) + val row = flashSequence[(phase / 2).mod(flashSequence.size)] + 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 = when (phase) { - 0 -> if (monochrome) Pair(android.graphics.Color.WHITE, android.graphics.Color.WHITE) else Pair(android.graphics.Color.RED, android.graphics.Color.GREEN) - 2 -> if (monochrome) Pair(android.graphics.Color.WHITE, android.graphics.Color.WHITE) else Pair(android.graphics.Color.GREEN, android.graphics.Color.RED) - else -> Pair(android.graphics.Color.BLACK, android.graphics.Color.BLACK) + private fun phaseName(phase: Int): String { + if (!isVisiblePhase(phase)) return "BLACK" + val row = flashSequence[(phase / 2).mod(flashSequence.size)] + return if (row.splitScreen) { + "${row.leftColor.name}_${row.rightColor.name}" + } else { + row.leftColor.name + } } - private fun phaseName(phase: Int): String = when (phase) { - 0 -> "RED_GREEN" - 1 -> "BLACK" - 2 -> "GREEN_RED" - else -> "BLACK" + private fun isVisiblePhase(phase: Int): Boolean = phase % 2 == 0 + + private fun phaseCount(): Int = (flashSequence.size.coerceAtLeast(1)) * 2 + + 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 diff --git a/app/src/main/java/com/mindmachine/mvp/data/UserProgramRepository.kt b/app/src/main/java/com/mindmachine/mvp/data/UserProgramRepository.kt index dbdc52f..b0ffbef 100644 --- a/app/src/main/java/com/mindmachine/mvp/data/UserProgramRepository.kt +++ b/app/src/main/java/com/mindmachine/mvp/data/UserProgramRepository.kt @@ -10,6 +10,9 @@ import solutions.tretter.mindmachine.session.TimelinePoint import solutions.tretter.mindmachine.session.TIMELINE_DEFAULT_GRANULARITY_SEC import solutions.tretter.mindmachine.session.TimelineSelection 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.map import org.json.JSONArray @@ -83,8 +86,10 @@ class UserProgramRepository(private val context: Context) { return TimelineEditorState( durationSec = o.getInt("durationSec"), 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( startSec = o.optInt("selectionStartSec", 0), endSec = o.optInt("selectionEndSec", o.getInt("durationSec")), @@ -115,6 +120,7 @@ class UserProgramRepository(private val context: Context) { val timeline = JSONObject() timeline.put("durationSec", program.timeline.durationSec) timeline.put("curveGranularitySec", program.timeline.curveGranularitySec) + timeline.put("flashSequence", flashSequenceArray(program.timeline.flashSequence)) timeline.put("selectionStartSec", program.timeline.selection.startSec) timeline.put("selectionEndSec", program.timeline.selection.endSec) timeline.put("viewportStartSec", program.timeline.viewport.startSec) @@ -138,6 +144,35 @@ class UserProgramRepository(private val context: Context) { } return arr } + + private fun parseFlashSequence(arr: JSONArray?): List { + 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): 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( diff --git a/app/src/main/java/com/mindmachine/mvp/domain/Models.kt b/app/src/main/java/com/mindmachine/mvp/domain/Models.kt index 6040e0f..dad234d 100644 --- a/app/src/main/java/com/mindmachine/mvp/domain/Models.kt +++ b/app/src/main/java/com/mindmachine/mvp/domain/Models.kt @@ -1,5 +1,8 @@ package solutions.tretter.mindmachine.domain +import solutions.tretter.mindmachine.session.FlashSequenceRow +import solutions.tretter.mindmachine.session.defaultFlashSequence + enum class VisualPattern { FLASH, PULSE } enum class SessionMode { AUDIO_VISUAL, AUDIO_ONLY, VISUAL_ONLY } enum class RuntimeState { IDLE, COUNTDOWN, RUNNING, PAUSED, INTERRUPTED, COMPLETED, STOPPED, ERROR } @@ -17,6 +20,7 @@ data class SessionPreset( val cautionNote: String, val sortOrder: Int, val isUserProgram: Boolean = false, + val flashSequence: List = defaultFlashSequence(), ) data class SessionConfig( @@ -25,6 +29,7 @@ data class SessionConfig( val mode: SessionMode, val visualPatternType: VisualPattern, val flashIntervalMs: Int, + val flashSequence: List = defaultFlashSequence(), val monochromeFlashMode: Boolean = false, val intensityPercent: Int, val carrierFrequencyHz: Float, @@ -97,6 +102,7 @@ fun SessionPreset.toConfig(defaultMode: SessionMode = SessionMode.AUDIO_VISUAL) mode = defaultMode, visualPatternType = visualPatternType, flashIntervalMs = flashIntervalMs, + flashSequence = flashSequence, monochromeFlashMode = false, intensityPercent = intensityPercent, carrierFrequencyHz = carrierFrequencyHz, diff --git a/app/src/main/java/com/mindmachine/mvp/session/DurationSliderCard.kt b/app/src/main/java/com/mindmachine/mvp/session/DurationSliderCard.kt index edbd4ed..5babb81 100644 --- a/app/src/main/java/com/mindmachine/mvp/session/DurationSliderCard.kt +++ b/app/src/main/java/com/mindmachine/mvp/session/DurationSliderCard.kt @@ -18,7 +18,7 @@ import androidx.compose.ui.unit.dp import kotlin.math.roundToInt 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 fun DurationSliderCard( @@ -51,14 +51,15 @@ fun DurationSliderCard( Slider( value = clamped.toFloat(), onValueChange = { - val snapped = (it / 60f).roundToInt().coerceIn(1, 8 * 60) * 60 + val snapped = (it / 60f).roundToInt().coerceIn(1, 60) * 60 onDurationSecChanged(snapped) }, valueRange = MIN_DURATION_SEC.toFloat()..MAX_DURATION_SEC.toFloat(), + steps = 58, ) Text( - "Range: 1 minute to 8 hours", + "Range: 1 minute to 60 minutes", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), ) diff --git a/app/src/main/java/com/mindmachine/mvp/session/FlashSequence.kt b/app/src/main/java/com/mindmachine/mvp/session/FlashSequence.kt new file mode 100644 index 0000000..57a955f --- /dev/null +++ b/app/src/main/java/com/mindmachine/mvp/session/FlashSequence.kt @@ -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 = listOf( + FlashSequenceRow(splitScreen = true, leftColor = FlashPaletteColor.RED, rightColor = FlashPaletteColor.GREEN) +) diff --git a/app/src/main/java/com/mindmachine/mvp/session/FlashSequenceEditor.kt b/app/src/main/java/com/mindmachine/mvp/session/FlashSequenceEditor.kt new file mode 100644 index 0000000..4dce4e7 --- /dev/null +++ b/app/src/main/java/com/mindmachine/mvp/session/FlashSequenceEditor.kt @@ -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, + onSequenceChanged: (List) -> 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) +} diff --git a/app/src/main/java/com/mindmachine/mvp/session/MainViewModel.kt b/app/src/main/java/com/mindmachine/mvp/session/MainViewModel.kt index e1de450..02d8c13 100644 --- a/app/src/main/java/com/mindmachine/mvp/session/MainViewModel.kt +++ b/app/src/main/java/com/mindmachine/mvp/session/MainViewModel.kt @@ -175,7 +175,7 @@ class MainViewModel( val savedSignature = timelineProgramSignature(timeline) it.copy( selectedPreset = preset, - config = preset.toConfig(SessionMode.AUDIO_VISUAL), + config = preset.toConfig(SessionMode.AUDIO_VISUAL).copy(flashSequence = timeline.flashSequence), timeline = timeline, savedProgramName = displayName, savedTimelineSignature = savedSignature, @@ -237,7 +237,7 @@ class MainViewModel( val savedSignature = timelineProgramSignature(timeline) it.copy( selectedPreset = fallback, - config = fallback.toConfig(SessionMode.AUDIO_VISUAL), + config = fallback.toConfig(SessionMode.AUDIO_VISUAL).copy(flashSequence = timeline.flashSequence), timeline = timeline, savedProgramName = fallback.name, 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 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) it.copy( config = it.config.copy(durationSec = clamped), @@ -273,7 +273,10 @@ class MainViewModel( fun updateTimeline(newState: TimelineEditorState) = _ui.update { it.copy( 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), error = null, ) @@ -451,7 +454,10 @@ class MainViewModel( val savedSignature = timelineProgramSignature(timeline) it.copy( 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, savedProgramName = preset.name, savedTimelineSignature = savedSignature, diff --git a/app/src/main/java/com/mindmachine/mvp/session/SessionValidator.kt b/app/src/main/java/com/mindmachine/mvp/session/SessionValidator.kt index c758201..ade8a06 100644 --- a/app/src/main/java/com/mindmachine/mvp/session/SessionValidator.kt +++ b/app/src/main/java/com/mindmachine/mvp/session/SessionValidator.kt @@ -3,7 +3,7 @@ package solutions.tretter.mindmachine.session import solutions.tretter.mindmachine.domain.SessionConfig object SessionValidator { 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." val carrier = config.carrierFrequencyHz if (!carrier.isFinite() || carrier < ParameterRanges.CARRIER_HZ_MIN || carrier > ParameterRanges.CARRIER_HZ_MAX) { diff --git a/app/src/main/java/com/mindmachine/mvp/session/SetupTimelineEditor.kt b/app/src/main/java/com/mindmachine/mvp/session/SetupTimelineEditor.kt index 9b9a530..a299c56 100644 --- a/app/src/main/java/com/mindmachine/mvp/session/SetupTimelineEditor.kt +++ b/app/src/main/java/com/mindmachine/mvp/session/SetupTimelineEditor.kt @@ -27,6 +27,9 @@ fun SetupTimelineEditor( modifier: Modifier = Modifier, ) { 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( modifier = modifier.fillMaxWidth(), @@ -38,7 +41,7 @@ fun SetupTimelineEditor( color = MaterialTheme.colorScheme.onBackground, ) 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, color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.75f), ) @@ -49,22 +52,22 @@ fun SetupTimelineEditor( style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onBackground, ) - var granularitySliderValue by remember(state.curveGranularitySec) { + var granularitySliderValue by remember(state.curveGranularitySec, state.durationSec) { mutableFloatStateOf(state.curveGranularitySec.toFloat()) } Slider( - value = granularitySliderValue, + value = granularitySliderValue.coerceIn(sliderMin, sliderMax), onValueChange = { granularitySliderValue = it }, onValueChangeFinished = { - val snapped = TimelineEditorState.normalizeGranularitySec(granularitySliderValue.roundToInt()) + val snapped = TimelineEditorState.normalizeGranularitySec(granularitySliderValue.roundToInt(), local.durationSec) if (snapped != local.curveGranularitySec) { local = local.setCurveGranularitySec(snapped).also(onStateChanged) onCurveGranularityChanged(snapped) } granularitySliderValue = local.curveGranularitySec.toFloat() }, - valueRange = TIMELINE_MIN_GRANULARITY_SEC.toFloat()..TIMELINE_MAX_GRANULARITY_SEC.toFloat(), - steps = ((TIMELINE_MAX_GRANULARITY_SEC - TIMELINE_MIN_GRANULARITY_SEC) / 5) - 1, + valueRange = sliderMin..sliderMax, + steps = max(0, allowedGranularity.size - 2), ) TimelineGraph( diff --git a/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorState.kt b/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorState.kt index 965046b..2563d91 100644 --- a/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorState.kt +++ b/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorState.kt @@ -1,17 +1,19 @@ package solutions.tretter.mindmachine.session +import kotlin.math.abs import kotlin.math.max import kotlin.math.min private const val MIN_DURATION_SEC = 60 -private const val MAX_DURATION_SEC = 8 * 60 * 60 -const val TIMELINE_MIN_GRANULARITY_SEC = 5 -const val TIMELINE_MAX_GRANULARITY_SEC = 300 +private const val MAX_DURATION_SEC = 60 * 60 +const val TIMELINE_MIN_GRANULARITY_SEC = 30 +const val TIMELINE_MAX_GRANULARITY_SEC = 30 * 60 const val TIMELINE_DEFAULT_GRANULARITY_SEC = 60 data class TimelineEditorState( val durationSec: Int, val curveGranularitySec: Int, + val flashSequence: List, val selection: TimelineSelection, val viewport: TimelineViewport, val activeCurve: ActiveCurve, @@ -32,11 +34,12 @@ data class TimelineEditorState( companion object { fun default(durationSec: Int = 20 * 60): TimelineEditorState { 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) return TimelineEditorState( durationSec = dur, curveGranularitySec = granularitySec, + flashSequence = defaultFlashSequence(), selection = TimelineSelection(0, dur), viewport = TimelineViewport( 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 - fun normalizeGranularitySec(granularitySec: Int): Int = - ((granularitySec.coerceIn(TIMELINE_MIN_GRANULARITY_SEC, TIMELINE_MAX_GRANULARITY_SEC) + 2) / 5) * 5 + fun allowedGranularitySteps(durationSec: Int): List { + 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) { @@ -76,35 +89,45 @@ data class TimelineEditorState( } } + fun withFlashSequence(sequence: List): TimelineEditorState = + copy(flashSequence = sequence.ifEmpty { defaultFlashSequence() }) + 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 + val normalizedGranularity = normalizeGranularitySec(curveGranularitySec, newDur) return copy( durationSec = newDur, + curveGranularitySec = normalizedGranularity, selection = selection.copy(endSec = max(selection.endSec, newDur)), - visualLeft = visualLeft.extendTo(newDur, curveGranularitySec), - visualRight = visualRight.extendTo(newDur, curveGranularitySec), - audioLeft = audioLeft.extendTo(newDur, curveGranularitySec), - audioRight = audioRight.extendTo(newDur, curveGranularitySec), + visualLeft = visualLeft.extendTo(newDur, normalizedGranularity), + visualRight = visualRight.extendTo(newDur, normalizedGranularity), + audioLeft = audioLeft.extendTo(newDur, normalizedGranularity), + audioRight = audioRight.extendTo(newDur, normalizedGranularity), ) } 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 + val normalizedGranularity = normalizeGranularitySec(curveGranularitySec, clamped) 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 { val newViewport = viewport.copy(startSec = viewport.startSec.coerceAtMost(clamped.toFloat())) copy( durationSec = clamped, + curveGranularitySec = normalizedGranularity, selection = TimelineSelection(0, clamped), - visualLeft = visualLeft.trimTo(clamped, curveGranularitySec), - visualRight = visualRight.trimTo(clamped, curveGranularitySec), - audioLeft = audioLeft.trimTo(clamped, curveGranularitySec), - audioRight = audioRight.trimTo(clamped, curveGranularitySec), + visualLeft = visualLeft.trimTo(clamped, normalizedGranularity), + visualRight = visualRight.trimTo(clamped, normalizedGranularity), + audioLeft = audioLeft.trimTo(clamped, normalizedGranularity), + audioRight = audioRight.trimTo(clamped, normalizedGranularity), viewport = newViewport, playheadSec = playheadSec.coerceIn(0f, clamped.toFloat()), ) @@ -112,7 +135,7 @@ data class TimelineEditorState( } fun setCurveGranularitySec(newGranularitySec: Int): TimelineEditorState { - val normalized = normalizeGranularitySec(newGranularitySec) + val normalized = normalizeGranularitySec(newGranularitySec, durationSec) if (normalized == curveGranularitySec) return this return copy( @@ -191,7 +214,7 @@ data class TimelineCurve( companion object { fun constant(value01: Float, durationSec: Int, granularitySec: Int = TIMELINE_DEFAULT_GRANULARITY_SEC): TimelineCurve { val v = value01.coerceIn(0f, 1f) - val stepSec = TimelineEditorState.normalizeGranularitySec(granularitySec) + val stepSec = TimelineEditorState.normalizeGranularitySec(granularitySec, durationSec) return TimelineCurve( points = (0..durationSec step stepSec).map { TimelinePoint(it, v) } ) @@ -214,8 +237,8 @@ data class TimelineCurve( } fun ensureTimelinePoints(durationSec: Int, granularitySec: Int): TimelineCurve { - val snappedDuration = ((durationSec.coerceIn(MIN_DURATION_SEC, MAX_DURATION_SEC) + 30) / 60) * 60 - val stepSec = TimelineEditorState.normalizeGranularitySec(granularitySec) + val snappedDuration = TimelineEditorState.snapDuration(durationSec) + val stepSec = TimelineEditorState.normalizeGranularitySec(granularitySec, snappedDuration) val normalized = (0..snappedDuration step stepSec).map { tSec -> TimelinePoint(tSec, valueAt(tSec.toFloat())) } @@ -236,7 +259,6 @@ data class TimelineCurve( if (index !in points.indices) return this val v = (1f - (newY / heightPx)).coerceIn(0f, 1f) val updated = points.toMutableList() - // Update the dragged point and all points after it (same or later time) for (i in index until updated.size) { updated[i] = updated[i].copy(value01 = v) } diff --git a/app/src/main/java/com/mindmachine/mvp/session/TimelineModel.kt b/app/src/main/java/com/mindmachine/mvp/session/TimelineModel.kt index d23c7cb..aee816d 100644 --- a/app/src/main/java/com/mindmachine/mvp/session/TimelineModel.kt +++ b/app/src/main/java/com/mindmachine/mvp/session/TimelineModel.kt @@ -84,6 +84,7 @@ data class TimelineModel( mode = mode, visualPatternType = preset.visualPatternType, flashIntervalMs = flashIntervalMs, + flashSequence = preset.flashSequence, monochromeFlashMode = false, intensityPercent = preset.intensityPercent, carrierFrequencyHz = carrier, diff --git a/app/src/main/java/com/mindmachine/mvp/session/TimelineProgramFactory.kt b/app/src/main/java/com/mindmachine/mvp/session/TimelineProgramFactory.kt index f82dc47..56f9ed9 100644 --- a/app/src/main/java/com/mindmachine/mvp/session/TimelineProgramFactory.kt +++ b/app/src/main/java/com/mindmachine/mvp/session/TimelineProgramFactory.kt @@ -48,6 +48,7 @@ object TimelineProgramFactory { cautionNote = "Stop if discomfort occurs.", sortOrder = sortOrder, isUserProgram = isUserProgram, + flashSequence = timeline.flashSequence, ) } @@ -74,6 +75,7 @@ object TimelineProgramFactory { return TimelineEditorState( durationSec = dur, curveGranularitySec = TIMELINE_DEFAULT_GRANULARITY_SEC, + flashSequence = preset.flashSequence, selection = TimelineSelection(0, dur), viewport = TimelineViewport(startSec = 0f, secondsPerScreen = minOf(10f * 60f, dur.toFloat())), activeCurve = TimelineEditorState.ActiveCurve.VISUAL_LEFT, @@ -124,6 +126,7 @@ object TimelineProgramFactory { return TimelineEditorState( durationSec = durationSec, curveGranularitySec = TIMELINE_DEFAULT_GRANULARITY_SEC, + flashSequence = defaultFlashSequence(), selection = TimelineSelection(0, durationSec), viewport = TimelineViewport(startSec = 0f, secondsPerScreen = minOf(10f * 60f, durationSec.toFloat())), activeCurve = TimelineEditorState.ActiveCurve.VISUAL_LEFT,