ommp.git

ommp

~ Oh My Music Player — a standalone terminal music player built with Rust and ratatui
git clone https://github.com/debuman/ommp.git
commit 9e51afb3605b901755bcad54865dded790170e76
parent 5309fc5c99d371a2d035996dd6a4d6b21bd3d9e0
Author: sqzer-x <110329244+sqzer-x@users.noreply.github.com>
Date: Sat, 1 Aug 2026 17:14:47 +0900

End the splash once the library is ready

The scan already ran on its own thread alongside the splash, so the two
were overlapped — but the splash held for a fixed two seconds regardless.
With the tag cache the scan finishes in tens of milliseconds, which made the
animation essentially the whole startup wait.

Completing the scan now skips the splash forward to its fade-out, the same
jump a keypress already performed, so a warm start takes about a second
instead of two and the fade still plays. A scan slower than the hold is
unaffected: the timeline runs to its normal end.

The scan is also spawned before the terminal image-protocol query and the
audio device open, both of which round-trip outside the process and neither
of which it needs to wait for.

Diffstat:
Msrc/main.rs | 33+++++++++++++--------------------
Msrc/ui/mod.rs | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
2 files changed, 93 insertion(+), 26 deletion(-)

diff --git a/src/main.rs b/src/main.rs
@@ -79,6 +79,12 @@ fn main() -> Result<()> {
 fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
     let music_dir = dirs_music_path();
 
+    // Start scanning first: querying the terminal for its image protocol and
+    // opening the audio device both round-trip to something outside this
+    // process, and the scan has no reason to wait for either.
+    let scan_dir = music_dir.clone();
+    let scan_handle = std::thread::spawn(move || library::Library::scan(&scan_dir));
+
     // Detect terminal image protocol BEFORE input thread steals stdin
     let picker = ratatui_image::picker::Picker::from_query_stdio()
         .unwrap_or_else(|_| ratatui_image::picker::Picker::from_fontsize((8, 16)));
@@ -98,12 +104,6 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()>
     app.set_audio_engine(audio_engine);
     app.set_event_tx(event_tx.clone());
 
-    // Scan library in background
-    let scan_dir = music_dir.clone();
-    let scan_handle = std::thread::spawn(move || {
-        library::Library::scan(&scan_dir)
-    });
-
     // UI
     let mut ui = ui::Ui::new(music_dir.clone(), picker);
 
@@ -175,6 +175,11 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()>
                                 scan_done = true;
                                 app.initial_scan_complete = true;
                                 dirty = true;
+                                // The splash exists to cover the scan. With the
+                                // cache that takes tens of milliseconds, so
+                                // sitting through the rest of a fixed two-second
+                                // timeline is the whole startup wait.
+                                ui.begin_splash_fade_out();
                                 _watcher = library::watcher::spawn_watcher(&music_dir, event_tx.clone());
                             }
                             Err(_) => {
@@ -200,19 +205,7 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()>
                     Event::Key(key) => {
                         // On key press during splash: jump to fade-out phase
                         if ui.show_splash {
-                            if let Some(start) = ui.splash_start {
-                                let elapsed = start.elapsed().as_secs_f32();
-                                if elapsed < 1.5 {
-                                    // Jump timeline to start of fade-out (1.5s mark).
-                                    // checked_sub because Instant is CLOCK_MONOTONIC:
-                                    // started within 1.5s of boot, plain `-` panics.
-                                    if let Some(t) = std::time::Instant::now()
-                                        .checked_sub(Duration::from_millis(1500))
-                                    {
-                                        ui.splash_start = Some(t);
-                                    }
-                                }
-                            }
+                            ui.begin_splash_fade_out();
                             vec![]
                         } else {
                         // Handle queue selection directly for playlist focus
@@ -247,7 +240,7 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()>
                         // Auto-dismiss splash after full timeline (2s)
                         if ui.show_splash {
                             if let Some(start) = ui.splash_start {
-                                if start.elapsed().as_secs_f32() >= 2.0 {
+                                if start.elapsed().as_secs_f32() >= ui::SPLASH_END {
                                     ui.show_splash = false;
                                     ui.splash_start = None;
                                 }
diff --git a/src/ui/mod.rs b/src/ui/mod.rs
@@ -19,6 +19,12 @@ use panes::list_pane::{self, ListPane};
 use panes::track_info_pane::TrackInfoPane;
 use panes::queue_pane::QueuePane;
 
+/// Splash timeline, in seconds: fade in, hold, then fade out until the end.
+/// The hold is skipped as soon as the library is ready.
+pub const SPLASH_FADE_IN: f32 = 0.5;
+pub const SPLASH_FADE_OUT_AT: f32 = 1.5;
+pub const SPLASH_END: f32 = 2.0;
+
 /// Index into `Ui::list_panes` for each list tab. Queue and Directories have
 /// their own pane types and never reach here.
 pub fn list_slot(tab: Tab) -> usize {
@@ -129,18 +135,37 @@ impl Ui {
         }
     }
 
+    /// Skip the splash forward to the start of its fade-out, if it has not got
+    /// there already. Used when the scan finishes and when a key is pressed —
+    /// the animation still completes, it just stops holding.
+    pub fn begin_splash_fade_out(&mut self) {
+        if !self.show_splash {
+            return;
+        }
+        let Some(start) = self.splash_start else { return };
+        if start.elapsed().as_secs_f32() >= SPLASH_FADE_OUT_AT {
+            return;
+        }
+        // checked_sub because Instant is CLOCK_MONOTONIC: started within
+        // SPLASH_FADE_OUT_AT of boot, plain `-` panics.
+        if let Some(t) = std::time::Instant::now()
+            .checked_sub(std::time::Duration::from_secs_f32(SPLASH_FADE_OUT_AT))
+        {
+            self.splash_start = Some(t);
+        }
+    }
+
     pub fn render(&mut self, frame: &mut Frame, app: &App) {
         if self.show_splash {
-            // Timeline: 0–0.5s fade-in, 0.5–1.5s hold, 1.5–2.0s fade-out
             let elapsed = self.splash_start
                 .map(|s| s.elapsed().as_secs_f32())
-                .unwrap_or(2.0);
-            let opacity = if elapsed < 0.5 {
-                elapsed / 0.5
-            } else if elapsed < 1.5 {
+                .unwrap_or(SPLASH_END);
+            let opacity = if elapsed < SPLASH_FADE_IN {
+                elapsed / SPLASH_FADE_IN
+            } else if elapsed < SPLASH_FADE_OUT_AT {
                 1.0
             } else {
-                (1.0 - (elapsed - 1.5) / 0.5).max(0.0)
+                (1.0 - (elapsed - SPLASH_FADE_OUT_AT) / (SPLASH_END - SPLASH_FADE_OUT_AT)).max(0.0)
             };
             about_modal::render_splash_screen(frame, frame.area(), &self.theme, opacity);
             return;
@@ -277,3 +302,52 @@ impl Ui {
         }
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn ui() -> Ui {
+        Ui::new(
+            std::path::PathBuf::from("."),
+            ratatui_image::picker::Picker::from_fontsize((8, 16)),
+        )
+    }
+
+    fn elapsed(ui: &Ui) -> f32 {
+        ui.splash_start.map(|s| s.elapsed().as_secs_f32()).unwrap_or(0.0)
+    }
+
+    #[test]
+    fn finishing_the_scan_skips_the_splash_hold() {
+        let mut ui = ui();
+        assert!(ui.show_splash);
+        assert!(elapsed(&ui) < SPLASH_FADE_OUT_AT);
+
+        ui.begin_splash_fade_out();
+
+        // Straight to the fade-out rather than sitting through the hold, which
+        // with a warm cache is the entire startup wait.
+        assert!(elapsed(&ui) >= SPLASH_FADE_OUT_AT);
+        assert!(elapsed(&ui) < SPLASH_END, "the fade-out still plays");
+    }
+
+    #[test]
+    fn skipping_twice_does_not_cut_the_fade_out_short() {
+        let mut ui = ui();
+        ui.begin_splash_fade_out();
+        let first = elapsed(&ui);
+        ui.begin_splash_fade_out();
+        assert!(elapsed(&ui) >= first, "must not jump past the end");
+        assert!(elapsed(&ui) < SPLASH_END);
+    }
+
+    #[test]
+    fn skipping_a_dismissed_splash_is_a_no_op() {
+        let mut ui = ui();
+        ui.show_splash = false;
+        ui.splash_start = None;
+        ui.begin_splash_fade_out();
+        assert!(ui.splash_start.is_none());
+    }
+}