From 0093db98c2acdb56b5591440dd76f58fbc2a544d Mon Sep 17 00:00:00 2001 From: Christoffer Martinsson Date: Sat, 6 Dec 2025 12:46:15 +0100 Subject: [PATCH] Add playlist support with marking and folder playback - Mark files with 't' key (shown with * prefix in yellow) - Clear marks with 'c' key - Enter plays: marked files > whole folder > single file - Navigate playlist with 'n' (next) and 'p' (previous) - Show playlist position in status (e.g., "song.mp3 [3/10]") - Collect all files recursively when playing folder - Remove emoji icons from status panel - Update help text with new keybindings --- src/main.rs | 28 ++++++++++++---- src/state/mod.rs | 85 ++++++++++++++++++++++++++++++++++++++++++++++++ src/ui/mod.rs | 42 ++++++++++++++++++------ 3 files changed, 138 insertions(+), 17 deletions(-) diff --git a/src/main.rs b/src/main.rs index 38c153b..e913965 100644 --- a/src/main.rs +++ b/src/main.rs @@ -113,14 +113,28 @@ async fn handle_key_event(state: &mut AppState, key_code: KeyCode) -> Result<()> KeyCode::Char('l') => { state.expand_selected(); } + KeyCode::Char('t') => { + state.toggle_mark(); + } + KeyCode::Char('c') => { + state.clear_marks(); + } + KeyCode::Char('n') => { + state.play_next(); + if let Some(ref path) = state.current_file { + tracing::info!("Next track: {:?}", path); + } + } + KeyCode::Char('p') => { + state.play_previous(); + if let Some(ref path) = state.current_file { + tracing::info!("Previous track: {:?}", path); + } + } KeyCode::Enter => { - if let Some(item) = state.get_selected_item() { - if !item.node.is_dir { - let path = item.node.path.clone(); - state.current_file = Some(path.clone()); - state.player_state = PlayerState::Playing; - tracing::info!("Playing: {:?}", path); - } + state.play_selection(); + if let Some(ref path) = state.current_file { + tracing::info!("Playing: {:?} (playlist: {} tracks)", path, state.playlist.len()); } } KeyCode::Char(' ') => { diff --git a/src/state/mod.rs b/src/state/mod.rs index 5aaf646..6adb78c 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -23,6 +23,9 @@ pub struct AppState { pub should_quit: bool, pub flattened_items: Vec, pub expanded_dirs: HashSet, + pub marked_files: HashSet, + pub playlist: Vec, + pub playlist_index: usize, } #[derive(Debug, Clone)] @@ -52,6 +55,9 @@ impl AppState { should_quit: false, flattened_items, expanded_dirs, + marked_files: HashSet::new(), + playlist: Vec::new(), + playlist_index: 0, } } @@ -105,6 +111,71 @@ impl AppState { } } + pub fn toggle_mark(&mut self) { + if let Some(item) = self.get_selected_item() { + if !item.node.is_dir { + let path = item.node.path.clone(); + if self.marked_files.contains(&path) { + self.marked_files.remove(&path); + } else { + self.marked_files.insert(path); + } + } + } + } + + pub fn clear_marks(&mut self) { + self.marked_files.clear(); + } + + pub fn play_selection(&mut self) { + // Priority: marked files > directory > single file + if !self.marked_files.is_empty() { + // Play marked files + self.playlist = self.marked_files.iter().cloned().collect(); + self.playlist.sort(); + self.playlist_index = 0; + if let Some(first) = self.playlist.first() { + self.current_file = Some(first.clone()); + self.player_state = PlayerState::Playing; + } + } else if let Some(item) = self.get_selected_item() { + let node = item.node.clone(); + if node.is_dir { + // Play all files in directory + self.playlist = collect_files_from_node(&node); + self.playlist_index = 0; + if let Some(first) = self.playlist.first() { + self.current_file = Some(first.clone()); + self.player_state = PlayerState::Playing; + } + } else { + // Play single file + let path = node.path.clone(); + self.playlist = vec![path.clone()]; + self.playlist_index = 0; + self.current_file = Some(path); + self.player_state = PlayerState::Playing; + } + } + } + + pub fn play_next(&mut self) { + if self.playlist_index + 1 < self.playlist.len() { + self.playlist_index += 1; + self.current_file = Some(self.playlist[self.playlist_index].clone()); + self.player_state = PlayerState::Playing; + } + } + + pub fn play_previous(&mut self) { + if self.playlist_index > 0 { + self.playlist_index -= 1; + self.current_file = Some(self.playlist[self.playlist_index].clone()); + self.player_state = PlayerState::Playing; + } + } + pub fn refresh_flattened_items(&mut self) { // Keep current expanded state after rescan self.rebuild_flattened_items(); @@ -137,3 +208,17 @@ fn flatten_tree(nodes: &[FileTreeNode], depth: usize, expanded_dirs: &HashSet Vec { + let mut files = Vec::new(); + + if node.is_dir { + for child in &node.children { + files.extend(collect_files_from_node(child)); + } + } else { + files.push(node.path.clone()); + } + + files +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 0329cd7..03563ea 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -24,13 +24,16 @@ fn render_file_panel(frame: &mut Frame, state: &AppState, area: Rect) { .enumerate() .map(|(idx, item)| { let indent = " ".repeat(item.depth); + let mark = if state.marked_files.contains(&item.node.path) { "* " } else { "" }; let suffix = if item.node.is_dir { "/" } else { "" }; - let text = format!("{}{}{}", indent, item.node.name, suffix); + let text = format!("{}{}{}{}", indent, mark, item.node.name, suffix); let style = if idx == state.selected_index { Style::default().fg(Color::Black).bg(Color::Cyan) } else if item.node.is_dir { Style::default().fg(Color::Blue) + } else if state.marked_files.contains(&item.node.path) { + Style::default().fg(Color::Yellow) } else { Style::default() }; @@ -64,23 +67,30 @@ fn render_status_panel(frame: &mut Frame, state: &AppState, area: Rect) { // Player state let state_text = match state.player_state { - PlayerState::Stopped => "⏹ Stopped", - PlayerState::Playing => "▶ Playing", - PlayerState::Paused => "⏸ Paused", + PlayerState::Stopped => "Stopped", + PlayerState::Playing => "Playing", + PlayerState::Paused => "Paused", }; let state_widget = Paragraph::new(state_text) .block(Block::default().borders(Borders::ALL).title("Status")) .style(Style::default().fg(Color::White)); frame.render_widget(state_widget, chunks[0]); - // Current file + // Current file with playlist position let current_file = state .current_file .as_ref() .and_then(|p| p.file_name()) .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| "None".to_string()); - let file_widget = Paragraph::new(current_file) + + let playlist_info = if !state.playlist.is_empty() { + format!("{} [{}/{}]", current_file, state.playlist_index + 1, state.playlist.len()) + } else { + current_file + }; + + let file_widget = Paragraph::new(playlist_info) .block(Block::default().borders(Borders::ALL).title("Current File")) .style(Style::default().fg(Color::White)); frame.render_widget(file_widget, chunks[1]); @@ -115,19 +125,31 @@ fn render_status_panel(frame: &mut Frame, state: &AppState, area: Rect) { Line::from(""), Line::from(vec![ Span::styled("j/k", Style::default().fg(Color::Cyan)), - Span::raw(" Navigate down/up"), + Span::raw(" Navigate"), ]), Line::from(vec![ Span::styled("h/l", Style::default().fg(Color::Cyan)), - Span::raw(" Collapse/expand dir"), + Span::raw(" Collapse/expand"), + ]), + Line::from(vec![ + Span::styled("t", Style::default().fg(Color::Cyan)), + Span::raw(" Mark file"), + ]), + Line::from(vec![ + Span::styled("c", Style::default().fg(Color::Cyan)), + Span::raw(" Clear marks"), ]), Line::from(vec![ Span::styled("Enter", Style::default().fg(Color::Cyan)), - Span::raw(" Play file"), + Span::raw(" Play"), ]), Line::from(vec![ Span::styled("Space", Style::default().fg(Color::Cyan)), - Span::raw(" Pause/Resume"), + Span::raw(" Pause"), + ]), + Line::from(vec![ + Span::styled("n/p", Style::default().fg(Color::Cyan)), + Span::raw(" Next/Prev"), ]), Line::from(vec![ Span::styled("r", Style::default().fg(Color::Cyan)),