8 Commits

Author SHA1 Message Date
814e593891 Add playlist visual indicators and split panel layout
All checks were successful
Build and Release / build-and-release (push) Successful in 52s
- Highlight files/folders in playlist with bold white text in file tree
- Show folders as in-playlist when they contain playlist items
- Split UI into files (70%) and playlist (30%) panels
- Make selection bar bold when over playlist items
- Fix search to keep selection on found item after pressing Enter
2025-12-16 23:33:05 +01:00
2953d73487 Use lowercase for panel labels
All checks were successful
Build and Release / build-and-release (push) Successful in 54s
Change "Files" to "files" and "Playlist" to "playlist" in the title bar
for consistent lowercase styling. Also remove unused render_info_popup
function.
2025-12-13 12:58:06 +01:00
cddfedf1a0 Add live file counter to library refresh
All checks were successful
Build and Release / build-and-release (push) Successful in 1m20s
Show real-time progress during library scanning with an atomic counter
that updates every 100ms. The refresh popup displays the number of
media files found as they are discovered, providing immediate feedback
without slowing down the scan operation.
2025-12-13 11:51:32 +01:00
821a844fe0 Add dynamic title bar colors based on player state
Title bar background color changes:
- Gray: Stopped or Paused
- Green: Playing
- Red: Error (when last_error is set)

Main content border remains gray.
2025-12-13 10:34:55 +01:00
b7cc219f40 Prevent auto-advance when MPV is force killed
Set previous_player_state to Stopped and clear current_file when MPV dies to prevent the auto-advance logic from triggering.
2025-12-12 21:18:22 +01:00
b5fde2d5d4 Handle MPV force kill gracefully
When MPV is killed externally, detect the dead process and automatically recreate it for next playback. Connection errors no longer crash the application - they're logged and handled silently.
2025-12-12 19:52:48 +01:00
d53542afa6 Eliminate code duplication with unified action functions
All checks were successful
Build and Release / build-and-release (push) Successful in 55s
Create action functions for stop, volume, and seek operations and
use them consistently across keyboard handlers, mouse handlers, and
API handlers. This eliminates duplicate logic and ensures consistent
behavior across all input methods.

Also fixes stop command triggering auto-advance by setting the
skip_position_update flag to prevent the Playing→Stopped transition
from being interpreted as a natural track ending.
2025-12-12 16:19:56 +01:00
be9ee8c005 Move refresh status to centered popup
All checks were successful
Build and Release / build-and-release (push) Successful in 54s
Display "Refreshing library..." in a centered popup overlay instead
of showing it in the title bar. This makes the refresh status more
prominent and cleaner.
2025-12-12 15:45:12 +01:00
7 changed files with 348 additions and 617 deletions

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "cm-player" name = "cm-player"
version = "0.1.31" version = "0.1.38"
edition = "2021" edition = "2021"
[dependencies] [dependencies]

View File

