diff --git a/MindMachine-Timeline-QuickRef.md b/MindMachine-Timeline-QuickRef.md new file mode 100644 index 0000000..b2dab8d --- /dev/null +++ b/MindMachine-Timeline-QuickRef.md @@ -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. \ No newline at end of file diff --git a/MindMachine-Timeline-Spec.md b/MindMachine-Timeline-Spec.md new file mode 100644 index 0000000..021296d --- /dev/null +++ b/MindMachine-Timeline-Spec.md @@ -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 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? \ No newline at end of file diff --git a/app/src/main/java/com/mindmachine/mvp/MainActivity.kt b/app/src/main/java/com/mindmachine/mvp/MainActivity.kt index 8519672..3b655af 100644 --- a/app/src/main/java/com/mindmachine/mvp/MainActivity.kt +++ b/app/src/main/java/com/mindmachine/mvp/MainActivity.kt @@ -8,6 +8,9 @@ import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent import androidx.activity.viewModels 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.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -184,10 +187,21 @@ fun App(vm: MainViewModel = viewModel()) { } } composable("setup") { - SetupScreen(vm = vm, onStart = { - vm.startSession() - nav.navigate("active") - }, onHolder = { nav.navigate("holder") }) + SetupScreen( + vm = vm, + onStart = { + vm.startSession() + nav.navigate("active") + }, + onHolder = { nav.navigate("holder") }, + onOpenTimelineEditor = { nav.navigate("timeline") }, + ) + } + composable("timeline") { + TimelineEditorScreen( + vm = vm, + onBack = { nav.popBackStack() } + ) } composable("active") { ActiveSessionScreen( @@ -249,9 +263,17 @@ fun SafetyScreen(onAck: () -> Unit, onHolder: () -> Unit) { } @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 cfg = ui.config + var model by remember(ui.selectedPreset.id, cfg.durationSec) { + mutableStateOf(TimelineModel.createForPreset(ui.selectedPreset, cfg.durationSec)) + } val scrollState = rememberScrollState() val containerModifier = Modifier .fillMaxSize() @@ -260,36 +282,32 @@ fun SetupScreen(vm: MainViewModel, onStart: () -> Unit, onHolder: () -> Unit) { .safeDrawingPadding() .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( - containerModifier, + Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(10.dp) ) { - Text(ui.selectedPreset.name, style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onBackground) - Text("Duration: ${cfg.durationSec / 60} min", color = MaterialTheme.colorScheme.onBackground) - 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("_", " ")) } - ) - } + OutlinedButton(onClick = onOpenTimelineEditor, modifier = Modifier.fillMaxWidth()) { + Text("Open Timeline Editor") } - 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") } if (ui.error != null) Text(ui.error!!, color = MaterialTheme.colorScheme.error) Button(onClick = onStart, modifier = Modifier.fillMaxWidth().height(52.dp)) { Text("Start") } diff --git a/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorScreen.kt b/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorScreen.kt new file mode 100644 index 0000000..e3b194f --- /dev/null +++ b/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorScreen.kt @@ -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) + } +} diff --git a/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorState.kt b/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorState.kt new file mode 100644 index 0000000..4aefec4 --- /dev/null +++ b/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorState.kt @@ -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 +) { + 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, +)