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

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

View File

@@ -0,0 +1,68 @@
# MindMachine Timeline Editor - Quick Reference Guide
## Getting Started
1. **Enter Timeline Editor** from the main MindMachine screen
2. **Select Curves** by tapping left (brightness/blink) or right (carrier/binaural) sides
3. **Create Points** by tapping anywhere on the graph
4. **Edit Points** by dragging them to new positions
5. **Zoom/Pan** using pinch gestures and drag
## Touch Controls
### Point Management
- **Create**: Tap empty area
- **Move**: Drag existing point
- **Delete**: Long-press point (haptic feedback)
- **Smooth**: Double-tap between points
### Navigation
- **Zoom In**: Pinch together
- **Zoom Out**: Pinch apart
- **Pan**: Drag graph horizontally
- **Selection**: Use left/right handles to set duration
## Curve Selection
- **Visual Curve**: Brightness (left) / Blink Rate (right)
- **Audio Curve**: Carrier Frequency (left) / Binaural Beat (right)
**Active Curve**: Highlighted with glow effect
## Parameter Ranges
- **Brightness**: 0% - 100%
- **Blink Rate**: 0.1 - 30 Hz
- **Carrier Frequency**: 200 - 1200 Hz
- **Binaural Beat**: 0.5 - 30 Hz
## Duration Limits
- **Minimum**: 1 minute
- **Maximum**: 8 hours
- **Default**: 20 minutes
## Visual Feedback
- **Point Creation**: White flash + haptic tap
- **Point Movement**: Subtle glow + haptic feedback
- **Curve Selection**: Smooth color transition + haptic
- **Range Limits**: Visual indicator + haptic feedback
## Tips
- Start with 3-4 points for simple curves
- Use pinch zoom for precise control
- Preview your session before saving
- Long-press for help on any control
## Troubleshooting
- **Can't create points**: Check if curve is selected
- **Points disappear**: You may have deleted them by long-press
- **Can't zoom**: Check if you're at minimum/maximum zoom level
- **Audio not working**: Ensure headphones are connected
## Help
Long-press anywhere for context-sensitive help or tap the "?" icon in the corner.

View File

