Compare commits

...

16 Commits
v0.1.7 ... main

Author SHA1 Message Date
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
ed6765039c Add nerd font icons and UI polish
All checks were successful
Build and Release / build-and-release (push) Successful in 1m18s
- Add nerd font file type icons:
  - Folder icons: closed/open folders with visual state
  - Music files: icon in green (mp3, flac, wav, ogg, etc.)
  - Video files: icon in yellow (mp4, mkv, avi, mov, etc.)
- Add spacing after icons for better readability

- Add "Refresh" option to title bar right-click menu
- Make all context menus more compact (13 chars wide)

- Change panel titles to lowercase:
  - "Media Files" → "files"
  - "Playlist" → "playlist"
- Remove bold styling from focused panel titles

- All icons show as bold black on selection bar
- Folders show in blue, music in green, video in yellow
2025-12-08 23:07:28 +01:00
f60ff02b2a Improve playlist handling and add UI enhancements
All checks were successful
Build and Release / build-and-release (push) Successful in 1m19s
- Fix cascading track failures when files can't be loaded
- Add position check (>0.5s) to prevent auto-advance on failed tracks
- Fix playlist scroll jumping when browsing while tracks auto-advance
- Only auto-scroll playlist when not focused on playlist panel
- Fix selector bar getting hidden by "... X more below" indicator
- Add effective height calculation for all navigation functions
- Fix Ctrl-U/D page scrolling in both panels

- Add play mode system (Normal/Loop) with 'm' key toggle
- Add Shift+R to randomize playlist order
- Add right-click context menus:
  - Playlist: Remove, Randomise
  - Title bar: Stop, Loop, Refresh
- Make context menus more compact (13 chars wide)
- Show play mode indicator in title bar ([Loop])

- Add incremental search support in playlist panel
- Fix search scrolling to account for bottom indicator
- Show search query in status bar when navigating results
- Change search text to white color

- Improve double-click detection and single-click selection
- Add focus indicators (bold title for active panel)
- Hide selector bar in inactive panel
2025-12-08 19:53:32 +01:00
59f9f548c1 Add playlist bounds validation and search mode visual indicator
All checks were successful
Build and Release / build-and-release (push) Successful in 1m1s
- Add bounds checking to prevent accessing invalid playlist indices
- Yellow/orange selection bar when in search mode
- Validate playlist index after navigation operations
- Handle empty playlists gracefully
2025-12-07 16:02:44 +01:00
248c5701fb Refactor mouse and keyboard handlers with shared actions
All checks were successful
Build and Release / build-and-release (push) Successful in 1m24s
- Extract common action functions to eliminate code duplication
- Mouse left-click toggles folder open/close
- Mouse right-click plays selection (identical to Enter key)
- Add confirmation popup for library refresh operation
- Improve popup visibility with Clear widget
2025-12-07 13:56:52 +01:00
0cef231cd3 Add metadata display and optimize CPU usage
All checks were successful
Build and Release / build-and-release (push) Successful in 58s
Metadata features:
- Display artist, album, title in bottom status bar (left-aligned)
- Display bitrate, codec, sample rate in bottom status bar (right-aligned)
- Fetch metadata from MPV using metadata/by-key properties
- Support both lowercase and uppercase metadata tags

Performance optimizations:
- Split property updates: fast updates (position/duration) vs slow (metadata)
- Update metadata only every 2 seconds instead of every 100ms
- Skip MPV property updates entirely when stopped
- Conditional UI redraws - only when state actually changes
- Variable poll rate: 200ms when stopped, 100ms when playing
- Position update throttling (0.5s minimum change)
- Reduces CPU usage from constant 10 FPS to ~0.1 FPS when idle
2025-12-06 23:07:33 +01:00
ae80e9a5db Improve search mode UX and fix playback bugs
All checks were successful
Build and Release / build-and-release (push) Successful in 51s
Search mode improvements:
- Search results persist until explicitly cleared
- Bold black highlighted chars on selection bar
- Fix fuzzy match scoring to select first occurrence
- Search info moved to bottom status bar

Keybinding changes:
- J/K for next/prev track (was n/p)
- H/L for seeking (was arrow keys)
- Simplified status bar shortcuts

UI improvements:
- Dynamic title bar color (green=playing, blue=paused, gray=stopped)
- White bold text for current playlist item
- Removed mouse capture for terminal text selection

Bug fixes:
- Fix auto-advance triggering multiple times when restarting from stopped state
2025-12-06 22:14:57 +01:00
1b07026b68 Replace playlist selection with bold colored text
All checks were successful
Build and Release / build-and-release (push) Successful in 59s
- Current track shown with bold text instead of selection highlight
- Green bold for playing, blue for paused, yellow for stopped
- Cleaner visual appearance
- Bump version to 0.1.8
2025-12-06 17:40:06 +01:00
7 changed files with 2169 additions and 282 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.7" version = "0.1.22"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
@ -31,6 +31,9 @@ thiserror = "1.0"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = "0.3" tracing-subscriber = "0.3"
# Random
rand = "0.8"
[profile.release] [profile.release]
opt-level = 3 opt-level = 3
lto = true lto = true

View File

