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 f9d25e6adbbf8a682ad9833869da6eeb9d6175ca
parent c28ef671890883609ba3c7c50818c28c9b228f13
Author: sqzer-x <110329244+sqzer-x@users.noreply.github.com>
Date: Sat, 1 Aug 2026 15:34:38 +0900

Stop panics and silent data loss from destroying a session

Terminal restore only ran on the normal return path, so any panic left raw
mode, the alternate screen and mouse tracking on with the message scrolled
away — install a panic hook that restores first. A backend write error hit
the same class of problem, returning from the event loop before state was
saved; record it and fall through instead.

Playlists were pruned rather than preserved: replace_library dropped every
path the new library lacked and the pruned result was written to disk on
exit, so one unmounted drive erased them for good. Refuse a scan that comes
back empty against a non-empty library, and park unresolved paths so they
come back when the files do.

The watcher seeded its debounce timestamp with the current time and never
reset it after scanning, firing two full rescans about two seconds into
every session with nothing changed — each one yanking the list cursor to the
top. Only arm the debounce on a real event.

Other crashes: the search modal cached raw library indices that no rescan
path remapped, panicking on the next draw; play_next, play_prev and the
queue renderer indexed library.tracks unchecked, now funnelled through one
guarded play_queue_position; the border-drag math underflowed u16 when
dragged past the dashboard edge and called clamp with min > max on a corrupt
pane_widths, both replaced by split_widths; and the splash key handler
subtracted from Instant without checked_sub.

State is now written via a temporary file and renamed, every field carries a
serde default so one unknown key no longer discards the whole file, and
pane_widths is validated on load like right_split already was.

Diffstat:
Msrc/app/handler.rs | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Msrc/app/mod.rs | 169+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Msrc/app/persist.rs | 64+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/app/state.rs | 5+++++
Msrc/library/watcher.rs | 52+++++++++++++++++++++++-----------------------------
Msrc/main.rs | 122+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Msrc/ui/mod.rs | 14++++++++++++++
7 files changed, 346 insertion(+), 157 deletion(-)

diff --git a/src/app/handler.rs b/src/app/handler.rs
@@ -9,6 +9,24 @@ use crate::ui::widgets::{progress_bar, tab_bar};
 use crate::ui::widgets::playlist_modal::PlaylistModalMode;
 use crate::ui::Ui;
 
