diff --git a/Cargo.toml b/Cargo.toml index 773089b..f68cb07 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cm-player" -version = "0.1.12" +version = "0.1.13" 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 diff --git a/src/main.rs b/src/main.rs index 1eae410..6df4d8f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -154,6 +154,7 @@ async fn run_app( 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; @@ -194,21 +195,30 @@ async fn run_app( 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() { - state.play_next(); + // Check if track ended and play next (but only if track was actually loaded AND played) + // Require position > 0.5 to ensure track actually started playing (not just loaded) + if player.is_idle() && state.player_state == PlayerState::Playing && state.current_duration > 0.0 && state.current_position > 0.5 { + state.play_next(); + // play_next() handles the play mode and may stop if in Normal mode at end + if state.player_state == PlayerState::Playing { if let Some(ref path) = state.current_file { + // Reset position/duration before playing new track + state.current_position = 0.0; + state.current_duration = 0.0; + last_position = 0.0; + player.play(path)?; } // Update metadata immediately when track changes player.update_metadata(); metadata_update_counter = 0; - state_changed = true; - } else { - state.player_state = PlayerState::Stopped; - state_changed = true; + // Auto-scroll playlist to show current track (only if user is not browsing playlist) + if !state.focus_playlist { + let playlist_visible_height = playlist_area.height.saturating_sub(2) as usize; + state.update_playlist_scroll(playlist_visible_height); + } } + state_changed = true; } } @@ -218,6 +228,7 @@ async fn run_app( let areas = ui::render(f, state, player); title_bar_area = areas.0; file_panel_area = areas.1; + playlist_area = areas.2; })?; needs_redraw = false; } @@ -238,7 +249,7 @@ async fn run_app( } } Event::Mouse(mouse) => { - handle_mouse_event(state, mouse, title_bar_area, file_panel_area, player)?; + handle_mouse_event(state, mouse, title_bar_area, file_panel_area, playlist_area, player)?; needs_redraw = true; // Force redraw after mouse event } _ => {} @@ -282,19 +293,39 @@ async fn handle_key_event(terminal: &mut Terminal< 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(); @@ -304,6 +335,97 @@ async fn handle_key_event(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 => 3, + }; + 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; @@ -320,6 +442,10 @@ async fn handle_key_event(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(); @@ -328,11 +454,15 @@ async fn handle_key_event(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) => { @@ -366,6 +496,10 @@ async fn handle_key_event(terminal: &mut Terminal< 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); + } } } } @@ -400,46 +534,123 @@ async fn handle_key_event(terminal: &mut Terminal< 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::Char('d'), _) => { + if state.focus_playlist { + // Remove selected track from playlist + state.remove_selected_playlist_item(); + // If removed currently playing track, handle it + if state.playlist.is_empty() { + state.player_state = PlayerState::Stopped; + state.current_file = None; + player.stop()?; + } else if state.playlist_index == state.selected_playlist_index { + // Removed currently playing track, play next one + if state.playlist_index < state.playlist.len() { + state.current_file = Some(state.playlist[state.playlist_index].clone()); + if state.player_state == PlayerState::Playing { + if let Some(ref path) = state.current_file { + player.play(path)?; + player.update_metadata(); + } + } + } + } + } + } (KeyCode::Enter, _) => { - action_play_selection(state, player)?; + 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)?; } @@ -476,22 +687,197 @@ async fn handle_key_event(terminal: &mut Terminal< Ok(()) } -fn handle_mouse_event(state: &mut AppState, mouse: MouseEvent, title_bar_area: ratatui::layout::Rect, file_panel_area: ratatui::layout::Rect, player: &mut player::Player) -> Result<()> { +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 => 3, + }; + 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 => { - // Scroll down - move selection down - state.move_selection_down(); + // 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 => { - // Scroll up - move selection up - state.move_selection_up(); + // 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) => { - let x = mouse.column; - let y = mouse.row; - // Check if click is on title bar if x >= title_bar_area.x && x < title_bar_area.x + title_bar_area.width @@ -504,8 +890,14 @@ fn handle_mouse_event(state: &mut AppState, mouse: MouseEvent, title_bar_area: r action_toggle_play_pause(state, player)?; } MouseButton::Right => { - // Right click on title bar = stop - action_stop(state, player)?; + // 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, + }); } _ => {} } @@ -524,21 +916,142 @@ fn handle_mouse_event(state: &mut AppState, mouse: MouseEvent, title_bar_area: r // 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 => { - // Left click = toggle folder open/close - action_toggle_folder(state); + // 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 = play (like Enter key) - action_play_selection(state, player)?; + // 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, + }); + } + } + _ => {} + } + } } _ => {} } diff --git a/src/state/mod.rs b/src/state/mod.rs index 4a59dc5..178c1ef 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -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, pub current_position: f64, @@ -26,6 +50,8 @@ pub struct AppState { pub marked_files: HashSet, pub playlist: Vec, 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,10 +59,20 @@ pub struct AppState { pub search_match_index: usize, pub tab_search_results: Vec, pub tab_search_index: usize, + pub playlist_search_matches: Vec, + pub playlist_search_match_index: usize, + pub playlist_tab_search_results: Vec, + pub playlist_tab_search_index: usize, pub visual_mode: bool, pub visual_anchor: usize, pub saved_expanded_dirs: HashSet, pub show_refresh_confirm: bool, + pub focus_playlist: bool, + pub last_click_time: Option, + pub last_click_index: Option, + pub last_click_is_playlist: bool, + pub context_menu: Option, + pub play_mode: PlayMode, } #[derive(Debug, Clone)] @@ -57,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, @@ -68,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(), @@ -75,10 +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, } } @@ -99,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(); @@ -106,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> { @@ -234,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 = 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()); } } } @@ -268,6 +470,8 @@ 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; @@ -282,6 +486,8 @@ 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; @@ -295,6 +501,8 @@ impl AppState { 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; } @@ -302,16 +510,73 @@ impl AppState { } pub fn play_next(&mut self) { - if self.playlist_index + 1 < self.playlist.len() { - self.playlist_index += 1; - // Double-check index is valid before accessing - if self.playlist_index < self.playlist.len() { + 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; + } + } + pub fn refresh_flattened_items(&mut self) { // Keep current expanded state after rescan self.rebuild_flattened_items(); @@ -327,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) { @@ -400,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; + } } } @@ -479,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; + } } } } @@ -506,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; + } } } } @@ -533,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; + } } } @@ -563,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; + } } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 6bd3b9c..60d97fe 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,17 +1,17 @@ mod theme; use crate::player::Player; -use crate::state::{AppState, PlayerState}; +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, Clear}, Frame, }; use theme::Theme; -pub fn render(frame: &mut Frame, state: &mut AppState, player: &Player) -> (Rect, Rect) { +pub fn render(frame: &mut Frame, state: &mut AppState, player: &Player) -> (Rect, Rect, Rect) { // Clear background frame.render_widget( Block::default().style(Theme::secondary()), @@ -44,8 +44,13 @@ pub fn render(frame: &mut Frame, state: &mut AppState, player: &Player) -> (Rect render_confirm_popup(frame, "Refresh library?", "This may take a while"); } - // Return title bar area and file panel area for mouse event handling - (main_chunks[0], content_chunks[0]) + // 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> { @@ -105,21 +110,42 @@ fn highlight_search_matches<'a>(text: &str, query: &str, is_selected: bool) -> V 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 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 = 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 = 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 let icon = if item.node.is_dir { @@ -167,36 +193,103 @@ 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 title_style = if !state.focus_playlist { + // File panel has focus - bold title + Theme::title_style().add_modifier(Modifier::BOLD) + } else { + Theme::title_style() + }; + let list = List::new(items) .block( Block::default() .borders(Borders::ALL) - .title("Media Files (Cached)") + .title("Media Files") .style(Theme::widget_border_style()) - .title_style(Theme::title_style()), + .title_style(title_style), ); let mut list_state = ListState::default(); - // Always show selection bar (yellow/orange in search mode, blue otherwise) - 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 = state + let mut playlist_items: Vec = 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()) @@ -205,27 +298,44 @@ 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()) } else { "Playlist (empty)".to_string() }; + let playlist_title_style = if state.focus_playlist { + // Playlist has focus - bold title + Theme::title_style().add_modifier(Modifier::BOLD) + } else { + Theme::title_style() + }; + let playlist_widget = List::new(playlist_items) .block( Block::default() .borders(Borders::ALL) .title(playlist_title) .style(Theme::widget_border_style()) - .title_style(Theme::title_style()), + .title_style(playlist_title_style), ); 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); } @@ -288,6 +398,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; @@ -327,21 +451,39 @@ fn render_title_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 @@ -469,3 +611,67 @@ fn render_confirm_popup(frame: &mut Frame, title: &str, message: &str) { .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 = 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); +}