diff --git a/Cargo.toml b/Cargo.toml index d859642..b3d9d84 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cm-player" -version = "0.1.5" +version = "0.1.7" edition = "2021" [dependencies] diff --git a/src/main.rs b/src/main.rs index 950efb3..0688304 100644 --- a/src/main.rs +++ b/src/main.rs @@ -161,24 +161,40 @@ async fn handle_key_event(terminal: &mut Terminal< } (KeyCode::Esc, _) => { state.search_matches.clear(); + if state.visual_mode { + state.visual_mode = false; + state.marked_files.clear(); + } } (KeyCode::Char('n'), _) => { if !state.search_matches.is_empty() { state.next_search_match(); - } else { - // If stopped, start from current index (0), otherwise go to next - if state.player_state == PlayerState::Stopped && !state.playlist.is_empty() { + } else if !state.playlist.is_empty() { + // Advance to next track + if state.playlist_index + 1 < state.playlist.len() { + state.playlist_index += 1; state.current_file = Some(state.playlist[state.playlist_index].clone()); - state.player_state = PlayerState::Playing; - if let Some(ref path) = state.current_file { - player.play(path)?; - tracing::info!("Starting playlist: {:?}", path); - } - } else { - state.play_next(); - if let Some(ref path) = state.current_file { - player.play(path)?; - tracing::info!("Next track: {:?}", path); + + match state.player_state { + PlayerState::Playing => { + // Keep playing + if let Some(ref path) = state.current_file { + player.play(path)?; + tracing::info!("Next track: {:?}", path); + } + } + PlayerState::Paused => { + // Load but stay paused + if let Some(ref path) = state.current_file { + player.play(path)?; + player.pause()?; + tracing::info!("Next track (paused): {:?}", path); + } + } + PlayerState::Stopped => { + // Just update current file, stay stopped + tracing::info!("Next track selected (stopped): {:?}", state.current_file); + } } } } @@ -209,15 +225,40 @@ async fn handle_key_event(terminal: &mut Terminal< } (KeyCode::Char('a'), _) => { state.add_to_playlist(); + if state.visual_mode { + state.visual_mode = false; + state.marked_files.clear(); + } } (KeyCode::Char('c'), _) => { state.clear_playlist(); } (KeyCode::Char('p'), _) => { - state.play_previous(); - if let Some(ref path) = state.current_file { - player.play(path)?; - tracing::info!("Previous track: {:?}", path); + if !state.playlist.is_empty() && state.playlist_index > 0 { + state.playlist_index -= 1; + state.current_file = Some(state.playlist[state.playlist_index].clone()); + + match state.player_state { + PlayerState::Playing => { + // Keep playing + if let Some(ref path) = state.current_file { + player.play(path)?; + tracing::info!("Previous track: {:?}", path); + } + } + PlayerState::Paused => { + // Load but stay paused + if let Some(ref path) = state.current_file { + player.play(path)?; + player.pause()?; + tracing::info!("Previous track (paused): {:?}", path); + } + } + PlayerState::Stopped => { + // Just update current file, stay stopped + tracing::info!("Previous track selected (stopped): {:?}", state.current_file); + } + } } } (KeyCode::Enter, _) => { @@ -226,9 +267,14 @@ async fn handle_key_event(terminal: &mut Terminal< player.play(path)?; tracing::info!("Playing: {:?} (playlist: {} tracks)", path, state.playlist.len()); } + if state.visual_mode { + state.visual_mode = false; + state.marked_files.clear(); + } } (KeyCode::Char('s'), _) => { // s: Stop playback + player.stop()?; state.player_state = PlayerState::Stopped; state.current_position = 0.0; state.current_duration = 0.0; @@ -246,7 +292,17 @@ async fn handle_key_event(terminal: &mut Terminal< state.player_state = PlayerState::Playing; tracing::info!("Resumed"); } - PlayerState::Stopped => {} + PlayerState::Stopped => { + // Restart playback from current playlist position + if !state.playlist.is_empty() { + state.current_file = Some(state.playlist[state.playlist_index].clone()); + state.player_state = PlayerState::Playing; + if let Some(ref path) = state.current_file { + player.play(path)?; + tracing::info!("Restarting playback: {:?}", path); + } + } + } } } (KeyCode::Left, _) => { diff --git a/src/player/mod.rs b/src/player/mod.rs index 4cd5e19..6f27b7d 100644 --- a/src/player/mod.rs +++ b/src/player/mod.rs @@ -187,6 +187,13 @@ impl Player { Ok(()) } + pub fn stop(&mut self) -> Result<()> { + self.send_command("stop", &[])?; + self.is_idle = true; + self.position = 0.0; + self.duration = 0.0; + Ok(()) + } pub fn set_volume(&mut self, volume: i64) -> Result<()> { self.send_command("set_property", &[json!("volume"), json!(volume)])?; diff --git a/src/state/mod.rs b/src/state/mod.rs index 4fd5bd6..77fb462 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -33,6 +33,8 @@ pub struct AppState { pub search_match_index: usize, pub tab_search_results: Vec, pub tab_search_index: usize, + pub visual_mode: bool, + pub visual_anchor: usize, } #[derive(Debug, Clone)] @@ -71,6 +73,8 @@ impl AppState { search_match_index: 0, tab_search_results: Vec::new(), tab_search_index: 0, + visual_mode: false, + visual_anchor: 0, } } @@ -81,12 +85,20 @@ impl AppState { if self.selected_index < self.scroll_offset { self.scroll_offset = self.selected_index; } + // Update visual selection if in visual mode + if self.visual_mode { + self.update_visual_selection(); + } } } pub fn move_selection_down(&mut self) { if self.selected_index < self.flattened_items.len().saturating_sub(1) { self.selected_index += 1; + // Update visual selection if in visual mode + if self.visual_mode { + self.update_visual_selection(); + } } } @@ -120,11 +132,39 @@ impl AppState { } pub fn collapse_selected(&mut self) { - if let Some(item) = self.get_selected_item() { + let item = self.get_selected_item().cloned(); + if let Some(item) = item { if item.node.is_dir { let path = item.node.path.clone(); - self.expanded_dirs.remove(&path); - self.rebuild_flattened_items(); + let was_expanded = self.expanded_dirs.contains(&path); + + if was_expanded { + // Close the expanded folder + self.expanded_dirs.remove(&path); + self.rebuild_flattened_items(); + } else { + // Folder is collapsed, close parent instead and jump to it + if let Some(parent) = path.parent() { + let parent_buf = parent.to_path_buf(); + self.expanded_dirs.remove(&parent_buf); + self.rebuild_flattened_items(); + // Jump to parent folder + if let Some(parent_idx) = self.flattened_items.iter().position(|i| i.node.path == parent_buf) { + self.selected_index = parent_idx; + } + } + } + } else { + // Close parent folder when on a file and jump to it + if let Some(parent) = item.node.path.parent() { + let parent_buf = parent.to_path_buf(); + self.expanded_dirs.remove(&parent_buf); + self.rebuild_flattened_items(); + // Jump to parent folder + if let Some(parent_idx) = self.flattened_items.iter().position(|i| i.node.path == parent_buf) { + self.selected_index = parent_idx; + } + } } } } @@ -140,13 +180,37 @@ 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); + if self.visual_mode { + // Exit visual mode and mark all files in range + self.update_visual_selection(); + self.visual_mode = false; + } else { + // Enter visual mode + self.visual_mode = true; + self.visual_anchor = self.selected_index; + // Clear previous marks when entering visual mode + self.marked_files.clear(); + // Mark current file + if let Some(item) = self.get_selected_item() { + if !item.node.is_dir { + self.marked_files.insert(item.node.path.clone()); + } + } + } + } + + fn update_visual_selection(&mut self) { + // Clear marks + self.marked_files.clear(); + + // Mark all files between anchor and current position + let start = self.visual_anchor.min(self.selected_index); + let end = self.visual_anchor.max(self.selected_index); + + for i in start..=end { + if let Some(item) = self.flattened_items.get(i) { + if !item.node.is_dir { + self.marked_files.insert(item.node.path.clone()); } } } @@ -229,14 +293,6 @@ impl AppState { } } - 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(); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index babfcd1..2ad59fa 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -95,8 +95,19 @@ fn render_right_panel(frame: &mut Frame, state: &AppState, area: Rect) { .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| path.to_string_lossy().to_string()); - let style = if idx == state.playlist_index && state.player_state != PlayerState::Stopped { - Theme::selected() + let style = if idx == state.playlist_index { + // Color based on player state + match state.player_state { + PlayerState::Playing => Style::default() + .fg(Theme::background()) + .bg(Theme::success()), // Green + PlayerState::Paused => Style::default() + .fg(Theme::background()) + .bg(Theme::highlight()), // Blue + PlayerState::Stopped => Style::default() + .fg(Theme::background()) + .bg(Theme::warning()), // Yellow/orange + } } else { Theme::secondary() }; @@ -121,7 +132,7 @@ fn render_right_panel(frame: &mut Frame, state: &AppState, area: Rect) { ); let mut playlist_state = ListState::default(); - if state.player_state != PlayerState::Stopped && !state.playlist.is_empty() { + if !state.playlist.is_empty() { playlist_state.select(Some(state.playlist_index)); } frame.render_stateful_widget(playlist_widget, area, &mut playlist_state); @@ -249,9 +260,15 @@ fn render_status_bar(frame: &mut Frame, state: &AppState, area: Rect) { let status_bar = Paragraph::new(search_text) .style(Style::default().fg(Theme::foreground()).bg(Theme::background())); frame.render_widget(status_bar, area); + } else if state.visual_mode { + // Show visual mode indicator + let visual_text = format!("-- VISUAL -- {} files marked", state.marked_files.len()); + let status_bar = Paragraph::new(visual_text) + .style(Style::default().fg(Theme::foreground()).bg(Theme::background())); + frame.render_widget(status_bar, area); } else { - // Normal mode shortcuts (always shown when not in search mode) - let shortcuts = "/: Search • v: Mark • a: Add • c: Clear • Enter: Play • Space: Pause • s: Stop • ←→: Seek • +/-: Vol • n/p: Next/Prev • r: Rescan • q: Quit"; + // Normal mode shortcuts (always shown when not in search or visual mode) + let shortcuts = "/: Search • v: Visual • a: Add • c: Clear • Enter: Play • Space: Pause • s: Stop • ←→: Seek • +/-: Vol • n/p: Next/Prev • r: Rescan • q: Quit"; let status_bar = Paragraph::new(shortcuts) .style(Style::default().fg(Theme::muted_text()).bg(Theme::background())) .alignment(Alignment::Center);