@ -7,7 +7,7 @@ mod ui;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use crossterm::{ use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}, event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent, MouseEventKind, EnableMouseCapture, DisableMouseCapture},
execute, execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
}; };
@ -75,45 +75,293 @@ async fn main() -> Result<()> {
result result
} }
// Common action functions that both keyboard and mouse handlers can call
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 state.expanded_dirs.contains(&path) {
// Folder is open, close it
state.collapse_selected();
} else {
// Folder is closed, open it
state.expand_selected();
}
}
}
}
fn action_play_selection(state: &mut AppState, player: &mut player::Player) -> Result<()> {
state.play_selection();
if let Some(ref path) = state.current_file {
player.play(path)?;
// Explicitly resume playback in case MPV was paused
player.resume()?;
state.player_state = PlayerState::Playing;
player.update_metadata();
tracing::info!("Playing: {:?} (playlist: {} tracks)", path, state.playlist.len());
}
if state.visual_mode {
state.visual_mode = false;
state.marked_files.clear();
}
Ok(())
}
fn action_toggle_play_pause(state: &mut AppState, player: &mut player::Player) -> Result<()> {
match state.player_state {
PlayerState::Playing => {
player.pause()?;
state.player_state = PlayerState::Paused;
tracing::info!("Paused");
}
PlayerState::Paused => {
player.resume()?;
state.player_state = PlayerState::Playing;
tracing::info!("Resumed");
}
PlayerState::Stopped => {
// Restart playback from current playlist position
if !state.playlist.is_empty() {
state.current_file = Some(state.playlist[state.playlist_index].clone());
state.player_state = PlayerState::Playing;
if let Some(ref path) = state.current_file {
player.play(path)?;
player.resume()?;
player.update_metadata();
tracing::info!("Restarting playback: {:?}", path);
}
}
}
}
Ok(())
}
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(())
}
fn action_remove_from_playlist(state: &mut AppState, player: &mut player::Player) -> Result<()> {
let was_playing_removed = state.playlist_index == state.selected_playlist_index;
state.remove_selected_playlist_item();
if state.playlist.is_empty() {
state.player_state = PlayerState::Stopped;
state.current_file = None;
player.stop()?;
} else if was_playing_removed && state.player_state == PlayerState::Playing {
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_play_from_playlist(state: &mut AppState, player: &mut player::Player, preserve_pause: bool) -> Result<()> {
state.playlist_index = state.selected_playlist_index;
state.current_file = Some(state.playlist[state.playlist_index].clone());
if preserve_pause {
match state.player_state {
PlayerState::Playing => {
if let Some(ref path) = state.current_file {
player.play(path)?;
player.resume()?;
player.update_metadata();
tracing::info!("Jumped to track: {:?}", path);
}
}
PlayerState::Paused => {
if let Some(ref path) = state.current_file {
player.play(path)?;
player.update_metadata();
player.pause()?;
tracing::info!("Jumped to track (paused): {:?}", path);
}
}
PlayerState::Stopped => {
state.player_state = PlayerState::Playing;
if let Some(ref path) = state.current_file {
player.play(path)?;
player.resume()?;
player.update_metadata();
tracing::info!("Started playing track: {:?}", path);
}
}
}
} else {
state.player_state = PlayerState::Playing;
if let Some(ref path) = state.current_file {
player.play(path)?;
// Explicitly resume playback in case MPV was paused
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(())
}
async fn run_app<B: ratatui::backend::Backend>( async 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,
) -> Result<()> { ) -> Result<()> {
let mut metadata_update_counter = 0u32;
let mut last_position = 0.0f64;
let mut needs_redraw = true;
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();
loop { loop {
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 {
state.player_state = PlayerState::Stopped; 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;
state_changed = true;
} }
// Update player properties from MPV // Only update properties when playing or paused (not when stopped)
player.update_properties(); if state.player_state != PlayerState::Stopped {
player.update_properties();
// Update position and duration from player // Update metadata only every 20 iterations (~2 seconds) to reduce IPC calls
state.current_position = player.get_position().unwrap_or(0.0); metadata_update_counter += 1;
state.current_duration = player.get_duration().unwrap_or(0.0); if metadata_update_counter >= 20 {
player.update_metadata();
metadata_update_counter = 0;
state_changed = true;
}
// Check if track ended and play next // Update position and duration from player
if player.is_idle() && state.player_state == PlayerState::Playing { let new_position = player.get_position().unwrap_or(0.0);
if state.playlist_index + 1 < state.playlist.len() { let new_duration = player.get_duration().unwrap_or(0.0);
// 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;
}
if state.current_duration != new_duration {
state.current_duration = new_duration;
state_changed = true;
}
// Check if track ended and play next (but only if track was actually loaded AND played)
// Require position > 0.5 to ensure track actually started playing (not just loaded)
// Also check !is_paused to avoid triggering during pause/unpause transitions
if player.is_idle() && !player.is_paused() && state.player_state == PlayerState::Playing && state.current_duration > 0.0 && state.current_position > 0.5 {
state.play_next(); state.play_next();
if let Some(ref path) = state.current_file { // play_next() handles the play mode and may stop if in Normal mode at end
player.play(path)?; if state.player_state == PlayerState::Playing {
if let Some(ref path) = state.current_file {
// Reset position/duration before playing new track
state.current_position = 0.0;
state.current_duration = 0.0;
last_position = 0.0;
player.play(path)?;
player.resume()?;
}
// Update metadata immediately when track changes
player.update_metadata();
metadata_update_counter = 0;
// Auto-scroll playlist to show current track (only if user is not browsing playlist)
if !state.focus_playlist {
let playlist_visible_height = playlist_area.height.saturating_sub(2) as usize;
state.update_playlist_scroll(playlist_visible_height);
}
} }
} else { state_changed = true;
state.player_state = PlayerState::Stopped;
} }
} }
terminal.draw(|f| ui::render(f, state))?; // Only redraw if something changed or forced
if needs_redraw || state_changed {
terminal.draw(|f| {
let areas = ui::render(f, state, player);
title_bar_area = areas.0;
file_panel_area = areas.1;
playlist_area = areas.2;
})?;
needs_redraw = false;
}
if event::poll(std::time::Duration::from_millis(100))? { // Poll for events - use longer timeout when stopped to reduce CPU
if let Event::Key(key) = event::read()? { let poll_duration = if state.player_state == PlayerState::Stopped {
if key.kind == KeyEventKind::Press { std::time::Duration::from_millis(200) // 5 FPS when stopped
handle_key_event(terminal, state, player, key).await?; } else {
std::time::Duration::from_millis(100) // 10 FPS when playing/paused
};
if event::poll(poll_duration)? {
match event::read()? {
Event::Key(key) => {
if key.kind == KeyEventKind::Press {
handle_key_event(terminal, state, player, key).await?;
needs_redraw = true; // Force redraw after key event
}
} }
Event::Mouse(mouse) => {
handle_mouse_event(state, mouse, title_bar_area, file_panel_area, playlist_area, player)?;
needs_redraw = true; // Force redraw after mouse event
}
_ => {}
} }
} }
@ -126,23 +374,67 @@ async fn run_app<B: ratatui::backend::Backend>(
} }
async fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, state: &mut AppState, player: &mut player::Player, key: KeyEvent) -> Result<()> { async 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 {
KeyCode::Char('y') | KeyCode::Char('Y') => {
state.show_refresh_confirm = false;
state.is_refreshing = true;
terminal.draw(|f| { let _ = ui::render(f, state, player); })?; // Show "Refreshing library..." immediately
tracing::info!("Rescanning...");
let cache_dir = cache::get_cache_dir()?;
let new_cache = scanner::scan_paths(&state.config.scan_paths.paths)?;
new_cache.save(&cache_dir)?;
state.cache = new_cache;
state.refresh_flattened_items();
state.is_refreshing = false;
tracing::info!("Rescan complete");
}
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
state.show_refresh_confirm = false;
}
_ => {}
}
return Ok(());
}
// Handle search mode separately // Handle search mode separately
if state.search_mode { if state.search_mode {
match key.code { match key.code {
KeyCode::Char(c) => { KeyCode::Char(c) => {
state.append_search_char(c); if state.focus_playlist {
state.append_playlist_search_char(c);
} else {
state.append_search_char(c);
}
} }
KeyCode::Backspace => { KeyCode::Backspace => {
state.backspace_search(); if state.focus_playlist {
state.backspace_playlist_search();
} else {
state.backspace_search();
}
} }
KeyCode::Tab => { KeyCode::Tab => {
state.tab_search_next(); if state.focus_playlist {
state.playlist_tab_search_next();
} else {
state.tab_search_next();
}
} }
KeyCode::BackTab => { KeyCode::BackTab => {
state.tab_search_prev(); if state.focus_playlist {
state.playlist_tab_search_prev();
} else {
state.tab_search_prev();
}
} }
KeyCode::Enter => { KeyCode::Enter => {
state.execute_search(); if state.focus_playlist {
state.execute_playlist_search();
} else {
state.execute_search();
}
} }
KeyCode::Esc => { KeyCode::Esc => {
state.exit_search_mode(); state.exit_search_mode();
@ -152,15 +444,60 @@ async fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<
return Ok(()); return Ok(());
} }
// Handle context menu navigation if menu is shown
if let Some(ref mut menu) = state.context_menu {
use crate::state::ContextMenuType;
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if menu.selected_index > 0 {
menu.selected_index -= 1;
}
}
KeyCode::Down | KeyCode::Char('j') => {
let max_items = match menu.menu_type {
ContextMenuType::FilePanel => 2,
ContextMenuType::Playlist => 2,
ContextMenuType::TitleBar => 4,
};
if menu.selected_index < max_items - 1 {
menu.selected_index += 1;
}
}
KeyCode::Enter => {
// Execute selected action
let menu_type = menu.menu_type;
let selected = menu.selected_index;
state.context_menu = None;
handle_context_menu_action(menu_type, selected, state, player)?;
}
KeyCode::Esc => {
state.context_menu = None;
}
_ => {}
}
return Ok(());
}
match (key.code, key.modifiers) { match (key.code, key.modifiers) {
(KeyCode::Char('q'), _) => { (KeyCode::Char('q'), _) => {
state.should_quit = true; state.should_quit = true;
} }
(KeyCode::Char('/'), _) => { (KeyCode::Char('/'), _) => {
state.enter_search_mode(); if state.search_mode && state.search_query.is_empty() {
// Exit search mode only if search query is blank
state.exit_search_mode();
} else if !state.search_mode {
state.enter_search_mode();
}
} }
(KeyCode::Esc, _) => { (KeyCode::Esc, _) => {
state.search_matches.clear(); if !state.search_matches.is_empty() {
state.search_matches.clear();
}
if !state.playlist_search_matches.is_empty() {
state.playlist_search_matches.clear();
state.playlist_tab_search_results.clear();
}
if state.visual_mode { if state.visual_mode {
state.visual_mode = false; state.visual_mode = false;
state.marked_files.clear(); state.marked_files.clear();
@ -169,10 +506,23 @@ async fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<
(KeyCode::Char('n'), _) => { (KeyCode::Char('n'), _) => {
if !state.search_matches.is_empty() { if !state.search_matches.is_empty() {
state.next_search_match(); state.next_search_match();
} else if !state.playlist.is_empty() { } else if !state.playlist_search_matches.is_empty() {
// Advance to next track state.next_playlist_search_match();
if state.playlist_index + 1 < state.playlist.len() { }
state.playlist_index += 1; }
(KeyCode::Char('N'), KeyModifiers::SHIFT) => {
if !state.search_matches.is_empty() {
state.prev_search_match();
} else if !state.playlist_search_matches.is_empty() {
state.prev_playlist_search_match();
}
}
(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()); state.current_file = Some(state.playlist[state.playlist_index].clone());
match state.player_state { match state.player_state {
@ -180,6 +530,8 @@ async fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<
// Keep playing // Keep playing
if let Some(ref path) = state.current_file { if let Some(ref path) = state.current_file {
player.play(path)?; player.play(path)?;
player.resume()?;
player.update_metadata(); // Update metadata immediately
tracing::info!("Next track: {:?}", path); tracing::info!("Next track: {:?}", path);
} }
} }
@ -187,6 +539,7 @@ async fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<
// Load but stay paused // Load but stay paused
if let Some(ref path) = state.current_file { if let Some(ref path) = state.current_file {
player.play(path)?; player.play(path)?;
player.update_metadata(); // Update metadata immediately
player.pause()?; player.pause()?;
tracing::info!("Next track (paused): {:?}", path); tracing::info!("Next track (paused): {:?}", path);
} }
@ -196,122 +549,146 @@ async fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<
tracing::info!("Next track selected (stopped): {:?}", state.current_file); 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('N'), KeyModifiers::SHIFT) => { (KeyCode::Char('K'), KeyModifiers::SHIFT) => {
state.prev_search_match(); // Previous track
if !state.playlist.is_empty() && state.playlist_index > 0 {
state.playlist_index -= 1;
// Validate index before accessing playlist
if state.playlist_index < state.playlist.len() {
state.current_file = Some(state.playlist[state.playlist_index].clone());
match state.player_state {
PlayerState::Playing => {
// Keep playing
if let Some(ref path) = state.current_file {
player.play(path)?;
player.resume()?;
player.update_metadata(); // Update metadata immediately
tracing::info!("Previous track: {:?}", path);
}
}
PlayerState::Paused => {
// Load but stay paused
if let Some(ref path) = state.current_file {
player.play(path)?;
player.update_metadata(); // Update metadata immediately
player.pause()?;
tracing::info!("Previous track (paused): {:?}", path);
}
}
PlayerState::Stopped => {
// Just update current file, stay stopped
tracing::info!("Previous track selected (stopped): {:?}", state.current_file);
}
}
// Auto-scroll playlist to show current track (only if user is not browsing playlist)
if !state.focus_playlist {
state.update_playlist_scroll(20);
}
}
}
} }
(KeyCode::Char('d'), KeyModifiers::CONTROL) => { (KeyCode::Char('d'), KeyModifiers::CONTROL) => {
state.page_down(); if state.focus_playlist {
state.playlist_page_down();
} else {
state.page_down();
}
} }
(KeyCode::Char('u'), KeyModifiers::CONTROL) => { (KeyCode::Char('u'), KeyModifiers::CONTROL) => {
state.page_up(); if state.focus_playlist {
state.playlist_page_up();
} else {
state.page_up();
}
}
(KeyCode::Tab, _) => {
// Switch focus between file panel and playlist panel
state.focus_playlist = !state.focus_playlist;
} }
(KeyCode::Char('k'), _) | (KeyCode::Up, _) => { (KeyCode::Char('k'), _) | (KeyCode::Up, _) => {
state.move_selection_up(); if state.focus_playlist {
state.move_playlist_selection_up();
} else {
state.move_selection_up();
}
} }
(KeyCode::Char('j'), _) | (KeyCode::Down, _) => { (KeyCode::Char('j'), _) | (KeyCode::Down, _) => {
state.move_selection_down(); if state.focus_playlist {
state.move_playlist_selection_down(state.playlist_visible_height);
} else {
state.move_selection_down();
}
} }
(KeyCode::Char('h'), _) => { (KeyCode::Char('h'), _) | (KeyCode::Left, _) => {
state.collapse_selected(); if !state.focus_playlist {
state.collapse_selected();
}
} }
(KeyCode::Char('l'), _) => { (KeyCode::Char('l'), _) | (KeyCode::Right, _) => {
state.expand_selected(); if !state.focus_playlist {
state.expand_selected();
}
} }
(KeyCode::Char('v'), _) => { (KeyCode::Char('v'), _) => {
state.toggle_mark(); if !state.focus_playlist {
state.toggle_mark();
}
} }
(KeyCode::Char('a'), _) => { (KeyCode::Char('a'), _) => {
state.add_to_playlist(); if !state.focus_playlist {
if state.visual_mode { state.add_to_playlist();
state.visual_mode = false; if state.visual_mode {
state.marked_files.clear(); state.visual_mode = false;
state.marked_files.clear();
}
} }
} }
(KeyCode::Char('c'), _) => { (KeyCode::Char('c'), _) => {
state.clear_playlist(); state.clear_playlist();
} }
(KeyCode::Char('p'), _) => { (KeyCode::Char('d'), _) => {
if !state.playlist.is_empty() && state.playlist_index > 0 { if state.focus_playlist {
state.playlist_index -= 1; action_remove_from_playlist(state, player)?;
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)?;
tracing::info!("Previous track: {:?}", path);
}
}
PlayerState::Paused => {
// Load but stay paused
if let Some(ref path) = state.current_file {
player.play(path)?;
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);
}
}
} }
} }
(KeyCode::Enter, _) => { (KeyCode::Enter, _) => {
state.play_selection(); if state.focus_playlist {
if let Some(ref path) = state.current_file { if state.selected_playlist_index < state.playlist.len() {
player.play(path)?; action_play_from_playlist(state, player, false)?;
tracing::info!("Playing: {:?} (playlist: {} tracks)", path, state.playlist.len()); }
} } else {
if state.visual_mode { action_play_selection(state, player)?;
state.visual_mode = false;
state.marked_files.clear();
} }
} }
(KeyCode::Char('s'), _) => { (KeyCode::Char('s'), _) => {
// s: Stop playback action_stop(state, player)?;
player.stop()?; }
state.player_state = PlayerState::Stopped; (KeyCode::Char('m'), _) => {
state.current_position = 0.0; state.cycle_play_mode();
state.current_duration = 0.0; tracing::info!("Play mode: {:?}", state.play_mode);
tracing::info!("Stopped"); }
(KeyCode::Char('R'), KeyModifiers::SHIFT) => {
state.shuffle_playlist();
tracing::info!("Playlist shuffled");
} }
(KeyCode::Char(' '), _) => { (KeyCode::Char(' '), _) => {
match state.player_state { action_toggle_play_pause(state, player)?;
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)?;
tracing::info!("Restarting playback: {:?}", path);
}
}
}
}
} }
(KeyCode::Left, _) => { (KeyCode::Char('H'), KeyModifiers::SHIFT) => {
if state.player_state != PlayerState::Stopped { if state.player_state != PlayerState::Stopped {
player.seek(-10.0)?; player.seek(-10.0)?;
tracing::info!("Seek backward 10s"); tracing::info!("Seek backward 10s");
} }
} }
(KeyCode::Right, _) => { (KeyCode::Char('L'), KeyModifiers::SHIFT) => {
if state.player_state != PlayerState::Stopped { if state.player_state != PlayerState::Stopped {
player.seek(10.0)?; player.seek(10.0)?;
tracing::info!("Seek forward 10s"); tracing::info!("Seek forward 10s");
@ -330,19 +707,296 @@ async fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<
tracing::info!("Volume: {}%", new_volume); tracing::info!("Volume: {}%", new_volume);
} }
(KeyCode::Char('r'), _) => { (KeyCode::Char('r'), _) => {
state.is_refreshing = true; state.show_refresh_confirm = true;
terminal.draw(|f| ui::render(f, state))?; // Show "Refreshing library..." immediately
tracing::info!("Rescanning...");
let cache_dir = cache::get_cache_dir()?;
let new_cache = scanner::scan_paths(&state.config.scan_paths.paths)?;
new_cache.save(&cache_dir)?;
state.cache = new_cache;
state.refresh_flattened_items();
state.is_refreshing = false;
tracing::info!("Rescan complete");
} }
_ => {} _ => {}
} }
Ok(()) Ok(())
} }
fn handle_mouse_event(state: &mut AppState, mouse: MouseEvent, title_bar_area: ratatui::layout::Rect, file_panel_area: ratatui::layout::Rect, playlist_area: ratatui::layout::Rect, player: &mut player::Player) -> Result<()> {
use crossterm::event::MouseButton;
use crate::state::ContextMenuType;
let x = mouse.column;
let y = mouse.row;
// Handle context menu if open (like cm-dashboard)
if let Some(ref menu) = state.context_menu.clone() {
// Calculate popup bounds
let items = match menu.menu_type {
ContextMenuType::FilePanel => 2,
ContextMenuType::Playlist => 2,
ContextMenuType::TitleBar => 4,
};
let popup_width = 13;
let popup_height = items as u16 + 2; // +2 for borders
// Get screen dimensions
let screen_width = title_bar_area.width.max(file_panel_area.width + playlist_area.width);
let screen_height = title_bar_area.height + file_panel_area.height.max(playlist_area.height) + 1;
let popup_x = if menu.x + popup_width < screen_width {
menu.x
} else {
screen_width.saturating_sub(popup_width)
};
let popup_y = if menu.y + popup_height < screen_height {
menu.y
} else {
screen_height.saturating_sub(popup_height)
};
let popup_area = ratatui::layout::Rect {
x: popup_x,
y: popup_y,
width: popup_width,
height: popup_height,
};
// Check if mouse is in popup area
let in_popup = x >= popup_area.x
&& x < popup_area.x + popup_area.width
&& y >= popup_area.y
&& y < popup_area.y + popup_area.height;
// Update selected index on mouse move
if matches!(mouse.kind, MouseEventKind::Moved) {
if in_popup {
let relative_y = y.saturating_sub(popup_y + 1) as usize; // +1 for top border
if relative_y < items {
if let Some(ref mut menu) = state.context_menu {
menu.selected_index = relative_y;
}
}
}
return Ok(());
}
// Handle left click
if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
if in_popup {
// Click inside popup - execute action
let relative_y = y.saturating_sub(popup_y + 1) as usize; // +1 for top border
if relative_y < items {
let menu_type = menu.menu_type;
let selected = relative_y;
state.context_menu = None;
handle_context_menu_action(menu_type, selected, state, player)?;
}
return Ok(());
} else {
// Click outside popup - close it
state.context_menu = None;
return Ok(());
}
}
// Any other event while popup is open - don't process panels
return Ok(());
}
match mouse.kind {
MouseEventKind::ScrollDown => {
// Check which panel the mouse is over
if x >= title_bar_area.x
&& x < title_bar_area.x + title_bar_area.width
&& y >= title_bar_area.y
&& y < title_bar_area.y + title_bar_area.height
{
// Scroll on title bar = decrease volume
let new_volume = (state.volume - 5).max(0);
state.volume = new_volume;
player.set_volume(new_volume)?;
tracing::info!("Volume: {}%", new_volume);
} else if x >= playlist_area.x
&& x < playlist_area.x + playlist_area.width
&& y >= playlist_area.y
&& y < playlist_area.y + playlist_area.height
{
// Scroll playlist
let visible_height = playlist_area.height.saturating_sub(2) as usize;
state.scroll_playlist_down(visible_height);
} else {
// Scroll file panel
let visible_height = file_panel_area.height.saturating_sub(2) as usize;
state.scroll_view_down(visible_height);
}
}
MouseEventKind::ScrollUp => {
// Check which panel the mouse is over
if x >= title_bar_area.x
&& x < title_bar_area.x + title_bar_area.width
&& y >= title_bar_area.y
&& y < title_bar_area.y + title_bar_area.height
{
// Scroll on title bar = increase volume
let new_volume = (state.volume + 5).min(100);
state.volume = new_volume;
player.set_volume(new_volume)?;
tracing::info!("Volume: {}%", new_volume);
} else if x >= playlist_area.x
&& x < playlist_area.x + playlist_area.width
&& y >= playlist_area.y
&& y < playlist_area.y + playlist_area.height
{
// Scroll playlist
state.scroll_playlist_up();
} else {
// Scroll file panel
state.scroll_view_up();
}
}
MouseEventKind::Down(button) => {
// Check if click is on title bar
if x >= title_bar_area.x
&& x < title_bar_area.x + title_bar_area.width
&& y >= title_bar_area.y
&& y < title_bar_area.y + title_bar_area.height
{
match button {
MouseButton::Left => {
// Left click on title bar = play/pause toggle
action_toggle_play_pause(state, player)?;
}
MouseButton::Right => {
// Right click on title bar = show context menu
use crate::state::{ContextMenu, ContextMenuType};
state.context_menu = Some(ContextMenu {
menu_type: ContextMenuType::TitleBar,
x,
y,
selected_index: 0,
});
}
_ => {}
}
}
// Check if click is within file panel area
else if x >= file_panel_area.x
&& x < file_panel_area.x + file_panel_area.width
&& y >= file_panel_area.y
&& y < file_panel_area.y + file_panel_area.height
{
// Calculate which item was clicked (accounting for borders and scroll offset)
// Border takes 1 line at top, so subtract 1 from y position
let relative_y = (y - file_panel_area.y).saturating_sub(1);
let clicked_index = state.scroll_offset + relative_y as usize;
// Set selection to clicked item if valid
if clicked_index < state.flattened_items.len() {
state.selected_index = clicked_index;
state.focus_playlist = false; // Switch focus to file panel
// Handle different mouse buttons
match button {
MouseButton::Left => {
// Detect double-click (same item within 500ms)
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
} else {
false
};
if is_double_click {
// Double click = toggle folder or play file
if let Some(item) = state.get_selected_item() {
if item.node.is_dir {
action_toggle_folder(state);
} else {
action_play_selection(state, player)?;
}
}
// Reset click tracking after action
state.last_click_time = None;
state.last_click_index = None;
} else {
// Single click = just select
state.last_click_time = Some(now);
state.last_click_index = Some(clicked_index);
state.last_click_is_playlist = false;
}
}
MouseButton::Right => {
// Right click = show context menu at mouse position
use crate::state::{ContextMenu, ContextMenuType};
state.context_menu = Some(ContextMenu {
menu_type: ContextMenuType::FilePanel,
x,
y,
selected_index: 0,
});
}
_ => {}
}
}
}
// Check if click is within playlist area
else if x >= playlist_area.x
&& x < playlist_area.x + playlist_area.width
&& y >= playlist_area.y
&& y < playlist_area.y + playlist_area.height
{
// Calculate which track was clicked (accounting for borders)
let relative_y = (y - playlist_area.y).saturating_sub(1);
let clicked_track = relative_y as usize;
// Add scroll offset to get actual index
let actual_track = state.playlist_scroll_offset + clicked_track;
match button {
MouseButton::Left => {
if actual_track < state.playlist.len() {
state.selected_playlist_index = actual_track;
state.focus_playlist = true; // Switch focus to playlist
// Detect double-click (same track within 500ms)
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
} else {
false
};
if is_double_click {
// 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;
} else {
// Single click = just select
state.last_click_time = Some(now);
state.last_click_index = Some(actual_track);
state.last_click_is_playlist = true;
}
}
}
MouseButton::Right => {
// Right click shows context menu at mouse position
if actual_track < state.playlist.len() {
state.selected_playlist_index = actual_track;
state.focus_playlist = true; // Switch focus to playlist
use crate::state::{ContextMenu, ContextMenuType};
state.context_menu = Some(ContextMenu {
menu_type: ContextMenuType::Playlist,
x,
y,
selected_index: 0,
});
}
}
_ => {}
}
}
}
_ => {}
}
Ok(())
}

