Add playlist bounds validation and search mode visual indicator
All checks were successful
Build and Release / build-and-release (push) Successful in 1m1s

- Add bounds checking to prevent accessing invalid playlist indices
- Yellow/orange selection bar when in search mode
- Validate playlist index after navigation operations
- Handle empty playlists gracefully
This commit is contained in:
Christoffer Martinsson 2025-12-07 16:02:44 +01:00
parent 248c5701fb
commit 59f9f548c1
5 changed files with 79 additions and 71 deletions

View File

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

View File

@ -339,29 +339,32 @@ async fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<
// Next track // Next track
if !state.playlist.is_empty() && state.playlist_index + 1 < state.playlist.len() { if !state.playlist.is_empty() && state.playlist_index + 1 < state.playlist.len() {
state.playlist_index += 1; state.playlist_index += 1;
state.current_file = Some(state.playlist[state.playlist_index].clone()); // Validate index before accessing playlist
if state.playlist_index < state.playlist.len() {
state.current_file = Some(state.playlist[state.playlist_index].clone());
match state.player_state { match state.player_state {
PlayerState::Playing => { PlayerState::Playing => {
// Keep playing // Keep playing
if let Some(ref path) = state.current_file { if let Some(ref path) = state.current_file {
player.play(path)?; player.play(path)?;
player.update_metadata(); // Update metadata immediately player.update_metadata(); // Update metadata immediately
tracing::info!("Next track: {:?}", path); tracing::info!("Next track: {:?}", path);
}
} }
} PlayerState::Paused => {
PlayerState::Paused => { // Load but stay paused
// Load but stay paused if let Some(ref path) = state.current_file {
if let Some(ref path) = state.current_file { player.play(path)?;
player.play(path)?; player.update_metadata(); // Update metadata immediately
player.update_metadata(); // Update metadata immediately player.pause()?;
player.pause()?; tracing::info!("Next track (paused): {:?}", path);
tracing::info!("Next track (paused): {:?}", path); }
}
PlayerState::Stopped => {
// Just update current file, stay stopped
tracing::info!("Next track selected (stopped): {:?}", state.current_file);
} }
}
PlayerState::Stopped => {
// Just update current file, stay stopped
tracing::info!("Next track selected (stopped): {:?}", state.current_file);
} }
} }
} }
@ -370,29 +373,32 @@ async fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<
// Previous track // Previous track
if !state.playlist.is_empty() && state.playlist_index > 0 { if !state.playlist.is_empty() && state.playlist_index > 0 {
state.playlist_index -= 1; state.playlist_index -= 1;
state.current_file = Some(state.playlist[state.playlist_index].clone()); // Validate index before accessing playlist
if state.playlist_index < state.playlist.len() {
state.current_file = Some(state.playlist[state.playlist_index].clone());
match state.player_state { match state.player_state {
PlayerState::Playing => { PlayerState::Playing => {
// Keep playing // Keep playing
if let Some(ref path) = state.current_file { if let Some(ref path) = state.current_file {
player.play(path)?; player.play(path)?;
player.update_metadata(); // Update metadata immediately player.update_metadata(); // Update metadata immediately
tracing::info!("Previous track: {:?}", path); tracing::info!("Previous track: {:?}", path);
}
} }
} PlayerState::Paused => {
PlayerState::Paused => { // Load but stay paused
// Load but stay paused if let Some(ref path) = state.current_file {
if let Some(ref path) = state.current_file { player.play(path)?;
player.play(path)?; player.update_metadata(); // Update metadata immediately
player.update_metadata(); // Update metadata immediately player.pause()?;
player.pause()?; tracing::info!("Previous track (paused): {:?}", path);
tracing::info!("Previous track (paused): {:?}", path); }
}
PlayerState::Stopped => {
// Just update current file, stay stopped
tracing::info!("Previous track selected (stopped): {:?}", state.current_file);
} }
}
PlayerState::Stopped => {
// Just update current file, stay stopped
tracing::info!("Previous track selected (stopped): {:?}", state.current_file);
} }
} }
} }

View File

