14 Commits

Author SHA1 Message Date
2953d73487 Use lowercase for panel labels
All checks were successful
Build and Release / build-and-release (push) Successful in 54s
Change "Files" to "files" and "Playlist" to "playlist" in the title bar
for consistent lowercase styling. Also remove unused render_info_popup
function.
2025-12-13 12:58:06 +01:00
cddfedf1a0 Add live file counter to library refresh
All checks were successful
Build and Release / build-and-release (push) Successful in 1m20s
Show real-time progress during library scanning with an atomic counter
that updates every 100ms. The refresh popup displays the number of
media files found as they are discovered, providing immediate feedback
without slowing down the scan operation.
2025-12-13 11:51:32 +01:00
821a844fe0 Add dynamic title bar colors based on player state
Title bar background color changes:
- Gray: Stopped or Paused
- Green: Playing
- Red: Error (when last_error is set)

Main content border remains gray.
2025-12-13 10:34:55 +01:00
b7cc219f40 Prevent auto-advance when MPV is force killed
Set previous_player_state to Stopped and clear current_file when MPV dies to prevent the auto-advance logic from triggering.
2025-12-12 21:18:22 +01:00
b5fde2d5d4 Handle MPV force kill gracefully
When MPV is killed externally, detect the dead process and automatically recreate it for next playback. Connection errors no longer crash the application - they're logged and handled silently.
2025-12-12 19:52:48 +01:00
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
b438065c23 Use small triangle arrow for playlist playing indicator
All checks were successful
Build and Release / build-and-release (push) Successful in 54s
Replace large arrow with small triangle (▸) to match the style
used for directory indicators before nerd fonts were introduced.
2025-12-12 13:08:00 +01:00
0fa26db116 Add playing indicator arrow in playlist
All checks were successful
Build and Release / build-and-release (push) Successful in 54s
Display a small arrow (▶) to the left of the currently playing track in the playlist, making it easier to identify which song is playing.
2025-12-12 12:57:12 +01:00
0ec328881a Optimize MPV polling with single batch query every 200ms
All checks were successful
Build and Release / build-and-release (push) Successful in 1m18s
Replace separate property queries with unified batch fetching:
- Consolidate position, duration, and metadata into one IPC call
- Reduce polling from 100ms to 200ms (5 FPS)
- Remove complex timeout handling in favor of simple blocking reads
- Remove unused is_idle, is_paused, and get_property methods

This eliminates status bar flashing and reduces CPU usage.
2025-12-12 11:54:42 +01:00
ccc762419f Add progress bar and dynamic panel sizing
All checks were successful
Build and Release / build-and-release (push) Successful in 53s
Add progress bar to bottom status bar showing playback progress with
gray background fill and metadata text overlay.

- Add progress bar to status bar with border gray background
- Implement dynamic panel sizing: 80/20 split favoring focused panel
- Fix progress bar flashing on track change by resetting position/duration
- Remove cache/buffer duration from status display
- Reset player position/duration in play() to prevent stale values

The progress bar uses a gray background (border color) that fills from
left to right as the track plays, with white text for the filled portion
and muted text for the unfilled portion.
2025-12-11 21:38:41 +01:00
93741320ac Add Unix socket API for OS-wide keyboard shortcuts
All checks were successful
Build and Release / build-and-release (push) Successful in 55s
Implement single binary pattern where cm-player acts as both server
(TUI mode) and client (command sender) based on CLI arguments.

- Add Unix socket API server at $XDG_RUNTIME_DIR/cm-player.sock
- Add client mode for sending commands: cm-player play-pause, next, etc.
- Support all playback commands: play-pause, stop, next, prev
- Support volume commands: volume-up, volume-down, volume <0-100>
- Support seek commands: seek-forward, seek-backward
- Support status query and quit commands
- Add short command aliases (pp, n, p, vu, vd, sf, sb, s, q)
- Commands run in parallel thread, non-blocking main loop
- Enable OS-wide keyboard shortcut integration (XF86Audio* keys)
2025-12-11 20:15:07 +01:00
7b4c664011 Add audio buffer for WSLg stability
Increase mpv audio buffer to 2 seconds to fix stuttering on WSLg.

Ref: https://github.com/microsoft/wslg/issues/1257
2025-12-11 20:04:49 +01:00
9 changed files with 1150 additions and 816 deletions

View File

@@ -90,6 +90,72 @@ paths = [
- `r` - Rescan library (manual refresh)
- `q` - Quit
## API for OS-Wide Shortcuts
cm-player provides a Unix socket API for external control, allowing integration with OS-wide media keys and custom shortcuts.
### Architecture
- **Single Binary**: `cm-player` acts as both TUI server and CLI client
- **IPC**: Unix socket at `$XDG_RUNTIME_DIR/cm-player.sock` (or `/tmp/cm-player.sock`)
- **Protocol**: JSON commands over Unix socket
### Usage
```bash
# Start TUI (server mode)
cm-player
# Send commands to running instance (client mode)
cm-player play-pause
cm-player next
cm-player prev
cm-player stop
cm-player volume-up
cm-player volume-down
cm-player volume 50
cm-player seek-forward 30
cm-player seek-backward 10
cm-player quit
```
### OS-Wide Keyboard Shortcuts
**i3/sway config:**
```
bindsym XF86AudioPlay exec cm-player play-pause
bindsym XF86AudioNext exec cm-player next
bindsym XF86AudioPrev exec cm-player prev
bindsym XF86AudioStop exec cm-player stop
```
**KDE/GNOME:**
Add custom shortcuts pointing to `cm-player <command>`
### JSON Protocol
Commands are JSON objects with a `command` field:
```json
{"command": "play-pause"}
{"command": "next"}
{"command": "prev"}
{"command": "stop"}
{"command": "volume-up"}
{"command": "volume-down"}
{"command": "volume-set", "volume": 50}
{"command": "seek-forward", "seconds": 30}
{"command": "seek-backward", "seconds": 10}
{"command": "get-status"}
{"command": "quit"}
```
Responses:
```json
{"success": true, "message": null, "data": null}
{"success": false, "message": "error details", "data": null}
```
### Technical Details
- **MPV IPC** - Communicates with mpv via Unix socket and JSON protocol
- **No Version Lock** - Uses mpv binary, not libmpv library (avoids version mismatch)

View File

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

120
src/api/mod.rs Normal file
View File

@@ -0,0 +1,120 @@
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::PathBuf;
use std::sync::mpsc::{self, Receiver, Sender};
use std::thread;
/// Commands that can be sent to cm-player via the API
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "command", rename_all = "kebab-case")]
pub enum ApiCommand {
/// Toggle play/pause (play if stopped, pause if playing)
PlayPause,
/// Stop playback
Stop,
/// Next track
Next,
/// Previous track
Prev,
/// Volume up by 5%
VolumeUp,
/// Volume down by 5%
VolumeDown,
/// Set volume to specific value (0-100)
VolumeSet { volume: i64 },
/// Seek forward by seconds
SeekForward { seconds: f64 },
/// Seek backward by seconds
SeekBackward { seconds: f64 },
/// Get current player status
GetStatus,
/// Quit the application
Quit,
}
/// Response from the API
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiResponse {
pub success: bool,
pub message: Option<String>,
pub data: Option<serde_json::Value>,
}
impl ApiResponse {
pub fn success() -> Self {
Self {
success: true,
message: None,
data: None,
}
}
}
/// Start the API server on a Unix socket
pub fn start_api_server(socket_path: PathBuf) -> Result<Receiver<ApiCommand>> {
// Remove old socket if it exists
if socket_path.exists() {
std::fs::remove_file(&socket_path)?;
}
let listener = UnixListener::bind(&socket_path)
.context("Failed to bind Unix socket for API")?;
tracing::info!("API server listening on {:?}", socket_path);
let (tx, rx) = mpsc::channel();
// Spawn thread to handle incoming connections
thread::spawn(move || {
for stream in listener.incoming() {
match stream {
Ok(stream) => {
let tx = tx.clone();
thread::spawn(move || {
if let Err(e) = handle_client(stream, tx) {
tracing::warn!("API client error: {}", e);
}
});
}
Err(e) => {
tracing::error!("API connection error: {}", e);
}
}
}
});
Ok(rx)
}
fn handle_client(mut stream: UnixStream, tx: Sender<ApiCommand>) -> Result<()> {
let mut reader = BufReader::new(stream.try_clone()?);
let mut line = String::new();
reader.read_line(&mut line)?;
// Parse command
let command: ApiCommand = serde_json::from_str(&line)
.context("Failed to parse API command")?;
tracing::debug!("Received API command: {:?}", command);
// Send command to main thread
tx.send(command.clone())
.context("Failed to send command to main thread")?;
// Send response
let response = ApiResponse::success();
let response_json = serde_json::to_string(&response)?;
writeln!(stream, "{}", response_json)?;
Ok(())
}
/// Helper function to get default socket path
pub fn get_default_socket_path() -> Result<PathBuf> {
let runtime_dir = std::env::var("XDG_RUNTIME_DIR")
.unwrap_or_else(|_| "/tmp".to_string());
Ok(PathBuf::from(runtime_dir).join("cm-player.sock"))
}

View File

