@@ -2,6 +2,8 @@ import { useSimContext } from "@/contexts/SimContext/context";
22import { FC , useCallback , useEffect , useRef } from "react" ;
33import { Button } from "@/components/Button" ;
44
5+ const SPEED_OPTIONS = [ 0.01 , 0.1 , 0.25 , 0.5 , 1 , 2 , 4 , 8 ] ;
6+
57export 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 <<
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 <
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 >
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 >>
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