From 6ad522f27c3172980659054e18f286537b5a710b Mon Sep 17 00:00:00 2001 From: Christoffer Martinsson Date: Thu, 11 Dec 2025 19:27:50 +0100 Subject: [PATCH] Optimize performance and reduce binary size - 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) --- Cargo.toml | 5 +- src/cache/mod.rs | 3 - src/main.rs | 395 ++++++++++++++++++++++++--------------------- src/player/mod.rs | 204 ++++++++++++----------- src/scanner/mod.rs | 3 - src/state/mod.rs | 265 +++++++++++++----------------- src/ui/mod.rs | 17 +- 7 files changed, 444 insertions(+), 448 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 8744bf7..01ae217 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cm-player" -version = "0.1.22" +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" diff --git a/src/cache/mod.rs b/src/cache/mod.rs index d012608..bd095e4 100644 --- a/src/cache/mod.rs +++ b/src/cache/mod.rs @@ -8,9 +8,6 @@ use std::path::{Path, PathBuf}; pub struct FileMetadata { pub path: PathBuf, pub size: u64, - pub duration: Option, - pub codec: Option, - pub hash: Option, pub is_video: bool, pub is_audio: bool, } diff --git a/src/main.rs b/src/main.rs index e59d125..ef3170b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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( +fn run_app( terminal: &mut Terminal, state: &mut AppState, player: &mut player::Player, @@ -260,25 +327,84 @@ async fn run_app( 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 = 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,36 +427,11 @@ async fn run_app( 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) - // Also check !is_paused to avoid triggering during pause/unpause transitions - if player.is_idle() && !player.is_paused() && 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| { @@ -343,17 +444,17 @@ async fn run_app( } // 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 } } @@ -373,7 +474,7 @@ async fn run_app( Ok(()) } -async fn handle_key_event(terminal: &mut Terminal, state: &mut AppState, player: &mut player::Player, key: KeyEvent) -> Result<()> { +fn handle_key_event(terminal: &mut Terminal, state: &mut AppState, player: &mut player::Player, key: KeyEvent) -> Result<()> { // Handle confirmation popup if state.show_refresh_confirm { match key.code { @@ -457,7 +558,7 @@ async fn handle_key_event(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; @@ -519,81 +620,11 @@ async fn handle_key_event(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 { @@ -683,13 +714,13 @@ async fn handle_key_event(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"); } @@ -728,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 @@ -897,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 }; @@ -958,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 }; diff --git a/src/player/mod.rs b/src/player/mod.rs index 84970e8..6ff6f5a 100644 --- a/src/player/mod.rs +++ b/src/player/mod.rs @@ -13,8 +13,6 @@ pub struct Player { socket: Option, position: f64, duration: f64, - is_paused: bool, - is_idle: bool, pub media_title: Option, pub artist: Option, pub album: Option, @@ -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 { - 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,12 +323,28 @@ impl Player { Some(self.duration) } - pub fn is_idle(&self) -> bool { - self.is_idle + pub fn is_idle(&mut self) -> Option { + self.get_property("idle-active") + .and_then(|v| v.as_bool()) } - pub fn is_paused(&self) -> bool { - self.is_paused + pub fn is_paused(&mut self) -> Option { + self.get_property("pause") + .and_then(|v| v.as_bool()) + } + + pub fn get_player_state(&mut self) -> Option { + 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 { @@ -337,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 } } diff --git a/src/scanner/mod.rs b/src/scanner/mod.rs index 2f6cc90..d787b24 100644 --- a/src/scanner/mod.rs +++ b/src/scanner/mod.rs @@ -78,9 +78,6 @@ pub fn scan_directory(root_path: &Path) -> Result { 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), }; diff --git a/src/state/mod.rs b/src/state/mod.rs index 178c1ef..2c56f2e 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -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, 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 { } fn fuzzy_match(text: &str, query: &str) -> Option { - 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)); } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 0f8fded..f6d136f 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -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 {