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 33727f4edb996d630343dfee79d170bc3534b3bc
parent 5aeea01f7c1878fa70c2636db895c0307e73f07a
Author: sqzer-x <110329244+sqzer-x@users.noreply.github.com>
Date: Sat, 1 Aug 2026 14:31:13 +0900

Wire up entry point and add README with assets

Terminal setup and teardown, background library scan, and the main event
loop dispatching into the action handler and renderer. README covers
install, requirements, usage, and keybindings.

Diffstat:
AREADME.md | 171+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aassets/ommp_logo.png | 0binary
Aassets/screenshot.png | 0binary
Asrc/main.rs | 318+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 489 insertion(+), 0 deletion(-)

diff --git a/README.md b/README.md
@@ -0,0 +1,171 @@
+<p align="center">
+  <img src="assets/ommp_logo.png" alt="OMMP" width="560">
+</p>
+
+<p align="center">
+  <a href="https://github.com/sqzer-x/ommp"><img src="https://img.shields.io/github/license/sqzer-x/ommp?color=blue" alt="License"></a>
+  <a href="https://github.com/sqzer-x/ommp"><img src="https://img.shields.io/badge/platform-linux-lightgrey" alt="Platform"></a>
+</p>
+
+<p align="center">
+  A standalone terminal music player built with Rust.<br>
+  No MPD, no daemon — just run <code>ommp</code> and play.
+</p>
+
+<p align="center">
+  <img src="assets/screenshot.png" alt="OMMP Screenshot" width="800">
+</p>
+
+---
+
+## Features
+
+- **Music playback** — FLAC, MP3, M4A, OGG, WAV, Opus, AAC, WMA
+- **Browse your library** — by artist, album, genre, format, directory, or playlist
+- **Album art** — displayed natively in supported terminals
+- **Search** — find any track instantly with filters like `artist:`, `album:`, `genre:`, `*.flac`
+- **Playlists** — create, rename, delete, and bookmark your favorite tracks
+- **Mouse friendly** — click, scroll, drag to resize panes
+- **Remembers your settings** — volume, playlists, layout are saved between sessions
+- **Live sync** — automatically picks up new or removed files in your music folder
+
+## Before You Start
+
+### Nerd Font (required)
+
+OMMP uses [Nerd Font](https://www.nerdfonts.com/) icons throughout the interface. Without one installed, icons will appear as broken squares.
+
+Recommended fonts: **JetBrainsMono Nerd Font**, **MesloLGS NF**, **FiraCode Nerd Font**
+
+After installing, set it as your terminal's font.
+
+### Terminal (recommended: Kitty)
+
+OMMP works in any terminal with true color (24-bit) support, but **[Kitty](https://sw.kovidgoyal.net/kitty/)** is recommended for the best experience — album art is displayed at full quality using Kitty's native image protocol.
+
+Other terminals with Sixel support (foot, WezTerm) also display album art well. Standard terminals fall back to block-character rendering.
+
+### Audio
+
+PulseAudio or ALSA is required for audio output on Linux.
+
+## Install
+
+Make sure you have [Rust](https://rustup.rs/) installed, then run:
+
+```bash
+cargo install --git https://github.com/sqzer-x/ommp
+```
+
+That's it. Now you can run `ommp` from anywhere:
+
+```bash
+ommp
+```
+
+<details>
+<summary><code>ommp: command not found</code>?</summary>
+
+`~/.cargo/bin` may not be in your PATH. Add this line to your `~/.bashrc` or `~/.zshrc`:
+
+```bash
+export PATH="$HOME/.cargo/bin:$PATH"
+```
+
+Restart your terminal to apply.
+
+</details>
+
+<details>
+<summary>Build from source</summary>
+
+```bash
+git clone https://github.com/sqzer-x/ommp.git
+cd ommp
+cargo build --release
+./target/release/ommp
+```
+
+</details>
+
+## Usage
+
+Place your music files in `~/Music` and run `ommp`. It will automatically scan all files and subdirectories.
+
+Album art is detected automatically — just place `cover.jpg`, `folder.jpg`, `front.jpg`, or any image file in the same directory as your tracks.
+
+### Browsing
+
+The screen is split into three panels:
+
+- **Left** — Library browser (artists, albums, genres, etc. depending on the active tab)
+- **Center** — Play queue
+- **Right** — Album art / Clock (top) + Lyrics (bottom)
+
+Switch browsing modes using the tabs at the top: Queue, Directories, Artists, Albums, Genre, Format, Playlists
+
+### Search
+
+Press `Ctrl+E, s` to open the search modal. Results filter as you type.
+
+You can also search by specific fields:
+
+- `artist:radiohead` — search by artist
+- `album:ok computer` — search by album
+- `genre:rock` — search by genre
+- `*.flac` — filter by format
+
+## Keybindings
+
+Press `Ctrl+E, h` inside the app to view the full keybinding list at any time.
+
+### Playback
+
+| Key | Action |
+|-----|--------|
+| `Space` | Play / Pause |
+| `n` / `N` | Next / Previous track |
+| `+` / `-` | Volume up / down |
+| `Left` / `Right` | Seek backward / forward 5s |
+| `s` | Toggle shuffle |
+| `r` | Cycle repeat (off / all / one) |
+
+### Navigation
+
+| Key | Action |
+|-----|--------|
+| `j` / `k` | Move down / up |
+| `h` / `l` | Focus previous / next panel |
+| `Tab` / `Shift+Tab` | Cycle panel focus |
+| `1`–`7` | Switch tab |
+| `Enter` | Play selected item |
+| `g` / `G` | Jump to top / bottom |
+| `d` | Remove from queue |
+| `b` | Add to playlist |
+| `p` | Toggle info panel (Clock / Album Art) |
+| `q` | Quit |
+
+### Chord commands (press `Ctrl+E`, then a key)
+
+| Key | Action |
+|-----|--------|
+| `Ctrl+E, s` | Search |
+| `Ctrl+E, h` | Help |
+| `Ctrl+E, r` | Panel resize mode |
+| `Ctrl+E, i` | About OMMP |
+| `Ctrl+E, l` | Rescan library |
+
+### Mouse
+
+| Action | Effect |
+|--------|--------|
+| Click tab | Switch tab |
+| Click panel | Focus panel + select item |
+| Double-click track | Play immediately |
+| Drag panel border | Resize panels |
+| Click progress bar | Seek to position |
+| Scroll wheel | Scroll lists |
+
+## License
+
+[MIT](LICENSE)
diff --git a/assets/ommp_logo.png b/assets/ommp_logo.png
Binary files differ.
diff --git a/assets/screenshot.png b/assets/screenshot.png
Binary files differ.
diff --git a/src/main.rs b/src/main.rs
@@ -0,0 +1,318 @@
+mod app;
+mod audio;
+mod event;
+mod library;
+mod ui;
+
+use std::io::{self, Write};
+use std::path::PathBuf;
+use std::time::Duration;
+
+use anyhow::Result;
+use crossterm::{
+    event::{DisableMouseCapture, EnableMouseCapture},
+    execute,
+    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
+};
+use ratatui::backend::CrosstermBackend;
+use ratatui::Terminal;
+
+use app::handler;
+use app::persist;
+use app::state::{FocusedPane, InfoView, RepeatMode};
+use app::App;
+use audio::AudioEngine;
+use event::input;
+use event::{AudioEvent, Event};
+
+fn main() -> Result<()> {
+    // Setup terminal
+    enable_raw_mode()?;
+    let mut stdout = io::stdout();
+    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
+    // Explicitly enable mouse motion tracking (SGR any-event mode)
+    // Some terminals need this even after EnableMouseCapture
+    stdout.write_all(b"\x1b[?1003h")?;
+    stdout.flush()?;
+    let backend = CrosstermBackend::new(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()?;
+
+    if let Err(e) = result {
+        eprintln!("Error: {}", e);
+    }
+
+    Ok(())
+}
+
+fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
+    let music_dir = dirs_music_path();
+
+    // Detect terminal image protocol BEFORE input thread steals stdin
+    let picker = ratatui_image::picker::Picker::from_query_stdio()
+        .unwrap_or_else(|_| ratatui_image::picker::Picker::from_fontsize((8, 16)));
+
+    // Event channel
+    let (event_tx, event_rx) = crossbeam_channel::unbounded();
+
+    // Spawn input thread
+    let _input_handle = input::spawn_input_thread(event_tx.clone());
+    let _tick_handle = input::spawn_tick_thread(event_tx.clone(), Duration::from_millis(200));
+
+    // Audio engine
+    let audio_engine = AudioEngine::new(event_tx.clone())?;
+
+    // App state
+    let mut app = App::new(music_dir.clone());
+    app.set_audio_engine(audio_engine);
+    app.set_event_tx(event_tx.clone());
+
+    // Scan library in background
+    let scan_dir = music_dir.clone();
+    let scan_handle = std::thread::spawn(move || {
+        library::Library::scan(&scan_dir)
+    });
+
+    // UI
+    let mut ui = ui::Ui::new(music_dir.clone(), picker);
+
+    // Initial render
+    terminal.draw(|frame| {
+        ui.render(frame, &app);
+    })?;
+
+    // Wait for library scan to complete (non-blocking check in event loop)
+    let mut scan_done = false;
+    let mut scan_join = Some(scan_handle);
+    let mut _watcher: Option<notify::RecommendedWatcher> = None;
+
+    loop {
+        // Check if library scan is done
+        if !scan_done {
+            if let Some(ref handle) = scan_join {
+                if handle.is_finished() {
+                    if let Some(handle) = scan_join.take() {
+                        match handle.join() {
+                            Ok(lib) => {
+                                app.library = lib;
+                                // Load all tracks into queue by default
+                                let all_indices: Vec<usize> = (0..app.library.tracks.len()).collect();
+                                app.handle_action(app::AppAction::AddToQueue(all_indices));
+                                ui.refresh_dir_browser(&app);
+
+                                // Restore persisted state
+                                if let Some(saved) = persist::load() {
+                                    app.playback.volume = saved.volume.clamp(0.0, 1.0);
+                                    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.info_view = InfoView::from_label(&saved.info_view);
+                                    ui.right_split = saved.right_split.clamp(10, 90);
+                                    // Restore playlists (path → index remapping)
+                                    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();
+                                        playlists.push(app::state::Playlist {
+                                            name: sp.name.clone(),
+                                            tracks,
+                                        });
+                                    }
+                                    if playlists.is_empty() {
+                                        playlists.push(app::state::Playlist::new("Bookmarks"));
+                                    }
+                                    app.playlists = playlists;
+                                }
+
+                                scan_done = true;
+                                app.initial_scan_complete = true;
+                                _watcher = library::watcher::spawn_watcher(&music_dir, event_tx.clone());
+                            }
+                            Err(_) => {
+                                scan_done = true;
+                                app.initial_scan_complete = true;
+                            }
+                        }
+                    }
+                }
+            }
+        }
+
+        // Process events
+        match event_rx.recv_timeout(Duration::from_millis(50)) {
+            Ok(event) => {
+                let actions = match event {
+                    Event::Key(key) => {
+                        // On key press during splash: jump to fade-out phase
+                        if ui.show_splash {
+                            if let Some(start) = ui.splash_start {
+                                let elapsed = start.elapsed().as_secs_f32();
+                                if elapsed < 1.5 {
+                                    // Jump timeline to start of fade-out (1.5s mark)
+                                    ui.splash_start = Some(
+                                        std::time::Instant::now() - Duration::from_millis(1500)
+                                    );
+                                }
+                            }
+                            vec![]
+                        } else {
+                        // Handle queue selection directly for playlist focus
+                        // Skip when any modal is open
+                        if app.focus == FocusedPane::Playlist
+                            && !app.search_mode
+                            && !ui.show_search_modal
+                            && !ui.show_help_modal
+                            && !ui.show_playlist_modal
+                            && !ui.resize_mode
+                            && !ui.chord_pending
+                        {
+                            handler::update_queue_selection(&mut app, key);
+                        }
+                        handler::handle_key_event(key, &app, &mut ui)
+                        }
+                    }
+                    Event::Mouse(mouse) => {
+                        let size = terminal.size()?;
+                        let area = ratatui::layout::Rect::new(0, 0, size.width, size.height);
+                        handler::handle_mouse_event(mouse, &app, &mut ui, area)
+                    }
+                    Event::Resize(_, _) => {
+                        vec![] // Will re-render on next loop
+                    }
+                    Event::Tick => {
+                        // Auto-dismiss splash after full timeline (2s)
+                        if ui.show_splash {
+                            if let Some(start) = ui.splash_start {
+                                if start.elapsed().as_secs_f32() >= 2.0 {
+                                    ui.show_splash = false;
+                                    ui.splash_start = None;
+                                }
+                            }
+                        }
+                        // Refresh hover + focus from stored mouse position
+                        let size = terminal.size()?;
+                        let area = ratatui::layout::Rect::new(0, 0, size.width, size.height);
+                        handler::refresh_hover(&app, &mut ui, area)
+                    }
+                    Event::LibraryReady(new_lib) => {
+                        app.replace_library(new_lib);
+                        ui.refresh_dir_browser(&app);
+                        ui.clamp_selections(&app);
+                        vec![]
+                    }
+                    Event::Audio(audio_event) => {
+                        match audio_event {
+                            AudioEvent::PositionUpdate {
+                                position_secs,
+                                duration_secs,
+                            } => vec![app::AppAction::UpdatePosition {
+                                position_secs,
+                                duration_secs,
+                            }],
+                            AudioEvent::TrackFinished => vec![app::AppAction::TrackFinished],
+                            AudioEvent::TrackError(_) => {
+                                // Skip to next track on decode error
+                                vec![app::AppAction::NextTrack]
+                            }
+                            AudioEvent::Playing => {
+                                app.playback.state = app::state::PlayState::Playing;
+                                vec![]
+                            }
+                            AudioEvent::Paused => {
+                                app.playback.state = app::state::PlayState::Paused;
+                                vec![]
+                            }
+                            AudioEvent::Stopped => {
+                                app.playback.state = app::state::PlayState::Stopped;
+                                vec![]
+                            }
+                        }
+                    }
+                };
+
+                for action in actions {
+                    app.handle_action(action);
+                }
+
+                if app.should_quit {
+                    break;
+                }
+            }
+            Err(crossbeam_channel::RecvTimeoutError::Timeout) => {
+                // Just re-render
+            }
+            Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
+                break;
+            }
+        }
+
+        // Render
+        terminal.draw(|frame| {
+            ui.render(frame, &app);
+        })?;
+    }
+
+    // Save state on exit
+    let saved_playlists: Vec<persist::SavedPlaylist> = app.playlists.iter().map(|pl| {
+        persist::SavedPlaylist {
+            name: pl.name.clone(),
+            tracks: pl.tracks.iter()
+                .filter_map(|&idx| app.library.tracks.get(idx).map(|t| t.path.clone()))
+                .collect(),
+        }
+    }).collect();
+
+    let saved = persist::SavedState {
+        volume: app.playback.volume,
+        shuffle: app.playback.shuffle,
+        repeat: app.playback.repeat.as_str().to_string(),
+        pane_widths: ui.pane_widths,
+        playlists: saved_playlists,
+        info_view: ui.info_view.as_str().to_string(),
+        right_split: ui.right_split,
+    };
+
+    if let Err(e) = persist::save(&saved) {
+        eprintln!("Warning: failed to save state: {}", e);
+    }
+
+    // Suppress rodio's "Dropping OutputStream" message:
+    // 1. Redirect stderr to /dev/null
+    // 2. Explicitly drop app (triggers AudioEngine → player thread shutdown)
+    // 3. Brief sleep so the player thread can exit and drop OutputStream silently
+    unsafe {
+        let devnull = libc::open(c"/dev/null".as_ptr(), libc::O_WRONLY);
+        if devnull >= 0 {
+            libc::dup2(devnull, 2);
+            libc::close(devnull);
+        }
+    }
+    drop(app);
+    std::thread::sleep(Duration::from_millis(50));
+
+    Ok(())
+}
+
+fn dirs_music_path() -> PathBuf {
+    if let Some(home) = std::env::var_os("HOME") {
+        let music = PathBuf::from(home).join("Music");
+        if music.is_dir() {
+            return music;
+        }
+    }
+    PathBuf::from(".")
+}