@@ -16,6 +16,10 @@ use ratatui::{backend::CrosstermBackend, Terminal};
use state::{AppState, PlayerState}; use state::{AppState, PlayerState};
use std::io::{self, BufRead, BufReader, Write}; use std::io::{self, BufRead, BufReader, Write};
use std::os::unix::net::UnixStream; use std::os::unix::net::UnixStream;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;
use tracing_subscriber; use tracing_subscriber;
// UI update intervals and thresholds // UI update intervals and thresholds
@@ -262,14 +266,44 @@ fn action_toggle_play_pause(state: &mut AppState, player: &mut player::Player) -
Ok(()) Ok(())
} }
fn action_stop(state: &mut AppState, player: &mut player::Player) -> Result<()> { fn action_stop(state: &mut AppState, player: &mut player::Player, skip_position_update: &mut bool) -> Result<()> {
player.stop()?; player.stop()?;
state.current_position = 0.0; state.current_position = 0.0;
state.current_duration = 0.0; state.current_duration = 0.0;
*skip_position_update = true; // Prevent auto-advance on manual stop
tracing::info!("Stopped"); tracing::info!("Stopped");
Ok(()) Ok(())
} }
fn action_volume_up(state: &mut AppState, player: &mut player::Player) -> Result<()> {
state.volume = (state.volume + 5).min(100);
player.set_volume(state.volume)?;
tracing::info!("Volume: {}%", state.volume);
Ok(())
}
fn action_volume_down(state: &mut AppState, player: &mut player::Player) -> Result<()> {
state.volume = (state.volume - 5).max(0);
player.set_volume(state.volume)?;
tracing::info!("Volume: {}%", state.volume);
Ok(())
}
fn action_volume_set(state: &mut AppState, player: &mut player::Player, volume: i64) -> Result<()> {
state.volume = volume.clamp(0, 100);
player.set_volume(state.volume)?;
tracing::info!("Volume: {}%", state.volume);
Ok(())
}
fn action_seek(player: &mut player::Player, seconds: f64) -> Result<()> {
if player.get_player_state() != Some(PlayerState::Stopped) {
player.seek(seconds)?;
tracing::info!("Seek {}s", seconds);
}
Ok(())
}
fn action_remove_from_playlist(state: &mut AppState, player: &mut player::Player) -> Result<()> { fn action_remove_from_playlist(state: &mut AppState, player: &mut player::Player) -> Result<()> {
let was_playing_removed = state.playlist_index == state.selected_playlist_index; let was_playing_removed = state.playlist_index == state.selected_playlist_index;
let was_playing = player.get_player_state() == Some(PlayerState::Playing); let was_playing = player.get_player_state() == Some(PlayerState::Playing);
@@ -415,7 +449,7 @@ fn handle_context_menu_action(menu_type: state::ContextMenuType, selected: usize
} }
state::ContextMenuType::TitleBar => { state::ContextMenuType::TitleBar => {
match selected { match selected {
0 => action_stop(state, player)?, 0 => action_stop(state, player, skip_position_update)?,
1 => { 1 => {
state.cycle_play_mode(); state.cycle_play_mode();
tracing::info!("Play mode: {:?}", state.play_mode); tracing::info!("Play mode: {:?}", state.play_mode);
@@ -453,26 +487,11 @@ fn run_app<B: ratatui::backend::Backend>(
tracing::debug!("Processing API command: {:?}", cmd); tracing::debug!("Processing API command: {:?}", cmd);
match cmd { match cmd {
api::ApiCommand::PlayPause => { api::ApiCommand::PlayPause => {
if let Some(player_state) = player.get_player_state() { action_toggle_play_pause(state, player)?;
match player_state { state_changed = true;
PlayerState::Stopped => {
// Play current file or first in playlist
if state.current_file.is_none() && !state.playlist.is_empty() {
state.current_file = Some(state.playlist[0].clone());
}
if let Some(ref file) = state.current_file {
player.play(file)?;
}
}
PlayerState::Playing => player.pause()?,
PlayerState::Paused => player.resume()?,
}
state_changed = true;
}
} }
api::ApiCommand::Stop => { api::ApiCommand::Stop => {
player.stop()?; action_stop(state, player, &mut skip_position_update)?;
state.current_file = None;
state_changed = true; state_changed = true;
} }
api::ApiCommand::Next => { api::ApiCommand::Next => {
@@ -484,26 +503,23 @@ fn run_app<B: ratatui::backend::Backend>(
state_changed = true; state_changed = true;
} }
api::ApiCommand::VolumeUp => { api::ApiCommand::VolumeUp => {
state.volume = (state.volume + 5).min(100); action_volume_up(state, player)?;
player.set_volume(state.volume)?;
state_changed = true; state_changed = true;
} }
api::ApiCommand::VolumeDown => { api::ApiCommand::VolumeDown => {
state.volume = (state.volume - 5).max(0); action_volume_down(state, player)?;
player.set_volume(state.volume)?;
state_changed = true; state_changed = true;
} }
api::ApiCommand::VolumeSet { volume } => { api::ApiCommand::VolumeSet { volume } => {
state.volume = volume.clamp(0, 100); action_volume_set(state, player, volume)?;
player.set_volume(state.volume)?;
state_changed = true; state_changed = true;
} }
api::ApiCommand::SeekForward { seconds } => { api::ApiCommand::SeekForward { seconds } => {
player.seek(seconds)?; action_seek(player, seconds)?;
state_changed = true; state_changed = true;
} }
api::ApiCommand::SeekBackward { seconds } => { api::ApiCommand::SeekBackward { seconds } => {
player.seek(-seconds)?; action_seek(player, -seconds)?;
state_changed = true; state_changed = true;
} }
api::ApiCommand::GetStatus => { api::ApiCommand::GetStatus => {
@@ -516,15 +532,18 @@ fn run_app<B: ratatui::backend::Backend>(
} }
} }
// Check if mpv process died (e.g., user closed video window) // Check if mpv process died (e.g., user closed video window or force killed)
if !player.is_process_alive() { if !player.is_process_alive() {
if let Some(player_state) = player.get_player_state() { tracing::info!("MPV process died, recreating...");
if player_state != PlayerState::Stopped { state.current_position = 0.0;
state.current_position = 0.0; state.current_duration = 0.0;
state.current_duration = 0.0; state.current_file = None;
state_changed = true; state_changed = true;
} skip_position_update = true; // Prevent auto-advance when MPV was killed
}
// Recreate player for next playback
*player = player::Player::new()?;
previous_player_state = Some(PlayerState::Stopped);
} }
// Always update all properties in one batch to keep state synchronized with MPV // Always update all properties in one batch to keep state synchronized with MPV
@@ -554,8 +573,10 @@ fn run_app<B: ratatui::backend::Backend>(
// Check if track ended and play next // Check if track ended and play next
// When MPV finishes playing a file, it goes to idle (Stopped state) // When MPV finishes playing a file, it goes to idle (Stopped state)
// Detect Playing → Stopped transition = track ended, play next // Detect Playing → Stopped transition = track ended, play next
// But skip this check if we just manually stopped (skip_position_update flag)
if previous_player_state == Some(PlayerState::Playing) if previous_player_state == Some(PlayerState::Playing)
&& player_state == PlayerState::Stopped && player_state == PlayerState::Stopped
&& !skip_position_update
{ {
let should_continue = state.play_next(); let should_continue = state.play_next();
// play_next() returns true if should continue playing, false if should stop // play_next() returns true if should continue playing, false if should stop
@@ -660,7 +681,8 @@ fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, st
KeyCode::Char('y') | KeyCode::Char('Y') => { KeyCode::Char('y') | KeyCode::Char('Y') => {
state.show_refresh_confirm = false; state.show_refresh_confirm = false;
state.is_refreshing = true; state.is_refreshing = true;
terminal.draw(|f| { let _ = ui::render(f, state, player); })?; // Show "Refreshing library..." immediately state.refresh_file_count = 0;
terminal.draw(|f| { let _ = ui::render(f, state, player); })?;
let cache_dir = cache::get_cache_dir()?; let cache_dir = cache::get_cache_dir()?;
@@ -668,8 +690,26 @@ fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, st
let _ = std::fs::remove_file(cache_dir.join("file_tree.json")); let _ = std::fs::remove_file(cache_dir.join("file_tree.json"));
let _ = std::fs::remove_file(cache_dir.join("metadata.json")); let _ = std::fs::remove_file(cache_dir.join("metadata.json"));
// Perform fresh scan // Create atomic counter for file count
let new_cache = scanner::scan_paths(&state.config.scan_paths.paths)?; let counter = Arc::new(AtomicUsize::new(0));
let counter_clone = Arc::clone(&counter);
// Spawn background thread to perform scan
let paths = state.config.scan_paths.paths.clone();
let scan_thread = thread::spawn(move || {
scanner::scan_paths(&paths, &counter_clone)
});
// Poll counter and update UI while scanning
while !scan_thread.is_finished() {
state.refresh_file_count = counter.load(Ordering::Relaxed);
terminal.draw(|f| { let _ = ui::render(f, state, player); })?;
thread::sleep(Duration::from_millis(100));
}
// Get the result
let new_cache = scan_thread.join().map_err(|_| anyhow::anyhow!("Scan thread panicked"))??;
new_cache.save(&cache_dir)?; new_cache.save(&cache_dir)?;
// Replace old cache completely // Replace old cache completely
@@ -684,6 +724,7 @@ fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, st
} }
state.is_refreshing = false; state.is_refreshing = false;
state.refresh_file_count = 0;
} }
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
state.show_refresh_confirm = false; state.show_refresh_confirm = false;
@@ -696,6 +737,20 @@ fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, st
// Handle search mode separately // Handle search mode separately
if state.search_mode { if state.search_mode {
match key.code { match key.code {
KeyCode::Char('n') => {
if state.focus_playlist {
state.next_playlist_search_result();
} else {
state.next_search_result();
}
}
KeyCode::Char('N') => {
if state.focus_playlist {
state.prev_playlist_search_result();
} else {
state.prev_search_result();
}
}
KeyCode::Char(c) => { KeyCode::Char(c) => {
if state.focus_playlist { if state.focus_playlist {
state.append_playlist_search_char(c); state.append_playlist_search_char(c);
@@ -710,20 +765,6 @@ fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, st
state.backspace_search(); state.backspace_search();
} }
} }
KeyCode::Tab => {
if state.focus_playlist {
state.playlist_tab_search_next();
} else {
state.tab_search_next();
}
}
KeyCode::BackTab => {
if state.focus_playlist {
state.playlist_tab_search_prev();
} else {
state.tab_search_prev();
}
}
KeyCode::Enter => { KeyCode::Enter => {
if state.focus_playlist { if state.focus_playlist {
state.execute_playlist_search(); state.execute_playlist_search();
@@ -786,32 +827,11 @@ fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, st
} }
} }
(KeyCode::Esc, _) => { (KeyCode::Esc, _) => {
if !state.search_matches.is_empty() {
state.search_matches.clear();
}
if !state.playlist_search_matches.is_empty() {
state.playlist_search_matches.clear();
state.playlist_tab_search_results.clear();
}
if state.visual_mode { if state.visual_mode {
state.visual_mode = false; state.visual_mode = false;
state.marked_files.clear(); state.marked_files.clear();
} }
} }
(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) => { (KeyCode::Char('J'), KeyModifiers::SHIFT) => {
// Next track // Next track
action_navigate_track(state, player, 1, skip_position_update)?; action_navigate_track(state, player, 1, skip_position_update)?;
@@ -894,7 +914,7 @@ fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, st
} }
} }
(KeyCode::Char('s'), _) => { (KeyCode::Char('s'), _) => {
action_stop(state, player)?; action_stop(state, player, skip_position_update)?;
} }
(KeyCode::Char('m'), _) => { (KeyCode::Char('m'), _) => {
state.cycle_play_mode(); state.cycle_play_mode();
@@ -908,28 +928,16 @@ fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, st
action_toggle_play_pause(state, player)?; action_toggle_play_pause(state, player)?;
} }
(KeyCode::Char('H'), KeyModifiers::SHIFT) => { (KeyCode::Char('H'), KeyModifiers::SHIFT) => {
if player.get_player_state() != Some(PlayerState::Stopped) { action_seek(player, -10.0)?;
player.seek(-10.0)?;
tracing::info!("Seek backward 10s");
}
} }
(KeyCode::Char('L'), KeyModifiers::SHIFT) => { (KeyCode::Char('L'), KeyModifiers::SHIFT) => {
if player.get_player_state() != Some(PlayerState::Stopped) { action_seek(player, 10.0)?;
player.seek(10.0)?;
tracing::info!("Seek forward 10s");
}
} }
(KeyCode::Char('+'), _) | (KeyCode::Char('='), _) => { (KeyCode::Char('+'), _) | (KeyCode::Char('='), _) => {
let new_volume = (state.volume + 5).min(100); action_volume_up(state, player)?;
state.volume = new_volume;
player.set_volume(new_volume)?;
tracing::info!("Volume: {}%", new_volume);
} }
(KeyCode::Char('-'), _) => { (KeyCode::Char('-'), _) => {
let new_volume = (state.volume - 5).max(0); action_volume_down(state, player)?;
state.volume = new_volume;
player.set_volume(new_volume)?;
tracing::info!("Volume: {}%", new_volume);
} }
(KeyCode::Char('r'), _) => { (KeyCode::Char('r'), _) => {
state.show_refresh_confirm = true; state.show_refresh_confirm = true;
@@ -1032,10 +1040,7 @@ fn handle_mouse_event(state: &mut AppState, mouse: MouseEvent, title_bar_area: r
&& y < title_bar_area.y + title_bar_area.height && y < title_bar_area.y + title_bar_area.height
{ {
// Scroll on title bar = decrease volume // Scroll on title bar = decrease volume
let new_volume = (state.volume - 5).max(0); action_volume_down(state, player)?;
state.volume = new_volume;
player.set_volume(new_volume)?;
tracing::info!("Volume: {}%", new_volume);
} else if x >= playlist_area.x } else if x >= playlist_area.x
&& x < playlist_area.x + playlist_area.width && x < playlist_area.x + playlist_area.width
&& y >= playlist_area.y && y >= playlist_area.y
@@ -1058,10 +1063,7 @@ fn handle_mouse_event(state: &mut AppState, mouse: MouseEvent, title_bar_area: r
&& y < title_bar_area.y + title_bar_area.height && y < title_bar_area.y + title_bar_area.height
{ {
// Scroll on title bar = increase volume // Scroll on title bar = increase volume
let new_volume = (state.volume + 5).min(100); action_volume_up(state, player)?;
state.volume = new_volume;
player.set_volume(new_volume)?;
tracing::info!("Volume: {}%", new_volume);
} else if x >= playlist_area.x } else if x >= playlist_area.x
&& x < playlist_area.x + playlist_area.width && x < playlist_area.x + playlist_area.width
&& y >= playlist_area.y && y >= playlist_area.y

View File

@@ -76,8 +76,10 @@ impl Player {
let stream = match UnixStream::connect(&self.socket_path) { let stream = match UnixStream::connect(&self.socket_path) {
Ok(s) => s, Ok(s) => s,
Err(e) => { Err(e) => {
// Connection failed - MPV probably not ready yet // Connection failed - MPV probably not ready yet or has been killed
return Err(anyhow::anyhow!("Failed to connect: {}", e)); // Don't propagate error - just leave socket as None
tracing::debug!("Failed to connect to MPV socket: {}", e);
return Ok(());
} }
}; };

View File

@@ -1,6 +1,7 @@
use crate::cache::{Cache, FileMetadata, FileTreeNode}; use crate::cache::{Cache, FileMetadata, FileTreeNode};
use anyhow::Result; use anyhow::Result;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use walkdir::WalkDir; use walkdir::WalkDir;
const AUDIO_EXTENSIONS: &[&str] = &["mp3", "flac", "wav", "ogg", "m4a", "aac", "opus", "wma"]; const AUDIO_EXTENSIONS: &[&str] = &["mp3", "flac", "wav", "ogg", "m4a", "aac", "opus", "wma"];
@@ -33,7 +34,7 @@ pub fn is_video_file(path: &Path) -> bool {
} }
} }
pub fn scan_directory(root_path: &Path) -> Result<FileTreeNode> { fn scan_directory_internal(root_path: &Path, counter: &AtomicUsize) -> Result<FileTreeNode> {
let name = root_path let name = root_path
.file_name() .file_name()
.unwrap_or_default() .unwrap_or_default()
@@ -62,13 +63,16 @@ pub fn scan_directory(root_path: &Path) -> Result<FileTreeNode> {
if entry.file_type().is_dir() { if entry.file_type().is_dir() {
// Recursively scan subdirectories // Recursively scan subdirectories
if let Ok(child_node) = scan_directory(path) { if let Ok(child_node) = scan_directory_internal(path, counter) {
// Only add directory if it contains media files or non-empty subdirectories // Only add directory if it contains media files or non-empty subdirectories
if !child_node.children.is_empty() { if !child_node.children.is_empty() {
node.children.push(child_node); node.children.push(child_node);
} }
} }
} else if is_media_file(path) { } else if is_media_file(path) {
// Increment counter for each media file found
counter.fetch_add(1, Ordering::Relaxed);
// Add media file // Add media file
let file_name = path let file_name = path
.file_name() .file_name()
@@ -100,13 +104,13 @@ pub fn scan_directory(root_path: &Path) -> Result<FileTreeNode> {
Ok(node) Ok(node)
} }
pub fn scan_paths(paths: &[PathBuf]) -> Result<Cache> { pub fn scan_paths(paths: &[PathBuf], counter: &AtomicUsize) -> Result<Cache> {
let mut cache = Cache::new(); let mut cache = Cache::new();
for path in paths { for path in paths {
if path.exists() { if path.exists() {
tracing::info!("Scanning path: {:?}", path); tracing::info!("Scanning path: {:?}", path);
let tree_node = scan_directory(path)?; let tree_node = scan_directory_internal(path, counter)?;
// Collect all metadata from the tree // Collect all metadata from the tree
collect_metadata(&tree_node, &mut cache); collect_metadata(&tree_node, &mut cache);

View File

@@ -74,14 +74,10 @@ pub struct AppState {
pub is_refreshing: bool, pub is_refreshing: bool,
pub search_mode: bool, pub search_mode: bool,
pub search_query: String, pub search_query: String,
pub search_matches: Vec<PathBuf>, pub search_results: Vec<PathBuf>,
pub search_match_index: usize, pub search_result_index: usize,
pub tab_search_results: Vec<PathBuf>, pub playlist_search_results: Vec<usize>,
pub tab_search_index: usize, pub playlist_search_result_index: usize,
pub playlist_search_matches: Vec<usize>,
pub playlist_search_match_index: usize,
pub playlist_tab_search_results: Vec<usize>,
pub playlist_tab_search_index: usize,
pub visual_mode: bool, pub visual_mode: bool,
pub visual_anchor: usize, pub visual_anchor: usize,
pub saved_expanded_dirs: HashSet<PathBuf>, pub saved_expanded_dirs: HashSet<PathBuf>,
@@ -92,6 +88,8 @@ pub struct AppState {
pub last_click_is_playlist: bool, pub last_click_is_playlist: bool,
pub context_menu: Option<ContextMenu>, pub context_menu: Option<ContextMenu>,
pub play_mode: PlayMode, pub play_mode: PlayMode,
pub last_error: Option<String>,
pub refresh_file_count: usize,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -131,14 +129,10 @@ impl AppState {
is_refreshing: false, is_refreshing: false,
search_mode: false, search_mode: false,
search_query: String::new(), search_query: String::new(),
search_matches: Vec::new(), search_results: Vec::new(),
search_match_index: 0, search_result_index: 0,
tab_search_results: Vec::new(), playlist_search_results: Vec::new(),
tab_search_index: 0, playlist_search_result_index: 0,
playlist_search_matches: Vec::new(),
playlist_search_match_index: 0,
playlist_tab_search_results: Vec::new(),
playlist_tab_search_index: 0,
visual_mode: false, visual_mode: false,
visual_anchor: 0, visual_anchor: 0,
saved_expanded_dirs: HashSet::new(), saved_expanded_dirs: HashSet::new(),
@@ -149,6 +143,8 @@ impl AppState {
last_click_is_playlist: false, last_click_is_playlist: false,
context_menu: None, context_menu: None,
play_mode: PlayMode::Normal, play_mode: PlayMode::Normal,
last_error: None,
refresh_file_count: 0,
} }
} }
@@ -680,16 +676,12 @@ impl AppState {
if self.focus_playlist { if self.focus_playlist {
// Clear playlist search state // Clear playlist search state
self.playlist_search_matches.clear(); self.playlist_search_results.clear();
self.playlist_search_match_index = 0; self.playlist_search_result_index = 0;
self.playlist_tab_search_results.clear();
self.playlist_tab_search_index = 0;
} else { } else {
// Clear file search state // Clear file search state
self.search_matches.clear(); self.search_results.clear();
self.search_match_index = 0; self.search_result_index = 0;
self.tab_search_results.clear();
self.tab_search_index = 0;
// Save current folder state // Save current folder state
self.saved_expanded_dirs = self.expanded_dirs.clone(); self.saved_expanded_dirs = self.expanded_dirs.clone();
} }
@@ -697,15 +689,16 @@ impl AppState {
pub fn exit_search_mode(&mut self) { pub fn exit_search_mode(&mut self) {
self.search_mode = false; self.search_mode = false;
self.search_query.clear();
if self.focus_playlist { if self.focus_playlist {
// Clear playlist search state // Clear playlist search state
self.playlist_tab_search_results.clear(); self.playlist_search_results.clear();
self.playlist_tab_search_index = 0; self.playlist_search_result_index = 0;
} else { } else {
// Clear file search state // Clear file search state
self.tab_search_results.clear(); self.search_results.clear();
self.tab_search_index = 0; self.search_result_index = 0;
// Restore folder state from before search // Restore folder state from before search
self.expanded_dirs = self.saved_expanded_dirs.clone(); self.expanded_dirs = self.saved_expanded_dirs.clone();
self.rebuild_flattened_items(); self.rebuild_flattened_items();
@@ -724,9 +717,8 @@ impl AppState {
fn perform_incremental_search(&mut self) { fn perform_incremental_search(&mut self) {
if self.search_query.is_empty() { if self.search_query.is_empty() {
self.tab_search_results.clear(); self.search_results.clear();
self.tab_search_index = 0; self.search_result_index = 0;
// Don't rebuild tree on every keystroke - only when exiting search
return; return;
} }
@@ -735,8 +727,8 @@ impl AppState {
collect_matching_paths(&self.cache.file_tree, &self.search_query, &mut matching_paths_with_scores); collect_matching_paths(&self.cache.file_tree, &self.search_query, &mut matching_paths_with_scores);
if matching_paths_with_scores.is_empty() { if matching_paths_with_scores.is_empty() {
self.tab_search_results.clear(); self.search_results.clear();
self.tab_search_index = 0; self.search_result_index = 0;
return; return;
} }
@@ -750,22 +742,30 @@ impl AppState {
// Sort by score (highest first), then by original index to prefer first occurrence // Sort by score (highest first), then by original index to prefer first occurrence
indexed_matches.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.2.cmp(&b.2))); indexed_matches.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.2.cmp(&b.2)));
// Store all matches for tab completion // Store all matches
self.tab_search_results = indexed_matches.iter().map(|(path, _, _)| path.clone()).collect(); self.search_results = indexed_matches.iter().map(|(path, _, _)| path.clone()).collect();
self.tab_search_index = 0; self.search_result_index = 0;
// Only expand and rebuild if this is a new best match // Jump to first match
let best_match = self.tab_search_results[0].clone(); self.jump_to_current_search_result();
}
fn jump_to_current_search_result(&mut self) {
if self.search_results.is_empty() {
return;
}
let current_match = self.search_results[self.search_result_index].clone();
// Check if we need to expand folders for this match // Check if we need to expand folders for this match
let needs_expand = best_match.ancestors() let needs_expand = current_match.ancestors()
.skip(1) // Skip the file itself .skip(1) // Skip the file itself
.any(|p| !self.expanded_dirs.contains(p)); .any(|p| !self.expanded_dirs.contains(p));
if needs_expand { if needs_expand {
// Close all folders and expand only for the best match // Close all folders and expand only for the current match
self.expanded_dirs.clear(); self.expanded_dirs.clear();
let mut parent = best_match.parent(); let mut parent = current_match.parent();
while let Some(p) = parent { while let Some(p) = parent {
self.expanded_dirs.insert(p.to_path_buf()); self.expanded_dirs.insert(p.to_path_buf());
parent = p.parent(); parent = p.parent();
@@ -775,8 +775,8 @@ impl AppState {
self.rebuild_flattened_items(); self.rebuild_flattened_items();
} }
// Find the best match in the flattened list and jump to it // Find the match in the flattened list and jump to it
if let Some(idx) = self.flattened_items.iter().position(|item| item.path == best_match) { if let Some(idx) = self.flattened_items.iter().position(|item| item.path == current_match) {
self.selected_index = idx; self.selected_index = idx;
// Scroll to show the match // Scroll to show the match
@@ -794,233 +794,31 @@ impl AppState {
} }
} }
pub fn next_search_result(&mut self) {
if !self.search_results.is_empty() {
self.search_result_index = (self.search_result_index + 1) % self.search_results.len();
self.jump_to_current_search_result();
}
}
pub fn prev_search_result(&mut self) {
if !self.search_results.is_empty() {
self.search_result_index = if self.search_result_index == 0 {
self.search_results.len() - 1
} else {
self.search_result_index - 1
};
self.jump_to_current_search_result();
}
}
pub fn execute_search(&mut self) { pub fn execute_search(&mut self) {
if self.search_query.is_empty() { // Keep the current selection and expanded state (don't restore saved_expanded_dirs)
self.search_mode = false;
return;
}
// Collect all matching paths with scores and preserve order
let mut matching_paths_with_scores = Vec::new();
collect_matching_paths(&self.cache.file_tree, &self.search_query, &mut matching_paths_with_scores);
if matching_paths_with_scores.is_empty() {
self.search_mode = false;
return;
}
// Add index to preserve original tree order when scores are equal
let mut indexed_matches: Vec<(PathBuf, i32, usize)> = matching_paths_with_scores
.into_iter()
.enumerate()
.map(|(idx, (path, score))| (path, score, idx))
.collect();
// Sort by score (highest first), then by original index to prefer first occurrence
indexed_matches.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.2.cmp(&b.2)));
let matching_paths_with_scores: Vec<(PathBuf, i32)> = indexed_matches
.into_iter()
.map(|(path, score, _)| (path, score))
.collect();
let matching_paths: Vec<PathBuf> = matching_paths_with_scores.iter().map(|(path, _)| path.clone()).collect();
// Store matching paths (not indices, as they change when folders collapse)
self.search_matches = matching_paths;
if !self.search_matches.is_empty() {
self.search_match_index = 0;
// Close all folders and expand only for first match
self.expanded_dirs.clear();
let first_match = self.search_matches[0].clone();
let mut parent = first_match.parent();
while let Some(p) = parent {
self.expanded_dirs.insert(p.to_path_buf());
parent = p.parent();
}
// Rebuild flattened items
self.rebuild_flattened_items();
// Find first match in flattened list
if let Some(idx) = self.flattened_items.iter().position(|item| item.path == first_match) {
self.selected_index = idx;
}
}
self.search_mode = false; self.search_mode = false;
} self.search_query.clear();
self.search_results.clear();
pub fn next_search_match(&mut self) { self.search_result_index = 0;
if !self.search_matches.is_empty() { // Don't restore expanded_dirs - keep current state so selection stays visible
self.search_match_index = (self.search_match_index + 1) % self.search_matches.len();
let target_path = self.search_matches[self.search_match_index].clone();
// Close all folders and expand only for this match
self.expanded_dirs.clear();
let mut parent = target_path.parent();
while let Some(p) = parent {
self.expanded_dirs.insert(p.to_path_buf());
parent = p.parent();
}
// Rebuild flattened items
self.rebuild_flattened_items();
// Find the path in current flattened items
if let Some(idx) = self.flattened_items.iter().position(|item| item.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;
}
}
}
}
pub fn prev_search_match(&mut self) {
if !self.search_matches.is_empty() {
if self.search_match_index == 0 {
self.search_match_index = self.search_matches.len() - 1;
} else {
self.search_match_index -= 1;
}
let target_path = self.search_matches[self.search_match_index].clone();
// Close all folders and expand only for this match
self.expanded_dirs.clear();
let mut parent = target_path.parent();
while let Some(p) = parent {
self.expanded_dirs.insert(p.to_path_buf());
parent = p.parent();
}
// Rebuild flattened items
self.rebuild_flattened_items();
// Find the path in current flattened items
if let Some(idx) = self.flattened_items.iter().position(|item| item.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;
}
}
}
}
pub fn tab_search_next(&mut self) {
if self.tab_search_results.is_empty() {
return;
}
// Cycle to next match
self.tab_search_index = (self.tab_search_index + 1) % self.tab_search_results.len();
let next_match = self.tab_search_results[self.tab_search_index].clone();
// Close all folders and expand only for this match
self.expanded_dirs.clear();
let mut parent = next_match.parent();
while let Some(p) = parent {
self.expanded_dirs.insert(p.to_path_buf());
parent = p.parent();
}
// Rebuild flattened items
self.rebuild_flattened_items();
// Find and select the match
if let Some(idx) = self.flattened_items.iter().position(|item| item.path == next_match) {
self.selected_index = idx;
// Scroll to show the match
let effective_height = calculate_effective_height(
self.scroll_offset,
self.file_panel_visible_height,
self.flattened_items.len()
);
if self.selected_index < self.scroll_offset {
self.scroll_offset = self.selected_index;
} else if self.selected_index >= self.scroll_offset + effective_height {
self.scroll_offset = self.selected_index - effective_height + 1;
}
}
}
pub fn tab_search_prev(&mut self) {
if self.tab_search_results.is_empty() {
return;
}
// Cycle to previous match
if self.tab_search_index == 0 {
self.tab_search_index = self.tab_search_results.len() - 1;
} else {
self.tab_search_index -= 1;
}
let prev_match = self.tab_search_results[self.tab_search_index].clone();
// Close all folders and expand only for this match
self.expanded_dirs.clear();
let mut parent = prev_match.parent();
while let Some(p) = parent {
self.expanded_dirs.insert(p.to_path_buf());
parent = p.parent();
}
// Rebuild flattened items
self.rebuild_flattened_items();
// Find and select the match
if let Some(idx) = self.flattened_items.iter().position(|item| item.path == prev_match) {
self.selected_index = idx;
// Scroll to show the match
let effective_height = calculate_effective_height(
self.scroll_offset,
self.file_panel_visible_height,
self.flattened_items.len()
);
if self.selected_index < self.scroll_offset {
self.scroll_offset = self.selected_index;
} 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) { pub fn append_playlist_search_char(&mut self, c: char) {
@@ -1035,8 +833,8 @@ impl AppState {
fn perform_playlist_incremental_search(&mut self) { fn perform_playlist_incremental_search(&mut self) {
if self.search_query.is_empty() { if self.search_query.is_empty() {
self.playlist_tab_search_results.clear(); self.playlist_search_results.clear();
self.playlist_tab_search_index = 0; self.playlist_search_result_index = 0;
return; return;
} }
@@ -1053,21 +851,29 @@ impl AppState {
.collect(); .collect();
if matching_indices_with_scores.is_empty() { if matching_indices_with_scores.is_empty() {
self.playlist_tab_search_results.clear(); self.playlist_search_results.clear();
self.playlist_tab_search_index = 0; self.playlist_search_result_index = 0;
return; return;
} }
// Sort by score (highest first) // Sort by score (highest first)
matching_indices_with_scores.sort_by(|a, b| b.1.cmp(&a.1)); matching_indices_with_scores.sort_by(|a, b| b.1.cmp(&a.1));
// Store all matches for tab completion // Store all matches
self.playlist_tab_search_results = matching_indices_with_scores.iter().map(|(idx, _)| *idx).collect(); self.playlist_search_results = matching_indices_with_scores.iter().map(|(idx, _)| *idx).collect();
self.playlist_tab_search_index = 0; self.playlist_search_result_index = 0;
// Jump to best match // Jump to first match
let best_match_idx = self.playlist_tab_search_results[0]; self.jump_to_current_playlist_search_result();
self.selected_playlist_index = best_match_idx; }
fn jump_to_current_playlist_search_result(&mut self) {
if self.playlist_search_results.is_empty() {
return;
}
let match_idx = self.playlist_search_results[self.playlist_search_result_index];
self.selected_playlist_index = match_idx;
// Scroll to show the match // Scroll to show the match
let effective_height = calculate_effective_height( let effective_height = calculate_effective_height(
@@ -1083,174 +889,26 @@ impl AppState {
} }
} }
pub fn playlist_tab_search_next(&mut self) { pub fn next_playlist_search_result(&mut self) {
if self.playlist_tab_search_results.is_empty() { if !self.playlist_search_results.is_empty() {
return; self.playlist_search_result_index = (self.playlist_search_result_index + 1) % self.playlist_search_results.len();
} self.jump_to_current_playlist_search_result();
// 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
let effective_height = calculate_effective_height(
self.playlist_scroll_offset,
self.playlist_visible_height,
self.playlist.len()
);
if self.selected_playlist_index < self.playlist_scroll_offset {
self.playlist_scroll_offset = self.selected_playlist_index;
} 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) { pub fn prev_playlist_search_result(&mut self) {
if self.playlist_tab_search_results.is_empty() { if !self.playlist_search_results.is_empty() {
return; self.playlist_search_result_index = if self.playlist_search_result_index == 0 {
} self.playlist_search_results.len() - 1
} else {
// Cycle to previous match self.playlist_search_result_index - 1
if self.playlist_tab_search_index == 0 { };
self.playlist_tab_search_index = self.playlist_tab_search_results.len() - 1; self.jump_to_current_playlist_search_result();
} 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
let effective_height = calculate_effective_height(
self.playlist_scroll_offset,
self.playlist_visible_height,
self.playlist.len()
);
if self.selected_playlist_index < self.playlist_scroll_offset {
self.playlist_scroll_offset = self.selected_playlist_index;
} 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) { pub fn execute_playlist_search(&mut self) {
if self.search_query.is_empty() { self.exit_search_mode();
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

@@ -28,55 +28,55 @@ pub fn render(frame: &mut Frame, state: &mut AppState, player: &mut Player) -> (
]) ])
.split(frame.area()); .split(frame.area());
// Always use tab mode - show only the focused panel // Split main content into files (70%) and playlist (30%)
let tab_mode = true; let content_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(70), // Files panel
Constraint::Percentage(30), // Playlist panel
])
.split(main_chunks[1]);
// Build the title with focused panel in bold // Build the files panel title
let file_style = if !state.focus_playlist { let file_style = Style::default().fg(Theme::bright_foreground());
Style::default().fg(Theme::bright_foreground()).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Theme::bright_foreground())
};
let playlist_style = if state.focus_playlist { let files_block = Block::default()
Style::default().fg(Theme::bright_foreground()).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Theme::bright_foreground())
};
// Add playlist counter
let playlist_text = if !state.playlist.is_empty() {
format!("Playlist [{}/{}]", state.playlist_index + 1, state.playlist.len())
} else {
"Playlist (empty)".to_string()
};
let title = Line::from(vec![
Span::styled("Files", file_style),
Span::raw(" | "),
Span::styled(playlist_text, playlist_style),
]);
// Create one border around the entire content area
let main_block = Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
.title(title) .title(Span::styled("files", file_style))
.style(Theme::widget_border_style()); .style(Theme::widget_border_style());
let inner_area = main_block.inner(main_chunks[1]); // Build the playlist panel title
let playlist_style = Style::default().fg(Theme::bright_foreground());
let playlist_text = if !state.playlist.is_empty() {
format!("playlist [{}/{}]", state.playlist_index + 1, state.playlist.len())
} else {
"playlist".to_string()
};
let playlist_block = Block::default()
.borders(Borders::ALL)
.title(Span::styled(playlist_text, playlist_style))
.style(Theme::widget_border_style());
let files_inner = files_block.inner(content_chunks[0]);
let playlist_inner = playlist_block.inner(content_chunks[1]);
render_title_bar(frame, state, player, main_chunks[0]); render_title_bar(frame, state, player, main_chunks[0]);
frame.render_widget(main_block, main_chunks[1]);
// Tab mode - show only focused panel frame.render_widget(files_block, content_chunks[0]);
if state.focus_playlist { frame.render_widget(playlist_block, content_chunks[1]);
render_right_panel(frame, state, inner_area, tab_mode);
} else { render_file_panel(frame, state, files_inner, false);
render_file_panel(frame, state, inner_area, tab_mode); render_right_panel(frame, state, playlist_inner, false);
}
render_status_bar(frame, state, player, main_chunks[2]); render_status_bar(frame, state, player, main_chunks[2]);
// Show refreshing popup if scanning
if state.is_refreshing {
render_refresh_popup(frame, state.refresh_file_count);
}
// Show confirmation popup if needed // Show confirmation popup if needed
if state.show_refresh_confirm { if state.show_refresh_confirm {
render_confirm_popup(frame, "Refresh library?", "This may take a while"); render_confirm_popup(frame, "Refresh library?", "This may take a while");
@@ -88,12 +88,7 @@ pub fn render(frame: &mut Frame, state: &mut AppState, player: &mut Player) -> (
} }
// Return title bar area, file panel area, and playlist area for mouse event handling // Return title bar area, file panel area, and playlist area for mouse event handling
// Use main_chunks[1] (full area) so mouse coordinates align properly (main_chunks[0], content_chunks[0], content_chunks[1])
if state.focus_playlist {
(main_chunks[0], Rect::default(), main_chunks[1])
} else {
(main_chunks[0], main_chunks[1], Rect::default())
}
} }
fn highlight_search_matches<'a>(text: &str, query: &str, is_selected: bool) -> Vec<Span<'a>> { fn highlight_search_matches<'a>(text: &str, query: &str, is_selected: bool) -> Vec<Span<'a>> {
@@ -157,7 +152,7 @@ fn render_file_panel(frame: &mut Frame, state: &mut AppState, area: Rect, _tab_m
// Store visible height for keyboard navigation scroll calculations // Store visible height for keyboard navigation scroll calculations
state.file_panel_visible_height = visible_height; state.file_panel_visible_height = visible_height;
let in_search = !state.focus_playlist && (state.search_mode || !state.search_matches.is_empty()); let in_search = !state.focus_playlist && state.search_mode;
let search_query = if in_search { state.search_query.to_lowercase() } else { String::new() }; let search_query = if in_search { state.search_query.to_lowercase() } else { String::new() };
// Calculate how many items are below the visible area // Calculate how many items are below the visible area
@@ -234,15 +229,30 @@ fn render_file_panel(frame: &mut Frame, state: &mut AppState, area: Rect, _tab_m
let suffix = if item.is_dir { "/" } else { "" }; let suffix = if item.is_dir { "/" } else { "" };
// Check if item is in playlist (for files) or contains playlist items (for folders)
let in_playlist = if item.is_dir {
state.playlist.iter().any(|p| p.starts_with(&item.path))
} else {
state.playlist.contains(&item.path)
};
let base_style = if is_selected { let base_style = if is_selected {
// Selection bar: yellow/orange when in search (typing or viewing results), blue otherwise // Selection bar: yellow/orange when in search (typing or viewing results), blue otherwise
if in_search { let style = if in_search {
Theme::search_selected() Theme::search_selected()
} else { } else {
Theme::selected() Theme::selected()
};
// Bold if item is in playlist
if in_playlist {
style.add_modifier(ratatui::style::Modifier::BOLD)
} else {
style
} }
} else if state.marked_files.contains(&item.path) { } else if state.marked_files.contains(&item.path) {
Theme::marked() Theme::marked()
} else if in_playlist {
Theme::in_playlist()
} else { } else {
Theme::secondary() Theme::secondary()
}; };
@@ -301,7 +311,7 @@ fn render_right_panel(frame: &mut Frame, state: &mut AppState, area: Rect, _tab_
}; };
// Check if in playlist search mode // 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 in_playlist_search = state.focus_playlist && state.search_mode;
let playlist_search_query = if in_playlist_search { state.search_query.to_lowercase() } else { String::new() }; let playlist_search_query = if in_playlist_search { state.search_query.to_lowercase() } else { String::new() };
// Playlist panel (no longer need the player status box) // Playlist panel (no longer need the player status box)
@@ -379,12 +389,17 @@ fn render_right_panel(frame: &mut Frame, state: &mut AppState, area: Rect, _tab_
} }
fn render_title_bar(frame: &mut Frame, state: &AppState, player: &mut Player, area: Rect) { fn render_title_bar(frame: &mut Frame, state: &AppState, player: &mut Player, area: Rect) {
// Default to stopped if we can't query MPV // Get player state
let player_state = player.get_player_state().unwrap_or(PlayerState::Stopped); let player_state = player.get_player_state().unwrap_or(PlayerState::Stopped);
let background_color = match player_state {
PlayerState::Playing => Theme::success(), // Green for playing // Title bar background color: red for error, gray for stopped/paused, green for playing
PlayerState::Paused => Theme::highlight(), // Blue for paused let background_color = if state.last_error.is_some() {
PlayerState::Stopped => Theme::dim_foreground(), // Gray for stopped Theme::error()
} else {
match player_state {
PlayerState::Playing => Theme::success(), // Green for playing
PlayerState::Paused | PlayerState::Stopped => Theme::border(), // Gray for paused/stopped
}
}; };
// Split the title bar into left and right sections // Split the title bar into left and right sections
@@ -409,16 +424,7 @@ fn render_title_bar(frame: &mut Frame, state: &AppState, player: &mut Player, ar
// Right side: Status • Progress • Volume • Search (if active) // Right side: Status • Progress • Volume • Search (if active)
let mut right_spans = Vec::new(); let mut right_spans = Vec::new();
if state.is_refreshing { {
// Show only "Refreshing library..." when refreshing
right_spans.push(Span::styled(
"Refreshing library... ",
Style::default()
.fg(Theme::background())
.bg(background_color)
.add_modifier(Modifier::BOLD)
));
} else {
// Status (bold when playing) // Status (bold when playing)
let status_text = match player_state { let status_text = match player_state {
PlayerState::Stopped => "Stopped", PlayerState::Stopped => "Stopped",
@@ -506,8 +512,8 @@ fn render_status_bar(frame: &mut Frame, state: &AppState, player: &mut Player, a
// Show search prompt with current query and match count - LEFT aligned // Show search prompt with current query and match count - LEFT aligned
if state.focus_playlist { if state.focus_playlist {
// Searching in playlist // Searching in playlist
if !state.playlist_tab_search_results.is_empty() { if !state.playlist_search_results.is_empty() {
format!("/{}_ Playlist Search: {}/{}", state.search_query, state.playlist_tab_search_index + 1, state.playlist_tab_search_results.len()) format!("/{}_ Playlist Search: {}/{}", state.search_query, state.playlist_search_result_index + 1, state.playlist_search_results.len())
} else if !state.search_query.is_empty() { } else if !state.search_query.is_empty() {
format!("/{}_ [no matches]", state.search_query) format!("/{}_ [no matches]", state.search_query)
} else { } else {
@@ -515,20 +521,14 @@ fn render_status_bar(frame: &mut Frame, state: &AppState, player: &mut Player, a
} }
} else { } else {
// Searching in file panel // Searching in file panel
if !state.tab_search_results.is_empty() { if !state.search_results.is_empty() {
format!("/{}_ Search: {}/{}", state.search_query, state.tab_search_index + 1, state.tab_search_results.len()) format!("/{}_ Search: {}/{}", state.search_query, state.search_result_index + 1, state.search_results.len())
} else if !state.search_query.is_empty() { } else if !state.search_query.is_empty() {
format!("/{}_ [no matches]", state.search_query) format!("/{}_ [no matches]", state.search_query)
} else { } else {
format!("/{}_", state.search_query) format!("/{}_", state.search_query)
} }
} }
} else if !state.search_matches.is_empty() {
// Show search navigation when file search results are active
format!("/{} Search: {}/{}", state.search_query, state.search_match_index + 1, state.search_matches.len())
} else if !state.playlist_search_matches.is_empty() {
// Show search navigation when playlist search results are active
format!("/{} Playlist Search: {}/{}", state.search_query, state.playlist_search_match_index + 1, state.playlist_search_matches.len())
} else if state.visual_mode { } else if state.visual_mode {
// Show visual mode indicator // Show visual mode indicator
format!("-- VISUAL -- {} files marked", state.marked_files.len()) format!("-- VISUAL -- {} files marked", state.marked_files.len())
@@ -724,6 +724,56 @@ fn render_progress_bar(frame: &mut Frame, _state: &AppState, player: &mut Player
frame.render_widget(progress_widget, area); frame.render_widget(progress_widget, area);
} }
fn render_refresh_popup(frame: &mut Frame, file_count: usize) {
// Create centered popup area - bigger for two lines
let area = frame.area();
let popup_width = 50;
let popup_height = 5;
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()
.borders(Borders::ALL)
.style(Style::default()
.bg(Theme::background())
.fg(Theme::bright_foreground()));
let inner = block.inner(popup_area);
frame.render_widget(block, popup_area);
// Build two-line message
let lines = if file_count > 0 {
vec![
Line::from("Refreshing library..."),
Line::from(""),
Line::from(format!("{} files found", file_count))
.style(Style::default().fg(Theme::highlight())),
]
} else {
vec![
Line::from("Refreshing library..."),
]
};
// Render message centered
let message_widget = Paragraph::new(lines)
.alignment(Alignment::Center)
.style(Style::default()
.fg(Theme::bright_foreground())
.bg(Theme::background()));
frame.render_widget(message_widget, inner);
}
fn render_confirm_popup(frame: &mut Frame, title: &str, message: &str) { fn render_confirm_popup(frame: &mut Frame, title: &str, message: &str) {
// Create centered popup area // Create centered popup area
let area = frame.area(); let area = frame.area();

View File

@@ -34,6 +34,10 @@ impl Theme {
Color::Rgb(215, 175, 95) // #d7af5f Color::Rgb(215, 175, 95) // #d7af5f
} }
pub fn normal_red() -> Color {
Color::Rgb(215, 95, 95) // #d75f5f
}
// Semantic mappings // Semantic mappings
pub fn secondary_text() -> Color { pub fn secondary_text() -> Color {
Self::foreground() Self::foreground()
@@ -59,6 +63,10 @@ impl Theme {
Self::normal_yellow() Self::normal_yellow()
} }
pub fn error() -> Color {
Self::normal_red()
}
// Styles // Styles
pub fn widget_border_style() -> Style { pub fn widget_border_style() -> Style {
Style::default().fg(Self::border()).bg(Self::background()) Style::default().fg(Self::border()).bg(Self::background())
@@ -87,4 +95,11 @@ impl Theme {
.fg(Self::background()) .fg(Self::background())
.bg(Self::warning()) .bg(Self::warning())
} }
pub fn in_playlist() -> Style {
Style::default()
.fg(Self::bright_foreground())
.bg(Self::background())
.add_modifier(ratatui::style::Modifier::BOLD)
}
} }