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 {
|