Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d53542afa6 | |||
| be9ee8c005 | |||
| 7c083cfb0e |
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cm-player"
|
||||
version = "0.1.29"
|
||||
version = "0.1.33"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
123
src/main.rs
123
src/main.rs
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user