@@ -0,0 +1,231 @@
# MindMachine - Timeline-Based Parameter Editing UX Spec
## 1. Feature Overview
This feature introduces timeline-based parameter editing for creating custom MindMachine sessions. Users can define visual and audio parameter curves over time using two synchronized graphs with pinch-to-zoom navigation and touch-based curve editing.
## 2. Core Interaction Model
### 2.1 Immersive Mode
- Full-screen editing canvas with no visible system UI
- All controls accessible via touch gestures only
- Visual feedback through the parameter curves themselves
- Haptic feedback for key interactions (optional)
### 2.2 Timeline Graphs
Two synchronized graphs displayed vertically:
- **Top Graph**: Visual parameters (brightness, blink rate)
- **Bottom Graph**: Audio parameters (carrier frequency, binaural beat)
Both graphs share:
- Same time scale (horizontal axis)
- Pinch-to-zoom for time axis
- Shared selection of editable curve
- Synchronized playback cursor
## 3. Curve Selection and Editing
### 3.1 Selecting Curves
- **Visual Curve**: Brightness (left) / Blink Rate (right)
- **Audio Curve**: Carrier Frequency (left) / Binaural Beat (right)
**Selection Method**:
- Tap left side of graph to select left curve
- Tap right side of graph to select right curve
- Visual indicator shows active selection (highlighted curve)
### 3.2 Editing Gestures
- **Create Point**: Tap on empty graph area
- **Move Point**: Drag existing point
- **Delete Point**: Long-press point (haptic feedback, then fade out)
- **Smooth Curve**: Double-tap between points (auto-smooth)
## 4. Timeline Navigation
### 4.1 Pinch-to-Zoom
- **Pinch In**: Zoom in (show more detail, shorter time range)
- **Pinch Out**: Zoom out (show longer duration, less detail)
- **Zoom Range**: 1 minute ← 8 hours
- **Minimum Zoom**: 30 seconds per screen width
- **Maximum Zoom**: 24 hours total visible
### 4.2 Pan Navigation
- **Drag Graph**: Pan horizontally through time
- **Edge Bounce**: Visual feedback when reaching timeline ends
- **Momentum Scrolling**: Natural deceleration after quick swipe
### 4.3 Time Indicators
- **Current Time Cursor**: Vertical line showing current playback position
- **Selection Handles**: Left/right handles for selecting time range
- **Duration Display**: Shows total selected duration
## 5. Parameter Ranges and Constraints
### 5.1 Visual Parameters
- **Brightness**: 0% - 100% (logarithmic scale for perceptual uniformity)
- **Blink Rate**: 0.1 - 30 Hz (logarithmic scale)
### 5.2 Audio Parameters
- **Carrier Frequency**: 200 - 1200 Hz (logarithmic scale)
- **Binaural Beat**: 0.5 - 30 Hz (logarithmic scale)
### 5.3 Duration Constraints
- **Minimum Duration**: 1 minute
- **Maximum Duration**: 8 hours
- **Default Duration**: 20 minutes
## 6. Visual Design System
### 6.1 Graph Appearance
- **Background**: Dark theme (reduces eye strain)
- **Grid Lines**: Light gray, 10% opacity
- **Curve Color**: Color-coded per parameter (blue for visual, green for audio)
- **Selected Curve**: Brightened with glow effect
- **Points**: White circles with 8px diameter
### 6.2 Touch Feedback
- **Point Creation**: White flash + haptic tap
- **Point Movement**: Subtle glow + haptic feedback
- **Curve Selection**: Smooth color transition + haptic
- **Zoom Gestures**: Dynamic scaling + haptic pulse at zoom stops
## 7. Edge Cases and Special Behaviors
### 7.1 Curve Intersections
- Allow curves to cross freely
- No special handling for intersections
- Visual clarity maintained through color coding
### 7.2 Extreme Values
- Clamp values to defined ranges
- Show visual indicator when reaching limits
- Provide haptic feedback at range boundaries
### 7.3 Zero-Length Curves
- Minimum 2 points required for valid curve
- Single point creates constant value
- Zero points = invalid state (show error)
### 7.4 Time Range Management
- Auto-extend timeline when creating points beyond current range
- Maintain zoom level when extending timeline
- Preserve curve shape during zoom/pan operations
## 8. User Workflow
### 8.1 Creating a Custom Session
1. User enters timeline editor from main screen
2. User selects which curves to edit (visual/audio)
3. User creates initial points by tapping
4. User adjusts points by dragging
5. User sets duration using selection handles
6. User previews session with playback controls
7. User saves session or starts immediate playback
### 8.2 Editing Existing Sessions
1. User opens saved session in editor
2. All curves and points load from saved state
3. User can modify any aspect (curves, duration, selection)
4. User can preview changes before saving
5. User can revert to original or save modifications
## 9. Accessibility Considerations
### 9.1 Visual Accessibility
- High contrast mode available
- Larger touch targets for motor-impaired users
- VoiceOver support for all interactive elements
- Color-blind friendly color palette
### 9.2 Motor Accessibility
- Adjustable touch target size
- Alternative input methods (keyboard navigation)
- Reduced motion option for animations
- Voice control for basic operations
## 10. Performance Requirements
### 10.1 Rendering Performance
- Maintain 60fps during all interactions
- Smooth curve rendering with anti-aliasing
- Efficient hit detection for touch points
- Low memory footprint for curve data
### 10.2 Battery Impact
- Optimize for minimal battery drain
- Reduce refresh rate when idle
- Efficient audio synthesis during preview
- Screen brightness management for visual elements
## 11. Error States and Recovery
### 11.1 Invalid Configurations
- **Too Few Points**: Show "At least 2 points required" message
- **Invalid Duration**: Show "Duration must be 1-8 hours" message
- **Out of Range Values**: Show "Value out of range, adjusted to limit"
### 11.2 System Errors
- **Audio Synthesis Failure**: Show "Audio generation failed" message
- **Rendering Issues**: Show "Graphics error, restarting" message
- **Memory Pressure**: Show "Low memory, closing editor" message
## 12. Implementation Notes for Andy
### 12.1 Data Structures
```java
class TimelineSession {
Duration duration;
Curve visualCurve; // Brightness/Blink Rate
Curve audioCurve; // Carrier/Binaural
boolean isPlaying;
}
class Curve {
List<Point> points;
CurveType type; // LEFT or RIGHT
boolean isActive;
}
class Point {
Time time;
double value;
boolean isSelected;
}
```
### 12.2 Key Algorithms
- **Curve Interpolation**: Cubic spline between points
- **Zoom Management**: Maintain aspect ratio, clamp zoom levels
- **Hit Detection**: Spatial indexing for touch points
- **Audio Synthesis**: Real-time parameter modulation based on curve
### 12.3 Performance Optimizations
- Use GPU acceleration for curve rendering
- Implement spatial indexing for hit detection
- Cache interpolated values for playback
- Use object pooling for point objects
### 12.4 Testing Priorities
1. Touch gesture accuracy and responsiveness
2. Curve editing precision and smoothness
3. Zoom and pan performance
4. Audio-visual synchronization
5. Edge case handling (extreme values, rapid gestures)
---
## 13. Open Questions for Joe
1. **Curve Smoothing**: Should there be automatic curve smoothing between points, or should users create every point manually?
2. **Preview Behavior**: When previewing, should the session loop continuously or play once through?
3. **Default Presets**: Should the timeline editor include some pre-built curve templates (e.g., "Gradual fade", "Pulsing rhythm")?
4. **Sharing**: Do users need to share custom sessions with others, or is this purely personal use?
5. **Undo/Redo**: Should there be an undo/redo system for curve editing, or is the current state sufficient?
6. **Curve Export**: Should users be able to export their custom curves as data files for backup or sharing?
7. **Tutorial**: Is a guided tutorial needed for first-time users, or should the interface be intuitive enough to learn through exploration?

