3 Commits

Author SHA1 Message Date
d53542afa6 Eliminate code duplication with unified action functions
All checks were successful
Build and Release / build-and-release (push) Successful in 55s
Create action functions for stop, volume, and seek operations and
use them consistently across keyboard handlers, mouse handlers, and
API handlers. This eliminates duplicate logic and ensures consistent
behavior across all input methods.

Also fixes stop command triggering auto-advance by setting the
skip_position_update flag to prevent the Playing→Stopped transition
from being interpreted as a natural track ending.
2025-12-12 16:19:56 +01:00
be9ee8c005 Move refresh status to centered popup
All checks were successful
Build and Release / build-and-release (push) Successful in 54s
Display "Refreshing library..." in a centered popup overlay instead
of showing it in the title bar. This makes the refresh status more
prominent and cleaner.
2025-12-12 15:45:12 +01:00
7c083cfb0e Filter out empty directories during library scan
All checks were successful
Build and Release / build-and-release (push) Successful in 54s
Skip directories that contain no media files or non-empty subdirectories.
This prevents empty folders from appearing in the file list, which can
occur when NFS cache is stale or when directories are emptied.
2025-12-12 15:34:29 +01:00
5 changed files with 191 additions and 69 deletions

View File

@@ -1,6 +1,6 @@
[package]
name = "cm-player"
version = "0.1.29"
version = "0.1.33"
edition = "2021"
[dependencies]

View File

