4 Commits

Author SHA1 Message Date
55e3f04e2c Fix auto-play next track during pause/unpause transitions
All checks were successful
Build and Release / build-and-release (push) Successful in 53s
When rapidly pressing play/pause, MPV briefly reports idle-active
as true during state transitions. Combined with our player_state
being set to Playing after unpause, this incorrectly triggered the
auto-play next track logic.

Fix: Add is_paused() check to auto-play condition to ensure we only
advance to next track when the current track actually ends, not
during pause state transitions.
2025-12-11 16:20:14 +01:00
1c2c942e4b Document state management architecture principles
Add critical guidelines for deriving player state from MPV rather
than maintaining duplicate state. Documents the single source of
truth pattern to prevent state synchronization bugs.
2025-12-11 16:11:45 +01:00
3e7707e883 Add arrow key support for folder navigation
All checks were successful
Build and Release / build-and-release (push) Successful in 54s
Add Left/Right arrow keys as alternatives to h/l for collapsing
and expanding folders in the file panel. Provides more intuitive
navigation for users not familiar with vim keybindings.

- Left arrow: Collapse selected folder (same as 'h')
- Right arrow: Expand selected folder (same as 'l')
2025-12-11 15:37:28 +01:00
b59d1aed65 Fix MPV pause state bug when loading new files
All checks were successful
Build and Release / build-and-release (push) Successful in 52s
When MPV is paused and a new file is loaded via loadfile command,
MPV loads the file but remains in a paused state. This caused the
UI to show "Playing" while no audio was actually playing.

Fix: Explicitly call resume() after every play() call to ensure
MPV unpauses when loading new files. This applies to:
- Playing new folder/file selections
- Playing from playlist
- Auto-play next/previous track
- Removing currently playing track from playlist

Fixes bug where after pause, playing another folder would show
"Playing" status but remain silent at 00:00.
2025-12-11 15:32:37 +01:00
4 changed files with 44 additions and 4 deletions

View File

@@ -14,6 +14,29 @@ A high-performance Rust-based TUI player for playing music and video files. Buil
## Architecture ## Architecture
### State Management
**CRITICAL:** Player state must be derived from MPV, not maintained separately.
**Single Source of Truth:** MPV properties via IPC
- `idle-active` (bool) - No file loaded or file ended
- `pause` (bool) - Playback is paused
**Derive PlayerState:**
```rust
if player.is_idle PlayerState::Stopped
if !player.is_idle && player.is_paused PlayerState::Paused
if !player.is_idle && !player.is_paused PlayerState::Playing
```
**Rationale:**
- Eliminates state synchronization bugs
- MPV is always the authoritative source
- No need to update state in multiple places
- Simpler auto-play logic
**Anti-pattern:** DO NOT maintain `state.player_state` that can desync from MPV
### Cache-Only Operation ### Cache-Only Operation
**CRITICAL:** Left panel shows ONLY cached data. Never browse filesystem directly during operation. **CRITICAL:** Left panel shows ONLY cached data. Never browse filesystem directly during operation.

View File

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

View File