View File

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

View File

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

View File

@@ -0,0 +1,119 @@
package com.mindmachine.mvp.session
import kotlin.math.max
import kotlin.math.min
data class TimelineEditorState(
val durationSec: Int,
val viewport: TimelineViewport,
val activeCurve: ActiveCurve,
val visualLeft: TimelineCurve,
val visualRight: TimelineCurve,
val audioLeft: TimelineCurve,
val audioRight: TimelineCurve,
) {
enum class ActiveCurve {
VISUAL_LEFT,
VISUAL_RIGHT,
AUDIO_LEFT,
AUDIO_RIGHT,
}
companion object {
fun default(durationSec: Int = 20 * 60): TimelineEditorState {
val initial = TimelineCurve(
points = listOf(
TimelinePoint(0, 0.5f),
TimelinePoint(durationSec, 0.5f),
)
)
return TimelineEditorState(
durationSec = durationSec,
viewport = TimelineViewport(
startSec = 0f,
secondsPerScreen = min(10f * 60f, durationSec.toFloat())
),
activeCurve = ActiveCurve.VISUAL_LEFT,
visualLeft = initial,
visualRight = initial,
audioLeft = initial,
audioRight = initial,
)
}
}
}
data class TimelineViewport(
val startSec: Float,
val secondsPerScreen: Float,
) {
fun applyZoomPan(
zoomChange: Float,
panPx: Float,
widthPx: Float,
centroidPx: Float,
): TimelineViewport {
val minSecondsPerScreen = 30f
val maxSecondsPerScreen = 24f * 60f * 60f
val current = secondsPerScreen
val newSecondsPerScreen = (current / zoomChange).coerceIn(minSecondsPerScreen, maxSecondsPerScreen)
val secondsPerPxBefore = current / widthPx
val secondsPerPxAfter = newSecondsPerScreen / widthPx
val timeAtCentroidBefore = startSec + centroidPx * secondsPerPxBefore
val newStart = timeAtCentroidBefore - centroidPx * secondsPerPxAfter
val panSec = -panPx * secondsPerPxAfter
return copy(
startSec = (newStart + panSec).coerceAtLeast(0f),
secondsPerScreen = newSecondsPerScreen,
)
}
fun timeToX(tSec: Int, widthPx: Float): Float {
val rel = tSec.toFloat() - startSec
return (rel / secondsPerScreen) * widthPx
}
}
data class TimelineCurve(
val points: List<TimelinePoint>
) {
fun addPointAt(offset: androidx.compose.ui.geometry.Offset, viewport: TimelineViewport, widthPx: Float, heightPx: Float): TimelineCurve {
val t = (viewport.startSec + (offset.x / widthPx) * viewport.secondsPerScreen).toInt().coerceAtLeast(0)
val v = (1f - (offset.y / heightPx)).coerceIn(0f, 1f)
return copy(points = (points + TimelinePoint(t, v)).sortedBy { it.tSec })
}
fun removePointAt(index: Int): TimelineCurve {
if (points.size <= 2) return this
return copy(points = points.filterIndexed { i, _ -> i != index })
}
fun movePoint(index: Int, newPosition: androidx.compose.ui.geometry.Offset, viewport: TimelineViewport, widthPx: Float, heightPx: Float): TimelineCurve {
val t = (viewport.startSec + (newPosition.x / widthPx) * viewport.secondsPerScreen).toInt().coerceAtLeast(0)
val v = (1f - (newPosition.y / heightPx)).coerceIn(0f, 1f)
val updated = points.toMutableList()
updated[index] = updated[index].copy(tSec = t, value01 = v)
return copy(points = updated.sortedBy { it.tSec })
}
fun hitTestPoint(offset: androidx.compose.ui.geometry.Offset, viewport: TimelineViewport, widthPx: Float, heightPx: Float): Int? {
val radiusPx = 24f
points.forEachIndexed { idx, p ->
val x = viewport.timeToX(p.tSec, widthPx)
val y = (1f - p.value01) * heightPx
val dx = offset.x - x
val dy = offset.y - y
if (dx * dx + dy * dy <= radiusPx * radiusPx) return idx
}
return null
}
}
data class TimelinePoint(
val tSec: Int,
val value01: Float,
)