Skip to content

Commit eb30fa0

Browse files
committed
Seek using left/right buttons. Also add key bindings
1 parent 9f6d55c commit eb30fa0

File tree

1 file changed

+172
-30
lines changed

1 file changed

+172
-30
lines changed

ui/src/components/Sim/modules/Playback.tsx

Lines changed: 172 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { useSimContext } from "@/contexts/SimContext/context";
22
import { FC, useCallback, useEffect, useRef } from "react";
33
import { Button } from "@/components/Button";
44

5+
const SPEED_OPTIONS = [0.01, 0.1, 0.25, 0.5, 1, 2, 4, 8];
6+
57
export const Playback: FC = () => {
68
const {
79
state: { events, isPlaying, speedMultiplier, currentTime, maxTime },
@@ -13,6 +15,10 @@ export const Playback: FC = () => {
1315
const lastUpdateRef = useRef<number>(0);
1416
const currentTimeRef = useRef<number>(currentTime);
1517

18+
// Refs for seeking functionality
19+
const eventsRef = useRef(events);
20+
const maxTimeRef = useRef(maxTime);
21+
1622
const handleSpeedChange = useCallback(
1723
(event: React.ChangeEvent<HTMLSelectElement>) => {
1824
const newSpeed = parseFloat(event.target.value);
@@ -25,17 +31,54 @@ export const Playback: FC = () => {
2531
dispatch({ type: "SET_TIMELINE_PLAYING", payload: !isPlaying });
2632
}, [dispatch, isPlaying]);
2733

34+
// Press-to-seek refs
35+
const stepIntervalRef = useRef<number | null>(null);
36+
const stepTimeoutRef = useRef<number | null>(null);
37+
38+
const stopSeeking = useCallback(() => {
39+
if (stepTimeoutRef.current) {
40+
clearTimeout(stepTimeoutRef.current);
41+
stepTimeoutRef.current = null;
42+
}
43+
if (stepIntervalRef.current) {
44+
clearInterval(stepIntervalRef.current);
45+
stepIntervalRef.current = null;
46+
}
47+
}, []);
48+
2849
const handleStep = useCallback(
2950
(stepAmount: number) => {
3051
const maxEventTime =
31-
events.length > 0 ? events[events.length - 1].time_s : maxTime;
52+
eventsRef.current.length > 0
53+
? eventsRef.current[eventsRef.current.length - 1].time_s
54+
: maxTimeRef.current;
55+
const currentTime = currentTimeRef.current;
3256
const newTime = Math.max(
3357
0,
3458
Math.min(currentTime + stepAmount, maxEventTime),
3559
);
60+
currentTimeRef.current = newTime;
3661
dispatch({ type: "SET_TIMELINE_TIME", payload: newTime });
3762
},
38-
[dispatch, currentTime, events, maxTime],
63+
[dispatch],
64+
);
65+
66+
const startSeeking = useCallback(
67+
(stepAmount: number) => {
68+
// Clear any existing seeking first
69+
stopSeeking();
70+
71+
// Initial step using current ref values
72+
handleStep(stepAmount);
73+
74+
// Start continuous seeking after delay
75+
stepTimeoutRef.current = window.setTimeout(() => {
76+
stepIntervalRef.current = window.setInterval(() => {
77+
handleStep(stepAmount);
78+
}, 33); // ~30 FPS smooth seeking
79+
}, 300); // initial delay
80+
},
81+
[handleStep, stopSeeking],
3982
);
4083

4184
// Timeline playback effect - handles automatic advancement when playing
@@ -81,81 +124,182 @@ export const Playback: FC = () => {
81124
intervalRef.current = null;
82125
}
83126
}
127+
}, [
128+
isPlaying,
129+
events.length,
130+
currentTime,
131+
speedMultiplier,
132+
dispatch,
133+
stopSeeking,
134+
]);
84135

85-
// Cleanup on unmount
86-
return () => {
87-
if (intervalRef.current) {
88-
clearInterval(intervalRef.current);
89-
}
90-
};
91-
}, [isPlaying, events.length, currentTime, speedMultiplier, dispatch]);
92-
93-
// Keep currentTimeRef in sync when currentTime changes externally (e.g., slider)
136+
// Keep refs in sync when values change externally
94137
useEffect(() => {
95138
currentTimeRef.current = currentTime;
96139
lastUpdateRef.current = performance.now();
97140
}, [currentTime]);
98141

142+
useEffect(() => {
143+
eventsRef.current = events;
144+
}, [events]);
145+
146+
useEffect(() => {
147+
maxTimeRef.current = maxTime;
148+
}, [maxTime]);
149+
99150
const disabled = events.length === 0;
100151