@ -271,6 +271,10 @@ impl AppState {
if let Some(first) = self.playlist.first() { if let Some(first) = self.playlist.first() {
self.current_file = Some(first.clone()); self.current_file = Some(first.clone());
self.player_state = PlayerState::Playing; self.player_state = PlayerState::Playing;
} else {
// Empty playlist
self.current_file = None;
self.player_state = PlayerState::Stopped;
} }
} else if let Some(item) = self.get_selected_item() { } else if let Some(item) = self.get_selected_item() {
let node = item.node.clone(); let node = item.node.clone();
@ -281,6 +285,10 @@ impl AppState {
if let Some(first) = self.playlist.first() { if let Some(first) = self.playlist.first() {
self.current_file = Some(first.clone()); self.current_file = Some(first.clone());
self.player_state = PlayerState::Playing; self.player_state = PlayerState::Playing;
} else {
// Empty directory
self.current_file = None;
self.player_state = PlayerState::Stopped;
} }
} else { } else {
// Play single file // Play single file
@ -296,8 +304,11 @@ impl AppState {
pub fn play_next(&mut self) { pub fn play_next(&mut self) {
if self.playlist_index + 1 < self.playlist.len() { if self.playlist_index + 1 < self.playlist.len() {
self.playlist_index += 1; self.playlist_index += 1;
self.current_file = Some(self.playlist[self.playlist_index].clone()); // Double-check index is valid before accessing
self.player_state = PlayerState::Playing; if self.playlist_index < self.playlist.len() {
self.current_file = Some(self.playlist[self.playlist_index].clone());
self.player_state = PlayerState::Playing;
}
} }
} }

View File

@ -48,7 +48,7 @@ pub fn render(frame: &mut Frame, state: &mut AppState, player: &Player) -> (Rect
(main_chunks[0], content_chunks[0]) (main_chunks[0], content_chunks[0])
} }
fn highlight_search_matches<'a>(text: &str, query: &str, search_typing: bool, is_selected: bool) -> Vec<Span<'a>> { fn highlight_search_matches<'a>(text: &str, query: &str, is_selected: bool) -> Vec<Span<'a>> {
let query_lower = query.to_lowercase(); let query_lower = query.to_lowercase();
let mut spans = Vec::new(); let mut spans = Vec::new();
@ -66,17 +66,9 @@ fn highlight_search_matches<'a>(text: &str, query: &str, search_typing: bool, is
spans.push(Span::raw(current_segment.clone())); spans.push(Span::raw(current_segment.clone()));
current_segment.clear(); current_segment.clear();
} }
// Add matched character with styling based on mode // Add matched character with styling
if search_typing && is_selected { if is_selected {
// While typing on selected row: green bg with black fg (inverted) // On selected row: bold black text on selection bar (yellow or blue)
spans.push(Span::styled(
ch.to_string(),
Style::default()
.fg(Theme::background())
.bg(Theme::success()),
));
} else if !search_typing && is_selected {
// After Enter on selected row: bold black text on blue selection bar
spans.push(Span::styled( spans.push(Span::styled(
ch.to_string(), ch.to_string(),
Style::default() Style::default()
@ -116,7 +108,6 @@ fn render_file_panel(frame: &mut Frame, state: &mut AppState, area: Rect) {
state.update_scroll_offset(visible_height); state.update_scroll_offset(visible_height);
let in_search = state.search_mode || !state.search_matches.is_empty(); let in_search = state.search_mode || !state.search_matches.is_empty();
let search_typing = state.search_mode; // True when actively typing search
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() };
let items: Vec<ListItem> = state let items: Vec<ListItem> = state
@ -133,7 +124,7 @@ fn render_file_panel(frame: &mut Frame, state: &mut AppState, area: Rect) {
// Add folder icon for directories // Add folder icon for directories
let icon = if item.node.is_dir { let icon = if item.node.is_dir {
// Bold black icon on selection bar, blue otherwise // Bold black icon on selection bar, blue otherwise
if !search_typing && is_selected { if is_selected {
Span::styled("", Style::default().fg(Theme::background()).add_modifier(Modifier::BOLD)) Span::styled("", Style::default().fg(Theme::background()).add_modifier(Modifier::BOLD))
} else { } else {
Span::styled("", Style::default().fg(Theme::highlight())) Span::styled("", Style::default().fg(Theme::highlight()))
@ -142,23 +133,20 @@ fn render_file_panel(frame: &mut Frame, state: &mut AppState, area: Rect) {
Span::raw(" ") Span::raw(" ")
}; };
let name_spans = if in_search && !search_query.is_empty() { let name_spans = if in_search && !search_query.is_empty() {
highlight_search_matches(&item.node.name, &search_query, search_typing, is_selected) highlight_search_matches(&item.node.name, &search_query, is_selected)
} else { } else {
vec![Span::raw(&item.node.name)] vec![Span::raw(&item.node.name)]
}; };
let suffix = if item.node.is_dir { "/" } else { "" }; let suffix = if item.node.is_dir { "/" } else { "" };
let base_style = if search_typing { let base_style = if is_selected {
// While typing search: no selection bar, just normal colors // Selection bar: yellow/orange when in search (typing or viewing results), blue otherwise
if state.marked_files.contains(&item.node.path) { if in_search {
Theme::marked() Theme::search_selected()
} else { } else {
Theme::secondary() Theme::selected()
} }
} else if is_selected {
// After pressing Enter or normal mode: normal blue selection bar
Theme::selected()
} else if state.marked_files.contains(&item.node.path) { } else if state.marked_files.contains(&item.node.path) {
Theme::marked() Theme::marked()
} else { } else {
@ -189,11 +177,8 @@ fn render_file_panel(frame: &mut Frame, state: &mut AppState, area: Rect) {
); );
let mut list_state = ListState::default(); let mut list_state = ListState::default();
// Don't show selection bar widget while typing search - we use inverted colors instead // Always show selection bar (yellow/orange in search mode, blue otherwise)
// Show it in normal mode and after executing search (Enter) list_state.select(Some(state.selected_index));
if !search_typing {
list_state.select(Some(state.selected_index));
}
*list_state.offset_mut() = state.scroll_offset; *list_state.offset_mut() = state.scroll_offset;
frame.render_stateful_widget(list, area, &mut list_state); frame.render_stateful_widget(list, area, &mut list_state);
} }

View File

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