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') => {
|
KeyCode::Char('l') => {
|
||||||
state.expand_selected();
|
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 => {
|
KeyCode::Enter => {
|
||||||
if let Some(item) = state.get_selected_item() {
|
state.play_selection();
|
||||||
if !item.node.is_dir {
|
if let Some(ref path) = state.current_file {
|
||||||
let path = item.node.path.clone();
|
tracing::info!("Playing: {:?} (playlist: {} tracks)", path, state.playlist.len());
|
||||||
state.current_file = Some(path.clone());
|
|
||||||
state.player_state = PlayerState::Playing;
|
|
||||||
tracing::info!("Playing: {:?}", path);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Char(' ') => {
|
KeyCode::Char(' ') => {
|
||||||
|
|||||||
@ -23,6 +23,9 @@ pub struct AppState {
|
|||||||
pub should_quit: bool,
|
pub should_quit: bool,
|
||||||
pub flattened_items: Vec<FlattenedItem>,
|
pub flattened_items: Vec<FlattenedItem>,
|
||||||
pub expanded_dirs: HashSet<PathBuf>,
|
pub expanded_dirs: HashSet<PathBuf>,
|
||||||
|
pub marked_files: HashSet<PathBuf>,
|
||||||
|
pub playlist: Vec<PathBuf>,
|
||||||
|
pub playlist_index: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@ -52,6 +55,9 @@ impl AppState {
|
|||||||
should_quit: false,
|
should_quit: false,
|
||||||
flattened_items,
|
flattened_items,
|
||||||
expanded_dirs,
|
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) {
|
pub fn refresh_flattened_items(&mut self) {
|
||||||
// Keep current expanded state after rescan
|
// Keep current expanded state after rescan
|
||||||
self.rebuild_flattened_items();
|
self.rebuild_flattened_items();
|
||||||
@ -137,3 +208,17 @@ fn flatten_tree(nodes: &[FileTreeNode], depth: usize, expanded_dirs: &HashSet<Pa
|
|||||||
|
|
||||||
result
|
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()
|
.enumerate()
|
||||||
.map(|(idx, item)| {
|
.map(|(idx, item)| {
|
||||||
let indent = " ".repeat(item.depth);
|
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 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 {
|
let style = if idx == state.selected_index {
|
||||||
Style::default().fg(Color::Black).bg(Color::Cyan)
|
Style::default().fg(Color::Black).bg(Color::Cyan)
|
||||||
} else if item.node.is_dir {
|
} else if item.node.is_dir {
|
||||||
Style::default().fg(Color::Blue)
|
Style::default().fg(Color::Blue)
|
||||||
|
} else if state.marked_files.contains(&item.node.path) {
|
||||||
|
Style::default().fg(Color::Yellow)
|
||||||
} else {
|
} else {
|
||||||
Style::default()
|
Style::default()
|
||||||
};
|
};
|
||||||
@ -64,23 +67,30 @@ fn render_status_panel(frame: &mut Frame, state: &AppState, area: Rect) {
|
|||||||
|
|
||||||
// Player state
|
// Player state
|
||||||
let state_text = match state.player_state {
|
let state_text = match state.player_state {
|
||||||
PlayerState::Stopped => "⏹ Stopped",
|
PlayerState::Stopped => "Stopped",
|
||||||
PlayerState::Playing => "▶ Playing",
|
PlayerState::Playing => "Playing",
|
||||||
PlayerState::Paused => "⏸ Paused",
|
PlayerState::Paused => "Paused",
|
||||||
};
|
};
|
||||||
let state_widget = Paragraph::new(state_text)
|
let state_widget = Paragraph::new(state_text)
|
||||||
.block(Block::default().borders(Borders::ALL).title("Status"))
|
.block(Block::default().borders(Borders::ALL).title("Status"))
|
||||||
.style(Style::default().fg(Color::White));
|
.style(Style::default().fg(Color::White));
|
||||||
frame.render_widget(state_widget, chunks[0]);
|
frame.render_widget(state_widget, chunks[0]);
|
||||||
|
|
||||||
// Current file
|
// Current file with playlist position
|
||||||
let current_file = state
|
let current_file = state
|
||||||
.current_file
|
.current_file
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|p| p.file_name())
|
.and_then(|p| p.file_name())
|
||||||
.map(|n| n.to_string_lossy().to_string())
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
.unwrap_or_else(|| "None".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"))
|
.block(Block::default().borders(Borders::ALL).title("Current File"))
|
||||||
.style(Style::default().fg(Color::White));
|
.style(Style::default().fg(Color::White));
|
||||||
frame.render_widget(file_widget, chunks[1]);
|
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(""),
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
Span::styled("j/k", Style::default().fg(Color::Cyan)),
|
Span::styled("j/k", Style::default().fg(Color::Cyan)),
|
||||||
Span::raw(" Navigate down/up"),
|
Span::raw(" Navigate"),
|
||||||
]),
|
]),
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
Span::styled("h/l", Style::default().fg(Color::Cyan)),
|
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![
|
Line::from(vec![
|
||||||
Span::styled("Enter", Style::default().fg(Color::Cyan)),
|
Span::styled("Enter", Style::default().fg(Color::Cyan)),
|
||||||
Span::raw(" Play file"),
|
Span::raw(" Play"),
|
||||||
]),
|
]),
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
Span::styled("Space", Style::default().fg(Color::Cyan)),
|
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![
|
Line::from(vec![
|
||||||
Span::styled("r", Style::default().fg(Color::Cyan)),
|
Span::styled("r", Style::default().fg(Color::Cyan)),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user