View File

@ -15,6 +15,13 @@ pub struct Player {
duration: f64, duration: f64,
is_paused: bool, is_paused: bool,
is_idle: 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 { impl Player {
@ -50,6 +57,13 @@ impl Player {
duration: 0.0, duration: 0.0,
is_paused: false, is_paused: false,
is_idle: true, is_idle: true,
media_title: None,
artist: None,
album: None,
audio_codec: None,
audio_bitrate: None,
sample_rate: None,
cache_duration: None,
}) })
} }
@ -101,6 +115,13 @@ impl Player {
self.position = 0.0; self.position = 0.0;
self.duration = 0.0; self.duration = 0.0;
self.is_paused = false; self.is_paused = false;
self.media_title = None;
self.artist = None;
self.album = None;
self.audio_codec = None;
self.audio_bitrate = None;
self.sample_rate = None;
self.cache_duration = None;
// Wait for socket to be created and mpv to be ready // Wait for socket to be created and mpv to be ready
std::thread::sleep(Duration::from_millis(800)); std::thread::sleep(Duration::from_millis(800));
@ -230,6 +251,70 @@ impl Player {
} }
} }
pub fn update_metadata(&mut self) {
// Try to get artist directly
if let Some(val) = self.get_property("metadata/by-key/artist") {
self.artist = val.as_str().map(|s| s.to_string());
}
// Fallback to ARTIST (uppercase)
if self.artist.is_none() {
if let Some(val) = self.get_property("metadata/by-key/ARTIST") {
self.artist = val.as_str().map(|s| s.to_string());
}
}
// Try to get album directly
if let Some(val) = self.get_property("metadata/by-key/album") {
self.album = val.as_str().map(|s| s.to_string());
}
// Fallback to ALBUM (uppercase)
if self.album.is_none() {
if let Some(val) = self.get_property("metadata/by-key/ALBUM") {
self.album = val.as_str().map(|s| s.to_string());
}
}
// Try to get title directly
if let Some(val) = self.get_property("metadata/by-key/title") {
self.media_title = val.as_str().map(|s| s.to_string());
}
// Fallback to TITLE (uppercase)
if self.media_title.is_none() {
if let Some(val) = self.get_property("metadata/by-key/TITLE") {
self.media_title = val.as_str().map(|s| s.to_string());
}
}
// Final fallback to media-title if metadata doesn't have title
if self.media_title.is_none() {
if let Some(val) = self.get_property("media-title") {
self.media_title = val.as_str().map(|s| s.to_string());
}
}
// Update audio codec
if let Some(val) = self.get_property("audio-codec-name") {
self.audio_codec = val.as_str().map(|s| s.to_string());
}
// Update audio bitrate (convert from bps to kbps)
if let Some(val) = self.get_property("audio-bitrate") {
self.audio_bitrate = val.as_f64().map(|b| b / 1000.0);
}
// Update sample rate
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> { pub fn get_position(&self) -> Option<f64> {
Some(self.position) Some(self.position)
} }
@ -242,6 +327,10 @@ impl Player {
self.is_idle self.is_idle
} }
pub fn is_paused(&self) -> bool {
self.is_paused
}
pub fn is_process_alive(&mut self) -> bool { pub fn is_process_alive(&mut self) -> bool {
// Check if mpv process is still running // Check if mpv process is still running
match self.process.try_wait() { match self.process.try_wait() {

View File

@ -2,6 +2,7 @@ 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::PathBuf;
use std::time::Instant;
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlayerState { pub enum PlayerState {
@ -10,11 +11,34 @@ pub enum PlayerState {
Paused, Paused,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlayMode {
Normal, // Play through once
Loop, // Repeat playlist
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContextMenu {
pub menu_type: ContextMenuType,
pub x: u16,
pub y: u16,
pub selected_index: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ContextMenuType {
FilePanel, // Shows "Play" and "Add"
Playlist, // Shows "Remove" and "Randomise"
TitleBar, // Shows "Stop" and "Loop"
}
pub struct AppState { pub struct AppState {
pub cache: Cache, pub cache: Cache,
pub config: Config, pub config: Config,
pub selected_index: usize, pub selected_index: usize,
pub scroll_offset: usize, pub scroll_offset: usize,
pub file_panel_visible_height: usize,
pub playlist_visible_height: usize,
pub player_state: PlayerState, pub player_state: PlayerState,
pub current_file: Option<PathBuf>, pub current_file: Option<PathBuf>,
pub current_position: f64, pub current_position: f64,
@ -26,15 +50,29 @@ pub struct AppState {
pub marked_files: HashSet<PathBuf>, pub marked_files: HashSet<PathBuf>,
pub playlist: Vec<PathBuf>, pub playlist: Vec<PathBuf>,
pub playlist_index: usize, pub playlist_index: usize,
pub playlist_scroll_offset: usize,
pub selected_playlist_index: usize,
pub is_refreshing: bool, pub is_refreshing: bool,
pub search_mode: bool, pub search_mode: bool,
pub search_query: String, pub search_query: String,
pub search_matches: Vec<usize>, pub search_matches: Vec<PathBuf>,
pub search_match_index: usize, pub search_match_index: usize,
pub tab_search_results: Vec<PathBuf>, pub tab_search_results: Vec<PathBuf>,
pub tab_search_index: usize, pub tab_search_index: usize,
pub playlist_search_matches: Vec<usize>,
pub playlist_search_match_index: usize,
pub playlist_tab_search_results: Vec<usize>,
pub playlist_tab_search_index: usize,
pub visual_mode: bool, pub visual_mode: bool,
pub visual_anchor: usize, pub visual_anchor: usize,
pub saved_expanded_dirs: HashSet<PathBuf>,
pub show_refresh_confirm: bool,
pub focus_playlist: bool,
pub last_click_time: Option<Instant>,
pub last_click_index: Option<usize>,
pub last_click_is_playlist: bool,
pub context_menu: Option<ContextMenu>,
pub play_mode: PlayMode,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -55,6 +93,8 @@ impl AppState {
config, config,
selected_index: 0, selected_index: 0,
scroll_offset: 0, scroll_offset: 0,
file_panel_visible_height: 20,
playlist_visible_height: 20,
player_state: PlayerState::Stopped, player_state: PlayerState::Stopped,
current_file: None, current_file: None,
current_position: 0.0, current_position: 0.0,
@ -66,6 +106,8 @@ impl AppState {
marked_files: HashSet::new(), marked_files: HashSet::new(),
playlist: Vec::new(), playlist: Vec::new(),
playlist_index: 0, playlist_index: 0,
playlist_scroll_offset: 0,
selected_playlist_index: 0,
is_refreshing: false, is_refreshing: false,
search_mode: false, search_mode: false,
search_query: String::new(), search_query: String::new(),
@ -73,8 +115,20 @@ impl AppState {
search_match_index: 0, search_match_index: 0,
tab_search_results: Vec::new(), tab_search_results: Vec::new(),
tab_search_index: 0, tab_search_index: 0,
playlist_search_matches: Vec::new(),
playlist_search_match_index: 0,
playlist_tab_search_results: Vec::new(),
playlist_tab_search_index: 0,
visual_mode: false, visual_mode: false,
visual_anchor: 0, visual_anchor: 0,
saved_expanded_dirs: HashSet::new(),
show_refresh_confirm: false,
focus_playlist: false,
last_click_time: None,
last_click_index: None,
last_click_is_playlist: false,
context_menu: None,
play_mode: PlayMode::Normal,
} }
} }
@ -95,6 +149,24 @@ impl AppState {
pub fn move_selection_down(&mut self) { pub fn move_selection_down(&mut self) {
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 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
};
// Scroll down when selection reaches bottom
if self.selected_index >= self.scroll_offset + effective_height {
self.scroll_offset = self.selected_index - effective_height + 1;
}
// Update visual selection if in visual mode // Update visual selection if in visual mode
if self.visual_mode { if self.visual_mode {
self.update_visual_selection(); self.update_visual_selection();
@ -102,29 +174,172 @@ impl AppState {
} }
} }
pub fn update_scroll_offset(&mut self, visible_height: usize) { pub fn scroll_view_up(&mut self) {
// Scroll down when selection reaches bottom // Scroll view up without changing selection
if self.selected_index >= self.scroll_offset + visible_height { if self.scroll_offset > 0 {
self.scroll_offset = self.selected_index - visible_height + 1; self.scroll_offset -= 1;
} }
// Scroll up when selection reaches top }
if self.selected_index < self.scroll_offset {
self.scroll_offset = self.selected_index; pub fn scroll_view_down(&mut self, visible_height: usize) {
// Scroll view down without changing selection
let max_scroll = self.flattened_items.len().saturating_sub(visible_height);
if self.scroll_offset < max_scroll {
self.scroll_offset += 1;
}
}
pub fn scroll_playlist_up(&mut self) {
// Scroll playlist view up
if self.playlist_scroll_offset > 0 {
self.playlist_scroll_offset -= 1;
}
}
pub fn scroll_playlist_down(&mut self, visible_height: usize) {
// Scroll playlist view down
let max_scroll = self.playlist.len().saturating_sub(visible_height);
if self.playlist_scroll_offset < max_scroll {
self.playlist_scroll_offset += 1;
}
}
pub fn update_playlist_scroll(&mut self, visible_height: usize) {
// Auto-scroll playlist to keep current track visible
if self.playlist_index >= self.playlist_scroll_offset + visible_height {
// Track is below visible area, scroll down
self.playlist_scroll_offset = self.playlist_index - visible_height + 1;
} else if self.playlist_index < self.playlist_scroll_offset {
// Track is above visible area, scroll up
self.playlist_scroll_offset = self.playlist_index;
}
}
pub fn move_playlist_selection_up(&mut self) {
if self.selected_playlist_index > 0 {
self.selected_playlist_index -= 1;
// Scroll up when selection reaches top
if self.selected_playlist_index < self.playlist_scroll_offset {
self.playlist_scroll_offset = self.selected_playlist_index;
}
}
}
pub fn move_playlist_selection_down(&mut self, visible_height: usize) {
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
};
// Scroll down when selection reaches bottom
if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height {
self.playlist_scroll_offset = self.selected_playlist_index - effective_height + 1;
}
}
}
pub fn remove_selected_playlist_item(&mut self) {
if self.selected_playlist_index < self.playlist.len() {
self.playlist.remove(self.selected_playlist_index);
// Adjust playlist_index if necessary
if self.playlist_index > self.selected_playlist_index {
self.playlist_index -= 1;
} else if self.playlist_index == self.selected_playlist_index {
// Keep same index (which is now the next track)
if self.playlist_index >= self.playlist.len() && !self.playlist.is_empty() {
self.playlist_index = self.playlist.len() - 1;
}
}
// Adjust selected_playlist_index if at end
if self.selected_playlist_index >= self.playlist.len() && !self.playlist.is_empty() {
self.selected_playlist_index = self.playlist.len() - 1;
}
}
}
pub fn playlist_page_down(&mut self) {
// Move down by half page (vim Ctrl-D behavior)
let half_page = self.playlist_visible_height / 2;
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
};
// Adjust scroll if needed
if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height {
self.playlist_scroll_offset = self.selected_playlist_index - effective_height + 1;
}
}
pub fn playlist_page_up(&mut self) {
// Move up by half page (vim Ctrl-U behavior)
let half_page = self.playlist_visible_height / 2;
let new_index = self.selected_playlist_index.saturating_sub(half_page);
self.selected_playlist_index = new_index;
// Adjust scroll if needed
if self.selected_playlist_index < self.playlist_scroll_offset {
self.playlist_scroll_offset = self.selected_playlist_index;
} }
} }
pub fn page_down(&mut self) { pub fn page_down(&mut self) {
// Move down by half page (vim Ctrl-D behavior) // Move down by half page (vim Ctrl-D behavior)
let half_page = 10; // Default half page size let half_page = self.file_panel_visible_height / 2;
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 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
};
// Adjust scroll if needed
if self.selected_index >= self.scroll_offset + effective_height {
self.scroll_offset = self.selected_index - effective_height + 1;
}
} }
pub fn page_up(&mut self) { pub fn page_up(&mut self) {
// Move up by half page (vim Ctrl-U behavior) // Move up by half page (vim Ctrl-U behavior)
let half_page = 10; // Default half page size let half_page = self.file_panel_visible_height / 2;
let new_index = self.selected_index.saturating_sub(half_page); let new_index = self.selected_index.saturating_sub(half_page);
self.selected_index = new_index; self.selected_index = new_index;
// Adjust scroll if needed
if self.selected_index < self.scroll_offset {
self.scroll_offset = self.selected_index;
}
} }
pub fn get_selected_item(&self) -> Option<&FlattenedItem> { pub fn get_selected_item(&self) -> Option<&FlattenedItem> {
@ -139,9 +354,13 @@ impl AppState {
let was_expanded = self.expanded_dirs.contains(&path); let was_expanded = self.expanded_dirs.contains(&path);
if was_expanded { if was_expanded {
// Close the expanded folder // Close the expanded folder and keep selection on it
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
if let Some(idx) = self.flattened_items.iter().position(|i| i.node.path == path) {
self.selected_index = idx;
}
} else { } else {
// Folder is collapsed, close parent instead and jump to it // Folder is collapsed, close parent instead and jump to it
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
@ -226,29 +445,20 @@ impl AppState {
pub fn add_to_playlist(&mut self) { pub fn add_to_playlist(&mut self) {
// Add marked files or current selection to playlist // Add marked files or current selection to playlist
if !self.marked_files.is_empty() { if !self.marked_files.is_empty() {
// Add marked files // Add marked files (allow duplicates)
for path in &self.marked_files { let mut files: Vec<PathBuf> = self.marked_files.iter().cloned().collect();
if !self.playlist.contains(path) { files.sort();
self.playlist.push(path.clone()); self.playlist.extend(files);
}
}
self.playlist.sort();
} else if let Some(item) = self.get_selected_item() { } else if let Some(item) = self.get_selected_item() {
let node = item.node.clone(); let node = item.node.clone();
if node.is_dir { if node.is_dir {
// Add all files in directory // Add all files in directory (allow duplicates)
let files = collect_files_from_node(&node); let mut files = collect_files_from_node(&node);
for path in files { files.sort();
if !self.playlist.contains(&path) { self.playlist.extend(files);
self.playlist.push(path);
}
}
self.playlist.sort();
} else { } else {
// Add single file // Add single file (allow duplicates)
if !self.playlist.contains(&node.path) { self.playlist.push(node.path.clone());
self.playlist.push(node.path.clone());
}
} }
} }
} }
@ -260,9 +470,15 @@ impl AppState {
self.playlist = self.marked_files.iter().cloned().collect(); self.playlist = self.marked_files.iter().cloned().collect();
self.playlist.sort(); self.playlist.sort();
self.playlist_index = 0; self.playlist_index = 0;
self.playlist_scroll_offset = 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; 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() { } else if let Some(item) = self.get_selected_item() {
let node = item.node.clone(); let node = item.node.clone();
@ -270,15 +486,23 @@ impl AppState {
// Play all files in directory // Play all files in directory
self.playlist = collect_files_from_node(&node); self.playlist = collect_files_from_node(&node);
self.playlist_index = 0; self.playlist_index = 0;
self.playlist_scroll_offset = 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; self.player_state = PlayerState::Playing;
} else {
// Empty directory
self.current_file = None;
self.player_state = PlayerState::Stopped;
} }
} else { } else {
// Play single file // Play single file
let path = node.path.clone(); 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.selected_playlist_index = 0;
self.current_file = Some(path); self.current_file = Some(path);
self.player_state = PlayerState::Playing; self.player_state = PlayerState::Playing;
} }
@ -286,10 +510,70 @@ impl AppState {
} }
pub fn play_next(&mut self) { pub fn play_next(&mut self) {
if self.playlist_index + 1 < self.playlist.len() { if self.playlist.is_empty() {
self.playlist_index += 1; return;
self.current_file = Some(self.playlist[self.playlist_index].clone()); }
self.player_state = PlayerState::Playing;
match self.play_mode {
PlayMode::Normal => {
// Play through once, stop at end
if self.playlist_index + 1 < self.playlist.len() {
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;
}
} else {
// Reached end, stop
self.player_state = PlayerState::Stopped;
}
}
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;
}
}
}
pub fn cycle_play_mode(&mut self) {
self.play_mode = match self.play_mode {
PlayMode::Normal => PlayMode::Loop,
PlayMode::Loop => PlayMode::Normal,
};
}
pub fn shuffle_playlist(&mut self) {
if self.playlist.is_empty() {
return;
}
use rand::seq::SliceRandom;
let mut rng = rand::thread_rng();
// Remember the currently playing track
let current_track = if self.playlist_index < self.playlist.len() {
Some(self.playlist[self.playlist_index].clone())
} else {
None
};
// Shuffle the playlist
self.playlist.shuffle(&mut rng);
// Find the new position of the currently playing track
if let Some(track) = current_track {
if let Some(new_index) = self.playlist.iter().position(|p| p == &track) {
self.playlist_index = new_index;
self.selected_playlist_index = new_index;
} else {
self.playlist_index = 0;
self.selected_playlist_index = 0;
}
} else {
self.playlist_index = 0;
self.selected_playlist_index = 0;
} }
} }
@ -298,7 +582,7 @@ impl AppState {
self.rebuild_flattened_items(); self.rebuild_flattened_items();
} }
fn rebuild_flattened_items(&mut self) { pub fn rebuild_flattened_items(&mut self) {
self.flattened_items = flatten_tree(&self.cache.file_tree, 0, &self.expanded_dirs); self.flattened_items = flatten_tree(&self.cache.file_tree, 0, &self.expanded_dirs);
if self.selected_index >= self.flattened_items.len() { if self.selected_index >= self.flattened_items.len() {
self.selected_index = self.flattened_items.len().saturating_sub(1); self.selected_index = self.flattened_items.len().saturating_sub(1);
@ -308,16 +592,39 @@ impl AppState {
pub fn enter_search_mode(&mut self) { pub fn enter_search_mode(&mut self) {
self.search_mode = true; self.search_mode = true;
self.search_query.clear(); self.search_query.clear();
self.search_matches.clear();
self.search_match_index = 0; if self.focus_playlist {
self.tab_search_results.clear(); // Clear playlist search state
self.tab_search_index = 0; self.playlist_search_matches.clear();
self.playlist_search_match_index = 0;
self.playlist_tab_search_results.clear();
self.playlist_tab_search_index = 0;
} else {
// Clear file search state
self.search_matches.clear();
self.search_match_index = 0;
self.tab_search_results.clear();
self.tab_search_index = 0;
// Save current folder state
self.saved_expanded_dirs = self.expanded_dirs.clone();
}
} }
pub fn exit_search_mode(&mut self) { pub fn exit_search_mode(&mut self) {
self.search_mode = false; self.search_mode = false;
self.tab_search_results.clear();
self.tab_search_index = 0; if self.focus_playlist {
// Clear playlist search state
self.playlist_tab_search_results.clear();
self.playlist_tab_search_index = 0;
} else {
// Clear file search state
self.tab_search_results.clear();
self.tab_search_index = 0;
// Restore folder state from before search
self.expanded_dirs = self.saved_expanded_dirs.clone();
self.rebuild_flattened_items();
}
} }
pub fn append_search_char(&mut self, c: char) { pub fn append_search_char(&mut self, c: char) {
@ -347,30 +654,55 @@ impl AppState {
return; return;
} }
// Sort by score (highest first) // Add index to preserve original tree order when scores are equal
matching_paths_with_scores.sort_by(|a, b| b.1.cmp(&a.1)); let mut indexed_matches: Vec<(PathBuf, i32, usize)> = matching_paths_with_scores
.into_iter()
.enumerate()
.map(|(idx, (path, score))| (path, score, idx))
.collect();
// Sort by score (highest first), then by original index to prefer first occurrence
indexed_matches.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.2.cmp(&b.2)));
// Store all matches for tab completion // Store all matches for tab completion
self.tab_search_results = matching_paths_with_scores.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;
// Expand parent directories of ALL matches (not just best match) // Close all folders and expand only for the best match
// This ensures folders deep in the tree become visible self.expanded_dirs.clear();
for (path, _) in &matching_paths_with_scores { let best_match = self.tab_search_results[0].clone();
let mut parent = path.parent(); let mut parent = best_match.parent();
while let Some(p) = parent { while let Some(p) = parent {
self.expanded_dirs.insert(p.to_path_buf()); self.expanded_dirs.insert(p.to_path_buf());
parent = p.parent(); parent = p.parent();
}
} }
// Rebuild flattened items // Rebuild flattened items
self.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
let best_match = &self.tab_search_results[0]; 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.node.path == best_match) {
self.selected_index = idx; 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
};
if self.selected_index < self.scroll_offset {
self.scroll_offset = self.selected_index;
} else if self.selected_index >= self.scroll_offset + effective_height {
self.scroll_offset = self.selected_index - effective_height + 1;
}
} }
} }
@ -380,7 +712,7 @@ impl AppState {
return; return;
} }
// Collect all matching paths with scores // Collect all matching paths with scores and preserve order
let mut matching_paths_with_scores = Vec::new(); let mut matching_paths_with_scores = Vec::new();
collect_matching_paths(&self.cache.file_tree, &self.search_query, &mut matching_paths_with_scores); collect_matching_paths(&self.cache.file_tree, &self.search_query, &mut matching_paths_with_scores);
@ -389,35 +721,43 @@ impl AppState {
return; return;
} }
// Sort by score (highest first) // Add index to preserve original tree order when scores are equal
matching_paths_with_scores.sort_by(|a, b| b.1.cmp(&a.1)); let mut indexed_matches: Vec<(PathBuf, i32, usize)> = matching_paths_with_scores
.into_iter()
.enumerate()
.map(|(idx, (path, score))| (path, score, idx))
.collect();
// Sort by score (highest first), then by original index to prefer first occurrence
indexed_matches.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.2.cmp(&b.2)));
let matching_paths_with_scores: Vec<(PathBuf, i32)> = indexed_matches
.into_iter()
.map(|(path, score, _)| (path, score))
.collect();
let matching_paths: Vec<PathBuf> = matching_paths_with_scores.iter().map(|(path, _)| path.clone()).collect(); let matching_paths: Vec<PathBuf> = matching_paths_with_scores.iter().map(|(path, _)| path.clone()).collect();
// Expand all parent directories // Store matching paths (not indices, as they change when folders collapse)
for path in &matching_paths { self.search_matches = matching_paths;
let mut parent = path.parent();
if !self.search_matches.is_empty() {
self.search_match_index = 0;
// Close all folders and expand only for first match
self.expanded_dirs.clear();
let first_match = self.search_matches[0].clone();
let mut parent = first_match.parent();
while let Some(p) = parent { while let Some(p) = parent {
self.expanded_dirs.insert(p.to_path_buf()); self.expanded_dirs.insert(p.to_path_buf());
parent = p.parent(); parent = p.parent();
} }
}
// Rebuild flattened items // Rebuild flattened items
self.rebuild_flattened_items(); self.rebuild_flattened_items();
// Find indices of matches in the flattened list // Find first match in flattened list
self.search_matches = matching_paths if let Some(idx) = self.flattened_items.iter().position(|item| item.node.path == first_match) {
.iter() self.selected_index = idx;
.filter_map(|path| { }
self.flattened_items
.iter()
.position(|item| &item.node.path == path)
})
.collect();
if !self.search_matches.is_empty() {
self.search_match_index = 0;
self.selected_index = self.search_matches[0];
} }
self.search_mode = false; self.search_mode = false;
@ -426,7 +766,43 @@ impl AppState {
pub fn next_search_match(&mut self) { pub fn next_search_match(&mut self) {
if !self.search_matches.is_empty() { if !self.search_matches.is_empty() {
self.search_match_index = (self.search_match_index + 1) % self.search_matches.len(); self.search_match_index = (self.search_match_index + 1) % self.search_matches.len();
self.selected_index = self.search_matches[self.search_match_index]; let target_path = self.search_matches[self.search_match_index].clone();
// Close all folders and expand only for this match
self.expanded_dirs.clear();
let mut parent = target_path.parent();
while let Some(p) = parent {
self.expanded_dirs.insert(p.to_path_buf());
parent = p.parent();
}
// Rebuild flattened items
self.rebuild_flattened_items();
// Find the path in current flattened items
if let Some(idx) = self.flattened_items.iter().position(|item| item.node.path == target_path) {
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
};
if self.selected_index < self.scroll_offset {
self.scroll_offset = self.selected_index;
} else if self.selected_index >= self.scroll_offset + effective_height {
self.scroll_offset = self.selected_index - effective_height + 1;
}
}
} }
} }
@ -437,7 +813,43 @@ impl AppState {
} else { } else {
self.search_match_index -= 1; self.search_match_index -= 1;
} }
self.selected_index = self.search_matches[self.search_match_index]; let target_path = self.search_matches[self.search_match_index].clone();
// Close all folders and expand only for this match
self.expanded_dirs.clear();
let mut parent = target_path.parent();
while let Some(p) = parent {
self.expanded_dirs.insert(p.to_path_buf());
parent = p.parent();
}
// Rebuild flattened items
self.rebuild_flattened_items();
// Find the path in current flattened items
if let Some(idx) = self.flattened_items.iter().position(|item| item.node.path == target_path) {
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
};
if self.selected_index < self.scroll_offset {
self.scroll_offset = self.selected_index;
} else if self.selected_index >= self.scroll_offset + effective_height {
self.scroll_offset = self.selected_index - effective_height + 1;
}
}
} }
} }
@ -450,7 +862,8 @@ impl AppState {
self.tab_search_index = (self.tab_search_index + 1) % self.tab_search_results.len(); self.tab_search_index = (self.tab_search_index + 1) % self.tab_search_results.len();
let next_match = self.tab_search_results[self.tab_search_index].clone(); let next_match = self.tab_search_results[self.tab_search_index].clone();
// Expand parent directories // Close all folders and expand only for this match
self.expanded_dirs.clear();
let mut parent = next_match.parent(); let mut parent = next_match.parent();
while let Some(p) = parent { while let Some(p) = parent {
self.expanded_dirs.insert(p.to_path_buf()); self.expanded_dirs.insert(p.to_path_buf());
@ -463,6 +876,26 @@ impl AppState {
// 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.node.path == next_match) {
self.selected_index = idx; 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
};
if self.selected_index < self.scroll_offset {
self.scroll_offset = self.selected_index;
} else if self.selected_index >= self.scroll_offset + effective_height {
self.scroll_offset = self.selected_index - effective_height + 1;
}
} }
} }
@ -479,7 +912,8 @@ impl AppState {
} }
let prev_match = self.tab_search_results[self.tab_search_index].clone(); let prev_match = self.tab_search_results[self.tab_search_index].clone();
// Expand parent directories // Close all folders and expand only for this match
self.expanded_dirs.clear();
let mut parent = prev_match.parent(); let mut parent = prev_match.parent();
while let Some(p) = parent { while let Some(p) = parent {
self.expanded_dirs.insert(p.to_path_buf()); self.expanded_dirs.insert(p.to_path_buf());
@ -492,6 +926,277 @@ impl AppState {
// 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.node.path == prev_match) {
self.selected_index = idx; 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
};
if self.selected_index < self.scroll_offset {
self.scroll_offset = self.selected_index;
} else if self.selected_index >= self.scroll_offset + effective_height {
self.scroll_offset = self.selected_index - effective_height + 1;
}
}
}
pub fn append_playlist_search_char(&mut self, c: char) {
self.search_query.push(c);
self.perform_playlist_incremental_search();
}
pub fn backspace_playlist_search(&mut self) {
self.search_query.pop();
self.perform_playlist_incremental_search();
}
fn perform_playlist_incremental_search(&mut self) {
if self.search_query.is_empty() {
self.playlist_tab_search_results.clear();
self.playlist_tab_search_index = 0;
return;
}
// Collect all matching indices with scores
let mut matching_indices_with_scores: Vec<(usize, i32)> = self.playlist
.iter()
.enumerate()
.filter_map(|(idx, path)| {
let filename = path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
fuzzy_match(filename, &self.search_query).map(|score| (idx, score))
})
.collect();
if matching_indices_with_scores.is_empty() {
self.playlist_tab_search_results.clear();
self.playlist_tab_search_index = 0;
return;
}
// Sort by score (highest first)
matching_indices_with_scores.sort_by(|a, b| b.1.cmp(&a.1));
// Store all matches for tab completion
self.playlist_tab_search_results = matching_indices_with_scores.iter().map(|(idx, _)| *idx).collect();
self.playlist_tab_search_index = 0;
// Jump to best match
let best_match_idx = self.playlist_tab_search_results[0];
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
};
if self.selected_playlist_index < self.playlist_scroll_offset {
self.playlist_scroll_offset = self.selected_playlist_index;
} else if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height {
self.playlist_scroll_offset = self.selected_playlist_index - effective_height + 1;
}
}
pub fn playlist_tab_search_next(&mut self) {
if self.playlist_tab_search_results.is_empty() {
return;
}
// Cycle to next match
self.playlist_tab_search_index = (self.playlist_tab_search_index + 1) % self.playlist_tab_search_results.len();
let next_match_idx = self.playlist_tab_search_results[self.playlist_tab_search_index];
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
};
if self.selected_playlist_index < self.playlist_scroll_offset {
self.playlist_scroll_offset = self.selected_playlist_index;
} else if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height {
self.playlist_scroll_offset = self.selected_playlist_index - effective_height + 1;
}
}
pub fn playlist_tab_search_prev(&mut self) {
if self.playlist_tab_search_results.is_empty() {
return;
}
// Cycle to previous match
if self.playlist_tab_search_index == 0 {
self.playlist_tab_search_index = self.playlist_tab_search_results.len() - 1;
} else {
self.playlist_tab_search_index -= 1;
}
let prev_match_idx = self.playlist_tab_search_results[self.playlist_tab_search_index];
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
};
if self.selected_playlist_index < self.playlist_scroll_offset {
self.playlist_scroll_offset = self.selected_playlist_index;
} else if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height {
self.playlist_scroll_offset = self.selected_playlist_index - effective_height + 1;
}
}
pub fn execute_playlist_search(&mut self) {
if self.search_query.is_empty() {
self.search_mode = false;
return;
}
// Collect all matching indices with scores
let mut matching_indices_with_scores: Vec<(usize, i32)> = self.playlist
.iter()
.enumerate()
.filter_map(|(idx, path)| {
let filename = path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
fuzzy_match(filename, &self.search_query).map(|score| (idx, score))
})
.collect();
if matching_indices_with_scores.is_empty() {
self.search_mode = false;
return;
}
// Sort by score (highest first)
matching_indices_with_scores.sort_by(|a, b| b.1.cmp(&a.1));
// Store matching indices
self.playlist_search_matches = matching_indices_with_scores.iter().map(|(idx, _)| *idx).collect();
if !self.playlist_search_matches.is_empty() {
self.playlist_search_match_index = 0;
let first_match_idx = self.playlist_search_matches[0];
self.selected_playlist_index = first_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
};
if self.selected_playlist_index < self.playlist_scroll_offset {
self.playlist_scroll_offset = self.selected_playlist_index;
} else if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height {
self.playlist_scroll_offset = self.selected_playlist_index - effective_height + 1;
}
}
self.search_mode = false;
}
pub fn next_playlist_search_match(&mut self) {
if !self.playlist_search_matches.is_empty() {
self.playlist_search_match_index = (self.playlist_search_match_index + 1) % self.playlist_search_matches.len();
let match_idx = self.playlist_search_matches[self.playlist_search_match_index];
self.selected_playlist_index = 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
};
if self.selected_playlist_index < self.playlist_scroll_offset {
self.playlist_scroll_offset = self.selected_playlist_index;
} else if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height {
self.playlist_scroll_offset = self.selected_playlist_index - effective_height + 1;
}
}
}
pub fn prev_playlist_search_match(&mut self) {
if !self.playlist_search_matches.is_empty() {
if self.playlist_search_match_index == 0 {
self.playlist_search_match_index = self.playlist_search_matches.len() - 1;
} else {
self.playlist_search_match_index -= 1;
}
let match_idx = self.playlist_search_matches[self.playlist_search_match_index];
self.selected_playlist_index = 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
};
if self.selected_playlist_index < self.playlist_scroll_offset {
self.playlist_scroll_offset = self.selected_playlist_index;
} else if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height {
self.playlist_scroll_offset = self.selected_playlist_index - effective_height + 1;
}
} }
} }
@ -570,9 +1275,6 @@ fn fuzzy_match(text: &str, query: &str) -> Option<i32> {
} }
} }
// Bonus for shorter strings (better matches)
score += 100 - text_lower.len() as i32;
Some(score) Some(score)
} }