152+
// Keyboard event handler
153+
useEffect(() => {
154+
const handleKeyDown = (event: KeyboardEvent) => {
155+
if (disabled) return;
156+
157+
switch (event.code) {
158+
case "Space":
159+
event.preventDefault();
160+
handlePlayPause();
161+
break;
162+
case "ArrowRight":
163+
event.preventDefault();
164+
if (event.ctrlKey) {
165+
handleStep(1.0 * speedMultiplier); // 10x forward (big step)
166+
} else {
167+
handleStep(0.1 * speedMultiplier); // 1x forward (small step)
168+
}
169+
break;
170+
case "ArrowLeft":
171+
event.preventDefault();
172+
if (event.ctrlKey) {
173+
handleStep(-1.0 * speedMultiplier); // 10x backward (big step)
174+
} else {
175+
handleStep(-0.1 * speedMultiplier); // 1x backward (small step)
176+
}
177+
break;
178+
case "ArrowUp":
179+
event.preventDefault();
180+
// Increase speed to next available option
181+
{
182+
const currentIndex = SPEED_OPTIONS.indexOf(speedMultiplier);
183+
if (currentIndex < SPEED_OPTIONS.length - 1) {
184+
dispatch({
185+
type: "SET_TIMELINE_SPEED",
186+
payload: SPEED_OPTIONS[currentIndex + 1],
187+
});
188+
}
189+
}
190+
break;
191+
case "ArrowDown":
192+
event.preventDefault();
193+
// Decrease speed to previous available option
194+
{
195+
const currentIndex = SPEED_OPTIONS.indexOf(speedMultiplier);
196+
if (currentIndex > 0) {
197+
dispatch({
198+
type: "SET_TIMELINE_SPEED",
199+
payload: SPEED_OPTIONS[currentIndex - 1],
200+
});
201+
}
202+
}
203+
break;
204+
}
205+
};
206+
207+
document.addEventListener("keydown", handleKeyDown);
208+
return () => document.removeEventListener("keydown", handleKeyDown);
209+
}, [disabled, handlePlayPause, handleStep, speedMultiplier, dispatch]);
210+
101211
return (
102212
<div className="flex items-center gap-2">
103-
{/* Play/Pause button */}
104213
<Button
105214
onClick={handlePlayPause}
106215
disabled={disabled}
107216
variant="primary"
108217
className="w-20"
218+
title={isPlaying ? "Pause (Space)" : "Play (Space)"}
109219
>
110220
{isPlaying ? "Pause" : "Play"}
111221
</Button>
112222

113-
{/* Step controls: << < > >> */}
114223
<Button
115-
onClick={() => handleStep(-0.01)}
224+
onClick={() => handleStep(-1.0 * speedMultiplier)}
225+
onMouseDown={(e) => {
226+
e.preventDefault();
227+
startSeeking(-1.0 * speedMultiplier);
228+
}}
229+
onMouseUp={(e) => {
230+
e.preventDefault();
231+
stopSeeking();
232+
}}
233+
onMouseLeave={stopSeeking}
116234
disabled={disabled}
117235
variant="secondary"
118236
size="sm"
119237
className="px-2"
120-
title="Step backward 10ms"
238+
title={`Step backward ${1.0 * speedMultiplier}s (Ctrl + ←)`}
121239
>
122240
&lt;&lt;
123241
</Button>
124242

125243
<Button
126-
onClick={() => handleStep(-0.001)}
244+
onClick={() => handleStep(-0.1 * speedMultiplier)}
245+
onMouseDown={(e) => {
246+
e.preventDefault();
247+
startSeeking(-0.1 * speedMultiplier);
248+
}}
249+
onMouseUp={(e) => {
250+
e.preventDefault();
251+
stopSeeking();
252+
}}
253+
onMouseLeave={stopSeeking}
127254
disabled={disabled}
128255
variant="secondary"
129256
size="sm"
130257
className="px-2"
131-
title="Step backward 1ms"
258+
title={`Step backward ${0.1 * speedMultiplier}s (←)`}
132259
>
133260
&lt;
134261
</Button>
135262

136263
<Button
137-
onClick={() => handleStep(0.001)}
264+
onClick={() => handleStep(0.1 * speedMultiplier)}
265+
onMouseDown={(e) => {
266+
e.preventDefault();
267+
startSeeking(0.1 * speedMultiplier);
268+
}}
269+
onMouseUp={(e) => {
270+
e.preventDefault();
271+
stopSeeking();
272+
}}
273+
onMouseLeave={stopSeeking}
138274
disabled={disabled}
139275
variant="secondary"
140276
size="sm"
141277
className="px-2"
142-
title="Step forward 1ms"
278+
title={`Step forward ${0.1 * speedMultiplier}s (→)`}
143279
>
144280
&gt;
145281
</Button>
146282

147283
<Button
148-
onClick={() => handleStep(0.01)}
284+
onClick={() => handleStep(1.0 * speedMultiplier)}
285+
onMouseDown={(e) => {
286+
e.preventDefault();
287+
startSeeking(1.0 * speedMultiplier);
288+
}}
289+
onMouseUp={(e) => {
290+
e.preventDefault();
291+
stopSeeking();
292+
}}
293+
onMouseLeave={stopSeeking}
149294
disabled={disabled}
150295
variant="secondary"
151296
size="sm"
152297
className="px-2"
153-
title="Step forward 10ms"
298+
title={`Step forward ${1.0 * speedMultiplier}s (Ctrl + →)`}
154299
>
155300
&gt;&gt;
156301
</Button>
157302

158-
{/* Speed control */}
159303
<div className="min-w-16">
160304
<label htmlFor="timelineSpeed" className="block text-xs text-gray-600">
161305
Speed
@@ -166,15 +310,13 @@ export const Playback: FC = () => {
166310
value={speedMultiplier}
167311
onChange={handleSpeedChange}
168312
disabled={disabled}
313+
title="Change speed (↑ / ↓)"
169314
>
170-
<option value={0.01}>0.01x</option>
171-
<option value={0.1}>0.1x</option>
172-
<option value={0.25}>0.25x</option>
173-
<option value={0.5}>0.5x</option>
174-
<option value={1}>1x</option>
175-
<option value={2}>2x</option>
176-
<option value={4}>4x</option>
177-
<option value={8}>8x</option>
315+
{SPEED_OPTIONS.map((speed) => (
316+
<option key={speed} value={speed}>
317+
{speed}x
318+
</option>
319+
))}
178320
</select>
179321
</div>
180322
</div>

0 commit comments

Comments
 (0)