+pub const MIN_PANE_WIDTH: u16 = 10;
+
+/// Split the 100% budget left over after `fixed` between the two panes flanking
+/// a dragged border, placing the boundary at `boundary_pct`. Returns None when
+/// there is not enough room for both.
+///
+/// Every arm of the drag math used to do this inline with bare `u16`
+/// subtraction, which underflows as soon as the pointer leaves the dashboard —
+/// dragging the playlist|lyrics border to the left edge panicked outright.
+fn split_widths(boundary_pct: u16, fixed: u16, min_w: u16) -> Option<(u16, u16)> {
+    let budget = 100u16.checked_sub(fixed)?;
+    if budget < min_w * 2 {
+        return None;
+    }
+    let first = boundary_pct.clamp(min_w, budget - min_w);
+    Some((first, budget - first))
+}
+
 pub fn handle_key_event(key: KeyEvent, app: &App, ui: &mut Ui) -> Vec<AppAction> {
     let mut actions = Vec::new();
 
@@ -548,24 +566,23 @@ pub fn handle_mouse_event(
                     if dashboard_w > 0 {
                         let rel_x = x.saturating_sub(dashboard_x);
                         let pct = ((rel_x as u32 * 100) / dashboard_w as u32) as u16;
-                        let min_w: u16 = 10;
+                        let min_w: u16 = MIN_PANE_WIDTH;
                         if border_idx == 0 {
                             // Dragging lib|playlist border
-                            let new_lib = pct.clamp(min_w, 100 - min_w - ui.pane_widths[2]);
-                            let new_play = (100 - new_lib - ui.pane_widths[2]).max(min_w);
-                            let new_lib = 100 - new_play - ui.pane_widths[2];
-                            if new_lib >= min_w {
-                                ui.pane_widths[0] = new_lib;
-                                ui.pane_widths[1] = new_play;
+                            if let Some((lib, play)) = split_widths(pct, ui.pane_widths[2], min_w) {
+                                ui.pane_widths[0] = lib;
+                                ui.pane_widths[1] = play;
                             }
                         } else if border_idx == 1 {
-                            // Dragging playlist|lyrics border
-                            let new_right = (100u16.saturating_sub(pct)).max(min_w);
-                            let new_play = (100 - ui.pane_widths[0] - new_right).max(min_w);
-                            let new_right = 100 - ui.pane_widths[0] - new_play;
-                            if new_right >= min_w {
-                                ui.pane_widths[1] = new_play;
-                                ui.pane_widths[2] = new_right;
+                            // Dragging playlist|lyrics border. `pct` is measured
+                            // from the dashboard's left edge, so subtract the
+                            // library pane to get the boundary inside the budget.
+                            let boundary = pct.saturating_sub(ui.pane_widths[0]);
+                            if let Some((play, right)) =
+                                split_widths(boundary, ui.pane_widths[0], min_w)
+                            {
+                                ui.pane_widths[1] = play;
+                                ui.pane_widths[2] = right;
                             }
                         } else if border_idx == 2 {
                             // Dragging info|lyrics horizontal border
@@ -921,3 +938,35 @@ pub fn update_queue_selection(app: &mut App, key: KeyEvent) {
         _ => {}
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn split_widths_keeps_the_total_at_100_and_both_panes_above_the_minimum() {
+        let (a, b) = split_widths(50, 20, MIN_PANE_WIDTH).unwrap();
+        assert_eq!((a, b), (50, 30));
+        assert_eq!(a + b + 20, 100);
+    }
+
+    #[test]
+    fn split_widths_survives_dragging_past_either_edge() {
+        // Pointer at or left of the dashboard: used to underflow `100 - fixed - 100`.
+        let (a, b) = split_widths(0, 20, MIN_PANE_WIDTH).unwrap();
+        assert_eq!((a, b), (MIN_PANE_WIDTH, 80 - MIN_PANE_WIDTH));
+        // Pointer dragged far past the right edge.
+        let (a, b) = split_widths(500, 20, MIN_PANE_WIDTH).unwrap();
+        assert_eq!((a, b), (80 - MIN_PANE_WIDTH, MIN_PANE_WIDTH));
+    }
+
+    #[test]
+    fn split_widths_refuses_when_the_third_pane_leaves_no_room() {
+        // 85 fixed leaves 15, which cannot hold two 10% panes. The old code
+        // called clamp(10, 5) here, and `Ord::clamp` panics when min > max.
+        assert_eq!(split_widths(50, 85, MIN_PANE_WIDTH), None);
+        assert_eq!(split_widths(50, 120, MIN_PANE_WIDTH), None);
+        // Exactly enough room is allowed.
+        assert_eq!(split_widths(50, 80, MIN_PANE_WIDTH), Some((10, 10)));
+    }
+}
diff --git a/src/app/mod.rs b/src/app/mod.rs
@@ -15,7 +15,6 @@ use state::*;
 #[derive(Debug, Clone)]
 pub enum AppAction {
     Quit,
-    PlayTrack(usize),
     PauseResume,
     NextTrack,
     PrevTrack,
@@ -58,7 +57,6 @@ pub struct App {
     pub search_mode: bool,
     pub search_results: Vec<usize>,
     pub playlists: Vec<state::Playlist>,
-    pub track_just_changed: bool,
     pub sync_state: SyncState,
     pub initial_scan_complete: bool,
     audio_engine: Option<AudioEngine>,
@@ -79,7 +77,6 @@ impl App {
             search_mode: false,
             search_results: Vec::new(),
             playlists: vec![state::Playlist::new("Bookmarks")],
-            track_just_changed: false,
             sync_state: SyncState::Idle,
             initial_scan_complete: false,
             audio_engine: None,
@@ -103,19 +100,6 @@ impl App {
                     engine.send(PlayerCommand::Stop);
                 }
             }
-            AppAction::PlayTrack(track_idx) => {
-                if track_idx < self.library.tracks.len() {
-                    let path = self.library.tracks[track_idx].path.clone();
-                    let dur = self.library.tracks[track_idx].duration.as_secs_f64();
-                    if let Some(ref engine) = self.audio_engine {
-                        engine.send(PlayerCommand::Play(path));
-                    }
-                    self.playback.state = PlayState::Playing;
-                    self.playback.position_secs = 0.0;
-                    self.playback.duration_secs = dur;
-                    self.track_just_changed = true;
-                }
-            }
             AppAction::PauseResume => match self.playback.state {
                 PlayState::Playing => {
                     if let Some(ref engine) = self.audio_engine {
@@ -132,9 +116,7 @@ impl App {
                 PlayState::Stopped => {
                     // Try to play current queue item
                     if let Some(idx) = self.queue.current_index {
-                        if let Some(&track_idx) = self.queue.tracks.get(idx) {
-                            self.handle_action(AppAction::PlayTrack(track_idx));
-                        }
+                        self.play_queue_position(idx);
                     }
                 }
             },
@@ -202,6 +184,9 @@ impl App {
                 self.queue.current_index = None;
                 self.queue.selected_index = 0;
                 self.queue.scroll_offset = 0;
+                // Without this the engine keeps playing a track the queue no
+                // longer contains, and its position updates keep the clock running.
+                self.stop_playback();
             }
             AppAction::RemoveFromQueue(idx) => {
                 if idx < self.queue.tracks.len() {
@@ -218,11 +203,10 @@ impl App {
                 }
             }
             AppAction::PlayQueueIndex(idx) => {
-                if idx < self.queue.tracks.len() {
-                    self.queue.current_index = Some(idx);
-                    let track_idx = self.queue.tracks[idx];
-                    self.handle_action(AppAction::PlayTrack(track_idx));
-                }
+                // play_queue_position only commits current_index once the track
+                // is known to resolve, so a stale row cannot move the ▶ marker
+                // while a different track keeps playing.
+                self.play_queue_position(idx);
             }
             AppAction::UpdatePosition { position_secs, duration_secs } => {
                 self.playback.position_secs = position_secs;
@@ -281,6 +265,15 @@ impl App {
     }
 
     pub fn replace_library(&mut self, new_lib: Library) {
+        // An empty scan against a non-empty library means the music directory
+        // went away (unmounted, renamed, permissions), not that the user deleted
+        // every file. Applying it would prune the queue and every playlist, and
+        // the pruned result is what gets written to disk on exit.
+        if new_lib.tracks.is_empty() && !self.library.tracks.is_empty() {
+            self.sync_state = SyncState::Idle;
+            return;
+        }
+
         // Build path→new_index map
         let path_map: HashMap<PathBuf, usize> = new_lib.tracks.iter().enumerate()
             .map(|(i, t)| (t.path.clone(), i))
@@ -317,15 +310,29 @@ impl App {
             self.queue.tracks.len().saturating_sub(1)
         );
 
-        // Remap playlists
+        // Remap playlists. Entries the new library lacks are parked in `missing`
+        // rather than dropped, and anything parked earlier is restored if it came
+        // back.
         for pl in &mut self.playlists {
-            pl.tracks = pl.tracks.iter()
-                .filter_map(|&old_idx| {
-                    self.library.tracks.get(old_idx)
-                        .and_then(|t| path_map.get(&t.path))
-                        .copied()
-                })
-                .collect();
+            let mut tracks = Vec::with_capacity(pl.tracks.len());
+            let mut missing = Vec::new();
+            for &old_idx in &pl.tracks {
+                let Some(path) = self.library.tracks.get(old_idx).map(|t| &t.path) else {
+                    continue;
+                };
+                match path_map.get(path) {
+                    Some(&new_idx) => tracks.push(new_idx),
+                    None => missing.push(path.clone()),
+                }
+            }
+            for path in pl.missing.drain(..) {
+                match path_map.get(&path) {
+                    Some(&new_idx) => tracks.push(new_idx),
+                    None => missing.push(path),
+                }
+            }
+            pl.tracks = tracks;
+            pl.missing = missing;
         }
 
         // Remap search results
@@ -337,6 +344,42 @@ impl App {
         self.sync_state = SyncState::Idle;
     }
 
+    /// Start the track at queue position `qi`, the single place playback is
+    /// actually kicked off. Returns false when the position — or the library
+    /// index behind it — no longer exists, which is why every caller goes
+    /// through here instead of indexing `library.tracks` directly.
+    fn play_queue_position(&mut self, qi: usize) -> bool {
+        let Some((path, dur)) = self
+            .queue
+            .tracks
+            .get(qi)
+            .and_then(|&ti| self.library.tracks.get(ti))
+            .map(|t| (t.path.clone(), t.duration.as_secs_f64()))
+        else {
+            return false;
+        };
+
+        if let Some(ref engine) = self.audio_engine {
+            engine.send(PlayerCommand::Play(path));
+        }
+        self.queue.current_index = Some(qi);
+        self.playback.state = PlayState::Playing;
+        self.playback.position_secs = 0.0;
+        self.playback.duration_secs = dur;
+        true
+    }
+
+    /// Tell the audio engine to stop and mark playback stopped. Setting only the
+    /// UI state leaves the engine playing and its position updates overwriting
+    /// the clock.
+    fn stop_playback(&mut self) {
+        if let Some(ref engine) = self.audio_engine {
+            engine.send(PlayerCommand::Stop);
+        }
+        self.playback.state = PlayState::Stopped;
+        self.playback.position_secs = 0.0;
+    }
+
     fn play_next(&mut self) {
         if self.queue.tracks.is_empty() {
             return;
@@ -345,16 +388,7 @@ impl App {
         match self.playback.repeat {
             RepeatMode::One => {
                 if let Some(idx) = self.queue.current_index {
-                    let track_idx = self.queue.tracks[idx];
-                    let path = self.library.tracks[track_idx].path.clone();
-                    let dur = self.library.tracks[track_idx].duration.as_secs_f64();
-                    if let Some(ref engine) = self.audio_engine {
-                        engine.send(PlayerCommand::Play(path));
-                    }
-                    self.playback.state = PlayState::Playing;
-                    self.playback.position_secs = 0.0;
-                    self.playback.duration_secs = dur;
-                    self.track_just_changed = true;
+                    self.play_queue_position(idx);
                 }
             }
             _ => {
@@ -375,21 +409,9 @@ impl App {
                     Some(0)
                 };
 
-                if let Some(next_idx) = next {
-                    self.queue.current_index = Some(next_idx);
-                    let track_idx = self.queue.tracks[next_idx];
-                    let path = self.library.tracks[track_idx].path.clone();
-                    let dur = self.library.tracks[track_idx].duration.as_secs_f64();
-                    if let Some(ref engine) = self.audio_engine {
-                        engine.send(PlayerCommand::Play(path));
-                    }
-                    self.playback.state = PlayState::Playing;
-                    self.playback.position_secs = 0.0;
-                    self.playback.duration_secs = dur;
-                    self.track_just_changed = true;
-                } else {
-                    self.playback.state = PlayState::Stopped;
-                    self.playback.position_secs = 0.0;
+                match next {
+                    Some(next_idx) if self.play_queue_position(next_idx) => {}
+                    _ => self.stop_playback(),
                 }
             }
         }
@@ -403,43 +425,26 @@ impl App {
         // If more than 3 seconds in, restart current track
         if self.playback.position_secs > 3.0 {
             if let Some(idx) = self.queue.current_index {
-                let track_idx = self.queue.tracks[idx];
-                let path = self.library.tracks[track_idx].path.clone();
-                let dur = self.library.tracks[track_idx].duration.as_secs_f64();
-                if let Some(ref engine) = self.audio_engine {
-                    engine.send(PlayerCommand::Play(path));
+                if self.play_queue_position(idx) {
+                    return;
                 }
-                self.playback.position_secs = 0.0;
-                self.playback.duration_secs = dur;
-                self.track_just_changed = true;
-                return;
             }
         }
 
         let prev = if let Some(idx) = self.queue.current_index {
             if idx > 0 {
-                Some(idx - 1)
+                idx - 1
             } else if self.playback.repeat == RepeatMode::All {
-                Some(self.queue.tracks.len() - 1)
+                self.queue.tracks.len() - 1
             } else {
-                Some(0)
+                0
             }
         } else {
-            Some(0)
+            0
         };
 
-        if let Some(prev_idx) = prev {
-            self.queue.current_index = Some(prev_idx);
-            let track_idx = self.queue.tracks[prev_idx];
-            let path = self.library.tracks[track_idx].path.clone();
-            let dur = self.library.tracks[track_idx].duration.as_secs_f64();
-            if let Some(ref engine) = self.audio_engine {
-                engine.send(PlayerCommand::Play(path));
-            }
-            self.playback.state = PlayState::Playing;
-            self.playback.position_secs = 0.0;
-            self.playback.duration_secs = dur;
-            self.track_just_changed = true;
+        if !self.play_queue_position(prev) {
+            self.stop_playback();
         }
     }
 
diff --git a/src/app/persist.rs b/src/app/persist.rs
@@ -2,12 +2,19 @@ use serde::{Deserialize, Serialize};
 use std::fs;
 use std::path::PathBuf;
 
+/// Every field defaults, so a state file written by an older build — or one
+/// missing a key — restores what it can instead of dropping the whole session.
 #[derive(Serialize, Deserialize)]
 pub struct SavedState {
+    #[serde(default = "default_volume")]
     pub volume: f32,
+    #[serde(default)]
     pub shuffle: bool,
+    #[serde(default = "default_repeat")]
     pub repeat: String,
+    #[serde(default = "default_pane_widths")]
     pub pane_widths: [u16; 3],
+    #[serde(default)]
     pub playlists: Vec<SavedPlaylist>,
     #[serde(default = "default_info_view")]
     pub info_view: String,
@@ -15,6 +22,20 @@ pub struct SavedState {
     pub right_split: u16,
 }
 
+fn default_volume() -> f32 {
+    0.5
+}
+
+fn default_repeat() -> String {
+    "Off".to_string()
+}
+
+pub const DEFAULT_PANE_WIDTHS: [u16; 3] = [20, 60, 20];
+
+fn default_pane_widths() -> [u16; 3] {
+    DEFAULT_PANE_WIDTHS
+}
+
 fn default_info_view() -> String {
     "Clock".to_string()
 }
@@ -29,6 +50,17 @@ pub struct SavedPlaylist {
     pub tracks: Vec<PathBuf>,
 }
 
+/// Pane widths must each clear the 10% minimum the resize code assumes and sum
+/// to exactly 100; anything else makes `LayoutAreas` produce degenerate rects
+/// and the border-drag math underflow. Reject rather than try to repair.
+pub fn sane_pane_widths(w: [u16; 3]) -> [u16; 3] {
+    if w.iter().all(|&p| p >= 10) && w.iter().sum::<u16>() == 100 {
+        w
+    } else {
+        DEFAULT_PANE_WIDTHS
+    }
+}
+
 fn state_path() -> PathBuf {
     let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
     PathBuf::from(home).join(".config/ommp/state.json")
@@ -40,7 +72,11 @@ pub fn save(state: &SavedState) -> anyhow::Result<()> {
         fs::create_dir_all(parent)?;
     }
     let json = serde_json::to_string_pretty(state)?;
-    fs::write(&path, json)?;
+    // Write-then-rename: a crash or full disk mid-write leaves the previous
+    // state file intact instead of a truncated one that parses to nothing.
+    let tmp = path.with_extension("json.tmp");
+    fs::write(&tmp, json)?;
+    fs::rename(&tmp, &path)?;
     Ok(())
 }
 
@@ -49,3 +85,29 @@ pub fn load() -> Option<SavedState> {
     let data = fs::read_to_string(path).ok()?;
     serde_json::from_str(&data).ok()
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn pane_widths_reject_anything_layout_cannot_use() {
+        assert_eq!(sane_pane_widths([20, 60, 20]), [20, 60, 20]);
+        assert_eq!(sane_pane_widths([10, 80, 10]), [10, 80, 10]);
+        // Below the 10% floor the resize math underflows.
+        assert_eq!(sane_pane_widths([10, 5, 85]), DEFAULT_PANE_WIDTHS);
+        // Sums that are not 100 leave the dashboard with a gap or overflow.
+        assert_eq!(sane_pane_widths([20, 20, 20]), DEFAULT_PANE_WIDTHS);
+        assert_eq!(sane_pane_widths([0, 0, 0]), DEFAULT_PANE_WIDTHS);
+    }
+
+    #[test]
+    fn missing_fields_fall_back_instead_of_dropping_the_file() {
+        let saved: SavedState = serde_json::from_str(r#"{"volume":0.25}"#).unwrap();
+        assert_eq!(saved.volume, 0.25);
+        assert_eq!(saved.repeat, "Off");
+        assert_eq!(saved.pane_widths, DEFAULT_PANE_WIDTHS);
+        assert_eq!(saved.right_split, 50);
+        assert!(saved.playlists.is_empty());
+    }
+}
diff --git a/src/app/state.rs b/src/app/state.rs
@@ -155,6 +155,10 @@ pub struct QueueState {
 pub struct Playlist {
     pub name: String,
     pub tracks: Vec<usize>,
+    /// Paths the library did not contain at the last remap, kept verbatim.
+    /// An unmounted drive or a partial scan must not delete playlist entries,
+    /// so they park here and are restored the moment the files reappear.
+    pub missing: Vec<std::path::PathBuf>,
 }
 
 impl Playlist {
@@ -162,6 +166,7 @@ impl Playlist {
         Self {
             name: name.into(),
             tracks: Vec::new(),
+            missing: Vec::new(),
         }
     }
 }
diff --git a/src/library/watcher.rs b/src/library/watcher.rs
@@ -1,6 +1,4 @@
 use std::path::Path;
-use std::sync::atomic::{AtomicBool, Ordering};
-use std::sync::Arc;
 use std::time::{Duration, Instant};
 
 use crossbeam_channel::Sender;
@@ -30,42 +28,38 @@ pub fn spawn_watcher(music_dir: &Path, event_tx: Sender<Event>) -> Option<Recomm
 
     watcher.watch(music_dir, RecursiveMode::Recursive).ok()?;
 
-    let scanning = Arc::new(AtomicBool::new(false));
-
-    // Debounce thread
-    let scanning_clone = scanning.clone();
+    // Debounce thread. `pending` is None until a real filesystem event arrives,
+    // so startup does not look like activity — seeding it with `Instant::now()`
+    // used to fire a full rescan ~2s in with nothing having changed, and again
+    // at ~2.5s because the timestamp was never reset after scanning.
     std::thread::spawn(move || {
         let debounce = Duration::from_secs(2);
-        let mut last_event = Instant::now();
+        let mut pending: Option<Instant> = None;
 
         loop {
             match notify_rx.recv_timeout(Duration::from_millis(500)) {
                 Ok(()) => {
-                    last_event = Instant::now();
+                    pending = Some(Instant::now());
                 }
                 Err(crossbeam_channel::RecvTimeoutError::Timeout) => {
-                    if last_event.elapsed() >= debounce
-                        && !scanning_clone.load(Ordering::Relaxed)
-                    {
-                        // Check if there were any events recently
-                        // Drain any pending events
-                        let mut had_events = false;
-                        while notify_rx.try_recv().is_ok() {
-                            had_events = true;
-                        }
-                        if had_events {
-                            last_event = Instant::now();
-                            continue;
-                        }
-
-                        // Only rescan if we actually saw events since last scan
-                        if last_event.elapsed() < debounce + Duration::from_millis(600) {
-                            scanning_clone.store(true, Ordering::Relaxed);
-                            let lib = Library::scan(&dir);
-                            let _ = event_tx.send(Event::LibraryReady(lib));
-                            scanning_clone.store(false, Ordering::Relaxed);
-                        }
+                    let Some(last_event) = pending else { continue };
+                    if last_event.elapsed() < debounce {
+                        continue;
+                    }
+                    // Drain anything that landed while we were deciding; if the
+                    // batch is still arriving, wait for it to settle.
+                    let mut had_events = false;
+                    while notify_rx.try_recv().is_ok() {
+                        had_events = true;
                     }
+                    if had_events {
+                        pending = Some(Instant::now());
+                        continue;
+                    }
+
+                    pending = None;
+                    let lib = Library::scan(&dir);
+                    let _ = event_tx.send(Event::LibraryReady(lib));
                 }
                 Err(crossbeam_channel::RecvTimeoutError::Disconnected) => break,
             }
diff --git a/src/main.rs b/src/main.rs
@@ -25,8 +25,7 @@ use audio::AudioEngine;
 use event::input;
 use event::{AudioEvent, Event};
 
-fn main() -> Result<()> {
-    // Setup terminal
+fn setup_terminal() -> Result<()> {
     enable_raw_mode()?;
     let mut stdout = io::stdout();
     execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
@@ -34,21 +33,41 @@ fn main() -> Result<()> {
     // Some terminals need this even after EnableMouseCapture
     stdout.write_all(b"\x1b[?1003h")?;
     stdout.flush()?;
-    let backend = CrosstermBackend::new(stdout);
+    Ok(())
+}
+
+/// Undo everything `setup_terminal` did. Best-effort and idempotent so the
+/// panic hook can call it without caring how far setup got.
+fn restore_terminal() {
+    let _ = disable_raw_mode();
+    let _ = execute!(
+        io::stdout(),
+        // Disable mouse motion tracking
+        crossterm::style::Print("\x1b[?1003l"),
+        LeaveAlternateScreen,
+        DisableMouseCapture,
+        crossterm::cursor::Show,
+    );
+}
+
+fn main() -> Result<()> {
+    setup_terminal()?;
+
+    // Restore before the default hook prints: a panic message written while the
+    // alternate screen is up scrolls away with it, leaving a raw-mode terminal
+    // and no explanation.
+    let default_hook = std::panic::take_hook();
+    std::panic::set_hook(Box::new(move |info| {
+        restore_terminal();
+        default_hook(info);
+    }));
+
+    let backend = CrosstermBackend::new(io::stdout());
     let mut terminal = Terminal::new(backend)?;
 
     let result = run_app(&mut terminal);
 
-    // Restore terminal
-    disable_raw_mode()?;
-    // Disable mouse motion tracking
-    execute!(
-        terminal.backend_mut(),
-        crossterm::style::Print("\x1b[?1003l"),
-        LeaveAlternateScreen,
-        DisableMouseCapture
-    )?;
-    terminal.show_cursor()?;
+    restore_terminal();
 
     if let Err(e) = result {
         eprintln!("Error: {}", e);
@@ -97,6 +116,9 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()>
     let mut scan_done = false;
     let mut scan_join = Some(scan_handle);
     let mut _watcher: Option<notify::RecommendedWatcher> = None;
+    // A backend write error must not skip the save below, so the loop records it
+    // and breaks instead of returning early.
+    let mut draw_error: Option<io::Error> = None;
 
     loop {
         // Check if library scan is done
@@ -118,18 +140,27 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()>
                                     app.playback.shuffle = saved.shuffle;
                                     app.playback.repeat = RepeatMode::from_label(&saved.repeat);
                                     app.handle_action(app::AppAction::SetVolume(app.playback.volume));
-                                    ui.pane_widths = saved.pane_widths;
+                                    ui.pane_widths = persist::sane_pane_widths(saved.pane_widths);
                                     ui.info_view = InfoView::from_label(&saved.info_view);
                                     ui.right_split = saved.right_split.clamp(10, 90);
-                                    // Restore playlists (path → index remapping)
+                                    // Restore playlists (path → index remapping).
+                                    // Paths the library does not have are kept as
+                                    // `missing` rather than dropped — launching once
+                                    // with the drive unplugged must not delete them.
                                     let mut playlists = Vec::new();
                                     for sp in &saved.playlists {
-                                        let tracks: Vec<usize> = sp.tracks.iter()
-                                            .filter_map(|p| app.library.path_to_index(p))
-                                            .collect();
+                                        let mut tracks = Vec::new();
+                                        let mut missing = Vec::new();
+                                        for p in &sp.tracks {
+                                            match app.library.path_to_index(p) {
+                                                Some(idx) => tracks.push(idx),
+                                                None => missing.push(p.clone()),
+                                            }
+                                        }
                                         playlists.push(app::state::Playlist {
                                             name: sp.name.clone(),
                                             tracks,
+                                            missing,
                                         });
                                     }
                                     if playlists.is_empty() {
@@ -162,10 +193,14 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()>
                             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)
-                                    ui.splash_start = Some(
-                                        std::time::Instant::now() - Duration::from_millis(1500)
-                                    );
+                                    // 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);
+                                    }
                                 }
                             }
                             vec![]
@@ -186,7 +221,7 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()>
                         }
                     }
                     Event::Mouse(mouse) => {
-                        let size = terminal.size()?;
+                        let size = terminal.size().unwrap_or_default();
                         let area = ratatui::layout::Rect::new(0, 0, size.width, size.height);
                         handler::handle_mouse_event(mouse, &app, &mut ui, area)
                     }
@@ -203,8 +238,8 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()>
                                 }
                             }
                         }
-                        // Refresh hover + focus from stored mouse position
-                        let size = terminal.size()?;
+                        // Refresh hover from stored mouse position
+                        let size = terminal.size().unwrap_or_default();
                         let area = ratatui::layout::Rect::new(0, 0, size.width, size.height);
                         handler::refresh_hover(&app, &mut ui, area)
                     }
@@ -212,6 +247,10 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()>
                         app.replace_library(new_lib);
                         ui.refresh_dir_browser(&app);
                         ui.clamp_selections(&app);
+                        // The search modal caches raw library indices, which
+                        // replace_library does not remap; rendering them against
+                        // a shrunken library panics.
+                        ui.refresh_search_results(&app);
                         vec![]
                     }
                     Event::Audio(audio_event) => {
@@ -261,17 +300,23 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()>
         }
 
         // Render
-        terminal.draw(|frame| {
+        if let Err(e) = terminal.draw(|frame| {
             ui.render(frame, &app);
-        })?;
+        }) {
+            draw_error = Some(e);
+            break;
+        }
     }
 
     // Save state on exit
     let saved_playlists: Vec<persist::SavedPlaylist> = app.playlists.iter().map(|pl| {
         persist::SavedPlaylist {
             name: pl.name.clone(),
+            // Write the unresolved paths back out too, otherwise a session that
+            // ran without the music drive would persist the pruned playlist.
             tracks: pl.tracks.iter()
                 .filter_map(|&idx| app.library.tracks.get(idx).map(|t| t.path.clone()))
+                .chain(pl.missing.iter().cloned())
                 .collect(),
         }
     }).collect();
@@ -291,20 +336,35 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()>
     }
 
     // Suppress rodio's "Dropping OutputStream" message:
-    // 1. Redirect stderr to /dev/null
+    // 1. Redirect stderr to /dev/null, keeping a copy to restore afterwards
     // 2. Explicitly drop app (triggers AudioEngine → player thread shutdown)
     // 3. Brief sleep so the player thread can exit and drop OutputStream silently
-    unsafe {
+    let saved_stderr = unsafe {
         let devnull = libc::open(c"/dev/null".as_ptr(), libc::O_WRONLY);
         if devnull >= 0 {
+            let saved = libc::dup(2);
             libc::dup2(devnull, 2);
             libc::close(devnull);
+            saved
+        } else {
+            -1
         }
-    }
+    };
     drop(app);
     std::thread::sleep(Duration::from_millis(50));
+    // Without this every later stderr write is swallowed, including the error
+    // reported by main and any panic message raised during teardown.
+    if saved_stderr >= 0 {
+        unsafe {
+            libc::dup2(saved_stderr, 2);
+            libc::close(saved_stderr);
+        }
+    }
 
-    Ok(())
+    match draw_error {
+        Some(e) => Err(e.into()),
+        None => Ok(()),
+    }
 }
 
 fn dirs_music_path() -> PathBuf {
diff --git a/src/ui/mod.rs b/src/ui/mod.rs
@@ -241,6 +241,20 @@ impl Ui {
         self.dir_browser_pane.refresh(app);
     }
 
+    /// Re-run the open search against the new library. `search_modal_results`
+    /// holds raw library indices and lives on `Ui`, so `App::replace_library`
+    /// never sees it — stale entries would panic the next render.
+    pub fn refresh_search_results(&mut self, app: &App) {
+        if !self.show_search_modal {
+            self.search_modal_results.clear();
+            return;
+        }
+        self.search_modal_results = app.library.search(&self.search_modal_input);
+        self.search_modal_selected = 0;
+        self.search_modal_scroll = 0;
+        self.search_modal_hover_row = None;
+    }
+
     pub fn clamp_selections(&mut self, app: &App) {
         let artists_len = app.library.get_artists().len();
         if artists_len == 0 {