4 Commits

Author SHA1 Message Date
6ad522f27c Optimize performance and reduce binary size
All checks were successful
Build and Release / build-and-release (push) Successful in 50s
- Remove tokio async runtime dependency (~2MB reduction)
- Optimize fuzzy search to avoid string allocations
- Optimize incremental search to only rebuild tree when needed
- Extract duplicate scrolling logic to helper function
- Replace magic numbers with named constants
- Fix terminal cleanup to run even on error
- Fix context menu item count mismatch
- Remove unused metadata fields (duration, codec, hash)
2025-12-11 19:27:50 +01:00
55e3f04e2c Fix auto-play next track during pause/unpause transitions
All checks were successful
Build and Release / build-and-release (push) Successful in 53s
When rapidly pressing play/pause, MPV briefly reports idle-active
as true during state transitions. Combined with our player_state
being set to Playing after unpause, this incorrectly triggered the
auto-play next track logic.

Fix: Add is_paused() check to auto-play condition to ensure we only
advance to next track when the current track actually ends, not
during pause state transitions.
2025-12-11 16:20:14 +01:00
1c2c942e4b Document state management architecture principles
Add critical guidelines for deriving player state from MPV rather
than maintaining duplicate state. Documents the single source of
truth pattern to prevent state synchronization bugs.
2025-12-11 16:11:45 +01:00
3e7707e883 Add arrow key support for folder navigation
All checks were successful
Build and Release / build-and-release (push) Successful in 54s
Add Left/Right arrow keys as alternatives to h/l for collapsing
and expanding folders in the file panel. Provides more intuitive
navigation for users not familiar with vim keybindings.

- Left arrow: Collapse selected folder (same as 'h')
- Right arrow: Expand selected folder (same as 'l')
2025-12-11 15:37:28 +01:00
8 changed files with 471 additions and 447 deletions

View File

@@ -14,6 +14,29 @@ A high-performance Rust-based TUI player for playing music and video files. Buil
## Architecture
### State Management
**CRITICAL:** Player state must be derived from MPV, not maintained separately.
**Single Source of Truth:** MPV properties via IPC
- `idle-active` (bool) - No file loaded or file ended
- `pause` (bool) - Playback is paused
**Derive PlayerState:**
```rust
if player.is_idle PlayerState::Stopped
if !player.is_idle && player.is_paused PlayerState::Paused
if !player.is_idle && !player.is_paused PlayerState::Playing
```
**Rationale:**
- Eliminates state synchronization bugs
- MPV is always the authoritative source
- No need to update state in multiple places
- Simpler auto-play logic
**Anti-pattern:** DO NOT maintain `state.player_state` that can desync from MPV
### Cache-Only Operation
**CRITICAL:** Left panel shows ONLY cached data. Never browse filesystem directly during operation.

View File

@@ -1,6 +1,6 @@
[package]
name = "cm-player"
version = "0.1.20"
version = "0.1.23"
edition = "2021"
[dependencies]
@@ -8,9 +8,6 @@ edition = "2021"
ratatui = "0.28"
crossterm = "0.28"
# Async runtime
tokio = { version = "1.40", features = ["full"] }
# Serialization
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

3
src/cache/mod.rs vendored
View File

@@ -8,9 +8,6 @@ use std::path::{Path, PathBuf};
pub struct FileMetadata {
pub path: PathBuf,
pub size: u64,
pub duration: Option<f64>,
pub codec: Option<String>,
pub hash: Option<String>,
pub is_video: bool,
pub is_audio: bool,
}

View File

