Auto commit Sat Mar 21 10:01:01 PM CDT 2026

This commit is contained in:
Tretzi
2026-03-21 22:01:01 -05:00
parent 93607c0d6a
commit 5c48387dc2
6 changed files with 495 additions and 153 deletions

View File

@@ -44,6 +44,7 @@ import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -77,6 +78,7 @@ import androidx.navigation.compose.rememberNavController
import com.mindmachine.mvp.audio.BinauralAudioEngine import com.mindmachine.mvp.audio.BinauralAudioEngine
import com.mindmachine.mvp.audio.HeadsetMonitor import com.mindmachine.mvp.audio.HeadsetMonitor
import com.mindmachine.mvp.data.SettingsRepository import com.mindmachine.mvp.data.SettingsRepository
import com.mindmachine.mvp.data.UserProgramRepository
import com.mindmachine.mvp.domain.CountdownPreference import com.mindmachine.mvp.domain.CountdownPreference
import com.mindmachine.mvp.domain.RuntimeState import com.mindmachine.mvp.domain.RuntimeState
import com.mindmachine.mvp.domain.SessionMode import com.mindmachine.mvp.domain.SessionMode
@@ -109,7 +111,12 @@ private val MindMachineColors = darkColorScheme(
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val vm: MainViewModel by viewModels { private val vm: MainViewModel by viewModels {
MainViewModel.Factory(SettingsRepository(applicationContext), HeadsetMonitor(applicationContext), BinauralAudioEngine()) MainViewModel.Factory(
SettingsRepository(applicationContext),
UserProgramRepository(applicationContext),
HeadsetMonitor(applicationContext),
BinauralAudioEngine(),
)
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -158,6 +165,10 @@ fun App(vm: MainViewModel = viewModel()) {
composable("home") { composable("home") {
Column(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
TopAppBar(title = { Text("MindMachine") }, actions = { TopAppBar(title = { Text("MindMachine") }, actions = {
TextButton(onClick = {
vm.createNewProgramDraft()
nav.navigate("setup")
}) { Text("Add") }
TextButton(onClick = { nav.navigate("settings") }) { Text("Settings") } TextButton(onClick = { nav.navigate("settings") }) { Text("Settings") }
}) })
LazyColumn(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { LazyColumn(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -175,10 +186,19 @@ fun App(vm: MainViewModel = viewModel()) {
nav.navigate("setup") nav.navigate("setup")
}.padding(4.dp) }.padding(4.dp)
) { ) {
Column(Modifier.padding(12.dp)) { Row(
Text(p.name, style = MaterialTheme.typography.titleMedium) modifier = Modifier.fillMaxWidth().padding(12.dp),
Text(p.description) horizontalArrangement = Arrangement.SpaceBetween,
Text("${p.defaultDurationSec / 60} min • ${p.visualPatternType} • binaural ${p.binauralDifferenceHz}") verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(p.name, style = MaterialTheme.typography.titleMedium)
Text(p.description)
Text("${p.defaultDurationSec / 60} min • ${p.visualPatternType} • binaural ${p.binauralDifferenceHz}")
}
if (p.isUserProgram) {
TextButton(onClick = { vm.deleteProgram(p.id) }) { Text("Delete") }
}
} }
} }
} }
@@ -270,6 +290,8 @@ fun SetupScreen(
.safeDrawingPadding() .safeDrawingPadding()
.padding(horizontal = 16.dp, vertical = 12.dp) .padding(horizontal = 16.dp, vertical = 12.dp)
var programName by remember(ui.selectedPreset.id) { mutableStateOf(ui.selectedPreset.name) }
Column( Column(
modifier = containerModifier, modifier = containerModifier,
verticalArrangement = Arrangement.spacedBy(14.dp), verticalArrangement = Arrangement.spacedBy(14.dp),
@@ -280,6 +302,13 @@ fun SetupScreen(
color = MaterialTheme.colorScheme.onBackground, color = MaterialTheme.colorScheme.onBackground,
) )
TextField(
value = programName,
onValueChange = { programName = it },
label = { Text("Program name") },
modifier = Modifier.fillMaxWidth(),
)
DurationSliderCard( DurationSliderCard(
durationSec = ui.timeline.durationSec, durationSec = ui.timeline.durationSec,
onDurationSecChanged = { vm.setDurationSec(it) }, onDurationSecChanged = { vm.setDurationSec(it) },
@@ -292,8 +321,13 @@ fun SetupScreen(
) )
TextButton(onClick = onHolder) { Text("Holder Guidance") } TextButton(onClick = onHolder) { Text("Holder Guidance") }
if (ui.error != null) Text(ui.error!!, color = MaterialTheme.colorScheme.error) Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
Button(onClick = onStart, modifier = Modifier.fillMaxWidth().height(52.dp)) { Text("Start") } OutlinedButton(onClick = { vm.saveCurrentProgram(programName) }, modifier = Modifier.weight(1f).height(52.dp)) {
Text("Save")
}
Button(onClick = onStart, modifier = Modifier.weight(1f).height(52.dp)) { Text("Start") }
}
if (ui.error != null) Text(ui.error!!, color = if (ui.error!!.startsWith("Saved")) Color(0xFF72E39A) else MaterialTheme.colorScheme.error)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
} }
} }

View File

@@ -0,0 +1,144 @@
package com.mindmachine.mvp.data
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.mindmachine.mvp.session.TimelineCurve
import com.mindmachine.mvp.session.TimelineEditorState
import com.mindmachine.mvp.session.TimelinePoint
import com.mindmachine.mvp.session.TimelineSelection
import com.mindmachine.mvp.session.TimelineViewport
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.json.JSONArray
import org.json.JSONObject
private val Context.programDataStore by preferencesDataStore("mindmachine_programs")
class UserProgramRepository(private val context: Context) {
private object Keys {
val programsJson = stringPreferencesKey("user_programs_json")
}
val userPrograms: Flow<List<UserProgramEntity>> = context.programDataStore.data.map { prefs ->
parsePrograms(prefs[Keys.programsJson])
}
suspend fun upsert(program: UserProgramEntity) {
context.programDataStore.edit { prefs ->
val current = parsePrograms(prefs[Keys.programsJson]).toMutableList()
val idx = current.indexOfFirst { it.id == program.id }
if (idx >= 0) {
current[idx] = program
} else {
current += program
}
prefs[Keys.programsJson] = toJson(current)
}
}
suspend fun delete(id: String) {
context.programDataStore.edit { prefs ->
val current = parsePrograms(prefs[Keys.programsJson]).filterNot { it.id == id }
prefs[Keys.programsJson] = toJson(current)
}
}
private fun parsePrograms(raw: String?): List<UserProgramEntity> {
if (raw.isNullOrBlank()) return emptyList()
return runCatching {
val arr = JSONArray(raw)
buildList {
for (i in 0 until arr.length()) {
val o = arr.getJSONObject(i)
add(
UserProgramEntity(
id = o.getString("id"),
name = o.getString("name"),
description = o.optString("description", "Custom program"),
timeline = parseTimeline(o.getJSONObject("timeline")),
sortOrder = o.optInt("sortOrder", i + 1000),
)
)
}
}
}.getOrDefault(emptyList())
}
private fun parseTimeline(o: JSONObject): TimelineEditorState {
fun parseCurve(name: String): TimelineCurve {
val points = o.getJSONArray(name)
return TimelineCurve(
points = buildList {
for (i in 0 until points.length()) {
val p = points.getJSONObject(i)
add(TimelinePoint(tSec = p.getInt("t"), value01 = p.getDouble("v").toFloat()))
}
}
)
}
return TimelineEditorState(
durationSec = o.getInt("durationSec"),
selection = TimelineSelection(
startSec = o.optInt("selectionStartSec", 0),
endSec = o.optInt("selectionEndSec", o.getInt("durationSec")),
),
viewport = TimelineViewport(
startSec = o.optDouble("viewportStartSec", 0.0).toFloat(),
secondsPerScreen = o.optDouble("viewportSecondsPerScreen", 600.0).toFloat(),
),
activeCurve = TimelineEditorState.ActiveCurve.valueOf(o.optString("activeCurve", TimelineEditorState.ActiveCurve.VISUAL_LEFT.name)),
visualLeft = parseCurve("visualLeft"),
visualRight = parseCurve("visualRight"),
audioLeft = parseCurve("audioLeft"),
audioRight = parseCurve("audioRight"),
isPlaying = false,
playheadSec = 0f,
).setDurationSec(o.getInt("durationSec"))
}
private fun toJson(programs: List<UserProgramEntity>): String {
val arr = JSONArray()
programs.sortedBy { it.sortOrder }.forEach { program ->
val o = JSONObject()
o.put("id", program.id)
o.put("name", program.name)
o.put("description", program.description)
o.put("sortOrder", program.sortOrder)
val timeline = JSONObject()
timeline.put("durationSec", program.timeline.durationSec)
timeline.put("selectionStartSec", program.timeline.selection.startSec)
timeline.put("selectionEndSec", program.timeline.selection.endSec)
timeline.put("viewportStartSec", program.timeline.viewport.startSec)
timeline.put("viewportSecondsPerScreen", program.timeline.viewport.secondsPerScreen)
timeline.put("activeCurve", program.timeline.activeCurve.name)
timeline.put("visualLeft", pointsArray(program.timeline.visualLeft))
timeline.put("visualRight", pointsArray(program.timeline.visualRight))
timeline.put("audioLeft", pointsArray(program.timeline.audioLeft))
timeline.put("audioRight", pointsArray(program.timeline.audioRight))
o.put("timeline", timeline)
arr.put(o)
}
return arr.toString()
}
private fun pointsArray(curve: TimelineCurve): JSONArray {
val arr = JSONArray()
curve.points.forEach { p ->
arr.put(JSONObject().put("t", p.tSec).put("v", p.value01.toDouble()))
}
return arr
}
}
data class UserProgramEntity(
val id: String,
val name: String,
val description: String,
val timeline: TimelineEditorState,
val sortOrder: Int,
)

View File

@@ -17,6 +17,7 @@ data class SessionPreset(
val binauralDifferenceHz: Float, val binauralDifferenceHz: Float,
val cautionNote: String, val cautionNote: String,
val sortOrder: Int, val sortOrder: Int,
val isUserProgram: Boolean = false,
) )
data class SessionConfig( data class SessionConfig(

View File

@@ -6,6 +6,8 @@ import androidx.lifecycle.viewModelScope
import com.mindmachine.mvp.audio.BinauralAudioEngine import com.mindmachine.mvp.audio.BinauralAudioEngine
import com.mindmachine.mvp.audio.HeadsetMonitor import com.mindmachine.mvp.audio.HeadsetMonitor
import com.mindmachine.mvp.data.SettingsRepository import com.mindmachine.mvp.data.SettingsRepository
import com.mindmachine.mvp.data.UserProgramEntity
import com.mindmachine.mvp.data.UserProgramRepository
import com.mindmachine.mvp.domain.AppSettings import com.mindmachine.mvp.domain.AppSettings
import com.mindmachine.mvp.domain.CountdownPreference import com.mindmachine.mvp.domain.CountdownPreference
import com.mindmachine.mvp.domain.Presets import com.mindmachine.mvp.domain.Presets
@@ -19,8 +21,10 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.UUID
const val SAFETY_VERSION = 1 const val SAFETY_VERSION = 1
@@ -46,6 +50,7 @@ data class UiState(
class MainViewModel( class MainViewModel(
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
private val userProgramRepository: UserProgramRepository,
private val headsetMonitor: HeadsetMonitor, private val headsetMonitor: HeadsetMonitor,
private val audioEngine: BinauralAudioEngine, private val audioEngine: BinauralAudioEngine,
) : ViewModel() { ) : ViewModel() {
@@ -54,34 +59,136 @@ class MainViewModel(
private var runJob: Job? = null private var runJob: Job? = null
private var elapsedBeforePauseSec: Float = 0f private var elapsedBeforePauseSec: Float = 0f
private var cachedUserPrograms: List<UserProgramEntity> = emptyList()
init { init {
viewModelScope.launch { viewModelScope.launch {
settingsRepository.settings.collect { s -> combine(settingsRepository.settings, userProgramRepository.userPrograms) { settings, userPrograms ->
settings to userPrograms
}.collect { (settings, userPrograms) ->
cachedUserPrograms = userPrograms
val userPresets = userPrograms.map {
TimelineProgramFactory.toPreset(
id = it.id,
name = it.name,
description = it.description,
timeline = it.timeline,
sortOrder = it.sortOrder,
isUserProgram = true,
)
}
val allPresets = (Presets.builtIn + userPresets).sortedBy { it.sortOrder }
_ui.update { current -> _ui.update { current ->
val preset = current.presets.find { it.id == (s.lastPresetId ?: current.selectedPreset.id) } ?: current.selectedPreset val selectedId = settings.lastPresetId ?: current.selectedPreset.id
current.copy(settings = s, selectedPreset = preset, config = current.config.copy(mode = s.defaultModePreference)) val selectedPreset = allPresets.find { it.id == selectedId } ?: allPresets.first()
val sameSelection = selectedPreset.id == current.selectedPreset.id
current.copy(
settings = settings,
presets = allPresets,
selectedPreset = selectedPreset,
config = current.config.copy(mode = settings.defaultModePreference),
timeline = if (sameSelection) current.timeline else timelineForPreset(selectedPreset, userPrograms),
)
} }
} }
} }
} }
fun acknowledgeSafety() = viewModelScope.launch { settingsRepository.acknowledgeSafety(SAFETY_VERSION) } fun acknowledgeSafety() = viewModelScope.launch { settingsRepository.acknowledgeSafety(SAFETY_VERSION) }
fun choosePreset(id: String) = viewModelScope.launch { fun choosePreset(id: String) = viewModelScope.launch {
val preset = _ui.value.presets.first { it.id == id } val state = _ui.value
val preset = state.presets.first { it.id == id }
settingsRepository.updateLastPreset(id) settingsRepository.updateLastPreset(id)
_ui.update { _ui.update {
val cfg = preset.toConfig(it.settings.defaultModePreference) val cfg = preset.toConfig(it.settings.defaultModePreference)
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, 8 * 60 * 60)),
timeline = TimelineProgramFactory.fromPreset(preset), timeline = timelineForPreset(preset, cachedUserPrograms),
error = null, error = null,
) )
} }
elapsedBeforePauseSec = 0f elapsedBeforePauseSec = 0f
} }
fun createNewProgramDraft() {
val draftId = "user-${UUID.randomUUID()}"
val displayName = nextUntitledName()
val timeline = TimelineEditorState.default(20 * 60)
val preset = TimelineProgramFactory.toPreset(
id = draftId,
name = displayName,
description = "Custom program",
timeline = timeline,
sortOrder = nextSortOrder(),
isUserProgram = true,
)
_ui.update {
it.copy(
selectedPreset = preset,
config = preset.toConfig(it.settings.defaultModePreference),
timeline = timeline,
error = null,
)
}
}
fun saveCurrentProgram(name: String) = viewModelScope.launch {
val trimmed = name.trim()
if (trimmed.isBlank()) {
_ui.update { it.copy(error = "Enter a name before saving.") }
return@launch
}
val state = _ui.value
val id = if (state.selectedPreset.isUserProgram) state.selectedPreset.id else "user-${UUID.randomUUID()}"
val sortOrder = cachedUserPrograms.find { it.id == id }?.sortOrder ?: nextSortOrder()
userProgramRepository.upsert(
UserProgramEntity(
id = id,
name = trimmed,
description = "Custom program",
timeline = state.timeline,
sortOrder = sortOrder,
)
)
settingsRepository.updateLastPreset(id)
_ui.update {
val savedPreset = TimelineProgramFactory.toPreset(
id = id,
name = trimmed,
description = "Custom program",
timeline = it.timeline,
sortOrder = sortOrder,
isUserProgram = true,
)
it.copy(
selectedPreset = savedPreset,
error = "Saved \"$trimmed\"",
)
}
}
fun deleteProgram(id: String) = viewModelScope.launch {
userProgramRepository.delete(id)
if (_ui.value.selectedPreset.id == id) {
val fallback = Presets.builtIn.first()
settingsRepository.updateLastPreset(fallback.id)
_ui.update {
it.copy(
selectedPreset = fallback,
config = fallback.toConfig(it.settings.defaultModePreference),
timeline = TimelineProgramFactory.fromPreset(fallback),
error = null,
)
}
}
}
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 {
@@ -101,7 +208,6 @@ class MainViewModel(
) )
} }
// Legacy fixed-parameter setters kept for now but no longer used by Setup.
fun setFlashIntervalMs(value: Int) = _ui.update { it.copy(config = it.config.copy(flashIntervalMs = value.coerceIn(50, 2000)), error = null) } fun setFlashIntervalMs(value: Int) = _ui.update { it.copy(config = it.config.copy(flashIntervalMs = value.coerceIn(50, 2000)), error = null) }
fun setCarrier(value: Float) = _ui.update { it.copy(config = it.config.copy(carrierFrequencyHz = value.coerceIn(80f, 400f)), error = null) } fun setCarrier(value: Float) = _ui.update { it.copy(config = it.config.copy(carrierFrequencyHz = value.coerceIn(80f, 400f)), error = null) }
fun setDifference(value: Float) = _ui.update { it.copy(config = it.config.copy(binauralDifferenceHz = value.coerceIn(0.5f, 20f)), error = null) } fun setDifference(value: Float) = _ui.update { it.copy(config = it.config.copy(binauralDifferenceHz = value.coerceIn(0.5f, 20f)), error = null) }
@@ -252,11 +358,29 @@ class MainViewModel(
return (total - state.remainingSec).coerceIn(0f, total) return (total - state.remainingSec).coerceIn(0f, total)
} }
private fun timelineForPreset(preset: SessionPreset, userPrograms: List<UserProgramEntity>): TimelineEditorState {
return userPrograms.firstOrNull { it.id == preset.id }?.timeline ?: TimelineProgramFactory.fromPreset(preset)
}
private fun nextSortOrder(): Int = (cachedUserPrograms.maxOfOrNull { it.sortOrder } ?: 999) + 1
private fun nextUntitledName(): String {
val names = cachedUserPrograms.map { it.name.lowercase() }.toSet()
var i = 1
while (true) {
val candidate = "Custom $i"
if (candidate.lowercase() !in names) return candidate
i++
}
}
class Factory( class Factory(
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
private val userProgramRepository: UserProgramRepository,
private val headsetMonitor: HeadsetMonitor, private val headsetMonitor: HeadsetMonitor,
private val audioEngine: BinauralAudioEngine, private val audioEngine: BinauralAudioEngine,
) : ViewModelProvider.Factory { ) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T = MainViewModel(settingsRepository, headsetMonitor, audioEngine) as T override fun <T : ViewModel> create(modelClass: Class<T>): T =
MainViewModel(settingsRepository, userProgramRepository, headsetMonitor, audioEngine) as T
} }
} }

