Skip to main content

rdi_core/
timeline.rs

1use std::sync::{Arc, Condvar, Mutex, MutexGuard};
2use std::thread::ThreadId;
3use std::time::{Duration, Instant};
4
5use crossbeam_channel::{Receiver, Sender, bounded, unbounded};
6
7use crate::{AnimationHandle, DesktopError, FinalCommitOutcome, FinishReason, IconAnimationState, IconId};
8
9#[derive(Clone, Copy, Debug, PartialEq, Eq)]
10pub enum TimelineCloseMode {
11    RestoreOrigins,
12    LeaveInPlace,
13    TeleportToTarget,
14}
15
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17pub enum TimelineState {
18    Paused,
19    Playing,
20    Closed,
21}
22
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub enum PlaybackOutcome {
25    Reached,
26    Interrupted,
27    Closed,
28}
29
30struct State {
31    position: f64,
32    speed: f64,
33    phase: TimelineState,
34    worker: Option<ThreadId>,
35    icons: Vec<IconAnimationState>,
36    real_icons_visible: bool,
37}
38
39type Shared = Arc<Mutex<State>>;
40
41fn lock(shared: &Shared) -> MutexGuard<'_, State> {
42    shared.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
43}
44
45fn check_thread(shared: &Shared) -> Result<(), DesktopError> {
46    if lock(shared).worker == Some(std::thread::current().id()) {
47        return Err(DesktopError::BackendUnavailable("blocking timeline calls cannot run on the animation worker".into()));
48    }
49    Ok(())
50}
51
52fn closed_error() -> DesktopError {
53    DesktopError::BackendUnavailable("timeline session is closed".into())
54}
55
56pub struct PlaybackHandle {
57    result: Arc<Completion>,
58    shared: Shared,
59}
60
61#[derive(Default)]
62struct Completion {
63    outcome: Mutex<Option<PlaybackOutcome>>,
64    ready: Condvar,
65}
66
67impl Completion {
68    fn complete(&self, outcome: PlaybackOutcome) {
69        *self.outcome.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(outcome);
70        self.ready.notify_all();
71    }
72}
73
74impl PlaybackHandle {
75    pub fn wait(&self) -> Result<PlaybackOutcome, DesktopError> {
76        check_thread(&self.shared)?;
77        let state = self.result.outcome.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
78        let state = self.result.ready.wait_while(state, |outcome| outcome.is_none())
79            .unwrap_or_else(std::sync::PoisonError::into_inner);
80        Ok(state.unwrap())
81    }
82
83    pub fn wait_timeout(&self, timeout: Duration) -> Result<Option<PlaybackOutcome>, DesktopError> {
84        check_thread(&self.shared)?;
85        let state = self.result.outcome.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
86        let (state, _) = self.result.ready.wait_timeout_while(state, timeout, |outcome| outcome.is_none())
87            .unwrap_or_else(std::sync::PoisonError::into_inner);
88        Ok(*state)
89    }
90}
91
92pub struct TimelineSession {
93    tx: Sender<Request>,
94    shared: Shared,
95    handle: AnimationHandle,
96}
97
98impl TimelineSession {
99    pub(crate) fn pair(handle: AnimationHandle) -> (Self, Runtime) {
100        let (tx, rx) = unbounded();
101        let shared = Arc::new(Mutex::new(State {
102            position: 0.0, speed: 1.0, phase: TimelineState::Paused,
103            worker: None, icons: Vec::new(), real_icons_visible: false,
104        }));
105        let runtime = Runtime {
106            rx, shared: shared.clone(), clock: Clock::new(0.0, Instant::now()),
107            playback: None, acknowledgements: Vec::new(), captures: Vec::new(), reached: false,
108        };
109        (Self { tx, shared, handle }, runtime)
110    }
111
112    fn request(&self, command: Command) -> Result<(), DesktopError> {
113        check_thread(&self.shared)?;
114        let (reply, response) = bounded(1);
115        self.tx.send(Request { command, reply }).map_err(|_| closed_error())?;
116        response.recv().map_err(|_| closed_error())?
117    }
118
119    pub fn play_to(&self, position: f64, speed: f64) -> Result<PlaybackHandle, DesktopError> {
120        validate_position(position)?;
121        validate_speed(speed)?;
122        let result = Arc::new(Completion::default());
123        self.request(Command::Play { position, speed, result: result.clone() })?;
124        Ok(PlaybackHandle { result, shared: self.shared.clone() })
125    }
126
127    pub fn seek(&self, position: f64) -> Result<(), DesktopError> {
128        validate_position(position)?;
129        self.request(Command::Seek(position))
130    }
131
132    pub fn pause(&self) -> Result<(), DesktopError> { self.request(Command::Pause) }
133
134    pub fn capture(&self) -> Result<crate::CapturedFrame, DesktopError> {
135        let (reply, response) = bounded(1);
136        self.request(Command::Capture(reply))?;
137        response.recv().map_err(|_| closed_error())?
138    }
139
140    pub fn seek_and_capture(&self, position: f64) -> Result<crate::CapturedFrame, DesktopError> {
141        validate_position(position)?;
142        let (reply, response) = bounded(1);
143        self.request(Command::SeekCapture(position, reply))?;
144        response.recv().map_err(|_| closed_error())?
145    }
146
147    pub fn set_speed(&self, speed: f64) -> Result<(), DesktopError> {
148        validate_speed(speed)?;
149        self.request(Command::Speed(speed))
150    }
151
152    pub fn position(&self) -> f64 { lock(&self.shared).position }
153    pub fn real_icons_visible(&self) -> bool { lock(&self.shared).real_icons_visible }
154
155    pub fn set_real_icons_visible(&self, visible: bool) -> Result<(), DesktopError> {
156        self.request(Command::RealIconsVisible(visible))
157    }
158    pub fn speed(&self) -> f64 { lock(&self.shared).speed }
159    pub fn state(&self) -> TimelineState { lock(&self.shared).phase }
160    pub fn snapshot(&self) -> Vec<IconAnimationState> { lock(&self.shared).icons.clone() }
161    pub fn missing_icons(&self) -> Vec<IconId> { self.handle.missing_icons() }
162    pub fn finish_reason(&self) -> Option<FinishReason> { self.handle.finish_reason() }
163    pub fn final_commit(&self) -> Option<FinalCommitOutcome> { self.handle.final_commit() }
164
165    pub fn close(&self, mode: TimelineCloseMode) -> Result<FinishReason, DesktopError> {
166        check_thread(&self.shared)?;
167        if self.state() != TimelineState::Closed {
168            let _ = self.request(Command::Close(mode));
169        }
170        Ok(self.handle.wait())
171    }
172}
173
174impl Drop for TimelineSession {
175    fn drop(&mut self) {
176        let (reply, _) = bounded(1);
177        let _ = self.tx.send(Request { command: Command::Close(TimelineCloseMode::RestoreOrigins), reply });
178    }
179}
180
181fn validate_position(position: f64) -> Result<(), DesktopError> {
182    if !position.is_finite() || !(0.0..=1.0).contains(&position) {
183        return Err(DesktopError::InvalidDuration("timeline position must be finite and within [0, 1]".into()));
184    }
185    Ok(())
186}
187
188fn validate_speed(speed: f64) -> Result<(), DesktopError> {
189    if !speed.is_finite() || speed <= 0.0 {
190        return Err(DesktopError::InvalidDuration("timeline speed must be finite and positive".into()));
191    }
192    Ok(())
193}
194
195pub(crate) struct Request {
196    command: Command,
197    reply: Sender<Result<(), DesktopError>>,
198}
199
200enum Command {
201    Capture(Sender<Result<crate::CapturedFrame, DesktopError>>),
202    SeekCapture(f64, Sender<Result<crate::CapturedFrame, DesktopError>>),
203    Play { position: f64, speed: f64, result: Arc<Completion> },
204    Seek(f64),
205    Pause,
206    Speed(f64),
207    RealIconsVisible(bool),
208    Close(TimelineCloseMode),
209}
210
211struct Clock {
212    duration: f64,
213    anchor: f64,
214    wall: Instant,
215    speed: f64,
216    destination: Option<f64>,
217}
218
219impl Clock {
220    fn new(duration: f64, now: Instant) -> Self {
221        Self { duration, anchor: 0.0, wall: now, speed: 1.0, destination: None }
222    }
223
224    fn position_at(&self, now: Instant) -> f64 {
225        let Some(destination) = self.destination else { return self.anchor; };
226        if self.duration == 0.0 { return destination; }
227        let distance = now.saturating_duration_since(self.wall).as_secs_f64() * self.speed / self.duration;
228        if destination >= self.anchor { (self.anchor + distance).min(destination) }
229        else { (self.anchor - distance).max(destination) }
230    }
231
232    fn reanchor(&mut self, now: Instant) {
233        self.anchor = self.position_at(now);
234        self.wall = now;
235    }
236}
237
238pub(crate) struct Runtime {
239    pub rx: Receiver<Request>,
240    shared: Shared,
241    clock: Clock,
242    playback: Option<Arc<Completion>>,
243    acknowledgements: Vec<Sender<Result<(), DesktopError>>>,
244    captures: Vec<Sender<Result<crate::CapturedFrame, DesktopError>>>,
245    reached: bool,
246}
247
248impl Runtime {
249    pub fn activate(&mut self, duration: f64) {
250        self.clock = Clock::new(duration, Instant::now());
251        lock(&self.shared).worker = Some(std::thread::current().id());
252    }
253
254    fn end_playback(&mut self, outcome: PlaybackOutcome) {
255        if let Some(reply) = self.playback.take() { reply.complete(outcome); }
256    }
257
258    pub fn control(&mut self, request: Request, now: Instant, backend: &mut dyn crate::DesktopBackend) -> Option<TimelineCloseMode> {
259        if let Command::Capture(reply) = request.command {
260            let seconds = lock(&self.shared).position * self.clock.duration;
261            let _ = reply.send(backend.capture_overlay(seconds));
262            let _ = request.reply.send(Ok(()));
263            return None;
264        }
265        if let Command::RealIconsVisible(visible) = request.command {
266            let result = backend.set_real_icons_visible(visible);
267            if result.is_ok() { lock(&self.shared).real_icons_visible = visible; }
268            let _ = request.reply.send(result);
269            return None;
270        }
271        self.clock.reanchor(now);
272        self.acknowledgements.push(request.reply);
273        match request.command {
274            Command::Capture(_) => unreachable!(),
275            Command::SeekCapture(position, reply) => {
276                self.end_playback(PlaybackOutcome::Interrupted);
277                self.clock.anchor = position;
278                self.clock.destination = None;
279                self.captures.push(reply);
280            }
281            Command::Play { position, speed, result } => {
282                self.end_playback(PlaybackOutcome::Interrupted);
283                self.clock.destination = Some(position);
284                self.clock.speed = speed;
285                self.playback = Some(result);
286            }
287            Command::Seek(position) => {
288                self.end_playback(PlaybackOutcome::Interrupted);
289                self.clock.anchor = position;
290                self.clock.destination = None;
291            }
292            Command::Pause => {
293                self.end_playback(PlaybackOutcome::Interrupted);
294                self.clock.destination = None;
295            }
296            Command::Speed(speed) => self.clock.speed = speed,
297            Command::RealIconsVisible(_) => unreachable!(),
298            Command::Close(mode) => return Some(mode),
299        }
300        None
301    }
302
303    pub fn sample(&mut self, now: Instant) -> (f64, f64) {
304        let position = self.clock.position_at(now);
305        self.reached = self.clock.destination == Some(position);
306        if self.reached {
307            self.clock.anchor = position;
308            self.clock.destination = None;
309        }
310        (position, position * self.clock.duration)
311    }
312
313    pub fn playing(&self) -> bool { self.clock.destination.is_some() }
314
315    pub fn capture_pending(&mut self, backend: &mut dyn crate::DesktopBackend, seconds: f64) {
316        for reply in self.captures.drain(..) { let _ = reply.send(backend.capture_overlay(seconds)); }
317    }
318
319    pub fn publish(&mut self, position: f64, icons: Vec<IconAnimationState>) {
320        {
321            let mut state = lock(&self.shared);
322            state.position = position;
323            state.speed = self.clock.speed;
324            state.phase = if self.playing() { TimelineState::Playing } else { TimelineState::Paused };
325            state.icons = icons;
326        }
327        if self.reached { self.end_playback(PlaybackOutcome::Reached); }
328        self.acknowledge();
329    }
330
331    pub fn acknowledge(&mut self) {
332        for reply in self.acknowledgements.drain(..) { let _ = reply.send(Ok(())); }
333    }
334
335    pub fn finish(&mut self) {
336        lock(&self.shared).phase = TimelineState::Closed;
337        self.end_playback(PlaybackOutcome::Closed);
338    }
339}
340
341impl Drop for Runtime {
342    fn drop(&mut self) {
343        self.finish();
344        for reply in self.acknowledgements.drain(..) { let _ = reply.send(Err(closed_error())); }
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351
352    #[test]
353    fn anchored_clock_reverses_and_changes_speed_without_jumps() {
354        let now = Instant::now();
355        let mut clock = Clock::new(2.0, now);
356        clock.destination = Some(0.5);
357        assert_eq!(clock.position_at(now + Duration::from_secs(3)), 0.5);
358        clock.reanchor(now + Duration::from_millis(500));
359        assert_eq!(clock.anchor, 0.25);
360        clock.speed = 2.0;
361        assert_eq!(clock.position_at(now + Duration::from_millis(750)), 0.5);
362        clock.reanchor(now + Duration::from_millis(750));
363        clock.destination = Some(0.0);
364        assert_eq!(clock.position_at(now + Duration::from_secs(1)), 0.25);
365        clock.reanchor(now + Duration::from_secs(1));
366        clock.destination = None;
367        assert_eq!(clock.position_at(now + Duration::from_secs(100)), 0.25);
368    }
369
370    #[test]
371    fn rejects_invalid_controls() {
372        for value in [f64::NAN, f64::INFINITY, -0.1, 1.1] { assert!(validate_position(value).is_err()); }
373        for value in [f64::NAN, f64::INFINITY, -1.0, 0.0] { assert!(validate_speed(value).is_err()); }
374    }
375}