@@ -1,3 +1,4 @@
mod api;
mod cache;
mod config;
mod player;
@@ -13,16 +14,26 @@ use crossterm::{
};
use ratatui::{backend::CrosstermBackend, Terminal};
use state::{AppState, PlayerState};
use std::io;
use std::io::{self, BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;
use tracing_subscriber;
// UI update intervals and thresholds
const METADATA_UPDATE_INTERVAL: u32 = 20; // Update metadata every N iterations (~2 seconds)
const POLL_DURATION_STOPPED_MS: u64 = 200; // 5 FPS when stopped
const POLL_DURATION_ACTIVE_MS: u64 = 100; // 10 FPS when playing/paused
const POLL_DURATION_ACTIVE_MS: u64 = 200; // 5 FPS when playing/paused - single batch poll
const DOUBLE_CLICK_MS: u128 = 500; // Double-click detection threshold
fn main() -> Result<()> {
// Check if we're in client mode (sending command to running instance)
let args: Vec<String> = std::env::args().collect();
if args.len() > 1 {
return send_command(&args[1..]);
}
// Initialize logging to file to avoid interfering with TUI
let log_file = std::fs::OpenOptions::new()
.create(true)
@@ -60,6 +71,11 @@ fn main() -> Result<()> {
let mut state = AppState::new(cache, config);
tracing::info!("State initialized");
// Start API server
let socket_path = api::get_default_socket_path()?;
let api_rx = api::start_api_server(socket_path)?;
tracing::info!("API server started");
// Setup terminal
enable_raw_mode()?;
tracing::info!("Raw mode enabled");
@@ -71,7 +87,7 @@ fn main() -> Result<()> {
tracing::info!("Terminal created, entering main loop");
// Run app (ensure terminal cleanup even on error)
let result = run_app(&mut terminal, &mut state, &mut player);
let result = run_app(&mut terminal, &mut state, &mut player, api_rx);
// Restore terminal (always run cleanup, even if result is Err)
let cleanup_result = (|| -> Result<()> {
@@ -93,6 +109,102 @@ fn main() -> Result<()> {
result
}
/// Send a command to a running cm-player instance (client mode)
fn send_command(args: &[String]) -> Result<()> {
let socket_path = api::get_default_socket_path()?;
if !socket_path.exists() {
eprintln!("Error: cm-player is not running (socket not found at {:?})", socket_path);
std::process::exit(1);
}
// Parse command
let command = match args[0].as_str() {
"play-pause" | "pp" => api::ApiCommand::PlayPause,
"stop" => api::ApiCommand::Stop,
"next" | "n" => api::ApiCommand::Next,
"prev" | "p" => api::ApiCommand::Prev,
"volume-up" | "vu" => api::ApiCommand::VolumeUp,
"volume-down" | "vd" => api::ApiCommand::VolumeDown,
"volume" | "v" => {
if args.len() < 2 {
eprintln!("Usage: cm-player volume <0-100>");
std::process::exit(1);
}
let volume: i64 = args[1].parse()
.context("Volume must be a number between 0-100")?;
api::ApiCommand::VolumeSet { volume }
}
"seek-forward" | "sf" => {
let seconds = if args.len() > 1 {
args[1].parse().unwrap_or(10.0)
} else {
10.0
};
api::ApiCommand::SeekForward { seconds }
}
"seek-backward" | "sb" => {
let seconds = if args.len() > 1 {
args[1].parse().unwrap_or(10.0)
} else {
10.0
};
api::ApiCommand::SeekBackward { seconds }
}
"status" | "s" => api::ApiCommand::GetStatus,
"quit" | "q" => api::ApiCommand::Quit,
_ => {
eprintln!("Unknown command: {}", args[0]);
print_usage();
std::process::exit(1);
}
};
// Connect to socket and send command
let mut stream = UnixStream::connect(&socket_path)
.context("Failed to connect to cm-player")?;
let command_json = serde_json::to_string(&command)?;
writeln!(stream, "{}", command_json)?;
// Read response
let mut reader = BufReader::new(stream);
let mut response_line = String::new();
reader.read_line(&mut response_line)?;
let response: api::ApiResponse = serde_json::from_str(&response_line)?;
if response.success {
if let Some(data) = response.data {
println!("{}", serde_json::to_string_pretty(&data)?);
}
} else {
eprintln!("Error: {}", response.message.unwrap_or_else(|| "Unknown error".to_string()));
std::process::exit(1);
}
Ok(())
}
fn print_usage() {
eprintln!("Usage: cm-player [command] [args]");
eprintln!();
eprintln!("Commands:");
eprintln!(" play-pause, pp Toggle play/pause");
eprintln!(" stop Stop playback");
eprintln!(" next, n Next track");
eprintln!(" prev, p Previous track");
eprintln!(" volume-up, vu Volume up by 5%");
eprintln!(" volume-down, vd Volume down by 5%");
eprintln!(" volume, v <0-100> Set volume");
eprintln!(" seek-forward, sf [sec] Seek forward (default: 10s)");
eprintln!(" seek-backward, sb [sec] Seek backward (default: 10s)");
eprintln!(" status, s Get player status");
eprintln!(" quit, q Quit cm-player");
eprintln!();
eprintln!("If no command is provided, cm-player starts in TUI mode.");
}
// Common action functions that both keyboard and mouse handlers can call
fn action_toggle_folder(state: &mut AppState) {
@@ -110,13 +222,14 @@ fn action_toggle_folder(state: &mut AppState) {
}
}
fn action_play_selection(state: &mut AppState, player: &mut player::Player) -> Result<()> {
fn action_play_selection(state: &mut AppState, player: &mut player::Player, skip_position_update: &mut bool) -> Result<()> {
state.play_selection();
if let Some(ref path) = state.current_file {
*skip_position_update = true; // Skip position update after track change
player.play(path)?;
// Explicitly resume playback in case MPV was paused
player.resume()?;
player.update_metadata();
// Metadata will be fetched by periodic update once MPV has it ready
tracing::info!("Playing: {:?} (playlist: {} tracks)", path, state.playlist.len());
}
if state.visual_mode {
@@ -144,7 +257,6 @@ fn action_toggle_play_pause(state: &mut AppState, player: &mut player::Player) -
if let Some(ref path) = state.current_file {
player.play(path)?;
player.resume()?;
player.update_metadata();
tracing::info!("Restarting playback: {:?}", path);
}
}
@@ -154,14 +266,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);
@@ -177,13 +319,13 @@ fn action_remove_from_playlist(state: &mut AppState, player: &mut player::Player
player.play(path)?;
// Explicitly resume playback in case MPV was paused
player.resume()?;
player.update_metadata();
// Metadata will be fetched by periodic update
}
}
Ok(())
}
fn action_navigate_track(state: &mut AppState, player: &mut player::Player, direction: i32) -> Result<()> {
fn action_navigate_track(state: &mut AppState, player: &mut player::Player, direction: i32, skip_position_update: &mut bool) -> Result<()> {
// direction: 1 for next, -1 for previous
let new_index = if direction > 0 {
state.playlist_index.saturating_add(1)
@@ -206,17 +348,19 @@ fn action_navigate_track(state: &mut AppState, player: &mut player::Player, dire
PlayerState::Playing => {
// Keep playing
if let Some(ref path) = state.current_file {
*skip_position_update = true; // Skip position update after track change
player.play(path)?;
player.resume()?;
player.update_metadata();
// Metadata will be fetched by periodic update
tracing::info!("{} track: {:?}", track_name, path);
}
}
PlayerState::Paused => {
// Load but stay paused
if let Some(ref path) = state.current_file {
*skip_position_update = true; // Skip position update after track change
player.play_paused(path)?;
player.update_metadata();
// Metadata will be fetched by periodic update
tracing::info!("{} track (paused): {:?}", track_name, path);
}
}
@@ -235,33 +379,37 @@ fn action_navigate_track(state: &mut AppState, player: &mut player::Player, dire
Ok(())
}
fn action_play_from_playlist(state: &mut AppState, player: &mut player::Player, preserve_pause: bool) -> Result<()> {
fn action_play_from_playlist(state: &mut AppState, player: &mut player::Player, preserve_pause: bool, skip_position_update: &mut bool) -> Result<()> {
state.playlist_index = state.selected_playlist_index;
state.current_file = Some(state.playlist[state.playlist_index].clone());
if preserve_pause {
if let Some(player_state) = player.get_player_state() {
match player_state {
PlayerState::Playing => {
if let Some(ref path) = state.current_file {
*skip_position_update = true; // Skip position update after track change
player.play(path)?;
player.resume()?;
player.update_metadata();
// Metadata will be fetched by periodic update
tracing::info!("Jumped to track: {:?}", path);
}
}
PlayerState::Paused => {
if let Some(ref path) = state.current_file {
*skip_position_update = true; // Skip position update after track change
player.play_paused(path)?;
player.update_metadata();
// Metadata will be fetched by periodic update
tracing::info!("Jumped to track (paused): {:?}", path);
}
}
PlayerState::Stopped => {
if let Some(ref path) = state.current_file {
*skip_position_update = true; // Skip position update after track change
player.play(path)?;
player.resume()?;
player.update_metadata();
// Metadata will be fetched by periodic update
tracing::info!("Started playing track: {:?}", path);
}
}
@@ -269,21 +417,22 @@ fn action_play_from_playlist(state: &mut AppState, player: &mut player::Player,
}
} else {
if let Some(ref path) = state.current_file {
*skip_position_update = true; // Skip position update after track change
player.play(path)?;
// Explicitly resume playback in case MPV was paused
player.resume()?;
player.update_metadata();
// Metadata will be fetched by periodic update
tracing::info!("Playing from playlist: {:?}", path);
}
}
Ok(())
}
fn handle_context_menu_action(menu_type: state::ContextMenuType, selected: usize, state: &mut AppState, player: &mut player::Player) -> Result<()> {
fn handle_context_menu_action(menu_type: state::ContextMenuType, selected: usize, state: &mut AppState, player: &mut player::Player, skip_position_update: &mut bool) -> Result<()> {
match menu_type {
state::ContextMenuType::FilePanel => {
match selected {
0 => action_play_selection(state, player)?,
0 => action_play_selection(state, player, skip_position_update)?,
1 => state.add_to_playlist(),
_ => {}
}
@@ -300,7 +449,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);
@@ -320,10 +469,11 @@ fn run_app<B: ratatui::backend::Backend>(
terminal: &mut Terminal<B>,
state: &mut AppState,
player: &mut player::Player,
api_rx: std::sync::mpsc::Receiver<api::ApiCommand>,
) -> Result<()> {
let mut metadata_update_counter = 0u32;
let mut last_position = 0.0f64;
let mut needs_redraw = true;
let mut skip_position_update = false;
let mut title_bar_area = ratatui::layout::Rect::default();
let mut file_panel_area = ratatui::layout::Rect::default();
let mut playlist_area = ratatui::layout::Rect::default();
@@ -332,19 +482,72 @@ fn run_app<B: ratatui::backend::Backend>(
loop {
let mut state_changed = false;
// Check if mpv process died (e.g., user closed video window)
if !player.is_process_alive() {
if let Some(player_state) = player.get_player_state() {
if player_state != PlayerState::Stopped {
state.current_position = 0.0;
state.current_duration = 0.0;
// Check for API commands (non-blocking)
while let Ok(cmd) = api_rx.try_recv() {
tracing::debug!("Processing API command: {:?}", cmd);
match cmd {
api::ApiCommand::PlayPause => {
action_toggle_play_pause(state, player)?;
state_changed = true;
}
api::ApiCommand::Stop => {
action_stop(state, player, &mut skip_position_update)?;
state_changed = true;
}
api::ApiCommand::Next => {
action_navigate_track(state, player, 1, &mut skip_position_update)?;
state_changed = true;
}
api::ApiCommand::Prev => {
action_navigate_track(state, player, -1, &mut skip_position_update)?;
state_changed = true;
}
api::ApiCommand::VolumeUp => {
action_volume_up(state, player)?;
state_changed = true;
}
api::ApiCommand::VolumeDown => {
action_volume_down(state, player)?;
state_changed = true;
}
api::ApiCommand::VolumeSet { volume } => {
action_volume_set(state, player, volume)?;
state_changed = true;
}
api::ApiCommand::SeekForward { seconds } => {
action_seek(player, seconds)?;
state_changed = true;
}
api::ApiCommand::SeekBackward { seconds } => {
action_seek(player, -seconds)?;
state_changed = true;
}
api::ApiCommand::GetStatus => {
// Status query - no state change needed
tracing::debug!("Status query received");
}
api::ApiCommand::Quit => {
state.should_quit = true;
}
}
}
// Always update properties to keep state synchronized with MPV
player.update_properties();
// Check if mpv process died (e.g., user closed video window or force killed)
if !player.is_process_alive() {
tracing::info!("MPV process died, recreating...");
state.current_position = 0.0;
state.current_duration = 0.0;
state.current_file = None;
state_changed = true;
skip_position_update = true; // Prevent auto-advance when MPV was killed
// Recreate player for next playback
*player = player::Player::new()?;
previous_player_state = Some(PlayerState::Stopped);
}
// Always update all properties in one batch to keep state synchronized with MPV
player.update_all_properties();
// Only proceed if we can successfully query player state
let Some(player_state) = player.get_player_state() else {
@@ -353,12 +556,12 @@ fn run_app<B: ratatui::backend::Backend>(
match event::read()? {
Event::Key(key) => {
if key.kind == KeyEventKind::Press {
handle_key_event(terminal, state, player, key)?;
handle_key_event(terminal, state, player, key, &mut skip_position_update)?;
needs_redraw = true;
}
}
Event::Mouse(mouse) => {
handle_mouse_event(state, mouse, title_bar_area, file_panel_area, playlist_area, player)?;
handle_mouse_event(state, mouse, title_bar_area, file_panel_area, playlist_area, player, &mut skip_position_update)?;
needs_redraw = true;
}
_ => {}
@@ -370,8 +573,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
@@ -381,13 +586,11 @@ fn run_app<B: ratatui::backend::Backend>(
state.current_position = 0.0;
state.current_duration = 0.0;
last_position = 0.0;
skip_position_update = true; // Skip position update this iteration
player.play(path)?;
player.resume()?;
}
// Update metadata immediately when track changes
player.update_metadata();
metadata_update_counter = 0;
// Auto-scroll playlist to show current track (only if user is not browsing playlist)
if !state.focus_playlist {
let playlist_visible_height = playlist_area.height.saturating_sub(2) as usize;
@@ -400,32 +603,29 @@ fn run_app<B: ratatui::backend::Backend>(
state_changed = true;
}
// Only update metadata and track playback when not stopped
// Only update playback position when not stopped
if player_state != PlayerState::Stopped {
// Update metadata periodically to reduce IPC calls
metadata_update_counter += 1;
if metadata_update_counter >= METADATA_UPDATE_INTERVAL {
player.update_metadata();
metadata_update_counter = 0;
state_changed = true;
}
// 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);
// Skip this iteration if we just started a new track to avoid stale MPV values
if skip_position_update {
skip_position_update = false;
} else {
let new_position = player.get_position().unwrap_or(0.0);
let new_duration = player.get_duration().unwrap_or(0.0);
// Only update if displayed value (rounded to seconds) changed
let old_display_secs = last_position as u32;
let new_display_secs = new_position as u32;
if new_display_secs != old_display_secs {
state.current_position = new_position;
last_position = new_position;
state_changed = true;
}
// Only update if displayed value (rounded to seconds) changed
let old_display_secs = last_position as u32;
let new_display_secs = new_position as u32;
if new_display_secs != old_display_secs {
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;
if state.current_duration != new_duration {
state.current_duration = new_duration;
state_changed = true;
}
}
}
@@ -454,12 +654,12 @@ fn run_app<B: ratatui::backend::Backend>(
match event::read()? {
Event::Key(key) => {
if key.kind == KeyEventKind::Press {
handle_key_event(terminal, state, player, key)?;
handle_key_event(terminal, state, player, key, &mut skip_position_update)?;
needs_redraw = true; // Force redraw after key event
}
}
Event::Mouse(mouse) => {
handle_mouse_event(state, mouse, title_bar_area, file_panel_area, playlist_area, player)?;
handle_mouse_event(state, mouse, title_bar_area, file_panel_area, playlist_area, player, &mut skip_position_update)?;
needs_redraw = true; // Force redraw after mouse event
}
_ => {}
@@ -474,22 +674,57 @@ fn run_app<B: ratatui::backend::Backend>(
Ok(())
}
fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, state: &mut AppState, player: &mut player::Player, key: KeyEvent) -> Result<()> {
fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, state: &mut AppState, player: &mut player::Player, key: KeyEvent, skip_position_update: &mut bool) -> Result<()> {
// Handle confirmation popup
if state.show_refresh_confirm {
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
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...");
state.refresh_file_count = 0;
terminal.draw(|f| { let _ = ui::render(f, state, player); })?;
let cache_dir = cache::get_cache_dir()?;
let new_cache = scanner::scan_paths(&state.config.scan_paths.paths)?;
// 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"));
// Create atomic counter for file count
let counter = Arc::new(AtomicUsize::new(0));
let counter_clone = Arc::clone(&counter);
// Spawn background thread to perform scan
let paths = state.config.scan_paths.paths.clone();
let scan_thread = thread::spawn(move || {
scanner::scan_paths(&paths, &counter_clone)
});
// Poll counter and update UI while scanning
while !scan_thread.is_finished() {
state.refresh_file_count = counter.load(Ordering::Relaxed);
terminal.draw(|f| { let _ = ui::render(f, state, player); })?;
thread::sleep(Duration::from_millis(100));
}
// Get the result
let new_cache = scan_thread.join().map_err(|_| anyhow::anyhow!("Scan thread panicked"))??;
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");
state.refresh_file_count = 0;
}
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
state.show_refresh_confirm = false;
@@ -502,6 +737,20 @@ fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, st
// Handle search mode separately
if state.search_mode {
match key.code {
KeyCode::Char('n') => {
if state.focus_playlist {
state.next_playlist_search_result();
} else {
state.next_search_result();
}
}
KeyCode::Char('N') => {
if state.focus_playlist {
state.prev_playlist_search_result();
} else {
state.prev_search_result();
}
}
KeyCode::Char(c) => {
if state.focus_playlist {
state.append_playlist_search_char(c);
@@ -516,20 +765,6 @@ fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, st
state.backspace_search();
}
}
KeyCode::Tab => {
if state.focus_playlist {
state.playlist_tab_search_next();
} else {
state.tab_search_next();
}
}
KeyCode::BackTab => {
if state.focus_playlist {
state.playlist_tab_search_prev();
} else {
state.tab_search_prev();
}
}
KeyCode::Enter => {
if state.focus_playlist {
state.execute_playlist_search();
@@ -569,7 +804,7 @@ fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, st
let menu_type = menu.menu_type;
let selected = menu.selected_index;
state.context_menu = None;
handle_context_menu_action(menu_type, selected, state, player)?;
handle_context_menu_action(menu_type, selected, state, player, skip_position_update)?;
}
KeyCode::Esc => {
state.context_menu = None;
@@ -592,39 +827,18 @@ fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, st
}
}
(KeyCode::Esc, _) => {
if !state.search_matches.is_empty() {
state.search_matches.clear();
}
if !state.playlist_search_matches.is_empty() {
state.playlist_search_matches.clear();
state.playlist_tab_search_results.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 !state.playlist_search_matches.is_empty() {
state.next_playlist_search_match();
}
}
(KeyCode::Char('N'), KeyModifiers::SHIFT) => {
if !state.search_matches.is_empty() {
state.prev_search_match();
} else if !state.playlist_search_matches.is_empty() {
state.prev_playlist_search_match();
}
}
(KeyCode::Char('J'), KeyModifiers::SHIFT) => {
// Next track
action_navigate_track(state, player, 1)?;
action_navigate_track(state, player, 1, skip_position_update)?;
}
(KeyCode::Char('K'), KeyModifiers::SHIFT) => {
// Previous track
action_navigate_track(state, player, -1)?;
action_navigate_track(state, player, -1, skip_position_update)?;
}
(KeyCode::Char('d'), KeyModifiers::CONTROL) => {
if state.focus_playlist {
@@ -693,14 +907,14 @@ fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, st
(KeyCode::Enter, _) => {
if state.focus_playlist {
if state.selected_playlist_index < state.playlist.len() {
action_play_from_playlist(state, player, false)?;
action_play_from_playlist(state, player, false, skip_position_update)?;
}
} else {
action_play_selection(state, player)?;
action_play_selection(state, player, skip_position_update)?;
}
}
(KeyCode::Char('s'), _) => {
action_stop(state, player)?;
action_stop(state, player, skip_position_update)?;
}
(KeyCode::Char('m'), _) => {
state.cycle_play_mode();
@@ -714,28 +928,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;
@@ -746,7 +948,7 @@ fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, st
Ok(())
}
fn handle_mouse_event(state: &mut AppState, mouse: MouseEvent, title_bar_area: ratatui::layout::Rect, file_panel_area: ratatui::layout::Rect, playlist_area: ratatui::layout::Rect, player: &mut player::Player) -> Result<()> {
fn handle_mouse_event(state: &mut AppState, mouse: MouseEvent, title_bar_area: ratatui::layout::Rect, file_panel_area: ratatui::layout::Rect, playlist_area: ratatui::layout::Rect, player: &mut player::Player, skip_position_update: &mut bool) -> Result<()> {
use crossterm::event::MouseButton;
use crate::state::ContextMenuType;
@@ -815,7 +1017,7 @@ fn handle_mouse_event(state: &mut AppState, mouse: MouseEvent, title_bar_area: r
let menu_type = menu.menu_type;
let selected = relative_y;
state.context_menu = None;
handle_context_menu_action(menu_type, selected, state, player)?;
handle_context_menu_action(menu_type, selected, state, player, skip_position_update)?;
}
return Ok(());
} else {
@@ -838,10 +1040,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
@@ -864,10 +1063,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
@@ -905,15 +1101,55 @@ fn handle_mouse_event(state: &mut AppState, mouse: MouseEvent, title_bar_area: r
_ => {}
}
}
// Check if click is on the panel title border (to switch focus)
// The visible area is whichever one is not Rect::default()
else if (file_panel_area.width > 0 && y == file_panel_area.y) ||
(playlist_area.width > 0 && y == playlist_area.y) {
// Click is on the top border line where "Files | Playlist" is shown
// Get the actual visible area (not the default one)
let area = if file_panel_area.width > 0 { file_panel_area } else { playlist_area };
// Build title text to calculate positions
let playlist_text = if !state.playlist.is_empty() {
format!("Playlist [{}/{}]", state.playlist_index + 1, state.playlist.len())
} else {
"Playlist (empty)".to_string()
};
// Title is left-aligned by default in ratatui, starts after left border
// Border character takes 1 position, then title starts
let title_start_x = area.x + 1;
// Calculate where "Files" and "Playlist" text are
let files_start = title_start_x;
let files_end = files_start + 5; // "Files" is 5 chars
let separator_start = files_end;
let separator_end = separator_start + 3; // " | " is 3 chars
let playlist_start = separator_end;
let playlist_end = playlist_start + playlist_text.len() as u16;
match button {
MouseButton::Left => {
if x >= files_start && x < files_end {
// Clicked on "Files" - switch to file panel
state.focus_playlist = false;
} else if x >= playlist_start && x < playlist_end {
// Clicked on "Playlist" - switch to playlist
state.focus_playlist = true;
}
}
_ => {}
}
}
// Check if click is within file panel area
else if x >= file_panel_area.x
&& x < file_panel_area.x + file_panel_area.width
&& y >= file_panel_area.y
&& y < file_panel_area.y + file_panel_area.height
{
// Calculate which item was clicked (accounting for borders and scroll offset)
// Border takes 1 line at top, so subtract 1 from y position
let relative_y = (y - file_panel_area.y).saturating_sub(1);
// Calculate which item was clicked (accounting for scroll offset and outer border)
// Outer border takes 1 line at top, content starts at file_panel_area.y + 1
let relative_y = y.saturating_sub(file_panel_area.y + 1);
let clicked_index = state.scroll_offset + relative_y as usize;
// Set selection to clicked item if valid
@@ -939,7 +1175,7 @@ fn handle_mouse_event(state: &mut AppState, mouse: MouseEvent, title_bar_area: r
if item.is_dir {
action_toggle_folder(state);
} else {
action_play_selection(state, player)?;
action_play_selection(state, player, skip_position_update)?;
}
}
// Reset click tracking after action
@@ -972,8 +1208,9 @@ fn handle_mouse_event(state: &mut AppState, mouse: MouseEvent, title_bar_area: r
&& y >= playlist_area.y
&& y < playlist_area.y + playlist_area.height
{
// Calculate which track was clicked (accounting for borders)
let relative_y = (y - playlist_area.y).saturating_sub(1);
// Calculate which track was clicked (accounting for outer border)
// Outer border takes 1 line at top, content starts at playlist_area.y + 1
let relative_y = y.saturating_sub(playlist_area.y + 1);
let clicked_track = relative_y as usize;
// Add scroll offset to get actual index
@@ -997,7 +1234,7 @@ fn handle_mouse_event(state: &mut AppState, mouse: MouseEvent, title_bar_area: r
if is_double_click {
// Double click = play the track (preserve pause state)
state.selected_playlist_index = actual_track;
action_play_from_playlist(state, player, true)?;
action_play_from_playlist(state, player, true, skip_position_update)?;
// Reset click tracking after action
state.last_click_time = None;
state.last_click_index = None;

View File

@@ -35,6 +35,7 @@ impl Player {
.arg("--no-terminal")
.arg("--profile=fast")
.arg("--audio-display=no") // Don't show cover art for audio files
.arg("--audio-buffer=2") // Larger buffer for WSLg audio stability
.arg(format!("--input-ipc-server={}", socket_path.display()))
.stdin(Stdio::null())
.stdout(Stdio::null())
@@ -75,8 +76,10 @@ impl Player {
let stream = match UnixStream::connect(&self.socket_path) {
Ok(s) => s,
Err(e) => {
// Connection failed - MPV probably not ready yet
return Err(anyhow::anyhow!("Failed to connect: {}", e));
// Connection failed - MPV probably not ready yet or has been killed
// Don't propagate error - just leave socket as None
tracing::debug!("Failed to connect to MPV socket: {}", e);
return Ok(());
}
};
@@ -118,88 +121,82 @@ impl Player {
Ok(())
}
fn get_property(&mut self, property: &str) -> Option<Value> {
// Try to connect - if respawning or connection fails, return None
if let Err(e) = self.connect() {
tracing::debug!("Failed to connect for property '{}': {}", property, e);
return None;
fn get_properties_batch(&mut self, properties: &[&str]) -> std::collections::HashMap<String, Value> {
let mut results = std::collections::HashMap::new();
// Try to connect
if self.connect().is_err() {
return results;
}
let cmd = json!({
"command": ["get_property", property],
"request_id": 1
});
if let Some(ref mut socket) = self.socket {
let msg = format!("{}\n", cmd);
// Write command
if let Err(e) = socket.write_all(msg.as_bytes()) {
tracing::warn!("Failed to write get_property command for '{}': {}", property, e);
self.socket = None;
return None;
// Send all property requests at once with unique request_ids
for (idx, property) in properties.iter().enumerate() {
let cmd = json!({
"command": ["get_property", property],
"request_id": idx + 1
});
let msg = format!("{}\n", cmd);
if socket.write_all(msg.as_bytes()).is_err() {
return results;
}
}
// Try to read response with timeout
// Read all responses
// IMPORTANT: Socket is non-blocking, need to set blocking mode for read
socket.set_nonblocking(false).ok();
socket.set_read_timeout(Some(Duration::from_millis(100))).ok();
let cloned_socket = match socket.try_clone() {
Ok(s) => s,
Err(e) => {
tracing::warn!("Failed to clone socket for '{}': {}", property, e);
Err(_) => {
socket.set_nonblocking(true).ok();
return None;
return results;
}
};
// Set timeout on cloned socket too (clone doesn't copy settings)
cloned_socket.set_nonblocking(false).ok();
cloned_socket.set_read_timeout(Some(Duration::from_millis(100))).ok();
let mut reader = BufReader::new(cloned_socket);
let mut response = String::new();
if let Err(e) = reader.read_line(&mut response) {
tracing::debug!("Failed to read response for '{}': {}", property, e);
socket.set_nonblocking(true).ok();
return None;
// Read responses - stop if we timeout or hit an error
for _ in 0..properties.len() {
let mut response = String::new();
if reader.read_line(&mut response).is_err() {
// Timeout or error - stop reading
break;
}
// Parse response
if let Ok(parsed) = serde_json::from_str::<Value>(&response) {
// Check for success
if let Some(error) = parsed.get("error").and_then(|e| e.as_str()) {
if error == "success" {
// Get request_id to match with property
if let Some(req_id) = parsed.get("request_id").and_then(|id| id.as_i64()) {
let idx = (req_id - 1) as usize;
if idx < properties.len() {
if let Some(data) = parsed.get("data") {
results.insert(properties[idx].to_string(), data.clone());
}
}
}
}
}
}
}
// Restore non-blocking mode
socket.set_nonblocking(true).ok();
// Parse and validate response
let parsed: Value = match serde_json::from_str(&response) {
Ok(v) => v,
Err(e) => {
tracing::warn!("Failed to parse JSON response for '{}': {} (response: {})", property, e, response.trim());
return None;
}
};
// Check for errors in response
// MPV returns {"error": "success"} when there's NO error
if let Some(error) = parsed.get("error").and_then(|e| e.as_str()) {
if error != "success" {
tracing::debug!("MPV returned error for '{}': {}", property, error);
return None;
}
}
// Validate request_id matches (should be 1)
if let Some(req_id) = parsed.get("request_id").and_then(|id| id.as_i64()) {
if req_id != 1 {
tracing::warn!("Request ID mismatch for '{}': expected 1, got {}", property, req_id);
}
}
return parsed.get("data").cloned();
}
None
results
}
pub fn play(&mut self, path: &Path) -> Result<()> {
let path_str = path.to_string_lossy();
// Reset position/duration before loading new file to avoid showing stale values
self.position = 0.0;
self.duration = 0.0;
self.send_command("loadfile", &[json!(path_str), json!("replace")])?;
tracing::info!("Playing: {}", path_str);
Ok(())
@@ -207,6 +204,9 @@ impl Player {
pub fn play_paused(&mut self, path: &Path) -> Result<()> {
let path_str = path.to_string_lossy();
// Reset position/duration before loading new file to avoid showing stale values
self.position = 0.0;
self.duration = 0.0;
// Load file but start paused - avoids audio blip when jumping tracks while paused
self.send_command("loadfile", &[json!(path_str), json!("replace"), json!({"pause": true})])?;
tracing::info!("Playing (paused): {}", path_str);
@@ -235,84 +235,73 @@ impl Player {
Ok(())
}
pub fn update_properties(&mut self) {
// Update position
if let Some(val) = self.get_property("time-pos") {
pub fn update_all_properties(&mut self) {
// Fetch ALL properties in one batch
let results = self.get_properties_batch(&[
"time-pos",
"duration",
"metadata/by-key/artist",
"metadata/by-key/ARTIST",
"metadata/by-key/album",
"metadata/by-key/ALBUM",
"metadata/by-key/title",
"metadata/by-key/TITLE",
"media-title",
"audio-codec-name",
"audio-bitrate",
"audio-params/samplerate",
"demuxer-cache-duration",
]);
// Position
if let Some(val) = results.get("time-pos") {
if let Some(pos) = val.as_f64() {
self.position = pos;
}
}
// Update duration
if let Some(val) = self.get_property("duration") {
// Duration
if let Some(val) = results.get("duration") {
if let Some(dur) = val.as_f64() {
self.duration = dur;
}
}
}
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());
}
}
// Artist - try lowercase first, then uppercase
self.artist = results.get("metadata/by-key/artist")
.and_then(|v| v.as_str().map(|s| s.to_string()))
.or_else(|| results.get("metadata/by-key/ARTIST")
.and_then(|v| v.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());
}
}
// Album - try lowercase first, then uppercase
self.album = results.get("metadata/by-key/album")
.and_then(|v| v.as_str().map(|s| s.to_string()))
.or_else(|| results.get("metadata/by-key/ALBUM")
.and_then(|v| v.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());
}
}
// Title - try lowercase, then uppercase, then media-title
self.media_title = results.get("metadata/by-key/title")
.and_then(|v| v.as_str().map(|s| s.to_string()))
.or_else(|| results.get("metadata/by-key/TITLE")
.and_then(|v| v.as_str().map(|s| s.to_string())))
.or_else(|| results.get("media-title")
.and_then(|v| v.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());
}
}
// Audio codec
self.audio_codec = results.get("audio-codec-name")
.and_then(|v| v.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());
}
// Audio bitrate (convert from bps to kbps)
self.audio_bitrate = results.get("audio-bitrate")
.and_then(|v| v.as_f64().map(|b| b / 1000.0));
// 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);
}
// Sample rate
self.sample_rate = results.get("audio-params/samplerate")
.and_then(|v| v.as_i64());
// Update sample rate
if let Some(val) = self.get_property("audio-params/samplerate") {
self.sample_rate = val.as_i64();
}
// Update cache duration (how many seconds are buffered ahead)
if let Some(val) = self.get_property("demuxer-cache-duration") {
self.cache_duration = val.as_f64();
} else {
self.cache_duration = None;
}
// Cache duration
self.cache_duration = results.get("demuxer-cache-duration")
.and_then(|v| v.as_f64());
}
pub fn get_position(&self) -> Option<f64> {
@@ -323,20 +312,14 @@ impl Player {
Some(self.duration)
}
pub fn is_idle(&mut self) -> Option<bool> {
self.get_property("idle-active")
.and_then(|v| v.as_bool())
}
pub fn is_paused(&mut self) -> Option<bool> {
self.get_property("pause")
.and_then(|v| v.as_bool())
}
pub fn get_player_state(&mut self) -> Option<crate::state::PlayerState> {
use crate::state::PlayerState;
let is_idle = self.is_idle()?;
let is_paused = self.is_paused()?;
// Batch fetch both properties at once
let results = self.get_properties_batch(&["idle-active", "pause"]);
let is_idle = results.get("idle-active").and_then(|v| v.as_bool())?;
let is_paused = results.get("pause").and_then(|v| v.as_bool())?;
Some(if is_idle {
PlayerState::Stopped

View File

@@ -1,6 +1,7 @@
use crate::cache::{Cache, FileMetadata, FileTreeNode};
use anyhow::Result;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use walkdir::WalkDir;
const AUDIO_EXTENSIONS: &[&str] = &["mp3", "flac", "wav", "ogg", "m4a", "aac", "opus", "wma"];
@@ -33,7 +34,7 @@ pub fn is_video_file(path: &Path) -> bool {
}
}
pub fn scan_directory(root_path: &Path) -> Result<FileTreeNode> {
fn scan_directory_internal(root_path: &Path, counter: &AtomicUsize) -> Result<FileTreeNode> {
let name = root_path
.file_name()
.unwrap_or_default()
@@ -62,10 +63,16 @@ 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);
if let Ok(child_node) = scan_directory_internal(path, counter) {
// 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) {
// Increment counter for each media file found
counter.fetch_add(1, Ordering::Relaxed);
// Add media file
let file_name = path
.file_name()
@@ -97,13 +104,13 @@ pub fn scan_directory(root_path: &Path) -> Result<FileTreeNode> {
Ok(node)
}
pub fn scan_paths(paths: &[PathBuf]) -> Result<Cache> {
pub fn scan_paths(paths: &[PathBuf], counter: &AtomicUsize) -> Result<Cache> {
let mut cache = Cache::new();
for path in paths {
if path.exists() {
tracing::info!("Scanning path: {:?}", path);
let tree_node = scan_directory(path)?;
let tree_node = scan_directory_internal(path, counter)?;
// Collect all metadata from the tree
collect_metadata(&tree_node, &mut cache);

View File

@@ -74,14 +74,10 @@ pub struct AppState {
pub is_refreshing: bool,
pub search_mode: bool,
pub search_query: String,
pub search_matches: Vec<PathBuf>,
pub search_match_index: usize,
pub tab_search_results: Vec<PathBuf>,
pub tab_search_index: usize,
pub playlist_search_matches: Vec<usize>,
pub playlist_search_match_index: usize,
pub playlist_tab_search_results: Vec<usize>,
pub playlist_tab_search_index: usize,
pub search_results: Vec<PathBuf>,
pub search_result_index: usize,
pub playlist_search_results: Vec<usize>,
pub playlist_search_result_index: usize,
pub visual_mode: bool,
pub visual_anchor: usize,
pub saved_expanded_dirs: HashSet<PathBuf>,
@@ -92,6 +88,8 @@ pub struct AppState {
pub last_click_is_playlist: bool,
pub context_menu: Option<ContextMenu>,
pub play_mode: PlayMode,
pub last_error: Option<String>,
pub refresh_file_count: usize,
}
#[derive(Debug, Clone)]
@@ -131,14 +129,10 @@ impl AppState {
is_refreshing: false,
search_mode: false,
search_query: String::new(),
search_matches: Vec::new(),
search_match_index: 0,
tab_search_results: Vec::new(),
tab_search_index: 0,
playlist_search_matches: Vec::new(),
playlist_search_match_index: 0,
playlist_tab_search_results: Vec::new(),
playlist_tab_search_index: 0,
search_results: Vec::new(),
search_result_index: 0,
playlist_search_results: Vec::new(),
playlist_search_result_index: 0,
visual_mode: false,
visual_anchor: 0,
saved_expanded_dirs: HashSet::new(),
@@ -149,6 +143,8 @@ impl AppState {
last_click_is_playlist: false,
context_menu: None,
play_mode: PlayMode::Normal,
last_error: None,
refresh_file_count: 0,
}
}
@@ -587,8 +583,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) {
@@ -604,16 +676,12 @@ impl AppState {
if self.focus_playlist {
// Clear playlist search state
self.playlist_search_matches.clear();
self.playlist_search_match_index = 0;
self.playlist_tab_search_results.clear();
self.playlist_tab_search_index = 0;
self.playlist_search_results.clear();
self.playlist_search_result_index = 0;
} else {
// Clear file search state
self.search_matches.clear();
self.search_match_index = 0;
self.tab_search_results.clear();
self.tab_search_index = 0;
self.search_results.clear();
self.search_result_index = 0;
// Save current folder state
self.saved_expanded_dirs = self.expanded_dirs.clone();
}
@@ -621,15 +689,16 @@ impl AppState {
pub fn exit_search_mode(&mut self) {
self.search_mode = false;
self.search_query.clear();
if self.focus_playlist {
// Clear playlist search state
self.playlist_tab_search_results.clear();
self.playlist_tab_search_index = 0;
self.playlist_search_results.clear();
self.playlist_search_result_index = 0;
} else {
// Clear file search state
self.tab_search_results.clear();
self.tab_search_index = 0;
self.search_results.clear();
self.search_result_index = 0;
// Restore folder state from before search
self.expanded_dirs = self.saved_expanded_dirs.clone();
self.rebuild_flattened_items();
@@ -648,9 +717,8 @@ impl AppState {
fn perform_incremental_search(&mut self) {
if self.search_query.is_empty() {
self.tab_search_results.clear();
self.tab_search_index = 0;
// Don't rebuild tree on every keystroke - only when exiting search
self.search_results.clear();
self.search_result_index = 0;
return;
}
@@ -659,8 +727,8 @@ impl AppState {
collect_matching_paths(&self.cache.file_tree, &self.search_query, &mut matching_paths_with_scores);
if matching_paths_with_scores.is_empty() {
self.tab_search_results.clear();
self.tab_search_index = 0;
self.search_results.clear();
self.search_result_index = 0;
return;
}
@@ -674,22 +742,30 @@ impl AppState {
// Sort by score (highest first), then by original index to prefer first occurrence
indexed_matches.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.2.cmp(&b.2)));
// Store all matches for tab completion
self.tab_search_results = indexed_matches.iter().map(|(path, _, _)| path.clone()).collect();
self.tab_search_index = 0;
// Store all matches
self.search_results = indexed_matches.iter().map(|(path, _, _)| path.clone()).collect();
self.search_result_index = 0;
// Only expand and rebuild if this is a new best match
let best_match = self.tab_search_results[0].clone();
// Jump to first match
self.jump_to_current_search_result();
}
fn jump_to_current_search_result(&mut self) {
if self.search_results.is_empty() {
return;
}
let current_match = self.search_results[self.search_result_index].clone();
// Check if we need to expand folders for this match
let needs_expand = best_match.ancestors()
let needs_expand = current_match.ancestors()
.skip(1) // Skip the file itself
.any(|p| !self.expanded_dirs.contains(p));
if needs_expand {
// Close all folders and expand only for the best match
// Close all folders and expand only for the current match
self.expanded_dirs.clear();
let mut parent = best_match.parent();
let mut parent = current_match.parent();
while let Some(p) = parent {
self.expanded_dirs.insert(p.to_path_buf());
parent = p.parent();
@@ -699,8 +775,8 @@ impl AppState {
self.rebuild_flattened_items();
}
// Find the best match in the flattened list and jump to it
if let Some(idx) = self.flattened_items.iter().position(|item| item.path == best_match) {
// Find the match in the flattened list and jump to it
if let Some(idx) = self.flattened_items.iter().position(|item| item.path == current_match) {
self.selected_index = idx;
// Scroll to show the match
@@ -718,233 +794,26 @@ impl AppState {
}
}
pub fn next_search_result(&mut self) {
if !self.search_results.is_empty() {
self.search_result_index = (self.search_result_index + 1) % self.search_results.len();
self.jump_to_current_search_result();
}
}
pub fn prev_search_result(&mut self) {
if !self.search_results.is_empty() {
self.search_result_index = if self.search_result_index == 0 {
self.search_results.len() - 1
} else {
self.search_result_index - 1
};
self.jump_to_current_search_result();
}
}
pub fn execute_search(&mut self) {
if self.search_query.is_empty() {
self.search_mode = false;
return;
}
// Collect all matching paths with scores and preserve order
let mut matching_paths_with_scores = Vec::new();
collect_matching_paths(&self.cache.file_tree, &self.search_query, &mut matching_paths_with_scores);
if matching_paths_with_scores.is_empty() {
self.search_mode = false;
return;
}
// Add index to preserve original tree order when scores are equal
let mut indexed_matches: Vec<(PathBuf, i32, usize)> = matching_paths_with_scores
.into_iter()
.enumerate()
.map(|(idx, (path, score))| (path, score, idx))
.collect();
// Sort by score (highest first), then by original index to prefer first occurrence
indexed_matches.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.2.cmp(&b.2)));
let matching_paths_with_scores: Vec<(PathBuf, i32)> = indexed_matches
.into_iter()
.map(|(path, score, _)| (path, score))
.collect();
let matching_paths: Vec<PathBuf> = matching_paths_with_scores.iter().map(|(path, _)| path.clone()).collect();
// Store matching paths (not indices, as they change when folders collapse)
self.search_matches = matching_paths;
if !self.search_matches.is_empty() {
self.search_match_index = 0;
// Close all folders and expand only for first match
self.expanded_dirs.clear();
let first_match = self.search_matches[0].clone();
let mut parent = first_match.parent();
while let Some(p) = parent {
self.expanded_dirs.insert(p.to_path_buf());
parent = p.parent();
}
// Rebuild flattened items
self.rebuild_flattened_items();
// Find first match in flattened list
if let Some(idx) = self.flattened_items.iter().position(|item| item.path == first_match) {
self.selected_index = idx;
}
}
self.search_mode = false;
}
pub fn next_search_match(&mut self) {
if !self.search_matches.is_empty() {
self.search_match_index = (self.search_match_index + 1) % self.search_matches.len();
let target_path = self.search_matches[self.search_match_index].clone();
// Close all folders and expand only for this match
self.expanded_dirs.clear();
let mut parent = target_path.parent();
while let Some(p) = parent {
self.expanded_dirs.insert(p.to_path_buf());
parent = p.parent();
}
// Rebuild flattened items
self.rebuild_flattened_items();
// Find the path in current flattened items
if let Some(idx) = self.flattened_items.iter().position(|item| item.path == target_path) {
self.selected_index = idx;
// Scroll to show the match
// Account for "... X more below" indicator which takes one line
let visible_end = self.scroll_offset + self.file_panel_visible_height;
let items_below = if visible_end < self.flattened_items.len() {
self.flattened_items.len() - visible_end
} else {
0
};
let effective_height = if items_below > 0 {
self.file_panel_visible_height.saturating_sub(1)
} else {
self.file_panel_visible_height
};
if self.selected_index < self.scroll_offset {
self.scroll_offset = self.selected_index;
} else if self.selected_index >= self.scroll_offset + effective_height {
self.scroll_offset = self.selected_index - effective_height + 1;
}
}
}
}
pub fn prev_search_match(&mut self) {
if !self.search_matches.is_empty() {
if self.search_match_index == 0 {
self.search_match_index = self.search_matches.len() - 1;
} else {
self.search_match_index -= 1;
}
let target_path = self.search_matches[self.search_match_index].clone();
// Close all folders and expand only for this match
self.expanded_dirs.clear();
let mut parent = target_path.parent();
while let Some(p) = parent {
self.expanded_dirs.insert(p.to_path_buf());
parent = p.parent();
}
// Rebuild flattened items
self.rebuild_flattened_items();
// Find the path in current flattened items
if let Some(idx) = self.flattened_items.iter().position(|item| item.path == target_path) {
self.selected_index = idx;
// Scroll to show the match
// Account for "... X more below" indicator which takes one line
let visible_end = self.scroll_offset + self.file_panel_visible_height;
let items_below = if visible_end < self.flattened_items.len() {
self.flattened_items.len() - visible_end
} else {
0
};
let effective_height = if items_below > 0 {
self.file_panel_visible_height.saturating_sub(1)
} else {
self.file_panel_visible_height
};
if self.selected_index < self.scroll_offset {
self.scroll_offset = self.selected_index;
} else if self.selected_index >= self.scroll_offset + effective_height {
self.scroll_offset = self.selected_index - effective_height + 1;
}
}
}
}
pub fn tab_search_next(&mut self) {
if self.tab_search_results.is_empty() {
return;
}
// Cycle to next match
self.tab_search_index = (self.tab_search_index + 1) % self.tab_search_results.len();
let next_match = self.tab_search_results[self.tab_search_index].clone();
// Close all folders and expand only for this match
self.expanded_dirs.clear();
let mut parent = next_match.parent();
while let Some(p) = parent {
self.expanded_dirs.insert(p.to_path_buf());
parent = p.parent();
}
// Rebuild flattened items
self.rebuild_flattened_items();
// Find and select the match
if let Some(idx) = self.flattened_items.iter().position(|item| item.path == next_match) {
self.selected_index = idx;
// Scroll to show the match
let effective_height = calculate_effective_height(
self.scroll_offset,
self.file_panel_visible_height,
self.flattened_items.len()
);
if self.selected_index < self.scroll_offset {
self.scroll_offset = self.selected_index;
} else if self.selected_index >= self.scroll_offset + effective_height {
self.scroll_offset = self.selected_index - effective_height + 1;
}
}
}
pub fn tab_search_prev(&mut self) {
if self.tab_search_results.is_empty() {
return;
}
// Cycle to previous match
if self.tab_search_index == 0 {
self.tab_search_index = self.tab_search_results.len() - 1;
} else {
self.tab_search_index -= 1;
}
let prev_match = self.tab_search_results[self.tab_search_index].clone();
// Close all folders and expand only for this match
self.expanded_dirs.clear();
let mut parent = prev_match.parent();
while let Some(p) = parent {
self.expanded_dirs.insert(p.to_path_buf());
parent = p.parent();
}
// Rebuild flattened items
self.rebuild_flattened_items();
// Find and select the match
if let Some(idx) = self.flattened_items.iter().position(|item| item.path == prev_match) {
self.selected_index = idx;
// Scroll to show the match
let effective_height = calculate_effective_height(
self.scroll_offset,
self.file_panel_visible_height,
self.flattened_items.len()
);
if self.selected_index < self.scroll_offset {
self.scroll_offset = self.selected_index;
} else if self.selected_index >= self.scroll_offset + effective_height {
self.scroll_offset = self.selected_index - effective_height + 1;
}
}
self.exit_search_mode();
}
pub fn append_playlist_search_char(&mut self, c: char) {
@@ -959,8 +828,8 @@ impl AppState {
fn perform_playlist_incremental_search(&mut self) {
if self.search_query.is_empty() {
self.playlist_tab_search_results.clear();
self.playlist_tab_search_index = 0;
self.playlist_search_results.clear();
self.playlist_search_result_index = 0;
return;
}
@@ -977,21 +846,29 @@ impl AppState {
.collect();
if matching_indices_with_scores.is_empty() {
self.playlist_tab_search_results.clear();
self.playlist_tab_search_index = 0;
self.playlist_search_results.clear();
self.playlist_search_result_index = 0;
return;
}
// Sort by score (highest first)
matching_indices_with_scores.sort_by(|a, b| b.1.cmp(&a.1));
// Store all matches for tab completion
self.playlist_tab_search_results = matching_indices_with_scores.iter().map(|(idx, _)| *idx).collect();
self.playlist_tab_search_index = 0;
// Store all matches
self.playlist_search_results = matching_indices_with_scores.iter().map(|(idx, _)| *idx).collect();
self.playlist_search_result_index = 0;
// Jump to best match
let best_match_idx = self.playlist_tab_search_results[0];
self.selected_playlist_index = best_match_idx;
// Jump to first match
self.jump_to_current_playlist_search_result();
}
fn jump_to_current_playlist_search_result(&mut self) {
if self.playlist_search_results.is_empty() {
return;
}
let match_idx = self.playlist_search_results[self.playlist_search_result_index];
self.selected_playlist_index = match_idx;
// Scroll to show the match
let effective_height = calculate_effective_height(
@@ -1007,174 +884,26 @@ impl AppState {
}
}
pub fn playlist_tab_search_next(&mut self) {
if self.playlist_tab_search_results.is_empty() {
return;
}
// Cycle to next match
self.playlist_tab_search_index = (self.playlist_tab_search_index + 1) % self.playlist_tab_search_results.len();
let next_match_idx = self.playlist_tab_search_results[self.playlist_tab_search_index];
self.selected_playlist_index = next_match_idx;
// Scroll to show the match
let effective_height = calculate_effective_height(
self.playlist_scroll_offset,
self.playlist_visible_height,
self.playlist.len()
);
if self.selected_playlist_index < self.playlist_scroll_offset {
self.playlist_scroll_offset = self.selected_playlist_index;
} else if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height {
self.playlist_scroll_offset = self.selected_playlist_index - effective_height + 1;
pub fn next_playlist_search_result(&mut self) {
if !self.playlist_search_results.is_empty() {
self.playlist_search_result_index = (self.playlist_search_result_index + 1) % self.playlist_search_results.len();
self.jump_to_current_playlist_search_result();
}
}
pub fn playlist_tab_search_prev(&mut self) {
if self.playlist_tab_search_results.is_empty() {
return;
}
// Cycle to previous match
if self.playlist_tab_search_index == 0 {
self.playlist_tab_search_index = self.playlist_tab_search_results.len() - 1;
} else {
self.playlist_tab_search_index -= 1;
}
let prev_match_idx = self.playlist_tab_search_results[self.playlist_tab_search_index];
self.selected_playlist_index = prev_match_idx;
// Scroll to show the match
let effective_height = calculate_effective_height(
self.playlist_scroll_offset,
self.playlist_visible_height,
self.playlist.len()
);
if self.selected_playlist_index < self.playlist_scroll_offset {
self.playlist_scroll_offset = self.selected_playlist_index;
} else if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height {
self.playlist_scroll_offset = self.selected_playlist_index - effective_height + 1;
pub fn prev_playlist_search_result(&mut self) {
if !self.playlist_search_results.is_empty() {
self.playlist_search_result_index = if self.playlist_search_result_index == 0 {
self.playlist_search_results.len() - 1
} else {
self.playlist_search_result_index - 1
};
self.jump_to_current_playlist_search_result();
}
}
pub fn execute_playlist_search(&mut self) {
if self.search_query.is_empty() {
self.search_mode = false;
return;
}
// Collect all matching indices with scores
let mut matching_indices_with_scores: Vec<(usize, i32)> = self.playlist
.iter()
.enumerate()
.filter_map(|(idx, path)| {
let filename = path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
fuzzy_match(filename, &self.search_query).map(|score| (idx, score))
})
.collect();
if matching_indices_with_scores.is_empty() {
self.search_mode = false;
return;
}
// Sort by score (highest first)
matching_indices_with_scores.sort_by(|a, b| b.1.cmp(&a.1));
// Store matching indices
self.playlist_search_matches = matching_indices_with_scores.iter().map(|(idx, _)| *idx).collect();
if !self.playlist_search_matches.is_empty() {
self.playlist_search_match_index = 0;
let first_match_idx = self.playlist_search_matches[0];
self.selected_playlist_index = first_match_idx;
// Scroll to show the match
// Account for "... X more below" indicator which takes one line
let visible_end = self.playlist_scroll_offset + self.playlist_visible_height;
let items_below = if visible_end < self.playlist.len() {
self.playlist.len() - visible_end
} else {
0
};
let effective_height = if items_below > 0 {
self.playlist_visible_height.saturating_sub(1)
} else {
self.playlist_visible_height
};
if self.selected_playlist_index < self.playlist_scroll_offset {
self.playlist_scroll_offset = self.selected_playlist_index;
} else if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height {
self.playlist_scroll_offset = self.selected_playlist_index - effective_height + 1;
}
}
self.search_mode = false;
}
pub fn next_playlist_search_match(&mut self) {
if !self.playlist_search_matches.is_empty() {
self.playlist_search_match_index = (self.playlist_search_match_index + 1) % self.playlist_search_matches.len();
let match_idx = self.playlist_search_matches[self.playlist_search_match_index];
self.selected_playlist_index = match_idx;
// Scroll to show the match
// Account for "... X more below" indicator which takes one line
let visible_end = self.playlist_scroll_offset + self.playlist_visible_height;
let items_below = if visible_end < self.playlist.len() {
self.playlist.len() - visible_end
} else {
0
};
let effective_height = if items_below > 0 {
self.playlist_visible_height.saturating_sub(1)
} else {
self.playlist_visible_height
};
if self.selected_playlist_index < self.playlist_scroll_offset {
self.playlist_scroll_offset = self.selected_playlist_index;
} else if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height {
self.playlist_scroll_offset = self.selected_playlist_index - effective_height + 1;
}
}
}
pub fn prev_playlist_search_match(&mut self) {
if !self.playlist_search_matches.is_empty() {
if self.playlist_search_match_index == 0 {
self.playlist_search_match_index = self.playlist_search_matches.len() - 1;
} else {
self.playlist_search_match_index -= 1;
}
let match_idx = self.playlist_search_matches[self.playlist_search_match_index];
self.selected_playlist_index = match_idx;
// Scroll to show the match
// Account for "... X more below" indicator which takes one line
let visible_end = self.playlist_scroll_offset + self.playlist_visible_height;
let items_below = if visible_end < self.playlist.len() {
self.playlist.len() - visible_end
} else {
0
};
let effective_height = if items_below > 0 {
self.playlist_visible_height.saturating_sub(1)
} else {
self.playlist_visible_height
};
if self.selected_playlist_index < self.playlist_scroll_offset {
self.playlist_scroll_offset = self.selected_playlist_index;
} else if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height {
self.playlist_scroll_offset = self.selected_playlist_index - effective_height + 1;
}
}
self.exit_search_mode();
}
}

View File

@@ -28,17 +28,60 @@ pub fn render(frame: &mut Frame, state: &mut AppState, player: &mut Player) -> (
])
.split(frame.area());
// Main content: left (files) | right (status + playlist)
let content_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(main_chunks[1]);
// Always use tab mode - show only the focused panel
let tab_mode = true;
// Build the title with focused panel in bold
let file_style = if !state.focus_playlist {
Style::default().fg(Theme::bright_foreground()).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Theme::bright_foreground())
};
let playlist_style = if state.focus_playlist {
Style::default().fg(Theme::bright_foreground()).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Theme::bright_foreground())
};
// Add playlist counter
let playlist_text = if !state.playlist.is_empty() {
format!("playlist [{}/{}]", state.playlist_index + 1, state.playlist.len())
} else {
"playlist (empty)".to_string()
};
let title = Line::from(vec![
Span::styled("files", file_style),
Span::raw(" | "),
Span::styled(playlist_text, playlist_style),
]);
// Create one border around the entire content area with fixed gray border
let main_block = Block::default()
.borders(Borders::ALL)
.title(title)
.style(Theme::widget_border_style());
let inner_area = main_block.inner(main_chunks[1]);
render_title_bar(frame, state, player, main_chunks[0]);
render_file_panel(frame, state, content_chunks[0]);
render_right_panel(frame, state, content_chunks[1]);
frame.render_widget(main_block, main_chunks[1]);
// Tab mode - show only focused panel
if state.focus_playlist {
render_right_panel(frame, state, inner_area, tab_mode);
} else {
render_file_panel(frame, state, inner_area, tab_mode);
}
render_status_bar(frame, state, player, main_chunks[2]);
// Show refreshing popup if scanning
if state.is_refreshing {
render_refresh_popup(frame, state.refresh_file_count);
}
// Show confirmation popup if needed
if state.show_refresh_confirm {
render_confirm_popup(frame, "Refresh library?", "This may take a while");
@@ -50,7 +93,12 @@ pub fn render(frame: &mut Frame, state: &mut AppState, player: &mut Player) -> (
}
// Return title bar area, file panel area, and playlist area for mouse event handling
(main_chunks[0], content_chunks[0], content_chunks[1])
// Use main_chunks[1] (full area) so mouse coordinates align properly
if state.focus_playlist {
(main_chunks[0], Rect::default(), main_chunks[1])
} else {
(main_chunks[0], main_chunks[1], Rect::default())
}
}
fn highlight_search_matches<'a>(text: &str, query: &str, is_selected: bool) -> Vec<Span<'a>> {
@@ -108,13 +156,13 @@ fn highlight_search_matches<'a>(text: &str, query: &str, is_selected: bool) -> V
spans
}
fn render_file_panel(frame: &mut Frame, state: &mut AppState, area: Rect) {
// Calculate visible height (subtract 2 for borders)
let visible_height = area.height.saturating_sub(2) as usize;
fn render_file_panel(frame: &mut Frame, state: &mut AppState, area: Rect, _tab_mode: bool) {
// Calculate visible height (no borders on individual panels now)
let visible_height = area.height as usize;
// Store visible height for keyboard navigation scroll calculations
state.file_panel_visible_height = visible_height;
let in_search = !state.focus_playlist && (state.search_mode || !state.search_matches.is_empty());
let in_search = !state.focus_playlist && state.search_mode;
let search_query = if in_search { state.search_query.to_lowercase() } else { String::new() };
// Calculate how many items are below the visible area
@@ -226,14 +274,7 @@ fn render_file_panel(frame: &mut Frame, state: &mut AppState, area: Rect) {
items.push(more_item);
}
let list = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.title("files")
.style(Theme::widget_border_style())
.title_style(Theme::title_style()),
);
let list = List::new(items);
let mut list_state = ListState::default();
// Don't set selection to avoid automatic scrolling - we manage scroll manually
@@ -242,9 +283,9 @@ fn render_file_panel(frame: &mut Frame, state: &mut AppState, area: Rect) {
frame.render_stateful_widget(list, area, &mut list_state);
}
fn render_right_panel(frame: &mut Frame, state: &mut AppState, area: Rect) {
// Calculate visible height (subtract 2 for borders)
let visible_height = area.height.saturating_sub(2) as usize;
fn render_right_panel(frame: &mut Frame, state: &mut AppState, area: Rect, _tab_mode: bool) {
// Calculate visible height (no borders on individual panels now)
let visible_height = area.height as usize;
// Store visible height for keyboard navigation scroll calculations
state.playlist_visible_height = visible_height;
@@ -265,7 +306,7 @@ fn render_right_panel(frame: &mut Frame, state: &mut AppState, area: Rect) {
};
// Check if in playlist search mode
let in_playlist_search = state.focus_playlist && (state.search_mode || !state.playlist_tab_search_results.is_empty() || !state.playlist_search_matches.is_empty());
let in_playlist_search = state.focus_playlist && state.search_mode;
let playlist_search_query = if in_playlist_search { state.search_query.to_lowercase() } else { String::new() };
// Playlist panel (no longer need the player status box)
@@ -285,12 +326,17 @@ fn render_right_panel(frame: &mut Frame, state: &mut AppState, area: Rect) {
let is_selected = state.focus_playlist && idx == state.selected_playlist_index;
let is_playing = idx == state.playlist_index;
// Add playing indicator arrow
let indicator = if is_playing { "" } else { " " };
// Build line with search highlighting if searching
let line = if in_playlist_search && !playlist_search_query.is_empty() {
Line::from(highlight_search_matches(&filename, &playlist_search_query, is_selected))
let mut line_spans = vec![Span::raw(indicator)];
if in_playlist_search && !playlist_search_query.is_empty() {
line_spans.extend(highlight_search_matches(&filename, &playlist_search_query, is_selected));
} else {
Line::from(filename)
};
line_spans.push(Span::raw(filename));
}
let line = Line::from(line_spans);
let style = if is_selected && is_playing {
// Both selected and playing: selection bar with bold
@@ -328,20 +374,7 @@ fn render_right_panel(frame: &mut Frame, state: &mut AppState, area: Rect) {
playlist_items.push(more_item);
}
let playlist_title = if !state.playlist.is_empty() {
format!("playlist [{}/{}]", state.playlist_index + 1, state.playlist.len())
} else {
"playlist (empty)".to_string()
};
let playlist_widget = List::new(playlist_items)
.block(
Block::default()
.borders(Borders::ALL)
.title(playlist_title)
.style(Theme::widget_border_style())
.title_style(Theme::title_style()),
);
let playlist_widget = List::new(playlist_items);
let mut playlist_state = ListState::default();
// Don't set selection to avoid automatic scrolling - we manage scroll manually
@@ -351,12 +384,17 @@ fn render_right_panel(frame: &mut Frame, state: &mut AppState, area: Rect) {
}
fn render_title_bar(frame: &mut Frame, state: &AppState, player: &mut Player, area: Rect) {
// Default to stopped if we can't query MPV
// Get player state
let player_state = player.get_player_state().unwrap_or(PlayerState::Stopped);
let background_color = match player_state {
PlayerState::Playing => Theme::success(), // Green for playing
PlayerState::Paused => Theme::highlight(), // Blue for paused
PlayerState::Stopped => Theme::dim_foreground(), // Gray for stopped
// Title bar background color: red for error, gray for stopped/paused, green for playing
let background_color = if state.last_error.is_some() {
Theme::error()
} else {
match player_state {
PlayerState::Playing => Theme::success(), // Green for playing
PlayerState::Paused | PlayerState::Stopped => Theme::border(), // Gray for paused/stopped
}
};
// Split the title bar into left and right sections
@@ -381,16 +419,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",
@@ -462,12 +491,24 @@ fn render_title_bar(frame: &mut Frame, state: &AppState, player: &mut Player, ar
}
fn render_status_bar(frame: &mut Frame, state: &AppState, player: &mut Player, area: Rect) {
if state.search_mode {
// Calculate progress percentage for progress bar
let progress_percent = if state.current_duration > 0.0 {
(state.current_position / state.current_duration).clamp(0.0, 1.0)
} else {
0.0
};
// If playing and has duration, show progress bar with overlaid text
let player_state = player.get_player_state().unwrap_or(PlayerState::Stopped);
let show_progress_bar = player_state != PlayerState::Stopped && state.current_duration > 0.0;
// Determine text content based on mode
let status_text = if state.search_mode {
// Show search prompt with current query and match count - LEFT aligned
let search_text = if state.focus_playlist {
if state.focus_playlist {
// Searching in playlist
if !state.playlist_tab_search_results.is_empty() {
format!("/{}_ Playlist Search: {}/{}", state.search_query, state.playlist_tab_search_index + 1, state.playlist_tab_search_results.len())
if !state.playlist_search_results.is_empty() {
format!("/{}_ Playlist Search: {}/{}", state.search_query, state.playlist_search_result_index + 1, state.playlist_search_results.len())
} else if !state.search_query.is_empty() {
format!("/{}_ [no matches]", state.search_query)
} else {
@@ -475,35 +516,29 @@ fn render_status_bar(frame: &mut Frame, state: &AppState, player: &mut Player, a
}
} else {
// Searching in file panel
if !state.tab_search_results.is_empty() {
format!("/{}_ Search: {}/{}", state.search_query, state.tab_search_index + 1, state.tab_search_results.len())
if !state.search_results.is_empty() {
format!("/{}_ Search: {}/{}", state.search_query, state.search_result_index + 1, state.search_results.len())
} else if !state.search_query.is_empty() {
format!("/{}_ [no matches]", state.search_query)
} else {
format!("/{}_", state.search_query)
}
};
let status_bar = Paragraph::new(search_text)
.style(Style::default().fg(Color::White).bg(Theme::background()));
frame.render_widget(status_bar, area);
} else if !state.search_matches.is_empty() {
// Show search navigation when file search results are active
let search_text = format!("/{} Search: {}/{}", state.search_query, state.search_match_index + 1, state.search_matches.len());
let status_bar = Paragraph::new(search_text)
.style(Style::default().fg(Color::White).bg(Theme::background()));
frame.render_widget(status_bar, area);
} else if !state.playlist_search_matches.is_empty() {
// Show search navigation when playlist search results are active
let search_text = format!("/{} Playlist Search: {}/{}", state.search_query, state.playlist_search_match_index + 1, state.playlist_search_matches.len());
let status_bar = Paragraph::new(search_text)
.style(Style::default().fg(Color::White).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()));
format!("-- VISUAL -- {} files marked", state.marked_files.len())
} else {
String::new()
};
// If we have status text (search/visual mode), show it without progress bar
if !status_text.is_empty() {
let status_bar = Paragraph::new(status_text)
.style(Style::default().fg(Color::White).bg(Theme::background()));
frame.render_widget(status_bar, area);
} else if show_progress_bar {
// Show progress bar with metadata text overlay
render_progress_bar(frame, state, player, area, progress_percent);
} else {
// Normal mode: show media metadata if playing
// Split into left (artist/album/title) and right (technical info)
@@ -537,12 +572,6 @@ fn render_status_bar(frame: &mut Frame, state: &AppState, player: &mut Player, a
right_parts.push(format!("{} Hz", samplerate));
}
if let Some(cache_dur) = player.cache_duration {
if cache_dur > 0.0 {
right_parts.push(format!("{:.1}s", cache_dur));
}
}
// Create layout for left and right sections
let chunks = Layout::default()
.direction(Direction::Horizontal)
@@ -575,6 +604,171 @@ fn render_status_bar(frame: &mut Frame, state: &AppState, player: &mut Player, a
}
}
fn render_progress_bar(frame: &mut Frame, _state: &AppState, player: &mut Player, area: Rect, progress_percent: f64) {
// Get metadata to display
let mut right_parts = Vec::new();
// Right side: Bitrate | Codec | Sample rate (metrics that must always be visible)
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));
}
// Build right text
let right_text = if !right_parts.is_empty() {
format!("{} ", right_parts.join(" | "))
} else {
String::new()
};
// Calculate available space
let total_width = area.width as usize;
let right_text_len = right_text.chars().count();
// Reserve space: 1 char at start, gap between left and right
let available_for_left = total_width.saturating_sub(right_text_len).saturating_sub(2);
// Collect left side metadata
let mut left_fields = Vec::new();
if let Some(ref artist) = player.artist {
left_fields.push(("artist", artist.as_str()));
}
if let Some(ref album) = player.album {
left_fields.push(("album", album.as_str()));
}
if let Some(ref title) = player.media_title {
left_fields.push(("title", title.as_str()));
}
// Calculate space per field (divide available space among fields)
let left_text = if !left_fields.is_empty() {
let num_fields = left_fields.len();
let separator_space = (num_fields - 1) * 3; // " | " between fields
let available_for_fields = available_for_left.saturating_sub(separator_space);
let max_per_field = available_for_fields / num_fields;
// Truncate each field individually
let truncated_fields: Vec<String> = left_fields.iter().map(|(_name, value)| {
if value.chars().count() > max_per_field && max_per_field > 3 {
let mut s: String = value.chars().take(max_per_field - 3).collect();
s.push_str("...");
s
} else if value.chars().count() > max_per_field {
// Very tight space, just cut hard
value.chars().take(max_per_field).collect()
} else {
value.to_string()
}
}).collect();
format!(" {}", truncated_fields.join(" | "))
} else {
String::new()
};
// Calculate filled width based on progress
let filled_width = (total_width as f64 * progress_percent) as usize;
// Build the full line character by character with proper spacing
let left_chars: Vec<char> = left_text.chars().collect();
let right_chars: Vec<char> = right_text.chars().collect();
let right_start_pos = total_width.saturating_sub(right_chars.len());
// Build spans with progress bar background
let mut spans = Vec::new();
for i in 0..total_width {
// Determine which character to show
let ch = if i < left_chars.len() {
left_chars[i].to_string()
} else if i >= right_start_pos && i - right_start_pos < right_chars.len() {
right_chars[i - right_start_pos].to_string()
} else {
" ".to_string()
};
// Apply progress bar background
if i < filled_width {
// Filled portion - border color background with black text
spans.push(Span::styled(
ch,
Style::default()
.fg(Color::Black)
.bg(Theme::border())
));
} else {
// Unfilled portion - normal background
spans.push(Span::styled(
ch,
Style::default()
.fg(Theme::muted_text())
.bg(Theme::background())
));
}
}
let progress_line = Line::from(spans);
let progress_widget = Paragraph::new(progress_line);
frame.render_widget(progress_widget, area);
}
fn render_refresh_popup(frame: &mut Frame, file_count: usize) {
// Create centered popup area - bigger for two lines
let area = frame.area();
let popup_width = 50;
let popup_height = 5;
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);
// Build two-line message
let lines = if file_count > 0 {
vec![
Line::from("Refreshing library..."),
Line::from(""),
Line::from(format!("{} files found", file_count))
.style(Style::default().fg(Theme::highlight())),
]
} else {
vec![
Line::from("Refreshing library..."),
]
};
// Render message centered
let message_widget = Paragraph::new(lines)
.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();

View File

@@ -34,6 +34,10 @@ impl Theme {
Color::Rgb(215, 175, 95) // #d7af5f
}
pub fn normal_red() -> Color {
Color::Rgb(215, 95, 95) // #d75f5f
}
// Semantic mappings
pub fn secondary_text() -> Color {
Self::foreground()
@@ -47,10 +51,6 @@ impl Theme {
Self::dim_foreground()
}
pub fn border_title() -> Color {
Self::bright_foreground()
}
pub fn highlight() -> Color {
Self::normal_blue()
}
@@ -63,17 +63,15 @@ impl Theme {
Self::normal_yellow()
}
pub fn error() -> Color {
Self::normal_red()
}
// Styles
pub fn widget_border_style() -> Style {
Style::default().fg(Self::border()).bg(Self::background())
}
pub fn title_style() -> Style {
Style::default()
.fg(Self::border_title())
.bg(Self::background())
}
pub fn secondary() -> Style {
Style::default()
.fg(Self::secondary_text())