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
### State Management
**CRITICAL:** Player state must be derived from MPV, not maintained separately.
**Single Source of Truth:** MPV properties via IPC
- `idle-active` (bool) - No file loaded or file ended
- `pause` (bool) - Playback is paused
**Derive PlayerState:**
```rust
if player.is_idle → PlayerState::Stopped
if !player.is_idle && player.is_paused → PlayerState::Paused
if !player.is_idle && !player.is_paused → PlayerState::Playing
```
**Rationale:**
- Eliminates state synchronization bugs
- MPV is always the authoritative source
- No need to update state in multiple places
- Simpler auto-play logic
**Anti-pattern:** DO NOT maintain `state.player_state` that can desync from MPV
### Cache-Only Operation
**CRITICAL:** Left panel shows ONLY cached data. Never browse filesystem directly during operation.

View File

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

3
src/cache/mod.rs vendored
View File

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

View File

@ -16,8 +16,13 @@ use state::{AppState, PlayerState};
use std::io;
use tracing_subscriber;
#[tokio::main]
async fn main() -> Result<()> {
// UI update intervals and thresholds
const METADATA_UPDATE_INTERVAL: u32 = 20; // Update metadata every N iterations (~2 seconds)
const POLL_DURATION_STOPPED_MS: u64 = 200; // 5 FPS when stopped
const POLL_DURATION_ACTIVE_MS: u64 = 100; // 10 FPS when playing/paused
const DOUBLE_CLICK_MS: u128 = 500; // Double-click detection threshold
fn main() -> Result<()> {
// Initialize logging to file to avoid interfering with TUI
let log_file = std::fs::OpenOptions::new()
.create(true)
@ -49,28 +54,41 @@ async fn main() -> Result<()> {
// Initialize player
let mut player = player::Player::new()?;
tracing::info!("Player initialized");
// Initialize app state
let mut state = AppState::new(cache, config);
tracing::info!("State initialized");
// Setup terminal
enable_raw_mode()?;
tracing::info!("Raw mode enabled");
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
tracing::info!("Terminal setup complete");
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
tracing::info!("Terminal created, entering main loop");
// Run app
let result = run_app(&mut terminal, &mut state, &mut player).await;
// Run app (ensure terminal cleanup even on error)
let result = run_app(&mut terminal, &mut state, &mut player);
// Restore terminal
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
// Restore terminal (always run cleanup, even if result is Err)
let cleanup_result = (|| -> Result<()> {
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
Ok(())
})();
// Log cleanup errors but prioritize original error
if let Err(e) = cleanup_result {
tracing::error!("Terminal cleanup failed: {}", e);
}
result
}
@ -79,8 +97,8 @@ async fn main() -> Result<()> {
fn action_toggle_folder(state: &mut AppState) {
if let Some(item) = state.get_selected_item() {
if item.node.is_dir {
let path = item.node.path.clone();
if item.is_dir {
let path = item.path.clone();
if state.expanded_dirs.contains(&path) {
// Folder is open, close it
state.collapse_selected();
@ -96,7 +114,8 @@ fn action_play_selection(state: &mut AppState, player: &mut player::Player) -> R
state.play_selection();
if let Some(ref path) = state.current_file {
player.play(path)?;
state.player_state = PlayerState::Playing;
// Explicitly resume playback in case MPV was paused
player.resume()?;
player.update_metadata();
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<()> {
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.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);
}
}
}
}
@ -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<()> {
player.stop()?;
state.player_state = PlayerState::Stopped;
state.current_position = 0.0;
state.current_duration = 0.0;
tracing::info!("Stopped");
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>,
state: &mut AppState,
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 file_panel_area = ratatui::layout::Rect::default();
let mut playlist_area = ratatui::layout::Rect::default();
let mut previous_player_state: Option<PlayerState> = None;
loop {
let mut state_changed = false;
// Check if mpv process died (e.g., user closed video window)
if !player.is_process_alive() && state.player_state != PlayerState::Stopped {
state.player_state = PlayerState::Stopped;
state.current_position = 0.0;
state.current_duration = 0.0;
if !player.is_process_alive() {
if let Some(player_state) = player.get_player_state() {
if player_state != PlayerState::Stopped {
state.current_position = 0.0;
state.current_duration = 0.0;
state_changed = true;
}
}
}
// Always update properties to keep state synchronized with MPV
player.update_properties();
// Only proceed if we can successfully query player state
let Some(player_state) = player.get_player_state() else {
// Can't get state from MPV, skip this iteration
if event::poll(std::time::Duration::from_millis(100))? {
match event::read()? {
Event::Key(key) => {
if key.kind == KeyEventKind::Press {
handle_key_event(terminal, state, player, key)?;
needs_redraw = true;
}
}
Event::Mouse(mouse) => {
handle_mouse_event(state, mouse, title_bar_area, file_panel_area, playlist_area, player)?;
needs_redraw = true;
}
_ => {}
}
}
continue;
};
// Check if track ended and play next
// When MPV finishes playing a file, it goes to idle (Stopped state)
// Detect Playing → Stopped transition = track ended, play next
if previous_player_state == Some(PlayerState::Playing)
&& player_state == PlayerState::Stopped
{
let should_continue = state.play_next();
// play_next() returns true if should continue playing, false if should stop
if should_continue {
if let Some(ref path) = state.current_file {
// Reset position/duration before playing new track
state.current_position = 0.0;
state.current_duration = 0.0;
last_position = 0.0;
player.play(path)?;
player.resume()?;
}
// Update metadata immediately when track changes
player.update_metadata();
metadata_update_counter = 0;
// Auto-scroll playlist to show current track (only if user is not browsing playlist)
if !state.focus_playlist {
let playlist_visible_height = playlist_area.height.saturating_sub(2) as usize;
state.update_playlist_scroll(playlist_visible_height);
}
} else {
// Reached end of playlist in Normal mode - stop playback
player.stop()?;
}
state_changed = true;
}
// Only update properties when playing or paused (not when stopped)
if state.player_state != PlayerState::Stopped {
player.update_properties();
// Update metadata only every 20 iterations (~2 seconds) to reduce IPC calls
// Only update metadata and track playback when not stopped
if player_state != PlayerState::Stopped {
// Update metadata periodically to reduce IPC calls
metadata_update_counter += 1;
if metadata_update_counter >= 20 {
if metadata_update_counter >= METADATA_UPDATE_INTERVAL {
player.update_metadata();
metadata_update_counter = 0;
state_changed = true;
@ -183,8 +414,10 @@ async fn run_app<B: ratatui::backend::Backend>(
let new_position = player.get_position().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
if (new_position - last_position).abs() >= 0.5 {
// Only update if displayed value (rounded to seconds) changed
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;
last_position = new_position;
state_changed = true;
@ -194,34 +427,11 @@ async fn run_app<B: ratatui::backend::Backend>(
state.current_duration = new_duration;
state_changed = true;
}
// Check if track ended and play next (but only if track was actually loaded AND played)
// Require position > 0.5 to ensure track actually started playing (not just loaded)
if player.is_idle() && state.player_state == PlayerState::Playing && state.current_duration > 0.0 && state.current_position > 0.5 {
state.play_next();
// play_next() handles the play mode and may stop if in Normal mode at end
if state.player_state == PlayerState::Playing {
if let Some(ref path) = state.current_file {
// Reset position/duration before playing new track
state.current_position = 0.0;
state.current_duration = 0.0;
last_position = 0.0;
player.play(path)?;
}
// 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| {
@ -234,17 +444,17 @@ async fn run_app<B: ratatui::backend::Backend>(
}
// Poll for events - use longer timeout when stopped to reduce CPU
let poll_duration = if state.player_state == PlayerState::Stopped {
std::time::Duration::from_millis(200) // 5 FPS when stopped
let poll_duration = if player_state == PlayerState::Stopped {
std::time::Duration::from_millis(POLL_DURATION_STOPPED_MS)
} else {
std::time::Duration::from_millis(100) // 10 FPS when playing/paused
std::time::Duration::from_millis(POLL_DURATION_ACTIVE_MS)
};
if event::poll(poll_duration)? {
match event::read()? {
Event::Key(key) => {
if key.kind == KeyEventKind::Press {
handle_key_event(terminal, state, player, key).await?;
handle_key_event(terminal, state, player, key)?;
needs_redraw = true; // Force redraw after key event
}
}
@ -264,7 +474,7 @@ async fn run_app<B: ratatui::backend::Backend>(
Ok(())
}
async fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, state: &mut AppState, player: &mut player::Player, key: KeyEvent) -> Result<()> {
fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, state: &mut AppState, player: &mut player::Player, key: KeyEvent) -> Result<()> {
// Handle confirmation popup
if state.show_refresh_confirm {
match key.code {
@ -359,64 +569,7 @@ async fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<
let menu_type = menu.menu_type;
let selected = menu.selected_index;
state.context_menu = None;
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");
}
_ => {}
}
}
}
handle_context_menu_action(menu_type, selected, state, player)?;
}
KeyCode::Esc => {
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) => {
// 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.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.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 {
@ -573,12 +658,12 @@ async fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<
state.move_selection_down();
}
}
(KeyCode::Char('h'), _) => {
(KeyCode::Char('h'), _) | (KeyCode::Left, _) => {
if !state.focus_playlist {
state.collapse_selected();
}
}
(KeyCode::Char('l'), _) => {
(KeyCode::Char('l'), _) | (KeyCode::Right, _) => {
if !state.focus_playlist {
state.expand_selected();
}
@ -602,39 +687,13 @@ async fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<
}
(KeyCode::Char('d'), _) => {
if state.focus_playlist {
// Remove selected track from playlist
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();
}
}
}
}
action_remove_from_playlist(state, player)?;
}
}
(KeyCode::Enter, _) => {
if state.focus_playlist {
// Play selected track from playlist
if state.selected_playlist_index < state.playlist.len() {
state.playlist_index = state.selected_playlist_index;
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);
}
action_play_from_playlist(state, player, false)?;
}
} else {
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)?;
}
(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");
}
@ -756,63 +815,7 @@ fn handle_mouse_event(state: &mut AppState, mouse: MouseEvent, title_bar_area: r
let menu_type = menu.menu_type;
let selected = relative_y;
state.context_menu = None;
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)");
}
_ => {}
}
}
}
handle_context_menu_action(menu_type, selected, state, player)?;
}
return Ok(());
} 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 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
};
@ -933,7 +936,7 @@ fn handle_mouse_event(state: &mut AppState, mouse: MouseEvent, title_bar_area: r
if is_double_click {
// Double click = toggle folder or play file
if let Some(item) = state.get_selected_item() {
if item.node.is_dir {
if item.is_dir {
action_toggle_folder(state);
} else {
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 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
};
if is_double_click {
// Double click = play the track
state.playlist_index = actual_track;
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();
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);
}
}
}
// Double click = play the track (preserve pause state)
state.selected_playlist_index = actual_track;
action_play_from_playlist(state, player, true)?;
// Reset click tracking after action
state.last_click_time = None;
state.last_click_index = None;

View File

@ -13,14 +13,13 @@ pub struct Player {
socket: Option<UnixStream>,
position: f64,
duration: f64,
is_paused: bool,
is_idle: bool,
pub media_title: Option<String>,
pub artist: Option<String>,
pub album: Option<String>,
pub audio_codec: Option<String>,
pub audio_bitrate: Option<f64>,
pub sample_rate: Option<i64>,
pub cache_duration: Option<f64>,
}
impl Player {
@ -45,88 +44,53 @@ 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,
audio_codec: None,
audio_bitrate: None,
sample_rate: None,
cache_duration: None,
})
}
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;
// 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()?;
@ -143,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(());
@ -156,7 +119,11 @@ impl Player {
}
fn get_property(&mut self, property: &str) -> Option<Value> {
self.connect().ok()?;
// Try to connect - if respawning or connection fails, return None
if let Err(e) = self.connect() {
tracing::debug!("Failed to connect for property '{}': {}", property, e);
return None;
}
let cmd = json!({
"command": ["get_property", property],
@ -165,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();
}
@ -187,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(())
@ -232,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) {
@ -303,6 +306,13 @@ impl Player {
if let Some(val) = self.get_property("audio-params/samplerate") {
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> {
@ -313,8 +323,28 @@ impl Player {
Some(self.duration)
}
pub fn is_idle(&self) -> bool {
self.is_idle
pub fn is_idle(&mut self) -> Option<bool> {
self.get_property("idle-active")
.and_then(|v| v.as_bool())
}
pub fn is_paused(&mut self) -> Option<bool> {
self.get_property("pause")
.and_then(|v| v.as_bool())
}
pub fn get_player_state(&mut self) -> Option<crate::state::PlayerState> {
use crate::state::PlayerState;
let is_idle = self.is_idle()?;
let is_paused = self.is_paused()?;
Some(if is_idle {
PlayerState::Stopped
} else if is_paused {
PlayerState::Paused
} else {
PlayerState::Playing
})
}
pub fn is_process_alive(&mut self) -> bool {
@ -323,14 +353,12 @@ impl Player {
Ok(Some(_)) => {
// Process has exited - clean up socket
self.socket = None;
self.is_idle = true;
false
}
Ok(None) => true, // Process is still running
Err(_) => {
// Error checking, assume dead and clean up
self.socket = None;
self.is_idle = true;
false
}
}

View File

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

View File

@ -1,9 +1,29 @@
use crate::cache::{Cache, FileTreeNode};
use crate::config::Config;
use std::collections::HashSet;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::time::Instant;
// Fuzzy match scoring bonuses
const FUZZY_CONSECUTIVE_BONUS: i32 = 10;
const FUZZY_WORD_START_BONUS: i32 = 15;
const FUZZY_FOLDER_BONUS: i32 = 50;
// Helper to calculate effective height accounting for "X more below" indicator
fn calculate_effective_height(scroll_offset: usize, visible_height: usize, total_items: usize) -> usize {
let visible_end = scroll_offset + visible_height;
let items_below = if visible_end < total_items {
total_items - visible_end
} else {
0
};
if items_below > 0 {
visible_height.saturating_sub(1)
} else {
visible_height
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlayerState {
Stopped,
@ -39,7 +59,6 @@ pub struct AppState {
pub scroll_offset: usize,
pub file_panel_visible_height: usize,
pub playlist_visible_height: usize,
pub player_state: PlayerState,
pub current_file: Option<PathBuf>,
pub current_position: f64,
pub current_duration: f64,
@ -77,7 +96,9 @@ pub struct AppState {
#[derive(Debug, Clone)]
pub struct FlattenedItem {
pub node: FileTreeNode,
pub path: PathBuf,
pub name: String,
pub is_dir: bool,
pub depth: usize,
}
@ -95,7 +116,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 +170,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 +242,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 +282,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 +311,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 {
@ -346,11 +338,29 @@ impl AppState {
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) {
let item = self.get_selected_item().cloned();
if let Some(item) = item {
if item.node.is_dir {
let path = item.node.path.clone();
if item.is_dir {
let path = item.path.clone();
let was_expanded = self.expanded_dirs.contains(&path);
if was_expanded {
@ -358,7 +368,7 @@ impl AppState {
self.expanded_dirs.remove(&path);
self.rebuild_flattened_items();
// 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;
}
} else {
@ -368,19 +378,19 @@ impl AppState {
self.expanded_dirs.remove(&parent_buf);
self.rebuild_flattened_items();
// 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;
}
}
}
} else {
// 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();
self.expanded_dirs.remove(&parent_buf);
self.rebuild_flattened_items();
// 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;
}
}
@ -390,8 +400,8 @@ impl AppState {
pub fn expand_selected(&mut self) {
if let Some(item) = self.get_selected_item() {
if item.node.is_dir {
let path = item.node.path.clone();
if item.is_dir {
let path = item.path.clone();
self.expanded_dirs.insert(path);
self.rebuild_flattened_items();
}
@ -411,8 +421,8 @@ impl AppState {
self.marked_files.clear();
// Mark current file
if let Some(item) = self.get_selected_item() {
if !item.node.is_dir {
self.marked_files.insert(item.node.path.clone());
if !item.is_dir {
self.marked_files.insert(item.path.clone());
}
}
}
@ -428,8 +438,8 @@ impl AppState {
for i in start..=end {
if let Some(item) = self.flattened_items.get(i) {
if !item.node.is_dir {
self.marked_files.insert(item.node.path.clone());
if !item.is_dir {
self.marked_files.insert(item.path.clone());
}
}
}
@ -438,7 +448,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;
}
@ -450,15 +459,19 @@ impl AppState {
files.sort();
self.playlist.extend(files);
} else if let Some(item) = self.get_selected_item() {
let node = item.node.clone();
if node.is_dir {
// Add all files in directory (allow duplicates)
let mut files = collect_files_from_node(&node);
files.sort();
self.playlist.extend(files);
let path = item.path.clone();
let is_dir = item.is_dir;
if is_dir {
// Look up the full node to get children
if let Some(node) = self.find_node_by_path(&path) {
// Add all files in directory (allow duplicates)
let mut files = collect_files_from_node(node);
files.sort();
self.playlist.extend(files);
}
} else {
// 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;
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();
if node.is_dir {
// Play all files in directory
self.playlist = collect_files_from_node(&node);
self.playlist_index = 0;
self.playlist_scroll_offset = 0;
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;
let path = item.path.clone();
let is_dir = item.is_dir;
if is_dir {
// Play all files in directory - look up node to get children
if let Some(node) = self.find_node_by_path(&path) {
self.playlist = collect_files_from_node(node);
self.playlist_index = 0;
self.playlist_scroll_offset = 0;
self.selected_playlist_index = 0;
if let Some(first) = self.playlist.first() {
self.current_file = Some(first.clone());
} else {
// Empty directory
self.current_file = None;
}
}
} else {
// Play single file
let path = node.path.clone();
self.playlist = vec![path.clone()];
self.playlist_index = 0;
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 +531,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 +650,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 +678,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();
// 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();
}
// 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) {
if let Some(idx) = self.flattened_items.iter().position(|item| item.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;
@ -755,7 +767,7 @@ impl AppState {
self.rebuild_flattened_items();
// 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;
}
}
@ -780,7 +792,7 @@ impl AppState {
self.rebuild_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;
// Scroll to show the match
@ -827,7 +839,7 @@ impl AppState {
self.rebuild_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;
// Scroll to show the match
@ -874,22 +886,15 @@ impl AppState {
self.rebuild_flattened_items();
// 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;
// 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;
@ -924,22 +929,15 @@ impl AppState {
self.rebuild_flattened_items();
// 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;
// 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 +994,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 +1018,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 +1046,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;
@ -1209,7 +1186,9 @@ fn flatten_tree(nodes: &[FileTreeNode], depth: usize, expanded_dirs: &HashSet<Pa
let is_expanded = expanded_dirs.contains(&node.path);
result.push(FlattenedItem {
node: node.clone(),
path: node.path.clone(),
name: node.name.clone(),
is_dir: node.is_dir,
depth,
});
@ -1236,38 +1215,44 @@ fn collect_files_from_node(node: &FileTreeNode) -> Vec<PathBuf> {
}
fn fuzzy_match(text: &str, query: &str) -> Option<i32> {
let text_lower = text.to_lowercase();
let query_lower = query.to_lowercase();
let mut text_chars = text_lower.chars();
// Avoid allocations by comparing chars directly with case-insensitive logic
let mut text_chars = text.chars();
let mut score = 0;
let mut prev_match_idx = 0;
let mut consecutive_bonus = 0;
let mut prev_char = '\0';
for query_char in query_lower.chars() {
for query_char in query.chars() {
// Lowercase query char inline
let query_char_lower = query_char.to_lowercase().next().unwrap_or(query_char);
let mut found = false;
let mut current_idx = prev_match_idx;
for text_char in text_chars.by_ref() {
current_idx += 1;
if text_char == query_char {
// Lowercase text char inline for comparison
let text_char_lower = text_char.to_lowercase().next().unwrap_or(text_char);
if text_char_lower == query_char_lower {
found = true;
// Bonus for consecutive matches
if current_idx == prev_match_idx + 1 {
consecutive_bonus += 10;
consecutive_bonus += FUZZY_CONSECUTIVE_BONUS;
} else {
consecutive_bonus = 0;
}
// Bonus for matching at word start
if current_idx == 1 || text_lower.chars().nth(current_idx - 2).map_or(false, |c| !c.is_alphanumeric()) {
score += 15;
if current_idx == 1 || !prev_char.is_alphanumeric() {
score += FUZZY_WORD_START_BONUS;
}
score += consecutive_bonus;
// Penalty for gap
score -= (current_idx - prev_match_idx - 1) as i32;
prev_match_idx = current_idx;
prev_char = text_char;
break;
}
prev_char = text_char;
}
if !found {
@ -1283,7 +1268,7 @@ fn collect_matching_paths(nodes: &[FileTreeNode], query: &str, matches: &mut Vec
if let Some(mut score) = fuzzy_match(&node.name, query) {
// Give folders a significant boost so they appear before files
if node.is_dir {
score += 50;
score += FUZZY_FOLDER_BONUS;
}
matches.push((node.path.clone(), score));
}

View File

@ -11,7 +11,7 @@ use ratatui::{
};
use theme::Theme;
pub fn render(frame: &mut Frame, state: &mut AppState, player: &Player) -> (Rect, Rect, Rect) {
pub fn render(frame: &mut Frame, state: &mut AppState, player: &mut Player) -> (Rect, Rect, Rect) {
// Clear background
frame.render_widget(
Block::default().style(Theme::secondary()),
@ -34,7 +34,7 @@ pub fn render(frame: &mut Frame, state: &mut AppState, player: &Player) -> (Rect
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.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_right_panel(frame, state, content_chunks[1]);
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();
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 {
@ -141,15 +142,15 @@ fn render_file_panel(frame: &mut Frame, state: &mut AppState, area: Rect) {
.map(|(display_idx, item)| {
let idx = state.scroll_offset + display_idx;
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
// Only show selection bar when file panel has focus
let is_selected = !state.focus_playlist && idx == state.selected_index;
// Add icon for directories and files
let icon = if item.node.is_dir {
let is_expanded = state.expanded_dirs.contains(&item.node.path);
let icon = if item.is_dir {
let is_expanded = state.expanded_dirs.contains(&item.path);
// Nerd font folder icons: \u{eaf7} = open, \u{ea83} = closed
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 {
// File icons based on extension
let extension = item.node.path.extension()
let extension = item.path.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.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() {
highlight_search_matches(&item.node.name, &search_query, is_selected)
highlight_search_matches(&item.name, &search_query, is_selected)
} 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 {
// 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 {
Theme::selected()
}
} else if state.marked_files.contains(&item.node.path) {
} else if state.marked_files.contains(&item.path) {
Theme::marked()
} else {
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);
}
fn render_title_bar(frame: &mut Frame, state: &AppState, 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, area: Rect) {
));
} 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, area: Rect) {
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 {
@ -521,7 +524,7 @@ fn render_status_bar(frame: &mut Frame, state: &AppState, player: &Player, area:
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 {
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));
}
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
let chunks = Layout::default()
.direction(Direction::Horizontal)