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:
parent
e906fbf294
commit
0093db98c2
28
src/main.rs
28
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(' ') => {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user