Compare commits

...

11 Commits

Author SHA1 Message Date
4529fad61d Reduce memory usage by ~50 MB for large libraries
All checks were successful
Build and Release / build-and-release (push) Successful in 50s
FlattenedItem now stores only essential fields (path, name, is_dir, depth)
instead of cloning entire FileTreeNode structures. For 500,000 files, this
reduces memory from ~100 MB to ~50 MB for the flattened view.

- Extract only needed fields in flatten_tree()
- Add find_node_by_path() helper to look up full nodes when needed
- Update all UI and state code to use new structure
2025-12-11 19:39:26 +01:00
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
b59d1aed65 Fix MPV pause state bug when loading new files
All checks were successful
Build and Release / build-and-release (push) Successful in 52s
When MPV is paused and a new file is loaded via loadfile command,
MPV loads the file but remains in a paused state. This caused the
UI to show "Playing" while no audio was actually playing.

Fix: Explicitly call resume() after every play() call to ensure
MPV unpauses when loading new files. This applies to:
- Playing new folder/file selections
- Playing from playlist
- Auto-play next/previous track
- Removing currently playing track from playlist

Fixes bug where after pause, playing another folder would show
"Playing" status but remain silent at 00:00.
2025-12-11 15:32:37 +01:00
f1412b4f8c Refactor to eliminate keyboard/mouse handler disconnects
All checks were successful
Build and Release / build-and-release (push) Successful in 53s
Extract duplicate logic into shared action functions to ensure
consistent behavior between keyboard and mouse interactions:

- action_remove_from_playlist: Unified playlist removal logic
- action_play_from_playlist: Unified playlist playback with optional
  pause state preservation
- handle_context_menu_action: Unified context menu execution

Fixes:
- Remove from playlist now checks index before removal (was broken
  in keyboard 'd' handler)
- Mouse double-click on playlist now preserves pause state
- Context menu handling no longer duplicated across 400+ lines

All keyboard and mouse actions now use identical code paths,
eliminating state bugs from inconsistent implementations.
2025-12-11 15:16:57 +01:00
ffe7cd0090 Fix time display to update smoothly
All checks were successful
Build and Release / build-and-release (push) Successful in 54s
Change position update logic to only trigger redraw when the
displayed value (rounded to seconds) changes, not when the raw
float value changes. This eliminates jumpy time display and
reduces unnecessary redraws.
2025-12-09 12:33:52 +01:00
907a734be3 Remove Cache prefix from cache duration display
All checks were successful
Build and Release / build-and-release (push) Successful in 55s
Display cache duration as "1.5s" instead of "Cache:1.5s" in
bottom status bar for cleaner presentation alongside other
technical metrics.
2025-12-09 12:23:04 +01:00
135700ce02 Update cache metric refresh rate to match other metadata
All checks were successful
Build and Release / build-and-release (push) Successful in 54s
Move cache duration update from update_properties (~10Hz) to
update_metadata (~0.5Hz) to match the refresh rate of codec,
bitrate, and sample rate. All bottom status bar metrics now
update at the same frequency.
2025-12-09 12:06:59 +01:00
ea72368841 Remove buffer mode feature and relocate cache metrics
All checks were successful
Build and Release / build-and-release (push) Successful in 56s
- Remove buffer mode toggle (Normal/Large/Huge) as demuxer settings
  do not significantly impact local file playback
- Move cache duration metric from title bar to bottom status bar
- Display cache alongside codec, bitrate, and sample rate info
- Remove 'b' key binding and Buffer context menu option
- Update version to 0.1.15
2025-12-09 11:51:51 +01:00
8 changed files with 656 additions and 646 deletions

View File

@ -14,6 +14,29 @@ A high-performance Rust-based TUI player for playing music and video files. Buil
## Architecture ## 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 ### Cache-Only Operation
**CRITICAL:** Left panel shows ONLY cached data. Never browse filesystem directly during operation. **CRITICAL:** Left panel shows ONLY cached data. Never browse filesystem directly during operation.

View File

@ -1,6 +1,6 @@
[package] [package]
name = "cm-player" name = "cm-player"
version = "0.1.14" version = "0.1.24"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
@ -8,9 +8,6 @@ edition = "2021"
ratatui = "0.28" ratatui = "0.28"
crossterm = "0.28" crossterm = "0.28"
# Async runtime
tokio = { version = "1.40", features = ["full"] }
# Serialization # Serialization
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" 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 struct FileMetadata {
pub path: PathBuf, pub path: PathBuf,
pub size: u64, pub size: u64,
pub duration: Option<f64>,
pub codec: Option<String>,
pub hash: Option<String>,
pub is_video: bool, pub is_video: bool,
pub is_audio: bool, pub is_audio: bool,
} }

View File

