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
This commit is contained in:
Christoffer Martinsson 2025-12-06 12:46:15 +01:00
parent e906fbf294
commit 0093db98c2
3 changed files with 138 additions and 17 deletions

View File

@ -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(' ') => {

View File

@ -23,6 +23,9 @@ pub struct AppState {
pub should_quit: bool,
pub flattened_items: Vec<FlattenedItem>,
pub expanded_dirs: HashSet<PathBuf>,
pub marked_files: HashSet<PathBuf>,
pub playlist: Vec<PathBuf>,
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<Pa
result
}
fn collect_files_from_node(node: &FileTreeNode) -> Vec<PathBuf> {
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
}

View File

@ -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)),