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. [`MasterClock`] is the crate-internal A/V sync
5//! reference driven by either consumed audio samples or an `Instant`.
6
7use std::sync::Arc;
8use std::sync::atomic::{AtomicU64, Ordering};
9use std::time::{Duration, Instant};
10
11// ── ClockState ────────────────────────────────────────────────────────────────
12
13/// Internal state machine for `PlaybackClock`.
14///
15/// Transitions:
16/// - `Stopped  → Running`:  `start()`
17/// - `Running  → Paused`:   `pause()`
18/// - `Running  → Stopped`:  `stop()`
19/// - `Paused   → Running`:  `resume()`
20/// - `Paused   → Stopped`:  `stop()`
21/// - `Running  → Running`:  `start()` is a no-op
22/// - `Paused   → Paused`:   `pause()` is a no-op
23enum ClockState {
24    Stopped,
25    Running { started_at: Instant, base: Duration },
26    Paused { frozen_at: Duration },
27}
28
29// ── PlaybackClock ─────────────────────────────────────────────────────────────
30
31/// A monotonic clock that tracks elapsed playback time.
32///
33/// The clock supports start, stop, pause, resume, and playback-rate scaling.
34/// It is used by `PreviewPlayer` internally to drive frame presentation timing
35/// and A/V synchronisation. Callers may also query it directly.
36///
37/// `PlaybackClock` is a value type — it is not `Arc<Mutex<...>>` internally.
38/// When multi-thread access is required, wrap it in a `Mutex`.
39///
40/// # Usage
41///
42/// ```ignore
43/// let mut clock = PlaybackClock::new();
44/// clock.start();
45/// let pts = clock.current_pts();
46/// clock.pause();
47/// // current_pts() is now frozen
48/// clock.resume();
49/// // current_pts() continues advancing from the frozen point
50/// clock.set_rate(2.0);          // fast-forward at 2×
51/// clock.set_position(Duration::from_secs(30)); // seek to 30 s
52/// ```
53pub struct PlaybackClock {
54    state: ClockState,
55    /// Playback rate multiplier. 1.0 = real-time.
56    rate: f64,
57    /// Pending seek position. Applied as the `base` when `start()` is called
58    /// from the `Stopped` state. Cleared by `stop()`.
59    seek_offset: Duration,
60}
61
62impl PlaybackClock {
63    /// Create a new clock in the `Stopped` state with a rate of 1.0.
64    #[must_use]
65    pub fn new() -> Self {
66        Self {
67            state: ClockState::Stopped,
68            rate: 1.0,
69            seek_offset: Duration::ZERO,
70        }
71    }
72
73    /// Start the clock from the current position.
74    ///
75    /// - If the clock is `Stopped`, it starts from the position last set by
76    ///   [`set_position`](Self::set_position), or `Duration::ZERO` if no seek
77    ///   has been performed.
78    /// - If the clock is `Paused`, it starts from the frozen position.
79    /// - If the clock is already `Running`, this is a no-op.
80    pub fn start(&mut self) {
81        let base = match &self.state {
82            ClockState::Running { .. } => return,
83            ClockState::Stopped => self.seek_offset,
84            ClockState::Paused { frozen_at } => *frozen_at,
85        };
86        self.state = ClockState::Running {
87            started_at: Instant::now(),
88            base,
89        };
90    }
91
92    /// Stop the clock and reset the position to `Duration::ZERO`.
93    ///
94    /// `current_time()` and `current_pts()` will return `Duration::ZERO`
95    /// until `start()` or `set_position()` is called again.
96    pub fn stop(&mut self) {
97        self.state = ClockState::Stopped;
98        self.seek_offset = Duration::ZERO;
99    }
100
101    /// Pause the clock at the current position.
102    ///
103    /// `current_time()` and `current_pts()` are frozen until
104    /// [`resume`](Self::resume) is called. If already `Paused` or `Stopped`, no-op.
105    pub fn pause(&mut self) {
106        if let ClockState::Running { started_at, base } = &self.state {
107            let elapsed = started_at.elapsed().mul_f64(self.rate);
108            self.state = ClockState::Paused {
109                frozen_at: *base + elapsed,
110            };
111        }
112    }
113
114    /// Resume from a paused position. No-op if not paused.
115    pub fn resume(&mut self) {
116        if let ClockState::Paused { frozen_at } = self.state {
117            self.state = ClockState::Running {
118                started_at: Instant::now(),
119                base: frozen_at,
120            };
121        }
122    }
123
124    /// Current wall-clock elapsed time since start (affected by rate).
125    ///
126    /// Equivalent to [`current_pts`](Self::current_pts) for clocks that
127    /// start at zero; use `current_pts()` when a seek offset has been set.
128    #[must_use]
129    pub fn current_time(&self) -> Duration {
130        match &self.state {
131            ClockState::Stopped => Duration::ZERO,
132            ClockState::Paused { frozen_at } => *frozen_at,
133            ClockState::Running { started_at, base } => {
134                *base + started_at.elapsed().mul_f64(self.rate)
135            }
136        }
137    }
138
139    /// Current presentation timestamp (elapsed time since position zero).
140    ///
141    /// Identical to `current_time()` when the clock was started from zero.
142    /// When a `set_position(t)` was called before `start()`, the clock
143    /// advances from `t` and this method returns values ≥ `t`.
144    #[must_use]
145    pub fn current_pts(&self) -> Duration {
146        match &self.state {
147            ClockState::Stopped => self.seek_offset,
148            _ => self.current_time(),
149        }
150    }
151
152    /// Returns `true` if the clock is actively advancing.
153    #[must_use]
154    pub fn is_running(&self) -> bool {
155        matches!(self.state, ClockState::Running { .. })
156    }
157
158    /// Set the playback rate. Values ≤ 0.0 are ignored (rate stays unchanged).
159    ///
160    /// When the clock is `Running`, the current position is re-anchored at
161    /// `Instant::now()` so that `current_time()` does not jump.
162    pub fn set_rate(&mut self, rate: f64) {
163        if rate <= 0.0 {
164            return;
165        }
166        if let ClockState::Running { started_at, base } = &mut self.state {
167            // Re-anchor to now so the position does not jump on rate change.
168            let elapsed = started_at.elapsed().mul_f64(self.rate);
169            *base += elapsed;
170            *started_at = Instant::now();
171        }
172        self.rate = rate;
173    }
174
175    /// Current playback rate (default: 1.0).
176    #[must_use]
177    pub fn rate(&self) -> f64 {
178        self.rate
179    }
180
181    /// Jump to an arbitrary position in media time.
182    ///
183    /// - `Running`: the clock continues advancing from `pts` immediately.
184    /// - `Paused`: the frozen position is updated to `pts`.
185    /// - `Stopped`: `pts` is stored and applied as the starting position when
186    ///   [`start`](Self::start) is next called.
187    ///
188    /// After `set_position(t)` + `start()`, [`current_pts`](Self::current_pts)
189    /// will immediately return values ≥ `t`.
190    pub fn set_position(&mut self, pts: Duration) {
191        // seek_offset is always updated so current_pts() is consistent for all states.
192        self.seek_offset = pts;
193        if matches!(self.state, ClockState::Running { .. }) {
194            // Re-anchor the running base at the new position.
195            self.state = ClockState::Running {
196                started_at: Instant::now(),
197                base: pts,
198            };
199        } else if matches!(self.state, ClockState::Paused { .. }) {
200            self.state = ClockState::Paused { frozen_at: pts };
201        }
202        // Stopped: seek_offset is set above; start() will use it as the initial base.
203    }
204}
205
206impl Default for PlaybackClock {
207    fn default() -> Self {
208        Self::new()
209    }
210}
211
212// ── MasterClock ───────────────────────────────────────────────────────────────
213
214/// Reference clock for the A/V sync loop in [`PreviewPlayer::run`].
215///
216/// - `Audio`: driven by consumed audio samples ÷ `sample_rate`.
217/// - `System`: driven by [`std::time::Instant`] (video-only files).
218pub(crate) enum MasterClock {
219    Audio {
220        samples_consumed: Arc<AtomicU64>,
221        sample_rate: u32,
222        /// Playback rate multiplier. 1.0 = real-time.
223        rate: f64,
224        /// Snapshot of `samples_consumed` at the last rate-change or seek.
225        samples_base: u64,
226        /// Media PTS at the last rate-change or seek.
227        pts_base: Duration,
228        /// Wall-clock fallback activated after the first presented frame when no
229        /// audio consumer has called `pop_audio_samples()`. Tuple: `(wall start, base PTS)`.
230        ///
231        /// When `Some`, `current_pts()` returns `base_pts + elapsed` instead of
232        /// `Duration::ZERO`, so video pacing runs at real time even without a cpal
233        /// consumer. If `samples_consumed` becomes non-zero later (a consumer
234        /// connects mid-playback), `current_pts()` automatically switches to the
235        /// audio-clock path with no additional coordination.
236        fallback: Option<(Instant, Duration)>,
237    },
238    System {
239        started_at: Instant,
240        base_pts: Duration,
241        /// Playback rate multiplier. 1.0 = real-time.
242        rate: f64,
243    },
244}
245
246impl MasterClock {
247    /// Current master clock position.
248    ///
249    /// For `Audio`: returns the maximum of the sample-based clock and the
250    /// wall-clock fallback (when set). Taking the maximum ensures that the
251    /// clock continues advancing at wall-clock rate after the audio ring
252    /// buffer drains (audio track ends before video), while also allowing
253    /// a late-connecting cpal consumer to drive the clock forward once it
254    /// overtakes the initial fallback.
255    #[allow(clippy::cast_precision_loss)]
256    pub(crate) fn current_pts(&self) -> Duration {
257        match self {
258            Self::Audio {
259                samples_consumed,
260                sample_rate,
261                rate,
262                samples_base,
263                pts_base,
264                fallback,
265            } => {
266                let s = samples_consumed.load(Ordering::Relaxed);
267                let delta = s.saturating_sub(*samples_base);
268                let sample_pts = if delta > 0 || !pts_base.is_zero() {
269                    Some(
270                        *pts_base
271                            + Duration::from_secs_f64(
272                                delta as f64 / f64::from(*sample_rate) * *rate,
273                            ),
274                    )
275                } else {
276                    None // before first sample consumed (no sync yet)
277                };
278                let fallback_pts = fallback
279                    .as_ref()
280                    .map(|(started_at, base_pts)| *base_pts + started_at.elapsed().mul_f64(*rate));
281                match (sample_pts, fallback_pts) {
282                    // Both present: use whichever is further ahead.
283                    // - During normal playback the sample clock is ahead → sample wins.
284                    // - After audio EOF (samples frozen) the wall-clock fallback
285                    //   overtakes → fallback wins.
286                    (Some(sp), Some(fp)) => sp.max(fp),
287                    (Some(sp), None) => sp,
288                    (None, Some(fp)) => fp,
289                    (None, None) => Duration::ZERO,
290                }
291            }
292            Self::System {
293                started_at,
294                base_pts,
295                rate,
296            } => *base_pts + started_at.elapsed().mul_f64(*rate),
297        }
298    }
299
300    /// Whether A/V sync should be applied for the current frame.
301    ///
302    /// - `System`: always `true` — wall clock drives FPS pacing.
303    /// - `Audio`: `true` once any of the following holds:
304    ///   - `samples_consumed > 0` (a cpal consumer has called `pop_audio_samples`), or
305    ///   - `fallback.is_some()` (the wall-clock fallback was armed after the first frame).
306    ///
307    ///   Returns `false` only in the brief window between `run()` starting and the
308    ///   first frame being presented — this prevents an indefinite sleep before any
309    ///   clock reference is available.
310    pub(crate) fn should_sync(&self) -> bool {
311        match self {
312            Self::System { .. } => true,
313            Self::Audio {
314                samples_consumed,
315                samples_base,
316                pts_base,
317                fallback,
318                ..
319            } => {
320                let s = samples_consumed.load(Ordering::Relaxed);
321                s > *samples_base || !pts_base.is_zero() || fallback.is_some()
322            }
323        }
324    }
325
326    /// Activate the wall-clock fallback at `base_pts` if no audio samples have
327    /// been consumed yet and the fallback has not already been armed.
328    ///
329    /// Called by [`PlayerRunner::run`] immediately after the first
330    /// `present_frame()` call. Once armed, `should_sync()` returns `true` and
331    /// `current_pts()` advances in real time even when no cpal consumer is
332    /// connected.
333    ///
334    /// Idempotent: subsequent calls are no-ops.  If `samples_consumed` becomes
335    /// non-zero (a consumer connects mid-playback), `current_pts()` automatically
336    /// switches to the audio-clock path without any additional coordination.
337    ///
338    /// No-op for [`MasterClock::System`].
339    pub(crate) fn activate_fallback_if_no_audio(&mut self, base_pts: Duration) {
340        if let Self::Audio {
341            samples_consumed,
342            samples_base,
343            fallback,
344            ..
345        } = self
346            && samples_consumed.load(Ordering::Relaxed) == *samples_base
347            && fallback.is_none()
348        {
349            *fallback = Some((Instant::now(), base_pts));
350        }
351    }
352
353    /// Re-arm the wall-clock fallback at `base_pts`, even when
354    /// `samples_consumed > 0`.
355    ///
356    /// Unlike [`activate_fallback_if_no_audio`](Self::activate_fallback_if_no_audio),
357    /// this method activates unconditionally and is intended to be called by
358    /// the pacing loop when it detects that audio has gone silent (audio track
359    /// ended before video). After re-arming, [`current_pts`](Self::current_pts)
360    /// returns the `max` of the frozen sample position and the advancing
361    /// wall-clock, so video continues at its native frame rate.
362    ///
363    /// No-op for [`MasterClock::System`].
364    pub(crate) fn rearm_fallback_at(&mut self, base_pts: Duration) {
365        if let Self::Audio { fallback, .. } = self {
366            *fallback = Some((Instant::now(), base_pts));
367        }
368    }
369
370    /// Update the playback rate multiplier.
371    ///
372    /// Re-baselines the clock so that `current_pts()` does not jump at the
373    /// moment of the rate change.  Values ≤ 0.0 are ignored.
374    #[allow(clippy::cast_precision_loss)]
375    pub(crate) fn set_rate(&mut self, new_rate: f64) {
376        if new_rate <= 0.0 {
377            return;
378        }
379        match self {
380            Self::Audio {
381                samples_consumed,
382                sample_rate,
383                rate,
384                pts_base,
385                samples_base,
386                fallback,
387            } => {
388                // Re-baseline at current pts so the clock doesn't jump.
389                let s = samples_consumed.load(Ordering::Relaxed);
390                let delta = s.saturating_sub(*samples_base);
391                let current = *pts_base
392                    + Duration::from_secs_f64(delta as f64 / f64::from(*sample_rate) * *rate);
393                *samples_base = s;
394                *pts_base = current;
395                *rate = new_rate;
396                if let Some((started_at, base)) = fallback.as_mut() {
397                    *base = current;
398                    *started_at = Instant::now();
399                }
400            }
401            Self::System {
402                started_at,
403                base_pts,
404                rate,
405            } => {
406                let current = *base_pts + started_at.elapsed().mul_f64(*rate);
407                *base_pts = current;
408                *started_at = Instant::now();
409                *rate = new_rate;
410            }
411        }
412    }
413
414    /// Current value of the audio sample counter, or `0` for a `System` clock.
415    ///
416    /// Used by the pacing loop to detect stalls: if this value stops
417    /// advancing for several consecutive frames while `> 0`, the audio track
418    /// has ended and `rearm_fallback_at` should be called.
419    pub(crate) fn audio_samples_snapshot(&self) -> u64 {
420        if let Self::Audio {
421            samples_consumed, ..
422        } = self
423        {
424            samples_consumed.load(Ordering::Relaxed)
425        } else {
426            0
427        }
428    }
429
430    /// Reset the clock to start ticking from `base` right now.
431    ///
432    /// For [`MasterClock::System`]: re-anchors `started_at` and sets `base_pts`.
433    ///
434    /// For [`MasterClock::Audio`]: if the wall-clock fallback is active (i.e. no
435    /// audio consumer is present), re-anchors the fallback at `(Instant::now(), base)`
436    /// so that post-seek pacing starts from the correct position. If the fallback
437    /// is not yet armed (pre-first-frame) or if `samples_consumed > 0` (audio
438    /// consumer active), this is a no-op — the seek position is reflected in the
439    /// audio buffer restart performed by `restart_audio_from`.
440    pub(crate) fn reset(&mut self, base: Duration) {
441        match self {
442            Self::System {
443                started_at,
444                base_pts,
445                ..
446            } => {
447                *started_at = Instant::now();
448                *base_pts = base;
449            }
450            Self::Audio {
451                samples_consumed,
452                samples_base,
453                pts_base,
454                fallback,
455                ..
456            } => {
457                let s = samples_consumed.load(Ordering::Relaxed);
458                *samples_base = s;
459                *pts_base = base;
460                if fallback.is_some() {
461                    *fallback = Some((Instant::now(), base));
462                }
463            }
464        }
465    }
466}
467
468// ── Tests ─────────────────────────────────────────────────────────────────────
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473    use std::thread;
474
475    #[test]
476    fn clock_stopped_should_return_zero() {
477        // Newly created clock returns zero.
478        let clock = PlaybackClock::new();
479        assert_eq!(clock.current_time(), Duration::ZERO);
480
481        // Clock returns zero after stop.
482        let mut clock = PlaybackClock::new();
483        clock.start();
484        thread::sleep(Duration::from_millis(5));
485        clock.stop();
486        assert_eq!(
487            clock.current_time(),
488            Duration::ZERO,
489            "current_time() must be ZERO after stop()"
490        );
491    }
492
493    #[test]
494    fn clock_paused_should_freeze_at_pause_time() {
495        let mut clock = PlaybackClock::new();
496        clock.start();
497        thread::sleep(Duration::from_millis(10));
498        clock.pause();
499
500        let t1 = clock.current_time();
501        thread::sleep(Duration::from_millis(10));
502        let t2 = clock.current_time();
503
504        assert_eq!(t1, t2, "current_time() must not advance while paused");
505        assert!(
506            !clock.is_running(),
507            "clock must not report running while paused"
508        );
509    }
510
511    #[test]
512    fn clock_resumed_should_continue_from_pause() {
513        let mut clock = PlaybackClock::new();
514        clock.start();
515        thread::sleep(Duration::from_millis(10));
516        clock.pause();
517        let t_paused = clock.current_time();
518
519        // Wait while paused — time must not advance.
520        thread::sleep(Duration::from_millis(10));
521        assert_eq!(clock.current_time(), t_paused);
522
523        clock.resume();
524        assert!(clock.is_running());
525        thread::sleep(Duration::from_millis(10));
526
527        let t_after = clock.current_time();
528        assert!(
529            t_after > t_paused,
530            "current_time() must advance after resume(); paused={t_paused:?} after={t_after:?}"
531        );
532    }
533
534    #[test]
535    fn clock_start_should_be_noop_when_already_running() {
536        let mut clock = PlaybackClock::new();
537        clock.start();
538        thread::sleep(Duration::from_millis(10));
539        let t_before = clock.current_time();
540
541        // Second start() should not reset the clock.
542        clock.start();
543        let t_after = clock.current_time();
544
545        assert!(
546            t_after >= t_before,
547            "second start() must not reset the clock; before={t_before:?} after={t_after:?}"
548        );
549    }
550
551    #[test]
552    fn clock_resume_should_be_noop_when_not_paused() {
553        // resume() on a stopped clock: stays stopped.
554        let mut clock = PlaybackClock::new();
555        clock.resume();
556        assert!(!clock.is_running());
557        assert_eq!(clock.current_time(), Duration::ZERO);
558
559        // resume() on a running clock: no effect.
560        clock.start();
561        thread::sleep(Duration::from_millis(5));
562        let t = clock.current_time();
563        clock.resume(); // no-op
564        assert!(clock.is_running());
565        assert!(clock.current_time() >= t);
566    }
567
568    #[test]
569    fn clock_default_should_equal_new() {
570        let a = PlaybackClock::new();
571        let b = PlaybackClock::default();
572        assert_eq!(a.current_time(), b.current_time());
573        assert_eq!(a.is_running(), b.is_running());
574    }
575
576    #[test]
577    fn set_rate_should_reject_non_positive_values() {
578        let mut clock = PlaybackClock::new();
579
580        clock.set_rate(0.0);
581        assert!(
582            (clock.rate() - 1.0).abs() < f64::EPSILON,
583            "rate must remain 1.0 after set_rate(0.0)"
584        );
585
586        clock.set_rate(-1.0);
587        assert!(
588            (clock.rate() - 1.0).abs() < f64::EPSILON,
589            "rate must remain 1.0 after set_rate(-1.0)"
590        );
591    }
592
593    #[test]
594    fn set_rate_should_update_rate_when_stopped_or_paused() {
595        // Stopped → rate updates.
596        let mut clock = PlaybackClock::new();
597        clock.set_rate(0.5);
598        assert!((clock.rate() - 0.5).abs() < f64::EPSILON);
599
600        // Paused → rate updates without resuming.
601        let mut clock = PlaybackClock::new();
602        clock.start();
603        clock.pause();
604        clock.set_rate(2.0);
605        assert!((clock.rate() - 2.0).abs() < f64::EPSILON);
606        assert!(
607            !clock.is_running(),
608            "clock must remain paused after set_rate"
609        );
610    }
611
612    #[test]
613    fn set_rate_running_should_not_jump_current_time() {
614        let mut clock = PlaybackClock::new();
615        clock.start();
616        thread::sleep(Duration::from_millis(10));
617        let before = clock.current_time();
618        clock.set_rate(2.0);
619        let after = clock.current_time();
620
621        // current_time() must not jump backward or skip more than a scheduler
622        // quantum (~16 ms) forward after set_rate while running.
623        assert!(
624            after >= before,
625            "current_time() must not go backward on set_rate; before={before:?} after={after:?}"
626        );
627        assert!(
628            after - before < Duration::from_millis(20),
629            "current_time() must not jump forward on set_rate; before={before:?} after={after:?}"
630        );
631        assert!((clock.rate() - 2.0).abs() < f64::EPSILON);
632    }
633
634    #[test]
635    #[ignore = "performance thresholds are environment-dependent; run explicitly with -- --include-ignored"]
636    fn rate_two_x_should_advance_at_double_speed() {
637        let mut clock = PlaybackClock::new();
638        clock.set_rate(2.0);
639        clock.start();
640        thread::sleep(Duration::from_millis(50));
641        let elapsed = clock.current_time();
642
643        // At 2×, 50 ms wall time should produce ≥80 ms of media time.
644        assert!(
645            elapsed >= Duration::from_millis(80),
646            "2× rate: expected ≥80 ms after 50 ms wall time, got {elapsed:?}"
647        );
648    }
649
650    #[test]
651    fn set_position_should_shift_pts_by_seek_offset() {
652        let seek_target = Duration::from_secs(30);
653
654        // Stopped: current_pts() returns the offset immediately.
655        let mut clock = PlaybackClock::new();
656        clock.set_position(seek_target);
657        assert_eq!(
658            clock.current_pts(),
659            seek_target,
660            "current_pts() must reflect seek_offset when stopped"
661        );
662
663        // start() must begin from the seek position.
664        clock.start();
665        let pts = clock.current_pts();
666        assert!(
667            pts >= seek_target,
668            "current_pts() must be ≥ seek target after start(); target={seek_target:?} pts={pts:?}"
669        );
670        assert!(
671            clock.is_running(),
672            "clock must be running after set_position + start()"
673        );
674    }
675
676    #[test]
677    fn set_position_while_paused_should_update_frozen_time() {
678        let mut clock = PlaybackClock::new();
679        clock.start();
680        thread::sleep(Duration::from_millis(5));
681        clock.pause();
682
683        let seek_target = Duration::from_secs(10);
684        clock.set_position(seek_target);
685
686        let pts = clock.current_pts();
687        assert_eq!(
688            pts, seek_target,
689            "frozen time must update to seek target; expected={seek_target:?} got={pts:?}"
690        );
691        assert!(
692            !clock.is_running(),
693            "clock must remain paused after set_position"
694        );
695
696        // resume() must continue advancing from the new position.
697        clock.resume();
698        thread::sleep(Duration::from_millis(5));
699        let pts_after = clock.current_pts();
700        assert!(
701            pts_after > seek_target,
702            "current_pts() must advance past seek target after resume(); target={seek_target:?} after={pts_after:?}"
703        );
704    }
705
706    #[test]
707    fn set_position_while_running_should_continue_from_new_position() {
708        let mut clock = PlaybackClock::new();
709        clock.start();
710        thread::sleep(Duration::from_millis(5));
711
712        let seek_target = Duration::from_secs(60);
713        clock.set_position(seek_target);
714
715        let pts = clock.current_pts();
716        assert!(
717            pts >= seek_target,
718            "current_pts() must be ≥ seek target immediately after set_position while running; \
719             target={seek_target:?} pts={pts:?}"
720        );
721        assert!(
722            clock.is_running(),
723            "clock must remain running after set_position"
724        );
725    }
726
727    #[test]
728    fn stop_should_clear_seek_offset() {
729        let mut clock = PlaybackClock::new();
730        clock.set_position(Duration::from_secs(30));
731        clock.stop();
732
733        assert_eq!(
734            clock.current_pts(),
735            Duration::ZERO,
736            "stop() must reset seek_offset to ZERO"
737        );
738    }
739
740    // ── MasterClock tests ─────────────────────────────────────────────────────
741
742    #[test]
743    fn master_clock_system_should_advance_from_base_pts() {
744        let clock = MasterClock::System {
745            started_at: Instant::now(),
746            base_pts: Duration::from_secs(5),
747            rate: 1.0,
748        };
749        let pts = clock.current_pts();
750        assert!(
751            pts >= Duration::from_secs(5),
752            "pts must be >= base_pts; got {pts:?}"
753        );
754        assert!(
755            pts < Duration::from_secs(6),
756            "pts must not advance 1 s in a unit test; got {pts:?}"
757        );
758        assert!(clock.should_sync(), "System clock must always sync");
759    }
760
761    #[test]
762    fn master_clock_system_reset_should_update_base_and_time_reference() {
763        let mut clock = MasterClock::System {
764            started_at: Instant::now() - Duration::from_secs(10),
765            base_pts: Duration::ZERO,
766            rate: 1.0,
767        };
768        assert!(
769            clock.current_pts() >= Duration::from_secs(9),
770            "clock should show ~10 s before reset"
771        );
772        clock.reset(Duration::from_secs(5));
773        let pts = clock.current_pts();
774        assert!(
775            pts >= Duration::from_secs(5),
776            "pts must be >= new base after reset; got {pts:?}"
777        );
778        assert!(
779            pts < Duration::from_secs(6),
780            "pts must not advance 1 s in a unit test after reset; got {pts:?}"
781        );
782    }
783
784    #[test]
785    fn master_clock_audio_should_not_sync_before_first_sample() {
786        let clock = MasterClock::Audio {
787            samples_consumed: Arc::new(AtomicU64::new(0)),
788            sample_rate: 48_000,
789            rate: 1.0,
790            samples_base: 0,
791            pts_base: Duration::ZERO,
792            fallback: None,
793        };
794        assert!(
795            !clock.should_sync(),
796            "audio clock must not sync before any samples are consumed and before fallback is armed"
797        );
798        assert_eq!(
799            clock.current_pts(),
800            Duration::ZERO,
801            "audio clock PTS must be zero before any samples and before fallback is armed"
802        );
803    }
804
805    #[test]
806    fn master_clock_audio_should_sync_and_report_pts_after_samples_consumed() {
807        let consumed = Arc::new(AtomicU64::new(48_000));
808        let clock = MasterClock::Audio {
809            samples_consumed: Arc::clone(&consumed),
810            sample_rate: 48_000,
811            rate: 1.0,
812            samples_base: 0,
813            pts_base: Duration::ZERO,
814            fallback: None,
815        };
816        assert!(
817            clock.should_sync(),
818            "audio clock must sync when samples > 0"
819        );
820        assert_eq!(
821            clock.current_pts(),
822            Duration::from_secs(1),
823            "48000 samples at 48000 Hz must equal 1 second"
824        );
825    }
826
827    #[test]
828    fn master_clock_audio_should_sync_after_fallback_activated() {
829        let mut clock = MasterClock::Audio {
830            samples_consumed: Arc::new(AtomicU64::new(0)),
831            sample_rate: 48_000,
832            rate: 1.0,
833            samples_base: 0,
834            pts_base: Duration::ZERO,
835            fallback: None,
836        };
837        assert!(
838            !clock.should_sync(),
839            "must not sync before fallback is armed"
840        );
841        clock.activate_fallback_if_no_audio(Duration::from_secs(1));
842        assert!(
843            clock.should_sync(),
844            "must sync after fallback is activated even when samples_consumed == 0"
845        );
846    }
847
848    #[test]
849    fn master_clock_audio_fallback_current_pts_should_advance_from_base_pts() {
850        let mut clock = MasterClock::Audio {
851            samples_consumed: Arc::new(AtomicU64::new(0)),
852            sample_rate: 48_000,
853            rate: 1.0,
854            samples_base: 0,
855            pts_base: Duration::ZERO,
856            fallback: None,
857        };
858        let base = Duration::from_secs(5);
859        clock.activate_fallback_if_no_audio(base);
860        let pts = clock.current_pts();
861        assert!(
862            pts >= base,
863            "fallback current_pts must be >= base_pts; got {pts:?}"
864        );
865        assert!(
866            pts < base + Duration::from_secs(1),
867            "fallback must not advance 1 s in a unit test; got {pts:?}"
868        );
869    }
870
871    #[test]
872    fn master_clock_audio_max_of_sample_and_fallback_should_prefer_further_ahead() {
873        // current_pts() returns max(sample_pts, fallback_pts) when both are set.
874        // Scenario: initial fallback armed at 2 s (first frame PTS=2s, no cpal
875        // consumer). Then 1 s of audio is consumed. sample_pts=1 s < fallback≈2 s,
876        // so the fallback wins and the clock reports ≈2 s.
877        let consumed = Arc::new(AtomicU64::new(0));
878        let mut clock = MasterClock::Audio {
879            samples_consumed: Arc::clone(&consumed),
880            sample_rate: 48_000,
881            rate: 1.0,
882            samples_base: 0,
883            pts_base: Duration::ZERO,
884            fallback: None,
885        };
886        clock.activate_fallback_if_no_audio(Duration::from_secs(2));
887        assert!(clock.should_sync(), "fallback must enable sync");
888        // Audio consumer processes 1 s of audio.
889        consumed.store(48_000, Ordering::Relaxed);
890        // sample_pts=1 s, fallback_pts≈2 s → max returns ≈2 s.
891        let pts = clock.current_pts();
892        assert!(
893            pts >= Duration::from_secs(2),
894            "max() must return fallback when fallback is further ahead; got {pts:?}"
895        );
896        assert!(
897            pts < Duration::from_secs(3),
898            "fallback must not be wildly ahead of 2 s; got {pts:?}"
899        );
900    }
901
902    #[test]
903    fn master_clock_audio_activate_fallback_should_be_idempotent() {
904        let mut clock = MasterClock::Audio {
905            samples_consumed: Arc::new(AtomicU64::new(0)),
906            sample_rate: 48_000,
907            rate: 1.0,
908            samples_base: 0,
909            pts_base: Duration::ZERO,
910            fallback: None,
911        };
912        clock.activate_fallback_if_no_audio(Duration::from_secs(1));
913        let pts1 = clock.current_pts();
914        thread::sleep(Duration::from_millis(5));
915        // Second call with a different base must be ignored.
916        clock.activate_fallback_if_no_audio(Duration::from_secs(100));
917        let pts2 = clock.current_pts();
918        assert!(
919            pts2 > pts1,
920            "clock must keep advancing from the first base after second activate; \
921             pts1={pts1:?} pts2={pts2:?}"
922        );
923        assert!(
924            pts2 < Duration::from_secs(5),
925            "second activate must not reset clock to base=100 s; pts2={pts2:?}"
926        );
927    }
928
929    #[test]
930    fn master_clock_audio_reset_should_update_fallback_base_pts() {
931        let mut clock = MasterClock::Audio {
932            samples_consumed: Arc::new(AtomicU64::new(0)),
933            sample_rate: 48_000,
934            rate: 1.0,
935            samples_base: 0,
936            pts_base: Duration::ZERO,
937            fallback: None,
938        };
939        clock.activate_fallback_if_no_audio(Duration::from_secs(5));
940        // Simulate a seek to 10 s.
941        clock.reset(Duration::from_secs(10));
942        let pts = clock.current_pts();
943        assert!(
944            pts >= Duration::from_secs(10),
945            "after reset, fallback must advance from the new base_pts; got {pts:?}"
946        );
947        assert!(
948            pts < Duration::from_secs(11),
949            "fallback must not advance 1 s in a unit test after reset; got {pts:?}"
950        );
951    }
952
953    #[test]
954    fn master_clock_audio_reset_should_not_arm_fallback_if_not_yet_active() {
955        let mut clock = MasterClock::Audio {
956            samples_consumed: Arc::new(AtomicU64::new(0)),
957            sample_rate: 48_000,
958            rate: 1.0,
959            samples_base: 0,
960            pts_base: Duration::ZERO,
961            fallback: None,
962        };
963        // reset() before the first frame must not arm the fallback.
964        clock.reset(Duration::ZERO);
965        assert!(
966            !clock.should_sync(),
967            "reset() before activate_fallback_if_no_audio must not arm the fallback"
968        );
969        assert_eq!(
970            clock.current_pts(),
971            Duration::ZERO,
972            "PTS must remain ZERO when fallback is not yet armed"
973        );
974    }
975
976    #[test]
977    fn master_clock_audio_rearm_should_advance_past_frozen_sample_pts() {
978        // Simulates audio-track-ended-before-video: samples_consumed is frozen
979        // at 45 222 ms worth of frames. After rearm_fallback_at(45.222s), the
980        // clock must advance beyond 45.222 s even though samples_consumed does
981        // not change.
982        let frozen_frames: u64 = (45_222 * 48_000) / 1_000; // frames for 45.222 s
983        let consumed = Arc::new(AtomicU64::new(frozen_frames));
984        let mut clock = MasterClock::Audio {
985            samples_consumed: Arc::clone(&consumed),
986            sample_rate: 48_000,
987            rate: 1.0,
988            samples_base: 0,
989            pts_base: Duration::ZERO,
990            fallback: None,
991        };
992        let frozen_pts = Duration::from_secs_f64(frozen_frames as f64 / 48_000.0);
993        // Before rearm: clock is frozen at the audio EOF position.
994        assert_eq!(
995            clock.current_pts(),
996            frozen_pts,
997            "clock must be frozen at audio EOF position before rearm"
998        );
999        // Re-arm at the frozen position.
1000        clock.rearm_fallback_at(frozen_pts);
1001        thread::sleep(Duration::from_millis(10));
1002        // After rearm: clock must have advanced past the frozen value.
1003        let pts_after = clock.current_pts();
1004        assert!(
1005            pts_after > frozen_pts,
1006            "clock must advance past frozen sample_pts after rearm; \
1007             frozen={frozen_pts:?} after={pts_after:?}"
1008        );
1009        assert!(
1010            pts_after < frozen_pts + Duration::from_secs(1),
1011            "clock must not advance 1 s in a unit test after rearm; got {pts_after:?}"
1012        );
1013    }
1014
1015    #[test]
1016    fn master_clock_audio_rearm_should_be_noop_for_system_clock() {
1017        let mut clock = MasterClock::System {
1018            started_at: Instant::now(),
1019            base_pts: Duration::ZERO,
1020            rate: 1.0,
1021        };
1022        // Must not panic and System behaviour must be unchanged.
1023        clock.rearm_fallback_at(Duration::from_secs(99));
1024        assert!(
1025            clock.should_sync(),
1026            "System clock must always sync after rearm_fallback_at"
1027        );
1028    }
1029
1030    #[test]
1031    fn audio_samples_snapshot_should_return_current_counter_for_audio_clock() {
1032        let consumed = Arc::new(AtomicU64::new(12_345));
1033        let clock = MasterClock::Audio {
1034            samples_consumed: Arc::clone(&consumed),
1035            sample_rate: 48_000,
1036            rate: 1.0,
1037            samples_base: 0,
1038            pts_base: Duration::ZERO,
1039            fallback: None,
1040        };
1041        assert_eq!(
1042            clock.audio_samples_snapshot(),
1043            12_345,
1044            "audio_samples_snapshot must reflect the current AtomicU64 value"
1045        );
1046    }
1047
1048    #[test]
1049    fn audio_samples_snapshot_should_return_zero_for_system_clock() {
1050        let clock = MasterClock::System {
1051            started_at: Instant::now(),
1052            base_pts: Duration::ZERO,
1053            rate: 1.0,
1054        };
1055        assert_eq!(
1056            clock.audio_samples_snapshot(),
1057            0,
1058            "audio_samples_snapshot must return 0 for System clock"
1059        );
1060    }
1061
1062    #[test]
1063    fn master_clock_audio_current_pts_should_advance_one_second_after_48k_frames() {
1064        // After the fix, MasterClock::Audio is always constructed with
1065        // sample_rate = DECODED_SAMPLE_RATE = 48_000 (the decoder output rate).
1066        // 48 000 stereo frames consumed at 48 000 Hz must equal exactly 1 second.
1067        let consumed = Arc::new(AtomicU64::new(48_000));
1068        let clock = MasterClock::Audio {
1069            samples_consumed: Arc::clone(&consumed),
1070            sample_rate: 48_000,
1071            rate: 1.0,
1072            samples_base: 0,
1073            pts_base: Duration::ZERO,
1074            fallback: None,
1075        };
1076        assert_eq!(
1077            clock.current_pts(),
1078            Duration::from_secs(1),
1079            "48 000 consumed frames / 48 000 Hz must equal exactly 1.0 s"
1080        );
1081    }
1082
1083    #[test]
1084    fn master_clock_audio_native_rate_mismatch_demonstrates_bug() {
1085        // Documents the pre-fix behaviour: if the source file's native rate
1086        // (e.g. 44 100 Hz) were used instead of the decoder's output rate,
1087        // 48 000 consumed frames would yield 1.088 s — 8.8 % too fast.
1088        // This test is deliberately left in to show what the wrong answer looks like.
1089        let consumed = Arc::new(AtomicU64::new(48_000));
1090        let clock_wrong = MasterClock::Audio {
1091            samples_consumed: Arc::clone(&consumed),
1092            sample_rate: 44_100, // wrong: source native rate, not decoder output rate
1093            rate: 1.0,
1094            samples_base: 0,
1095            pts_base: Duration::ZERO,
1096            fallback: None,
1097        };
1098        let pts_wrong = clock_wrong.current_pts();
1099        // 48 000 / 44 100 ≈ 1.0884 s — NOT 1.0 s
1100        assert!(
1101            pts_wrong > Duration::from_secs(1),
1102            "using native rate produces a clock that runs too fast; got {pts_wrong:?}"
1103        );
1104        assert!(
1105            pts_wrong < Duration::from_millis(1_100),
1106            "drift must be bounded to ~8.8 %; got {pts_wrong:?}"
1107        );
1108    }
1109
1110    #[test]
1111    fn master_clock_system_activate_fallback_should_be_noop() {
1112        let mut clock = MasterClock::System {
1113            started_at: Instant::now(),
1114            base_pts: Duration::ZERO,
1115            rate: 1.0,
1116        };
1117        // Must not panic and must not change System behaviour.
1118        clock.activate_fallback_if_no_audio(Duration::from_secs(99));
1119        assert!(
1120            clock.should_sync(),
1121            "System clock must always sync regardless of activate_fallback_if_no_audio"
1122        );
1123    }
1124
1125    #[test]
1126    fn set_rate_should_scale_audio_clock_pts() {
1127        // Step 1: 48 000 samples already consumed (1 s at 1×).
1128        let consumed = Arc::new(AtomicU64::new(48_000));
1129        let mut clock = MasterClock::Audio {
1130            samples_consumed: Arc::clone(&consumed),
1131            sample_rate: 48_000,
1132            rate: 1.0,
1133            samples_base: 0,
1134            pts_base: Duration::ZERO,
1135            fallback: None,
1136        };
1137
1138        // Sanity check: before rate change the clock reports exactly 1 s.
1139        assert_eq!(
1140            clock.current_pts(),
1141            Duration::from_secs(1),
1142            "before set_rate clock must report 1 s"
1143        );
1144
1145        // Step 2: change rate to 2×. The baseline snaps to the current 1 s PTS.
1146        clock.set_rate(2.0);
1147
1148        // Step 3: simulate another 1 real second of audio hardware drain
1149        // (48 000 more samples consumed).
1150        consumed.fetch_add(48_000, Ordering::Relaxed); // total = 96 000
1151
1152        // Expected: 1 s (at 1×) + 1 real second at 2× = 3 s media time.
1153        let pts = clock.current_pts();
1154        let expected = Duration::from_secs(3);
1155        let tolerance = Duration::from_millis(1);
1156        assert!(
1157            pts >= expected.saturating_sub(tolerance) && pts <= expected + tolerance,
1158            "1 s at 1× + 1 real-s at 2× must equal ≈3 s; got {pts:?}"
1159        );
1160    }
1161
1162    #[test]
1163    fn set_rate_system_clock_should_scale_elapsed() {
1164        let mut clock = MasterClock::System {
1165            started_at: Instant::now(),
1166            base_pts: Duration::ZERO,
1167            rate: 1.0,
1168        };
1169
1170        // Let a little time pass at 1×.
1171        thread::sleep(Duration::from_millis(10));
1172        let pts_before_rate_change = clock.current_pts();
1173        assert!(
1174            pts_before_rate_change >= Duration::from_millis(5),
1175            "clock must have advanced ~10 ms before rate change; got {pts_before_rate_change:?}"
1176        );
1177
1178        // Change to 2×. The baseline is re-anchored at the current PTS.
1179        clock.set_rate(2.0);
1180        let pts_at_rate_change = clock.current_pts();
1181        // The clock must not jump backward.
1182        assert!(
1183            pts_at_rate_change >= pts_before_rate_change,
1184            "clock must not go backward on set_rate; before={pts_before_rate_change:?} at={pts_at_rate_change:?}"
1185        );
1186
1187        // Let another 10 ms of wall time pass at 2×.
1188        thread::sleep(Duration::from_millis(10));
1189        let pts_after = clock.current_pts();
1190
1191        // At 2× rate, 10 ms wall time should produce ≥ 15 ms of media time
1192        // (allowing for scheduler imprecision).
1193        let media_elapsed = pts_after.saturating_sub(pts_at_rate_change);
1194        assert!(
1195            media_elapsed >= Duration::from_millis(15),
1196            "2× rate: 10 ms wall time must produce ≥15 ms media time; got media_elapsed={media_elapsed:?}"
1197        );
1198        assert!(
1199            pts_after > Duration::from_millis(20),
1200            "total PTS after ~10ms at 1× + ~10ms at 2× must be >20ms; got {pts_after:?}"
1201        );
1202    }
1203}