diff --git a/Cargo.toml b/Cargo.toml index daf6db1..4e9ceb6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cm-player" -version = "0.1.9" +version = "0.1.10" edition = "2021" [dependencies] diff --git a/src/main.rs b/src/main.rs index 7f7a622..c78c12f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -79,39 +79,85 @@ async fn run_app( state: &mut AppState, player: &mut player::Player, ) -> Result<()> { + let mut metadata_update_counter = 0u32; + let mut last_position = 0.0f64; + let mut needs_redraw = true; + loop { + let mut state_changed = false; + // Check if mpv process died (e.g., user closed video window) if !player.is_process_alive() && state.player_state != PlayerState::Stopped { state.player_state = PlayerState::Stopped; state.current_position = 0.0; state.current_duration = 0.0; + state_changed = true; } - // Update player properties from MPV - player.update_properties(); + // Only update properties when playing or paused (not when stopped) + if state.player_state != PlayerState::Stopped { + player.update_properties(); - // Update position and duration from player - state.current_position = player.get_position().unwrap_or(0.0); - state.current_duration = player.get_duration().unwrap_or(0.0); + // Update metadata only every 20 iterations (~2 seconds) to reduce IPC calls + metadata_update_counter += 1; + if metadata_update_counter >= 20 { + player.update_metadata(); + metadata_update_counter = 0; + state_changed = true; + } - // Check if track ended and play next (but only if track was actually loaded) - if player.is_idle() && state.player_state == PlayerState::Playing && state.current_duration > 0.0 { - if state.playlist_index + 1 < state.playlist.len() { - state.play_next(); - if let Some(ref path) = state.current_file { - player.play(path)?; + // Update position and duration from player + let new_position = player.get_position().unwrap_or(0.0); + let new_duration = player.get_duration().unwrap_or(0.0); + + // Only mark as changed if position moved by at least 0.5 seconds + if (new_position - last_position).abs() >= 0.5 { + state.current_position = new_position; + last_position = new_position; + state_changed = true; + } + + if state.current_duration != new_duration { + state.current_duration = new_duration; + state_changed = true; + } + + // Check if track ended and play next (but only if track was actually loaded) + if player.is_idle() && state.player_state == PlayerState::Playing && state.current_duration > 0.0 { + if state.playlist_index + 1 < state.playlist.len() { + state.play_next(); + if let Some(ref path) = state.current_file { + player.play(path)?; + } + // Update metadata immediately when track changes + player.update_metadata(); + metadata_update_counter = 0; + state_changed = true; + } else { + state.player_state = PlayerState::Stopped; + state_changed = true; } - } else { - state.player_state = PlayerState::Stopped; } } - terminal.draw(|f| ui::render(f, state))?; + // Only redraw if something changed or forced + if needs_redraw || state_changed { + terminal.draw(|f| ui::render(f, state, player))?; + needs_redraw = false; + } - if event::poll(std::time::Duration::from_millis(100))? { + // Poll for events - use longer timeout when stopped to reduce CPU + let poll_duration = if state.player_state == PlayerState::Stopped { + std::time::Duration::from_millis(200) // 5 FPS when stopped + } else { + std::time::Duration::from_millis(100) // 10 FPS when playing/paused + }; + + if event::poll(poll_duration)? { if let Event::Key(key) = event::read()? { if key.kind == KeyEventKind::Press { handle_key_event(terminal, state, player, key).await?; + needs_redraw = true; // Force redraw after key event } } } @@ -193,6 +239,7 @@ async fn handle_key_event(terminal: &mut Terminal< // Keep playing if let Some(ref path) = state.current_file { player.play(path)?; + player.update_metadata(); // Update metadata immediately tracing::info!("Next track: {:?}", path); } } @@ -200,6 +247,7 @@ async fn handle_key_event(terminal: &mut Terminal< // Load but stay paused if let Some(ref path) = state.current_file { player.play(path)?; + player.update_metadata(); // Update metadata immediately player.pause()?; tracing::info!("Next track (paused): {:?}", path); } @@ -222,6 +270,7 @@ async fn handle_key_event(terminal: &mut Terminal< // Keep playing if let Some(ref path) = state.current_file { player.play(path)?; + player.update_metadata(); // Update metadata immediately tracing::info!("Previous track: {:?}", path); } } @@ -229,6 +278,7 @@ async fn handle_key_event(terminal: &mut Terminal< // Load but stay paused if let Some(ref path) = state.current_file { player.play(path)?; + player.update_metadata(); // Update metadata immediately player.pause()?; tracing::info!("Previous track (paused): {:?}", path); } @@ -275,6 +325,7 @@ async fn handle_key_event(terminal: &mut Terminal< state.play_selection(); if let Some(ref path) = state.current_file { player.play(path)?; + player.update_metadata(); // Update metadata immediately when playing new file tracing::info!("Playing: {:?} (playlist: {} tracks)", path, state.playlist.len()); } if state.visual_mode { @@ -309,6 +360,7 @@ async fn handle_key_event(terminal: &mut Terminal< state.player_state = PlayerState::Playing; if let Some(ref path) = state.current_file { player.play(path)?; + player.update_metadata(); // Update metadata immediately tracing::info!("Restarting playback: {:?}", path); } } @@ -341,7 +393,7 @@ async fn handle_key_event(terminal: &mut Terminal< } (KeyCode::Char('r'), _) => { state.is_refreshing = true; - terminal.draw(|f| ui::render(f, state))?; // Show "Refreshing library..." immediately + terminal.draw(|f| ui::render(f, state, player))?; // Show "Refreshing library..." immediately tracing::info!("Rescanning..."); let cache_dir = cache::get_cache_dir()?; let new_cache = scanner::scan_paths(&state.config.scan_paths.paths)?; diff --git a/src/player/mod.rs b/src/player/mod.rs index 6f27b7d..33b8e32 100644 --- a/src/player/mod.rs +++ b/src/player/mod.rs @@ -15,6 +15,12 @@ pub struct Player { duration: f64, is_paused: bool, is_idle: bool, + pub media_title: Option, + pub artist: Option, + pub album: Option, + pub audio_codec: Option, + pub audio_bitrate: Option, + pub sample_rate: Option, } impl Player { @@ -50,6 +56,12 @@ impl Player { duration: 0.0, is_paused: false, is_idle: true, + media_title: None, + artist: None, + album: None, + audio_codec: None, + audio_bitrate: None, + sample_rate: None, }) } @@ -101,6 +113,12 @@ impl Player { self.position = 0.0; self.duration = 0.0; self.is_paused = false; + self.media_title = None; + self.artist = None; + self.album = None; + self.audio_codec = None; + self.audio_bitrate = None; + self.sample_rate = None; // Wait for socket to be created and mpv to be ready std::thread::sleep(Duration::from_millis(800)); @@ -230,6 +248,63 @@ impl Player { } } + pub fn update_metadata(&mut self) { + // Try to get artist directly + if let Some(val) = self.get_property("metadata/by-key/artist") { + self.artist = val.as_str().map(|s| s.to_string()); + } + // Fallback to ARTIST (uppercase) + if self.artist.is_none() { + if let Some(val) = self.get_property("metadata/by-key/ARTIST") { + self.artist = val.as_str().map(|s| s.to_string()); + } + } + + // Try to get album directly + if let Some(val) = self.get_property("metadata/by-key/album") { + self.album = val.as_str().map(|s| s.to_string()); + } + // Fallback to ALBUM (uppercase) + if self.album.is_none() { + if let Some(val) = self.get_property("metadata/by-key/ALBUM") { + self.album = val.as_str().map(|s| s.to_string()); + } + } + + // Try to get title directly + if let Some(val) = self.get_property("metadata/by-key/title") { + self.media_title = val.as_str().map(|s| s.to_string()); + } + // Fallback to TITLE (uppercase) + if self.media_title.is_none() { + if let Some(val) = self.get_property("metadata/by-key/TITLE") { + self.media_title = val.as_str().map(|s| s.to_string()); + } + } + + // Final fallback to media-title if metadata doesn't have title + if self.media_title.is_none() { + if let Some(val) = self.get_property("media-title") { + self.media_title = val.as_str().map(|s| s.to_string()); + } + } + + // Update audio codec + if let Some(val) = self.get_property("audio-codec-name") { + self.audio_codec = val.as_str().map(|s| s.to_string()); + } + + // Update audio bitrate (convert from bps to kbps) + if let Some(val) = self.get_property("audio-bitrate") { + self.audio_bitrate = val.as_f64().map(|b| b / 1000.0); + } + + // Update sample rate + if let Some(val) = self.get_property("audio-params/samplerate") { + self.sample_rate = val.as_i64(); + } + } + pub fn get_position(&self) -> Option { Some(self.position) } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 1c9b32f..4399d2a 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,5 +1,6 @@ mod theme; +use crate::player::Player; use crate::state::{AppState, PlayerState}; use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, @@ -10,7 +11,7 @@ use ratatui::{ }; use theme::Theme; -pub fn render(frame: &mut Frame, state: &mut AppState) { +pub fn render(frame: &mut Frame, state: &mut AppState, player: &Player) { // Clear background frame.render_widget( Block::default().style(Theme::secondary()), @@ -36,7 +37,7 @@ pub fn render(frame: &mut Frame, state: &mut AppState) { render_title_bar(frame, state, main_chunks[0]); render_file_panel(frame, state, content_chunks[0]); render_right_panel(frame, state, content_chunks[1]); - render_status_bar(frame, state, main_chunks[2]); + render_status_bar(frame, state, player, main_chunks[2]); } fn highlight_search_matches<'a>(text: &str, query: &str, search_typing: bool, is_selected: bool) -> Vec> { @@ -330,7 +331,7 @@ fn render_title_bar(frame: &mut Frame, state: &AppState, area: Rect) { frame.render_widget(right_title, chunks[1]); } -fn render_status_bar(frame: &mut Frame, state: &AppState, area: Rect) { +fn render_status_bar(frame: &mut Frame, state: &AppState, player: &Player, area: Rect) { if state.search_mode { // Show search prompt with current query and match count - LEFT aligned let search_text = if !state.tab_search_results.is_empty() { @@ -356,11 +357,66 @@ fn render_status_bar(frame: &mut Frame, state: &AppState, area: Rect) { .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 or visual mode) - let shortcuts = "a: Add • c: Clear • Enter: Play • Space: Pause • s: Stop • +/-: Vol • r: Rescan"; - let status_bar = Paragraph::new(shortcuts) + // Normal mode: show media metadata if playing + // Split into left (artist/album/title) and right (technical info) + + let mut left_parts = Vec::new(); + let mut right_parts = Vec::new(); + + // Left side: Artist | Album | Title + if let Some(ref artist) = player.artist { + left_parts.push(artist.clone()); + } + + if let Some(ref album) = player.album { + left_parts.push(album.clone()); + } + + if let Some(ref title) = player.media_title { + left_parts.push(title.clone()); + } + + // Right side: Bitrate | Codec | Sample rate + if let Some(bitrate) = player.audio_bitrate { + right_parts.push(format!("{:.0} kbps", bitrate)); + } + + if let Some(ref codec) = player.audio_codec { + right_parts.push(codec.to_uppercase()); + } + + if let Some(samplerate) = player.sample_rate { + right_parts.push(format!("{} Hz", samplerate)); + } + + // Create layout for left and right sections + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(area); + + // Left side text + let left_text = if !left_parts.is_empty() { + format!(" {}", left_parts.join(" | ")) + } else { + String::new() + }; + + let left_bar = Paragraph::new(left_text) .style(Style::default().fg(Theme::muted_text()).bg(Theme::background())) - .alignment(Alignment::Center); - frame.render_widget(status_bar, area); + .alignment(Alignment::Left); + frame.render_widget(left_bar, chunks[0]); + + // Right side text + let right_text = if !right_parts.is_empty() { + format!("{} ", right_parts.join(" | ")) + } else { + String::new() + }; + + let right_bar = Paragraph::new(right_text) + .style(Style::default().fg(Theme::muted_text()).bg(Theme::background())) + .alignment(Alignment::Right); + frame.render_widget(right_bar, chunks[1]); } }