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.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 = {
|
||||
SetupScreen(
|
||||
vm = vm,
|
||||
onStart = {
|
||||
vm.startSession()
|
||||
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") {
|
||||
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") }
|
||||
|
||||
@@ -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