MindMachine: add timeline editor screen with pinch zoom and duration slider
This commit is contained in:
68
MindMachine-Timeline-QuickRef.md
Normal file
68
MindMachine-Timeline-QuickRef.md
Normal 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.
|
||||||
231
MindMachine-Timeline-Spec.md
Normal file
231
MindMachine-Timeline-Spec.md
Normal 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?
|
||||||
@@ -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.startSession()
|
vm = vm,
|
||||||
nav.navigate("active")
|
onStart = {
|
||||||
}, onHolder = { nav.navigate("holder") })
|
vm.startSession()
|
||||||
|
nav.navigate("active")
|
||||||
|
},
|
||||||
|
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") }
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user