View File

@ -1,16 +1,17 @@
mod theme; mod theme;
use crate::state::{AppState, PlayerState}; use crate::player::Player;
use crate::state::{AppState, PlayerState, ContextMenu, ContextMenuType, PlayMode};
use ratatui::{ use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect}, layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Modifier, Style}, style::{Color, Modifier, Style},
text::{Line, Span}, text::{Line, Span},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Clear},
Frame, Frame,
}; };
use theme::Theme; use theme::Theme;
pub fn render(frame: &mut Frame, state: &mut AppState) { pub fn render(frame: &mut Frame, state: &mut AppState, player: &Player) -> (Rect, Rect, Rect) {
// Clear background // Clear background
frame.render_widget( frame.render_widget(
Block::default().style(Theme::secondary()), Block::default().style(Theme::secondary()),
@ -33,93 +34,303 @@ pub fn render(frame: &mut Frame, state: &mut AppState) {
.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, main_chunks[2]); render_status_bar(frame, state, player, main_chunks[2]);
// Show confirmation popup if needed
if state.show_refresh_confirm {
render_confirm_popup(frame, "Refresh library?", "This may take a while");
}
// Show context menu if needed
if let Some(ref menu) = state.context_menu {
render_context_menu(frame, menu);
}
// Return title bar area, file panel area, and playlist area for mouse event handling
(main_chunks[0], content_chunks[0], content_chunks[1])
}
fn highlight_search_matches<'a>(text: &str, query: &str, is_selected: bool) -> Vec<Span<'a>> {
let query_lower = query.to_lowercase();
let mut spans = Vec::new();
let mut query_chars = query_lower.chars();
let mut current_query_char = query_chars.next();
let mut current_segment = String::new();
for ch in text.chars() {
let ch_lower = ch.to_lowercase().next().unwrap();
if let Some(query_ch) = current_query_char {
if ch_lower == query_ch {
// Found a match - flush current segment
if !current_segment.is_empty() {
spans.push(Span::raw(current_segment.clone()));
current_segment.clear();
}
// Add matched character with styling
if is_selected {
// On selected row: bold black text on selection bar (yellow or blue)
spans.push(Span::styled(
ch.to_string(),
Style::default()
.fg(Theme::background())
.add_modifier(Modifier::BOLD),
));
} else {
// Other rows: just green text
spans.push(Span::styled(
ch.to_string(),
Style::default().fg(Theme::success()),
));
}
// Move to next query character
current_query_char = query_chars.next();
} else {
// No match - add to current segment
current_segment.push(ch);
}
} else {
// No more query characters to match
current_segment.push(ch);
}
}
// Flush remaining segment
if !current_segment.is_empty() {
spans.push(Span::raw(current_segment));
}
spans
} }
fn render_file_panel(frame: &mut Frame, state: &mut AppState, area: Rect) { fn render_file_panel(frame: &mut Frame, state: &mut AppState, area: Rect) {
// Calculate visible height (subtract 2 for borders) // Calculate visible height (subtract 2 for borders)
let visible_height = area.height.saturating_sub(2) as usize; let visible_height = area.height.saturating_sub(2) as usize;
state.update_scroll_offset(visible_height); // Store visible height for keyboard navigation scroll calculations
state.file_panel_visible_height = visible_height;
let items: Vec<ListItem> = state let in_search = !state.focus_playlist && (state.search_mode || !state.search_matches.is_empty());
let search_query = if in_search { state.search_query.to_lowercase() } else { String::new() };
// Calculate how many items are below the visible area
let total_items = state.flattened_items.len();
let visible_end = state.scroll_offset + visible_height;
let items_below = if visible_end < total_items {
total_items - visible_end
} else {
0
};
// Reserve one line for "X more below" if needed
let list_visible_height = if items_below > 0 {
visible_height.saturating_sub(1)
} else {
visible_height
};
let mut items: Vec<ListItem> = state
.flattened_items .flattened_items
.iter() .iter()
.skip(state.scroll_offset)
.take(list_visible_height)
.enumerate() .enumerate()
.map(|(idx, item)| { .map(|(display_idx, item)| {
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.node.path) { "* " } else { "" };
let suffix = if item.node.is_dir { "/" } else { "" };
let text = format!("{}{}{}{}", indent, mark, item.node.name, suffix);
let style = if idx == state.selected_index { // Build name with search highlighting
Theme::selected() // Only show selection bar when file panel has focus
} else if item.node.is_dir { let is_selected = !state.focus_playlist && idx == state.selected_index;
Theme::directory()
// Add icon for directories and files
let icon = if item.node.is_dir {
let is_expanded = state.expanded_dirs.contains(&item.node.path);
// Nerd font folder icons: \u{eaf7} = open, \u{ea83} = closed
let icon_char = if is_expanded { "\u{eaf7} " } else { "\u{ea83} " };
// Bold black icon on selection bar, blue otherwise
if is_selected {
Span::styled(icon_char, Style::default().fg(Theme::background()).add_modifier(Modifier::BOLD))
} else {
Span::styled(icon_char, Style::default().fg(Theme::highlight()))
}
} else {
// File icons based on extension
let extension = item.node.path.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
let (icon_char, color) = match extension.as_str() {
// Audio files - music note icon
"mp3" | "flac" | "wav" | "ogg" | "m4a" | "aac" | "wma" | "opus" =>
("\u{f0e2a} ", Theme::success()), //
// Video files - film icon
"mp4" | "mkv" | "avi" | "mov" | "webm" | "flv" | "wmv" | "m4v" =>
("\u{f1c8} ", Theme::warning()), //
_ => (" ", Theme::foreground()),
};
if is_selected {
Span::styled(icon_char, Style::default().fg(Theme::background()).add_modifier(Modifier::BOLD))
} else {
Span::styled(icon_char, Style::default().fg(color))
}
};
let name_spans = if in_search && !search_query.is_empty() {
highlight_search_matches(&item.node.name, &search_query, is_selected)
} else {
vec![Span::raw(&item.node.name)]
};
let suffix = if item.node.is_dir { "/" } else { "" };
let base_style = if is_selected {
// Selection bar: yellow/orange when in search (typing or viewing results), blue otherwise
if in_search {
Theme::search_selected()
} else {
Theme::selected()
}
} else if state.marked_files.contains(&item.node.path) { } else if state.marked_files.contains(&item.node.path) {
Theme::marked() Theme::marked()
} else { } else {
Theme::secondary() Theme::secondary()
}; };
ListItem::new(text).style(style) let mut line_spans = vec![
Span::raw(indent),
Span::raw(mark),
icon,
];
line_spans.extend(name_spans);
line_spans.push(Span::raw(suffix));
let line = Line::from(line_spans);
ListItem::new(line).style(base_style)
}) })
.collect(); .collect();
// Add "... X more below" message if content was truncated
if items_below > 0 {
let more_text = format!("... {} more below", items_below);
let more_item = ListItem::new(more_text)
.style(Style::default().fg(Theme::dim_foreground()).bg(Theme::background()));
items.push(more_item);
}
let list = List::new(items) let list = List::new(items)
.block( .block(
Block::default() Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
.title("Media Files (Cached)") .title("files")
.style(Theme::widget_border_style()) .style(Theme::widget_border_style())
.title_style(Theme::title_style()), .title_style(Theme::title_style()),
); );
let mut list_state = ListState::default(); let mut list_state = ListState::default();
list_state.select(Some(state.selected_index)); // Don't set selection to avoid automatic scrolling - we manage scroll manually
*list_state.offset_mut() = state.scroll_offset; // Just set the offset (always 0 since we manually slice the items)
*list_state.offset_mut() = 0;
frame.render_stateful_widget(list, area, &mut list_state); frame.render_stateful_widget(list, area, &mut list_state);
} }
fn render_right_panel(frame: &mut Frame, state: &AppState, area: Rect) { fn render_right_panel(frame: &mut Frame, state: &mut AppState, area: Rect) {
// Calculate visible height (subtract 2 for borders)
let visible_height = area.height.saturating_sub(2) as usize;
// Store visible height for keyboard navigation scroll calculations
state.playlist_visible_height = visible_height;
// Calculate how many items are below the visible area
let total_playlist = state.playlist.len();
let visible_end = state.playlist_scroll_offset + visible_height;
let items_below = if visible_end < total_playlist {
total_playlist - visible_end
} else {
0
};
// Reserve one line for "X more below" if needed
let list_visible_height = if items_below > 0 {
visible_height.saturating_sub(1)
} else {
visible_height
};
// Check if in playlist search mode
let in_playlist_search = state.focus_playlist && (state.search_mode || !state.playlist_tab_search_results.is_empty() || !state.playlist_search_matches.is_empty());
let playlist_search_query = if in_playlist_search { state.search_query.to_lowercase() } else { String::new() };
// Playlist panel (no longer need the player status box) // Playlist panel (no longer need the player status box)
let playlist_items: Vec<ListItem> = state let mut playlist_items: Vec<ListItem> = state
.playlist .playlist
.iter() .iter()
.skip(state.playlist_scroll_offset)
.take(list_visible_height)
.enumerate() .enumerate()
.map(|(idx, path)| { .map(|(display_idx, path)| {
let idx = state.playlist_scroll_offset + display_idx;
let filename = path let filename = path
.file_name() .file_name()
.map(|n| n.to_string_lossy().to_string()) .map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| path.to_string_lossy().to_string()); .unwrap_or_else(|| path.to_string_lossy().to_string());
let style = if idx == state.playlist_index { let is_selected = state.focus_playlist && idx == state.selected_playlist_index;
// Color based on player state let is_playing = idx == state.playlist_index;
match state.player_state {
PlayerState::Playing => Style::default() // Build line with search highlighting if searching
.fg(Theme::background()) let line = if in_playlist_search && !playlist_search_query.is_empty() {
.bg(Theme::success()), // Green Line::from(highlight_search_matches(&filename, &playlist_search_query, is_selected))
PlayerState::Paused => Style::default() } else {
.fg(Theme::background()) Line::from(filename)
.bg(Theme::highlight()), // Blue };
PlayerState::Stopped => Style::default()
.fg(Theme::background()) let style = if is_selected && is_playing {
.bg(Theme::warning()), // Yellow/orange // Both selected and playing: selection bar with bold
if in_playlist_search {
Theme::search_selected().add_modifier(Modifier::BOLD)
} else {
Theme::selected().add_modifier(Modifier::BOLD)
} }
} else if is_selected {
// Selection bar when playlist is focused
if in_playlist_search {
Theme::search_selected()
} else {
Theme::selected()
}
} else if is_playing {
// Current playing file: white bold
Style::default()
.fg(Theme::bright_foreground())
.bg(Theme::background())
.add_modifier(Modifier::BOLD)
} else { } else {
Theme::secondary() Theme::secondary()
}; };
ListItem::new(filename).style(style) ListItem::new(line).style(style)
}) })
.collect(); .collect();
// Add "... X more below" message if content was truncated
if items_below > 0 {
let more_text = format!("... {} more below", items_below);
let more_item = ListItem::new(more_text)
.style(Style::default().fg(Theme::dim_foreground()).bg(Theme::background()));
playlist_items.push(more_item);
}
let playlist_title = if !state.playlist.is_empty() { let playlist_title = if !state.playlist.is_empty() {
format!("Playlist [{}/{}]", state.playlist_index + 1, state.playlist.len()) format!("playlist [{}/{}]", state.playlist_index + 1, state.playlist.len())
} else { } else {
"Playlist (empty)".to_string() "playlist (empty)".to_string()
}; };
let playlist_widget = List::new(playlist_items) let playlist_widget = List::new(playlist_items)
@ -132,14 +343,18 @@ fn render_right_panel(frame: &mut Frame, state: &AppState, area: Rect) {
); );
let mut playlist_state = ListState::default(); let mut playlist_state = ListState::default();
if !state.playlist.is_empty() { // Don't set selection to avoid automatic scrolling - we manage scroll manually
playlist_state.select(Some(state.playlist_index)); // Just set the offset (always 0 since we manually slice the items)
} *playlist_state.offset_mut() = 0;
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: &Player, area: Rect) {
let background_color = Theme::success(); // Green for normal operation let background_color = match state.player_state {
PlayerState::Playing => Theme::success(), // Green for playing
PlayerState::Paused => Theme::highlight(), // Blue for paused
PlayerState::Stopped => Theme::dim_foreground(), // Gray for stopped
};
// Split the title bar into left and right sections // Split the title bar into left and right sections
let chunks = Layout::default() let chunks = Layout::default()
@ -193,6 +408,20 @@ fn render_title_bar(frame: &mut Frame, state: &AppState, area: Rect) {
right_spans.push(Span::styled(status_text.to_string(), status_style)); right_spans.push(Span::styled(status_text.to_string(), status_style));
// Play mode indicator
let mode_text = match state.play_mode {
PlayMode::Normal => "",
PlayMode::Loop => " [Loop]",
};
if !mode_text.is_empty() {
right_spans.push(Span::styled(
mode_text.to_string(),
Style::default()
.fg(Theme::background())
.bg(background_color)
));
}
// Progress // Progress
let progress_text = if state.current_duration > 0.0 { let progress_text = if state.current_duration > 0.0 {
let position_mins = (state.current_position / 60.0) as u32; let position_mins = (state.current_position / 60.0) as u32;
@ -216,29 +445,11 @@ fn render_title_bar(frame: &mut Frame, state: &AppState, area: Rect) {
// Volume // Volume
right_spans.push(Span::styled( right_spans.push(Span::styled(
format!(" • Vol: {}%", state.volume), format!(" • Vol: {}% ", state.volume),
Style::default() Style::default()
.fg(Theme::background()) .fg(Theme::background())
.bg(background_color) .bg(background_color)
)); ));
// Add search info if active
if !state.search_matches.is_empty() {
right_spans.push(Span::styled(
format!(" • Search: {}/{} ", state.search_match_index + 1, state.search_matches.len()),
Style::default()
.fg(Theme::background())
.bg(background_color)
.add_modifier(Modifier::BOLD)
));
} else {
right_spans.push(Span::styled(
" ",
Style::default()
.fg(Theme::background())
.bg(background_color)
));
}
} }
let right_title = Paragraph::new(Line::from(right_spans)) let right_title = Paragraph::new(Line::from(right_spans))
@ -247,18 +458,42 @@ 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, area: Rect) { fn render_status_bar(frame: &mut Frame, state: &AppState, player: &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.tab_search_results.is_empty() { let search_text = if state.focus_playlist {
format!("/{}_ Search: {}/{}", state.search_query, state.tab_search_index + 1, state.tab_search_results.len()) // Searching in playlist
} else if !state.search_query.is_empty() { if !state.playlist_tab_search_results.is_empty() {
format!("/{}_ [no matches]", state.search_query) format!("/{}_ Playlist Search: {}/{}", state.search_query, state.playlist_tab_search_index + 1, state.playlist_tab_search_results.len())
} else if !state.search_query.is_empty() {
format!("/{}_ [no matches]", state.search_query)
} else {
format!("/{}_", state.search_query)
}
} else { } else {
format!("/{}_", state.search_query) // Searching in file panel
if !state.tab_search_results.is_empty() {
format!("/{}_ Search: {}/{}", state.search_query, state.tab_search_index + 1, state.tab_search_results.len())
} else if !state.search_query.is_empty() {
format!("/{}_ [no matches]", state.search_query)
} else {
format!("/{}_", state.search_query)
}
}; };
let status_bar = Paragraph::new(search_text) let status_bar = Paragraph::new(search_text)
.style(Style::default().fg(Theme::foreground()).bg(Theme::background())); .style(Style::default().fg(Color::White).bg(Theme::background()));
frame.render_widget(status_bar, area);
} else if !state.search_matches.is_empty() {
// Show search navigation when file search results are active
let search_text = format!("/{} Search: {}/{}", state.search_query, state.search_match_index + 1, state.search_matches.len());
let status_bar = Paragraph::new(search_text)
.style(Style::default().fg(Color::White).bg(Theme::background()));
frame.render_widget(status_bar, area);
} else if !state.playlist_search_matches.is_empty() {
// Show search navigation when playlist search results are active
let search_text = format!("/{} Playlist Search: {}/{}", state.search_query, state.playlist_search_match_index + 1, state.playlist_search_matches.len());
let status_bar = Paragraph::new(search_text)
.style(Style::default().fg(Color::White).bg(Theme::background()));
frame.render_widget(status_bar, area); frame.render_widget(status_bar, area);
} else if state.visual_mode { } else if state.visual_mode {
// Show visual mode indicator // Show visual mode indicator
@ -267,11 +502,192 @@ fn render_status_bar(frame: &mut Frame, state: &AppState, area: Rect) {
.style(Style::default().fg(Theme::foreground()).bg(Theme::background())); .style(Style::default().fg(Theme::foreground()).bg(Theme::background()));
frame.render_widget(status_bar, area); frame.render_widget(status_bar, area);
} else { } else {
// Normal mode shortcuts (always shown when not in search or visual mode) // Normal mode: show media metadata if playing
let shortcuts = "/: Search • v: Visual • a: Add • c: Clear • Enter: Play • Space: Pause • s: Stop • ←→: Seek • +/-: Vol • n/p: Next/Prev • r: Rescan • q: Quit"; // Split into left (artist/album/title) and right (technical info)
let status_bar = Paragraph::new(shortcuts)
let mut left_parts = Vec::new();
let mut right_parts = Vec::new();
// Left side: Artist | Album | Title
if let Some(ref artist) = player.artist {
left_parts.push(artist.clone());
}
if let Some(ref album) = player.album {
left_parts.push(album.clone());
}
if let Some(ref title) = player.media_title {
left_parts.push(title.clone());
}
// Right side: Bitrate | Codec | Sample rate | Cache
if let Some(bitrate) = player.audio_bitrate {
right_parts.push(format!("{:.0} kbps", bitrate));
}
if let Some(ref codec) = player.audio_codec {
right_parts.push(codec.to_uppercase());
}
if let Some(samplerate) = player.sample_rate {
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)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(area);
// Left side text
let left_text = if !left_parts.is_empty() {
format!(" {}", left_parts.join(" | "))
} else {
String::new()
};
let left_bar = Paragraph::new(left_text)
.style(Style::default().fg(Theme::muted_text()).bg(Theme::background())) .style(Style::default().fg(Theme::muted_text()).bg(Theme::background()))
.alignment(Alignment::Center); .alignment(Alignment::Left);
frame.render_widget(status_bar, area); frame.render_widget(left_bar, chunks[0]);
// Right side text
let right_text = if !right_parts.is_empty() {
format!("{} ", right_parts.join(" | "))
} else {
String::new()
};
let right_bar = Paragraph::new(right_text)
.style(Style::default().fg(Theme::muted_text()).bg(Theme::background()))
.alignment(Alignment::Right);
frame.render_widget(right_bar, chunks[1]);
} }
} }
fn render_confirm_popup(frame: &mut Frame, title: &str, message: &str) {
// Create centered popup area
let area = frame.area();
let popup_width = 52;
let popup_height = 7;
let popup_area = Rect {
x: (area.width.saturating_sub(popup_width)) / 2,
y: (area.height.saturating_sub(popup_height)) / 2,
width: popup_width.min(area.width),
height: popup_height.min(area.height),
};
// Use Clear widget to completely erase the background
frame.render_widget(Clear, popup_area);
// Render the popup block with solid background
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.style(Style::default()
.bg(Theme::background())
.fg(Theme::bright_foreground()))
.title_style(Style::default()
.fg(Theme::bright_foreground())
.add_modifier(Modifier::BOLD));
let inner = block.inner(popup_area);
frame.render_widget(block, popup_area);
// Render message and prompt
let text_area = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // Empty line
Constraint::Length(1), // Message
Constraint::Length(1), // Empty line
Constraint::Length(1), // Prompt
])
.split(inner);
let message_text = Paragraph::new(message)
.style(Style::default()
.fg(Theme::bright_foreground())
.bg(Theme::background()))
.alignment(Alignment::Center);
frame.render_widget(message_text, text_area[1]);
let prompt_text = Paragraph::new("Press 'y' to confirm or 'n' to cancel")
.style(Style::default()
.fg(Theme::foreground())
.bg(Theme::background()))
.alignment(Alignment::Center);
frame.render_widget(prompt_text, text_area[3]);
}
fn render_context_menu(frame: &mut Frame, menu: &ContextMenu) {
// Determine menu items based on type
let items = match menu.menu_type {
ContextMenuType::FilePanel => vec!["Play", "Add"],
ContextMenuType::Playlist => vec!["Remove", "Randomise"],
ContextMenuType::TitleBar => vec!["Stop", "Loop", "Refresh"],
};
// Calculate popup size
let width = 13;
let height = items.len() as u16 + 2; // +2 for borders
// Position popup near click location, but keep it on screen
let screen_width = frame.area().width;
let screen_height = frame.area().height;
let x = if menu.x + width < screen_width {
menu.x
} else {
screen_width.saturating_sub(width)
};
let y = if menu.y + height < screen_height {
menu.y
} else {
screen_height.saturating_sub(height)
};
let popup_area = Rect {
x,
y,
width,
height,
};
// Create menu items with selection highlight
let menu_items: Vec<ListItem> = items
.iter()
.enumerate()
.map(|(i, item)| {
let style = if i == menu.selected_index {
Style::default()
.fg(Color::Black)
.bg(Color::White)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Theme::bright_foreground())
};
ListItem::new(*item).style(style)
})
.collect();
let menu_list = List::new(menu_items)
.block(
Block::default()
.borders(Borders::ALL)
.style(Style::default().bg(Theme::background()).fg(Theme::bright_foreground()))
);
// Clear the area and render menu
frame.render_widget(Clear, popup_area);
frame.render_widget(menu_list, popup_area);
}

View File

@ -86,15 +86,15 @@ impl Theme {
.bg(Self::highlight()) .bg(Self::highlight())
} }
pub fn directory() -> Style {
Style::default()
.fg(Self::normal_blue())
.bg(Self::background())
}
pub fn marked() -> Style { pub fn marked() -> Style {
Style::default() Style::default()
.fg(Self::warning()) .fg(Self::warning())
.bg(Self::background()) .bg(Self::background())
} }
pub fn search_selected() -> Style {
Style::default()
.fg(Self::background())
.bg(Self::warning())
}
} }