13 Commits

Author SHA1 Message Date
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
4529fad61d Reduce memory usage by ~50 MB for large libraries
All checks were successful
Build and Release / build-and-release (push) Successful in 50s
FlattenedItem now stores only essential fields (path, name, is_dir, depth)
instead of cloning entire FileTreeNode structures. For 500,000 files, this
reduces memory from ~100 MB to ~50 MB for the flattened view.

- Extract only needed fields in flatten_tree()
- Add find_node_by_path() helper to look up full nodes when needed
- Update all UI and state code to use new structure
2025-12-11 19:39:26 +01:00
6ad522f27c Optimize performance and reduce binary size
All checks were successful
Build and Release / build-and-release (push) Successful in 50s
- Remove tokio async runtime dependency (~2MB reduction)
- Optimize fuzzy search to avoid string allocations
- Optimize incremental search to only rebuild tree when needed
- Extract duplicate scrolling logic to helper function
- Replace magic numbers with named constants
- Fix terminal cleanup to run even on error
- Fix context menu item count mismatch
- Remove unused metadata fields (duration, codec, hash)
2025-12-11 19:27:50 +01:00
55e3f04e2c Fix auto-play next track during pause/unpause transitions
All checks were successful
Build and Release / build-and-release (push) Successful in 53s
When rapidly pressing play/pause, MPV briefly reports idle-active
as true during state transitions. Combined with our player_state
being set to Playing after unpause, this incorrectly triggered the
auto-play next track logic.

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

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

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

Fixes bug where after pause, playing another folder would show
"Playing" status but remain silent at 00:00.
2025-12-11 15:32:37 +01:00
f1412b4f8c Refactor to eliminate keyboard/mouse handler disconnects
All checks were successful
Build and Release / build-and-release (push) Successful in 53s
Extract duplicate logic into shared action functions to ensure
consistent behavior between keyboard and mouse interactions:

- action_remove_from_playlist: Unified playlist removal logic
- action_play_from_playlist: Unified playlist playback with optional
  pause state preservation
- handle_context_menu_action: Unified context menu execution

Fixes:
- Remove from playlist now checks index before removal (was broken
  in keyboard 'd' handler)
- Mouse double-click on playlist now preserves pause state
- Context menu handling no longer duplicated across 400+ lines

All keyboard and mouse actions now use identical code paths,
eliminating state bugs from inconsistent implementations.
2025-12-11 15:16:57 +01:00
ffe7cd0090 Fix time display to update smoothly
All checks were successful
Build and Release / build-and-release (push) Successful in 54s
Change position update logic to only trigger redraw when the
displayed value (rounded to seconds) changes, not when the raw
float value changes. This eliminates jumpy time display and
reduces unnecessary redraws.
2025-12-09 12:33:52 +01:00
907a734be3 Remove Cache prefix from cache duration display
All checks were successful
Build and Release / build-and-release (push) Successful in 55s
Display cache duration as "1.5s" instead of "Cache:1.5s" in
bottom status bar for cleaner presentation alongside other
technical metrics.
2025-12-09 12:23:04 +01:00
135700ce02 Update cache metric refresh rate to match other metadata
All checks were successful
Build and Release / build-and-release (push) Successful in 54s
Move cache duration update from update_properties (~10Hz) to
update_metadata (~0.5Hz) to match the refresh rate of codec,
bitrate, and sample rate. All bottom status bar metrics now
update at the same frequency.
2025-12-09 12:06:59 +01:00
9 changed files with 1192 additions and 693 deletions

View File

