use std::time::{Duration, Instant};
enum ClockState {
Stopped,
Running { started_at: Instant, base: Duration },
Paused { frozen_at: Duration },
}
pub struct PlaybackClock {
state: ClockState,
rate: f64,
seek_offset: Duration,
}
impl PlaybackClock {
#[must_use]
pub fn new() -> Self {
Self {
state: ClockState::Stopped,
rate: 1.0,
seek_offset: Duration::ZERO,
}
}
pub fn start(&mut self) {
let base = match &self.state {
ClockState::Running { .. } => return,
ClockState::Stopped => self.seek_offset,
ClockState::Paused { frozen_at } => *frozen_at,
};
self.state = ClockState::Running {
started_at: Instant::now(),
base,
};
}
pub fn stop(&mut self) {
self.state = ClockState::Stopped;
self.seek_offset = Duration::ZERO;
}
pub fn pause(&mut self) {
if let ClockState::Running { started_at, base } = &self.state {
let elapsed = started_at.elapsed().mul_f64(self.rate);
self.state = ClockState::Paused {
frozen_at: *base + elapsed,
};
}
}
pub fn resume(&mut self) {
if let ClockState::Paused { frozen_at } = self.state {
self.state = ClockState::Running {
started_at: Instant::now(),
base: frozen_at,
};
}
}
#[must_use]
pub fn current_time(&self) -> Duration {
match &self.state {
ClockState::Stopped => Duration::ZERO,
ClockState::Paused { frozen_at } => *frozen_at,
ClockState::Running { started_at, base } => {
*base + started_at.elapsed().mul_f64(self.rate)
}
}
}
#[must_use]
pub fn current_pts(&self) -> Duration {
match &self.state {
ClockState::Stopped => self.seek_offset,
_ => self.current_time(),
}
}
#[must_use]
pub fn is_running(&self) -> bool {
matches!(self.state, ClockState::Running { .. })
}
pub fn set_rate(&mut self, rate: f64) {
if rate <= 0.0 {
return;
}
if let ClockState::Running { started_at, base } = &mut self.state {
let elapsed = started_at.elapsed().mul_f64(self.rate);
*base += elapsed;
*started_at = Instant::now();
}
self.rate = rate;
}
#[must_use]
pub fn rate(&self) -> f64 {
self.rate
}
pub fn set_position(&mut self, pts: Duration) {
self.seek_offset = pts;
if matches!(self.state, ClockState::Running { .. }) {
self.state = ClockState::Running {
started_at: Instant::now(),
base: pts,
};
} else if matches!(self.state, ClockState::Paused { .. }) {
self.state = ClockState::Paused { frozen_at: pts };
}
}
}
impl Default for PlaybackClock {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::thread;
#[test]
fn clock_stopped_should_return_zero() {
let clock = PlaybackClock::new();
assert_eq!(clock.current_time(), Duration::ZERO);
let mut clock = PlaybackClock::new();
clock.start();
thread::sleep(Duration::from_millis(5));
clock.stop();
assert_eq!(
clock.current_time(),
Duration::ZERO,
"current_time() must be ZERO after stop()"
);
}
#[test]
fn clock_paused_should_freeze_at_pause_time() {
let mut clock = PlaybackClock::new();
clock.start();
thread::sleep(Duration::from_millis(10));
clock.pause();
let t1 = clock.current_time();
thread::sleep(Duration::from_millis(10));
let t2 = clock.current_time();
assert_eq!(t1, t2, "current_time() must not advance while paused");
assert!(
!clock.is_running(),
"clock must not report running while paused"
);
}
#[test]
fn clock_resumed_should_continue_from_pause() {
let mut clock = PlaybackClock::new();
clock.start();
thread::sleep(Duration::from_millis(10));
clock.pause();
let t_paused = clock.current_time();
thread::sleep(Duration::from_millis(10));
assert_eq!(clock.current_time(), t_paused);
clock.resume();
assert!(clock.is_running());
thread::sleep(Duration::from_millis(10));
let t_after = clock.current_time();
assert!(
t_after > t_paused,
"current_time() must advance after resume(); paused={t_paused:?} after={t_after:?}"
);
}
#[test]
fn clock_start_should_be_noop_when_already_running() {
let mut clock = PlaybackClock::new();
clock.start();
thread::sleep(Duration::from_millis(10));
let t_before = clock.current_time();
clock.start();
let t_after = clock.current_time();
assert!(
t_after >= t_before,
"second start() must not reset the clock; before={t_before:?} after={t_after:?}"
);
}
#[test]
fn clock_resume_should_be_noop_when_not_paused() {
let mut clock = PlaybackClock::new();
clock.resume();
assert!(!clock.is_running());
assert_eq!(clock.current_time(), Duration::ZERO);
clock.start();
thread::sleep(Duration::from_millis(5));
let t = clock.current_time();
clock.resume(); assert!(clock.is_running());
assert!(clock.current_time() >= t);
}
#[test]
fn clock_default_should_equal_new() {
let a = PlaybackClock::new();
let b = PlaybackClock::default();
assert_eq!(a.current_time(), b.current_time());
assert_eq!(a.is_running(), b.is_running());
}
#[test]
fn set_rate_should_reject_non_positive_values() {
let mut clock = PlaybackClock::new();
clock.set_rate(0.0);
assert!(
(clock.rate() - 1.0).abs() < f64::EPSILON,
"rate must remain 1.0 after set_rate(0.0)"
);
clock.set_rate(-1.0);
assert!(
(clock.rate() - 1.0).abs() < f64::EPSILON,
"rate must remain 1.0 after set_rate(-1.0)"
);
}
#[test]
fn set_rate_should_update_rate_when_stopped_or_paused() {
let mut clock = PlaybackClock::new();
clock.set_rate(0.5);
assert!((clock.rate() - 0.5).abs() < f64::EPSILON);
let mut clock = PlaybackClock::new();
clock.start();
clock.pause();
clock.set_rate(2.0);
assert!((clock.rate() - 2.0).abs() < f64::EPSILON);
assert!(
!clock.is_running(),
"clock must remain paused after set_rate"
);
}
#[test]
fn set_rate_running_should_not_jump_current_time() {
let mut clock = PlaybackClock::new();
clock.start();
thread::sleep(Duration::from_millis(10));
let before = clock.current_time();
clock.set_rate(2.0);
let after = clock.current_time();
assert!(
after >= before,
"current_time() must not go backward on set_rate; before={before:?} after={after:?}"
);
assert!(
after - before < Duration::from_millis(20),
"current_time() must not jump forward on set_rate; before={before:?} after={after:?}"
);
assert!((clock.rate() - 2.0).abs() < f64::EPSILON);
}
#[test]
#[ignore = "performance thresholds are environment-dependent; run explicitly with -- --include-ignored"]
fn rate_two_x_should_advance_at_double_speed() {
let mut clock = PlaybackClock::new();
clock.set_rate(2.0);
clock.start();
thread::sleep(Duration::from_millis(50));
let elapsed = clock.current_time();
assert!(
elapsed >= Duration::from_millis(80),
"2× rate: expected ≥80 ms after 50 ms wall time, got {elapsed:?}"
);
}
#[test]
fn set_position_should_shift_pts_by_seek_offset() {
let seek_target = Duration::from_secs(30);
let mut clock = PlaybackClock::new();
clock.set_position(seek_target);
assert_eq!(
clock.current_pts(),
seek_target,
"current_pts() must reflect seek_offset when stopped"
);
clock.start();
let pts = clock.current_pts();
assert!(
pts >= seek_target,
"current_pts() must be ≥ seek target after start(); target={seek_target:?} pts={pts:?}"
);
assert!(
clock.is_running(),
"clock must be running after set_position + start()"
);
}
#[test]
fn set_position_while_paused_should_update_frozen_time() {
let mut clock = PlaybackClock::new();
clock.start();
thread::sleep(Duration::from_millis(5));
clock.pause();
let seek_target = Duration::from_secs(10);
clock.set_position(seek_target);
let pts = clock.current_pts();
assert_eq!(
pts, seek_target,
"frozen time must update to seek target; expected={seek_target:?} got={pts:?}"
);
assert!(
!clock.is_running(),
"clock must remain paused after set_position"
);
clock.resume();
thread::sleep(Duration::from_millis(5));
let pts_after = clock.current_pts();
assert!(
pts_after > seek_target,
"current_pts() must advance past seek target after resume(); target={seek_target:?} after={pts_after:?}"
);
}
#[test]
fn set_position_while_running_should_continue_from_new_position() {
let mut clock = PlaybackClock::new();
clock.start();
thread::sleep(Duration::from_millis(5));
let seek_target = Duration::from_secs(60);
clock.set_position(seek_target);
let pts = clock.current_pts();
assert!(
pts >= seek_target,
"current_pts() must be ≥ seek target immediately after set_position while running; \
target={seek_target:?} pts={pts:?}"
);
assert!(
clock.is_running(),
"clock must remain running after set_position"
);
}
#[test]
fn stop_should_clear_seek_offset() {
let mut clock = PlaybackClock::new();
clock.set_position(Duration::from_secs(30));
clock.stop();
assert_eq!(
clock.current_pts(),
Duration::ZERO,
"stop() must reset seek_offset to ZERO"
);
}
}