Skip to main content

screen_app/
editor_session.rs

1//! `EditorSession` — backend playhead state + transport for the editor
2//! (ED.7 / M-EDIT).
3//!
4//! The editor's clock is the native [`EditorPlayer`] (a pure frame-indexed
5//! clock). It lives here, in the Tauri backend, alongside the future
6//! preview window; the webview is a thin transport UI that sends
7//! [`TransportAction`]s and renders the returned [`EditorStatusView`].
8//!
9//! Ticking follows `EditorPlayer`/`Driver`'s "host injects dt" model: while
10//! playing, the UI sends [`TransportAction::Tick`] from its animation loop
11//! and the clock advances by that `dt`. (When the native preview window
12//! lands it drives the same tick + renders the frame at `current_frame`.)
13
14#![allow(
15    clippy::needless_pass_by_value,
16    reason = "Tauri injects State<'_, T> into #[command] fns by value; it is borrowed, not moved"
17)]
18
19use std::sync::Mutex;
20use std::time::Duration;
21
22use playback::EditorPlayer;
23use serde::{Deserialize, Serialize};
24use tauri::State;
25
26/// Serializable snapshot of the editor playhead for the transport UI.
27#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
28pub struct EditorStatusView {
29    /// Current playhead frame.
30    pub current_frame: u64,
31    /// Total project length in frames.
32    pub duration_frames: u64,
33    /// Whether the clock is advancing.
34    pub playing: bool,
35    /// Project frame rate.
36    pub fps: u32,
37    /// Playback rate multiplier.
38    pub rate: f32,
39    /// In-point (inclusive).
40    pub in_frame: u64,
41    /// Out-point (exclusive).
42    pub out_frame: u64,
43    /// Whether looping over the in/out range is enabled.
44    pub looping: bool,
45}
46
47/// A transport command from the UI. Enum-dispatched through the single
48/// `editor_transport` command so registration stays a one-liner.
49#[derive(Clone, Copy, Debug, PartialEq, Deserialize)]
50#[serde(tag = "kind", rename_all = "snake_case")]
51pub enum TransportAction {
52    /// Start advancing.
53    Play,
54    /// Stop advancing.
55    Pause,
56    /// Toggle play/pause.
57    TogglePlay,
58    /// Advance the clock by `dt_ms` milliseconds (the UI's per-frame tick).
59    Tick {
60        /// Elapsed milliseconds since the last tick.
61        dt_ms: u32,
62    },
63    /// Seek to an exact frame.
64    Seek {
65        /// Target frame.
66        frame: u64,
67    },
68    /// Step `delta` frames (negative = back) and pause.
69    Step {
70        /// Frame delta.
71        delta: i64,
72    },
73    /// Set the playback rate.
74    SetRate {
75        /// New rate multiplier.
76        rate: f32,
77    },
78    /// Set the in/out points (order-independent).
79    SetInOut {
80        /// One bound.
81        a: u64,
82        /// The other bound.
83        b: u64,
84    },
85    /// Clear the in/out points to the full project.
86    ClearInOut,
87    /// Enable/disable looping.
88    SetLooping {
89        /// Loop flag.
90        looping: bool,
91    },
92    /// Update the project length after an edit changed it (ED.11 ripple /
93    /// trim) so the clock's range tracks the new timeline.
94    SetDuration {
95        /// New total project length in frames.
96        frames: u64,
97    },
98    /// No-op — just read the current status.
99    Status,
100}
101
102/// The editor's playhead clock for one open clip.
103pub struct EditorSession {
104    player: EditorPlayer,
105}
106
107impl EditorSession {
108    /// A session for a project of `duration_frames` at `fps`.
109    #[must_use]
110    pub fn new(fps: u32, duration_frames: u64) -> Self {
111        Self {
112            player: EditorPlayer::new(fps, duration_frames),
113        }
114    }
115
116    /// Apply a transport action and return the resulting status.
117    pub fn apply(&mut self, action: TransportAction) -> EditorStatusView {
118        match action {
119            TransportAction::Play => self.player.play(),
120            TransportAction::Pause => self.player.pause(),
121            TransportAction::TogglePlay => self.player.toggle_play(),
122            TransportAction::Tick { dt_ms } => {
123                self.player.tick(Duration::from_millis(u64::from(dt_ms)));
124            }
125            TransportAction::Seek { frame } => self.player.seek(frame),
126            TransportAction::Step { delta } => self.player.step(delta),
127            TransportAction::SetRate { rate } => self.player.set_rate(rate),
128            TransportAction::SetInOut { a, b } => self.player.set_in_out(a, b),
129            TransportAction::ClearInOut => self.player.clear_in_out(),
130            TransportAction::SetLooping { looping } => self.player.set_looping(looping),
131            TransportAction::SetDuration { frames } => self.player.set_duration(frames),
132            TransportAction::Status => {}
133        }
134        self.status()
135    }
136
137    /// Current playhead status.
138    #[must_use]
139    pub fn status(&self) -> EditorStatusView {
140        EditorStatusView {
141            current_frame: self.player.current_frame(),
142            duration_frames: self.player.duration_frames(),
143            playing: self.player.is_playing(),
144            fps: self.player.fps(),
145            rate: self.player.rate(),
146            in_frame: self.player.in_frame(),
147            out_frame: self.player.out_frame(),
148            looping: self.player.looping(),
149        }
150    }
151}
152
153/// Tauri-managed editor session state. `None` until a clip is opened.
154#[derive(Default)]
155pub struct EditorSessionState(pub Mutex<Option<EditorSession>>);
156
157impl std::fmt::Debug for EditorSessionState {
158    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159        f.debug_struct("EditorSessionState").finish_non_exhaustive()
160    }
161}
162
163/// Apply a transport action to the active editor session. Returns `None`
164/// when no clip is open.
165#[tauri::command]
166pub fn editor_transport(
167    action: TransportAction,
168    state: State<'_, EditorSessionState>,
169) -> Option<EditorStatusView> {
170    let mut guard = state
171        .0
172        .lock()
173        .unwrap_or_else(std::sync::PoisonError::into_inner);
174    let session = guard.as_mut()?;
175    Some(session.apply(action))
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    fn session() -> EditorSession {
183        EditorSession::new(30, 900)
184    }
185
186    #[test]
187    fn play_pause_reflected_in_status() {
188        let mut s = session();
189        assert!(!s.status().playing);
190        assert!(s.apply(TransportAction::Play).playing);
191        assert!(!s.apply(TransportAction::Pause).playing);
192        assert!(s.apply(TransportAction::TogglePlay).playing);
193    }
194
195    #[test]
196    fn tick_advances_while_playing() {
197        let mut s = session();
198        s.apply(TransportAction::Play);
199        let st = s.apply(TransportAction::Tick { dt_ms: 1000 }); // 1s @ 30fps
200        assert_eq!(st.current_frame, 30);
201    }
202
203    #[test]
204    fn seek_and_step() {
205        let mut s = session();
206        assert_eq!(
207            s.apply(TransportAction::Seek { frame: 100 }).current_frame,
208            100
209        );
210        assert_eq!(
211            s.apply(TransportAction::Step { delta: 1 }).current_frame,
212            101
213        );
214        let st = s.apply(TransportAction::Step { delta: -5 });
215        assert_eq!(st.current_frame, 96);
216        assert!(!st.playing, "stepping pauses");
217    }
218
219    #[test]
220    fn rate_and_in_out_and_loop_in_status() {
221        let mut s = session();
222        let st = s.apply(TransportAction::SetRate { rate: 2.0 });
223        assert!((st.rate - 2.0).abs() < 1e-6);
224        let st = s.apply(TransportAction::SetInOut { a: 200, b: 100 });
225        assert_eq!((st.in_frame, st.out_frame), (100, 200));
226        assert!(
227            s.apply(TransportAction::SetLooping { looping: true })
228                .looping
229        );
230        let st = s.apply(TransportAction::ClearInOut);
231        assert_eq!((st.in_frame, st.out_frame), (0, 900));
232    }
233
234    #[test]
235    fn status_action_is_a_pure_read() {
236        let mut s = session();
237        s.apply(TransportAction::Seek { frame: 42 });
238        let a = s.apply(TransportAction::Status);
239        let b = s.apply(TransportAction::Status);
240        assert_eq!(a, b);
241        assert_eq!(a.current_frame, 42);
242    }
243}