Skip to main content

ff_preview/playback/
clock.rs

1//! Playback clock types for ff-preview.
2//!
3//! [`PlaybackClock`] is the public wall-clock API for callers that need
4//! independent timing queries. The crate-internal A/V sync reference clock is
5//! [`MasterClock`](super::master_clock::MasterClock), defined in
6//! `playback/master_clock.rs`.
7
8use std::time::{Duration, Instant};
9
10// ── ClockState ────────────────────────────────────────────────────────────────
11
12/// Internal state machine for `PlaybackClock`.
13///
14/// Transitions:
15/// - `Stopped  → Running`:  `start()`
16/// - `Running  → Paused`:   `pause()`
17/// - `Running  → Stopped`:  `stop()`
18/// - `Paused   → Running`:  `resume()`
19/// - `Paused   → Stopped`:  `stop()`
20/// - `Running  → Running`:  `start()` is a no-op
21/// - `Paused   → Paused`:   `pause()` is a no-op
22enum ClockState {
23    Stopped,
24    Running { started_at: Instant, base: Duration },
25    Paused { frozen_at: Duration },
26}
27
28// ── PlaybackClock ─────────────────────────────────────────────────────────────
29
30/// A monotonic clock that tracks elapsed playback time.
31///
32/// The clock supports start, stop, pause, resume, and playback-rate scaling.
33/// It is used by `PreviewPlayer` internally to drive frame presentation timing
34/// and A/V synchronisation. Callers may also query it directly.
35///
36/// `PlaybackClock` is a value type — it is not `Arc<Mutex<...>>` internally.
37/// When multi-thread access is required, wrap it in a `Mutex`.
38///
39/// # Usage
40///
41/// ```ignore
42/// let mut clock = PlaybackClock::new();
43/// clock.start();
44/// let pts = clock.current_pts();
45/// clock.pause();
46/// // current_pts() is now frozen
47/// clock.resume();
48/// // current_pts() continues advancing from the frozen point
49/// clock.set_rate(2.0);          // fast-forward at 2×
50/// clock.set_position(Duration::from_secs(30)); // seek to 30 s
51/// ```
52pub struct PlaybackClock {
53    state: ClockState,
54    /// Playback rate multiplier. 1.0 = real-time.
55    rate: f64,
56    /// Pending seek position. Applied as the `base` when `start()` is called
57    /// from the `Stopped` state. Cleared by `stop()`.
58    seek_offset: Duration,
59}
60
61impl PlaybackClock {
62    /// Create a new clock in the `Stopped` state with a rate of 1.0.
63    #[must_use]
64    pub fn new() -> Self {
65        Self {
66            state: ClockState::Stopped,
67            rate: 1.0,
68            seek_offset: Duration::ZERO,
69        }
70    }
71
72    /// Start the clock from the current position.
73    ///
74    /// - If the clock is `Stopped`, it starts from the position last set by
75    ///   [`set_position`](Self::set_position), or `Duration::ZERO` if no seek
76    ///   has been performed.
77    /// - If the clock is `Paused`, it starts from the frozen position.
78    /// - If the clock is already `Running`, this is a no-op.
79    pub fn start(&mut self) {
80        let base = match &self.state {
81            ClockState::Running { .. } => return,
82            ClockState::Stopped => self.seek_offset,
83            ClockState::Paused { frozen_at } => *frozen_at,
84        };
85        self.state = ClockState::Running {
86            started_at: Instant::now(),
87            base,
88        };
89    }
90
91    /// Stop the clock and reset the position to `Duration::ZERO`.
92    ///
93    /// `current_time()` and `current_pts()` will return `Duration::ZERO`
94    /// until `start()` or `set_position()` is called again.
95    pub fn stop(&mut self) {
96        self.state = ClockState::Stopped;
97        self.seek_offset = Duration::ZERO;
98    }
99
100    /// Pause the clock at the current position.
101    ///
102    /// `current_time()` and `current_pts()` are frozen until
103    /// [`resume`](Self::resume) is called. If already `Paused` or `Stopped`, no-op.
104    pub fn pause(&mut self) {
105        if let ClockState::Running { started_at, base } = &self.state {
106            let elapsed = started_at.elapsed().mul_f64(self.rate);
107            self.state = ClockState::Paused {
108                frozen_at: *base + elapsed,
109            };
110        }
111    }
112
113    /// Resume from a paused position. No-op if not paused.
114    pub fn resume(&mut self) {
115        if let ClockState::Paused { frozen_at } = self.state {
116            self.state = ClockState::Running {
117                started_at: Instant::now(),
118                base: frozen_at,
119            };
120        }
121    }
122
123    /// Current wall-clock elapsed time since start (affected by rate).
124    ///
125    /// Equivalent to [`current_pts`](Self::current_pts) for clocks that
126    /// start at zero; use `current_pts()` when a seek offset has been set.
127    #[must_use]
128    pub fn current_time(&self) -> Duration {
129        match &self.state {
130            ClockState::Stopped => Duration::ZERO,
131            ClockState::Paused { frozen_at } => *frozen_at,
132            ClockState::Running { started_at, base } => {
133                *base + started_at.elapsed().mul_f64(self.rate)
134            }
135        }
136    }
137
138    /// Current presentation timestamp (elapsed time since position zero).
139    ///
140    /// Identical to `current_time()` when the clock was started from zero.
141    /// When a `set_position(t)` was called before `start()`, the clock
142    /// advances from `t` and this method returns values ≥ `t`.
143    #[must_use]
144    pub fn current_pts(&self) -> Duration {
145        match &self.state {
146            ClockState::Stopped => self.seek_offset,
147            _ => self.current_time(),
148        }
149    }
150
151    /// Returns `true` if the clock is actively advancing.
152    #[must_use]
153    pub fn is_running(&self) -> bool {
154        matches!(self.state, ClockState::Running { .. })
155    }
156
157    /// Set the playback rate. Values ≤ 0.0 are ignored (rate stays unchanged).
158    ///
159    /// When the clock is `Running`, the current position is re-anchored at
160    /// `Instant::now()` so that `current_time()` does not jump.
161    pub fn set_rate(&mut self, rate: f64) {
162        if rate <= 0.0 {
163            return;
164        }
165        if let ClockState::Running { started_at, base } = &mut self.state {
166            // Re-anchor to now so the position does not jump on rate change.
167            let elapsed = started_at.elapsed().mul_f64(self.rate);
168            *base += elapsed;
169            *started_at = Instant::now();
170        }
171        self.rate = rate;
172    }
173
174    /// Current playback rate (default: 1.0).
175    #[must_use]
176    pub fn rate(&self) -> f64 {
177        self.rate
178    }
179
180    /// Jump to an arbitrary position in media time.
181    ///
182    /// - `Running`: the clock continues advancing from `pts` immediately.
183    /// - `Paused`: the frozen position is updated to `pts`.
184    /// - `Stopped`: `pts` is stored and applied as the starting position when
185    ///   [`start`](Self::start) is next called.
186    ///
187    /// After `set_position(t)` + `start()`, [`current_pts`](Self::current_pts)
188    /// will immediately return values ≥ `t`.
189    pub fn set_position(&mut self, pts: Duration) {
190        // seek_offset is always updated so current_pts() is consistent for all states.
191        self.seek_offset = pts;
192        if matches!(self.state, ClockState::Running { .. }) {
193            // Re-anchor the running base at the new position.
194            self.state = ClockState::Running {
195                started_at: Instant::now(),
196                base: pts,
197            };
198        } else if matches!(self.state, ClockState::Paused { .. }) {
199            self.state = ClockState::Paused { frozen_at: pts };
200        }
201        // Stopped: seek_offset is set above; start() will use it as the initial base.
202    }
203}
204
205impl Default for PlaybackClock {
206    fn default() -> Self {
207        Self::new()
208    }
209}
210
211// ── Tests ─────────────────────────────────────────────────────────────────────
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use std::thread;
217
218    #[test]
219    fn clock_stopped_should_return_zero() {
220        // Newly created clock returns zero.
221        let clock = PlaybackClock::new();
222        assert_eq!(clock.current_time(), Duration::ZERO);
223
224        // Clock returns zero after stop.
225        let mut clock = PlaybackClock::new();
226        clock.start();
227        thread::sleep(Duration::from_millis(5));
228        clock.stop();
229        assert_eq!(
230            clock.current_time(),
231            Duration::ZERO,
232            "current_time() must be ZERO after stop()"
233        );
234    }
235
236    #[test]
237    fn clock_paused_should_freeze_at_pause_time() {
238        let mut clock = PlaybackClock::new();
239        clock.start();
240        thread::sleep(Duration::from_millis(10));
241        clock.pause();
242
243        let t1 = clock.current_time();
244        thread::sleep(Duration::from_millis(10));
245        let t2 = clock.current_time();
246
247        assert_eq!(t1, t2, "current_time() must not advance while paused");
248        assert!(
249            !clock.is_running(),
250            "clock must not report running while paused"
251        );
252    }
253
254    #[test]
255    fn clock_resumed_should_continue_from_pause() {
256        let mut clock = PlaybackClock::new();
257        clock.start();
258        thread::sleep(Duration::from_millis(10));
259        clock.pause();
260        let t_paused = clock.current_time();
261
262        // Wait while paused — time must not advance.
263        thread::sleep(Duration::from_millis(10));
264        assert_eq!(clock.current_time(), t_paused);
265
266        clock.resume();
267        assert!(clock.is_running());
268        thread::sleep(Duration::from_millis(10));
269
270        let t_after = clock.current_time();
271        assert!(
272            t_after > t_paused,
273            "current_time() must advance after resume(); paused={t_paused:?} after={t_after:?}"
274        );
275    }
276
277    #[test]
278    fn clock_start_should_be_noop_when_already_running() {
279        let mut clock = PlaybackClock::new();
280        clock.start();
281        thread::sleep(Duration::from_millis(10));
282        let t_before = clock.current_time();
283
284        // Second start() should not reset the clock.
285        clock.start();
286        let t_after = clock.current_time();
287
288        assert!(
289            t_after >= t_before,
290            "second start() must not reset the clock; before={t_before:?} after={t_after:?}"
291        );
292    }
293
294    #[test]
295    fn clock_resume_should_be_noop_when_not_paused() {
296        // resume() on a stopped clock: stays stopped.
297        let mut clock = PlaybackClock::new();
298        clock.resume();
299        assert!(!clock.is_running());
300        assert_eq!(clock.current_time(), Duration::ZERO);
301
302        // resume() on a running clock: no effect.
303        clock.start();
304        thread::sleep(Duration::from_millis(5));
305        let t = clock.current_time();
306        clock.resume(); // no-op
307        assert!(clock.is_running());
308        assert!(clock.current_time() >= t);
309    }
310
311    #[test]
312    fn clock_default_should_equal_new() {
313        let a = PlaybackClock::new();
314        let b = PlaybackClock::default();
315        assert_eq!(a.current_time(), b.current_time());
316        assert_eq!(a.is_running(), b.is_running());
317    }
318
319    #[test]
320    fn set_rate_should_reject_non_positive_values() {
321        let mut clock = PlaybackClock::new();
322
323        clock.set_rate(0.0);
324        assert!(
325            (clock.rate() - 1.0).abs() < f64::EPSILON,
326            "rate must remain 1.0 after set_rate(0.0)"
327        );
328
329        clock.set_rate(-1.0);
330        assert!(
331            (clock.rate() - 1.0).abs() < f64::EPSILON,
332            "rate must remain 1.0 after set_rate(-1.0)"
333        );
334    }
335
336    #[test]
337    fn set_rate_should_update_rate_when_stopped_or_paused() {
338        // Stopped → rate updates.
339        let mut clock = PlaybackClock::new();
340        clock.set_rate(0.5);
341        assert!((clock.rate() - 0.5).abs() < f64::EPSILON);
342
343        // Paused → rate updates without resuming.
344        let mut clock = PlaybackClock::new();
345        clock.start();
346        clock.pause();
347        clock.set_rate(2.0);
348        assert!((clock.rate() - 2.0).abs() < f64::EPSILON);
349        assert!(
350            !clock.is_running(),
351            "clock must remain paused after set_rate"
352        );
353    }
354
355    #[test]
356    fn set_rate_running_should_not_jump_current_time() {
357        let mut clock = PlaybackClock::new();
358        clock.start();
359        thread::sleep(Duration::from_millis(10));
360        let before = clock.current_time();
361        clock.set_rate(2.0);
362        let after = clock.current_time();
363
364        assert!(
365            after >= before,
366            "current_time() must not go backward on set_rate; before={before:?} after={after:?}"
367        );
368        assert!(
369            after - before < Duration::from_millis(20),
370            "current_time() must not jump forward on set_rate; before={before:?} after={after:?}"
371        );
372        assert!((clock.rate() - 2.0).abs() < f64::EPSILON);
373    }
374
375    #[test]
376    #[ignore = "performance thresholds are environment-dependent; run explicitly with -- --include-ignored"]
377    fn rate_two_x_should_advance_at_double_speed() {
378        let mut clock = PlaybackClock::new();
379        clock.set_rate(2.0);
380        clock.start();
381        thread::sleep(Duration::from_millis(50));
382        let elapsed = clock.current_time();
383
384        // At 2×, 50 ms wall time should produce ≥80 ms of media time.
385        assert!(
386            elapsed >= Duration::from_millis(80),
387            "2× rate: expected ≥80 ms after 50 ms wall time, got {elapsed:?}"
388        );
389    }
390
391    #[test]
392    fn set_position_should_shift_pts_by_seek_offset() {
393        let seek_target = Duration::from_secs(30);
394
395        // Stopped: current_pts() returns the offset immediately.
396        let mut clock = PlaybackClock::new();
397        clock.set_position(seek_target);
398        assert_eq!(
399            clock.current_pts(),
400            seek_target,
401            "current_pts() must reflect seek_offset when stopped"
402        );
403
404        // start() must begin from the seek position.
405        clock.start();
406        let pts = clock.current_pts();
407        assert!(
408            pts >= seek_target,
409            "current_pts() must be ≥ seek target after start(); target={seek_target:?} pts={pts:?}"
410        );
411        assert!(
412            clock.is_running(),
413            "clock must be running after set_position + start()"
414        );
415    }
416
417    #[test]
418    fn set_position_while_paused_should_update_frozen_time() {
419        let mut clock = PlaybackClock::new();
420        clock.start();
421        thread::sleep(Duration::from_millis(5));
422        clock.pause();
423
424        let seek_target = Duration::from_secs(10);
425        clock.set_position(seek_target);
426
427        let pts = clock.current_pts();
428        assert_eq!(
429            pts, seek_target,
430            "frozen time must update to seek target; expected={seek_target:?} got={pts:?}"
431        );
432        assert!(
433            !clock.is_running(),
434            "clock must remain paused after set_position"
435        );
436
437        // resume() must continue advancing from the new position.
438        clock.resume();
439        thread::sleep(Duration::from_millis(5));
440        let pts_after = clock.current_pts();
441        assert!(
442            pts_after > seek_target,
443            "current_pts() must advance past seek target after resume(); target={seek_target:?} after={pts_after:?}"
444        );
445    }
446
447    #[test]
448    fn set_position_while_running_should_continue_from_new_position() {
449        let mut clock = PlaybackClock::new();
450        clock.start();
451        thread::sleep(Duration::from_millis(5));
452
453        let seek_target = Duration::from_secs(60);
454        clock.set_position(seek_target);
455
456        let pts = clock.current_pts();
457        assert!(
458            pts >= seek_target,
459            "current_pts() must be ≥ seek target immediately after set_position while running; \
460             target={seek_target:?} pts={pts:?}"
461        );
462        assert!(
463            clock.is_running(),
464            "clock must remain running after set_position"
465        );
466    }
467
468    #[test]
469    fn stop_should_clear_seek_offset() {
470        let mut clock = PlaybackClock::new();
471        clock.set_position(Duration::from_secs(30));
472        clock.stop();
473
474        assert_eq!(
475            clock.current_pts(),
476            Duration::ZERO,
477            "stop() must reset seek_offset to ZERO"
478        );
479    }
480}