@@ -96,6 +96,8 @@ fn action_play_selection(state: &mut AppState, player: &mut player::Player) -> R
state.play_selection(); state.play_selection();
if let Some(ref path) = state.current_file { if let Some(ref path) = state.current_file {
player.play(path)?; player.play(path)?;
// Explicitly resume playback in case MPV was paused
player.resume()?;
state.player_state = PlayerState::Playing; state.player_state = PlayerState::Playing;
player.update_metadata(); player.update_metadata();
tracing::info!("Playing: {:?} (playlist: {} tracks)", path, state.playlist.len()); tracing::info!("Playing: {:?} (playlist: {} tracks)", path, state.playlist.len());
@@ -126,6 +128,7 @@ fn action_toggle_play_pause(state: &mut AppState, player: &mut player::Player) -
state.player_state = PlayerState::Playing; state.player_state = PlayerState::Playing;
if let Some(ref path) = state.current_file { if let Some(ref path) = state.current_file {
player.play(path)?; player.play(path)?;
player.resume()?;
player.update_metadata(); player.update_metadata();
tracing::info!("Restarting playback: {:?}", path); tracing::info!("Restarting playback: {:?}", path);
} }
@@ -156,6 +159,8 @@ fn action_remove_from_playlist(state: &mut AppState, player: &mut player::Player
state.current_file = Some(state.playlist[state.playlist_index].clone()); state.current_file = Some(state.playlist[state.playlist_index].clone());
if let Some(ref path) = state.current_file { if let Some(ref path) = state.current_file {
player.play(path)?; player.play(path)?;
// Explicitly resume playback in case MPV was paused
player.resume()?;
player.update_metadata(); player.update_metadata();
} }
} }
@@ -171,6 +176,7 @@ fn action_play_from_playlist(state: &mut AppState, player: &mut player::Player,
PlayerState::Playing => { PlayerState::Playing => {
if let Some(ref path) = state.current_file { if let Some(ref path) = state.current_file {
player.play(path)?; player.play(path)?;
player.resume()?;
player.update_metadata(); player.update_metadata();
tracing::info!("Jumped to track: {:?}", path); tracing::info!("Jumped to track: {:?}", path);
} }
@@ -187,6 +193,7 @@ fn action_play_from_playlist(state: &mut AppState, player: &mut player::Player,
state.player_state = PlayerState::Playing; state.player_state = PlayerState::Playing;
if let Some(ref path) = state.current_file { if let Some(ref path) = state.current_file {
player.play(path)?; player.play(path)?;
player.resume()?;
player.update_metadata(); player.update_metadata();
tracing::info!("Started playing track: {:?}", path); tracing::info!("Started playing track: {:?}", path);
} }
@@ -196,6 +203,8 @@ fn action_play_from_playlist(state: &mut AppState, player: &mut player::Player,
state.player_state = PlayerState::Playing; state.player_state = PlayerState::Playing;
if let Some(ref path) = state.current_file { if let Some(ref path) = state.current_file {
player.play(path)?; player.play(path)?;
// Explicitly resume playback in case MPV was paused
player.resume()?;
player.update_metadata(); player.update_metadata();
tracing::info!("Playing from playlist: {:?}", path); tracing::info!("Playing from playlist: {:?}", path);
} }
@@ -295,7 +304,8 @@ async fn run_app<B: ratatui::backend::Backend>(
// Check if track ended and play next (but only if track was actually loaded AND played) // Check if track ended and play next (but only if track was actually loaded AND played)
// Require position > 0.5 to ensure track actually started playing (not just loaded) // Require position > 0.5 to ensure track actually started playing (not just loaded)
if player.is_idle() && state.player_state == PlayerState::Playing && state.current_duration > 0.0 && state.current_position > 0.5 { // Also check !is_paused to avoid triggering during pause/unpause transitions
if player.is_idle() && !player.is_paused() && state.player_state == PlayerState::Playing && state.current_duration > 0.0 && state.current_position > 0.5 {
state.play_next(); state.play_next();
// play_next() handles the play mode and may stop if in Normal mode at end // play_next() handles the play mode and may stop if in Normal mode at end
if state.player_state == PlayerState::Playing { if state.player_state == PlayerState::Playing {
@@ -306,6 +316,7 @@ async fn run_app<B: ratatui::backend::Backend>(
last_position = 0.0; last_position = 0.0;
player.play(path)?; player.play(path)?;
player.resume()?;
} }
// Update metadata immediately when track changes // Update metadata immediately when track changes
player.update_metadata(); player.update_metadata();
@@ -519,6 +530,7 @@ async fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<
// Keep playing // Keep playing
if let Some(ref path) = state.current_file { if let Some(ref path) = state.current_file {
player.play(path)?; player.play(path)?;
player.resume()?;
player.update_metadata(); // Update metadata immediately player.update_metadata(); // Update metadata immediately
tracing::info!("Next track: {:?}", path); tracing::info!("Next track: {:?}", path);
} }
@@ -557,6 +569,7 @@ async fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<
// Keep playing // Keep playing
if let Some(ref path) = state.current_file { if let Some(ref path) = state.current_file {
player.play(path)?; player.play(path)?;
player.resume()?;
player.update_metadata(); // Update metadata immediately player.update_metadata(); // Update metadata immediately
tracing::info!("Previous track: {:?}", path); tracing::info!("Previous track: {:?}", path);
} }
@@ -614,12 +627,12 @@ async fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<
state.move_selection_down(); state.move_selection_down();
} }
} }
(KeyCode::Char('h'), _) => { (KeyCode::Char('h'), _) | (KeyCode::Left, _) => {
if !state.focus_playlist { if !state.focus_playlist {
state.collapse_selected(); state.collapse_selected();
} }
} }
(KeyCode::Char('l'), _) => { (KeyCode::Char('l'), _) | (KeyCode::Right, _) => {
if !state.focus_playlist { if !state.focus_playlist {
state.expand_selected(); state.expand_selected();
} }

View File

@@ -327,6 +327,10 @@ impl Player {
self.is_idle self.is_idle
} }
pub fn is_paused(&self) -> bool {
self.is_paused
}
pub fn is_process_alive(&mut self) -> bool { pub fn is_process_alive(&mut self) -> bool {
// Check if mpv process is still running // Check if mpv process is still running
match self.process.try_wait() { match self.process.try_wait() {