View File

@@ -19,7 +19,6 @@ 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.layout.weight
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -321,169 +320,167 @@ internal fun TimelineGraph(
var curveRight = currentCurveRight var curveRight = currentCurveRight
val w = size.width.toFloat() val w = size.width.toFloat()
val h = size.height.toFloat() val h = size.height.toFloat()
val startX = viewport.timeToX(selection.startSec, w)
val endX = viewport.timeToX(selection.endSec, w)
val hitPx = 32f val hitPx = 32f
var activePointerId = down.id var activePointerId = down.id
var lastPos = down.position var lastPos = down.position
var isTransform = false var isTransform = false
var moved = false var moved = false
val hits = buildList { val startX = viewport.timeToX(selection.startSec, w)
curveLeft.hitTestPoint(down.position, viewport, w, h)?.let { idx -> val endX = viewport.timeToX(selection.endSec, w)
val point = curveLeft.points[idx] val hits = buildList {
val x = viewport.timeToX(point.tSec, w) curveLeft.hitTestPoint(down.position, viewport, w, h)?.let { idx ->
val y = valueToY(point.value01, h) val point = curveLeft.points[idx]
val dx = down.position.x - x val x = viewport.timeToX(point.tSec, w)
val dy = down.position.y - y val y = valueToY(point.value01, h)
add(Triple(leftId, idx, dx * dx + dy * dy)) val dx = down.position.x - x
val dy = down.position.y - y
add(Triple(leftId, idx, dx * dx + dy * dy))
}
curveRight.hitTestPoint(down.position, viewport, w, h)?.let { idx ->
val point = curveRight.points[idx]
val x = viewport.timeToX(point.tSec, w)
val y = valueToY(point.value01, h)
val dx = down.position.x - x
val dy = down.position.y - y
add(Triple(rightId, idx, dx * dx + dy * dy))
}
} }
curveRight.hitTestPoint(down.position, viewport, w, h)?.let { idx ->
val point = curveRight.points[idx]
val x = viewport.timeToX(point.tSec, w)
val y = valueToY(point.value01, h)
val dx = down.position.x - x
val dy = down.position.y - y
add(Triple(rightId, idx, dx * dx + dy * dy))
}
}
val nearestHit = hits.minByOrNull { it.third } val nearestHit = hits.minByOrNull { it.third }
if (nearestHit != null) { if (nearestHit != null) {
draggingCurveId = nearestHit.first draggingCurveId = nearestHit.first
draggingPointIndex = nearestHit.second draggingPointIndex = nearestHit.second
dragMode = HandleDrag.NONE dragMode = HandleDrag.NONE
currentOnTapSideSelect(if (nearestHit.first == leftId) Side.LEFT else Side.RIGHT) currentOnTapSideSelect(if (nearestHit.first == leftId) Side.LEFT else Side.RIGHT)
} else { } else {
draggingPointIndex = null
draggingCurveId = null
dragMode = when {
abs(down.position.x - startX) <= hitPx -> HandleDrag.START
abs(down.position.x - endX) <= hitPx -> HandleDrag.END
else -> HandleDrag.NONE
}
}
while (true) {
val event = awaitPointerEvent(pass = PointerEventPass.Initial)
val pressed = event.changes.filter { it.pressed }
if (pressed.isEmpty()) break
if (pressed.size >= 2) {
isTransform = true
draggingPointIndex = null draggingPointIndex = null
draggingCurveId = null draggingCurveId = null
dragMode = HandleDrag.NONE dragMode = when {
} else if (pressed.size == 1 && isTransform) { abs(down.position.x - startX) <= hitPx -> HandleDrag.START
// Single finger after multi-finger: treat as single-finger drag abs(down.position.x - endX) <= hitPx -> HandleDrag.END
isTransform = false else -> HandleDrag.NONE
}
} }
if (isTransform) { while (true) {
val zoom = event.calculateZoom() val event = awaitPointerEvent(pass = PointerEventPass.Initial)
val pan = event.calculatePan() val pressed = event.changes.filter { it.pressed }
val centroid = event.calculateCentroid(useCurrent = true) if (pressed.isEmpty()) break
if (zoom != 1f || pan.x != 0f || pan.y != 0f) {
moved = true if (pressed.size >= 2) {
val newViewport = viewport.applyZoomPan( isTransform = true
zoomChange = zoom, draggingPointIndex = null
panPx = pan.x, draggingCurveId = null
widthPx = size.width.toFloat(), dragMode = HandleDrag.NONE
centroidPx = centroid.x, } else if (pressed.size == 1 && isTransform) {
) isTransform = false
viewport = newViewport lastPos = pressed.first().position
currentOnViewportChanged(newViewport)
} }
event.changes.forEach { it.consume() }
continue
}
val primary = pressed.firstOrNull { it.id == activePointerId } ?: pressed.first() if (isTransform) {
activePointerId = primary.id val zoom = event.calculateZoom()
val delta = primary.position - lastPos val pan = event.calculatePan()
lastPos = primary.position val centroid = event.calculateCentroid(useCurrent = true)
if (zoom != 1f || pan.x != 0f || pan.y != 0f) {
if (delta.getDistanceSquared() > 0.5f) moved = true moved = true
val pointIndex = draggingPointIndex
val curveId = draggingCurveId
if (pointIndex != null && curveId != null) {
val targetCurve = if (curveId == leftId) curveLeft else curveRight
val updated = targetCurve.movePointVerticalWithPropagation(pointIndex, primary.position.y, h)
if (curveId == leftId) curveLeft = updated else curveRight = updated
currentOnUpdateCurve(curveId, updated)
onCurveEditedHaptic()
primary.consume()
continue
}
val t = viewport.xToTimeSec(primary.position.x, w).toInt().coerceAtLeast(0)
when (dragMode) {
HandleDrag.START -> {
val newSelection = TimelineSelection(startSec = min(t, selection.endSec - 60), endSec = selection.endSec)
selection = newSelection
currentOnSelectionChanged(newSelection)
primary.consume()
}
HandleDrag.END -> {
val newSelection = TimelineSelection(startSec = selection.startSec, endSec = max(t, selection.startSec + 60))
selection = newSelection
currentOnSelectionChanged(newSelection)
primary.consume()
}
HandleDrag.NONE -> {
if (delta.x != 0f) {
val newViewport = viewport.applyZoomPan( val newViewport = viewport.applyZoomPan(
zoomChange = 1f, zoomChange = zoom,
panPx = delta.x, panPx = pan.x,
widthPx = w, widthPx = w,
centroidPx = w / 2f, centroidPx = centroid.x,
) )
viewport = newViewport viewport = newViewport
currentOnViewportChanged(newViewport) currentOnViewportChanged(newViewport)
}
event.changes.forEach { it.consume() }
continue
}
val primary = pressed.firstOrNull { it.id == activePointerId } ?: pressed.first()
activePointerId = primary.id
val delta = primary.position - lastPos
lastPos = primary.position
if (delta.getDistanceSquared() > 0.5f) moved = true
val pointIndex = draggingPointIndex
val curveId = draggingCurveId
if (pointIndex != null && curveId != null) {
val targetCurve = if (curveId == leftId) curveLeft else curveRight
val updated = targetCurve.movePointVerticalWithPropagation(pointIndex, primary.position.y, h)
if (curveId == leftId) curveLeft = updated else curveRight = updated
currentOnUpdateCurve(curveId, updated)
onCurveEditedHaptic()
primary.consume()
continue
}
val t = viewport.xToTimeSec(primary.position.x, w).toInt().coerceAtLeast(0)
when (dragMode) {
HandleDrag.START -> {
val newSelection = TimelineSelection(startSec = min(t, selection.endSec - 60), endSec = selection.endSec)
selection = newSelection
currentOnSelectionChanged(newSelection)
primary.consume() primary.consume()
} }
HandleDrag.END -> {
val newSelection = TimelineSelection(startSec = selection.startSec, endSec = max(t, selection.startSec + 60))
selection = newSelection
currentOnSelectionChanged(newSelection)
primary.consume()
}
HandleDrag.NONE -> {
if (delta.x != 0f) {
val newViewport = viewport.applyZoomPan(
zoomChange = 1f,
panPx = delta.x,
widthPx = w,
centroidPx = w / 2f,
)
viewport = newViewport
currentOnViewportChanged(newViewport)
primary.consume()
}
}
} }
} }
}
if (!moved && !isTransform) { if (!moved && !isTransform) {
val side = if (down.position.x < size.width / 2f) Side.LEFT else Side.RIGHT val side = if (down.position.x < size.width / 2f) Side.LEFT else Side.RIGHT
currentOnTapSideSelect(side) currentOnTapSideSelect(side)
}
draggingPointIndex = null
draggingCurveId = null
dragMode = HandleDrag.NONE
} }
draggingPointIndex = null
draggingCurveId = null
dragMode = HandleDrag.NONE
} }
} }
) {
Canvas(modifier = Modifier.fillMaxSize()) {
drawGrid()
val startX = viewport.timeToX(selection.startSec, size.width)
val endX = viewport.timeToX(selection.endSec, size.width)
drawRect(
color = Color.White.copy(alpha = 0.06f),
topLeft = Offset(min(startX, endX), 0f),
size = androidx.compose.ui.geometry.Size(abs(endX - startX), size.height)
)
drawLine(Color.White.copy(alpha = 0.35f), Offset(startX, 0f), Offset(startX, size.height), strokeWidth = 3f)
drawLine(Color.White.copy(alpha = 0.35f), Offset(endX, 0f), Offset(endX, size.height), strokeWidth = 3f)
val handleColor = Color.White.copy(alpha = 0.9f)
drawCircle(handleColor, radius = 10f, center = Offset(startX, size.height - 14f))
drawCircle(handleColor, radius = 10f, center = Offset(endX, size.height - 14f))
val playX = viewport.timeToX(playheadSec.toInt(), size.width)
drawLine(Color(0xFFFFFFFF).copy(alpha = 0.6f), Offset(playX, 0f), Offset(playX, size.height), strokeWidth = 2f)
drawCurve(curveLeft, selected = selectedLeft, color = leftColor, viewport = viewport)
drawCurve(curveRight, selected = selectedRight, color = rightColor, viewport = viewport)
} }
) {
Canvas(modifier = Modifier.fillMaxSize()) {
drawGrid()
val startX = viewport.timeToX(selection.startSec, size.width)
val endX = viewport.timeToX(selection.endSec, size.width)
drawRect(
color = Color.White.copy(alpha = 0.06f),
topLeft = Offset(min(startX, endX), 0f),
size = androidx.compose.ui.geometry.Size(abs(endX - startX), size.height)
)
drawLine(Color.White.copy(alpha = 0.35f), Offset(startX, 0f), Offset(startX, size.height), strokeWidth = 3f)
drawLine(Color.White.copy(alpha = 0.35f), Offset(endX, 0f), Offset(endX, size.height), strokeWidth = 3f)
val handleColor = Color.White.copy(alpha = 0.9f)
drawCircle(handleColor, radius = 10f, center = Offset(startX, size.height - 14f))
drawCircle(handleColor, radius = 10f, center = Offset(endX, size.height - 14f))
val playX = viewport.timeToX(playheadSec.toInt(), size.width)
drawLine(Color(0xFFFFFFFF).copy(alpha = 0.6f), Offset(playX, 0f), Offset(playX, size.height), strokeWidth = 2f)
drawCurve(curveLeft, selected = selectedLeft, color = leftColor, viewport = viewport)
drawCurve(curveRight, selected = selectedRight, color = rightColor, viewport = viewport)
}
}
} }
} }

