Compare commits

...

9 Commits
v0.1.9 ... main

Author SHA1 Message Date
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
6 changed files with 2005 additions and 234 deletions

View File

@ -1,6 +1,6 @@
[package]
name = "cm-player"
version = "0.1.9"
version = "0.1.18"
edition = "2021"
[dependencies]
@ -31,6 +31,9 @@ thiserror = "1.0"
tracing = "0.1"
tracing-subscriber = "0.3"
# Random
rand = "0.8"
[profile.release]
opt-level = 3
lto = true

View File

@ -7,7 +7,7 @@ mod ui;
use anyhow::{Context, Result};
use crossterm::{
event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent, MouseEventKind, EnableMouseCapture, DisableMouseCapture},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
@ -56,7 +56,7 @@ async fn main() -> Result<()> {
// Setup terminal
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
@ -67,52 +67,194 @@ async fn main() -> Result<()> {
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
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)?;
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.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(())
}
async fn run_app<B: ratatui::backend::Backend>(
terminal: &mut Terminal<B>,
state: &mut AppState,
player: &mut player::Player,
) -> 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 {
let mut state_changed = false;
// Check if mpv process died (e.g., user closed video window)
if !player.is_process_alive() && state.player_state != PlayerState::Stopped {
state.player_state = PlayerState::Stopped;
state.current_position = 0.0;
state.current_duration = 0.0;
state_changed = true;
}
// Update player properties from MPV
player.update_properties();
// Only update properties when playing or paused (not when stopped)
if state.player_state != PlayerState::Stopped {
player.update_properties();
// Update position and duration from player
state.current_position = player.get_position().unwrap_or(0.0);
state.current_duration = player.get_duration().unwrap_or(0.0);
// Update metadata only every 20 iterations (~2 seconds) to reduce IPC calls
metadata_update_counter += 1;
if metadata_update_counter >= 20 {
player.update_metadata();
metadata_update_counter = 0;
state_changed = true;
}
// Check if track ended and play next (but only if track was actually loaded)
if player.is_idle() && state.player_state == PlayerState::Playing && state.current_duration > 0.0 {
if state.playlist_index + 1 < state.playlist.len() {
// Update position and duration from player
let new_position = player.get_position().unwrap_or(0.0);
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)
if player.is_idle() && state.player_state == PlayerState::Playing && state.current_duration > 0.0 && state.current_position > 0.5 {
state.play_next();
if let Some(ref path) = state.current_file {
player.play(path)?;
// play_next() handles the play mode and may stop if in Normal mode at end
if state.player_state == PlayerState::Playing {
if let Some(ref path) = state.current_file {
// Reset position/duration before playing new track
state.current_position = 0.0;
state.current_duration = 0.0;
last_position = 0.0;
player.play(path)?;
}
// Update metadata immediately when track changes
player.update_metadata();
metadata_update_counter = 0;
// Auto-scroll playlist to show current track (only if user is not browsing playlist)
if !state.focus_playlist {
let playlist_visible_height = playlist_area.height.saturating_sub(2) as usize;
state.update_playlist_scroll(playlist_visible_height);
}
}
} else {
state.player_state = PlayerState::Stopped;
state_changed = true;
}
}
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))? {
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
handle_key_event(terminal, state, player, key).await?;
// Poll for events - use longer timeout when stopped to reduce CPU
let poll_duration = if state.player_state == PlayerState::Stopped {
std::time::Duration::from_millis(200) // 5 FPS when stopped
} 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
}
_ => {}
}
}
@ -125,23 +267,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<()> {
// 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
if state.search_mode {
match key.code {
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 => {
state.backspace_search();
if state.focus_playlist {
state.backspace_playlist_search();
} else {
state.backspace_search();
}
}
KeyCode::Tab => {
state.tab_search_next();
if state.focus_playlist {
state.playlist_tab_search_next();
} else {
state.tab_search_next();
}
}
KeyCode::BackTab => {
state.tab_search_prev();
if state.focus_playlist {
state.playlist_tab_search_prev();
} else {
state.tab_search_prev();
}
}
KeyCode::Enter => {
state.execute_search();
if state.focus_playlist {
state.execute_playlist_search();
} else {
state.execute_search();
}
}
KeyCode::Esc => {
state.exit_search_mode();
@ -151,6 +337,97 @@ async fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<
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;
match menu_type {
ContextMenuType::FilePanel => {
match selected {
0 => action_play_selection(state, player)?,
1 => state.add_to_playlist(),
_ => {}
}
}
ContextMenuType::Playlist => {
match selected {
0 => {
// Remove
let was_playing_removed = state.playlist_index == state.selected_playlist_index;
state.remove_selected_playlist_item();
// Handle edge cases after removal
if state.playlist.is_empty() {
state.player_state = PlayerState::Stopped;
state.current_file = None;
player.stop()?;
} else if was_playing_removed && state.player_state == PlayerState::Playing {
// Removed currently playing track, start new one at same index
state.current_file = Some(state.playlist[state.playlist_index].clone());
if let Some(ref path) = state.current_file {
player.play(path)?;
player.update_metadata();
}
}
}
1 => {
// Randomise
state.shuffle_playlist();
tracing::info!("Playlist randomised from context menu");
}
_ => {}
}
}
ContextMenuType::TitleBar => {
match selected {
0 => {
// Stop
action_stop(state, player)?;
}
1 => {
// Toggle Loop
state.cycle_play_mode();
tracing::info!("Play mode: {:?}", state.play_mode);
}
2 => {
// Refresh
state.show_refresh_confirm = true;
tracing::info!("Refresh requested from context menu");
}
_ => {}
}
}
}
}
KeyCode::Esc => {
state.context_menu = None;
}
_ => {}
}
return Ok(());
}
match (key.code, key.modifiers) {
(KeyCode::Char('q'), _) => {
state.should_quit = true;
@ -167,6 +444,10 @@ async fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<
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 {
state.visual_mode = false;
state.marked_files.clear();
@ -175,38 +456,51 @@ async fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<
(KeyCode::Char('n'), _) => {
if !state.search_matches.is_empty() {
state.next_search_match();
} else if !state.playlist_search_matches.is_empty() {
state.next_playlist_search_match();
}
}
(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;
state.current_file = Some(state.playlist[state.playlist_index].clone());
// 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)?;
tracing::info!("Next track: {:?}", path);
match state.player_state {
PlayerState::Playing => {
// Keep playing
if let Some(ref path) = state.current_file {
player.play(path)?;
player.update_metadata(); // Update metadata immediately
tracing::info!("Next track: {:?}", path);
}
}
PlayerState::Paused => {
// Load but stay paused
if let Some(ref path) = state.current_file {
player.play(path)?;
player.update_metadata(); // Update metadata immediately
player.pause()?;
tracing::info!("Next track (paused): {:?}", path);
}
}
PlayerState::Stopped => {
// Just update current file, stay stopped
tracing::info!("Next track selected (stopped): {:?}", state.current_file);
}
}
PlayerState::Paused => {
// Load but stay paused
if let Some(ref path) = state.current_file {
player.play(path)?;
player.pause()?;
tracing::info!("Next track (paused): {:?}", path);
}
}
PlayerState::Stopped => {
// Just update current file, stay stopped
tracing::info!("Next track selected (stopped): {:?}", state.current_file);
// Auto-scroll playlist to show current track (only if user is not browsing playlist)
if !state.focus_playlist {
state.update_playlist_scroll(20);
}
}
}
@ -215,106 +509,153 @@ async fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<
// Previous track
if !state.playlist.is_empty() && state.playlist_index > 0 {
state.playlist_index -= 1;
state.current_file = Some(state.playlist[state.playlist_index].clone());
// 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)?;
tracing::info!("Previous track: {:?}", path);
match state.player_state {
PlayerState::Playing => {
// Keep playing
if let Some(ref path) = state.current_file {
player.play(path)?;
player.update_metadata(); // Update metadata immediately
tracing::info!("Previous track: {:?}", path);
}
}
PlayerState::Paused => {
// Load but stay paused
if let Some(ref path) = state.current_file {
player.play(path)?;
player.update_metadata(); // Update metadata immediately
player.pause()?;
tracing::info!("Previous track (paused): {:?}", path);
}
}
PlayerState::Stopped => {
// Just update current file, stay stopped
tracing::info!("Previous track selected (stopped): {:?}", state.current_file);
}
}
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);
// 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) => {
state.page_down();
if state.focus_playlist {
state.playlist_page_down();
} else {
state.page_down();
}
}
(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, _) => {
state.move_selection_up();
if state.focus_playlist {
state.move_playlist_selection_up();
} else {
state.move_selection_up();
}
}
(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'), _) => {
state.collapse_selected();
if !state.focus_playlist {
state.collapse_selected();
}
}
(KeyCode::Char('l'), _) => {
state.expand_selected();
if !state.focus_playlist {
state.expand_selected();
}
}
(KeyCode::Char('v'), _) => {
state.toggle_mark();
if !state.focus_playlist {
state.toggle_mark();
}
}
(KeyCode::Char('a'), _) => {
state.add_to_playlist();
if state.visual_mode {
state.visual_mode = false;
state.marked_files.clear();
if !state.focus_playlist {
state.add_to_playlist();
if state.visual_mode {
state.visual_mode = false;
state.marked_files.clear();
}
}
}
(KeyCode::Char('c'), _) => {
state.clear_playlist();
}
(KeyCode::Enter, _) => {
state.play_selection();
if let Some(ref path) = state.current_file {
player.play(path)?;
tracing::info!("Playing: {:?} (playlist: {} tracks)", path, state.playlist.len());
}
if state.visual_mode {
state.visual_mode = false;
state.marked_files.clear();
}
}
(KeyCode::Char('s'), _) => {
// s: Stop playback
player.stop()?;
state.player_state = PlayerState::Stopped;
state.current_position = 0.0;
state.current_duration = 0.0;
tracing::info!("Stopped");
}
(KeyCode::Char(' '), _) => {
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() {
(KeyCode::Char('d'), _) => {
if state.focus_playlist {
// Remove selected track from playlist
state.remove_selected_playlist_item();
// If removed currently playing track, handle it
if state.playlist.is_empty() {
state.player_state = PlayerState::Stopped;
state.current_file = None;
player.stop()?;
} else if state.playlist_index == state.selected_playlist_index {
// Removed currently playing track, play next one
if state.playlist_index < state.playlist.len() {
state.current_file = Some(state.playlist[state.playlist_index].clone());
state.player_state = PlayerState::Playing;
if let Some(ref path) = state.current_file {
player.play(path)?;
tracing::info!("Restarting playback: {:?}", path);
if state.player_state == PlayerState::Playing {
if let Some(ref path) = state.current_file {
player.play(path)?;
player.update_metadata();
}
}
}
}
}
}
(KeyCode::Enter, _) => {
if state.focus_playlist {
// Play selected track from playlist
if state.selected_playlist_index < state.playlist.len() {
state.playlist_index = state.selected_playlist_index;
state.current_file = Some(state.playlist[state.playlist_index].clone());
state.player_state = PlayerState::Playing;
if let Some(ref path) = state.current_file {
player.play(path)?;
player.update_metadata();
tracing::info!("Playing from playlist: {:?}", path);
}
}
} else {
action_play_selection(state, player)?;
}
}
(KeyCode::Char('s'), _) => {
action_stop(state, player)?;
}
(KeyCode::Char('m'), _) => {
state.cycle_play_mode();
tracing::info!("Play mode: {:?}", state.play_mode);
}
(KeyCode::Char('R'), KeyModifiers::SHIFT) => {
state.shuffle_playlist();
tracing::info!("Playlist shuffled");
}
(KeyCode::Char(' '), _) => {
action_toggle_play_pause(state, player)?;
}
(KeyCode::Char('H'), KeyModifiers::SHIFT) => {
if state.player_state != PlayerState::Stopped {
player.seek(-10.0)?;
@ -340,19 +681,381 @@ async fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<
tracing::info!("Volume: {}%", new_volume);
}
(KeyCode::Char('r'), _) => {
state.is_refreshing = 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");
state.show_refresh_confirm = true;
}
_ => {}
}
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;
match menu_type {
ContextMenuType::FilePanel => {
match selected {
0 => action_play_selection(state, player)?,
1 => state.add_to_playlist(),
_ => {}
}
}
ContextMenuType::Playlist => {
match selected {
0 => {
// Remove
let was_playing_removed = state.playlist_index == state.selected_playlist_index;
state.remove_selected_playlist_item();
// Handle edge cases after removal
if state.playlist.is_empty() {
state.player_state = PlayerState::Stopped;
state.current_file = None;
player.stop()?;
} else if was_playing_removed && state.player_state == PlayerState::Playing {
state.current_file = Some(state.playlist[state.playlist_index].clone());
if let Some(ref path) = state.current_file {
player.play(path)?;
player.update_metadata();
}
}
}
1 => {
// Randomise
state.shuffle_playlist();
tracing::info!("Playlist randomised from context menu (mouse)");
}
_ => {}
}
}
ContextMenuType::TitleBar => {
match selected {
0 => {
// Stop
action_stop(state, player)?;
}
1 => {
// Toggle Loop
state.cycle_play_mode();
tracing::info!("Play mode: {:?} (mouse)", state.play_mode);
}
2 => {
// Refresh
state.show_refresh_confirm = true;
tracing::info!("Refresh requested from context menu (mouse)");
}
_ => {}
}
}
}
}
return Ok(());
} 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
state.playlist_index = actual_track;
state.current_file = Some(state.playlist[state.playlist_index].clone());
match state.player_state {
PlayerState::Playing => {
// Keep playing
if let Some(ref path) = state.current_file {
player.play(path)?;
player.update_metadata();
tracing::info!("Jumped to track: {:?}", path);
}
}
PlayerState::Paused => {
// Load but stay paused
if let Some(ref path) = state.current_file {
player.play(path)?;
player.update_metadata();
player.pause()?;
tracing::info!("Jumped to track (paused): {:?}", path);
}
}
PlayerState::Stopped => {
// Start playing from clicked track
state.player_state = PlayerState::Playing;
if let Some(ref path) = state.current_file {
player.play(path)?;
player.update_metadata();
tracing::info!("Started playing track: {:?}", path);
}
}
}
// 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,
is_paused: bool,
is_idle: bool,
pub media_title: Option<String>,
pub artist: Option<String>,
pub album: Option<String>,
pub audio_codec: Option<String>,
pub audio_bitrate: Option<f64>,
pub sample_rate: Option<i64>,
pub cache_duration: Option<f64>,
}
impl Player {
@ -50,6 +57,13 @@ impl Player {
duration: 0.0,
is_paused: false,
is_idle: true,
media_title: None,
artist: None,
album: None,
audio_codec: None,
audio_bitrate: None,
sample_rate: None,
cache_duration: None,
})
}
@ -101,6 +115,13 @@ impl Player {
self.position = 0.0;
self.duration = 0.0;
self.is_paused = false;
self.media_title = None;
self.artist = None;
self.album = None;
self.audio_codec = None;
self.audio_bitrate = None;
self.sample_rate = None;
self.cache_duration = None;
// Wait for socket to be created and mpv to be ready
std::thread::sleep(Duration::from_millis(800));
@ -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> {
Some(self.position)
}

View File

@ -2,6 +2,7 @@ use crate::cache::{Cache, FileTreeNode};
use crate::config::Config;
use std::collections::HashSet;
use std::path::PathBuf;
use std::time::Instant;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlayerState {
@ -10,11 +11,34 @@ pub enum PlayerState {
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 cache: Cache,
pub config: Config,
pub selected_index: usize,
pub scroll_offset: usize,
pub file_panel_visible_height: usize,
pub playlist_visible_height: usize,
pub player_state: PlayerState,
pub current_file: Option<PathBuf>,
pub current_position: f64,
@ -26,6 +50,8 @@ pub struct AppState {
pub marked_files: HashSet<PathBuf>,
pub playlist: Vec<PathBuf>,
pub playlist_index: usize,
pub playlist_scroll_offset: usize,
pub selected_playlist_index: usize,
pub is_refreshing: bool,
pub search_mode: bool,
pub search_query: String,
@ -33,9 +59,20 @@ pub struct AppState {
pub search_match_index: usize,
pub tab_search_results: Vec<PathBuf>,
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_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)]
@ -56,6 +93,8 @@ impl AppState {
config,
selected_index: 0,
scroll_offset: 0,
file_panel_visible_height: 20,
playlist_visible_height: 20,
player_state: PlayerState::Stopped,
current_file: None,
current_position: 0.0,
@ -67,6 +106,8 @@ impl AppState {
marked_files: HashSet::new(),
playlist: Vec::new(),
playlist_index: 0,
playlist_scroll_offset: 0,
selected_playlist_index: 0,
is_refreshing: false,
search_mode: false,
search_query: String::new(),
@ -74,9 +115,20 @@ impl AppState {
search_match_index: 0,
tab_search_results: Vec::new(),
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_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,
}
}
@ -97,6 +149,24 @@ impl AppState {
pub fn move_selection_down(&mut self) {
if self.selected_index < self.flattened_items.len().saturating_sub(1) {
self.selected_index += 1;
// Account for "... X more below" indicator which takes one line
let visible_end = self.scroll_offset + self.file_panel_visible_height;
let items_below = if visible_end < self.flattened_items.len() {
self.flattened_items.len() - visible_end
} else {
0
};
let effective_height = if items_below > 0 {
self.file_panel_visible_height.saturating_sub(1)
} else {
self.file_panel_visible_height
};
// 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
if self.visual_mode {
self.update_visual_selection();
@ -104,29 +174,172 @@ impl AppState {
}
}
pub fn update_scroll_offset(&mut self, visible_height: usize) {
// Scroll down when selection reaches bottom
if self.selected_index >= self.scroll_offset + visible_height {
self.scroll_offset = self.selected_index - visible_height + 1;
pub fn scroll_view_up(&mut self) {
// Scroll view up without changing selection
if self.scroll_offset > 0 {
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) {
// 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));
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) {
// 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);
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> {
@ -232,29 +445,20 @@ impl AppState {
pub fn add_to_playlist(&mut self) {
// Add marked files or current selection to playlist
if !self.marked_files.is_empty() {
// Add marked files
for path in &self.marked_files {
if !self.playlist.contains(path) {
self.playlist.push(path.clone());
}
}
self.playlist.sort();
// Add marked files (allow duplicates)
let mut files: Vec<PathBuf> = self.marked_files.iter().cloned().collect();
files.sort();
self.playlist.extend(files);
} else if let Some(item) = self.get_selected_item() {
let node = item.node.clone();
if node.is_dir {
// Add all files in directory
let files = collect_files_from_node(&node);
for path in files {
if !self.playlist.contains(&path) {
self.playlist.push(path);
}
}
self.playlist.sort();
// Add all files in directory (allow duplicates)
let mut files = collect_files_from_node(&node);
files.sort();
self.playlist.extend(files);
} else {
// Add single file
if !self.playlist.contains(&node.path) {
self.playlist.push(node.path.clone());
}
// Add single file (allow duplicates)
self.playlist.push(node.path.clone());
}
}
}
@ -266,9 +470,15 @@ impl AppState {
self.playlist = self.marked_files.iter().cloned().collect();
self.playlist.sort();
self.playlist_index = 0;
self.playlist_scroll_offset = 0;
self.selected_playlist_index = 0;
if let Some(first) = self.playlist.first() {
self.current_file = Some(first.clone());
self.player_state = PlayerState::Playing;
} else {
// Empty playlist
self.current_file = None;
self.player_state = PlayerState::Stopped;
}
} else if let Some(item) = self.get_selected_item() {
let node = item.node.clone();
@ -276,15 +486,23 @@ impl AppState {
// Play all files in directory
self.playlist = collect_files_from_node(&node);
self.playlist_index = 0;
self.playlist_scroll_offset = 0;
self.selected_playlist_index = 0;
if let Some(first) = self.playlist.first() {
self.current_file = Some(first.clone());
self.player_state = PlayerState::Playing;
} else {
// Empty directory
self.current_file = None;
self.player_state = PlayerState::Stopped;
}
} else {
// Play single file
let path = node.path.clone();
self.playlist = vec![path.clone()];
self.playlist_index = 0;
self.playlist_scroll_offset = 0;
self.selected_playlist_index = 0;
self.current_file = Some(path);
self.player_state = PlayerState::Playing;
}
@ -292,10 +510,70 @@ impl AppState {
}
pub fn play_next(&mut self) {
if self.playlist_index + 1 < self.playlist.len() {
self.playlist_index += 1;
self.current_file = Some(self.playlist[self.playlist_index].clone());
self.player_state = PlayerState::Playing;
if self.playlist.is_empty() {
return;
}
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;
}
}
@ -314,21 +592,39 @@ impl AppState {
pub fn enter_search_mode(&mut self) {
self.search_mode = true;
self.search_query.clear();
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();
if self.focus_playlist {
// Clear playlist search state
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) {
self.search_mode = false;
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();
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) {
@ -387,6 +683,26 @@ impl AppState {
// Find the best match in the flattened list and jump to it
if let Some(idx) = self.flattened_items.iter().position(|item| item.node.path == best_match) {
self.selected_index = idx;
// Scroll to show the match
// Account for "... X more below" indicator which takes one line
let visible_end = self.scroll_offset + self.file_panel_visible_height;
let items_below = if visible_end < self.flattened_items.len() {
self.flattened_items.len() - visible_end
} else {
0
};
let effective_height = if items_below > 0 {
self.file_panel_visible_height.saturating_sub(1)
} else {
self.file_panel_visible_height
};
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;
}
}
}
@ -466,6 +782,26 @@ impl AppState {
// 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;
}
}
}
}
@ -493,6 +829,26 @@ impl AppState {
// 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;
}
}
}
}
@ -520,6 +876,26 @@ impl AppState {
// Find and select the match
if let Some(idx) = self.flattened_items.iter().position(|item| item.node.path == next_match) {
self.selected_index = idx;
// Scroll to show the match
// Account for "... X more below" indicator which takes one line
let visible_end = self.scroll_offset + self.file_panel_visible_height;
let items_below = if visible_end < self.flattened_items.len() {
self.flattened_items.len() - visible_end
} else {
0
};
let effective_height = if items_below > 0 {
self.file_panel_visible_height.saturating_sub(1)
} else {
self.file_panel_visible_height
};
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;
}
}
}
@ -550,6 +926,277 @@ impl AppState {
// Find and select the match
if let Some(idx) = self.flattened_items.iter().position(|item| item.node.path == prev_match) {
self.selected_index = idx;
// Scroll to show the match
// Account for "... X more below" indicator which takes one line
let visible_end = self.scroll_offset + self.file_panel_visible_height;
let items_below = if visible_end < self.flattened_items.len() {
self.flattened_items.len() - visible_end
} else {
0
};
let effective_height = if items_below > 0 {
self.file_panel_visible_height.saturating_sub(1)
} else {
self.file_panel_visible_height
};
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;
}
}
}

View File

@ -1,16 +1,17 @@
mod theme;
use crate::state::{AppState, PlayerState};
use crate::player::Player;
use crate::state::{AppState, PlayerState, ContextMenu, ContextMenuType, PlayMode};
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Clear},
Frame,
};
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
frame.render_widget(
Block::default().style(Theme::secondary()),
@ -33,13 +34,26 @@ pub fn render(frame: &mut Frame, state: &mut AppState) {
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(main_chunks[1]);
render_title_bar(frame, state, main_chunks[0]);
render_title_bar(frame, state, player, main_chunks[0]);
render_file_panel(frame, state, content_chunks[0]);
render_right_panel(frame, state, content_chunks[1]);
render_status_bar(frame, state, 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, search_typing: bool, is_selected: bool) -> Vec<Span<'a>> {
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();
@ -57,17 +71,9 @@ fn highlight_search_matches<'a>(text: &str, query: &str, search_typing: bool, is
spans.push(Span::raw(current_segment.clone()));
current_segment.clear();
}
// Add matched character with styling based on mode
if search_typing && is_selected {
// While typing on selected row: green bg with black fg (inverted)
spans.push(Span::styled(
ch.to_string(),
Style::default()
.fg(Theme::background())
.bg(Theme::success()),
));
} else if !search_typing && is_selected {
// After Enter on selected row: bold black text on blue selection bar
// 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()
@ -104,52 +110,93 @@ fn highlight_search_matches<'a>(text: &str, query: &str, search_typing: bool, is
fn render_file_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;
state.update_scroll_offset(visible_height);
// Store visible height for keyboard navigation scroll calculations
state.file_panel_visible_height = visible_height;
let in_search = state.search_mode || !state.search_matches.is_empty();
let search_typing = state.search_mode; // True when actively typing search
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() };
let items: Vec<ListItem> = state
// 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
.iter()
.skip(state.scroll_offset)
.take(list_visible_height)
.enumerate()
.map(|(idx, item)| {
.map(|(display_idx, item)| {
let idx = state.scroll_offset + display_idx;
let indent = " ".repeat(item.depth);
let mark = if state.marked_files.contains(&item.node.path) { "* " } else { "" };
// Build name with search highlighting
let is_selected = idx == state.selected_index;
// Only show selection bar when file panel has focus
let is_selected = !state.focus_playlist && idx == state.selected_index;
// Add folder icon for directories
// 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 !search_typing && is_selected {
Span::styled("", Style::default().fg(Theme::background()).add_modifier(Modifier::BOLD))
if is_selected {
Span::styled(icon_char, Style::default().fg(Theme::background()).add_modifier(Modifier::BOLD))
} else {
Span::styled("", Style::default().fg(Theme::highlight()))
Span::styled(icon_char, Style::default().fg(Theme::highlight()))
}
} else {
Span::raw(" ")
// 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, search_typing, is_selected)
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 search_typing {
// While typing search: no selection bar, just normal colors
if state.marked_files.contains(&item.node.path) {
Theme::marked()
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::secondary()
Theme::selected()
}
} else if is_selected {
// After pressing Enter or normal mode: normal blue selection bar
Theme::selected()
} else if state.marked_files.contains(&item.node.path) {
Theme::marked()
} else {
@ -170,39 +217,96 @@ fn render_file_panel(frame: &mut Frame, state: &mut AppState, area: Rect) {
})
.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)
.block(
Block::default()
.borders(Borders::ALL)
.title("Media Files (Cached)")
.title("files")
.style(Theme::widget_border_style())
.title_style(Theme::title_style()),
);
let mut list_state = ListState::default();
// Don't show selection bar widget while typing search - we use inverted colors instead
// Show it in normal mode and after executing search (Enter)
if !search_typing {
list_state.select(Some(state.selected_index));
}
*list_state.offset_mut() = state.scroll_offset;
// Don't set selection to avoid automatic scrolling - we manage scroll manually
// 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);
}
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)
let playlist_items: Vec<ListItem> = state
let mut playlist_items: Vec<ListItem> = state
.playlist
.iter()
.skip(state.playlist_scroll_offset)
.take(list_visible_height)
.enumerate()
.map(|(idx, path)| {
.map(|(display_idx, path)| {
let idx = state.playlist_scroll_offset + display_idx;
let filename = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| path.to_string_lossy().to_string());
let style = if idx == state.playlist_index {
// Current file: white bold
let is_selected = state.focus_playlist && idx == state.selected_playlist_index;
let is_playing = idx == state.playlist_index;
// Build line with search highlighting if searching
let line = if in_playlist_search && !playlist_search_query.is_empty() {
Line::from(highlight_search_matches(&filename, &playlist_search_query, is_selected))
} else {
Line::from(filename)
};
let style = if is_selected && is_playing {
// 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())
@ -211,14 +315,22 @@ fn render_right_panel(frame: &mut Frame, state: &AppState, area: Rect) {
Theme::secondary()
};
ListItem::new(filename).style(style)
ListItem::new(line).style(style)
})
.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() {
format!("Playlist [{}/{}]", state.playlist_index + 1, state.playlist.len())
format!("playlist [{}/{}]", state.playlist_index + 1, state.playlist.len())
} else {
"Playlist (empty)".to_string()
"playlist (empty)".to_string()
};
let playlist_widget = List::new(playlist_items)
@ -231,11 +343,13 @@ fn render_right_panel(frame: &mut Frame, state: &AppState, area: Rect) {
);
let mut playlist_state = ListState::default();
// Don't set selection - use bold text instead
// Don't set selection to avoid automatic scrolling - we manage scroll manually
// 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);
}
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 = match state.player_state {
PlayerState::Playing => Theme::success(), // Green for playing
PlayerState::Paused => Theme::highlight(), // Blue for paused
@ -294,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));
// 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
let progress_text = if state.current_duration > 0.0 {
let position_mins = (state.current_position / 60.0) as u32;
@ -330,24 +458,42 @@ fn render_title_bar(frame: &mut Frame, state: &AppState, area: Rect) {
frame.render_widget(right_title, chunks[1]);
}
fn render_status_bar(frame: &mut Frame, state: &AppState, area: Rect) {
fn render_status_bar(frame: &mut Frame, state: &AppState, player: &Player, area: Rect) {
if state.search_mode {
// Show search prompt with current query and match count - LEFT aligned
let search_text = 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)
let search_text = if state.focus_playlist {
// Searching in playlist
if !state.playlist_tab_search_results.is_empty() {
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 {
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)
.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 search results are active
let search_text = format!("Search: {}/{} • n: Next • N: Prev • Esc: Clear", state.search_match_index + 1, state.search_matches.len());
// 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(Theme::foreground()).bg(Theme::background()));
.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);
} else if state.visual_mode {
// Show visual mode indicator
@ -356,11 +502,192 @@ fn render_status_bar(frame: &mut Frame, state: &AppState, area: Rect) {
.style(Style::default().fg(Theme::foreground()).bg(Theme::background()));
frame.render_widget(status_bar, area);
} else {
// Normal mode shortcuts (always shown when not in search or visual mode)
let shortcuts = "a: Add • c: Clear • Enter: Play • Space: Pause • s: Stop • +/-: Vol • r: Rescan";
let status_bar = Paragraph::new(shortcuts)
// Normal mode: show media metadata if playing
// Split into left (artist/album/title) and right (technical info)
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()))
.alignment(Alignment::Center);
frame.render_widget(status_bar, area);
.alignment(Alignment::Left);
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

@ -91,4 +91,10 @@ impl Theme {
.fg(Self::warning())
.bg(Self::background())
}
pub fn search_selected() -> Style {
Style::default()
.fg(Self::background())
.bg(Self::warning())
}
}