src/main.rs (17117B)
1 mod app;
2 mod audio;
3 mod event;
4 mod library;
5 mod ui;
6
7 use std::io::{self, Write};
8 use std::path::PathBuf;
9 use std::time::Duration;
10
11 use anyhow::Result;
12 use crossterm::{
13 event::{DisableMouseCapture, EnableMouseCapture},
14 execute,
15 terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
16 };
17 use crossterm::tty::IsTty;
18 use ratatui::backend::CrosstermBackend;
19 use ratatui::Terminal;
20
21 use app::handler;
22 use app::persist;
23 use app::state::{FocusedPane, InfoView, RepeatMode};
24 use app::App;
25 use audio::AudioEngine;
26 use event::input;
27 use event::{AudioEvent, Event};
28
29 fn setup_terminal() -> Result<()> {
30 enable_raw_mode()?;
31 let mut stdout = io::stdout();
32 execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
33 // Explicitly enable mouse motion tracking (SGR any-event mode)
34 // Some terminals need this even after EnableMouseCapture
35 stdout.write_all(b"\x1b[?1003h")?;
36 stdout.flush()?;
37 Ok(())
38 }
39
40 /// Undo everything `setup_terminal` did. Best-effort and idempotent so the
41 /// panic hook can call it without caring how far setup got.
42 fn restore_terminal() {
43 let _ = disable_raw_mode();
44 let _ = execute!(
45 io::stdout(),
46 // Disable mouse motion tracking
47 crossterm::style::Print("\x1b[?1003l"),
48 LeaveAlternateScreen,
49 DisableMouseCapture,
50 crossterm::cursor::Show,
51 );
52 }
53
54 fn main() -> Result<()> {
55 // Raw mode fails with a bare "No such device or address" when stdin or
56 // stdout is not a terminal, which says nothing about what to do.
57 if !io::stdin().is_tty() || !io::stdout().is_tty() {
58 eprintln!("ommp is a full-screen terminal application and needs an interactive terminal.");
59 eprintln!();
60 eprintln!("Run it directly in your terminal:");
61 eprintln!();
62 eprintln!(" ommp");
63 eprintln!();
64 eprintln!("It cannot run through a pipe, a redirect, or a non-interactive shell.");
65 std::process::exit(1);
66 }
67
68 setup_terminal()?;
69
70 // Restore before the default hook prints: a panic message written while the
71 // alternate screen is up scrolls away with it, leaving a raw-mode terminal
72 // and no explanation.
73 let default_hook = std::panic::take_hook();
74 std::panic::set_hook(Box::new(move |info| {
75 restore_terminal();
76 default_hook(info);
77 }));
78
79 let backend = CrosstermBackend::new(io::stdout());
80 let mut terminal = Terminal::new(backend)?;
81
82 let result = run_app(&mut terminal);
83
84 restore_terminal();
85
86 if let Err(e) = result {
87 eprintln!("Error: {}", e);
88 }
89
90 Ok(())
91 }
92
93 fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
94 let music_dir = dirs_music_path();
95
96 // Start scanning first: querying the terminal for its image protocol and
97 // opening the audio device both round-trip to something outside this
98 // process, and the scan has no reason to wait for either.
99 let scan_dir = music_dir.clone();
100 let scan_handle = std::thread::spawn(move || library::Library::scan(&scan_dir));
101
102 // Detect terminal image protocol BEFORE input thread steals stdin
103 let picker = ratatui_image::picker::Picker::from_query_stdio()
104 .unwrap_or_else(|_| ratatui_image::picker::Picker::from_fontsize((8, 16)));
105
106 // Event channel
107 let (event_tx, event_rx) = crossbeam_channel::unbounded();
108
109 // Spawn input thread
110 let _input_handle = input::spawn_input_thread(event_tx.clone());
111 let _tick_handle = input::spawn_tick_thread(event_tx.clone(), Duration::from_millis(200));
112
113 // Audio engine
114 let audio_engine = AudioEngine::new(event_tx.clone())?;
115
116 // App state
117 let mut app = App::new(music_dir.clone());
118 app.set_audio_engine(audio_engine);
119 app.set_event_tx(event_tx.clone());
120
121 // UI
122 let mut ui = ui::Ui::new(music_dir.clone(), picker);
123
124 // Initial render
125 terminal.draw(|frame| {
126 ui.render(frame, &app);
127 })?;
128
129 // Wait for library scan to complete (non-blocking check in event loop)
130 let mut scan_done = false;
131 let mut scan_join = Some(scan_handle);
132 let mut _watcher: Option<notify::RecommendedWatcher> = None;
133 // A backend write error must not skip the save below, so the loop records it
134 // and breaks instead of returning early.
135 let mut draw_error: Option<io::Error> = None;
136 // Set by anything that can change what is on screen.
137 let mut dirty = true;
138
139 loop {
140 // Check if library scan is done
141 if !scan_done {
142 if let Some(ref handle) = scan_join {
143 if handle.is_finished() {
144 if let Some(handle) = scan_join.take() {
145 match handle.join() {
146 Ok(lib) => {
147 app.library = lib;
148 // Load all tracks into queue by default
149 let all_indices: Vec<usize> = (0..app.library.tracks.len()).collect();
150 app.handle_action(app::AppAction::ReplaceQueue(all_indices));
151 ui.refresh_dir_browser(&app);
152
153 // Restore persisted state
154 if let Some(saved) = persist::load() {
155 app.playback.volume = saved.volume.clamp(0.0, 1.0);
156 app.playback.shuffle = saved.shuffle;
157 app.playback.repeat = RepeatMode::from_label(&saved.repeat);
158 app.handle_action(app::AppAction::SetVolume(app.playback.volume));
159 ui.pane_widths = persist::sane_pane_widths(saved.pane_widths);
160 ui.info_view = InfoView::from_label(&saved.info_view);
161 ui.right_split = saved.right_split.clamp(10, 90);
162 // Restore playlists (path → index remapping).
163 // Paths the library does not have are kept as
164 // `missing` rather than dropped — launching once
165 // with the drive unplugged must not delete them.
166 let mut playlists = Vec::new();
167 for sp in &saved.playlists {
168 let mut tracks = Vec::new();
169 let mut missing = Vec::new();
170 for p in &sp.tracks {
171 match app.library.path_to_index(p) {
172 Some(idx) => tracks.push(idx),
173 None => missing.push(p.clone()),
174 }
175 }
176 playlists.push(app::state::Playlist {
177 name: sp.name.clone(),
178 tracks,
179 missing,
180 });
181 }
182 if playlists.is_empty() {
183 playlists.push(app::state::Playlist::new("Bookmarks"));
184 }
185 app.playlists = playlists;
186 app.rebuild_playlist_members();
187 }
188
189 scan_done = true;
190 app.initial_scan_complete = true;
191 dirty = true;
192 _watcher = library::watcher::spawn_watcher(&music_dir, event_tx.clone());
193 }
194 Err(_) => {
195 scan_done = true;
196 app.initial_scan_complete = true;
197 }
198 }
199 }
200 }
201 }
202 }
203
204 // The splash cross-fades over two seconds and needs frames of its own;
205 // everything else only redraws when an event changed something.
206 if ui.show_splash {
207 dirty = true;
208 }
209
210 // Process events
211 match event_rx.recv_timeout(Duration::from_millis(50)) {
212 Ok(event) => {
213 let actions = match event {
214 Event::Key(key) => {
215 // A key during the splash dismisses it outright — the
216 // hint promises the app, not another half second of
217 // animation.
218 if ui.show_splash {
219 ui.dismiss_splash();
220 vec![]
221 } else {
222 // Handle queue selection directly for playlist focus
223 // Skip when any modal is open
224 if app.focus == FocusedPane::Playlist
225 && !ui.show_search_modal
226 && !ui.show_help_modal
227 && !ui.show_playlist_modal
228 {
229 handler::update_queue_selection(&mut app, key);
230 }
231 handler::handle_key_event(key, &app, &mut ui)
232 }
233 }
234 Event::Mouse(mouse) => {
235 // Keys are ignored during the splash; mouse events were
236 // not, so clicks landed on an invisible UI — switching
237 // tabs, seeking, and replacing the queue.
238 if ui.show_splash {
239 vec![]
240 } else {
241 let size = terminal.size().unwrap_or_default();
242 let area =
243 ratatui::layout::Rect::new(0, 0, size.width, size.height);
244 handler::handle_mouse_event(mouse, &app, &mut ui, area)
245 }
246 }
247 Event::Resize => {
248 vec![] // Will re-render on next loop
249 }
250 Event::Tick => {
251 // Auto-dismiss splash after full timeline (2s)
252 if ui.show_splash {
253 if let Some(start) = ui.splash_start {
254 if start.elapsed().as_secs_f32() >= ui::SPLASH_END {
255 ui.show_splash = false;
256 ui.splash_start = None;
257 }
258 }
259 }
260 // Refresh hover from stored mouse position
261 let size = terminal.size().unwrap_or_default();
262 let area = ratatui::layout::Rect::new(0, 0, size.width, size.height);
263 handler::refresh_hover(&app, &mut ui, area);
264 vec![]
265 }
266 Event::LibraryReady(new_lib) => {
267 app.replace_library(*new_lib);
268 ui.refresh_dir_browser(&app);
269 ui.clamp_selections(&app);
270 // The search modal caches raw library indices, which
271 // replace_library does not remap; rendering them against
272 // a shrunken library panics.
273 ui.refresh_search_results(&app);
274 vec![]
275 }
276 Event::Audio(audio_event) => {
277 match audio_event {
278 AudioEvent::PositionUpdate {
279 position_secs,
280 duration_secs,
281 } => vec![app::AppAction::UpdatePosition {
282 position_secs,
283 duration_secs,
284 }],
285 AudioEvent::TrackFinished => vec![app::AppAction::TrackFinished],
286 AudioEvent::TrackError => vec![app::AppAction::TrackFailed],
287 AudioEvent::DeviceError(msg) => {
288 app.audio_error = Some(msg);
289 app.playback.state = app::state::PlayState::Stopped;
290 vec![]
291 }
292 AudioEvent::Playing => {
293 app.playback.state = app::state::PlayState::Playing;
294 app.note_playback_started();
295 vec![]
296 }
297 AudioEvent::Paused => {
298 app.playback.state = app::state::PlayState::Paused;
299 vec![]
300 }
301 AudioEvent::Stopped => {
302 app.playback.state = app::state::PlayState::Stopped;
303 vec![]
304 }
305 }
306 }
307 };
308
309 for action in actions {
310 app.handle_action(action);
311 }
312
313 if app.should_quit {
314 break;
315 }
316 dirty = true;
317 }
318 Err(crossbeam_channel::RecvTimeoutError::Timeout) => {
319 // Nothing happened, so nothing can have changed.
320 }
321 Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
322 break;
323 }
324 }
325
326 // Render only when something changed, and only once the queue is drained.
327 //
328 // Redrawing on the 50ms timeout burned a full frame 20 times a second
329 // with nothing happening; the ticks and position updates that do change
330 // the display arrive on their own. Mouse motion tracking emits hundreds
331 // of events a second, and drawing between each one let the unbounded
332 // channel grow faster than it drained, so the UI replayed stale pointer
333 // positions for seconds after the mouse stopped.
334 if dirty && event_rx.is_empty() {
335 dirty = false;
336 if let Err(e) = terminal.draw(|frame| {
337 ui.render(frame, &app);
338 }) {
339 draw_error = Some(e);
340 break;
341 }
342 }
343 }
344
345 // Save state on exit
346 let saved_playlists: Vec<persist::SavedPlaylist> = app.playlists.iter().map(|pl| {
347 persist::SavedPlaylist {
348 name: pl.name.clone(),
349 // Write the unresolved paths back out too, otherwise a session that
350 // ran without the music drive would persist the pruned playlist.
351 tracks: pl.tracks.iter()
352 .filter_map(|&idx| app.library.tracks.get(idx).map(|t| t.path.clone()))
353 .chain(pl.missing.iter().cloned())
354 .collect(),
355 }
356 }).collect();
357
358 let saved = persist::SavedState {
359 volume: app.playback.volume,
360 shuffle: app.playback.shuffle,
361 repeat: app.playback.repeat.as_str().to_string(),
362 pane_widths: ui.pane_widths,
363 playlists: saved_playlists,
364 info_view: ui.info_view.as_str().to_string(),
365 right_split: ui.right_split,
366 };
367
368 if let Err(e) = persist::save(&saved) {
369 eprintln!("Warning: failed to save state: {}", e);
370 }
371
372 // Suppress rodio's "Dropping OutputStream" message:
373 // 1. Redirect stderr to /dev/null, keeping a copy to restore afterwards
374 // 2. Explicitly drop app (triggers AudioEngine → player thread shutdown)
375 // 3. Brief sleep so the player thread can exit and drop OutputStream silently
376 let saved_stderr = unsafe {
377 let devnull = libc::open(c"/dev/null".as_ptr(), libc::O_WRONLY);
378 if devnull >= 0 {
379 let saved = libc::dup(2);
380 libc::dup2(devnull, 2);
381 libc::close(devnull);
382 saved
383 } else {
384 -1
385 }
386 };
387 drop(app);
388 std::thread::sleep(Duration::from_millis(50));
389 // Without this every later stderr write is swallowed, including the error
390 // reported by main and any panic message raised during teardown.
391 if saved_stderr >= 0 {
392 unsafe {
393 libc::dup2(saved_stderr, 2);
394 libc::close(saved_stderr);
395 }
396 }
397
398 match draw_error {
399 Some(e) => Err(e.into()),
400 None => Ok(()),
401 }
402 }
403
404 fn dirs_music_path() -> PathBuf {
405 if let Some(home) = std::env::var_os("HOME") {
406 let music = PathBuf::from(home).join("Music");
407 if music.is_dir() {
408 return music;
409 }
410 }
411 PathBuf::from(".")
412 }