Skip to main content

kobo_core/
clock.rs

1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 Nayeem Bin Ahsan
3//! `SharedClock` - the single playback time source (decision 2).
4//!
5//! Both the Player (worker thread) and the UI (main thread) read the SAME
6//! clock, so highlight and audio physically can't drift. The Player writes
7//! position; the UI's ~15 Hz timer reads [`SharedClock::position_s`] and
8//! highlights the `WordMark` whose `time_s` <= position.
9//!
10//! Cross-thread via a relaxed `AtomicU64` (nanoseconds) - cheap and
11//! single-producer (Player) / single-consumer (UI timer).
12use std::sync::atomic::{AtomicU64, Ordering};
13use std::sync::Arc;
14
15/// Shared playback position in nanoseconds (0 = start of playback).
16#[derive(Clone, Debug)]
17pub struct SharedClock(Arc<AtomicU64>);
18
19impl Default for SharedClock {
20    fn default() -> Self {
21        Self::new()
22    }
23}
24
25impl SharedClock {
26    pub fn new() -> Self {
27        Self(Arc::new(AtomicU64::new(0)))
28    }
29    /// Player (producer) sets the current position.
30    pub fn set_ns(&self, ns: u64) {
31        self.0.store(ns, Ordering::Relaxed);
32    }
33    /// UI timer (consumer) reads the current position in seconds.
34    pub fn position_s(&self) -> f64 {
35        self.0.load(Ordering::Relaxed) as f64 / 1e9
36    }
37}
38
39/// Pick the index of the last `WordMark` whose `time_s` is <= `position_s`
40/// (the currently-highlighted word). Returns None before the first word.
41pub fn highlight_index(word_times: &[f64], position_s: f64) -> Option<usize> {
42    // word_times assumed ascending; find the last index with time_s <= position.
43    let mut found = None;
44    for (i, &t) in word_times.iter().enumerate() {
45        if t <= position_s {
46            found = Some(i);
47        } else {
48            break;
49        }
50    }
51    found
52}
53
54#[cfg(test)]
55mod tests {
56    use super::*;
57
58    #[test]
59    fn highlight_advances_with_clock() {
60        let times = [0.0, 0.5, 1.0, 1.5, 2.0]; // word start times
61        assert_eq!(highlight_index(&times, 0.0), Some(0));
62        assert_eq!(highlight_index(&times, 0.4), Some(0));
63        assert_eq!(highlight_index(&times, 0.5), Some(1));
64        assert_eq!(highlight_index(&times, 1.24), Some(2));
65        assert_eq!(highlight_index(&times, 2.0), Some(4));
66        assert_eq!(highlight_index(&times, 5.0), Some(4)); // past end -> last
67    }
68
69    #[test]
70    fn clock_is_shared_across_clone() {
71        let player_clock = SharedClock::new();
72        let ui_clock = player_clock.clone(); // shared (Arc)
73        assert_eq!(ui_clock.position_s(), 0.0);
74        player_clock.set_ns(1_500_000_000); // 1.5s
75        assert_eq!(ui_clock.position_s(), 1.5); // UI sees the Player's write
76    }
77}