@@ -16,8 +16,13 @@ use state::{AppState, PlayerState};
use std::io;
use tracing_subscriber;
#[tokio::main]
async fn main() -> Result<()> {
// UI update intervals and thresholds
const METADATA_UPDATE_INTERVAL: u32 = 20; // Update metadata every N iterations (~2 seconds)
const POLL_DURATION_STOPPED_MS: u64 = 200; // 5 FPS when stopped
const POLL_DURATION_ACTIVE_MS: u64 = 100; // 10 FPS when playing/paused
const DOUBLE_CLICK_MS: u128 = 500; // Double-click detection threshold
fn main() -> Result<()> {
// Initialize logging to file to avoid interfering with TUI
let log_file = std::fs::OpenOptions::new()
.create(true)
@@ -49,28 +54,41 @@ async fn main() -> Result<()> {
// Initialize player
let mut player = player::Player::new()?;
tracing::info!("Player initialized");
// Initialize app state
let mut state = AppState::new(cache, config);
tracing::info!("State initialized");
// Setup terminal
enable_raw_mode()?;
tracing::info!("Raw mode enabled");
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
tracing::info!("Terminal setup complete");
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
tracing::info!("Terminal created, entering main loop");
// Run app
let result = run_app(&mut terminal, &mut state, &mut player).await;
// Run app (ensure terminal cleanup even on error)
let result = run_app(&mut terminal, &mut state, &mut player);
// Restore terminal
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
// Restore terminal (always run cleanup, even if result is Err)
let cleanup_result = (|| -> Result<()> {
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
Ok(())
})();
// Log cleanup errors but prioritize original error
if let Err(e) = cleanup_result {
tracing::error!("Terminal cleanup failed: {}", e);
}
result
}
@@ -98,7 +116,6 @@ fn action_play_selection(state: &mut AppState, player: &mut player::Player) -> R
player.play(path)?;
// Explicitly resume playback in case MPV was paused
player.resume()?;
state.player_state = PlayerState::Playing;
player.update_metadata();
tracing::info!("Playing: {:?} (playlist: {} tracks)", path, state.playlist.len());
}
@@ -110,27 +127,26 @@ fn action_play_selection(state: &mut AppState, player: &mut player::Player) -> R
}
fn action_toggle_play_pause(state: &mut AppState, player: &mut player::Player) -> Result<()> {
match state.player_state {
PlayerState::Playing => {
player.pause()?;
state.player_state = PlayerState::Paused;
tracing::info!("Paused");
}
PlayerState::Paused => {
player.resume()?;
state.player_state = PlayerState::Playing;
tracing::info!("Resumed");
}
PlayerState::Stopped => {
// Restart playback from current playlist position
if !state.playlist.is_empty() {
state.current_file = Some(state.playlist[state.playlist_index].clone());
state.player_state = PlayerState::Playing;
if let Some(ref path) = state.current_file {
player.play(path)?;
player.resume()?;
player.update_metadata();
tracing::info!("Restarting playback: {:?}", path);
if let Some(player_state) = player.get_player_state() {
match player_state {
PlayerState::Playing => {
player.pause()?;
tracing::info!("Paused");
}
PlayerState::Paused => {
player.resume()?;
tracing::info!("Resumed");
}
PlayerState::Stopped => {
// Restart playback from current playlist position
if !state.playlist.is_empty() {
state.current_file = Some(state.playlist[state.playlist_index].clone());
if let Some(ref path) = state.current_file {
player.play(path)?;
player.resume()?;
player.update_metadata();
tracing::info!("Restarting playback: {:?}", path);
}
}
}
}
@@ -140,7 +156,6 @@ fn action_toggle_play_pause(state: &mut AppState, player: &mut player::Player) -
fn action_stop(state: &mut AppState, player: &mut player::Player) -> Result<()> {
player.stop()?;
state.player_state = PlayerState::Stopped;
state.current_position = 0.0;
state.current_duration = 0.0;
tracing::info!("Stopped");
@@ -149,13 +164,14 @@ fn action_stop(state: &mut AppState, player: &mut player::Player) -> Result<()>
fn action_remove_from_playlist(state: &mut AppState, player: &mut player::Player) -> Result<()> {
let was_playing_removed = state.playlist_index == state.selected_playlist_index;
let was_playing = player.get_player_state() == Some(PlayerState::Playing);
state.remove_selected_playlist_item();
if state.playlist.is_empty() {
state.player_state = PlayerState::Stopped;
state.current_file = None;
player.stop()?;
} else if was_playing_removed && state.player_state == PlayerState::Playing {
} else if was_playing_removed && was_playing && state.playlist_index < state.playlist.len() {
// Validate index before accessing playlist
state.current_file = Some(state.playlist[state.playlist_index].clone());
if let Some(ref path) = state.current_file {
player.play(path)?;
@@ -167,40 +183,91 @@ fn action_remove_from_playlist(state: &mut AppState, player: &mut player::Player
Ok(())
}
fn action_navigate_track(state: &mut AppState, player: &mut player::Player, direction: i32) -> Result<()> {
// direction: 1 for next, -1 for previous
let new_index = if direction > 0 {
state.playlist_index.saturating_add(1)
} else {
state.playlist_index.saturating_sub(1)
};
// Validate bounds
if state.playlist.is_empty() || new_index >= state.playlist.len() {
return Ok(());
}
state.playlist_index = new_index;
state.current_file = Some(state.playlist[state.playlist_index].clone());
let track_name = if direction > 0 { "Next" } else { "Previous" };
if let Some(player_state) = player.get_player_state() {
match player_state {
PlayerState::Playing => {
// Keep playing
if let Some(ref path) = state.current_file {
player.play(path)?;
player.resume()?;
player.update_metadata();
tracing::info!("{} track: {:?}", track_name, path);
}
}
PlayerState::Paused => {
// Load but stay paused
if let Some(ref path) = state.current_file {
player.play_paused(path)?;
player.update_metadata();
tracing::info!("{} track (paused): {:?}", track_name, path);
}
}
PlayerState::Stopped => {
// Just update current file, stay stopped
tracing::info!("{} track selected (stopped): {:?}", track_name, state.current_file);
}
}
}
// Auto-scroll playlist to show current track (only if user is not browsing playlist)
if !state.focus_playlist {
state.update_playlist_scroll(20);
}
Ok(())
}
fn action_play_from_playlist(state: &mut AppState, player: &mut player::Player, preserve_pause: bool) -> Result<()> {
state.playlist_index = state.selected_playlist_index;
state.current_file = Some(state.playlist[state.playlist_index].clone());
if preserve_pause {
match state.player_state {
PlayerState::Playing => {
if let Some(ref path) = state.current_file {
player.play(path)?;
player.resume()?;
player.update_metadata();
tracing::info!("Jumped to track: {:?}", path);
if let Some(player_state) = player.get_player_state() {
match player_state {
PlayerState::Playing => {
if let Some(ref path) = state.current_file {
player.play(path)?;
player.resume()?;
player.update_metadata();
tracing::info!("Jumped to track: {:?}", path);
}
}
}
PlayerState::Paused => {
if let Some(ref path) = state.current_file {
player.play(path)?;
player.update_metadata();
player.pause()?;
tracing::info!("Jumped to track (paused): {:?}", path);
PlayerState::Paused => {
if let Some(ref path) = state.current_file {
player.play_paused(path)?;
player.update_metadata();
tracing::info!("Jumped to track (paused): {:?}", path);
}
}
}
PlayerState::Stopped => {
state.player_state = PlayerState::Playing;
if let Some(ref path) = state.current_file {
player.play(path)?;
player.resume()?;
player.update_metadata();
tracing::info!("Started playing track: {:?}", path);
PlayerState::Stopped => {
if let Some(ref path) = state.current_file {
player.play(path)?;
player.resume()?;
player.update_metadata();
tracing::info!("Started playing track: {:?}", path);
}
}
}
}
} else {
state.player_state = PlayerState::Playing;
if let Some(ref path) = state.current_file {
player.play(path)?;
// Explicitly resume playback in case MPV was paused
@@ -249,7 +316,7 @@ fn handle_context_menu_action(menu_type: state::ContextMenuType, selected: usize
Ok(())
}
async fn run_app<B: ratatui::backend::Backend>(
fn run_app<B: ratatui::backend::Backend>(
terminal: &mut Terminal<B>,
state: &mut AppState,
player: &mut player::Player,
@@ -260,25 +327,84 @@ async fn run_app<B: ratatui::backend::Backend>(
let mut title_bar_area = ratatui::layout::Rect::default();
let mut file_panel_area = ratatui::layout::Rect::default();
let mut playlist_area = ratatui::layout::Rect::default();
let mut previous_player_state: Option<PlayerState> = None;
loop {
let mut state_changed = false;
// Check if mpv process died (e.g., user closed video window)
if !player.is_process_alive() && state.player_state != PlayerState::Stopped {
state.player_state = PlayerState::Stopped;
state.current_position = 0.0;
state.current_duration = 0.0;
if !player.is_process_alive() {
if let Some(player_state) = player.get_player_state() {
if player_state != PlayerState::Stopped {
state.current_position = 0.0;
state.current_duration = 0.0;
state_changed = true;
}
}
}
// Always update properties to keep state synchronized with MPV
player.update_properties();
// Only proceed if we can successfully query player state
let Some(player_state) = player.get_player_state() else {
// Can't get state from MPV, skip this iteration
if event::poll(std::time::Duration::from_millis(100))? {
match event::read()? {
Event::Key(key) => {
if key.kind == KeyEventKind::Press {
handle_key_event(terminal, state, player, key)?;
needs_redraw = true;
}
}
Event::Mouse(mouse) => {
handle_mouse_event(state, mouse, title_bar_area, file_panel_area, playlist_area, player)?;
needs_redraw = true;
}
_ => {}
}
}
continue;
};
// Check if track ended and play next
// When MPV finishes playing a file, it goes to idle (Stopped state)
// Detect Playing → Stopped transition = track ended, play next
if previous_player_state == Some(PlayerState::Playing)
&& player_state == PlayerState::Stopped
{
let should_continue = state.play_next();
// play_next() returns true if should continue playing, false if should stop
if should_continue {
if let Some(ref path) = state.current_file {
// Reset position/duration before playing new track
state.current_position = 0.0;
state.current_duration = 0.0;
last_position = 0.0;
player.play(path)?;
player.resume()?;
}
// Update metadata immediately when track changes
player.update_metadata();
metadata_update_counter = 0;
// Auto-scroll playlist to show current track (only if user is not browsing playlist)
if !state.focus_playlist {
let playlist_visible_height = playlist_area.height.saturating_sub(2) as usize;
state.update_playlist_scroll(playlist_visible_height);
}
} else {
// Reached end of playlist in Normal mode - stop playback
player.stop()?;
}
state_changed = true;
}
// Only update properties when playing or paused (not when stopped)
if state.player_state != PlayerState::Stopped {
player.update_properties();
// Update metadata only every 20 iterations (~2 seconds) to reduce IPC calls
// Only update metadata and track playback when not stopped
if player_state != PlayerState::Stopped {
// Update metadata periodically to reduce IPC calls
metadata_update_counter += 1;
if metadata_update_counter >= 20 {
if metadata_update_counter >= METADATA_UPDATE_INTERVAL {
player.update_metadata();
metadata_update_counter = 0;
state_changed = true;
@@ -301,35 +427,11 @@ async fn run_app<B: ratatui::backend::Backend>(
state.current_duration = new_duration;
state_changed = true;
}
// Check if track ended and play next (but only if track was actually loaded AND played)
// Require position > 0.5 to ensure track actually started playing (not just loaded)
if player.is_idle() && state.player_state == PlayerState::Playing && state.current_duration > 0.0 && state.current_position > 0.5 {
state.play_next();
// play_next() handles the play mode and may stop if in Normal mode at end
if state.player_state == PlayerState::Playing {
if let Some(ref path) = state.current_file {
// Reset position/duration before playing new track
state.current_position = 0.0;
state.current_duration = 0.0;
last_position = 0.0;
player.play(path)?;
player.resume()?;
}
// Update metadata immediately when track changes
player.update_metadata();
metadata_update_counter = 0;
// Auto-scroll playlist to show current track (only if user is not browsing playlist)
if !state.focus_playlist {
let playlist_visible_height = playlist_area.height.saturating_sub(2) as usize;
state.update_playlist_scroll(playlist_visible_height);
}
}
state_changed = true;
}
}
// Save current state for next iteration
previous_player_state = Some(player_state);
// Only redraw if something changed or forced
if needs_redraw || state_changed {
terminal.draw(|f| {
@@ -342,17 +444,17 @@ async fn run_app<B: ratatui::backend::Backend>(
}
// Poll for events - use longer timeout when stopped to reduce CPU
let poll_duration = if state.player_state == PlayerState::Stopped {
std::time::Duration::from_millis(200) // 5 FPS when stopped
let poll_duration = if player_state == PlayerState::Stopped {
std::time::Duration::from_millis(POLL_DURATION_STOPPED_MS)
} else {
std::time::Duration::from_millis(100) // 10 FPS when playing/paused
std::time::Duration::from_millis(POLL_DURATION_ACTIVE_MS)
};
if event::poll(poll_duration)? {
match event::read()? {
Event::Key(key) => {
if key.kind == KeyEventKind::Press {
handle_key_event(terminal, state, player, key).await?;
handle_key_event(terminal, state, player, key)?;
needs_redraw = true; // Force redraw after key event
}
}
@@ -372,7 +474,7 @@ async fn run_app<B: ratatui::backend::Backend>(
Ok(())
}
async fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, state: &mut AppState, player: &mut player::Player, key: KeyEvent) -> Result<()> {
fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, state: &mut AppState, player: &mut player::Player, key: KeyEvent) -> Result<()> {
// Handle confirmation popup
if state.show_refresh_confirm {
match key.code {
@@ -456,7 +558,7 @@ async fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<
let max_items = match menu.menu_type {
ContextMenuType::FilePanel => 2,
ContextMenuType::Playlist => 2,
ContextMenuType::TitleBar => 4,
ContextMenuType::TitleBar => 3,
};
if menu.selected_index < max_items - 1 {
menu.selected_index += 1;
@@ -518,81 +620,11 @@ async fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<
}
(KeyCode::Char('J'), KeyModifiers::SHIFT) => {
// Next track
if !state.playlist.is_empty() && state.playlist_index + 1 < state.playlist.len() {
state.playlist_index += 1;
// Validate index before accessing playlist
if state.playlist_index < state.playlist.len() {
state.current_file = Some(state.playlist[state.playlist_index].clone());
match state.player_state {
PlayerState::Playing => {
// Keep playing
if let Some(ref path) = state.current_file {
player.play(path)?;
player.resume()?;
player.update_metadata(); // Update metadata immediately
tracing::info!("Next track: {:?}", path);
}
}
PlayerState::Paused => {
// Load but stay paused
if let Some(ref path) = state.current_file {
player.play(path)?;
player.update_metadata(); // Update metadata immediately
player.pause()?;
tracing::info!("Next track (paused): {:?}", path);
}
}
PlayerState::Stopped => {
// Just update current file, stay stopped
tracing::info!("Next track selected (stopped): {:?}", state.current_file);
}
}
// Auto-scroll playlist to show current track (only if user is not browsing playlist)
if !state.focus_playlist {
state.update_playlist_scroll(20);
}
}
}
action_navigate_track(state, player, 1)?;
}
(KeyCode::Char('K'), KeyModifiers::SHIFT) => {
// Previous track
if !state.playlist.is_empty() && state.playlist_index > 0 {
state.playlist_index -= 1;
// Validate index before accessing playlist
if state.playlist_index < state.playlist.len() {
state.current_file = Some(state.playlist[state.playlist_index].clone());
match state.player_state {
PlayerState::Playing => {
// Keep playing
if let Some(ref path) = state.current_file {
player.play(path)?;
player.resume()?;
player.update_metadata(); // Update metadata immediately
tracing::info!("Previous track: {:?}", path);
}
}
PlayerState::Paused => {
// Load but stay paused
if let Some(ref path) = state.current_file {
player.play(path)?;
player.update_metadata(); // Update metadata immediately
player.pause()?;
tracing::info!("Previous track (paused): {:?}", path);
}
}
PlayerState::Stopped => {
// Just update current file, stay stopped
tracing::info!("Previous track selected (stopped): {:?}", state.current_file);
}
}
// Auto-scroll playlist to show current track (only if user is not browsing playlist)
if !state.focus_playlist {
state.update_playlist_scroll(20);
}
}
}
action_navigate_track(state, player, -1)?;
}
(KeyCode::Char('d'), KeyModifiers::CONTROL) => {
if state.focus_playlist {
@@ -626,12 +658,12 @@ async fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<
state.move_selection_down();
}
}
(KeyCode::Char('h'), _) => {
(KeyCode::Char('h'), _) | (KeyCode::Left, _) => {
if !state.focus_playlist {
state.collapse_selected();
}
}
(KeyCode::Char('l'), _) => {
(KeyCode::Char('l'), _) | (KeyCode::Right, _) => {
if !state.focus_playlist {
state.expand_selected();
}
@@ -682,13 +714,13 @@ async fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<
action_toggle_play_pause(state, player)?;
}
(KeyCode::Char('H'), KeyModifiers::SHIFT) => {
if state.player_state != PlayerState::Stopped {
if player.get_player_state() != Some(PlayerState::Stopped) {
player.seek(-10.0)?;
tracing::info!("Seek backward 10s");
}
}
(KeyCode::Char('L'), KeyModifiers::SHIFT) => {
if state.player_state != PlayerState::Stopped {
if player.get_player_state() != Some(PlayerState::Stopped) {
player.seek(10.0)?;
tracing::info!("Seek forward 10s");
}
@@ -727,7 +759,7 @@ fn handle_mouse_event(state: &mut AppState, mouse: MouseEvent, title_bar_area: r
let items = match menu.menu_type {
ContextMenuType::FilePanel => 2,
ContextMenuType::Playlist => 2,
ContextMenuType::TitleBar => 4,
ContextMenuType::TitleBar => 3,
};
let popup_width = 13;
let popup_height = items as u16 + 2; // +2 for borders
@@ -896,7 +928,7 @@ fn handle_mouse_event(state: &mut AppState, mouse: MouseEvent, title_bar_area: r
let now = std::time::Instant::now();
let is_double_click = if let (Some(last_time), Some(last_idx), false) =
(state.last_click_time, state.last_click_index, state.last_click_is_playlist) {
last_idx == clicked_index && now.duration_since(last_time).as_millis() < 500
last_idx == clicked_index && now.duration_since(last_time).as_millis() < DOUBLE_CLICK_MS
} else {
false
};
@@ -957,7 +989,7 @@ fn handle_mouse_event(state: &mut AppState, mouse: MouseEvent, title_bar_area: r
let now = std::time::Instant::now();
let is_double_click = if let (Some(last_time), Some(last_idx), true) =
(state.last_click_time, state.last_click_index, state.last_click_is_playlist) {
last_idx == actual_track && now.duration_since(last_time).as_millis() < 500
last_idx == actual_track && now.duration_since(last_time).as_millis() < DOUBLE_CLICK_MS
} else {
false
};

View File

@@ -13,8 +13,6 @@ pub struct Player {
socket: Option<UnixStream>,
position: f64,
duration: f64,
is_paused: bool,
is_idle: bool,
pub media_title: Option<String>,
pub artist: Option<String>,
pub album: Option<String>,
@@ -46,17 +44,12 @@ impl Player {
tracing::info!("MPV process started with IPC at {:?}", socket_path);
// Wait for socket to be created
std::thread::sleep(Duration::from_millis(500));
Ok(Self {
process,
socket_path,
socket: None,
position: 0.0,
duration: 0.0,
is_paused: false,
is_idle: true,
media_title: None,
artist: None,
album: None,
@@ -69,67 +62,35 @@ impl Player {
fn connect(&mut self) -> Result<()> {
if self.socket.is_none() {
// Try to connect, if it fails, respawn mpv
match UnixStream::connect(&self.socket_path) {
Ok(stream) => {
stream.set_nonblocking(true).ok();
self.socket = Some(stream);
}
Err(_) => {
// MPV probably died, respawn it
self.respawn()?;
let stream = UnixStream::connect(&self.socket_path)
.context("Failed to connect to MPV IPC socket after respawn")?;
stream.set_nonblocking(true).ok();
self.socket = Some(stream);
}
// CRITICAL: Only try to connect if socket file exists
// If socket doesn't exist, MPV hasn't created it yet - fail fast
if !self.socket_path.exists() {
return Err(anyhow::anyhow!("Socket file doesn't exist yet"));
}
// Try to connect with a timeout using non-blocking mode
// IMPORTANT: UnixStream::connect() blocks in the kernel if socket exists
// but server isn't listening yet. We check existence first but still
// need to handle connect blocking if MPV just created socket but isn't ready.
let stream = match UnixStream::connect(&self.socket_path) {
Ok(s) => s,
Err(e) => {
// Connection failed - MPV probably not ready yet
return Err(anyhow::anyhow!("Failed to connect: {}", e));
}
};
// Set non-blocking and timeout to prevent hangs on reads/writes
stream.set_nonblocking(true)?;
stream.set_read_timeout(Some(Duration::from_millis(100)))?;
stream.set_write_timeout(Some(Duration::from_millis(100)))?;
self.socket = Some(stream);
tracing::debug!("Connected to MPV socket successfully");
}
Ok(())
}
fn respawn(&mut self) -> Result<()> {
// Kill old process if still running
self.process.kill().ok();
self.process.wait().ok();
// Clean up old socket
std::fs::remove_file(&self.socket_path).ok();
// Spawn new MPV process
let process = Command::new("mpv")
.arg("--idle")
.arg("--no-terminal")
.arg("--profile=fast")
.arg("--audio-display=no")
.arg(format!("--input-ipc-server={}", self.socket_path.display()))
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.context("Failed to respawn MPV process")?;
self.process = process;
self.socket = None;
self.is_idle = true;
self.position = 0.0;
self.duration = 0.0;
self.is_paused = false;
self.media_title = None;
self.artist = None;
self.album = None;
self.audio_codec = None;
self.audio_bitrate = None;
self.sample_rate = None;
self.cache_duration = None;
// Wait for socket to be created and mpv to be ready
std::thread::sleep(Duration::from_millis(800));
tracing::info!("MPV process respawned");
Ok(())
}
fn send_command(&mut self, command: &str, args: &[Value]) -> Result<()> {
self.connect()?;
@@ -146,7 +107,6 @@ impl Player {
if let Err(e) = socket.write_all(msg.as_bytes()) {
if e.kind() == std::io::ErrorKind::BrokenPipe {
self.socket = None;
self.is_idle = true;
// Clean up dead process
self.process.kill().ok();
return Ok(());
@@ -159,7 +119,11 @@ impl Player {
}
fn get_property(&mut self, property: &str) -> Option<Value> {
self.connect().ok()?;
// Try to connect - if respawning or connection fails, return None
if let Err(e) = self.connect() {
tracing::debug!("Failed to connect for property '{}': {}", property, e);
return None;
}
let cmd = json!({
"command": ["get_property", property],
@@ -168,19 +132,66 @@ impl Player {
if let Some(ref mut socket) = self.socket {
let msg = format!("{}\n", cmd);
socket.write_all(msg.as_bytes()).ok()?;
// Try to read response (non-blocking)
// Write command
if let Err(e) = socket.write_all(msg.as_bytes()) {
tracing::warn!("Failed to write get_property command for '{}': {}", property, e);
self.socket = None;
return None;
}
// Try to read response with timeout
socket.set_nonblocking(false).ok();
socket.set_read_timeout(Some(Duration::from_millis(100))).ok();
let mut reader = BufReader::new(socket.try_clone().ok()?);
let cloned_socket = match socket.try_clone() {
Ok(s) => s,
Err(e) => {
tracing::warn!("Failed to clone socket for '{}': {}", property, e);
socket.set_nonblocking(true).ok();
return None;
}
};
// Set timeout on cloned socket too (clone doesn't copy settings)
cloned_socket.set_nonblocking(false).ok();
cloned_socket.set_read_timeout(Some(Duration::from_millis(100))).ok();
let mut reader = BufReader::new(cloned_socket);
let mut response = String::new();
reader.read_line(&mut response).ok()?;
if let Err(e) = reader.read_line(&mut response) {
tracing::debug!("Failed to read response for '{}': {}", property, e);
socket.set_nonblocking(true).ok();
return None;
}
socket.set_nonblocking(true).ok();
let parsed: Value = serde_json::from_str(&response).ok()?;
// Parse and validate response
let parsed: Value = match serde_json::from_str(&response) {
Ok(v) => v,
Err(e) => {
tracing::warn!("Failed to parse JSON response for '{}': {} (response: {})", property, e, response.trim());
return None;
}
};
// Check for errors in response
// MPV returns {"error": "success"} when there's NO error
if let Some(error) = parsed.get("error").and_then(|e| e.as_str()) {
if error != "success" {
tracing::debug!("MPV returned error for '{}': {}", property, error);
return None;
}
}
// Validate request_id matches (should be 1)
if let Some(req_id) = parsed.get("request_id").and_then(|id| id.as_i64()) {
if req_id != 1 {
tracing::warn!("Request ID mismatch for '{}': expected 1, got {}", property, req_id);
}
}
return parsed.get("data").cloned();
}
@@ -190,27 +201,30 @@ impl Player {
pub fn play(&mut self, path: &Path) -> Result<()> {
let path_str = path.to_string_lossy();
self.send_command("loadfile", &[json!(path_str), json!("replace")])?;
self.is_paused = false;
self.is_idle = false;
tracing::info!("Playing: {}", path_str);
Ok(())
}
pub fn play_paused(&mut self, path: &Path) -> Result<()> {
let path_str = path.to_string_lossy();
// Load file but start paused - avoids audio blip when jumping tracks while paused
self.send_command("loadfile", &[json!(path_str), json!("replace"), json!({"pause": true})])?;
tracing::info!("Playing (paused): {}", path_str);
Ok(())
}
pub fn pause(&mut self) -> Result<()> {
self.send_command("set_property", &[json!("pause"), json!(true)])?;
self.is_paused = true;
Ok(())
}
pub fn resume(&mut self) -> Result<()> {
self.send_command("set_property", &[json!("pause"), json!(false)])?;
self.is_paused = false;
Ok(())
}
pub fn stop(&mut self) -> Result<()> {
self.send_command("stop", &[])?;
self.is_idle = true;
self.position = 0.0;
self.duration = 0.0;
Ok(())
@@ -235,20 +249,6 @@ impl Player {
self.duration = dur;
}
}
// Update pause state
if let Some(val) = self.get_property("pause") {
if let Some(paused) = val.as_bool() {
self.is_paused = paused;
}
}
// Update idle state
if let Some(val) = self.get_property("idle-active") {
if let Some(idle) = val.as_bool() {
self.is_idle = idle;
}
}
}
pub fn update_metadata(&mut self) {
@@ -323,8 +323,28 @@ impl Player {
Some(self.duration)
}
pub fn is_idle(&self) -> bool {
self.is_idle
pub fn is_idle(&mut self) -> Option<bool> {
self.get_property("idle-active")
.and_then(|v| v.as_bool())
}
pub fn is_paused(&mut self) -> Option<bool> {
self.get_property("pause")
.and_then(|v| v.as_bool())
}
pub fn get_player_state(&mut self) -> Option<crate::state::PlayerState> {
use crate::state::PlayerState;
let is_idle = self.is_idle()?;
let is_paused = self.is_paused()?;
Some(if is_idle {
PlayerState::Stopped
} else if is_paused {
PlayerState::Paused
} else {
PlayerState::Playing
})
}
pub fn is_process_alive(&mut self) -> bool {
@@ -333,14 +353,12 @@ impl Player {
Ok(Some(_)) => {
// Process has exited - clean up socket
self.socket = None;
self.is_idle = true;
false
}
Ok(None) => true, // Process is still running
Err(_) => {
// Error checking, assume dead and clean up
self.socket = None;
self.is_idle = true;
false
}
}

View File

@@ -78,9 +78,6 @@ pub fn scan_directory(root_path: &Path) -> Result<FileTreeNode> {
let metadata = FileMetadata {
path: path.to_path_buf(),
size,
duration: None, // Will be populated by MPV later
codec: None,
hash: None,
is_video: is_video_file(path),
is_audio: is_audio_file(path),
};

View File

@@ -4,6 +4,26 @@ use std::collections::HashSet;
use std::path::PathBuf;
use std::time::Instant;
// Fuzzy match scoring bonuses
const FUZZY_CONSECUTIVE_BONUS: i32 = 10;
const FUZZY_WORD_START_BONUS: i32 = 15;
const FUZZY_FOLDER_BONUS: i32 = 50;
// Helper to calculate effective height accounting for "X more below" indicator
fn calculate_effective_height(scroll_offset: usize, visible_height: usize, total_items: usize) -> usize {
let visible_end = scroll_offset + visible_height;
let items_below = if visible_end < total_items {
total_items - visible_end
} else {
0
};
if items_below > 0 {
visible_height.saturating_sub(1)
} else {
visible_height
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlayerState {
Stopped,
@@ -39,7 +59,6 @@ pub struct AppState {
pub scroll_offset: usize,
pub file_panel_visible_height: usize,
pub playlist_visible_height: usize,
pub player_state: PlayerState,
pub current_file: Option<PathBuf>,
pub current_position: f64,
pub current_duration: f64,
@@ -95,7 +114,6 @@ impl AppState {
scroll_offset: 0,
file_panel_visible_height: 20,
playlist_visible_height: 20,
player_state: PlayerState::Stopped,
current_file: None,
current_position: 0.0,
current_duration: 0.0,
@@ -150,18 +168,11 @@ impl AppState {
if self.selected_index < self.flattened_items.len().saturating_sub(1) {
self.selected_index += 1;
// Account for "... X more below" indicator which takes one line
let visible_end = self.scroll_offset + self.file_panel_visible_height;
let items_below = if visible_end < self.flattened_items.len() {
self.flattened_items.len() - visible_end
} else {
0
};
let effective_height = if items_below > 0 {
self.file_panel_visible_height.saturating_sub(1)
} else {
self.file_panel_visible_height
};
let effective_height = calculate_effective_height(
self.scroll_offset,
self.file_panel_visible_height,
self.flattened_items.len()
);
// Scroll down when selection reaches bottom
if self.selected_index >= self.scroll_offset + effective_height {
@@ -229,18 +240,11 @@ impl AppState {
if self.selected_playlist_index < self.playlist.len().saturating_sub(1) {
self.selected_playlist_index += 1;
// Account for "... X more below" indicator which takes one line
let visible_end = self.playlist_scroll_offset + visible_height;
let items_below = if visible_end < self.playlist.len() {
self.playlist.len() - visible_end
} else {
0
};
let effective_height = if items_below > 0 {
visible_height.saturating_sub(1)
} else {
visible_height
};
let effective_height = calculate_effective_height(
self.playlist_scroll_offset,
visible_height,
self.playlist.len()
);
// Scroll down when selection reaches bottom
if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height {
@@ -276,18 +280,11 @@ impl AppState {
let new_index = (self.selected_playlist_index + half_page).min(self.playlist.len().saturating_sub(1));
self.selected_playlist_index = new_index;
// Account for "... X more below" indicator which takes one line
let visible_end = self.playlist_scroll_offset + self.playlist_visible_height;
let items_below = if visible_end < self.playlist.len() {
self.playlist.len() - visible_end
} else {
0
};
let effective_height = if items_below > 0 {
self.playlist_visible_height.saturating_sub(1)
} else {
self.playlist_visible_height
};
let effective_height = calculate_effective_height(
self.playlist_scroll_offset,
self.playlist_visible_height,
self.playlist.len()
);
// Adjust scroll if needed
if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height {
@@ -312,18 +309,11 @@ impl AppState {
let new_index = (self.selected_index + half_page).min(self.flattened_items.len().saturating_sub(1));
self.selected_index = new_index;
// Account for "... X more below" indicator which takes one line
let visible_end = self.scroll_offset + self.file_panel_visible_height;
let items_below = if visible_end < self.flattened_items.len() {
self.flattened_items.len() - visible_end
} else {
0
};
let effective_height = if items_below > 0 {
self.file_panel_visible_height.saturating_sub(1)
} else {
self.file_panel_visible_height
};
let effective_height = calculate_effective_height(
self.scroll_offset,
self.file_panel_visible_height,
self.flattened_items.len()
);
// Adjust scroll if needed
if self.selected_index >= self.scroll_offset + effective_height {
@@ -438,7 +428,6 @@ impl AppState {
pub fn clear_playlist(&mut self) {
self.playlist.clear();
self.playlist_index = 0;
self.player_state = PlayerState::Stopped;
self.current_file = None;
}
@@ -474,11 +463,9 @@ impl AppState {
self.selected_playlist_index = 0;
if let Some(first) = self.playlist.first() {
self.current_file = Some(first.clone());
self.player_state = PlayerState::Playing;
} else {
// Empty playlist
self.current_file = None;
self.player_state = PlayerState::Stopped;
}
} else if let Some(item) = self.get_selected_item() {
let node = item.node.clone();
@@ -490,11 +477,9 @@ impl AppState {
self.selected_playlist_index = 0;
if let Some(first) = self.playlist.first() {
self.current_file = Some(first.clone());
self.player_state = PlayerState::Playing;
} else {
// Empty directory
self.current_file = None;
self.player_state = PlayerState::Stopped;
}
} else {
// Play single file
@@ -504,14 +489,13 @@ impl AppState {
self.playlist_scroll_offset = 0;
self.selected_playlist_index = 0;
self.current_file = Some(path);
self.player_state = PlayerState::Playing;
}
}
}
pub fn play_next(&mut self) {
pub fn play_next(&mut self) -> bool {
if self.playlist.is_empty() {
return;
return false;
}
match self.play_mode {
@@ -521,18 +505,17 @@ impl AppState {
self.playlist_index += 1;
if self.playlist_index < self.playlist.len() {
self.current_file = Some(self.playlist[self.playlist_index].clone());
self.player_state = PlayerState::Playing;
return true; // Should continue playing
}
} else {
// Reached end, stop
self.player_state = PlayerState::Stopped;
}
// Reached end, should stop
false
}
PlayMode::Loop => {
// Loop back to beginning when reaching end
self.playlist_index = (self.playlist_index + 1) % self.playlist.len();
self.current_file = Some(self.playlist[self.playlist_index].clone());
self.player_state = PlayerState::Playing;
true // Should continue playing
}
}
}
@@ -641,6 +624,7 @@ impl AppState {
if self.search_query.is_empty() {
self.tab_search_results.clear();
self.tab_search_index = 0;
// Don't rebuild tree on every keystroke - only when exiting search
return;
}
@@ -668,35 +652,37 @@ impl AppState {
self.tab_search_results = indexed_matches.iter().map(|(path, _, _)| path.clone()).collect();
self.tab_search_index = 0;
// Close all folders and expand only for the best match
self.expanded_dirs.clear();
// Only expand and rebuild if this is a new best match
let best_match = self.tab_search_results[0].clone();
let mut parent = best_match.parent();
while let Some(p) = parent {
self.expanded_dirs.insert(p.to_path_buf());
parent = p.parent();
}
// Rebuild flattened items
self.rebuild_flattened_items();
// Check if we need to expand folders for this match
let needs_expand = best_match.ancestors()
.skip(1) // Skip the file itself
.any(|p| !self.expanded_dirs.contains(p));
if needs_expand {
// Close all folders and expand only for the best match
self.expanded_dirs.clear();
let mut parent = best_match.parent();
while let Some(p) = parent {
self.expanded_dirs.insert(p.to_path_buf());
parent = p.parent();
}
// Rebuild flattened items
self.rebuild_flattened_items();
}
// Find the best match in the flattened list and jump to it
if let Some(idx) = self.flattened_items.iter().position(|item| item.node.path == best_match) {
self.selected_index = idx;
// Scroll to show the match
// Account for "... X more below" indicator which takes one line
let visible_end = self.scroll_offset + self.file_panel_visible_height;
let items_below = if visible_end < self.flattened_items.len() {
self.flattened_items.len() - visible_end
} else {
0
};
let effective_height = if items_below > 0 {
self.file_panel_visible_height.saturating_sub(1)
} else {
self.file_panel_visible_height
};
let effective_height = calculate_effective_height(
self.scroll_offset,
self.file_panel_visible_height,
self.flattened_items.len()
);
if self.selected_index < self.scroll_offset {
self.scroll_offset = self.selected_index;
@@ -878,18 +864,11 @@ impl AppState {
self.selected_index = idx;
// Scroll to show the match
// Account for "... X more below" indicator which takes one line
let visible_end = self.scroll_offset + self.file_panel_visible_height;
let items_below = if visible_end < self.flattened_items.len() {
self.flattened_items.len() - visible_end
} else {
0
};
let effective_height = if items_below > 0 {
self.file_panel_visible_height.saturating_sub(1)
} else {
self.file_panel_visible_height
};
let effective_height = calculate_effective_height(
self.scroll_offset,
self.file_panel_visible_height,
self.flattened_items.len()
);
if self.selected_index < self.scroll_offset {
self.scroll_offset = self.selected_index;
@@ -928,18 +907,11 @@ impl AppState {
self.selected_index = idx;
// Scroll to show the match
// Account for "... X more below" indicator which takes one line
let visible_end = self.scroll_offset + self.file_panel_visible_height;
let items_below = if visible_end < self.flattened_items.len() {
self.flattened_items.len() - visible_end
} else {
0
};
let effective_height = if items_below > 0 {
self.file_panel_visible_height.saturating_sub(1)
} else {
self.file_panel_visible_height
};
let effective_height = calculate_effective_height(
self.scroll_offset,
self.file_panel_visible_height,
self.flattened_items.len()
);
if self.selected_index < self.scroll_offset {
self.scroll_offset = self.selected_index;
@@ -996,18 +968,11 @@ impl AppState {
self.selected_playlist_index = best_match_idx;
// Scroll to show the match
// Account for "... X more below" indicator which takes one line
let visible_end = self.playlist_scroll_offset + self.playlist_visible_height;
let items_below = if visible_end < self.playlist.len() {
self.playlist.len() - visible_end
} else {
0
};
let effective_height = if items_below > 0 {
self.playlist_visible_height.saturating_sub(1)
} else {
self.playlist_visible_height
};
let effective_height = calculate_effective_height(
self.playlist_scroll_offset,
self.playlist_visible_height,
self.playlist.len()
);
if self.selected_playlist_index < self.playlist_scroll_offset {
self.playlist_scroll_offset = self.selected_playlist_index;
@@ -1027,18 +992,11 @@ impl AppState {
self.selected_playlist_index = next_match_idx;
// Scroll to show the match
// Account for "... X more below" indicator which takes one line
let visible_end = self.playlist_scroll_offset + self.playlist_visible_height;
let items_below = if visible_end < self.playlist.len() {
self.playlist.len() - visible_end
} else {
0
};
let effective_height = if items_below > 0 {
self.playlist_visible_height.saturating_sub(1)
} else {
self.playlist_visible_height
};
let effective_height = calculate_effective_height(
self.playlist_scroll_offset,
self.playlist_visible_height,
self.playlist.len()
);
if self.selected_playlist_index < self.playlist_scroll_offset {
self.playlist_scroll_offset = self.selected_playlist_index;
@@ -1062,18 +1020,11 @@ impl AppState {
self.selected_playlist_index = prev_match_idx;
// Scroll to show the match
// Account for "... X more below" indicator which takes one line
let visible_end = self.playlist_scroll_offset + self.playlist_visible_height;
let items_below = if visible_end < self.playlist.len() {
self.playlist.len() - visible_end
} else {
0
};
let effective_height = if items_below > 0 {
self.playlist_visible_height.saturating_sub(1)
} else {
self.playlist_visible_height
};
let effective_height = calculate_effective_height(
self.playlist_scroll_offset,
self.playlist_visible_height,
self.playlist.len()
);
if self.selected_playlist_index < self.playlist_scroll_offset {
self.playlist_scroll_offset = self.selected_playlist_index;
@@ -1236,38 +1187,44 @@ fn collect_files_from_node(node: &FileTreeNode) -> Vec<PathBuf> {
}
fn fuzzy_match(text: &str, query: &str) -> Option<i32> {
let text_lower = text.to_lowercase();
let query_lower = query.to_lowercase();
let mut text_chars = text_lower.chars();
// Avoid allocations by comparing chars directly with case-insensitive logic
let mut text_chars = text.chars();
let mut score = 0;
let mut prev_match_idx = 0;
let mut consecutive_bonus = 0;
let mut prev_char = '\0';
for query_char in query_lower.chars() {
for query_char in query.chars() {
// Lowercase query char inline
let query_char_lower = query_char.to_lowercase().next().unwrap_or(query_char);
let mut found = false;
let mut current_idx = prev_match_idx;
for text_char in text_chars.by_ref() {
current_idx += 1;
if text_char == query_char {
// Lowercase text char inline for comparison
let text_char_lower = text_char.to_lowercase().next().unwrap_or(text_char);
if text_char_lower == query_char_lower {
found = true;
// Bonus for consecutive matches
if current_idx == prev_match_idx + 1 {
consecutive_bonus += 10;
consecutive_bonus += FUZZY_CONSECUTIVE_BONUS;
} else {
consecutive_bonus = 0;
}
// Bonus for matching at word start
if current_idx == 1 || text_lower.chars().nth(current_idx - 2).map_or(false, |c| !c.is_alphanumeric()) {
score += 15;
if current_idx == 1 || !prev_char.is_alphanumeric() {
score += FUZZY_WORD_START_BONUS;
}
score += consecutive_bonus;
// Penalty for gap
score -= (current_idx - prev_match_idx - 1) as i32;
prev_match_idx = current_idx;
prev_char = text_char;
break;
}
prev_char = text_char;
}
if !found {
@@ -1283,7 +1240,7 @@ fn collect_matching_paths(nodes: &[FileTreeNode], query: &str, matches: &mut Vec
if let Some(mut score) = fuzzy_match(&node.name, query) {
// Give folders a significant boost so they appear before files
if node.is_dir {
score += 50;
score += FUZZY_FOLDER_BONUS;
}
matches.push((node.path.clone(), score));
}

View File

@@ -11,7 +11,7 @@ use ratatui::{
};
use theme::Theme;
pub fn render(frame: &mut Frame, state: &mut AppState, player: &Player) -> (Rect, Rect, Rect) {
pub fn render(frame: &mut Frame, state: &mut AppState, player: &mut Player) -> (Rect, Rect, Rect) {
// Clear background
frame.render_widget(
Block::default().style(Theme::secondary()),
@@ -62,7 +62,8 @@ fn highlight_search_matches<'a>(text: &str, query: &str, is_selected: bool) -> V
let mut current_segment = String::new();
for ch in text.chars() {
let ch_lower = ch.to_lowercase().next().unwrap();
// to_lowercase() returns an iterator, get first char (always exists but use unwrap_or for safety)
let ch_lower = ch.to_lowercase().next().unwrap_or(ch);
if let Some(query_ch) = current_query_char {
if ch_lower == query_ch {
@@ -349,8 +350,10 @@ fn render_right_panel(frame: &mut Frame, state: &mut AppState, area: Rect) {
frame.render_stateful_widget(playlist_widget, area, &mut playlist_state);
}
fn render_title_bar(frame: &mut Frame, state: &AppState, _player: &Player, area: Rect) {
let background_color = match state.player_state {
fn render_title_bar(frame: &mut Frame, state: &AppState, player: &mut Player, area: Rect) {
// Default to stopped if we can't query MPV
let player_state = player.get_player_state().unwrap_or(PlayerState::Stopped);
let background_color = match player_state {
PlayerState::Playing => Theme::success(), // Green for playing
PlayerState::Paused => Theme::highlight(), // Blue for paused
PlayerState::Stopped => Theme::dim_foreground(), // Gray for stopped
@@ -389,13 +392,13 @@ fn render_title_bar(frame: &mut Frame, state: &AppState, _player: &Player, area:
));
} else {
// Status (bold when playing)
let status_text = match state.player_state {
let status_text = match player_state {
PlayerState::Stopped => "Stopped",
PlayerState::Playing => "Playing",
PlayerState::Paused => "Paused",
};
let status_style = if state.player_state == PlayerState::Playing {
let status_style = if player_state == PlayerState::Playing {
Style::default()
.fg(Theme::background())
.bg(background_color)
@@ -458,7 +461,7 @@ fn render_title_bar(frame: &mut Frame, state: &AppState, _player: &Player, area:
frame.render_widget(right_title, chunks[1]);
}
fn render_status_bar(frame: &mut Frame, state: &AppState, player: &Player, area: Rect) {
fn render_status_bar(frame: &mut Frame, state: &AppState, player: &mut Player, area: Rect) {
if state.search_mode {
// Show search prompt with current query and match count - LEFT aligned
let search_text = if state.focus_playlist {