@ -16,8 +16,13 @@ use state::{AppState, PlayerState};
use std::io; use std::io;
use tracing_subscriber; use tracing_subscriber;
#[tokio::main] // UI update intervals and thresholds
async fn main() -> Result<()> { 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 // Initialize logging to file to avoid interfering with TUI
let log_file = std::fs::OpenOptions::new() let log_file = std::fs::OpenOptions::new()
.create(true) .create(true)
@ -49,28 +54,41 @@ async fn main() -> Result<()> {
// Initialize player // Initialize player
let mut player = player::Player::new()?; let mut player = player::Player::new()?;
tracing::info!("Player initialized");
// Initialize app state // Initialize app state
let mut state = AppState::new(cache, config); let mut state = AppState::new(cache, config);
tracing::info!("State initialized");
// Setup terminal // Setup terminal
enable_raw_mode()?; enable_raw_mode()?;
tracing::info!("Raw mode enabled");
let mut stdout = io::stdout(); let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
tracing::info!("Terminal setup complete");
let backend = CrosstermBackend::new(stdout); let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?; let mut terminal = Terminal::new(backend)?;
tracing::info!("Terminal created, entering main loop");
// Run app // Run app (ensure terminal cleanup even on error)
let result = run_app(&mut terminal, &mut state, &mut player).await; let result = run_app(&mut terminal, &mut state, &mut player);
// Restore terminal // Restore terminal (always run cleanup, even if result is Err)
disable_raw_mode()?; let cleanup_result = (|| -> Result<()> {
execute!( disable_raw_mode()?;
terminal.backend_mut(), execute!(
LeaveAlternateScreen, terminal.backend_mut(),
DisableMouseCapture LeaveAlternateScreen,
)?; DisableMouseCapture
terminal.show_cursor()?; )?;
terminal.show_cursor()?;
Ok(())
})();
// Log cleanup errors but prioritize original error
if let Err(e) = cleanup_result {
tracing::error!("Terminal cleanup failed: {}", e);
}
result result
} }
@ -79,8 +97,8 @@ async fn main() -> Result<()> {
fn action_toggle_folder(state: &mut AppState) { fn action_toggle_folder(state: &mut AppState) {
if let Some(item) = state.get_selected_item() { if let Some(item) = state.get_selected_item() {
if item.node.is_dir { if item.is_dir {
let path = item.node.path.clone(); let path = item.path.clone();
if state.expanded_dirs.contains(&path) { if state.expanded_dirs.contains(&path) {
// Folder is open, close it // Folder is open, close it
state.collapse_selected(); state.collapse_selected();
@ -96,7 +114,8 @@ fn action_play_selection(state: &mut AppState, player: &mut player::Player) -> R
state.play_selection(); state.play_selection();
if let Some(ref path) = state.current_file { if let Some(ref path) = state.current_file {
player.play(path)?; player.play(path)?;
state.player_state = PlayerState::Playing; // Explicitly resume playback in case MPV was paused
player.resume()?;
player.update_metadata(); player.update_metadata();
tracing::info!("Playing: {:?} (playlist: {} tracks)", path, state.playlist.len()); tracing::info!("Playing: {:?} (playlist: {} tracks)", path, state.playlist.len());
} }
@ -108,26 +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<()> { fn action_toggle_play_pause(state: &mut AppState, player: &mut player::Player) -> Result<()> {
match state.player_state { if let Some(player_state) = player.get_player_state() {
PlayerState::Playing => { match player_state {
player.pause()?; PlayerState::Playing => {
state.player_state = PlayerState::Paused; player.pause()?;
tracing::info!("Paused"); tracing::info!("Paused");
} }
PlayerState::Paused => { PlayerState::Paused => {
player.resume()?; player.resume()?;
state.player_state = PlayerState::Playing; tracing::info!("Resumed");
tracing::info!("Resumed"); }
} PlayerState::Stopped => {
PlayerState::Stopped => { // Restart playback from current playlist position
// Restart playback from current playlist position if !state.playlist.is_empty() {
if !state.playlist.is_empty() { state.current_file = Some(state.playlist[state.playlist_index].clone());
state.current_file = Some(state.playlist[state.playlist_index].clone()); if let Some(ref path) = state.current_file {
state.player_state = PlayerState::Playing; player.play(path)?;
if let Some(ref path) = state.current_file { player.resume()?;
player.play(path)?; player.update_metadata();
player.update_metadata(); tracing::info!("Restarting playback: {:?}", path);
tracing::info!("Restarting playback: {:?}", path); }
} }
} }
} }
@ -137,14 +156,167 @@ fn action_toggle_play_pause(state: &mut AppState, player: &mut player::Player) -
fn action_stop(state: &mut AppState, player: &mut player::Player) -> Result<()> { fn action_stop(state: &mut AppState, player: &mut player::Player) -> Result<()> {
player.stop()?; player.stop()?;
state.player_state = PlayerState::Stopped;
state.current_position = 0.0; state.current_position = 0.0;
state.current_duration = 0.0; state.current_duration = 0.0;
tracing::info!("Stopped"); tracing::info!("Stopped");
Ok(()) Ok(())
} }
async fn run_app<B: ratatui::backend::Backend>( 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.current_file = None;
player.stop()?;
} 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)?;
// Explicitly resume playback in case MPV was paused
player.resume()?;
player.update_metadata();
}
}
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 {
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_paused(path)?;
player.update_metadata();
tracing::info!("Jumped to track (paused): {:?}", 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 {
if let Some(ref path) = state.current_file {
player.play(path)?;
// Explicitly resume playback in case MPV was paused
player.resume()?;
player.update_metadata();
tracing::info!("Playing from playlist: {:?}", path);
}
}
Ok(())
}
fn handle_context_menu_action(menu_type: state::ContextMenuType, selected: usize, state: &mut AppState, player: &mut player::Player) -> Result<()> {
match menu_type {
state::ContextMenuType::FilePanel => {
match selected {
0 => action_play_selection(state, player)?,
1 => state.add_to_playlist(),
_ => {}
}
}
state::ContextMenuType::Playlist => {
match selected {
0 => action_remove_from_playlist(state, player)?,
1 => {
state.shuffle_playlist();
tracing::info!("Playlist randomised from context menu");
}
_ => {}
}
}
state::ContextMenuType::TitleBar => {
match selected {
0 => action_stop(state, player)?,
1 => {
state.cycle_play_mode();
tracing::info!("Play mode: {:?}", state.play_mode);
}
2 => {
state.show_refresh_confirm = true;
tracing::info!("Refresh requested from context menu");
}
_ => {}
}
}
}
Ok(())
}
fn run_app<B: ratatui::backend::Backend>(
terminal: &mut Terminal<B>, terminal: &mut Terminal<B>,
state: &mut AppState, state: &mut AppState,
player: &mut player::Player, player: &mut player::Player,
@ -155,25 +327,84 @@ async fn run_app<B: ratatui::backend::Backend>(
let mut title_bar_area = ratatui::layout::Rect::default(); let mut title_bar_area = ratatui::layout::Rect::default();
let mut file_panel_area = ratatui::layout::Rect::default(); let mut file_panel_area = ratatui::layout::Rect::default();
let mut playlist_area = ratatui::layout::Rect::default(); let mut playlist_area = ratatui::layout::Rect::default();
let mut previous_player_state: Option<PlayerState> = None;
loop { loop {
let mut state_changed = false; let mut state_changed = false;
// Check if mpv process died (e.g., user closed video window) // Check if mpv process died (e.g., user closed video window)
if !player.is_process_alive() && state.player_state != PlayerState::Stopped { if !player.is_process_alive() {
state.player_state = PlayerState::Stopped; if let Some(player_state) = player.get_player_state() {
state.current_position = 0.0; if player_state != PlayerState::Stopped {
state.current_duration = 0.0; 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; state_changed = true;
} }
// Only update properties when playing or paused (not when stopped) // Only update metadata and track playback when not stopped
if state.player_state != PlayerState::Stopped { if player_state != PlayerState::Stopped {
player.update_properties(); // Update metadata periodically to reduce IPC calls
// Update metadata only every 20 iterations (~2 seconds) to reduce IPC calls
metadata_update_counter += 1; metadata_update_counter += 1;
if metadata_update_counter >= 20 { if metadata_update_counter >= METADATA_UPDATE_INTERVAL {
player.update_metadata(); player.update_metadata();
metadata_update_counter = 0; metadata_update_counter = 0;
state_changed = true; state_changed = true;
@ -183,8 +414,10 @@ async fn run_app<B: ratatui::backend::Backend>(
let new_position = player.get_position().unwrap_or(0.0); let new_position = player.get_position().unwrap_or(0.0);
let new_duration = player.get_duration().unwrap_or(0.0); let new_duration = player.get_duration().unwrap_or(0.0);
// Only mark as changed if position moved by at least 0.5 seconds // Only update if displayed value (rounded to seconds) changed
if (new_position - last_position).abs() >= 0.5 { let old_display_secs = last_position as u32;
let new_display_secs = new_position as u32;
if new_display_secs != old_display_secs {
state.current_position = new_position; state.current_position = new_position;
last_position = new_position; last_position = new_position;
state_changed = true; state_changed = true;
@ -194,34 +427,11 @@ async fn run_app<B: ratatui::backend::Backend>(
state.current_duration = new_duration; state.current_duration = new_duration;
state_changed = true; 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)?;
}
// 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 // Only redraw if something changed or forced
if needs_redraw || state_changed { if needs_redraw || state_changed {
terminal.draw(|f| { terminal.draw(|f| {
@ -234,17 +444,17 @@ async fn run_app<B: ratatui::backend::Backend>(
} }
// Poll for events - use longer timeout when stopped to reduce CPU // Poll for events - use longer timeout when stopped to reduce CPU
let poll_duration = if state.player_state == PlayerState::Stopped { let poll_duration = if player_state == PlayerState::Stopped {
std::time::Duration::from_millis(200) // 5 FPS when stopped std::time::Duration::from_millis(POLL_DURATION_STOPPED_MS)
} else { } 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)? { if event::poll(poll_duration)? {
match event::read()? { match event::read()? {
Event::Key(key) => { Event::Key(key) => {
if key.kind == KeyEventKind::Press { 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 needs_redraw = true; // Force redraw after key event
} }
} }
@ -264,7 +474,7 @@ async fn run_app<B: ratatui::backend::Backend>(
Ok(()) 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 // Handle confirmation popup
if state.show_refresh_confirm { if state.show_refresh_confirm {
match key.code { match key.code {
@ -359,64 +569,7 @@ async fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<
let menu_type = menu.menu_type; let menu_type = menu.menu_type;
let selected = menu.selected_index; let selected = menu.selected_index;
state.context_menu = None; state.context_menu = None;
handle_context_menu_action(menu_type, selected, state, player)?;
match menu_type {
ContextMenuType::FilePanel => {
match selected {
0 => action_play_selection(state, player)?,
1 => state.add_to_playlist(),
_ => {}
}
}
ContextMenuType::Playlist => {
match selected {
0 => {
// Remove
let was_playing_removed = state.playlist_index == state.selected_playlist_index;
state.remove_selected_playlist_item();
// Handle edge cases after removal
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 {
// Removed currently playing track, start new one at same index
state.current_file = Some(state.playlist[state.playlist_index].clone());
if let Some(ref path) = state.current_file {
player.play(path)?;
player.update_metadata();
}
}
}
1 => {
// Randomise
state.shuffle_playlist();
tracing::info!("Playlist randomised from context menu");
}
_ => {}
}
}
ContextMenuType::TitleBar => {
match selected {
0 => {
// Stop
action_stop(state, player)?;
}
1 => {
// Toggle Loop
state.cycle_play_mode();
tracing::info!("Play mode: {:?}", state.play_mode);
}
2 => {
// Refresh
state.show_refresh_confirm = true;
tracing::info!("Refresh requested from context menu");
}
_ => {}
}
}
}
} }
KeyCode::Esc => { KeyCode::Esc => {
state.context_menu = None; state.context_menu = None;
@ -467,79 +620,11 @@ async fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<
} }
(KeyCode::Char('J'), KeyModifiers::SHIFT) => { (KeyCode::Char('J'), KeyModifiers::SHIFT) => {
// Next track // Next track
if !state.playlist.is_empty() && state.playlist_index + 1 < state.playlist.len() { action_navigate_track(state, player, 1)?;
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.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);
}
}
}
} }
(KeyCode::Char('K'), KeyModifiers::SHIFT) => { (KeyCode::Char('K'), KeyModifiers::SHIFT) => {
// Previous track // Previous track
if !state.playlist.is_empty() && state.playlist_index > 0 { action_navigate_track(state, player, -1)?;
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.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);
}
}
}
} }
(KeyCode::Char('d'), KeyModifiers::CONTROL) => { (KeyCode::Char('d'), KeyModifiers::CONTROL) => {
if state.focus_playlist { if state.focus_playlist {
@ -573,12 +658,12 @@ async fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<
state.move_selection_down(); state.move_selection_down();
} }
} }
(KeyCode::Char('h'), _) => { (KeyCode::Char('h'), _) | (KeyCode::Left, _) => {
if !state.focus_playlist { if !state.focus_playlist {
state.collapse_selected(); state.collapse_selected();
} }
} }
(KeyCode::Char('l'), _) => { (KeyCode::Char('l'), _) | (KeyCode::Right, _) => {
if !state.focus_playlist { if !state.focus_playlist {
state.expand_selected(); state.expand_selected();
} }
@ -602,39 +687,13 @@ async fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<
} }
(KeyCode::Char('d'), _) => { (KeyCode::Char('d'), _) => {
if state.focus_playlist { if state.focus_playlist {
// Remove selected track from playlist action_remove_from_playlist(state, player)?;
state.remove_selected_playlist_item();
// If removed currently playing track, handle it
if state.playlist.is_empty() {
state.player_state = PlayerState::Stopped;
state.current_file = None;
player.stop()?;
} else if state.playlist_index == state.selected_playlist_index {
// Removed currently playing track, play next one
if state.playlist_index < state.playlist.len() {
state.current_file = Some(state.playlist[state.playlist_index].clone());
if state.player_state == PlayerState::Playing {
if let Some(ref path) = state.current_file {
player.play(path)?;
player.update_metadata();
}
}
}
}
} }
} }
(KeyCode::Enter, _) => { (KeyCode::Enter, _) => {
if state.focus_playlist { if state.focus_playlist {
// Play selected track from playlist
if state.selected_playlist_index < state.playlist.len() { if state.selected_playlist_index < state.playlist.len() {
state.playlist_index = state.selected_playlist_index; action_play_from_playlist(state, player, false)?;
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.update_metadata();
tracing::info!("Playing from playlist: {:?}", path);
}
} }
} else { } else {
action_play_selection(state, player)?; action_play_selection(state, player)?;
@ -655,13 +714,13 @@ async fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<
action_toggle_play_pause(state, player)?; action_toggle_play_pause(state, player)?;
} }
(KeyCode::Char('H'), KeyModifiers::SHIFT) => { (KeyCode::Char('H'), KeyModifiers::SHIFT) => {
if state.player_state != PlayerState::Stopped { if player.get_player_state() != Some(PlayerState::Stopped) {
player.seek(-10.0)?; player.seek(-10.0)?;
tracing::info!("Seek backward 10s"); tracing::info!("Seek backward 10s");
} }
} }
(KeyCode::Char('L'), KeyModifiers::SHIFT) => { (KeyCode::Char('L'), KeyModifiers::SHIFT) => {
if state.player_state != PlayerState::Stopped { if player.get_player_state() != Some(PlayerState::Stopped) {
player.seek(10.0)?; player.seek(10.0)?;
tracing::info!("Seek forward 10s"); tracing::info!("Seek forward 10s");
} }
@ -756,63 +815,7 @@ fn handle_mouse_event(state: &mut AppState, mouse: MouseEvent, title_bar_area: r
let menu_type = menu.menu_type; let menu_type = menu.menu_type;
let selected = relative_y; let selected = relative_y;
state.context_menu = None; state.context_menu = None;
handle_context_menu_action(menu_type, selected, state, player)?;
match menu_type {
ContextMenuType::FilePanel => {
match selected {
0 => action_play_selection(state, player)?,
1 => state.add_to_playlist(),
_ => {}
}
}
ContextMenuType::Playlist => {
match selected {
0 => {
// Remove
let was_playing_removed = state.playlist_index == state.selected_playlist_index;
state.remove_selected_playlist_item();
// Handle edge cases after removal
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 {
state.current_file = Some(state.playlist[state.playlist_index].clone());
if let Some(ref path) = state.current_file {
player.play(path)?;
player.update_metadata();
}
}
}
1 => {
// Randomise
state.shuffle_playlist();
tracing::info!("Playlist randomised from context menu (mouse)");
}
_ => {}
}
}
ContextMenuType::TitleBar => {
match selected {
0 => {
// Stop
action_stop(state, player)?;
}
1 => {
// Toggle Loop
state.cycle_play_mode();
tracing::info!("Play mode: {:?} (mouse)", state.play_mode);
}
2 => {
// Refresh
state.show_refresh_confirm = true;
tracing::info!("Refresh requested from context menu (mouse)");
}
_ => {}
}
}
}
} }
return Ok(()); return Ok(());
} else { } else {
@ -925,7 +928,7 @@ fn handle_mouse_event(state: &mut AppState, mouse: MouseEvent, title_bar_area: r
let now = std::time::Instant::now(); let now = std::time::Instant::now();
let is_double_click = if let (Some(last_time), Some(last_idx), false) = 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) { (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 { } else {
false false
}; };
@ -933,7 +936,7 @@ fn handle_mouse_event(state: &mut AppState, mouse: MouseEvent, title_bar_area: r
if is_double_click { if is_double_click {
// Double click = toggle folder or play file // Double click = toggle folder or play file
if let Some(item) = state.get_selected_item() { if let Some(item) = state.get_selected_item() {
if item.node.is_dir { if item.is_dir {
action_toggle_folder(state); action_toggle_folder(state);
} else { } else {
action_play_selection(state, player)?; action_play_selection(state, player)?;
@ -986,44 +989,15 @@ fn handle_mouse_event(state: &mut AppState, mouse: MouseEvent, title_bar_area: r
let now = std::time::Instant::now(); let now = std::time::Instant::now();
let is_double_click = if let (Some(last_time), Some(last_idx), true) = 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) { (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 { } else {
false false
}; };
if is_double_click { if is_double_click {
// Double click = play the track // Double click = play the track (preserve pause state)
state.playlist_index = actual_track; state.selected_playlist_index = actual_track;
state.current_file = Some(state.playlist[state.playlist_index].clone()); action_play_from_playlist(state, player, true)?;
match state.player_state {
PlayerState::Playing => {
// Keep playing
if let Some(ref path) = state.current_file {
player.play(path)?;
player.update_metadata();
tracing::info!("Jumped to track: {:?}", path);
}
}
PlayerState::Paused => {
// Load but stay 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::Stopped => {
// Start playing from clicked track
state.player_state = PlayerState::Playing;
if let Some(ref path) = state.current_file {
player.play(path)?;
player.update_metadata();
tracing::info!("Started playing track: {:?}", path);
}
}
}
// Reset click tracking after action // Reset click tracking after action
state.last_click_time = None; state.last_click_time = None;
state.last_click_index = None; state.last_click_index = None;

View File

@ -13,14 +13,13 @@ pub struct Player {
socket: Option<UnixStream>, socket: Option<UnixStream>,
position: f64, position: f64,
duration: f64, duration: f64,
is_paused: bool,
is_idle: bool,
pub media_title: Option<String>, pub media_title: Option<String>,
pub artist: Option<String>, pub artist: Option<String>,
pub album: Option<String>, pub album: Option<String>,
pub audio_codec: Option<String>, pub audio_codec: Option<String>,
pub audio_bitrate: Option<f64>, pub audio_bitrate: Option<f64>,
pub sample_rate: Option<i64>, pub sample_rate: Option<i64>,
pub cache_duration: Option<f64>,
} }
impl Player { impl Player {
@ -45,88 +44,53 @@ impl Player {
tracing::info!("MPV process started with IPC at {:?}", socket_path); 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 { Ok(Self {
process, process,
socket_path, socket_path,
socket: None, socket: None,
position: 0.0, position: 0.0,
duration: 0.0, duration: 0.0,
is_paused: false,
is_idle: true,
media_title: None, media_title: None,
artist: None, artist: None,
album: None, album: None,
audio_codec: None, audio_codec: None,
audio_bitrate: None, audio_bitrate: None,
sample_rate: None, sample_rate: None,
cache_duration: None,
}) })
} }
fn connect(&mut self) -> Result<()> { fn connect(&mut self) -> Result<()> {
if self.socket.is_none() { if self.socket.is_none() {
// Try to connect, if it fails, respawn mpv // CRITICAL: Only try to connect if socket file exists
match UnixStream::connect(&self.socket_path) { // If socket doesn't exist, MPV hasn't created it yet - fail fast
Ok(stream) => { if !self.socket_path.exists() {
stream.set_nonblocking(true).ok(); return Err(anyhow::anyhow!("Socket file doesn't exist yet"));
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);
}
} }
// 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(()) 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;
// 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<()> { fn send_command(&mut self, command: &str, args: &[Value]) -> Result<()> {
self.connect()?; self.connect()?;
@ -143,7 +107,6 @@ impl Player {
if let Err(e) = socket.write_all(msg.as_bytes()) { if let Err(e) = socket.write_all(msg.as_bytes()) {
if e.kind() == std::io::ErrorKind::BrokenPipe { if e.kind() == std::io::ErrorKind::BrokenPipe {
self.socket = None; self.socket = None;
self.is_idle = true;
// Clean up dead process // Clean up dead process
self.process.kill().ok(); self.process.kill().ok();
return Ok(()); return Ok(());
@ -156,7 +119,11 @@ impl Player {
} }
fn get_property(&mut self, property: &str) -> Option<Value> { 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!({ let cmd = json!({
"command": ["get_property", property], "command": ["get_property", property],
@ -165,19 +132,66 @@ impl Player {
if let Some(ref mut socket) = self.socket { if let Some(ref mut socket) = self.socket {
let msg = format!("{}\n", cmd); 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_nonblocking(false).ok();
socket.set_read_timeout(Some(Duration::from_millis(100))).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(); 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(); 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(); return parsed.get("data").cloned();
} }
@ -187,27 +201,30 @@ impl Player {
pub fn play(&mut self, path: &Path) -> Result<()> { pub fn play(&mut self, path: &Path) -> Result<()> {
let path_str = path.to_string_lossy(); let path_str = path.to_string_lossy();
self.send_command("loadfile", &[json!(path_str), json!("replace")])?; self.send_command("loadfile", &[json!(path_str), json!("replace")])?;
self.is_paused = false;
self.is_idle = false;
tracing::info!("Playing: {}", path_str); tracing::info!("Playing: {}", path_str);
Ok(()) 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<()> { pub fn pause(&mut self) -> Result<()> {
self.send_command("set_property", &[json!("pause"), json!(true)])?; self.send_command("set_property", &[json!("pause"), json!(true)])?;
self.is_paused = true;
Ok(()) Ok(())
} }
pub fn resume(&mut self) -> Result<()> { pub fn resume(&mut self) -> Result<()> {
self.send_command("set_property", &[json!("pause"), json!(false)])?; self.send_command("set_property", &[json!("pause"), json!(false)])?;
self.is_paused = false;
Ok(()) Ok(())
} }
pub fn stop(&mut self) -> Result<()> { pub fn stop(&mut self) -> Result<()> {
self.send_command("stop", &[])?; self.send_command("stop", &[])?;
self.is_idle = true;
self.position = 0.0; self.position = 0.0;
self.duration = 0.0; self.duration = 0.0;
Ok(()) Ok(())
@ -232,20 +249,6 @@ impl Player {
self.duration = dur; 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) { pub fn update_metadata(&mut self) {
@ -303,6 +306,13 @@ impl Player {
if let Some(val) = self.get_property("audio-params/samplerate") { if let Some(val) = self.get_property("audio-params/samplerate") {
self.sample_rate = val.as_i64(); self.sample_rate = val.as_i64();
} }
// Update cache duration (how many seconds are buffered ahead)
if let Some(val) = self.get_property("demuxer-cache-duration") {
self.cache_duration = val.as_f64();
} else {
self.cache_duration = None;
}
} }
pub fn get_position(&self) -> Option<f64> { pub fn get_position(&self) -> Option<f64> {
@ -313,8 +323,28 @@ impl Player {
Some(self.duration) Some(self.duration)
} }
pub fn is_idle(&self) -> bool { pub fn is_idle(&mut self) -> Option<bool> {
self.is_idle 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 { pub fn is_process_alive(&mut self) -> bool {
@ -323,14 +353,12 @@ impl Player {
Ok(Some(_)) => { Ok(Some(_)) => {
// Process has exited - clean up socket // Process has exited - clean up socket
self.socket = None; self.socket = None;
self.is_idle = true;
false false
} }
Ok(None) => true, // Process is still running Ok(None) => true, // Process is still running
Err(_) => { Err(_) => {
// Error checking, assume dead and clean up // Error checking, assume dead and clean up
self.socket = None; self.socket = None;
self.is_idle = true;
false false
} }
} }

View File

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

View File

@ -1,9 +1,29 @@
use crate::cache::{Cache, FileTreeNode}; use crate::cache::{Cache, FileTreeNode};
use crate::config::Config; use crate::config::Config;
use std::collections::HashSet; use std::collections::HashSet;
use std::path::PathBuf; use std::path::{Path, PathBuf};
use std::time::Instant; 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlayerState { pub enum PlayerState {
Stopped, Stopped,
@ -39,7 +59,6 @@ pub struct AppState {
pub scroll_offset: usize, pub scroll_offset: usize,
pub file_panel_visible_height: usize, pub file_panel_visible_height: usize,
pub playlist_visible_height: usize, pub playlist_visible_height: usize,
pub player_state: PlayerState,
pub current_file: Option<PathBuf>, pub current_file: Option<PathBuf>,
pub current_position: f64, pub current_position: f64,
pub current_duration: f64, pub current_duration: f64,
@ -77,7 +96,9 @@ pub struct AppState {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct FlattenedItem { pub struct FlattenedItem {
pub node: FileTreeNode, pub path: PathBuf,
pub name: String,
pub is_dir: bool,
pub depth: usize, pub depth: usize,
} }
@ -95,7 +116,6 @@ impl AppState {
scroll_offset: 0, scroll_offset: 0,
file_panel_visible_height: 20, file_panel_visible_height: 20,
playlist_visible_height: 20, playlist_visible_height: 20,
player_state: PlayerState::Stopped,
current_file: None, current_file: None,
current_position: 0.0, current_position: 0.0,
current_duration: 0.0, current_duration: 0.0,
@ -150,18 +170,11 @@ impl AppState {
if self.selected_index < self.flattened_items.len().saturating_sub(1) { if self.selected_index < self.flattened_items.len().saturating_sub(1) {
self.selected_index += 1; self.selected_index += 1;
// Account for "... X more below" indicator which takes one line let effective_height = calculate_effective_height(
let visible_end = self.scroll_offset + self.file_panel_visible_height; self.scroll_offset,
let items_below = if visible_end < self.flattened_items.len() { self.file_panel_visible_height,
self.flattened_items.len() - visible_end self.flattened_items.len()
} else { );
0
};
let effective_height = if items_below > 0 {
self.file_panel_visible_height.saturating_sub(1)
} else {
self.file_panel_visible_height
};
// Scroll down when selection reaches bottom // Scroll down when selection reaches bottom
if self.selected_index >= self.scroll_offset + effective_height { if self.selected_index >= self.scroll_offset + effective_height {
@ -229,18 +242,11 @@ impl AppState {
if self.selected_playlist_index < self.playlist.len().saturating_sub(1) { if self.selected_playlist_index < self.playlist.len().saturating_sub(1) {
self.selected_playlist_index += 1; self.selected_playlist_index += 1;
// Account for "... X more below" indicator which takes one line let effective_height = calculate_effective_height(
let visible_end = self.playlist_scroll_offset + visible_height; self.playlist_scroll_offset,
let items_below = if visible_end < self.playlist.len() { visible_height,
self.playlist.len() - visible_end self.playlist.len()
} else { );
0
};
let effective_height = if items_below > 0 {
visible_height.saturating_sub(1)
} else {
visible_height
};
// Scroll down when selection reaches bottom // Scroll down when selection reaches bottom
if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height { if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height {
@ -276,18 +282,11 @@ impl AppState {
let new_index = (self.selected_playlist_index + half_page).min(self.playlist.len().saturating_sub(1)); let new_index = (self.selected_playlist_index + half_page).min(self.playlist.len().saturating_sub(1));
self.selected_playlist_index = new_index; self.selected_playlist_index = new_index;
// Account for "... X more below" indicator which takes one line let effective_height = calculate_effective_height(
let visible_end = self.playlist_scroll_offset + self.playlist_visible_height; self.playlist_scroll_offset,
let items_below = if visible_end < self.playlist.len() { self.playlist_visible_height,
self.playlist.len() - visible_end self.playlist.len()
} else { );
0
};
let effective_height = if items_below > 0 {
self.playlist_visible_height.saturating_sub(1)
} else {
self.playlist_visible_height
};
// Adjust scroll if needed // Adjust scroll if needed
if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height { if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height {
@ -312,18 +311,11 @@ impl AppState {
let new_index = (self.selected_index + half_page).min(self.flattened_items.len().saturating_sub(1)); let new_index = (self.selected_index + half_page).min(self.flattened_items.len().saturating_sub(1));
self.selected_index = new_index; self.selected_index = new_index;
// Account for "... X more below" indicator which takes one line let effective_height = calculate_effective_height(
let visible_end = self.scroll_offset + self.file_panel_visible_height; self.scroll_offset,
let items_below = if visible_end < self.flattened_items.len() { self.file_panel_visible_height,
self.flattened_items.len() - visible_end self.flattened_items.len()
} else { );
0
};
let effective_height = if items_below > 0 {
self.file_panel_visible_height.saturating_sub(1)
} else {
self.file_panel_visible_height
};
// Adjust scroll if needed // Adjust scroll if needed
if self.selected_index >= self.scroll_offset + effective_height { if self.selected_index >= self.scroll_offset + effective_height {
@ -346,11 +338,29 @@ impl AppState {
self.flattened_items.get(self.selected_index) self.flattened_items.get(self.selected_index)
} }
// Helper to find a node in the tree by path
fn find_node_by_path<'a>(&'a self, path: &Path) -> Option<&'a FileTreeNode> {
fn search_nodes<'a>(nodes: &'a [FileTreeNode], path: &Path) -> Option<&'a FileTreeNode> {
for node in nodes {
if node.path == path {
return Some(node);
}
if node.is_dir {
if let Some(found) = search_nodes(&node.children, path) {
return Some(found);
}
}
}
None
}
search_nodes(&self.cache.file_tree, path)
}
pub fn collapse_selected(&mut self) { pub fn collapse_selected(&mut self) {
let item = self.get_selected_item().cloned(); let item = self.get_selected_item().cloned();
if let Some(item) = item { if let Some(item) = item {
if item.node.is_dir { if item.is_dir {
let path = item.node.path.clone(); let path = item.path.clone();
let was_expanded = self.expanded_dirs.contains(&path); let was_expanded = self.expanded_dirs.contains(&path);
if was_expanded { if was_expanded {
@ -358,7 +368,7 @@ impl AppState {
self.expanded_dirs.remove(&path); self.expanded_dirs.remove(&path);
self.rebuild_flattened_items(); self.rebuild_flattened_items();
// Find the collapsed folder and select it // Find the collapsed folder and select it
if let Some(idx) = self.flattened_items.iter().position(|i| i.node.path == path) { if let Some(idx) = self.flattened_items.iter().position(|i| i.path == path) {
self.selected_index = idx; self.selected_index = idx;
} }
} else { } else {
@ -368,19 +378,19 @@ impl AppState {
self.expanded_dirs.remove(&parent_buf); self.expanded_dirs.remove(&parent_buf);
self.rebuild_flattened_items(); self.rebuild_flattened_items();
// Jump to parent folder // Jump to parent folder
if let Some(parent_idx) = self.flattened_items.iter().position(|i| i.node.path == parent_buf) { if let Some(parent_idx) = self.flattened_items.iter().position(|i| i.path == parent_buf) {
self.selected_index = parent_idx; self.selected_index = parent_idx;
} }
} }
} }
} else { } else {
// Close parent folder when on a file and jump to it // Close parent folder when on a file and jump to it
if let Some(parent) = item.node.path.parent() { if let Some(parent) = item.path.parent() {
let parent_buf = parent.to_path_buf(); let parent_buf = parent.to_path_buf();
self.expanded_dirs.remove(&parent_buf); self.expanded_dirs.remove(&parent_buf);
self.rebuild_flattened_items(); self.rebuild_flattened_items();
// Jump to parent folder // Jump to parent folder
if let Some(parent_idx) = self.flattened_items.iter().position(|i| i.node.path == parent_buf) { if let Some(parent_idx) = self.flattened_items.iter().position(|i| i.path == parent_buf) {
self.selected_index = parent_idx; self.selected_index = parent_idx;
} }
} }
@ -390,8 +400,8 @@ impl AppState {
pub fn expand_selected(&mut self) { pub fn expand_selected(&mut self) {
if let Some(item) = self.get_selected_item() { if let Some(item) = self.get_selected_item() {
if item.node.is_dir { if item.is_dir {
let path = item.node.path.clone(); let path = item.path.clone();
self.expanded_dirs.insert(path); self.expanded_dirs.insert(path);
self.rebuild_flattened_items(); self.rebuild_flattened_items();
} }
@ -411,8 +421,8 @@ impl AppState {
self.marked_files.clear(); self.marked_files.clear();
// Mark current file // Mark current file
if let Some(item) = self.get_selected_item() { if let Some(item) = self.get_selected_item() {
if !item.node.is_dir { if !item.is_dir {
self.marked_files.insert(item.node.path.clone()); self.marked_files.insert(item.path.clone());
} }
} }
} }
@ -428,8 +438,8 @@ impl AppState {
for i in start..=end { for i in start..=end {
if let Some(item) = self.flattened_items.get(i) { if let Some(item) = self.flattened_items.get(i) {
if !item.node.is_dir { if !item.is_dir {
self.marked_files.insert(item.node.path.clone()); self.marked_files.insert(item.path.clone());
} }
} }
} }
@ -438,7 +448,6 @@ impl AppState {
pub fn clear_playlist(&mut self) { pub fn clear_playlist(&mut self) {
self.playlist.clear(); self.playlist.clear();
self.playlist_index = 0; self.playlist_index = 0;
self.player_state = PlayerState::Stopped;
self.current_file = None; self.current_file = None;
} }
@ -450,15 +459,19 @@ impl AppState {
files.sort(); files.sort();
self.playlist.extend(files); self.playlist.extend(files);
} else if let Some(item) = self.get_selected_item() { } else if let Some(item) = self.get_selected_item() {
let node = item.node.clone(); let path = item.path.clone();
if node.is_dir { let is_dir = item.is_dir;
// Add all files in directory (allow duplicates) if is_dir {
let mut files = collect_files_from_node(&node); // Look up the full node to get children
files.sort(); if let Some(node) = self.find_node_by_path(&path) {
self.playlist.extend(files); // Add all files in directory (allow duplicates)
let mut files = collect_files_from_node(node);
files.sort();
self.playlist.extend(files);
}
} else { } else {
// Add single file (allow duplicates) // Add single file (allow duplicates)
self.playlist.push(node.path.clone()); self.playlist.push(path);
} }
} }
} }
@ -474,44 +487,41 @@ impl AppState {
self.selected_playlist_index = 0; self.selected_playlist_index = 0;
if let Some(first) = self.playlist.first() { if let Some(first) = self.playlist.first() {
self.current_file = Some(first.clone()); self.current_file = Some(first.clone());
self.player_state = PlayerState::Playing;
} else { } else {
// Empty playlist // Empty playlist
self.current_file = None; self.current_file = None;
self.player_state = PlayerState::Stopped;
} }
} else if let Some(item) = self.get_selected_item() { } else if let Some(item) = self.get_selected_item() {
let node = item.node.clone(); let path = item.path.clone();
if node.is_dir { let is_dir = item.is_dir;
// Play all files in directory if is_dir {
self.playlist = collect_files_from_node(&node); // Play all files in directory - look up node to get children
self.playlist_index = 0; if let Some(node) = self.find_node_by_path(&path) {
self.playlist_scroll_offset = 0; self.playlist = collect_files_from_node(node);
self.selected_playlist_index = 0; self.playlist_index = 0;
if let Some(first) = self.playlist.first() { self.playlist_scroll_offset = 0;
self.current_file = Some(first.clone()); self.selected_playlist_index = 0;
self.player_state = PlayerState::Playing; if let Some(first) = self.playlist.first() {
} else { self.current_file = Some(first.clone());
// Empty directory } else {
self.current_file = None; // Empty directory
self.player_state = PlayerState::Stopped; self.current_file = None;
}
} }
} else { } else {
// Play single file // Play single file
let path = node.path.clone();
self.playlist = vec![path.clone()]; self.playlist = vec![path.clone()];
self.playlist_index = 0; self.playlist_index = 0;
self.playlist_scroll_offset = 0; self.playlist_scroll_offset = 0;
self.selected_playlist_index = 0; self.selected_playlist_index = 0;
self.current_file = Some(path); 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() { if self.playlist.is_empty() {
return; return false;
} }
match self.play_mode { match self.play_mode {
@ -521,18 +531,17 @@ impl AppState {
self.playlist_index += 1; self.playlist_index += 1;
if self.playlist_index < self.playlist.len() { if self.playlist_index < self.playlist.len() {
self.current_file = Some(self.playlist[self.playlist_index].clone()); 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 => { PlayMode::Loop => {
// Loop back to beginning when reaching end // Loop back to beginning when reaching end
self.playlist_index = (self.playlist_index + 1) % self.playlist.len(); self.playlist_index = (self.playlist_index + 1) % self.playlist.len();
self.current_file = Some(self.playlist[self.playlist_index].clone()); self.current_file = Some(self.playlist[self.playlist_index].clone());
self.player_state = PlayerState::Playing; true // Should continue playing
} }
} }
} }
@ -641,6 +650,7 @@ impl AppState {
if self.search_query.is_empty() { if self.search_query.is_empty() {
self.tab_search_results.clear(); self.tab_search_results.clear();
self.tab_search_index = 0; self.tab_search_index = 0;
// Don't rebuild tree on every keystroke - only when exiting search
return; return;
} }
@ -668,35 +678,37 @@ impl AppState {
self.tab_search_results = indexed_matches.iter().map(|(path, _, _)| path.clone()).collect(); self.tab_search_results = indexed_matches.iter().map(|(path, _, _)| path.clone()).collect();
self.tab_search_index = 0; self.tab_search_index = 0;
// Close all folders and expand only for the best match // Only expand and rebuild if this is a new best match
self.expanded_dirs.clear();
let best_match = self.tab_search_results[0].clone(); let best_match = self.tab_search_results[0].clone();
let mut parent = best_match.parent();
while let Some(p) = parent { // Check if we need to expand folders for this match
self.expanded_dirs.insert(p.to_path_buf()); let needs_expand = best_match.ancestors()
parent = p.parent(); .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();
} }
// Rebuild flattened items
self.rebuild_flattened_items();
// Find the best match in the flattened list and jump to it // 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) { if let Some(idx) = self.flattened_items.iter().position(|item| item.path == best_match) {
self.selected_index = idx; self.selected_index = idx;
// Scroll to show the match // Scroll to show the match
// Account for "... X more below" indicator which takes one line let effective_height = calculate_effective_height(
let visible_end = self.scroll_offset + self.file_panel_visible_height; self.scroll_offset,
let items_below = if visible_end < self.flattened_items.len() { self.file_panel_visible_height,
self.flattened_items.len() - visible_end self.flattened_items.len()
} else { );
0
};
let effective_height = if items_below > 0 {
self.file_panel_visible_height.saturating_sub(1)
} else {
self.file_panel_visible_height
};
if self.selected_index < self.scroll_offset { if self.selected_index < self.scroll_offset {
self.scroll_offset = self.selected_index; self.scroll_offset = self.selected_index;
@ -755,7 +767,7 @@ impl AppState {
self.rebuild_flattened_items(); self.rebuild_flattened_items();
// Find first match in flattened list // Find first match in flattened list
if let Some(idx) = self.flattened_items.iter().position(|item| item.node.path == first_match) { if let Some(idx) = self.flattened_items.iter().position(|item| item.path == first_match) {
self.selected_index = idx; self.selected_index = idx;
} }
} }
@ -780,7 +792,7 @@ impl AppState {
self.rebuild_flattened_items(); self.rebuild_flattened_items();
// Find the path in current flattened items // Find the path in current flattened items
if let Some(idx) = self.flattened_items.iter().position(|item| item.node.path == target_path) { if let Some(idx) = self.flattened_items.iter().position(|item| item.path == target_path) {
self.selected_index = idx; self.selected_index = idx;
// Scroll to show the match // Scroll to show the match
@ -827,7 +839,7 @@ impl AppState {
self.rebuild_flattened_items(); self.rebuild_flattened_items();
// Find the path in current flattened items // Find the path in current flattened items
if let Some(idx) = self.flattened_items.iter().position(|item| item.node.path == target_path) { if let Some(idx) = self.flattened_items.iter().position(|item| item.path == target_path) {
self.selected_index = idx; self.selected_index = idx;
// Scroll to show the match // Scroll to show the match
@ -874,22 +886,15 @@ impl AppState {
self.rebuild_flattened_items(); self.rebuild_flattened_items();
// Find and select the match // Find and select the match
if let Some(idx) = self.flattened_items.iter().position(|item| item.node.path == next_match) { if let Some(idx) = self.flattened_items.iter().position(|item| item.path == next_match) {
self.selected_index = idx; self.selected_index = idx;
// Scroll to show the match // Scroll to show the match
// Account for "... X more below" indicator which takes one line let effective_height = calculate_effective_height(
let visible_end = self.scroll_offset + self.file_panel_visible_height; self.scroll_offset,
let items_below = if visible_end < self.flattened_items.len() { self.file_panel_visible_height,
self.flattened_items.len() - visible_end self.flattened_items.len()
} else { );
0
};
let effective_height = if items_below > 0 {
self.file_panel_visible_height.saturating_sub(1)
} else {
self.file_panel_visible_height
};
if self.selected_index < self.scroll_offset { if self.selected_index < self.scroll_offset {
self.scroll_offset = self.selected_index; self.scroll_offset = self.selected_index;
@ -924,22 +929,15 @@ impl AppState {
self.rebuild_flattened_items(); self.rebuild_flattened_items();
// Find and select the match // Find and select the match
if let Some(idx) = self.flattened_items.iter().position(|item| item.node.path == prev_match) { if let Some(idx) = self.flattened_items.iter().position(|item| item.path == prev_match) {
self.selected_index = idx; self.selected_index = idx;
// Scroll to show the match // Scroll to show the match
// Account for "... X more below" indicator which takes one line let effective_height = calculate_effective_height(
let visible_end = self.scroll_offset + self.file_panel_visible_height; self.scroll_offset,
let items_below = if visible_end < self.flattened_items.len() { self.file_panel_visible_height,
self.flattened_items.len() - visible_end self.flattened_items.len()
} else { );
0
};
let effective_height = if items_below > 0 {
self.file_panel_visible_height.saturating_sub(1)
} else {
self.file_panel_visible_height
};
if self.selected_index < self.scroll_offset { if self.selected_index < self.scroll_offset {
self.scroll_offset = self.selected_index; self.scroll_offset = self.selected_index;
@ -996,18 +994,11 @@ impl AppState {
self.selected_playlist_index = best_match_idx; self.selected_playlist_index = best_match_idx;
// Scroll to show the match // Scroll to show the match
// Account for "... X more below" indicator which takes one line let effective_height = calculate_effective_height(
let visible_end = self.playlist_scroll_offset + self.playlist_visible_height; self.playlist_scroll_offset,
let items_below = if visible_end < self.playlist.len() { self.playlist_visible_height,
self.playlist.len() - visible_end self.playlist.len()
} else { );
0
};
let effective_height = if items_below > 0 {
self.playlist_visible_height.saturating_sub(1)
} else {
self.playlist_visible_height
};
if self.selected_playlist_index < self.playlist_scroll_offset { if self.selected_playlist_index < self.playlist_scroll_offset {
self.playlist_scroll_offset = self.selected_playlist_index; self.playlist_scroll_offset = self.selected_playlist_index;
@ -1027,18 +1018,11 @@ impl AppState {
self.selected_playlist_index = next_match_idx; self.selected_playlist_index = next_match_idx;
// Scroll to show the match // Scroll to show the match
// Account for "... X more below" indicator which takes one line let effective_height = calculate_effective_height(
let visible_end = self.playlist_scroll_offset + self.playlist_visible_height; self.playlist_scroll_offset,
let items_below = if visible_end < self.playlist.len() { self.playlist_visible_height,
self.playlist.len() - visible_end self.playlist.len()
} else { );
0
};
let effective_height = if items_below > 0 {
self.playlist_visible_height.saturating_sub(1)
} else {
self.playlist_visible_height
};
if self.selected_playlist_index < self.playlist_scroll_offset { if self.selected_playlist_index < self.playlist_scroll_offset {
self.playlist_scroll_offset = self.selected_playlist_index; self.playlist_scroll_offset = self.selected_playlist_index;
@ -1062,18 +1046,11 @@ impl AppState {
self.selected_playlist_index = prev_match_idx; self.selected_playlist_index = prev_match_idx;
// Scroll to show the match // Scroll to show the match
// Account for "... X more below" indicator which takes one line let effective_height = calculate_effective_height(
let visible_end = self.playlist_scroll_offset + self.playlist_visible_height; self.playlist_scroll_offset,
let items_below = if visible_end < self.playlist.len() { self.playlist_visible_height,
self.playlist.len() - visible_end self.playlist.len()
} else { );
0
};
let effective_height = if items_below > 0 {
self.playlist_visible_height.saturating_sub(1)
} else {
self.playlist_visible_height
};
if self.selected_playlist_index < self.playlist_scroll_offset { if self.selected_playlist_index < self.playlist_scroll_offset {
self.playlist_scroll_offset = self.selected_playlist_index; self.playlist_scroll_offset = self.selected_playlist_index;
@ -1209,7 +1186,9 @@ fn flatten_tree(nodes: &[FileTreeNode], depth: usize, expanded_dirs: &HashSet<Pa
let is_expanded = expanded_dirs.contains(&node.path); let is_expanded = expanded_dirs.contains(&node.path);
result.push(FlattenedItem { result.push(FlattenedItem {
node: node.clone(), path: node.path.clone(),
name: node.name.clone(),
is_dir: node.is_dir,
depth, depth,
}); });
@ -1236,38 +1215,44 @@ fn collect_files_from_node(node: &FileTreeNode) -> Vec<PathBuf> {
} }
fn fuzzy_match(text: &str, query: &str) -> Option<i32> { fn fuzzy_match(text: &str, query: &str) -> Option<i32> {
let text_lower = text.to_lowercase(); // Avoid allocations by comparing chars directly with case-insensitive logic
let query_lower = query.to_lowercase(); let mut text_chars = text.chars();
let mut text_chars = text_lower.chars();
let mut score = 0; let mut score = 0;
let mut prev_match_idx = 0; let mut prev_match_idx = 0;
let mut consecutive_bonus = 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 found = false;
let mut current_idx = prev_match_idx; let mut current_idx = prev_match_idx;
for text_char in text_chars.by_ref() { for text_char in text_chars.by_ref() {
current_idx += 1; 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; found = true;
// Bonus for consecutive matches // Bonus for consecutive matches
if current_idx == prev_match_idx + 1 { if current_idx == prev_match_idx + 1 {
consecutive_bonus += 10; consecutive_bonus += FUZZY_CONSECUTIVE_BONUS;
} else { } else {
consecutive_bonus = 0; consecutive_bonus = 0;
} }
// Bonus for matching at word start // Bonus for matching at word start
if current_idx == 1 || text_lower.chars().nth(current_idx - 2).map_or(false, |c| !c.is_alphanumeric()) { if current_idx == 1 || !prev_char.is_alphanumeric() {
score += 15; score += FUZZY_WORD_START_BONUS;
} }
score += consecutive_bonus; score += consecutive_bonus;
// Penalty for gap // Penalty for gap
score -= (current_idx - prev_match_idx - 1) as i32; score -= (current_idx - prev_match_idx - 1) as i32;
prev_match_idx = current_idx; prev_match_idx = current_idx;
prev_char = text_char;
break; break;
} }
prev_char = text_char;
} }
if !found { if !found {
@ -1283,7 +1268,7 @@ fn collect_matching_paths(nodes: &[FileTreeNode], query: &str, matches: &mut Vec
if let Some(mut score) = fuzzy_match(&node.name, query) { if let Some(mut score) = fuzzy_match(&node.name, query) {
// Give folders a significant boost so they appear before files // Give folders a significant boost so they appear before files
if node.is_dir { if node.is_dir {
score += 50; score += FUZZY_FOLDER_BONUS;
} }
matches.push((node.path.clone(), score)); matches.push((node.path.clone(), score));
} }

View File

@ -11,7 +11,7 @@ use ratatui::{
}; };
use theme::Theme; 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 // Clear background
frame.render_widget( frame.render_widget(
Block::default().style(Theme::secondary()), Block::default().style(Theme::secondary()),
@ -34,7 +34,7 @@ pub fn render(frame: &mut Frame, state: &mut AppState, player: &Player) -> (Rect
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(main_chunks[1]); .split(main_chunks[1]);
render_title_bar(frame, state, main_chunks[0]); render_title_bar(frame, state, player, main_chunks[0]);
render_file_panel(frame, state, content_chunks[0]); render_file_panel(frame, state, content_chunks[0]);
render_right_panel(frame, state, content_chunks[1]); render_right_panel(frame, state, content_chunks[1]);
render_status_bar(frame, state, player, main_chunks[2]); render_status_bar(frame, state, player, main_chunks[2]);
@ -62,7 +62,8 @@ fn highlight_search_matches<'a>(text: &str, query: &str, is_selected: bool) -> V
let mut current_segment = String::new(); let mut current_segment = String::new();
for ch in text.chars() { 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 let Some(query_ch) = current_query_char {
if ch_lower == query_ch { if ch_lower == query_ch {
@ -141,15 +142,15 @@ fn render_file_panel(frame: &mut Frame, state: &mut AppState, area: Rect) {
.map(|(display_idx, item)| { .map(|(display_idx, item)| {
let idx = state.scroll_offset + display_idx; let idx = state.scroll_offset + display_idx;
let indent = " ".repeat(item.depth); let indent = " ".repeat(item.depth);
let mark = if state.marked_files.contains(&item.node.path) { "* " } else { "" }; let mark = if state.marked_files.contains(&item.path) { "* " } else { "" };
// Build name with search highlighting // Build name with search highlighting
// Only show selection bar when file panel has focus // Only show selection bar when file panel has focus
let is_selected = !state.focus_playlist && idx == state.selected_index; let is_selected = !state.focus_playlist && idx == state.selected_index;
// Add icon for directories and files // Add icon for directories and files
let icon = if item.node.is_dir { let icon = if item.is_dir {
let is_expanded = state.expanded_dirs.contains(&item.node.path); let is_expanded = state.expanded_dirs.contains(&item.path);
// Nerd font folder icons: \u{eaf7} = open, \u{ea83} = closed // Nerd font folder icons: \u{eaf7} = open, \u{ea83} = closed
let icon_char = if is_expanded { "\u{eaf7} " } else { "\u{ea83} " }; let icon_char = if is_expanded { "\u{eaf7} " } else { "\u{ea83} " };
@ -161,7 +162,7 @@ fn render_file_panel(frame: &mut Frame, state: &mut AppState, area: Rect) {
} }
} else { } else {
// File icons based on extension // File icons based on extension
let extension = item.node.path.extension() let extension = item.path.extension()
.and_then(|e| e.to_str()) .and_then(|e| e.to_str())
.unwrap_or("") .unwrap_or("")
.to_lowercase(); .to_lowercase();
@ -183,12 +184,12 @@ fn render_file_panel(frame: &mut Frame, state: &mut AppState, area: Rect) {
} }
}; };
let name_spans = if in_search && !search_query.is_empty() { let name_spans = if in_search && !search_query.is_empty() {
highlight_search_matches(&item.node.name, &search_query, is_selected) highlight_search_matches(&item.name, &search_query, is_selected)
} else { } else {
vec![Span::raw(&item.node.name)] vec![Span::raw(&item.name)]
}; };
let suffix = if item.node.is_dir { "/" } else { "" }; let suffix = if item.is_dir { "/" } else { "" };
let base_style = if is_selected { let base_style = if is_selected {
// Selection bar: yellow/orange when in search (typing or viewing results), blue otherwise // Selection bar: yellow/orange when in search (typing or viewing results), blue otherwise
@ -197,7 +198,7 @@ fn render_file_panel(frame: &mut Frame, state: &mut AppState, area: Rect) {
} else { } else {
Theme::selected() Theme::selected()
} }
} else if state.marked_files.contains(&item.node.path) { } else if state.marked_files.contains(&item.path) {
Theme::marked() Theme::marked()
} else { } else {
Theme::secondary() Theme::secondary()
@ -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); frame.render_stateful_widget(playlist_widget, area, &mut playlist_state);
} }
fn render_title_bar(frame: &mut Frame, state: &AppState, area: Rect) { fn render_title_bar(frame: &mut Frame, state: &AppState, player: &mut Player, area: Rect) {
let background_color = match state.player_state { // 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::Playing => Theme::success(), // Green for playing
PlayerState::Paused => Theme::highlight(), // Blue for paused PlayerState::Paused => Theme::highlight(), // Blue for paused
PlayerState::Stopped => Theme::dim_foreground(), // Gray for stopped PlayerState::Stopped => Theme::dim_foreground(), // Gray for stopped
@ -389,13 +392,13 @@ fn render_title_bar(frame: &mut Frame, state: &AppState, area: Rect) {
)); ));
} else { } else {
// Status (bold when playing) // Status (bold when playing)
let status_text = match state.player_state { let status_text = match player_state {
PlayerState::Stopped => "Stopped", PlayerState::Stopped => "Stopped",
PlayerState::Playing => "Playing", PlayerState::Playing => "Playing",
PlayerState::Paused => "Paused", PlayerState::Paused => "Paused",
}; };
let status_style = if state.player_state == PlayerState::Playing { let status_style = if player_state == PlayerState::Playing {
Style::default() Style::default()
.fg(Theme::background()) .fg(Theme::background())
.bg(background_color) .bg(background_color)
@ -458,7 +461,7 @@ fn render_title_bar(frame: &mut Frame, state: &AppState, area: Rect) {
frame.render_widget(right_title, chunks[1]); 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 { if state.search_mode {
// Show search prompt with current query and match count - LEFT aligned // Show search prompt with current query and match count - LEFT aligned
let search_text = if state.focus_playlist { let search_text = if state.focus_playlist {
@ -521,7 +524,7 @@ fn render_status_bar(frame: &mut Frame, state: &AppState, player: &Player, area:
left_parts.push(title.clone()); left_parts.push(title.clone());
} }
// Right side: Bitrate | Codec | Sample rate // Right side: Bitrate | Codec | Sample rate | Cache
if let Some(bitrate) = player.audio_bitrate { if let Some(bitrate) = player.audio_bitrate {
right_parts.push(format!("{:.0} kbps", bitrate)); right_parts.push(format!("{:.0} kbps", bitrate));
} }
@ -534,6 +537,12 @@ fn render_status_bar(frame: &mut Frame, state: &AppState, player: &Player, area:
right_parts.push(format!("{} Hz", samplerate)); right_parts.push(format!("{} Hz", samplerate));
} }
if let Some(cache_dur) = player.cache_duration {
if cache_dur > 0.0 {
right_parts.push(format!("{:.1}s", cache_dur));
}
}
// Create layout for left and right sections // Create layout for left and right sections
let chunks = Layout::default() let chunks = Layout::default()
.direction(Direction::Horizontal) .direction(Direction::Horizontal)