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

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 }