View File

@@ -1,6 +1,7 @@
package com.mindmachine.mvp.session package com.mindmachine.mvp.session
import com.mindmachine.mvp.domain.SessionPreset import com.mindmachine.mvp.domain.SessionPreset
import com.mindmachine.mvp.domain.VisualPattern
object TimelineProgramFactory { object TimelineProgramFactory {
fun fromPreset(preset: SessionPreset): TimelineEditorState { fun fromPreset(preset: SessionPreset): TimelineEditorState {
@@ -36,4 +37,45 @@ object TimelineProgramFactory {
playheadSec = 0f, playheadSec = 0f,
) )
} }
fun toPreset(
id: String,
name: String,
description: String,
timeline: TimelineEditorState,
sortOrder: Int,
isUserProgram: Boolean,
): SessionPreset {
val t0 = 0f
val flashMs = ParameterMapping.logMap01(
timeline.visualLeft.valueAt(t0),
ParameterRanges.FLASH_INTERVAL_MS_MIN,
ParameterRanges.FLASH_INTERVAL_MS_MAX,
).toInt().coerceIn(ParameterRanges.FLASH_INTERVAL_MS_MIN.toInt(), ParameterRanges.FLASH_INTERVAL_MS_MAX.toInt())
val carrierHz = ParameterMapping.logMap01(
timeline.audioLeft.valueAt(t0),
ParameterRanges.CARRIER_HZ_MIN,
ParameterRanges.CARRIER_HZ_MAX,
)
val binauralHz = ParameterMapping.logMap01(
timeline.audioRight.valueAt(t0),
ParameterRanges.BINAURAL_HZ_MIN,
ParameterRanges.BINAURAL_HZ_MAX,
)
return SessionPreset(
id = id,
name = name,
description = description,
defaultDurationSec = timeline.durationSec,
visualPatternType = VisualPattern.FLASH,
flashIntervalMs = flashMs,
intensityPercent = 60,
carrierFrequencyHz = carrierHz,
binauralDifferenceHz = binauralHz,
cautionNote = "Stop if discomfort occurs.",
sortOrder = sortOrder,
isUserProgram = isUserProgram,
)
}
} }