@@ -262,14 +262,44 @@ fn action_toggle_play_pause(state: &mut AppState, player: &mut player::Player) -
Ok(())
}
fn action_stop(state: &mut AppState, player: &mut player::Player) -> Result<()> {
fn action_stop(state: &mut AppState, player: &mut player::Player, skip_position_update: &mut bool) -> Result<()> {
player.stop()?;
state.current_position = 0.0;
state.current_duration = 0.0;
*skip_position_update = true; // Prevent auto-advance on manual stop
tracing::info!("Stopped");
Ok(())
}
fn action_volume_up(state: &mut AppState, player: &mut player::Player) -> Result<()> {
state.volume = (state.volume + 5).min(100);
player.set_volume(state.volume)?;
tracing::info!("Volume: {}%", state.volume);
Ok(())
}
fn action_volume_down(state: &mut AppState, player: &mut player::Player) -> Result<()> {
state.volume = (state.volume - 5).max(0);
player.set_volume(state.volume)?;
tracing::info!("Volume: {}%", state.volume);
Ok(())
}
fn action_volume_set(state: &mut AppState, player: &mut player::Player, volume: i64) -> Result<()> {
state.volume = volume.clamp(0, 100);
player.set_volume(state.volume)?;
tracing::info!("Volume: {}%", state.volume);
Ok(())
}
fn action_seek(player: &mut player::Player, seconds: f64) -> Result<()> {
if player.get_player_state() != Some(PlayerState::Stopped) {
player.seek(seconds)?;
tracing::info!("Seek {}s", seconds);
}
Ok(())
}
fn action_remove_from_playlist(state: &mut AppState, player: &mut player::Player) -> Result<()> {
let was_playing_removed = state.playlist_index == state.selected_playlist_index;
let was_playing = player.get_player_state() == Some(PlayerState::Playing);
@@ -415,7 +445,7 @@ fn handle_context_menu_action(menu_type: state::ContextMenuType, selected: usize
}
state::ContextMenuType::TitleBar => {
match selected {
0 => action_stop(state, player)?,
0 => action_stop(state, player, skip_position_update)?,
1 => {
state.cycle_play_mode();
tracing::info!("Play mode: {:?}", state.play_mode);
@@ -453,26 +483,11 @@ fn run_app<B: ratatui::backend::Backend>(
tracing::debug!("Processing API command: {:?}", cmd);
match cmd {
api::ApiCommand::PlayPause => {
if let Some(player_state) = player.get_player_state() {
match player_state {
PlayerState::Stopped => {
// Play current file or first in playlist
if state.current_file.is_none() && !state.playlist.is_empty() {
state.current_file = Some(state.playlist[0].clone());
}
if let Some(ref file) = state.current_file {
player.play(file)?;
}
}
PlayerState::Playing => player.pause()?,
PlayerState::Paused => player.resume()?,
}
state_changed = true;
}
action_toggle_play_pause(state, player)?;
state_changed = true;
}
api::ApiCommand::Stop => {
player.stop()?;
state.current_file = None;
action_stop(state, player, &mut skip_position_update)?;
state_changed = true;
}
api::ApiCommand::Next => {
@@ -484,26 +499,23 @@ fn run_app<B: ratatui::backend::Backend>(
state_changed = true;
}
api::ApiCommand::VolumeUp => {
state.volume = (state.volume + 5).min(100);
player.set_volume(state.volume)?;
action_volume_up(state, player)?;
state_changed = true;
}
api::ApiCommand::VolumeDown => {
state.volume = (state.volume - 5).max(0);
player.set_volume(state.volume)?;
action_volume_down(state, player)?;
state_changed = true;
}
api::ApiCommand::VolumeSet { volume } => {
state.volume = volume.clamp(0, 100);
player.set_volume(state.volume)?;
action_volume_set(state, player, volume)?;
state_changed = true;
}
api::ApiCommand::SeekForward { seconds } => {
player.seek(seconds)?;
action_seek(player, seconds)?;
state_changed = true;
}
api::ApiCommand::SeekBackward { seconds } => {
player.seek(-seconds)?;
action_seek(player, -seconds)?;
state_changed = true;
}
api::ApiCommand::GetStatus => {
@@ -554,8 +566,10 @@ fn run_app<B: ratatui::backend::Backend>(
// Check if track ended and play next
// When MPV finishes playing a file, it goes to idle (Stopped state)
// Detect Playing → Stopped transition = track ended, play next
// But skip this check if we just manually stopped (skip_position_update flag)
if previous_player_state == Some(PlayerState::Playing)
&& player_state == PlayerState::Stopped
&& !skip_position_update
{
let should_continue = state.play_next();
// play_next() returns true if should continue playing, false if should stop
@@ -661,14 +675,29 @@ fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, st
state.show_refresh_confirm = false;
state.is_refreshing = true;
terminal.draw(|f| { let _ = ui::render(f, state, player); })?; // Show "Refreshing library..." immediately
tracing::info!("Rescanning...");
let cache_dir = cache::get_cache_dir()?;
// Delete old cache files to ensure fresh scan
let _ = std::fs::remove_file(cache_dir.join("file_tree.json"));
let _ = std::fs::remove_file(cache_dir.join("metadata.json"));
// Perform fresh scan
let new_cache = scanner::scan_paths(&state.config.scan_paths.paths)?;
new_cache.save(&cache_dir)?;
// Replace old cache completely
state.cache = new_cache;
state.refresh_flattened_items();
state.refresh_flattened_items(); // This also cleans up playlist and expanded_dirs
// If current file was removed, stop playback
if state.current_file.is_none() {
player.stop()?;
state.current_position = 0.0;
state.current_duration = 0.0;
}
state.is_refreshing = false;
tracing::info!("Rescan complete");
}
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
state.show_refresh_confirm = false;
@@ -879,7 +908,7 @@ fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, st
}
}
(KeyCode::Char('s'), _) => {
action_stop(state, player)?;
action_stop(state, player, skip_position_update)?;
}
(KeyCode::Char('m'), _) => {
state.cycle_play_mode();
@@ -893,28 +922,16 @@ fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, st
action_toggle_play_pause(state, player)?;
}
(KeyCode::Char('H'), KeyModifiers::SHIFT) => {
if player.get_player_state() != Some(PlayerState::Stopped) {
player.seek(-10.0)?;
tracing::info!("Seek backward 10s");
}
action_seek(player, -10.0)?;
}
(KeyCode::Char('L'), KeyModifiers::SHIFT) => {
if player.get_player_state() != Some(PlayerState::Stopped) {
player.seek(10.0)?;
tracing::info!("Seek forward 10s");
}
action_seek(player, 10.0)?;
}
(KeyCode::Char('+'), _) | (KeyCode::Char('='), _) => {
let new_volume = (state.volume + 5).min(100);
state.volume = new_volume;
player.set_volume(new_volume)?;
tracing::info!("Volume: {}%", new_volume);
action_volume_up(state, player)?;
}
(KeyCode::Char('-'), _) => {
let new_volume = (state.volume - 5).max(0);
state.volume = new_volume;
player.set_volume(new_volume)?;
tracing::info!("Volume: {}%", new_volume);
action_volume_down(state, player)?;
}
(KeyCode::Char('r'), _) => {
state.show_refresh_confirm = true;
@@ -1017,10 +1034,7 @@ fn handle_mouse_event(state: &mut AppState, mouse: MouseEvent, title_bar_area: r
&& y < title_bar_area.y + title_bar_area.height
{
// Scroll on title bar = decrease volume
let new_volume = (state.volume - 5).max(0);
state.volume = new_volume;
player.set_volume(new_volume)?;
tracing::info!("Volume: {}%", new_volume);
action_volume_down(state, player)?;
} else if x >= playlist_area.x
&& x < playlist_area.x + playlist_area.width
&& y >= playlist_area.y
@@ -1043,10 +1057,7 @@ fn handle_mouse_event(state: &mut AppState, mouse: MouseEvent, title_bar_area: r
&& y < title_bar_area.y + title_bar_area.height
{
// Scroll on title bar = increase volume
let new_volume = (state.volume + 5).min(100);
state.volume = new_volume;
player.set_volume(new_volume)?;
tracing::info!("Volume: {}%", new_volume);
action_volume_up(state, player)?;
} else if x >= playlist_area.x
&& x < playlist_area.x + playlist_area.width
&& y >= playlist_area.y

View File

@@ -63,7 +63,10 @@ pub fn scan_directory(root_path: &Path) -> Result<FileTreeNode> {
if entry.file_type().is_dir() {
// Recursively scan subdirectories
if let Ok(child_node) = scan_directory(path) {
node.children.push(child_node);
// Only add directory if it contains media files or non-empty subdirectories
if !child_node.children.is_empty() {
node.children.push(child_node);
}
}
} else if is_media_file(path) {
// Add media file

View File

@@ -587,8 +587,84 @@ impl AppState {
}
pub fn refresh_flattened_items(&mut self) {
// Keep current expanded state after rescan
// Clean up expanded_dirs - remove paths that no longer exist in new cache
self.cleanup_expanded_dirs();
// Rebuild view with cleaned expanded state
self.rebuild_flattened_items();
// Clean up playlist - remove files that no longer exist in cache
self.cleanup_playlist();
}
fn cleanup_expanded_dirs(&mut self) {
// Build a set of valid directory paths from the cache
let mut valid_dirs = std::collections::HashSet::new();
fn collect_dirs(node: &crate::cache::FileTreeNode, dirs: &mut std::collections::HashSet<std::path::PathBuf>) {
if node.is_dir {
dirs.insert(node.path.clone());
}
for child in &node.children {
collect_dirs(child, dirs);
}
}
for root in &self.cache.file_tree {
collect_dirs(root, &mut valid_dirs);
}
// Remove invalid paths from expanded_dirs
let original_len = self.expanded_dirs.len();
self.expanded_dirs.retain(|path| valid_dirs.contains(path));
if self.expanded_dirs.len() < original_len {
tracing::info!("Cleaned up expanded_dirs: removed {} invalid paths", original_len - self.expanded_dirs.len());
}
}
fn cleanup_playlist(&mut self) {
// Build a set of valid paths from the cache for fast lookup
let mut valid_paths = std::collections::HashSet::new();
fn collect_paths(node: &crate::cache::FileTreeNode, paths: &mut std::collections::HashSet<std::path::PathBuf>) {
if !node.is_dir {
paths.insert(node.path.clone());
}
for child in &node.children {
collect_paths(child, paths);
}
}
for root in &self.cache.file_tree {
collect_paths(root, &mut valid_paths);
}
// Check if current file is invalid
let current_file_invalid = if let Some(ref current) = self.current_file {
!valid_paths.contains(current)
} else {
false
};
if current_file_invalid {
self.current_file = None;
tracing::info!("Current playing file was deleted, cleared current_file");
}
// Remove files from playlist that don't exist in cache
let original_len = self.playlist.len();
self.playlist.retain(|path| valid_paths.contains(path));
// Adjust indices if playlist was modified
if self.playlist.len() < original_len {
// Ensure playlist_index is valid
if self.playlist_index >= self.playlist.len() && !self.playlist.is_empty() {
self.playlist_index = self.playlist.len() - 1;
}
// Ensure selected_playlist_index is valid
if self.selected_playlist_index >= self.playlist.len() && !self.playlist.is_empty() {
self.selected_playlist_index = self.playlist.len() - 1;
}
tracing::info!("Cleaned up playlist: removed {} deleted files", original_len - self.playlist.len());
}
}
pub fn rebuild_flattened_items(&mut self) {

View File

@@ -77,6 +77,11 @@ pub fn render(frame: &mut Frame, state: &mut AppState, player: &mut Player) -> (
render_status_bar(frame, state, player, main_chunks[2]);
// Show refreshing popup if scanning
if state.is_refreshing {
render_info_popup(frame, "Refreshing library...");
}
// Show confirmation popup if needed
if state.show_refresh_confirm {
render_confirm_popup(frame, "Refresh library?", "This may take a while");
@@ -409,16 +414,7 @@ fn render_title_bar(frame: &mut Frame, state: &AppState, player: &mut Player, ar
// Right side: Status • Progress • Volume • Search (if active)
let mut right_spans = Vec::new();
if state.is_refreshing {
// Show only "Refreshing library..." when refreshing
right_spans.push(Span::styled(
"Refreshing library... ",
Style::default()
.fg(Theme::background())
.bg(background_color)
.add_modifier(Modifier::BOLD)
));
} else {
{
// Status (bold when playing)
let status_text = match player_state {
PlayerState::Stopped => "Stopped",
@@ -724,6 +720,42 @@ fn render_progress_bar(frame: &mut Frame, _state: &AppState, player: &mut Player
frame.render_widget(progress_widget, area);
}
fn render_info_popup(frame: &mut Frame, message: &str) {
// Create centered popup area - smaller than confirm popup
let area = frame.area();
let popup_width = 40;
let popup_height = 3;
let popup_area = Rect {
x: (area.width.saturating_sub(popup_width)) / 2,
y: (area.height.saturating_sub(popup_height)) / 2,
width: popup_width.min(area.width),
height: popup_height.min(area.height),
};
// Use Clear widget to completely erase the background
frame.render_widget(Clear, popup_area);
// Render the popup block with solid background
let block = Block::default()
.borders(Borders::ALL)
.style(Style::default()
.bg(Theme::background())
.fg(Theme::bright_foreground()));
let inner = block.inner(popup_area);
frame.render_widget(block, popup_area);
// Render message centered
let message_widget = Paragraph::new(message)
.alignment(Alignment::Center)
.style(Style::default()
.fg(Theme::bright_foreground())
.bg(Theme::background()));
frame.render_widget(message_widget, inner);
}
fn render_confirm_popup(frame: &mut Frame, title: &str, message: &str) {
// Create centered popup area
let area = frame.area();