@@ -14,6 +14,29 @@ A high-performance Rust-based TUI player for playing music and video files. Buil
## Architecture ## Architecture
### State Management
**CRITICAL:** Player state must be derived from MPV, not maintained separately.
**Single Source of Truth:** MPV properties via IPC
- `idle-active` (bool) - No file loaded or file ended
- `pause` (bool) - Playback is paused
**Derive PlayerState:**
```rust
if player.is_idle PlayerState::Stopped
if !player.is_idle && player.is_paused PlayerState::Paused
if !player.is_idle && !player.is_paused PlayerState::Playing
```
**Rationale:**
- Eliminates state synchronization bugs
- MPV is always the authoritative source
- No need to update state in multiple places
- Simpler auto-play logic
**Anti-pattern:** DO NOT maintain `state.player_state` that can desync from MPV
### Cache-Only Operation ### Cache-Only Operation
**CRITICAL:** Left panel shows ONLY cached data. Never browse filesystem directly during operation. **CRITICAL:** Left panel shows ONLY cached data. Never browse filesystem directly during operation.
@@ -67,6 +90,72 @@ paths = [
- `r` - Rescan library (manual refresh) - `r` - Rescan library (manual refresh)
- `q` - Quit - `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 ### Technical Details
- **MPV IPC** - Communicates with mpv via Unix socket and JSON protocol - **MPV IPC** - Communicates with mpv via Unix socket and JSON protocol
- **No Version Lock** - Uses mpv binary, not libmpv library (avoids version mismatch) - **No Version Lock** - Uses mpv binary, not libmpv library (avoids version mismatch)

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "cm-player" name = "cm-player"
version = "0.1.15" version = "0.1.26"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
@@ -8,9 +8,6 @@ edition = "2021"
ratatui = "0.28" ratatui = "0.28"
crossterm = "0.28" crossterm = "0.28"
# Async runtime
tokio = { version = "1.40", features = ["full"] }
# Serialization # Serialization
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"

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

@@ -0,0 +1,136 @@
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,
}
}
pub fn success_with_data(data: serde_json::Value) -> Self {
Self {
success: true,
message: None,
data: Some(data),
}
}
pub fn error(message: String) -> Self {
Self {
success: false,
message: Some(message),
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"))
}

3
src/cache/mod.rs vendored
View File

@@ -8,9 +8,6 @@ use std::path::{Path, PathBuf};
pub struct FileMetadata { pub struct FileMetadata {
pub path: PathBuf, pub path: PathBuf,
pub size: u64, pub size: u64,
pub duration: Option<f64>,
pub codec: Option<String>,
pub hash: Option<String>,
pub is_video: bool, pub is_video: bool,
pub is_audio: bool, pub is_audio: bool,
} }

View File

@@ -1,3 +1,4 @@
mod api;
mod cache; mod cache;
mod config; mod config;
mod player; mod player;
@@ -13,11 +14,23 @@ use crossterm::{
}; };
use ratatui::{backend::CrosstermBackend, Terminal}; use ratatui::{backend::CrosstermBackend, Terminal};
use state::{AppState, PlayerState}; use state::{AppState, PlayerState};
use std::io; use std::io::{self, BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use tracing_subscriber; use tracing_subscriber;
#[tokio::main] // UI update intervals and thresholds
async fn main() -> Result<()> { 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 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 // Initialize logging to file to avoid interfering with TUI
let log_file = std::fs::OpenOptions::new() let log_file = std::fs::OpenOptions::new()
.create(true) .create(true)
@@ -49,21 +62,32 @@ async fn main() -> Result<()> {
// Initialize player // Initialize player
let mut player = player::Player::new()?; let mut player = player::Player::new()?;
tracing::info!("Player initialized");
// Initialize app state // Initialize app state
let mut state = AppState::new(cache, config); 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 // Setup terminal
enable_raw_mode()?; enable_raw_mode()?;
tracing::info!("Raw mode enabled");
let mut stdout = io::stdout(); let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
tracing::info!("Terminal setup complete");
let backend = CrosstermBackend::new(stdout); let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?; let mut terminal = Terminal::new(backend)?;
tracing::info!("Terminal created, entering main loop");
// Run app // Run app (ensure terminal cleanup even on error)
let result = run_app(&mut terminal, &mut state, &mut player).await; let result = run_app(&mut terminal, &mut state, &mut player, api_rx);
// Restore terminal // Restore terminal (always run cleanup, even if result is Err)
let cleanup_result = (|| -> Result<()> {
disable_raw_mode()?; disable_raw_mode()?;
execute!( execute!(
terminal.backend_mut(), terminal.backend_mut(),
@@ -71,16 +95,119 @@ async fn main() -> Result<()> {
DisableMouseCapture DisableMouseCapture
)?; )?;
terminal.show_cursor()?; terminal.show_cursor()?;
Ok(())
})();
// Log cleanup errors but prioritize original error
if let Err(e) = cleanup_result {
tracing::error!("Terminal cleanup failed: {}", e);
}
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 // Common action functions that both keyboard and mouse handlers can call
fn action_toggle_folder(state: &mut AppState) { fn action_toggle_folder(state: &mut AppState) {
if let Some(item) = state.get_selected_item() { if let Some(item) = state.get_selected_item() {
if item.node.is_dir { if item.is_dir {
let path = item.node.path.clone(); let path = item.path.clone();
if state.expanded_dirs.contains(&path) { if state.expanded_dirs.contains(&path) {
// Folder is open, close it // Folder is open, close it
state.collapse_selected(); state.collapse_selected();
@@ -92,11 +219,13 @@ 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(); state.play_selection();
if let Some(ref path) = state.current_file { if let Some(ref path) = state.current_file {
*skip_position_update = true; // Skip position update after track change
player.play(path)?; player.play(path)?;
state.player_state = PlayerState::Playing; // Explicitly resume playback in case MPV was paused
player.resume()?;
player.update_metadata(); player.update_metadata();
tracing::info!("Playing: {:?} (playlist: {} tracks)", path, state.playlist.len()); tracing::info!("Playing: {:?} (playlist: {} tracks)", path, state.playlist.len());
} }
@@ -108,106 +237,340 @@ fn action_play_selection(state: &mut AppState, player: &mut player::Player) -> R
} }
fn action_toggle_play_pause(state: &mut AppState, player: &mut player::Player) -> Result<()> { fn action_toggle_play_pause(state: &mut AppState, player: &mut player::Player) -> Result<()> {
match state.player_state { if let Some(player_state) = player.get_player_state() {
match player_state {
PlayerState::Playing => { PlayerState::Playing => {
player.pause()?; player.pause()?;
state.player_state = PlayerState::Paused;
tracing::info!("Paused"); tracing::info!("Paused");
} }
PlayerState::Paused => { PlayerState::Paused => {
player.resume()?; player.resume()?;
state.player_state = PlayerState::Playing;
tracing::info!("Resumed"); tracing::info!("Resumed");
} }
PlayerState::Stopped => { PlayerState::Stopped => {
// Restart playback from current playlist position // Restart playback from current playlist position
if !state.playlist.is_empty() { if !state.playlist.is_empty() {
state.current_file = Some(state.playlist[state.playlist_index].clone()); state.current_file = Some(state.playlist[state.playlist_index].clone());
state.player_state = PlayerState::Playing;
if let Some(ref path) = state.current_file { if let Some(ref path) = state.current_file {
player.play(path)?; player.play(path)?;
player.resume()?;
player.update_metadata(); player.update_metadata();
tracing::info!("Restarting playback: {:?}", path); tracing::info!("Restarting playback: {:?}", path);
} }
} }
} }
} }
}
Ok(()) Ok(())
} }
fn action_stop(state: &mut AppState, player: &mut player::Player) -> Result<()> { fn action_stop(state: &mut AppState, player: &mut player::Player) -> Result<()> {
player.stop()?; player.stop()?;
state.player_state = PlayerState::Stopped;
state.current_position = 0.0; state.current_position = 0.0;
state.current_duration = 0.0; state.current_duration = 0.0;
tracing::info!("Stopped"); tracing::info!("Stopped");
Ok(()) Ok(())
} }
async fn run_app<B: ratatui::backend::Backend>( 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);
state.remove_selected_playlist_item();
if state.playlist.is_empty() {
state.current_file = None;
player.stop()?;
} else if was_playing_removed && was_playing && state.playlist_index < state.playlist.len() {
// Validate index before accessing playlist
state.current_file = Some(state.playlist[state.playlist_index].clone());
if let Some(ref path) = state.current_file {
player.play(path)?;
// Explicitly resume playback in case MPV was paused
player.resume()?;
player.update_metadata();
}
}
Ok(())
}
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)
} else {
state.playlist_index.saturating_sub(1)
};
// Validate bounds
if state.playlist.is_empty() || new_index >= state.playlist.len() {
return Ok(());
}
state.playlist_index = new_index;
state.current_file = Some(state.playlist[state.playlist_index].clone());
let track_name = if direction > 0 { "Next" } else { "Previous" };
if let Some(player_state) = player.get_player_state() {
match player_state {
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();
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();
tracing::info!("{} track (paused): {:?}", track_name, path);
}
}
PlayerState::Stopped => {
// Just update current file, stay stopped
tracing::info!("{} track selected (stopped): {:?}", track_name, state.current_file);
}
}
}
// Auto-scroll playlist to show current track (only if user is not browsing playlist)
if !state.focus_playlist {
state.update_playlist_scroll(20);
}
Ok(())
}
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();
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();
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();
tracing::info!("Started playing track: {:?}", path);
}
}
}
}
} 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();
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, skip_position_update: &mut bool) -> Result<()> {
match menu_type {
state::ContextMenuType::FilePanel => {
match selected {
0 => action_play_selection(state, player, skip_position_update)?,
1 => state.add_to_playlist(),
_ => {}
}
}
state::ContextMenuType::Playlist => {
match selected {
0 => action_remove_from_playlist(state, player)?,
1 => {
state.shuffle_playlist();
tracing::info!("Playlist randomised from context menu");
}
_ => {}
}
}
state::ContextMenuType::TitleBar => {
match selected {
0 => action_stop(state, player)?,
1 => {
state.cycle_play_mode();
tracing::info!("Play mode: {:?}", state.play_mode);
}
2 => {
state.show_refresh_confirm = true;
tracing::info!("Refresh requested from context menu");
}
_ => {}
}
}
}
Ok(())
}
fn run_app<B: ratatui::backend::Backend>(
terminal: &mut Terminal<B>, terminal: &mut Terminal<B>,
state: &mut AppState, state: &mut AppState,
player: &mut player::Player, player: &mut player::Player,
api_rx: std::sync::mpsc::Receiver<api::ApiCommand>,
) -> Result<()> { ) -> Result<()> {
let mut metadata_update_counter = 0u32; let mut metadata_update_counter = 0u32;
let mut last_position = 0.0f64; let mut last_position = 0.0f64;
let mut needs_redraw = true; let mut needs_redraw = true;
let mut skip_position_update = false;
let mut title_bar_area = ratatui::layout::Rect::default(); let mut title_bar_area = ratatui::layout::Rect::default();
let mut file_panel_area = ratatui::layout::Rect::default(); let mut file_panel_area = ratatui::layout::Rect::default();
let mut playlist_area = ratatui::layout::Rect::default(); let mut playlist_area = ratatui::layout::Rect::default();
let mut previous_player_state: Option<PlayerState> = None;
loop { loop {
let mut state_changed = false; let mut state_changed = false;
// 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 => {
if let Some(player_state) = player.get_player_state() {
match player_state {
PlayerState::Stopped => {
// Play current file or first in playlist
if state.current_file.is_none() && !state.playlist.is_empty() {
state.current_file = Some(state.playlist[0].clone());
}
if let Some(ref file) = state.current_file {
player.play(file)?;
}
}
PlayerState::Playing => player.pause()?,
PlayerState::Paused => player.resume()?,
}
state_changed = true;
}
}
api::ApiCommand::Stop => {
player.stop()?;
state.current_file = None;
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 => {
state.volume = (state.volume + 5).min(100);
player.set_volume(state.volume)?;
state_changed = true;
}
api::ApiCommand::VolumeDown => {
state.volume = (state.volume - 5).max(0);
player.set_volume(state.volume)?;
state_changed = true;
}
api::ApiCommand::VolumeSet { volume } => {
state.volume = volume.clamp(0, 100);
player.set_volume(state.volume)?;
state_changed = true;
}
api::ApiCommand::SeekForward { seconds } => {
player.seek(seconds)?;
state_changed = true;
}
api::ApiCommand::SeekBackward { seconds } => {
player.seek(-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;
}
}
}
// Check if mpv process died (e.g., user closed video window) // Check if mpv process died (e.g., user closed video window)
if !player.is_process_alive() && state.player_state != PlayerState::Stopped { if !player.is_process_alive() {
state.player_state = PlayerState::Stopped; if let Some(player_state) = player.get_player_state() {
if player_state != PlayerState::Stopped {
state.current_position = 0.0; state.current_position = 0.0;
state.current_duration = 0.0; state.current_duration = 0.0;
state_changed = true; state_changed = true;
} }
}
}
// Only update properties when playing or paused (not when stopped) // Always update properties to keep state synchronized with MPV
if state.player_state != PlayerState::Stopped {
player.update_properties(); player.update_properties();
// Update metadata only every 20 iterations (~2 seconds) to reduce IPC calls // Only proceed if we can successfully query player state
metadata_update_counter += 1; let Some(player_state) = player.get_player_state() else {
if metadata_update_counter >= 20 { // Can't get state from MPV, skip this iteration
player.update_metadata(); if event::poll(std::time::Duration::from_millis(100))? {
metadata_update_counter = 0; match event::read()? {
state_changed = true; Event::Key(key) => {
if key.kind == KeyEventKind::Press {
handle_key_event(terminal, state, player, key, &mut skip_position_update)?;
needs_redraw = 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);
// Only mark as changed if position moved by at least 0.5 seconds
if (new_position - last_position).abs() >= 0.5 {
state.current_position = new_position;
last_position = new_position;
state_changed = true;
} }
Event::Mouse(mouse) => {
if state.current_duration != new_duration { handle_mouse_event(state, mouse, title_bar_area, file_panel_area, playlist_area, player, &mut skip_position_update)?;
state.current_duration = new_duration; needs_redraw = true;
state_changed = true;
} }
_ => {}
}
}
continue;
};
// Check if track ended and play next (but only if track was actually loaded AND played) // Check if track ended and play next
// Require position > 0.5 to ensure track actually started playing (not just loaded) // When MPV finishes playing a file, it goes to idle (Stopped state)
if player.is_idle() && state.player_state == PlayerState::Playing && state.current_duration > 0.0 && state.current_position > 0.5 { // Detect Playing → Stopped transition = track ended, play next
state.play_next(); if previous_player_state == Some(PlayerState::Playing)
// play_next() handles the play mode and may stop if in Normal mode at end && player_state == PlayerState::Stopped
if state.player_state == PlayerState::Playing { {
let should_continue = state.play_next();
// play_next() returns true if should continue playing, false if should stop
if should_continue {
if let Some(ref path) = state.current_file { if let Some(ref path) = state.current_file {
// Reset position/duration before playing new track // Reset position/duration before playing new track
state.current_position = 0.0; state.current_position = 0.0;
state.current_duration = 0.0; state.current_duration = 0.0;
last_position = 0.0; last_position = 0.0;
skip_position_update = true; // Skip position update this iteration
player.play(path)?; player.play(path)?;
player.resume()?;
} }
// Update metadata immediately when track changes // Update metadata immediately when track changes
player.update_metadata(); player.update_metadata();
@@ -217,11 +580,50 @@ async fn run_app<B: ratatui::backend::Backend>(
let playlist_visible_height = playlist_area.height.saturating_sub(2) as usize; let playlist_visible_height = playlist_area.height.saturating_sub(2) as usize;
state.update_playlist_scroll(playlist_visible_height); state.update_playlist_scroll(playlist_visible_height);
} }
} else {
// Reached end of playlist in Normal mode - stop playback
player.stop()?;
} }
state_changed = true; state_changed = true;
} }
// Only update metadata and track playback 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
// 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;
}
if state.current_duration != new_duration {
state.current_duration = new_duration;
state_changed = true;
}
}
}
// Save current state for next iteration
previous_player_state = Some(player_state);
// Only redraw if something changed or forced // Only redraw if something changed or forced
if needs_redraw || state_changed { if needs_redraw || state_changed {
terminal.draw(|f| { terminal.draw(|f| {
@@ -234,22 +636,22 @@ async fn run_app<B: ratatui::backend::Backend>(
} }
// Poll for events - use longer timeout when stopped to reduce CPU // Poll for events - use longer timeout when stopped to reduce CPU
let poll_duration = if state.player_state == PlayerState::Stopped { let poll_duration = if player_state == PlayerState::Stopped {
std::time::Duration::from_millis(200) // 5 FPS when stopped std::time::Duration::from_millis(POLL_DURATION_STOPPED_MS)
} else { } else {
std::time::Duration::from_millis(100) // 10 FPS when playing/paused std::time::Duration::from_millis(POLL_DURATION_ACTIVE_MS)
}; };
if event::poll(poll_duration)? { if event::poll(poll_duration)? {
match event::read()? { match event::read()? {
Event::Key(key) => { Event::Key(key) => {
if key.kind == KeyEventKind::Press { if key.kind == KeyEventKind::Press {
handle_key_event(terminal, state, player, key).await?; handle_key_event(terminal, state, player, key, &mut skip_position_update)?;
needs_redraw = true; // Force redraw after key event needs_redraw = true; // Force redraw after key event
} }
} }
Event::Mouse(mouse) => { 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 needs_redraw = true; // Force redraw after mouse event
} }
_ => {} _ => {}
@@ -264,7 +666,7 @@ async fn run_app<B: ratatui::backend::Backend>(
Ok(()) Ok(())
} }
async 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 // Handle confirmation popup
if state.show_refresh_confirm { if state.show_refresh_confirm {
match key.code { match key.code {
@@ -348,7 +750,7 @@ async fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<
let max_items = match menu.menu_type { let max_items = match menu.menu_type {
ContextMenuType::FilePanel => 2, ContextMenuType::FilePanel => 2,
ContextMenuType::Playlist => 2, ContextMenuType::Playlist => 2,
ContextMenuType::TitleBar => 4, ContextMenuType::TitleBar => 3,
}; };
if menu.selected_index < max_items - 1 { if menu.selected_index < max_items - 1 {
menu.selected_index += 1; menu.selected_index += 1;
@@ -359,64 +761,7 @@ async fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<
let menu_type = menu.menu_type; let menu_type = menu.menu_type;
let selected = menu.selected_index; let selected = menu.selected_index;
state.context_menu = None; state.context_menu = None;
handle_context_menu_action(menu_type, selected, state, player, skip_position_update)?;
match menu_type {
ContextMenuType::FilePanel => {
match selected {
0 => action_play_selection(state, player)?,
1 => state.add_to_playlist(),
_ => {}
}
}
ContextMenuType::Playlist => {
match selected {
0 => {
// Remove
let was_playing_removed = state.playlist_index == state.selected_playlist_index;
state.remove_selected_playlist_item();
// Handle edge cases after removal
if state.playlist.is_empty() {
state.player_state = PlayerState::Stopped;
state.current_file = None;
player.stop()?;
} else if was_playing_removed && state.player_state == PlayerState::Playing {
// Removed currently playing track, start new one at same index
state.current_file = Some(state.playlist[state.playlist_index].clone());
if let Some(ref path) = state.current_file {
player.play(path)?;
player.update_metadata();
}
}
}
1 => {
// Randomise
state.shuffle_playlist();
tracing::info!("Playlist randomised from context menu");
}
_ => {}
}
}
ContextMenuType::TitleBar => {
match selected {
0 => {
// Stop
action_stop(state, player)?;
}
1 => {
// Toggle Loop
state.cycle_play_mode();
tracing::info!("Play mode: {:?}", state.play_mode);
}
2 => {
// Refresh
state.show_refresh_confirm = true;
tracing::info!("Refresh requested from context menu");
}
_ => {}
}
}
}
} }
KeyCode::Esc => { KeyCode::Esc => {
state.context_menu = None; state.context_menu = None;
@@ -467,79 +812,11 @@ async fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<
} }
(KeyCode::Char('J'), KeyModifiers::SHIFT) => { (KeyCode::Char('J'), KeyModifiers::SHIFT) => {
// Next track // Next track
if !state.playlist.is_empty() && state.playlist_index + 1 < state.playlist.len() { action_navigate_track(state, player, 1, skip_position_update)?;
state.playlist_index += 1;
// Validate index before accessing playlist
if state.playlist_index < state.playlist.len() {
state.current_file = Some(state.playlist[state.playlist_index].clone());
match state.player_state {
PlayerState::Playing => {
// Keep playing
if let Some(ref path) = state.current_file {
player.play(path)?;
player.update_metadata(); // Update metadata immediately
tracing::info!("Next track: {:?}", path);
}
}
PlayerState::Paused => {
// Load but stay paused
if let Some(ref path) = state.current_file {
player.play(path)?;
player.update_metadata(); // Update metadata immediately
player.pause()?;
tracing::info!("Next track (paused): {:?}", path);
}
}
PlayerState::Stopped => {
// Just update current file, stay stopped
tracing::info!("Next track selected (stopped): {:?}", state.current_file);
}
}
// Auto-scroll playlist to show current track (only if user is not browsing playlist)
if !state.focus_playlist {
state.update_playlist_scroll(20);
}
}
}
} }
(KeyCode::Char('K'), KeyModifiers::SHIFT) => { (KeyCode::Char('K'), KeyModifiers::SHIFT) => {
// Previous track // Previous track
if !state.playlist.is_empty() && state.playlist_index > 0 { action_navigate_track(state, player, -1, skip_position_update)?;
state.playlist_index -= 1;
// Validate index before accessing playlist
if state.playlist_index < state.playlist.len() {
state.current_file = Some(state.playlist[state.playlist_index].clone());
match state.player_state {
PlayerState::Playing => {
// Keep playing
if let Some(ref path) = state.current_file {
player.play(path)?;
player.update_metadata(); // Update metadata immediately
tracing::info!("Previous track: {:?}", path);
}
}
PlayerState::Paused => {
// Load but stay paused
if let Some(ref path) = state.current_file {
player.play(path)?;
player.update_metadata(); // Update metadata immediately
player.pause()?;
tracing::info!("Previous track (paused): {:?}", path);
}
}
PlayerState::Stopped => {
// Just update current file, stay stopped
tracing::info!("Previous track selected (stopped): {:?}", state.current_file);
}
}
// Auto-scroll playlist to show current track (only if user is not browsing playlist)
if !state.focus_playlist {
state.update_playlist_scroll(20);
}
}
}
} }
(KeyCode::Char('d'), KeyModifiers::CONTROL) => { (KeyCode::Char('d'), KeyModifiers::CONTROL) => {
if state.focus_playlist { if state.focus_playlist {
@@ -573,12 +850,12 @@ async fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<
state.move_selection_down(); state.move_selection_down();
} }
} }
(KeyCode::Char('h'), _) => { (KeyCode::Char('h'), _) | (KeyCode::Left, _) => {
if !state.focus_playlist { if !state.focus_playlist {
state.collapse_selected(); state.collapse_selected();
} }
} }
(KeyCode::Char('l'), _) => { (KeyCode::Char('l'), _) | (KeyCode::Right, _) => {
if !state.focus_playlist { if !state.focus_playlist {
state.expand_selected(); state.expand_selected();
} }
@@ -602,42 +879,16 @@ async fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<
} }
(KeyCode::Char('d'), _) => { (KeyCode::Char('d'), _) => {
if state.focus_playlist { if state.focus_playlist {
// Remove selected track from playlist action_remove_from_playlist(state, player)?;
state.remove_selected_playlist_item();
// If removed currently playing track, handle it
if state.playlist.is_empty() {
state.player_state = PlayerState::Stopped;
state.current_file = None;
player.stop()?;
} else if state.playlist_index == state.selected_playlist_index {
// Removed currently playing track, play next one
if state.playlist_index < state.playlist.len() {
state.current_file = Some(state.playlist[state.playlist_index].clone());
if state.player_state == PlayerState::Playing {
if let Some(ref path) = state.current_file {
player.play(path)?;
player.update_metadata();
}
}
}
}
} }
} }
(KeyCode::Enter, _) => { (KeyCode::Enter, _) => {
if state.focus_playlist { if state.focus_playlist {
// Play selected track from playlist
if state.selected_playlist_index < state.playlist.len() { if state.selected_playlist_index < state.playlist.len() {
state.playlist_index = state.selected_playlist_index; action_play_from_playlist(state, player, false, skip_position_update)?;
state.current_file = Some(state.playlist[state.playlist_index].clone());
state.player_state = PlayerState::Playing;
if let Some(ref path) = state.current_file {
player.play(path)?;
player.update_metadata();
tracing::info!("Playing from playlist: {:?}", path);
}
} }
} else { } else {
action_play_selection(state, player)?; action_play_selection(state, player, skip_position_update)?;
} }
} }
(KeyCode::Char('s'), _) => { (KeyCode::Char('s'), _) => {
@@ -655,13 +906,13 @@ async fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<
action_toggle_play_pause(state, player)?; action_toggle_play_pause(state, player)?;
} }
(KeyCode::Char('H'), KeyModifiers::SHIFT) => { (KeyCode::Char('H'), KeyModifiers::SHIFT) => {
if state.player_state != PlayerState::Stopped { if player.get_player_state() != Some(PlayerState::Stopped) {
player.seek(-10.0)?; player.seek(-10.0)?;
tracing::info!("Seek backward 10s"); tracing::info!("Seek backward 10s");
} }
} }
(KeyCode::Char('L'), KeyModifiers::SHIFT) => { (KeyCode::Char('L'), KeyModifiers::SHIFT) => {
if state.player_state != PlayerState::Stopped { if player.get_player_state() != Some(PlayerState::Stopped) {
player.seek(10.0)?; player.seek(10.0)?;
tracing::info!("Seek forward 10s"); tracing::info!("Seek forward 10s");
} }
@@ -687,7 +938,7 @@ async fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<
Ok(()) 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 crossterm::event::MouseButton;
use crate::state::ContextMenuType; use crate::state::ContextMenuType;
@@ -700,7 +951,7 @@ fn handle_mouse_event(state: &mut AppState, mouse: MouseEvent, title_bar_area: r
let items = match menu.menu_type { let items = match menu.menu_type {
ContextMenuType::FilePanel => 2, ContextMenuType::FilePanel => 2,
ContextMenuType::Playlist => 2, ContextMenuType::Playlist => 2,
ContextMenuType::TitleBar => 4, ContextMenuType::TitleBar => 3,
}; };
let popup_width = 13; let popup_width = 13;
let popup_height = items as u16 + 2; // +2 for borders let popup_height = items as u16 + 2; // +2 for borders
@@ -756,63 +1007,7 @@ fn handle_mouse_event(state: &mut AppState, mouse: MouseEvent, title_bar_area: r
let menu_type = menu.menu_type; let menu_type = menu.menu_type;
let selected = relative_y; let selected = relative_y;
state.context_menu = None; state.context_menu = None;
handle_context_menu_action(menu_type, selected, state, player, skip_position_update)?;
match menu_type {
ContextMenuType::FilePanel => {
match selected {
0 => action_play_selection(state, player)?,
1 => state.add_to_playlist(),
_ => {}
}
}
ContextMenuType::Playlist => {
match selected {
0 => {
// Remove
let was_playing_removed = state.playlist_index == state.selected_playlist_index;
state.remove_selected_playlist_item();
// Handle edge cases after removal
if state.playlist.is_empty() {
state.player_state = PlayerState::Stopped;
state.current_file = None;
player.stop()?;
} else if was_playing_removed && state.player_state == PlayerState::Playing {
state.current_file = Some(state.playlist[state.playlist_index].clone());
if let Some(ref path) = state.current_file {
player.play(path)?;
player.update_metadata();
}
}
}
1 => {
// Randomise
state.shuffle_playlist();
tracing::info!("Playlist randomised from context menu (mouse)");
}
_ => {}
}
}
ContextMenuType::TitleBar => {
match selected {
0 => {
// Stop
action_stop(state, player)?;
}
1 => {
// Toggle Loop
state.cycle_play_mode();
tracing::info!("Play mode: {:?} (mouse)", state.play_mode);
}
2 => {
// Refresh
state.show_refresh_confirm = true;
tracing::info!("Refresh requested from context menu (mouse)");
}
_ => {}
}
}
}
} }
return Ok(()); return Ok(());
} else { } else {
@@ -925,7 +1120,7 @@ fn handle_mouse_event(state: &mut AppState, mouse: MouseEvent, title_bar_area: r
let now = std::time::Instant::now(); let now = std::time::Instant::now();
let is_double_click = if let (Some(last_time), Some(last_idx), false) = let is_double_click = if let (Some(last_time), Some(last_idx), false) =
(state.last_click_time, state.last_click_index, state.last_click_is_playlist) { (state.last_click_time, state.last_click_index, state.last_click_is_playlist) {
last_idx == clicked_index && now.duration_since(last_time).as_millis() < 500 last_idx == clicked_index && now.duration_since(last_time).as_millis() < DOUBLE_CLICK_MS
} else { } else {
false false
}; };
@@ -933,10 +1128,10 @@ fn handle_mouse_event(state: &mut AppState, mouse: MouseEvent, title_bar_area: r
if is_double_click { if is_double_click {
// Double click = toggle folder or play file // Double click = toggle folder or play file
if let Some(item) = state.get_selected_item() { if let Some(item) = state.get_selected_item() {
if item.node.is_dir { if item.is_dir {
action_toggle_folder(state); action_toggle_folder(state);
} else { } else {
action_play_selection(state, player)?; action_play_selection(state, player, skip_position_update)?;
} }
} }
// Reset click tracking after action // Reset click tracking after action
@@ -986,44 +1181,15 @@ fn handle_mouse_event(state: &mut AppState, mouse: MouseEvent, title_bar_area: r
let now = std::time::Instant::now(); let now = std::time::Instant::now();
let is_double_click = if let (Some(last_time), Some(last_idx), true) = let is_double_click = if let (Some(last_time), Some(last_idx), true) =
(state.last_click_time, state.last_click_index, state.last_click_is_playlist) { (state.last_click_time, state.last_click_index, state.last_click_is_playlist) {
last_idx == actual_track && now.duration_since(last_time).as_millis() < 500 last_idx == actual_track && now.duration_since(last_time).as_millis() < DOUBLE_CLICK_MS
} else { } else {
false false
}; };
if is_double_click { if is_double_click {
// Double click = play the track // Double click = play the track (preserve pause state)
state.playlist_index = actual_track; state.selected_playlist_index = actual_track;
state.current_file = Some(state.playlist[state.playlist_index].clone()); action_play_from_playlist(state, player, true, skip_position_update)?;
match state.player_state {
PlayerState::Playing => {
// Keep playing
if let Some(ref path) = state.current_file {
player.play(path)?;
player.update_metadata();
tracing::info!("Jumped to track: {:?}", path);
}
}
PlayerState::Paused => {
// Load but stay paused
if let Some(ref path) = state.current_file {
player.play(path)?;
player.update_metadata();
player.pause()?;
tracing::info!("Jumped to track (paused): {:?}", path);
}
}
PlayerState::Stopped => {
// Start playing from clicked track
state.player_state = PlayerState::Playing;
if let Some(ref path) = state.current_file {
player.play(path)?;
player.update_metadata();
tracing::info!("Started playing track: {:?}", path);
}
}
}
// Reset click tracking after action // Reset click tracking after action
state.last_click_time = None; state.last_click_time = None;
state.last_click_index = None; state.last_click_index = None;

View File

@@ -13,8 +13,6 @@ pub struct Player {
socket: Option<UnixStream>, socket: Option<UnixStream>,
position: f64, position: f64,
duration: f64, duration: f64,
is_paused: bool,
is_idle: bool,
pub media_title: Option<String>, pub media_title: Option<String>,
pub artist: Option<String>, pub artist: Option<String>,
pub album: Option<String>, pub album: Option<String>,
@@ -37,6 +35,7 @@ impl Player {
.arg("--no-terminal") .arg("--no-terminal")
.arg("--profile=fast") .arg("--profile=fast")
.arg("--audio-display=no") // Don't show cover art for audio files .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())) .arg(format!("--input-ipc-server={}", socket_path.display()))
.stdin(Stdio::null()) .stdin(Stdio::null())
.stdout(Stdio::null()) .stdout(Stdio::null())
@@ -46,17 +45,12 @@ impl Player {
tracing::info!("MPV process started with IPC at {:?}", socket_path); tracing::info!("MPV process started with IPC at {:?}", socket_path);
// Wait for socket to be created
std::thread::sleep(Duration::from_millis(500));
Ok(Self { Ok(Self {
process, process,
socket_path, socket_path,
socket: None, socket: None,
position: 0.0, position: 0.0,
duration: 0.0, duration: 0.0,
is_paused: false,
is_idle: true,
media_title: None, media_title: None,
artist: None, artist: None,
album: None, album: None,
@@ -69,64 +63,32 @@ impl Player {
fn connect(&mut self) -> Result<()> { fn connect(&mut self) -> Result<()> {
if self.socket.is_none() { if self.socket.is_none() {
// Try to connect, if it fails, respawn mpv // CRITICAL: Only try to connect if socket file exists
match UnixStream::connect(&self.socket_path) { // If socket doesn't exist, MPV hasn't created it yet - fail fast
Ok(stream) => { if !self.socket_path.exists() {
stream.set_nonblocking(true).ok(); return Err(anyhow::anyhow!("Socket file doesn't exist yet"));
}
// Try to connect with a timeout using non-blocking mode
// IMPORTANT: UnixStream::connect() blocks in the kernel if socket exists
// but server isn't listening yet. We check existence first but still
// need to handle connect blocking if MPV just created socket but isn't ready.
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));
}
};
// Set non-blocking and timeout to prevent hangs on reads/writes
stream.set_nonblocking(true)?;
stream.set_read_timeout(Some(Duration::from_millis(100)))?;
stream.set_write_timeout(Some(Duration::from_millis(100)))?;
self.socket = Some(stream); self.socket = Some(stream);
tracing::debug!("Connected to MPV socket successfully");
} }
Err(_) => {
// MPV probably died, respawn it
self.respawn()?;
let stream = UnixStream::connect(&self.socket_path)
.context("Failed to connect to MPV IPC socket after respawn")?;
stream.set_nonblocking(true).ok();
self.socket = Some(stream);
}
}
}
Ok(())
}
fn respawn(&mut self) -> Result<()> {
// Kill old process if still running
self.process.kill().ok();
self.process.wait().ok();
// Clean up old socket
std::fs::remove_file(&self.socket_path).ok();
// Spawn new MPV process
let process = Command::new("mpv")
.arg("--idle")
.arg("--no-terminal")
.arg("--profile=fast")
.arg("--audio-display=no")
.arg(format!("--input-ipc-server={}", self.socket_path.display()))
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.context("Failed to respawn MPV process")?;
self.process = process;
self.socket = None;
self.is_idle = true;
self.position = 0.0;
self.duration = 0.0;
self.is_paused = false;
self.media_title = None;
self.artist = None;
self.album = None;
self.audio_codec = None;
self.audio_bitrate = None;
self.sample_rate = None;
self.cache_duration = None;
// Wait for socket to be created and mpv to be ready
std::thread::sleep(Duration::from_millis(800));
tracing::info!("MPV process respawned");
Ok(()) Ok(())
} }
@@ -146,7 +108,6 @@ impl Player {
if let Err(e) = socket.write_all(msg.as_bytes()) { if let Err(e) = socket.write_all(msg.as_bytes()) {
if e.kind() == std::io::ErrorKind::BrokenPipe { if e.kind() == std::io::ErrorKind::BrokenPipe {
self.socket = None; self.socket = None;
self.is_idle = true;
// Clean up dead process // Clean up dead process
self.process.kill().ok(); self.process.kill().ok();
return Ok(()); return Ok(());
@@ -159,7 +120,11 @@ impl Player {
} }
fn get_property(&mut self, property: &str) -> Option<Value> { fn get_property(&mut self, property: &str) -> Option<Value> {
self.connect().ok()?; // 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;
}
let cmd = json!({ let cmd = json!({
"command": ["get_property", property], "command": ["get_property", property],
@@ -168,19 +133,66 @@ impl Player {
if let Some(ref mut socket) = self.socket { if let Some(ref mut socket) = self.socket {
let msg = format!("{}\n", cmd); let msg = format!("{}\n", cmd);
socket.write_all(msg.as_bytes()).ok()?;
// Try to read response (non-blocking) // 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;
}
// Try to read response with timeout
socket.set_nonblocking(false).ok(); socket.set_nonblocking(false).ok();
socket.set_read_timeout(Some(Duration::from_millis(100))).ok(); socket.set_read_timeout(Some(Duration::from_millis(100))).ok();
let mut reader = BufReader::new(socket.try_clone().ok()?); let cloned_socket = match socket.try_clone() {
Ok(s) => s,
Err(e) => {
tracing::warn!("Failed to clone socket for '{}': {}", property, e);
socket.set_nonblocking(true).ok();
return None;
}
};
// 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(); let mut response = String::new();
reader.read_line(&mut response).ok()?; 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;
}
socket.set_nonblocking(true).ok(); socket.set_nonblocking(true).ok();
let parsed: Value = serde_json::from_str(&response).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(); return parsed.get("data").cloned();
} }
@@ -189,28 +201,37 @@ impl Player {
pub fn play(&mut self, path: &Path) -> Result<()> { pub fn play(&mut self, path: &Path) -> Result<()> {
let path_str = path.to_string_lossy(); 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")])?; self.send_command("loadfile", &[json!(path_str), json!("replace")])?;
self.is_paused = false;
self.is_idle = false;
tracing::info!("Playing: {}", path_str); tracing::info!("Playing: {}", path_str);
Ok(()) Ok(())
} }
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);
Ok(())
}
pub fn pause(&mut self) -> Result<()> { pub fn pause(&mut self) -> Result<()> {
self.send_command("set_property", &[json!("pause"), json!(true)])?; self.send_command("set_property", &[json!("pause"), json!(true)])?;
self.is_paused = true;
Ok(()) Ok(())
} }
pub fn resume(&mut self) -> Result<()> { pub fn resume(&mut self) -> Result<()> {
self.send_command("set_property", &[json!("pause"), json!(false)])?; self.send_command("set_property", &[json!("pause"), json!(false)])?;
self.is_paused = false;
Ok(()) Ok(())
} }
pub fn stop(&mut self) -> Result<()> { pub fn stop(&mut self) -> Result<()> {
self.send_command("stop", &[])?; self.send_command("stop", &[])?;
self.is_idle = true;
self.position = 0.0; self.position = 0.0;
self.duration = 0.0; self.duration = 0.0;
Ok(()) Ok(())
@@ -235,27 +256,6 @@ impl Player {
self.duration = dur; self.duration = dur;
} }
} }
// Update pause state
if let Some(val) = self.get_property("pause") {
if let Some(paused) = val.as_bool() {
self.is_paused = paused;
}
}
// Update idle state
if let Some(val) = self.get_property("idle-active") {
if let Some(idle) = val.as_bool() {
self.is_idle = idle;
}
}
// 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;
}
} }
pub fn update_metadata(&mut self) { pub fn update_metadata(&mut self) {
@@ -313,6 +313,13 @@ impl Player {
if let Some(val) = self.get_property("audio-params/samplerate") { if let Some(val) = self.get_property("audio-params/samplerate") {
self.sample_rate = val.as_i64(); 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;
}
} }
pub fn get_position(&self) -> Option<f64> { pub fn get_position(&self) -> Option<f64> {
@@ -323,8 +330,28 @@ impl Player {
Some(self.duration) Some(self.duration)
} }
pub fn is_idle(&self) -> bool { pub fn is_idle(&mut self) -> Option<bool> {
self.is_idle 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()?;
Some(if is_idle {
PlayerState::Stopped
} else if is_paused {
PlayerState::Paused
} else {
PlayerState::Playing
})
} }
pub fn is_process_alive(&mut self) -> bool { pub fn is_process_alive(&mut self) -> bool {
@@ -333,14 +360,12 @@ impl Player {
Ok(Some(_)) => { Ok(Some(_)) => {
// Process has exited - clean up socket // Process has exited - clean up socket
self.socket = None; self.socket = None;
self.is_idle = true;
false false
} }
Ok(None) => true, // Process is still running Ok(None) => true, // Process is still running
Err(_) => { Err(_) => {
// Error checking, assume dead and clean up // Error checking, assume dead and clean up
self.socket = None; self.socket = None;
self.is_idle = true;
false false
} }
} }

View File

@@ -78,9 +78,6 @@ pub fn scan_directory(root_path: &Path) -> Result<FileTreeNode> {
let metadata = FileMetadata { let metadata = FileMetadata {
path: path.to_path_buf(), path: path.to_path_buf(),
size, size,
duration: None, // Will be populated by MPV later
codec: None,
hash: None,
is_video: is_video_file(path), is_video: is_video_file(path),
is_audio: is_audio_file(path), is_audio: is_audio_file(path),
}; };

View File

@@ -1,9 +1,29 @@
use crate::cache::{Cache, FileTreeNode}; use crate::cache::{Cache, FileTreeNode};
use crate::config::Config; use crate::config::Config;
use std::collections::HashSet; use std::collections::HashSet;
use std::path::PathBuf; use std::path::{Path, PathBuf};
use std::time::Instant; use std::time::Instant;
// Fuzzy match scoring bonuses
const FUZZY_CONSECUTIVE_BONUS: i32 = 10;
const FUZZY_WORD_START_BONUS: i32 = 15;
const FUZZY_FOLDER_BONUS: i32 = 50;
// Helper to calculate effective height accounting for "X more below" indicator
fn calculate_effective_height(scroll_offset: usize, visible_height: usize, total_items: usize) -> usize {
let visible_end = scroll_offset + visible_height;
let items_below = if visible_end < total_items {
total_items - visible_end
} else {
0
};
if items_below > 0 {
visible_height.saturating_sub(1)
} else {
visible_height
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlayerState { pub enum PlayerState {
Stopped, Stopped,
@@ -39,7 +59,6 @@ pub struct AppState {
pub scroll_offset: usize, pub scroll_offset: usize,
pub file_panel_visible_height: usize, pub file_panel_visible_height: usize,
pub playlist_visible_height: usize, pub playlist_visible_height: usize,
pub player_state: PlayerState,
pub current_file: Option<PathBuf>, pub current_file: Option<PathBuf>,
pub current_position: f64, pub current_position: f64,
pub current_duration: f64, pub current_duration: f64,
@@ -77,7 +96,9 @@ pub struct AppState {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct FlattenedItem { pub struct FlattenedItem {
pub node: FileTreeNode, pub path: PathBuf,
pub name: String,
pub is_dir: bool,
pub depth: usize, pub depth: usize,
} }
@@ -95,7 +116,6 @@ impl AppState {
scroll_offset: 0, scroll_offset: 0,
file_panel_visible_height: 20, file_panel_visible_height: 20,
playlist_visible_height: 20, playlist_visible_height: 20,
player_state: PlayerState::Stopped,
current_file: None, current_file: None,
current_position: 0.0, current_position: 0.0,
current_duration: 0.0, current_duration: 0.0,
@@ -150,18 +170,11 @@ impl AppState {
if self.selected_index < self.flattened_items.len().saturating_sub(1) { if self.selected_index < self.flattened_items.len().saturating_sub(1) {
self.selected_index += 1; self.selected_index += 1;
// Account for "... X more below" indicator which takes one line let effective_height = calculate_effective_height(
let visible_end = self.scroll_offset + self.file_panel_visible_height; self.scroll_offset,
let items_below = if visible_end < self.flattened_items.len() { self.file_panel_visible_height,
self.flattened_items.len() - visible_end self.flattened_items.len()
} else { );
0
};
let effective_height = if items_below > 0 {
self.file_panel_visible_height.saturating_sub(1)
} else {
self.file_panel_visible_height
};
// Scroll down when selection reaches bottom // Scroll down when selection reaches bottom
if self.selected_index >= self.scroll_offset + effective_height { if self.selected_index >= self.scroll_offset + effective_height {
@@ -229,18 +242,11 @@ impl AppState {
if self.selected_playlist_index < self.playlist.len().saturating_sub(1) { if self.selected_playlist_index < self.playlist.len().saturating_sub(1) {
self.selected_playlist_index += 1; self.selected_playlist_index += 1;
// Account for "... X more below" indicator which takes one line let effective_height = calculate_effective_height(
let visible_end = self.playlist_scroll_offset + visible_height; self.playlist_scroll_offset,
let items_below = if visible_end < self.playlist.len() { visible_height,
self.playlist.len() - visible_end self.playlist.len()
} else { );
0
};
let effective_height = if items_below > 0 {
visible_height.saturating_sub(1)
} else {
visible_height
};
// Scroll down when selection reaches bottom // Scroll down when selection reaches bottom
if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height { if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height {
@@ -276,18 +282,11 @@ impl AppState {
let new_index = (self.selected_playlist_index + half_page).min(self.playlist.len().saturating_sub(1)); let new_index = (self.selected_playlist_index + half_page).min(self.playlist.len().saturating_sub(1));
self.selected_playlist_index = new_index; self.selected_playlist_index = new_index;
// Account for "... X more below" indicator which takes one line let effective_height = calculate_effective_height(
let visible_end = self.playlist_scroll_offset + self.playlist_visible_height; self.playlist_scroll_offset,
let items_below = if visible_end < self.playlist.len() { self.playlist_visible_height,
self.playlist.len() - visible_end self.playlist.len()
} else { );
0
};
let effective_height = if items_below > 0 {
self.playlist_visible_height.saturating_sub(1)
} else {
self.playlist_visible_height
};
// Adjust scroll if needed // Adjust scroll if needed
if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height { if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height {
@@ -312,18 +311,11 @@ impl AppState {
let new_index = (self.selected_index + half_page).min(self.flattened_items.len().saturating_sub(1)); let new_index = (self.selected_index + half_page).min(self.flattened_items.len().saturating_sub(1));
self.selected_index = new_index; self.selected_index = new_index;
// Account for "... X more below" indicator which takes one line let effective_height = calculate_effective_height(
let visible_end = self.scroll_offset + self.file_panel_visible_height; self.scroll_offset,
let items_below = if visible_end < self.flattened_items.len() { self.file_panel_visible_height,
self.flattened_items.len() - visible_end self.flattened_items.len()
} else { );
0
};
let effective_height = if items_below > 0 {
self.file_panel_visible_height.saturating_sub(1)
} else {
self.file_panel_visible_height
};
// Adjust scroll if needed // Adjust scroll if needed
if self.selected_index >= self.scroll_offset + effective_height { if self.selected_index >= self.scroll_offset + effective_height {
@@ -346,11 +338,29 @@ impl AppState {
self.flattened_items.get(self.selected_index) self.flattened_items.get(self.selected_index)
} }
// Helper to find a node in the tree by path
fn find_node_by_path<'a>(&'a self, path: &Path) -> Option<&'a FileTreeNode> {
fn search_nodes<'a>(nodes: &'a [FileTreeNode], path: &Path) -> Option<&'a FileTreeNode> {
for node in nodes {
if node.path == path {
return Some(node);
}
if node.is_dir {
if let Some(found) = search_nodes(&node.children, path) {
return Some(found);
}
}
}
None
}
search_nodes(&self.cache.file_tree, path)
}
pub fn collapse_selected(&mut self) { pub fn collapse_selected(&mut self) {
let item = self.get_selected_item().cloned(); let item = self.get_selected_item().cloned();
if let Some(item) = item { if let Some(item) = item {
if item.node.is_dir { if item.is_dir {
let path = item.node.path.clone(); let path = item.path.clone();
let was_expanded = self.expanded_dirs.contains(&path); let was_expanded = self.expanded_dirs.contains(&path);
if was_expanded { if was_expanded {
@@ -358,7 +368,7 @@ impl AppState {
self.expanded_dirs.remove(&path); self.expanded_dirs.remove(&path);
self.rebuild_flattened_items(); self.rebuild_flattened_items();
// Find the collapsed folder and select it // Find the collapsed folder and select it
if let Some(idx) = self.flattened_items.iter().position(|i| i.node.path == path) { if let Some(idx) = self.flattened_items.iter().position(|i| i.path == path) {
self.selected_index = idx; self.selected_index = idx;
} }
} else { } else {
@@ -368,19 +378,19 @@ impl AppState {
self.expanded_dirs.remove(&parent_buf); self.expanded_dirs.remove(&parent_buf);
self.rebuild_flattened_items(); self.rebuild_flattened_items();
// Jump to parent folder // Jump to parent folder
if let Some(parent_idx) = self.flattened_items.iter().position(|i| i.node.path == parent_buf) { if let Some(parent_idx) = self.flattened_items.iter().position(|i| i.path == parent_buf) {
self.selected_index = parent_idx; self.selected_index = parent_idx;
} }
} }
} }
} else { } else {
// Close parent folder when on a file and jump to it // Close parent folder when on a file and jump to it
if let Some(parent) = item.node.path.parent() { if let Some(parent) = item.path.parent() {
let parent_buf = parent.to_path_buf(); let parent_buf = parent.to_path_buf();
self.expanded_dirs.remove(&parent_buf); self.expanded_dirs.remove(&parent_buf);
self.rebuild_flattened_items(); self.rebuild_flattened_items();
// Jump to parent folder // Jump to parent folder
if let Some(parent_idx) = self.flattened_items.iter().position(|i| i.node.path == parent_buf) { if let Some(parent_idx) = self.flattened_items.iter().position(|i| i.path == parent_buf) {
self.selected_index = parent_idx; self.selected_index = parent_idx;
} }
} }
@@ -390,8 +400,8 @@ impl AppState {
pub fn expand_selected(&mut self) { pub fn expand_selected(&mut self) {
if let Some(item) = self.get_selected_item() { if let Some(item) = self.get_selected_item() {
if item.node.is_dir { if item.is_dir {
let path = item.node.path.clone(); let path = item.path.clone();
self.expanded_dirs.insert(path); self.expanded_dirs.insert(path);
self.rebuild_flattened_items(); self.rebuild_flattened_items();
} }
@@ -411,8 +421,8 @@ impl AppState {
self.marked_files.clear(); self.marked_files.clear();
// Mark current file // Mark current file
if let Some(item) = self.get_selected_item() { if let Some(item) = self.get_selected_item() {
if !item.node.is_dir { if !item.is_dir {
self.marked_files.insert(item.node.path.clone()); self.marked_files.insert(item.path.clone());
} }
} }
} }
@@ -428,8 +438,8 @@ impl AppState {
for i in start..=end { for i in start..=end {
if let Some(item) = self.flattened_items.get(i) { if let Some(item) = self.flattened_items.get(i) {
if !item.node.is_dir { if !item.is_dir {
self.marked_files.insert(item.node.path.clone()); self.marked_files.insert(item.path.clone());
} }
} }
} }
@@ -438,7 +448,6 @@ impl AppState {
pub fn clear_playlist(&mut self) { pub fn clear_playlist(&mut self) {
self.playlist.clear(); self.playlist.clear();
self.playlist_index = 0; self.playlist_index = 0;
self.player_state = PlayerState::Stopped;
self.current_file = None; self.current_file = None;
} }
@@ -450,15 +459,19 @@ impl AppState {
files.sort(); files.sort();
self.playlist.extend(files); self.playlist.extend(files);
} else if let Some(item) = self.get_selected_item() { } else if let Some(item) = self.get_selected_item() {
let node = item.node.clone(); let path = item.path.clone();
if node.is_dir { let is_dir = item.is_dir;
if is_dir {
// Look up the full node to get children
if let Some(node) = self.find_node_by_path(&path) {
// Add all files in directory (allow duplicates) // Add all files in directory (allow duplicates)
let mut files = collect_files_from_node(&node); let mut files = collect_files_from_node(node);
files.sort(); files.sort();
self.playlist.extend(files); self.playlist.extend(files);
}
} else { } else {
// Add single file (allow duplicates) // Add single file (allow duplicates)
self.playlist.push(node.path.clone()); self.playlist.push(path);
} }
} }
} }
@@ -474,44 +487,41 @@ impl AppState {
self.selected_playlist_index = 0; self.selected_playlist_index = 0;
if let Some(first) = self.playlist.first() { if let Some(first) = self.playlist.first() {
self.current_file = Some(first.clone()); self.current_file = Some(first.clone());
self.player_state = PlayerState::Playing;
} else { } else {
// Empty playlist // Empty playlist
self.current_file = None; self.current_file = None;
self.player_state = PlayerState::Stopped;
} }
} else if let Some(item) = self.get_selected_item() { } else if let Some(item) = self.get_selected_item() {
let node = item.node.clone(); let path = item.path.clone();
if node.is_dir { let is_dir = item.is_dir;
// Play all files in directory if is_dir {
self.playlist = collect_files_from_node(&node); // Play all files in directory - look up node to get children
if let Some(node) = self.find_node_by_path(&path) {
self.playlist = collect_files_from_node(node);
self.playlist_index = 0; self.playlist_index = 0;
self.playlist_scroll_offset = 0; self.playlist_scroll_offset = 0;
self.selected_playlist_index = 0; self.selected_playlist_index = 0;
if let Some(first) = self.playlist.first() { if let Some(first) = self.playlist.first() {
self.current_file = Some(first.clone()); self.current_file = Some(first.clone());
self.player_state = PlayerState::Playing;
} else { } else {
// Empty directory // Empty directory
self.current_file = None; self.current_file = None;
self.player_state = PlayerState::Stopped; }
} }
} else { } else {
// Play single file // Play single file
let path = node.path.clone();
self.playlist = vec![path.clone()]; self.playlist = vec![path.clone()];
self.playlist_index = 0; self.playlist_index = 0;
self.playlist_scroll_offset = 0; self.playlist_scroll_offset = 0;
self.selected_playlist_index = 0; self.selected_playlist_index = 0;
self.current_file = Some(path); self.current_file = Some(path);
self.player_state = PlayerState::Playing;
} }
} }
} }
pub fn play_next(&mut self) { pub fn play_next(&mut self) -> bool {
if self.playlist.is_empty() { if self.playlist.is_empty() {
return; return false;
} }
match self.play_mode { match self.play_mode {
@@ -521,18 +531,17 @@ impl AppState {
self.playlist_index += 1; self.playlist_index += 1;
if self.playlist_index < self.playlist.len() { if self.playlist_index < self.playlist.len() {
self.current_file = Some(self.playlist[self.playlist_index].clone()); self.current_file = Some(self.playlist[self.playlist_index].clone());
self.player_state = PlayerState::Playing; return true; // Should continue playing
} }
} else {
// Reached end, stop
self.player_state = PlayerState::Stopped;
} }
// Reached end, should stop
false
} }
PlayMode::Loop => { PlayMode::Loop => {
// Loop back to beginning when reaching end // Loop back to beginning when reaching end
self.playlist_index = (self.playlist_index + 1) % self.playlist.len(); self.playlist_index = (self.playlist_index + 1) % self.playlist.len();
self.current_file = Some(self.playlist[self.playlist_index].clone()); self.current_file = Some(self.playlist[self.playlist_index].clone());
self.player_state = PlayerState::Playing; true // Should continue playing
} }
} }
} }
@@ -641,6 +650,7 @@ impl AppState {
if self.search_query.is_empty() { if self.search_query.is_empty() {
self.tab_search_results.clear(); self.tab_search_results.clear();
self.tab_search_index = 0; self.tab_search_index = 0;
// Don't rebuild tree on every keystroke - only when exiting search
return; return;
} }
@@ -668,9 +678,17 @@ impl AppState {
self.tab_search_results = indexed_matches.iter().map(|(path, _, _)| path.clone()).collect(); self.tab_search_results = indexed_matches.iter().map(|(path, _, _)| path.clone()).collect();
self.tab_search_index = 0; self.tab_search_index = 0;
// Only expand and rebuild if this is a new best match
let best_match = self.tab_search_results[0].clone();
// Check if we need to expand folders for this match
let needs_expand = best_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 best match
self.expanded_dirs.clear(); self.expanded_dirs.clear();
let best_match = self.tab_search_results[0].clone();
let mut parent = best_match.parent(); let mut parent = best_match.parent();
while let Some(p) = parent { while let Some(p) = parent {
self.expanded_dirs.insert(p.to_path_buf()); self.expanded_dirs.insert(p.to_path_buf());
@@ -679,24 +697,18 @@ impl AppState {
// Rebuild flattened items // Rebuild flattened items
self.rebuild_flattened_items(); self.rebuild_flattened_items();
}
// Find the best match in the flattened list and jump to it // Find the best match in the flattened list and jump to it
if let Some(idx) = self.flattened_items.iter().position(|item| item.node.path == best_match) { if let Some(idx) = self.flattened_items.iter().position(|item| item.path == best_match) {
self.selected_index = idx; self.selected_index = idx;
// Scroll to show the match // Scroll to show the match
// Account for "... X more below" indicator which takes one line let effective_height = calculate_effective_height(
let visible_end = self.scroll_offset + self.file_panel_visible_height; self.scroll_offset,
let items_below = if visible_end < self.flattened_items.len() { self.file_panel_visible_height,
self.flattened_items.len() - visible_end self.flattened_items.len()
} 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 { if self.selected_index < self.scroll_offset {
self.scroll_offset = self.selected_index; self.scroll_offset = self.selected_index;
@@ -755,7 +767,7 @@ impl AppState {
self.rebuild_flattened_items(); self.rebuild_flattened_items();
// Find first match in flattened list // Find first match in flattened list
if let Some(idx) = self.flattened_items.iter().position(|item| item.node.path == first_match) { if let Some(idx) = self.flattened_items.iter().position(|item| item.path == first_match) {
self.selected_index = idx; self.selected_index = idx;
} }
} }
@@ -780,7 +792,7 @@ impl AppState {
self.rebuild_flattened_items(); self.rebuild_flattened_items();
// Find the path in current flattened items // Find the path in current flattened items
if let Some(idx) = self.flattened_items.iter().position(|item| item.node.path == target_path) { if let Some(idx) = self.flattened_items.iter().position(|item| item.path == target_path) {
self.selected_index = idx; self.selected_index = idx;
// Scroll to show the match // Scroll to show the match
@@ -827,7 +839,7 @@ impl AppState {
self.rebuild_flattened_items(); self.rebuild_flattened_items();
// Find the path in current flattened items // Find the path in current flattened items
if let Some(idx) = self.flattened_items.iter().position(|item| item.node.path == target_path) { if let Some(idx) = self.flattened_items.iter().position(|item| item.path == target_path) {
self.selected_index = idx; self.selected_index = idx;
// Scroll to show the match // Scroll to show the match
@@ -874,22 +886,15 @@ impl AppState {
self.rebuild_flattened_items(); self.rebuild_flattened_items();
// Find and select the match // Find and select the match
if let Some(idx) = self.flattened_items.iter().position(|item| item.node.path == next_match) { if let Some(idx) = self.flattened_items.iter().position(|item| item.path == next_match) {
self.selected_index = idx; self.selected_index = idx;
// Scroll to show the match // Scroll to show the match
// Account for "... X more below" indicator which takes one line let effective_height = calculate_effective_height(
let visible_end = self.scroll_offset + self.file_panel_visible_height; self.scroll_offset,
let items_below = if visible_end < self.flattened_items.len() { self.file_panel_visible_height,
self.flattened_items.len() - visible_end self.flattened_items.len()
} 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 { if self.selected_index < self.scroll_offset {
self.scroll_offset = self.selected_index; self.scroll_offset = self.selected_index;
@@ -924,22 +929,15 @@ impl AppState {
self.rebuild_flattened_items(); self.rebuild_flattened_items();
// Find and select the match // Find and select the match
if let Some(idx) = self.flattened_items.iter().position(|item| item.node.path == prev_match) { if let Some(idx) = self.flattened_items.iter().position(|item| item.path == prev_match) {
self.selected_index = idx; self.selected_index = idx;
// Scroll to show the match // Scroll to show the match
// Account for "... X more below" indicator which takes one line let effective_height = calculate_effective_height(
let visible_end = self.scroll_offset + self.file_panel_visible_height; self.scroll_offset,
let items_below = if visible_end < self.flattened_items.len() { self.file_panel_visible_height,
self.flattened_items.len() - visible_end self.flattened_items.len()
} 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 { if self.selected_index < self.scroll_offset {
self.scroll_offset = self.selected_index; self.scroll_offset = self.selected_index;
@@ -996,18 +994,11 @@ impl AppState {
self.selected_playlist_index = best_match_idx; self.selected_playlist_index = best_match_idx;
// Scroll to show the match // Scroll to show the match
// Account for "... X more below" indicator which takes one line let effective_height = calculate_effective_height(
let visible_end = self.playlist_scroll_offset + self.playlist_visible_height; self.playlist_scroll_offset,
let items_below = if visible_end < self.playlist.len() { self.playlist_visible_height,
self.playlist.len() - visible_end self.playlist.len()
} 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 { if self.selected_playlist_index < self.playlist_scroll_offset {
self.playlist_scroll_offset = self.selected_playlist_index; self.playlist_scroll_offset = self.selected_playlist_index;
@@ -1027,18 +1018,11 @@ impl AppState {
self.selected_playlist_index = next_match_idx; self.selected_playlist_index = next_match_idx;
// Scroll to show the match // Scroll to show the match
// Account for "... X more below" indicator which takes one line let effective_height = calculate_effective_height(
let visible_end = self.playlist_scroll_offset + self.playlist_visible_height; self.playlist_scroll_offset,
let items_below = if visible_end < self.playlist.len() { self.playlist_visible_height,
self.playlist.len() - visible_end self.playlist.len()
} 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 { if self.selected_playlist_index < self.playlist_scroll_offset {
self.playlist_scroll_offset = self.selected_playlist_index; self.playlist_scroll_offset = self.selected_playlist_index;
@@ -1062,18 +1046,11 @@ impl AppState {
self.selected_playlist_index = prev_match_idx; self.selected_playlist_index = prev_match_idx;
// Scroll to show the match // Scroll to show the match
// Account for "... X more below" indicator which takes one line let effective_height = calculate_effective_height(
let visible_end = self.playlist_scroll_offset + self.playlist_visible_height; self.playlist_scroll_offset,
let items_below = if visible_end < self.playlist.len() { self.playlist_visible_height,
self.playlist.len() - visible_end self.playlist.len()
} 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 { if self.selected_playlist_index < self.playlist_scroll_offset {
self.playlist_scroll_offset = self.selected_playlist_index; self.playlist_scroll_offset = self.selected_playlist_index;
@@ -1209,7 +1186,9 @@ fn flatten_tree(nodes: &[FileTreeNode], depth: usize, expanded_dirs: &HashSet<Pa
let is_expanded = expanded_dirs.contains(&node.path); let is_expanded = expanded_dirs.contains(&node.path);
result.push(FlattenedItem { result.push(FlattenedItem {
node: node.clone(), path: node.path.clone(),
name: node.name.clone(),
is_dir: node.is_dir,
depth, depth,
}); });
@@ -1236,38 +1215,44 @@ fn collect_files_from_node(node: &FileTreeNode) -> Vec<PathBuf> {
} }
fn fuzzy_match(text: &str, query: &str) -> Option<i32> { fn fuzzy_match(text: &str, query: &str) -> Option<i32> {
let text_lower = text.to_lowercase(); // Avoid allocations by comparing chars directly with case-insensitive logic
let query_lower = query.to_lowercase(); let mut text_chars = text.chars();
let mut text_chars = text_lower.chars();
let mut score = 0; let mut score = 0;
let mut prev_match_idx = 0; let mut prev_match_idx = 0;
let mut consecutive_bonus = 0; let mut consecutive_bonus = 0;
let mut prev_char = '\0';
for query_char in query_lower.chars() { for query_char in query.chars() {
// Lowercase query char inline
let query_char_lower = query_char.to_lowercase().next().unwrap_or(query_char);
let mut found = false; let mut found = false;
let mut current_idx = prev_match_idx; let mut current_idx = prev_match_idx;
for text_char in text_chars.by_ref() { for text_char in text_chars.by_ref() {
current_idx += 1; current_idx += 1;
if text_char == query_char { // Lowercase text char inline for comparison
let text_char_lower = text_char.to_lowercase().next().unwrap_or(text_char);
if text_char_lower == query_char_lower {
found = true; found = true;
// Bonus for consecutive matches // Bonus for consecutive matches
if current_idx == prev_match_idx + 1 { if current_idx == prev_match_idx + 1 {
consecutive_bonus += 10; consecutive_bonus += FUZZY_CONSECUTIVE_BONUS;
} else { } else {
consecutive_bonus = 0; consecutive_bonus = 0;
} }
// Bonus for matching at word start // Bonus for matching at word start
if current_idx == 1 || text_lower.chars().nth(current_idx - 2).map_or(false, |c| !c.is_alphanumeric()) { if current_idx == 1 || !prev_char.is_alphanumeric() {
score += 15; score += FUZZY_WORD_START_BONUS;
} }
score += consecutive_bonus; score += consecutive_bonus;
// Penalty for gap // Penalty for gap
score -= (current_idx - prev_match_idx - 1) as i32; score -= (current_idx - prev_match_idx - 1) as i32;
prev_match_idx = current_idx; prev_match_idx = current_idx;
prev_char = text_char;
break; break;
} }
prev_char = text_char;
} }
if !found { if !found {
@@ -1283,7 +1268,7 @@ fn collect_matching_paths(nodes: &[FileTreeNode], query: &str, matches: &mut Vec
if let Some(mut score) = fuzzy_match(&node.name, query) { if let Some(mut score) = fuzzy_match(&node.name, query) {
// Give folders a significant boost so they appear before files // Give folders a significant boost so they appear before files
if node.is_dir { if node.is_dir {
score += 50; score += FUZZY_FOLDER_BONUS;
} }
matches.push((node.path.clone(), score)); matches.push((node.path.clone(), score));
} }

View File

@@ -11,7 +11,7 @@ use ratatui::{
}; };
use theme::Theme; use theme::Theme;
pub fn render(frame: &mut Frame, state: &mut AppState, player: &Player) -> (Rect, Rect, Rect) { pub fn render(frame: &mut Frame, state: &mut AppState, player: &mut Player) -> (Rect, Rect, Rect) {
// Clear background // Clear background
frame.render_widget( frame.render_widget(
Block::default().style(Theme::secondary()), Block::default().style(Theme::secondary()),
@@ -29,9 +29,16 @@ pub fn render(frame: &mut Frame, state: &mut AppState, player: &Player) -> (Rect
.split(frame.area()); .split(frame.area());
// Main content: left (files) | right (status + playlist) // Main content: left (files) | right (status + playlist)
// Switch proportions based on focus: 80/20 for focused panel
let (left_percent, right_percent) = if state.focus_playlist {
(20, 80) // Playlist focused: small file panel, large playlist
} else {
(80, 20) // File panel focused: large file panel, small playlist
};
let content_chunks = Layout::default() let content_chunks = Layout::default()
.direction(Direction::Horizontal) .direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) .constraints([Constraint::Percentage(left_percent), Constraint::Percentage(right_percent)])
.split(main_chunks[1]); .split(main_chunks[1]);
render_title_bar(frame, state, player, main_chunks[0]); render_title_bar(frame, state, player, main_chunks[0]);
@@ -62,7 +69,8 @@ fn highlight_search_matches<'a>(text: &str, query: &str, is_selected: bool) -> V
let mut current_segment = String::new(); let mut current_segment = String::new();
for ch in text.chars() { for ch in text.chars() {
let ch_lower = ch.to_lowercase().next().unwrap(); // to_lowercase() returns an iterator, get first char (always exists but use unwrap_or for safety)
let ch_lower = ch.to_lowercase().next().unwrap_or(ch);
if let Some(query_ch) = current_query_char { if let Some(query_ch) = current_query_char {
if ch_lower == query_ch { if ch_lower == query_ch {
@@ -141,15 +149,15 @@ fn render_file_panel(frame: &mut Frame, state: &mut AppState, area: Rect) {
.map(|(display_idx, item)| { .map(|(display_idx, item)| {
let idx = state.scroll_offset + display_idx; let idx = state.scroll_offset + display_idx;
let indent = " ".repeat(item.depth); let indent = " ".repeat(item.depth);
let mark = if state.marked_files.contains(&item.node.path) { "* " } else { "" }; let mark = if state.marked_files.contains(&item.path) { "* " } else { "" };
// Build name with search highlighting // Build name with search highlighting
// Only show selection bar when file panel has focus // Only show selection bar when file panel has focus
let is_selected = !state.focus_playlist && idx == state.selected_index; let is_selected = !state.focus_playlist && idx == state.selected_index;
// Add icon for directories and files // Add icon for directories and files
let icon = if item.node.is_dir { let icon = if item.is_dir {
let is_expanded = state.expanded_dirs.contains(&item.node.path); let is_expanded = state.expanded_dirs.contains(&item.path);
// Nerd font folder icons: \u{eaf7} = open, \u{ea83} = closed // Nerd font folder icons: \u{eaf7} = open, \u{ea83} = closed
let icon_char = if is_expanded { "\u{eaf7} " } else { "\u{ea83} " }; let icon_char = if is_expanded { "\u{eaf7} " } else { "\u{ea83} " };
@@ -161,7 +169,7 @@ fn render_file_panel(frame: &mut Frame, state: &mut AppState, area: Rect) {
} }
} else { } else {
// File icons based on extension // File icons based on extension
let extension = item.node.path.extension() let extension = item.path.extension()
.and_then(|e| e.to_str()) .and_then(|e| e.to_str())
.unwrap_or("") .unwrap_or("")
.to_lowercase(); .to_lowercase();
@@ -183,12 +191,12 @@ fn render_file_panel(frame: &mut Frame, state: &mut AppState, area: Rect) {
} }
}; };
let name_spans = if in_search && !search_query.is_empty() { let name_spans = if in_search && !search_query.is_empty() {
highlight_search_matches(&item.node.name, &search_query, is_selected) highlight_search_matches(&item.name, &search_query, is_selected)
} else { } else {
vec![Span::raw(&item.node.name)] vec![Span::raw(&item.name)]
}; };
let suffix = if item.node.is_dir { "/" } else { "" }; let suffix = if item.is_dir { "/" } else { "" };
let base_style = if is_selected { let base_style = if is_selected {
// Selection bar: yellow/orange when in search (typing or viewing results), blue otherwise // Selection bar: yellow/orange when in search (typing or viewing results), blue otherwise
@@ -197,7 +205,7 @@ fn render_file_panel(frame: &mut Frame, state: &mut AppState, area: Rect) {
} else { } else {
Theme::selected() Theme::selected()
} }
} else if state.marked_files.contains(&item.node.path) { } else if state.marked_files.contains(&item.path) {
Theme::marked() Theme::marked()
} else { } else {
Theme::secondary() Theme::secondary()
@@ -349,8 +357,10 @@ fn render_right_panel(frame: &mut Frame, state: &mut AppState, area: Rect) {
frame.render_stateful_widget(playlist_widget, area, &mut playlist_state); frame.render_stateful_widget(playlist_widget, area, &mut playlist_state);
} }
fn render_title_bar(frame: &mut Frame, state: &AppState, _player: &Player, area: Rect) { fn render_title_bar(frame: &mut Frame, state: &AppState, player: &mut Player, area: Rect) {
let background_color = match state.player_state { // Default to stopped if we can't query MPV
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::Playing => Theme::success(), // Green for playing
PlayerState::Paused => Theme::highlight(), // Blue for paused PlayerState::Paused => Theme::highlight(), // Blue for paused
PlayerState::Stopped => Theme::dim_foreground(), // Gray for stopped PlayerState::Stopped => Theme::dim_foreground(), // Gray for stopped
@@ -389,13 +399,13 @@ fn render_title_bar(frame: &mut Frame, state: &AppState, _player: &Player, area:
)); ));
} else { } else {
// Status (bold when playing) // Status (bold when playing)
let status_text = match state.player_state { let status_text = match player_state {
PlayerState::Stopped => "Stopped", PlayerState::Stopped => "Stopped",
PlayerState::Playing => "Playing", PlayerState::Playing => "Playing",
PlayerState::Paused => "Paused", PlayerState::Paused => "Paused",
}; };
let status_style = if state.player_state == PlayerState::Playing { let status_style = if player_state == PlayerState::Playing {
Style::default() Style::default()
.fg(Theme::background()) .fg(Theme::background())
.bg(background_color) .bg(background_color)
@@ -458,10 +468,22 @@ fn render_title_bar(frame: &mut Frame, state: &AppState, _player: &Player, area:
frame.render_widget(right_title, chunks[1]); frame.render_widget(right_title, chunks[1]);
} }
fn render_status_bar(frame: &mut Frame, state: &AppState, player: &Player, area: Rect) { 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 // 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 // Searching in playlist
if !state.playlist_tab_search_results.is_empty() { 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()) format!("/{}_ Playlist Search: {}/{}", state.search_query, state.playlist_tab_search_index + 1, state.playlist_tab_search_results.len())
@@ -479,28 +501,28 @@ fn render_status_bar(frame: &mut Frame, state: &AppState, player: &Player, area:
} else { } else {
format!("/{}_", state.search_query) 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() { } else if !state.search_matches.is_empty() {
// Show search navigation when file search results are active // 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()); 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() { } else if !state.playlist_search_matches.is_empty() {
// Show search navigation when playlist search results are active // 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()); 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 { } else if state.visual_mode {
// Show visual mode indicator // Show visual mode indicator
let visual_text = format!("-- VISUAL -- {} files marked", state.marked_files.len()); format!("-- VISUAL -- {} files marked", state.marked_files.len())
let status_bar = Paragraph::new(visual_text) } else {
.style(Style::default().fg(Theme::foreground()).bg(Theme::background())); 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); 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 { } else {
// Normal mode: show media metadata if playing // Normal mode: show media metadata if playing
// Split into left (artist/album/title) and right (technical info) // Split into left (artist/album/title) and right (technical info)
@@ -534,12 +556,6 @@ fn render_status_bar(frame: &mut Frame, state: &AppState, player: &Player, area:
right_parts.push(format!("{} Hz", samplerate)); right_parts.push(format!("{} Hz", samplerate));
} }
if let Some(cache_dur) = player.cache_duration {
if cache_dur > 0.0 {
right_parts.push(format!("Cache:{:.1}s", cache_dur));
}
}
// Create layout for left and right sections // Create layout for left and right sections
let chunks = Layout::default() let chunks = Layout::default()
.direction(Direction::Horizontal) .direction(Direction::Horizontal)
@@ -572,6 +588,97 @@ fn render_status_bar(frame: &mut Frame, state: &AppState, player: &Player, area:
} }
} }
fn render_progress_bar(frame: &mut Frame, _state: &AppState, player: &mut Player, area: Rect, progress_percent: f64) {
// Get metadata to display
let mut left_parts = Vec::new();
let mut right_parts = Vec::new();
// Left side: Artist | Album | Title
if let Some(ref artist) = player.artist {
left_parts.push(artist.clone());
}
if let Some(ref album) = player.album {
left_parts.push(album.clone());
}
if let Some(ref title) = player.media_title {
left_parts.push(title.clone());
}
// Right side: Bitrate | Codec | Sample rate | Cache
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 text parts
let left_text = if !left_parts.is_empty() {
format!(" {}", left_parts.join(" | "))
} else {
String::new()
};
let right_text = if !right_parts.is_empty() {
format!("{} ", right_parts.join(" | "))
} else {
String::new()
};
// Calculate filled width based on progress
let total_width = area.width as usize;
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_confirm_popup(frame: &mut Frame, title: &str, message: &str) { fn render_confirm_popup(frame: &mut Frame, title: &str, message: &str) {
// Create centered popup area // Create centered popup area
let area = frame.area(); let area = frame.area();