Compare commits

..

21 Commits

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

Main content border remains gray.
2025-12-13 10:34:55 +01:00
b7cc219f40 Prevent auto-advance when MPV is force killed
Set previous_player_state to Stopped and clear current_file when MPV dies to prevent the auto-advance logic from triggering.
2025-12-12 21:18:22 +01:00
b5fde2d5d4 Handle MPV force kill gracefully
When MPV is killed externally, detect the dead process and automatically recreate it for next playback. Connection errors no longer crash the application - they're logged and handled silently.
2025-12-12 19:52:48 +01:00
d53542afa6 Eliminate code duplication with unified action functions
All checks were successful
Build and Release / build-and-release (push) Successful in 55s
Create action functions for stop, volume, and seek operations and
use them consistently across keyboard handlers, mouse handlers, and
API handlers. This eliminates duplicate logic and ensures consistent
behavior across all input methods.

Also fixes stop command triggering auto-advance by setting the
skip_position_update flag to prevent the Playing→Stopped transition
from being interpreted as a natural track ending.
2025-12-12 16:19:56 +01:00
be9ee8c005 Move refresh status to centered popup
All checks were successful
Build and Release / build-and-release (push) Successful in 54s
Display "Refreshing library..." in a centered popup overlay instead
of showing it in the title bar. This makes the refresh status more
prominent and cleaner.
2025-12-12 15:45:12 +01:00
7c083cfb0e Filter out empty directories during library scan
All checks were successful
Build and Release / build-and-release (push) Successful in 54s
Skip directories that contain no media files or non-empty subdirectories.
This prevents empty folders from appearing in the file list, which can
occur when NFS cache is stale or when directories are emptied.
2025-12-12 15:34:29 +01:00
b438065c23 Use small triangle arrow for playlist playing indicator
All checks were successful
Build and Release / build-and-release (push) Successful in 54s
Replace large arrow with small triangle (▸) to match the style
used for directory indicators before nerd fonts were introduced.
2025-12-12 13:08:00 +01:00
0fa26db116 Add playing indicator arrow in playlist
All checks were successful
Build and Release / build-and-release (push) Successful in 54s
Display a small arrow (▶) to the left of the currently playing track in the playlist, making it easier to identify which song is playing.
2025-12-12 12:57:12 +01:00
0ec328881a Optimize MPV polling with single batch query every 200ms
All checks were successful
Build and Release / build-and-release (push) Successful in 1m18s
Replace separate property queries with unified batch fetching:
- Consolidate position, duration, and metadata into one IPC call
- Reduce polling from 100ms to 200ms (5 FPS)
- Remove complex timeout handling in favor of simple blocking reads
- Remove unused is_idle, is_paused, and get_property methods

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

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

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

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

Ref: https://github.com/microsoft/wslg/issues/1257
2025-12-11 20:04:49 +01:00
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
10 changed files with 1656 additions and 1330 deletions

View File

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

View File

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

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

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

3
src/cache/mod.rs vendored
View File

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

View File

@ -1,3 +1,4 @@
mod api;
mod cache;
mod config;
mod player;
@ -13,11 +14,26 @@ use crossterm::{
};
use ratatui::{backend::CrosstermBackend, Terminal};
use state::{AppState, PlayerState};
use std::io;
use std::io::{self, BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;
use tracing_subscriber;
#[tokio::main]
async fn main() -> Result<()> {
// UI update intervals and thresholds
const POLL_DURATION_STOPPED_MS: u64 = 200; // 5 FPS when stopped
const POLL_DURATION_ACTIVE_MS: u64 = 200; // 5 FPS when playing/paused - single batch poll
const DOUBLE_CLICK_MS: u128 = 500; // Double-click detection threshold
fn main() -> Result<()> {
// Check if we're in client mode (sending command to running instance)
let args: Vec<String> = std::env::args().collect();
if args.len() > 1 {
return send_command(&args[1..]);
}
// Initialize logging to file to avoid interfering with TUI
let log_file = std::fs::OpenOptions::new()
.create(true)
@ -49,21 +65,32 @@ async fn main() -> Result<()> {
// Initialize player
let mut player = player::Player::new()?;
tracing::info!("Player initialized");
// Initialize app state
let mut state = AppState::new(cache, config);
tracing::info!("State initialized");
// Start API server
let socket_path = api::get_default_socket_path()?;
let api_rx = api::start_api_server(socket_path)?;
tracing::info!("API server started");
// Setup terminal
enable_raw_mode()?;
tracing::info!("Raw mode enabled");
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
tracing::info!("Terminal setup complete");
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
tracing::info!("Terminal created, entering main loop");
// Run app
let result = run_app(&mut terminal, &mut state, &mut player).await;
// Run app (ensure terminal cleanup even on error)
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()?;
execute!(
terminal.backend_mut(),
@ -71,16 +98,119 @@ async fn main() -> Result<()> {
DisableMouseCapture
)?;
terminal.show_cursor()?;
Ok(())
})();
// Log cleanup errors but prioritize original error
if let Err(e) = cleanup_result {
tracing::error!("Terminal cleanup failed: {}", e);
}
result
}
/// Send a command to a running cm-player instance (client mode)
fn send_command(args: &[String]) -> Result<()> {
let socket_path = api::get_default_socket_path()?;
if !socket_path.exists() {
eprintln!("Error: cm-player is not running (socket not found at {:?})", socket_path);
std::process::exit(1);
}
// Parse command
let command = match args[0].as_str() {
"play-pause" | "pp" => api::ApiCommand::PlayPause,
"stop" => api::ApiCommand::Stop,
"next" | "n" => api::ApiCommand::Next,
"prev" | "p" => api::ApiCommand::Prev,
"volume-up" | "vu" => api::ApiCommand::VolumeUp,
"volume-down" | "vd" => api::ApiCommand::VolumeDown,
"volume" | "v" => {
if args.len() < 2 {
eprintln!("Usage: cm-player volume <0-100>");
std::process::exit(1);
}
let volume: i64 = args[1].parse()
.context("Volume must be a number between 0-100")?;
api::ApiCommand::VolumeSet { volume }
}
"seek-forward" | "sf" => {
let seconds = if args.len() > 1 {
args[1].parse().unwrap_or(10.0)
} else {
10.0
};
api::ApiCommand::SeekForward { seconds }
}
"seek-backward" | "sb" => {
let seconds = if args.len() > 1 {
args[1].parse().unwrap_or(10.0)
} else {
10.0
};
api::ApiCommand::SeekBackward { seconds }
}
"status" | "s" => api::ApiCommand::GetStatus,
"quit" | "q" => api::ApiCommand::Quit,
_ => {
eprintln!("Unknown command: {}", args[0]);
print_usage();
std::process::exit(1);
}
};
// Connect to socket and send command
let mut stream = UnixStream::connect(&socket_path)
.context("Failed to connect to cm-player")?;
let command_json = serde_json::to_string(&command)?;
writeln!(stream, "{}", command_json)?;
// Read response
let mut reader = BufReader::new(stream);
let mut response_line = String::new();
reader.read_line(&mut response_line)?;
let response: api::ApiResponse = serde_json::from_str(&response_line)?;
if response.success {
if let Some(data) = response.data {
println!("{}", serde_json::to_string_pretty(&data)?);
}
} else {
eprintln!("Error: {}", response.message.unwrap_or_else(|| "Unknown error".to_string()));
std::process::exit(1);
}
Ok(())
}
fn print_usage() {
eprintln!("Usage: cm-player [command] [args]");
eprintln!();
eprintln!("Commands:");
eprintln!(" play-pause, pp Toggle play/pause");
eprintln!(" stop Stop playback");
eprintln!(" next, n Next track");
eprintln!(" prev, p Previous track");
eprintln!(" volume-up, vu Volume up by 5%");
eprintln!(" volume-down, vd Volume down by 5%");
eprintln!(" volume, v <0-100> Set volume");
eprintln!(" seek-forward, sf [sec] Seek forward (default: 10s)");
eprintln!(" seek-backward, sb [sec] Seek backward (default: 10s)");
eprintln!(" status, s Get player status");
eprintln!(" quit, q Quit cm-player");
eprintln!();
eprintln!("If no command is provided, cm-player starts in TUI mode.");
}
// Common action functions that both keyboard and mouse handlers can call
fn action_toggle_folder(state: &mut AppState) {
if let Some(item) = state.get_selected_item() {
if item.node.is_dir {
let path = item.node.path.clone();
if item.is_dir {
let path = item.path.clone();
if state.expanded_dirs.contains(&path) {
// Folder is open, close it
state.collapse_selected();
@ -92,12 +222,14 @@ fn action_toggle_folder(state: &mut AppState) {
}
}
fn action_play_selection(state: &mut AppState, player: &mut player::Player) -> Result<()> {
fn action_play_selection(state: &mut AppState, player: &mut player::Player, skip_position_update: &mut bool) -> Result<()> {
state.play_selection();
if let Some(ref path) = state.current_file {
*skip_position_update = true; // Skip position update after track change
player.play(path)?;
state.player_state = PlayerState::Playing;
player.update_metadata();
// Explicitly resume playback in case MPV was paused
player.resume()?;
// Metadata will be fetched by periodic update once MPV has it ready
tracing::info!("Playing: {:?} (playlist: {} tracks)", path, state.playlist.len());
}
if state.visual_mode {
@ -108,78 +240,376 @@ 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<()> {
match state.player_state {
if let Some(player_state) = player.get_player_state() {
match player_state {
PlayerState::Playing => {
player.pause()?;
state.player_state = PlayerState::Paused;
tracing::info!("Paused");
}
PlayerState::Paused => {
player.resume()?;
state.player_state = PlayerState::Playing;
tracing::info!("Resumed");
}
PlayerState::Stopped => {
// Restart playback from current playlist position
if !state.playlist.is_empty() {
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();
player.resume()?;
tracing::info!("Restarting playback: {:?}", path);
}
}
}
}
}
Ok(())
}
fn action_stop(state: &mut AppState, player: &mut player::Player) -> Result<()> {
fn action_stop(state: &mut AppState, player: &mut player::Player, skip_position_update: &mut bool) -> Result<()> {
player.stop()?;
state.player_state = PlayerState::Stopped;
state.current_position = 0.0;
state.current_duration = 0.0;
*skip_position_update = true; // Prevent auto-advance on manual stop
tracing::info!("Stopped");
Ok(())
}
async fn run_app<B: ratatui::backend::Backend>(
fn action_volume_up(state: &mut AppState, player: &mut player::Player) -> Result<()> {
state.volume = (state.volume + 5).min(100);
player.set_volume(state.volume)?;
tracing::info!("Volume: {}%", state.volume);
Ok(())
}
fn action_volume_down(state: &mut AppState, player: &mut player::Player) -> Result<()> {
state.volume = (state.volume - 5).max(0);
player.set_volume(state.volume)?;
tracing::info!("Volume: {}%", state.volume);
Ok(())
}
fn action_volume_set(state: &mut AppState, player: &mut player::Player, volume: i64) -> Result<()> {
state.volume = volume.clamp(0, 100);
player.set_volume(state.volume)?;
tracing::info!("Volume: {}%", state.volume);
Ok(())
}
fn action_seek(player: &mut player::Player, seconds: f64) -> Result<()> {
if player.get_player_state() != Some(PlayerState::Stopped) {
player.seek(seconds)?;
tracing::info!("Seek {}s", seconds);
}
Ok(())
}
fn action_remove_from_playlist(state: &mut AppState, player: &mut player::Player) -> Result<()> {
let was_playing_removed = state.playlist_index == state.selected_playlist_index;
let was_playing = player.get_player_state() == Some(PlayerState::Playing);
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()?;
// Metadata will be fetched by periodic update
}
}
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()?;
// Metadata will be fetched by periodic update
tracing::info!("{} track: {:?}", track_name, path);
}
}
PlayerState::Paused => {
// Load but stay paused
if let Some(ref path) = state.current_file {
*skip_position_update = true; // Skip position update after track change
player.play_paused(path)?;
// Metadata will be fetched by periodic update
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()?;
// Metadata will be fetched by periodic update
tracing::info!("Jumped to track: {:?}", path);
}
}
PlayerState::Paused => {
if let Some(ref path) = state.current_file {
*skip_position_update = true; // Skip position update after track change
player.play_paused(path)?;
// Metadata will be fetched by periodic update
tracing::info!("Jumped to track (paused): {:?}", path);
}
}
PlayerState::Stopped => {
if let Some(ref path) = state.current_file {
*skip_position_update = true; // Skip position update after track change
player.play(path)?;
player.resume()?;
// Metadata will be fetched by periodic update
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()?;
// Metadata will be fetched by periodic update
tracing::info!("Playing from playlist: {:?}", path);
}
}
Ok(())
}
fn handle_context_menu_action(menu_type: state::ContextMenuType, selected: usize, state: &mut AppState, player: &mut player::Player, 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, skip_position_update)?,
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>,
state: &mut AppState,
player: &mut player::Player,
api_rx: std::sync::mpsc::Receiver<api::ApiCommand>,
) -> Result<()> {
let mut metadata_update_counter = 0u32;
let mut last_position = 0.0f64;
let mut needs_redraw = true;
let mut skip_position_update = false;
let mut title_bar_area = ratatui::layout::Rect::default();
let mut file_panel_area = ratatui::layout::Rect::default();
let mut playlist_area = ratatui::layout::Rect::default();
let mut previous_player_state: Option<PlayerState> = None;
loop {
let mut state_changed = false;
// Check if mpv process died (e.g., user closed video window)
if !player.is_process_alive() && state.player_state != PlayerState::Stopped {
state.player_state = PlayerState::Stopped;
// Check for API commands (non-blocking)
while let Ok(cmd) = api_rx.try_recv() {
tracing::debug!("Processing API command: {:?}", cmd);
match cmd {
api::ApiCommand::PlayPause => {
action_toggle_play_pause(state, player)?;
state_changed = true;
}
api::ApiCommand::Stop => {
action_stop(state, player, &mut skip_position_update)?;
state_changed = true;
}
api::ApiCommand::Next => {
action_navigate_track(state, player, 1, &mut skip_position_update)?;
state_changed = true;
}
api::ApiCommand::Prev => {
action_navigate_track(state, player, -1, &mut skip_position_update)?;
state_changed = true;
}
api::ApiCommand::VolumeUp => {
action_volume_up(state, player)?;
state_changed = true;
}
api::ApiCommand::VolumeDown => {
action_volume_down(state, player)?;
state_changed = true;
}
api::ApiCommand::VolumeSet { volume } => {
action_volume_set(state, player, volume)?;
state_changed = true;
}
api::ApiCommand::SeekForward { seconds } => {
action_seek(player, seconds)?;
state_changed = true;
}
api::ApiCommand::SeekBackward { seconds } => {
action_seek(player, -seconds)?;
state_changed = true;
}
api::ApiCommand::GetStatus => {
// Status query - no state change needed
tracing::debug!("Status query received");
}
api::ApiCommand::Quit => {
state.should_quit = true;
}
}
}
// Check if mpv process died (e.g., user closed video window or force killed)
if !player.is_process_alive() {
tracing::info!("MPV process died, recreating...");
state.current_position = 0.0;
state.current_duration = 0.0;
state.current_file = None;
state_changed = true;
skip_position_update = true; // Prevent auto-advance when MPV was killed
// Recreate player for next playback
*player = player::Player::new()?;
previous_player_state = Some(PlayerState::Stopped);
}
// Always update all properties in one batch to keep state synchronized with MPV
player.update_all_properties();
// Only proceed if we can successfully query player state
let Some(player_state) = player.get_player_state() else {
// Can't get state from MPV, skip this iteration
if event::poll(std::time::Duration::from_millis(100))? {
match event::read()? {
Event::Key(key) => {
if key.kind == KeyEventKind::Press {
handle_key_event(terminal, state, player, key, &mut skip_position_update)?;
needs_redraw = true;
}
}
Event::Mouse(mouse) => {
handle_mouse_event(state, mouse, title_bar_area, file_panel_area, playlist_area, player, &mut skip_position_update)?;
needs_redraw = true;
}
_ => {}
}
}
continue;
};
// Check if track ended and play next
// When MPV finishes playing a file, it goes to idle (Stopped state)
// Detect Playing → Stopped transition = track ended, play next
// But skip this check if we just manually stopped (skip_position_update flag)
if previous_player_state == Some(PlayerState::Playing)
&& player_state == PlayerState::Stopped
&& !skip_position_update
{
let should_continue = state.play_next();
// play_next() returns true if should continue playing, false if should stop
if should_continue {
if let Some(ref path) = state.current_file {
// Reset position/duration before playing new track
state.current_position = 0.0;
state.current_duration = 0.0;
last_position = 0.0;
skip_position_update = true; // Skip position update this iteration
player.play(path)?;
player.resume()?;
}
// Auto-scroll playlist to show current track (only if user is not browsing playlist)
if !state.focus_playlist {
let playlist_visible_height = playlist_area.height.saturating_sub(2) as usize;
state.update_playlist_scroll(playlist_visible_height);
}
} else {
// Reached end of playlist in Normal mode - stop playback
player.stop()?;
}
state_changed = true;
}
// Only update properties when playing or paused (not when stopped)
if state.player_state != PlayerState::Stopped {
player.update_properties();
// Update metadata only every 20 iterations (~2 seconds) to reduce IPC calls
metadata_update_counter += 1;
if metadata_update_counter >= 20 {
player.update_metadata();
metadata_update_counter = 0;
state_changed = true;
}
// Only update playback position when not stopped
if player_state != PlayerState::Stopped {
// 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);
@ -196,33 +626,11 @@ async fn run_app<B: ratatui::backend::Backend>(
state.current_duration = new_duration;
state_changed = true;
}
}
}
// Check if track ended and play next (but only if track was actually loaded AND played)
// Require position > 0.5 to ensure track actually started playing (not just loaded)
if player.is_idle() && state.player_state == PlayerState::Playing && state.current_duration > 0.0 && state.current_position > 0.5 {
state.play_next();
// play_next() handles the play mode and may stop if in Normal mode at end
if state.player_state == PlayerState::Playing {
if let Some(ref path) = state.current_file {
// Reset position/duration before playing new track
state.current_position = 0.0;
state.current_duration = 0.0;
last_position = 0.0;
player.play(path)?;
}
// Update metadata immediately when track changes
player.update_metadata();
metadata_update_counter = 0;
// Auto-scroll playlist to show current track (only if user is not browsing playlist)
if !state.focus_playlist {
let playlist_visible_height = playlist_area.height.saturating_sub(2) as usize;
state.update_playlist_scroll(playlist_visible_height);
}
}
state_changed = true;
}
}
// Save current state for next iteration
previous_player_state = Some(player_state);
// Only redraw if something changed or forced
if needs_redraw || state_changed {
@ -236,22 +644,22 @@ async fn run_app<B: ratatui::backend::Backend>(
}
// Poll for events - use longer timeout when stopped to reduce CPU
let poll_duration = if state.player_state == PlayerState::Stopped {
std::time::Duration::from_millis(200) // 5 FPS when stopped
let poll_duration = if player_state == PlayerState::Stopped {
std::time::Duration::from_millis(POLL_DURATION_STOPPED_MS)
} 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)? {
match event::read()? {
Event::Key(key) => {
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
}
}
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
}
_ => {}
@ -266,22 +674,57 @@ async fn run_app<B: ratatui::backend::Backend>(
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
if state.show_refresh_confirm {
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
state.show_refresh_confirm = false;
state.is_refreshing = true;
terminal.draw(|f| { let _ = ui::render(f, state, player); })?; // Show "Refreshing library..." immediately
tracing::info!("Rescanning...");
state.refresh_file_count = 0;
terminal.draw(|f| { let _ = ui::render(f, state, player); })?;
let cache_dir = cache::get_cache_dir()?;
let new_cache = scanner::scan_paths(&state.config.scan_paths.paths)?;
// Delete old cache files to ensure fresh scan
let _ = std::fs::remove_file(cache_dir.join("file_tree.json"));
let _ = std::fs::remove_file(cache_dir.join("metadata.json"));
// Create atomic counter for file count
let counter = Arc::new(AtomicUsize::new(0));
let counter_clone = Arc::clone(&counter);
// Spawn background thread to perform scan
let paths = state.config.scan_paths.paths.clone();
let scan_thread = thread::spawn(move || {
scanner::scan_paths(&paths, &counter_clone)
});
// Poll counter and update UI while scanning
while !scan_thread.is_finished() {
state.refresh_file_count = counter.load(Ordering::Relaxed);
terminal.draw(|f| { let _ = ui::render(f, state, player); })?;
thread::sleep(Duration::from_millis(100));
}
// Get the result
let new_cache = scan_thread.join().map_err(|_| anyhow::anyhow!("Scan thread panicked"))??;
new_cache.save(&cache_dir)?;
// Replace old cache completely
state.cache = new_cache;
state.refresh_flattened_items();
state.refresh_flattened_items(); // This also cleans up playlist and expanded_dirs
// If current file was removed, stop playback
if state.current_file.is_none() {
player.stop()?;
state.current_position = 0.0;
state.current_duration = 0.0;
}
state.is_refreshing = false;
tracing::info!("Rescan complete");
state.refresh_file_count = 0;
}
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
state.show_refresh_confirm = false;
@ -294,6 +737,20 @@ async fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<
// Handle search mode separately
if state.search_mode {
match key.code {
KeyCode::Char('n') => {
if state.focus_playlist {
state.next_playlist_search_result();
} else {
state.next_search_result();
}
}
KeyCode::Char('N') => {
if state.focus_playlist {
state.prev_playlist_search_result();
} else {
state.prev_search_result();
}
}
KeyCode::Char(c) => {
if state.focus_playlist {
state.append_playlist_search_char(c);
@ -308,20 +765,6 @@ async fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<
state.backspace_search();
}
}
KeyCode::Tab => {
if state.focus_playlist {
state.playlist_tab_search_next();
} else {
state.tab_search_next();
}
}
KeyCode::BackTab => {
if state.focus_playlist {
state.playlist_tab_search_prev();
} else {
state.tab_search_prev();
}
}
KeyCode::Enter => {
if state.focus_playlist {
state.execute_playlist_search();
@ -350,7 +793,7 @@ async fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<
let max_items = match menu.menu_type {
ContextMenuType::FilePanel => 2,
ContextMenuType::Playlist => 2,
ContextMenuType::TitleBar => 4,
ContextMenuType::TitleBar => 3,
};
if menu.selected_index < max_items - 1 {
menu.selected_index += 1;
@ -361,64 +804,7 @@ async fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<
let menu_type = menu.menu_type;
let selected = menu.selected_index;
state.context_menu = None;
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");
}
_ => {}
}
}
}
handle_context_menu_action(menu_type, selected, state, player, skip_position_update)?;
}
KeyCode::Esc => {
state.context_menu = None;
@ -441,107 +827,18 @@ async fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<
}
}
(KeyCode::Esc, _) => {
if !state.search_matches.is_empty() {
state.search_matches.clear();
}
if !state.playlist_search_matches.is_empty() {
state.playlist_search_matches.clear();
state.playlist_tab_search_results.clear();
}
if state.visual_mode {
state.visual_mode = false;
state.marked_files.clear();
}
}
(KeyCode::Char('n'), _) => {
if !state.search_matches.is_empty() {
state.next_search_match();
} else if !state.playlist_search_matches.is_empty() {
state.next_playlist_search_match();
}
}
(KeyCode::Char('N'), KeyModifiers::SHIFT) => {
if !state.search_matches.is_empty() {
state.prev_search_match();
} else if !state.playlist_search_matches.is_empty() {
state.prev_playlist_search_match();
}
}
(KeyCode::Char('J'), KeyModifiers::SHIFT) => {
// Next track
if !state.playlist.is_empty() && state.playlist_index + 1 < state.playlist.len() {
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);
}
}
}
action_navigate_track(state, player, 1, skip_position_update)?;
}
(KeyCode::Char('K'), KeyModifiers::SHIFT) => {
// Previous track
if !state.playlist.is_empty() && state.playlist_index > 0 {
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);
}
}
}
action_navigate_track(state, player, -1, skip_position_update)?;
}
(KeyCode::Char('d'), KeyModifiers::CONTROL) => {
if state.focus_playlist {
@ -575,12 +872,12 @@ async fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<
state.move_selection_down();
}
}
(KeyCode::Char('h'), _) => {
(KeyCode::Char('h'), _) | (KeyCode::Left, _) => {
if !state.focus_playlist {
state.collapse_selected();
}
}
(KeyCode::Char('l'), _) => {
(KeyCode::Char('l'), _) | (KeyCode::Right, _) => {
if !state.focus_playlist {
state.expand_selected();
}
@ -604,46 +901,20 @@ async fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<
}
(KeyCode::Char('d'), _) => {
if state.focus_playlist {
// Remove selected track from playlist
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();
}
}
}
}
action_remove_from_playlist(state, player)?;
}
}
(KeyCode::Enter, _) => {
if state.focus_playlist {
// Play selected track from playlist
if state.selected_playlist_index < state.playlist.len() {
state.playlist_index = state.selected_playlist_index;
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);
}
action_play_from_playlist(state, player, false, skip_position_update)?;
}
} else {
action_play_selection(state, player)?;
action_play_selection(state, player, skip_position_update)?;
}
}
(KeyCode::Char('s'), _) => {
action_stop(state, player)?;
action_stop(state, player, skip_position_update)?;
}
(KeyCode::Char('m'), _) => {
state.cycle_play_mode();
@ -657,28 +928,16 @@ async fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<
action_toggle_play_pause(state, player)?;
}
(KeyCode::Char('H'), KeyModifiers::SHIFT) => {
if state.player_state != PlayerState::Stopped {
player.seek(-10.0)?;
tracing::info!("Seek backward 10s");
}
action_seek(player, -10.0)?;
}
(KeyCode::Char('L'), KeyModifiers::SHIFT) => {
if state.player_state != PlayerState::Stopped {
player.seek(10.0)?;
tracing::info!("Seek forward 10s");
}
action_seek(player, 10.0)?;
}
(KeyCode::Char('+'), _) | (KeyCode::Char('='), _) => {
let new_volume = (state.volume + 5).min(100);
state.volume = new_volume;
player.set_volume(new_volume)?;
tracing::info!("Volume: {}%", new_volume);
action_volume_up(state, player)?;
}
(KeyCode::Char('-'), _) => {
let new_volume = (state.volume - 5).max(0);
state.volume = new_volume;
player.set_volume(new_volume)?;
tracing::info!("Volume: {}%", new_volume);
action_volume_down(state, player)?;
}
(KeyCode::Char('r'), _) => {
state.show_refresh_confirm = true;
@ -689,7 +948,7 @@ async fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<
Ok(())
}
fn handle_mouse_event(state: &mut AppState, mouse: MouseEvent, title_bar_area: ratatui::layout::Rect, file_panel_area: ratatui::layout::Rect, playlist_area: ratatui::layout::Rect, player: &mut player::Player) -> Result<()> {
fn handle_mouse_event(state: &mut AppState, mouse: MouseEvent, title_bar_area: ratatui::layout::Rect, file_panel_area: ratatui::layout::Rect, playlist_area: ratatui::layout::Rect, player: &mut player::Player, skip_position_update: &mut bool) -> Result<()> {
use crossterm::event::MouseButton;
use crate::state::ContextMenuType;
@ -702,7 +961,7 @@ fn handle_mouse_event(state: &mut AppState, mouse: MouseEvent, title_bar_area: r
let items = match menu.menu_type {
ContextMenuType::FilePanel => 2,
ContextMenuType::Playlist => 2,
ContextMenuType::TitleBar => 4,
ContextMenuType::TitleBar => 3,
};
let popup_width = 13;
let popup_height = items as u16 + 2; // +2 for borders
@ -758,63 +1017,7 @@ fn handle_mouse_event(state: &mut AppState, mouse: MouseEvent, title_bar_area: r
let menu_type = menu.menu_type;
let selected = relative_y;
state.context_menu = None;
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)");
}
_ => {}
}
}
}
handle_context_menu_action(menu_type, selected, state, player, skip_position_update)?;
}
return Ok(());
} else {
@ -837,10 +1040,7 @@ fn handle_mouse_event(state: &mut AppState, mouse: MouseEvent, title_bar_area: r
&& y < title_bar_area.y + title_bar_area.height
{
// Scroll on title bar = decrease volume
let new_volume = (state.volume - 5).max(0);
state.volume = new_volume;
player.set_volume(new_volume)?;
tracing::info!("Volume: {}%", new_volume);
action_volume_down(state, player)?;
} else if x >= playlist_area.x
&& x < playlist_area.x + playlist_area.width
&& y >= playlist_area.y
@ -863,10 +1063,7 @@ fn handle_mouse_event(state: &mut AppState, mouse: MouseEvent, title_bar_area: r
&& y < title_bar_area.y + title_bar_area.height
{
// Scroll on title bar = increase volume
let new_volume = (state.volume + 5).min(100);
state.volume = new_volume;
player.set_volume(new_volume)?;
tracing::info!("Volume: {}%", new_volume);
action_volume_up(state, player)?;
} else if x >= playlist_area.x
&& x < playlist_area.x + playlist_area.width
&& y >= playlist_area.y
@ -904,15 +1101,55 @@ fn handle_mouse_event(state: &mut AppState, mouse: MouseEvent, title_bar_area: r
_ => {}
}
}
// Check if click is on the panel title border (to switch focus)
// The visible area is whichever one is not Rect::default()
else if (file_panel_area.width > 0 && y == file_panel_area.y) ||
(playlist_area.width > 0 && y == playlist_area.y) {
// Click is on the top border line where "Files | Playlist" is shown
// Get the actual visible area (not the default one)
let area = if file_panel_area.width > 0 { file_panel_area } else { playlist_area };
// Build title text to calculate positions
let playlist_text = if !state.playlist.is_empty() {
format!("Playlist [{}/{}]", state.playlist_index + 1, state.playlist.len())
} else {
"Playlist (empty)".to_string()
};
// Title is left-aligned by default in ratatui, starts after left border
// Border character takes 1 position, then title starts
let title_start_x = area.x + 1;
// Calculate where "Files" and "Playlist" text are
let files_start = title_start_x;
let files_end = files_start + 5; // "Files" is 5 chars
let separator_start = files_end;
let separator_end = separator_start + 3; // " | " is 3 chars
let playlist_start = separator_end;
let playlist_end = playlist_start + playlist_text.len() as u16;
match button {
MouseButton::Left => {
if x >= files_start && x < files_end {
// Clicked on "Files" - switch to file panel
state.focus_playlist = false;
} else if x >= playlist_start && x < playlist_end {
// Clicked on "Playlist" - switch to playlist
state.focus_playlist = true;
}
}
_ => {}
}
}
// Check if click is within file panel area
else if x >= file_panel_area.x
&& x < file_panel_area.x + file_panel_area.width
&& y >= file_panel_area.y
&& y < file_panel_area.y + file_panel_area.height
{
// Calculate which item was clicked (accounting for borders and scroll offset)
// Border takes 1 line at top, so subtract 1 from y position
let relative_y = (y - file_panel_area.y).saturating_sub(1);
// Calculate which item was clicked (accounting for scroll offset and outer border)
// Outer border takes 1 line at top, content starts at file_panel_area.y + 1
let relative_y = y.saturating_sub(file_panel_area.y + 1);
let clicked_index = state.scroll_offset + relative_y as usize;
// Set selection to clicked item if valid
@ -927,7 +1164,7 @@ fn handle_mouse_event(state: &mut AppState, mouse: MouseEvent, title_bar_area: r
let now = std::time::Instant::now();
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) {
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 {
false
};
@ -935,10 +1172,10 @@ fn handle_mouse_event(state: &mut AppState, mouse: MouseEvent, title_bar_area: r
if is_double_click {
// Double click = toggle folder or play file
if let Some(item) = state.get_selected_item() {
if item.node.is_dir {
if item.is_dir {
action_toggle_folder(state);
} else {
action_play_selection(state, player)?;
action_play_selection(state, player, skip_position_update)?;
}
}
// Reset click tracking after action
@ -971,8 +1208,9 @@ fn handle_mouse_event(state: &mut AppState, mouse: MouseEvent, title_bar_area: r
&& y >= playlist_area.y
&& y < playlist_area.y + playlist_area.height
{
// Calculate which track was clicked (accounting for borders)
let relative_y = (y - playlist_area.y).saturating_sub(1);
// Calculate which track was clicked (accounting for outer border)
// Outer border takes 1 line at top, content starts at playlist_area.y + 1
let relative_y = y.saturating_sub(playlist_area.y + 1);
let clicked_track = relative_y as usize;
// Add scroll offset to get actual index
@ -988,44 +1226,15 @@ fn handle_mouse_event(state: &mut AppState, mouse: MouseEvent, title_bar_area: r
let now = std::time::Instant::now();
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) {
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 {
false
};
if is_double_click {
// Double click = play the track
state.playlist_index = actual_track;
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();
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);
}
}
}
// Double click = play the track (preserve pause state)
state.selected_playlist_index = actual_track;
action_play_from_playlist(state, player, true, skip_position_update)?;
// Reset click tracking after action
state.last_click_time = None;
state.last_click_index = None;

View File

@ -13,8 +13,6 @@ pub struct Player {
socket: Option<UnixStream>,
position: f64,
duration: f64,
is_paused: bool,
is_idle: bool,
pub media_title: Option<String>,
pub artist: Option<String>,
pub album: Option<String>,
@ -37,6 +35,7 @@ impl Player {
.arg("--no-terminal")
.arg("--profile=fast")
.arg("--audio-display=no") // Don't show cover art for audio files
.arg("--audio-buffer=2") // Larger buffer for WSLg audio stability
.arg(format!("--input-ipc-server={}", socket_path.display()))
.stdin(Stdio::null())
.stdout(Stdio::null())
@ -46,17 +45,12 @@ impl Player {
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 {
process,
socket_path,
socket: None,
position: 0.0,
duration: 0.0,
is_paused: false,
is_idle: true,
media_title: None,
artist: None,
album: None,
@ -69,64 +63,34 @@ impl Player {
fn connect(&mut self) -> Result<()> {
if self.socket.is_none() {
// Try to connect, if it fails, respawn mpv
match UnixStream::connect(&self.socket_path) {
Ok(stream) => {
stream.set_nonblocking(true).ok();
// CRITICAL: Only try to connect if socket file exists
// If socket doesn't exist, MPV hasn't created it yet - fail fast
if !self.socket_path.exists() {
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 or has been killed
// Don't propagate error - just leave socket as None
tracing::debug!("Failed to connect to MPV socket: {}", e);
return Ok(());
}
};
// 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);
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(())
}
@ -146,7 +110,6 @@ impl Player {
if let Err(e) = socket.write_all(msg.as_bytes()) {
if e.kind() == std::io::ErrorKind::BrokenPipe {
self.socket = None;
self.is_idle = true;
// Clean up dead process
self.process.kill().ok();
return Ok(());
@ -158,59 +121,110 @@ impl Player {
Ok(())
}
fn get_property(&mut self, property: &str) -> Option<Value> {
self.connect().ok()?;
fn get_properties_batch(&mut self, properties: &[&str]) -> std::collections::HashMap<String, Value> {
let mut results = std::collections::HashMap::new();
let cmd = json!({
"command": ["get_property", property],
"request_id": 1
});
if let Some(ref mut socket) = self.socket {
let msg = format!("{}\n", cmd);
socket.write_all(msg.as_bytes()).ok()?;
// Try to read response (non-blocking)
socket.set_nonblocking(false).ok();
socket.set_read_timeout(Some(Duration::from_millis(100))).ok();
let mut reader = BufReader::new(socket.try_clone().ok()?);
let mut response = String::new();
reader.read_line(&mut response).ok()?;
socket.set_nonblocking(true).ok();
let parsed: Value = serde_json::from_str(&response).ok()?;
return parsed.get("data").cloned();
// Try to connect
if self.connect().is_err() {
return results;
}
None
if let Some(ref mut socket) = self.socket {
// Send all property requests at once with unique request_ids
for (idx, property) in properties.iter().enumerate() {
let cmd = json!({
"command": ["get_property", property],
"request_id": idx + 1
});
let msg = format!("{}\n", cmd);
if socket.write_all(msg.as_bytes()).is_err() {
return results;
}
}
// Read all responses
// IMPORTANT: Socket is non-blocking, need to set blocking mode for read
socket.set_nonblocking(false).ok();
let cloned_socket = match socket.try_clone() {
Ok(s) => s,
Err(_) => {
socket.set_nonblocking(true).ok();
return results;
}
};
cloned_socket.set_nonblocking(false).ok();
let mut reader = BufReader::new(cloned_socket);
// Read responses - stop if we timeout or hit an error
for _ in 0..properties.len() {
let mut response = String::new();
if reader.read_line(&mut response).is_err() {
// Timeout or error - stop reading
break;
}
// Parse response
if let Ok(parsed) = serde_json::from_str::<Value>(&response) {
// Check for success
if let Some(error) = parsed.get("error").and_then(|e| e.as_str()) {
if error == "success" {
// Get request_id to match with property
if let Some(req_id) = parsed.get("request_id").and_then(|id| id.as_i64()) {
let idx = (req_id - 1) as usize;
if idx < properties.len() {
if let Some(data) = parsed.get("data") {
results.insert(properties[idx].to_string(), data.clone());
}
}
}
}
}
}
}
// Restore non-blocking mode
socket.set_nonblocking(true).ok();
}
results
}
pub fn play(&mut self, path: &Path) -> Result<()> {
let path_str = path.to_string_lossy();
// Reset position/duration before loading new file to avoid showing stale values
self.position = 0.0;
self.duration = 0.0;
self.send_command("loadfile", &[json!(path_str), json!("replace")])?;
self.is_paused = false;
self.is_idle = false;
tracing::info!("Playing: {}", path_str);
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<()> {
self.send_command("set_property", &[json!("pause"), json!(true)])?;
self.is_paused = true;
Ok(())
}
pub fn resume(&mut self) -> Result<()> {
self.send_command("set_property", &[json!("pause"), json!(false)])?;
self.is_paused = false;
Ok(())
}
pub fn stop(&mut self) -> Result<()> {
self.send_command("stop", &[])?;
self.is_idle = true;
self.position = 0.0;
self.duration = 0.0;
Ok(())
@ -221,98 +235,73 @@ impl Player {
Ok(())
}
pub fn update_properties(&mut self) {
// Update position
if let Some(val) = self.get_property("time-pos") {
pub fn update_all_properties(&mut self) {
// Fetch ALL properties in one batch
let results = self.get_properties_batch(&[
"time-pos",
"duration",
"metadata/by-key/artist",
"metadata/by-key/ARTIST",
"metadata/by-key/album",
"metadata/by-key/ALBUM",
"metadata/by-key/title",
"metadata/by-key/TITLE",
"media-title",
"audio-codec-name",
"audio-bitrate",
"audio-params/samplerate",
"demuxer-cache-duration",
]);
// Position
if let Some(val) = results.get("time-pos") {
if let Some(pos) = val.as_f64() {
self.position = pos;
}
}
// Update duration
if let Some(val) = self.get_property("duration") {
// Duration
if let Some(val) = results.get("duration") {
if let Some(dur) = val.as_f64() {
self.duration = dur;
}
}
// Update pause state
if let Some(val) = self.get_property("pause") {
if let Some(paused) = val.as_bool() {
self.is_paused = paused;
}
}
// Artist - try lowercase first, then uppercase
self.artist = results.get("metadata/by-key/artist")
.and_then(|v| v.as_str().map(|s| s.to_string()))
.or_else(|| results.get("metadata/by-key/ARTIST")
.and_then(|v| v.as_str().map(|s| s.to_string())));
// Update idle state
if let Some(val) = self.get_property("idle-active") {
if let Some(idle) = val.as_bool() {
self.is_idle = idle;
}
}
}
// Album - try lowercase first, then uppercase
self.album = results.get("metadata/by-key/album")
.and_then(|v| v.as_str().map(|s| s.to_string()))
.or_else(|| results.get("metadata/by-key/ALBUM")
.and_then(|v| v.as_str().map(|s| s.to_string())));
pub fn update_metadata(&mut self) {
// Try to get artist directly
if let Some(val) = self.get_property("metadata/by-key/artist") {
self.artist = val.as_str().map(|s| s.to_string());
}
// Fallback to ARTIST (uppercase)
if self.artist.is_none() {
if let Some(val) = self.get_property("metadata/by-key/ARTIST") {
self.artist = val.as_str().map(|s| s.to_string());
}
}
// Title - try lowercase, then uppercase, then media-title
self.media_title = results.get("metadata/by-key/title")
.and_then(|v| v.as_str().map(|s| s.to_string()))
.or_else(|| results.get("metadata/by-key/TITLE")
.and_then(|v| v.as_str().map(|s| s.to_string())))
.or_else(|| results.get("media-title")
.and_then(|v| v.as_str().map(|s| s.to_string())));
// Try to get album directly
if let Some(val) = self.get_property("metadata/by-key/album") {
self.album = val.as_str().map(|s| s.to_string());
}
// Fallback to ALBUM (uppercase)
if self.album.is_none() {
if let Some(val) = self.get_property("metadata/by-key/ALBUM") {
self.album = val.as_str().map(|s| s.to_string());
}
}
// Audio codec
self.audio_codec = results.get("audio-codec-name")
.and_then(|v| v.as_str().map(|s| s.to_string()));
// Try to get title directly
if let Some(val) = self.get_property("metadata/by-key/title") {
self.media_title = val.as_str().map(|s| s.to_string());
}
// Fallback to TITLE (uppercase)
if self.media_title.is_none() {
if let Some(val) = self.get_property("metadata/by-key/TITLE") {
self.media_title = val.as_str().map(|s| s.to_string());
}
}
// Audio bitrate (convert from bps to kbps)
self.audio_bitrate = results.get("audio-bitrate")
.and_then(|v| v.as_f64().map(|b| b / 1000.0));
// Final fallback to media-title if metadata doesn't have title
if self.media_title.is_none() {
if let Some(val) = self.get_property("media-title") {
self.media_title = val.as_str().map(|s| s.to_string());
}
}
// Sample rate
self.sample_rate = results.get("audio-params/samplerate")
.and_then(|v| v.as_i64());
// Update audio codec
if let Some(val) = self.get_property("audio-codec-name") {
self.audio_codec = val.as_str().map(|s| s.to_string());
}
// Update audio bitrate (convert from bps to kbps)
if let Some(val) = self.get_property("audio-bitrate") {
self.audio_bitrate = val.as_f64().map(|b| b / 1000.0);
}
// Update sample rate
if let Some(val) = self.get_property("audio-params/samplerate") {
self.sample_rate = val.as_i64();
}
// Update cache duration (how many seconds are buffered ahead)
if let Some(val) = self.get_property("demuxer-cache-duration") {
self.cache_duration = val.as_f64();
} else {
self.cache_duration = None;
}
// Cache duration
self.cache_duration = results.get("demuxer-cache-duration")
.and_then(|v| v.as_f64());
}
pub fn get_position(&self) -> Option<f64> {
@ -323,8 +312,22 @@ impl Player {
Some(self.duration)
}
pub fn is_idle(&self) -> bool {
self.is_idle
pub fn get_player_state(&mut self) -> Option<crate::state::PlayerState> {
use crate::state::PlayerState;
// Batch fetch both properties at once
let results = self.get_properties_batch(&["idle-active", "pause"]);
let is_idle = results.get("idle-active").and_then(|v| v.as_bool())?;
let is_paused = results.get("pause").and_then(|v| v.as_bool())?;
Some(if is_idle {
PlayerState::Stopped
} else if is_paused {
PlayerState::Paused
} else {
PlayerState::Playing
})
}
pub fn is_process_alive(&mut self) -> bool {
@ -333,14 +336,12 @@ impl Player {
Ok(Some(_)) => {
// Process has exited - clean up socket
self.socket = None;
self.is_idle = true;
false
}
Ok(None) => true, // Process is still running
Err(_) => {
// Error checking, assume dead and clean up
self.socket = None;
self.is_idle = true;
false
}
}

View File

@ -1,6 +1,7 @@
use crate::cache::{Cache, FileMetadata, FileTreeNode};
use anyhow::Result;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use walkdir::WalkDir;
const AUDIO_EXTENSIONS: &[&str] = &["mp3", "flac", "wav", "ogg", "m4a", "aac", "opus", "wma"];
@ -33,7 +34,7 @@ pub fn is_video_file(path: &Path) -> bool {
}
}
pub fn scan_directory(root_path: &Path) -> Result<FileTreeNode> {
fn scan_directory_internal(root_path: &Path, counter: &AtomicUsize) -> Result<FileTreeNode> {
let name = root_path
.file_name()
.unwrap_or_default()
@ -62,10 +63,16 @@ pub fn scan_directory(root_path: &Path) -> Result<FileTreeNode> {
if entry.file_type().is_dir() {
// Recursively scan subdirectories
if let Ok(child_node) = scan_directory(path) {
if let Ok(child_node) = scan_directory_internal(path, counter) {
// Only add directory if it contains media files or non-empty subdirectories
if !child_node.children.is_empty() {
node.children.push(child_node);
}
}
} else if is_media_file(path) {
// Increment counter for each media file found
counter.fetch_add(1, Ordering::Relaxed);
// Add media file
let file_name = path
.file_name()
@ -78,9 +85,6 @@ pub fn scan_directory(root_path: &Path) -> Result<FileTreeNode> {
let metadata = FileMetadata {
path: path.to_path_buf(),
size,
duration: None, // Will be populated by MPV later
codec: None,
hash: None,
is_video: is_video_file(path),
is_audio: is_audio_file(path),
};
@ -100,13 +104,13 @@ pub fn scan_directory(root_path: &Path) -> Result<FileTreeNode> {
Ok(node)
}
pub fn scan_paths(paths: &[PathBuf]) -> Result<Cache> {
pub fn scan_paths(paths: &[PathBuf], counter: &AtomicUsize) -> Result<Cache> {
let mut cache = Cache::new();
for path in paths {
if path.exists() {
tracing::info!("Scanning path: {:?}", path);
let tree_node = scan_directory(path)?;
let tree_node = scan_directory_internal(path, counter)?;
// Collect all metadata from the tree
collect_metadata(&tree_node, &mut cache);

View File

@ -1,9 +1,29 @@
use crate::cache::{Cache, FileTreeNode};
use crate::config::Config;
use std::collections::HashSet;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
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)]
pub enum PlayerState {
Stopped,
@ -39,7 +59,6 @@ pub struct AppState {
pub scroll_offset: usize,
pub file_panel_visible_height: usize,
pub playlist_visible_height: usize,
pub player_state: PlayerState,
pub current_file: Option<PathBuf>,
pub current_position: f64,
pub current_duration: f64,
@ -55,14 +74,10 @@ pub struct AppState {
pub is_refreshing: bool,
pub search_mode: bool,
pub search_query: String,
pub search_matches: Vec<PathBuf>,
pub search_match_index: usize,
pub tab_search_results: Vec<PathBuf>,
pub tab_search_index: usize,
pub playlist_search_matches: Vec<usize>,
pub playlist_search_match_index: usize,
pub playlist_tab_search_results: Vec<usize>,
pub playlist_tab_search_index: usize,
pub search_results: Vec<PathBuf>,
pub search_result_index: usize,
pub playlist_search_results: Vec<usize>,
pub playlist_search_result_index: usize,
pub visual_mode: bool,
pub visual_anchor: usize,
pub saved_expanded_dirs: HashSet<PathBuf>,
@ -73,11 +88,15 @@ pub struct AppState {
pub last_click_is_playlist: bool,
pub context_menu: Option<ContextMenu>,
pub play_mode: PlayMode,
pub last_error: Option<String>,
pub refresh_file_count: usize,
}
#[derive(Debug, Clone)]
pub struct FlattenedItem {
pub node: FileTreeNode,
pub path: PathBuf,
pub name: String,
pub is_dir: bool,
pub depth: usize,
}
@ -95,7 +114,6 @@ impl AppState {
scroll_offset: 0,
file_panel_visible_height: 20,
playlist_visible_height: 20,
player_state: PlayerState::Stopped,
current_file: None,
current_position: 0.0,
current_duration: 0.0,
@ -111,14 +129,10 @@ impl AppState {
is_refreshing: false,
search_mode: false,
search_query: String::new(),
search_matches: Vec::new(),
search_match_index: 0,
tab_search_results: Vec::new(),
tab_search_index: 0,
playlist_search_matches: Vec::new(),
playlist_search_match_index: 0,
playlist_tab_search_results: Vec::new(),
playlist_tab_search_index: 0,
search_results: Vec::new(),
search_result_index: 0,
playlist_search_results: Vec::new(),
playlist_search_result_index: 0,
visual_mode: false,
visual_anchor: 0,
saved_expanded_dirs: HashSet::new(),
@ -129,6 +143,8 @@ impl AppState {
last_click_is_playlist: false,
context_menu: None,
play_mode: PlayMode::Normal,
last_error: None,
refresh_file_count: 0,
}
}
@ -150,18 +166,11 @@ impl AppState {
if self.selected_index < self.flattened_items.len().saturating_sub(1) {
self.selected_index += 1;
// Account for "... X more below" indicator which takes one line
let visible_end = self.scroll_offset + self.file_panel_visible_height;
let items_below = if visible_end < self.flattened_items.len() {
self.flattened_items.len() - visible_end
} else {
0
};
let effective_height = if items_below > 0 {
self.file_panel_visible_height.saturating_sub(1)
} else {
self.file_panel_visible_height
};
let effective_height = calculate_effective_height(
self.scroll_offset,
self.file_panel_visible_height,
self.flattened_items.len()
);
// Scroll down when selection reaches bottom
if self.selected_index >= self.scroll_offset + effective_height {
@ -229,18 +238,11 @@ impl AppState {
if self.selected_playlist_index < self.playlist.len().saturating_sub(1) {
self.selected_playlist_index += 1;
// Account for "... X more below" indicator which takes one line
let visible_end = self.playlist_scroll_offset + visible_height;
let items_below = if visible_end < self.playlist.len() {
self.playlist.len() - visible_end
} else {
0
};
let effective_height = if items_below > 0 {
visible_height.saturating_sub(1)
} else {
visible_height
};
let effective_height = calculate_effective_height(
self.playlist_scroll_offset,
visible_height,
self.playlist.len()
);
// Scroll down when selection reaches bottom
if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height {
@ -276,18 +278,11 @@ impl AppState {
let new_index = (self.selected_playlist_index + half_page).min(self.playlist.len().saturating_sub(1));
self.selected_playlist_index = new_index;
// Account for "... X more below" indicator which takes one line
let visible_end = self.playlist_scroll_offset + self.playlist_visible_height;
let items_below = if visible_end < self.playlist.len() {
self.playlist.len() - visible_end
} else {
0
};
let effective_height = if items_below > 0 {
self.playlist_visible_height.saturating_sub(1)
} else {
self.playlist_visible_height
};
let effective_height = calculate_effective_height(
self.playlist_scroll_offset,
self.playlist_visible_height,
self.playlist.len()
);
// Adjust scroll if needed
if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height {
@ -312,18 +307,11 @@ impl AppState {
let new_index = (self.selected_index + half_page).min(self.flattened_items.len().saturating_sub(1));
self.selected_index = new_index;
// Account for "... X more below" indicator which takes one line
let visible_end = self.scroll_offset + self.file_panel_visible_height;
let items_below = if visible_end < self.flattened_items.len() {
self.flattened_items.len() - visible_end
} else {
0
};
let effective_height = if items_below > 0 {
self.file_panel_visible_height.saturating_sub(1)
} else {
self.file_panel_visible_height
};
let effective_height = calculate_effective_height(
self.scroll_offset,
self.file_panel_visible_height,
self.flattened_items.len()
);
// Adjust scroll if needed
if self.selected_index >= self.scroll_offset + effective_height {
@ -346,11 +334,29 @@ impl AppState {
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) {
let item = self.get_selected_item().cloned();
if let Some(item) = item {
if item.node.is_dir {
let path = item.node.path.clone();
if item.is_dir {
let path = item.path.clone();
let was_expanded = self.expanded_dirs.contains(&path);
if was_expanded {
@ -358,7 +364,7 @@ impl AppState {
self.expanded_dirs.remove(&path);
self.rebuild_flattened_items();
// 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;
}
} else {
@ -368,19 +374,19 @@ impl AppState {
self.expanded_dirs.remove(&parent_buf);
self.rebuild_flattened_items();
// 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;
}
}
}
} else {
// 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();
self.expanded_dirs.remove(&parent_buf);
self.rebuild_flattened_items();
// 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;
}
}
@ -390,8 +396,8 @@ impl AppState {
pub fn expand_selected(&mut self) {
if let Some(item) = self.get_selected_item() {
if item.node.is_dir {
let path = item.node.path.clone();
if item.is_dir {
let path = item.path.clone();
self.expanded_dirs.insert(path);
self.rebuild_flattened_items();
}
@ -411,8 +417,8 @@ impl AppState {
self.marked_files.clear();
// Mark current file
if let Some(item) = self.get_selected_item() {
if !item.node.is_dir {
self.marked_files.insert(item.node.path.clone());
if !item.is_dir {
self.marked_files.insert(item.path.clone());
}
}
}
@ -428,8 +434,8 @@ impl AppState {
for i in start..=end {
if let Some(item) = self.flattened_items.get(i) {
if !item.node.is_dir {
self.marked_files.insert(item.node.path.clone());
if !item.is_dir {
self.marked_files.insert(item.path.clone());
}
}
}
@ -438,7 +444,6 @@ impl AppState {
pub fn clear_playlist(&mut self) {
self.playlist.clear();
self.playlist_index = 0;
self.player_state = PlayerState::Stopped;
self.current_file = None;
}
@ -450,15 +455,19 @@ impl AppState {
files.sort();
self.playlist.extend(files);
} else if let Some(item) = self.get_selected_item() {
let node = item.node.clone();
if node.is_dir {
let path = item.path.clone();
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)
let mut files = collect_files_from_node(&node);
let mut files = collect_files_from_node(node);
files.sort();
self.playlist.extend(files);
}
} else {
// Add single file (allow duplicates)
self.playlist.push(node.path.clone());
self.playlist.push(path);
}
}
}
@ -474,44 +483,41 @@ impl AppState {
self.selected_playlist_index = 0;
if let Some(first) = self.playlist.first() {
self.current_file = Some(first.clone());
self.player_state = PlayerState::Playing;
} else {
// Empty playlist
self.current_file = None;
self.player_state = PlayerState::Stopped;
}
} else if let Some(item) = self.get_selected_item() {
let node = item.node.clone();
if node.is_dir {
// Play all files in directory
self.playlist = collect_files_from_node(&node);
let path = item.path.clone();
let is_dir = item.is_dir;
if is_dir {
// 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_scroll_offset = 0;
self.selected_playlist_index = 0;
if let Some(first) = self.playlist.first() {
self.current_file = Some(first.clone());
self.player_state = PlayerState::Playing;
} else {
// Empty directory
self.current_file = None;
self.player_state = PlayerState::Stopped;
}
}
} else {
// Play single file
let path = node.path.clone();
self.playlist = vec![path.clone()];
self.playlist_index = 0;
self.playlist_scroll_offset = 0;
self.selected_playlist_index = 0;
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() {
return;
return false;
}
match self.play_mode {
@ -521,18 +527,17 @@ impl AppState {
self.playlist_index += 1;
if self.playlist_index < self.playlist.len() {
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 => {
// Loop back to beginning when reaching end
self.playlist_index = (self.playlist_index + 1) % self.playlist.len();
self.current_file = Some(self.playlist[self.playlist_index].clone());
self.player_state = PlayerState::Playing;
true // Should continue playing
}
}
}
@ -578,8 +583,84 @@ impl AppState {
}
pub fn refresh_flattened_items(&mut self) {
// Keep current expanded state after rescan
// Clean up expanded_dirs - remove paths that no longer exist in new cache
self.cleanup_expanded_dirs();
// Rebuild view with cleaned expanded state
self.rebuild_flattened_items();
// Clean up playlist - remove files that no longer exist in cache
self.cleanup_playlist();
}
fn cleanup_expanded_dirs(&mut self) {
// Build a set of valid directory paths from the cache
let mut valid_dirs = std::collections::HashSet::new();
fn collect_dirs(node: &crate::cache::FileTreeNode, dirs: &mut std::collections::HashSet<std::path::PathBuf>) {
if node.is_dir {
dirs.insert(node.path.clone());
}
for child in &node.children {
collect_dirs(child, dirs);
}
}
for root in &self.cache.file_tree {
collect_dirs(root, &mut valid_dirs);
}
// Remove invalid paths from expanded_dirs
let original_len = self.expanded_dirs.len();
self.expanded_dirs.retain(|path| valid_dirs.contains(path));
if self.expanded_dirs.len() < original_len {
tracing::info!("Cleaned up expanded_dirs: removed {} invalid paths", original_len - self.expanded_dirs.len());
}
}
fn cleanup_playlist(&mut self) {
// Build a set of valid paths from the cache for fast lookup
let mut valid_paths = std::collections::HashSet::new();
fn collect_paths(node: &crate::cache::FileTreeNode, paths: &mut std::collections::HashSet<std::path::PathBuf>) {
if !node.is_dir {
paths.insert(node.path.clone());
}
for child in &node.children {
collect_paths(child, paths);
}
}
for root in &self.cache.file_tree {
collect_paths(root, &mut valid_paths);
}
// Check if current file is invalid
let current_file_invalid = if let Some(ref current) = self.current_file {
!valid_paths.contains(current)
} else {
false
};
if current_file_invalid {
self.current_file = None;
tracing::info!("Current playing file was deleted, cleared current_file");
}
// Remove files from playlist that don't exist in cache
let original_len = self.playlist.len();
self.playlist.retain(|path| valid_paths.contains(path));
// Adjust indices if playlist was modified
if self.playlist.len() < original_len {
// Ensure playlist_index is valid
if self.playlist_index >= self.playlist.len() && !self.playlist.is_empty() {
self.playlist_index = self.playlist.len() - 1;
}
// Ensure selected_playlist_index is valid
if self.selected_playlist_index >= self.playlist.len() && !self.playlist.is_empty() {
self.selected_playlist_index = self.playlist.len() - 1;
}
tracing::info!("Cleaned up playlist: removed {} deleted files", original_len - self.playlist.len());
}
}
pub fn rebuild_flattened_items(&mut self) {
@ -595,16 +676,12 @@ impl AppState {
if self.focus_playlist {
// Clear playlist search state
self.playlist_search_matches.clear();
self.playlist_search_match_index = 0;
self.playlist_tab_search_results.clear();
self.playlist_tab_search_index = 0;
self.playlist_search_results.clear();
self.playlist_search_result_index = 0;
} else {
// Clear file search state
self.search_matches.clear();
self.search_match_index = 0;
self.tab_search_results.clear();
self.tab_search_index = 0;
self.search_results.clear();
self.search_result_index = 0;
// Save current folder state
self.saved_expanded_dirs = self.expanded_dirs.clone();
}
@ -612,15 +689,16 @@ impl AppState {
pub fn exit_search_mode(&mut self) {
self.search_mode = false;
self.search_query.clear();
if self.focus_playlist {
// Clear playlist search state
self.playlist_tab_search_results.clear();
self.playlist_tab_search_index = 0;
self.playlist_search_results.clear();
self.playlist_search_result_index = 0;
} else {
// Clear file search state
self.tab_search_results.clear();
self.tab_search_index = 0;
self.search_results.clear();
self.search_result_index = 0;
// Restore folder state from before search
self.expanded_dirs = self.saved_expanded_dirs.clone();
self.rebuild_flattened_items();
@ -639,8 +717,8 @@ impl AppState {
fn perform_incremental_search(&mut self) {
if self.search_query.is_empty() {
self.tab_search_results.clear();
self.tab_search_index = 0;
self.search_results.clear();
self.search_result_index = 0;
return;
}
@ -649,8 +727,8 @@ impl AppState {
collect_matching_paths(&self.cache.file_tree, &self.search_query, &mut matching_paths_with_scores);
if matching_paths_with_scores.is_empty() {
self.tab_search_results.clear();
self.tab_search_index = 0;
self.search_results.clear();
self.search_result_index = 0;
return;
}
@ -664,14 +742,30 @@ impl AppState {
// Sort by score (highest first), then by original index to prefer first occurrence
indexed_matches.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.2.cmp(&b.2)));
// Store all matches for tab completion
self.tab_search_results = indexed_matches.iter().map(|(path, _, _)| path.clone()).collect();
self.tab_search_index = 0;
// Store all matches
self.search_results = indexed_matches.iter().map(|(path, _, _)| path.clone()).collect();
self.search_result_index = 0;
// Close all folders and expand only for the best match
// Jump to first match
self.jump_to_current_search_result();
}
fn jump_to_current_search_result(&mut self) {
if self.search_results.is_empty() {
return;
}
let current_match = self.search_results[self.search_result_index].clone();
// Check if we need to expand folders for this match
let needs_expand = current_match.ancestors()
.skip(1) // Skip the file itself
.any(|p| !self.expanded_dirs.contains(p));
if needs_expand {
// Close all folders and expand only for the current match
self.expanded_dirs.clear();
let best_match = self.tab_search_results[0].clone();
let mut parent = best_match.parent();
let mut parent = current_match.parent();
while let Some(p) = parent {
self.expanded_dirs.insert(p.to_path_buf());
parent = p.parent();
@ -679,24 +773,18 @@ impl AppState {
// Rebuild flattened items
self.rebuild_flattened_items();
}
// Find the best match in the flattened list and jump to it
if let Some(idx) = self.flattened_items.iter().position(|item| item.node.path == best_match) {
// Find the match in the flattened list and jump to it
if let Some(idx) = self.flattened_items.iter().position(|item| item.path == current_match) {
self.selected_index = idx;
// Scroll to show the match
// Account for "... X more below" indicator which takes one line
let visible_end = self.scroll_offset + self.file_panel_visible_height;
let items_below = if visible_end < self.flattened_items.len() {
self.flattened_items.len() - visible_end
} else {
0
};
let effective_height = if items_below > 0 {
self.file_panel_visible_height.saturating_sub(1)
} else {
self.file_panel_visible_height
};
let effective_height = calculate_effective_height(
self.scroll_offset,
self.file_panel_visible_height,
self.flattened_items.len()
);
if self.selected_index < self.scroll_offset {
self.scroll_offset = self.selected_index;
@ -706,247 +794,26 @@ impl AppState {
}
}
pub fn next_search_result(&mut self) {
if !self.search_results.is_empty() {
self.search_result_index = (self.search_result_index + 1) % self.search_results.len();
self.jump_to_current_search_result();
}
}
pub fn prev_search_result(&mut self) {
if !self.search_results.is_empty() {
self.search_result_index = if self.search_result_index == 0 {
self.search_results.len() - 1
} else {
self.search_result_index - 1
};
self.jump_to_current_search_result();
}
}
pub fn execute_search(&mut self) {
if self.search_query.is_empty() {
self.search_mode = false;
return;
}
// Collect all matching paths with scores and preserve order
let mut matching_paths_with_scores = Vec::new();
collect_matching_paths(&self.cache.file_tree, &self.search_query, &mut matching_paths_with_scores);
if matching_paths_with_scores.is_empty() {
self.search_mode = false;
return;
}
// Add index to preserve original tree order when scores are equal
let mut indexed_matches: Vec<(PathBuf, i32, usize)> = matching_paths_with_scores
.into_iter()
.enumerate()
.map(|(idx, (path, score))| (path, score, idx))
.collect();
// Sort by score (highest first), then by original index to prefer first occurrence
indexed_matches.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.2.cmp(&b.2)));
let matching_paths_with_scores: Vec<(PathBuf, i32)> = indexed_matches
.into_iter()
.map(|(path, score, _)| (path, score))
.collect();
let matching_paths: Vec<PathBuf> = matching_paths_with_scores.iter().map(|(path, _)| path.clone()).collect();
// Store matching paths (not indices, as they change when folders collapse)
self.search_matches = matching_paths;
if !self.search_matches.is_empty() {
self.search_match_index = 0;
// Close all folders and expand only for first match
self.expanded_dirs.clear();
let first_match = self.search_matches[0].clone();
let mut parent = first_match.parent();
while let Some(p) = parent {
self.expanded_dirs.insert(p.to_path_buf());
parent = p.parent();
}
// Rebuild flattened items
self.rebuild_flattened_items();
// Find first match in flattened list
if let Some(idx) = self.flattened_items.iter().position(|item| item.node.path == first_match) {
self.selected_index = idx;
}
}
self.search_mode = false;
}
pub fn next_search_match(&mut self) {
if !self.search_matches.is_empty() {
self.search_match_index = (self.search_match_index + 1) % self.search_matches.len();
let target_path = self.search_matches[self.search_match_index].clone();
// Close all folders and expand only for this match
self.expanded_dirs.clear();
let mut parent = target_path.parent();
while let Some(p) = parent {
self.expanded_dirs.insert(p.to_path_buf());
parent = p.parent();
}
// Rebuild flattened items
self.rebuild_flattened_items();
// Find the path in current flattened items
if let Some(idx) = self.flattened_items.iter().position(|item| item.node.path == target_path) {
self.selected_index = idx;
// Scroll to show the match
// Account for "... X more below" indicator which takes one line
let visible_end = self.scroll_offset + self.file_panel_visible_height;
let items_below = if visible_end < self.flattened_items.len() {
self.flattened_items.len() - visible_end
} else {
0
};
let effective_height = if items_below > 0 {
self.file_panel_visible_height.saturating_sub(1)
} else {
self.file_panel_visible_height
};
if self.selected_index < self.scroll_offset {
self.scroll_offset = self.selected_index;
} else if self.selected_index >= self.scroll_offset + effective_height {
self.scroll_offset = self.selected_index - effective_height + 1;
}
}
}
}
pub fn prev_search_match(&mut self) {
if !self.search_matches.is_empty() {
if self.search_match_index == 0 {
self.search_match_index = self.search_matches.len() - 1;
} else {
self.search_match_index -= 1;
}
let target_path = self.search_matches[self.search_match_index].clone();
// Close all folders and expand only for this match
self.expanded_dirs.clear();
let mut parent = target_path.parent();
while let Some(p) = parent {
self.expanded_dirs.insert(p.to_path_buf());
parent = p.parent();
}
// Rebuild flattened items
self.rebuild_flattened_items();
// Find the path in current flattened items
if let Some(idx) = self.flattened_items.iter().position(|item| item.node.path == target_path) {
self.selected_index = idx;
// Scroll to show the match
// Account for "... X more below" indicator which takes one line
let visible_end = self.scroll_offset + self.file_panel_visible_height;
let items_below = if visible_end < self.flattened_items.len() {
self.flattened_items.len() - visible_end
} else {
0
};
let effective_height = if items_below > 0 {
self.file_panel_visible_height.saturating_sub(1)
} else {
self.file_panel_visible_height
};
if self.selected_index < self.scroll_offset {
self.scroll_offset = self.selected_index;
} else if self.selected_index >= self.scroll_offset + effective_height {
self.scroll_offset = self.selected_index - effective_height + 1;
}
}
}
}
pub fn tab_search_next(&mut self) {
if self.tab_search_results.is_empty() {
return;
}
// Cycle to next match
self.tab_search_index = (self.tab_search_index + 1) % self.tab_search_results.len();
let next_match = self.tab_search_results[self.tab_search_index].clone();
// Close all folders and expand only for this match
self.expanded_dirs.clear();
let mut parent = next_match.parent();
while let Some(p) = parent {
self.expanded_dirs.insert(p.to_path_buf());
parent = p.parent();
}
// Rebuild flattened items
self.rebuild_flattened_items();
// Find and select the match
if let Some(idx) = self.flattened_items.iter().position(|item| item.node.path == next_match) {
self.selected_index = idx;
// Scroll to show the match
// Account for "... X more below" indicator which takes one line
let visible_end = self.scroll_offset + self.file_panel_visible_height;
let items_below = if visible_end < self.flattened_items.len() {
self.flattened_items.len() - visible_end
} else {
0
};
let effective_height = if items_below > 0 {
self.file_panel_visible_height.saturating_sub(1)
} else {
self.file_panel_visible_height
};
if self.selected_index < self.scroll_offset {
self.scroll_offset = self.selected_index;
} else if self.selected_index >= self.scroll_offset + effective_height {
self.scroll_offset = self.selected_index - effective_height + 1;
}
}
}
pub fn tab_search_prev(&mut self) {
if self.tab_search_results.is_empty() {
return;
}
// Cycle to previous match
if self.tab_search_index == 0 {
self.tab_search_index = self.tab_search_results.len() - 1;
} else {
self.tab_search_index -= 1;
}
let prev_match = self.tab_search_results[self.tab_search_index].clone();
// Close all folders and expand only for this match
self.expanded_dirs.clear();
let mut parent = prev_match.parent();
while let Some(p) = parent {
self.expanded_dirs.insert(p.to_path_buf());
parent = p.parent();
}
// Rebuild flattened items
self.rebuild_flattened_items();
// Find and select the match
if let Some(idx) = self.flattened_items.iter().position(|item| item.node.path == prev_match) {
self.selected_index = idx;
// Scroll to show the match
// Account for "... X more below" indicator which takes one line
let visible_end = self.scroll_offset + self.file_panel_visible_height;
let items_below = if visible_end < self.flattened_items.len() {
self.flattened_items.len() - visible_end
} else {
0
};
let effective_height = if items_below > 0 {
self.file_panel_visible_height.saturating_sub(1)
} else {
self.file_panel_visible_height
};
if self.selected_index < self.scroll_offset {
self.scroll_offset = self.selected_index;
} else if self.selected_index >= self.scroll_offset + effective_height {
self.scroll_offset = self.selected_index - effective_height + 1;
}
}
self.exit_search_mode();
}
pub fn append_playlist_search_char(&mut self, c: char) {
@ -961,8 +828,8 @@ impl AppState {
fn perform_playlist_incremental_search(&mut self) {
if self.search_query.is_empty() {
self.playlist_tab_search_results.clear();
self.playlist_tab_search_index = 0;
self.playlist_search_results.clear();
self.playlist_search_result_index = 0;
return;
}
@ -979,66 +846,36 @@ impl AppState {
.collect();
if matching_indices_with_scores.is_empty() {
self.playlist_tab_search_results.clear();
self.playlist_tab_search_index = 0;
self.playlist_search_results.clear();
self.playlist_search_result_index = 0;
return;
}
// Sort by score (highest first)
matching_indices_with_scores.sort_by(|a, b| b.1.cmp(&a.1));
// Store all matches for tab completion
self.playlist_tab_search_results = matching_indices_with_scores.iter().map(|(idx, _)| *idx).collect();
self.playlist_tab_search_index = 0;
// Store all matches
self.playlist_search_results = matching_indices_with_scores.iter().map(|(idx, _)| *idx).collect();
self.playlist_search_result_index = 0;
// Jump to best match
let best_match_idx = self.playlist_tab_search_results[0];
self.selected_playlist_index = best_match_idx;
// Scroll to show the match
// Account for "... X more below" indicator which takes one line
let visible_end = self.playlist_scroll_offset + self.playlist_visible_height;
let items_below = if visible_end < self.playlist.len() {
self.playlist.len() - visible_end
} else {
0
};
let effective_height = if items_below > 0 {
self.playlist_visible_height.saturating_sub(1)
} else {
self.playlist_visible_height
};
if self.selected_playlist_index < self.playlist_scroll_offset {
self.playlist_scroll_offset = self.selected_playlist_index;
} else if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height {
self.playlist_scroll_offset = self.selected_playlist_index - effective_height + 1;
}
// Jump to first match
self.jump_to_current_playlist_search_result();
}
pub fn playlist_tab_search_next(&mut self) {
if self.playlist_tab_search_results.is_empty() {
fn jump_to_current_playlist_search_result(&mut self) {
if self.playlist_search_results.is_empty() {
return;
}
// Cycle to next match
self.playlist_tab_search_index = (self.playlist_tab_search_index + 1) % self.playlist_tab_search_results.len();
let next_match_idx = self.playlist_tab_search_results[self.playlist_tab_search_index];
self.selected_playlist_index = next_match_idx;
let match_idx = self.playlist_search_results[self.playlist_search_result_index];
self.selected_playlist_index = match_idx;
// Scroll to show the match
// Account for "... X more below" indicator which takes one line
let visible_end = self.playlist_scroll_offset + self.playlist_visible_height;
let items_below = if visible_end < self.playlist.len() {
self.playlist.len() - visible_end
} else {
0
};
let effective_height = if items_below > 0 {
self.playlist_visible_height.saturating_sub(1)
} else {
self.playlist_visible_height
};
let effective_height = calculate_effective_height(
self.playlist_scroll_offset,
self.playlist_visible_height,
self.playlist.len()
);
if self.selected_playlist_index < self.playlist_scroll_offset {
self.playlist_scroll_offset = self.selected_playlist_index;
@ -1047,157 +884,26 @@ impl AppState {
}
}
pub fn playlist_tab_search_prev(&mut self) {
if self.playlist_tab_search_results.is_empty() {
return;
pub fn next_playlist_search_result(&mut self) {
if !self.playlist_search_results.is_empty() {
self.playlist_search_result_index = (self.playlist_search_result_index + 1) % self.playlist_search_results.len();
self.jump_to_current_playlist_search_result();
}
}
// Cycle to previous match
if self.playlist_tab_search_index == 0 {
self.playlist_tab_search_index = self.playlist_tab_search_results.len() - 1;
pub fn prev_playlist_search_result(&mut self) {
if !self.playlist_search_results.is_empty() {
self.playlist_search_result_index = if self.playlist_search_result_index == 0 {
self.playlist_search_results.len() - 1
} else {
self.playlist_tab_search_index -= 1;
}
let prev_match_idx = self.playlist_tab_search_results[self.playlist_tab_search_index];
self.selected_playlist_index = prev_match_idx;
// Scroll to show the match
// Account for "... X more below" indicator which takes one line
let visible_end = self.playlist_scroll_offset + self.playlist_visible_height;
let items_below = if visible_end < self.playlist.len() {
self.playlist.len() - visible_end
} else {
0
self.playlist_search_result_index - 1
};
let effective_height = if items_below > 0 {
self.playlist_visible_height.saturating_sub(1)
} else {
self.playlist_visible_height
};
if self.selected_playlist_index < self.playlist_scroll_offset {
self.playlist_scroll_offset = self.selected_playlist_index;
} else if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height {
self.playlist_scroll_offset = self.selected_playlist_index - effective_height + 1;
self.jump_to_current_playlist_search_result();
}
}
pub fn execute_playlist_search(&mut self) {
if self.search_query.is_empty() {
self.search_mode = false;
return;
}
// Collect all matching indices with scores
let mut matching_indices_with_scores: Vec<(usize, i32)> = self.playlist
.iter()
.enumerate()
.filter_map(|(idx, path)| {
let filename = path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
fuzzy_match(filename, &self.search_query).map(|score| (idx, score))
})
.collect();
if matching_indices_with_scores.is_empty() {
self.search_mode = false;
return;
}
// Sort by score (highest first)
matching_indices_with_scores.sort_by(|a, b| b.1.cmp(&a.1));
// Store matching indices
self.playlist_search_matches = matching_indices_with_scores.iter().map(|(idx, _)| *idx).collect();
if !self.playlist_search_matches.is_empty() {
self.playlist_search_match_index = 0;
let first_match_idx = self.playlist_search_matches[0];
self.selected_playlist_index = first_match_idx;
// Scroll to show the match
// Account for "... X more below" indicator which takes one line
let visible_end = self.playlist_scroll_offset + self.playlist_visible_height;
let items_below = if visible_end < self.playlist.len() {
self.playlist.len() - visible_end
} else {
0
};
let effective_height = if items_below > 0 {
self.playlist_visible_height.saturating_sub(1)
} else {
self.playlist_visible_height
};
if self.selected_playlist_index < self.playlist_scroll_offset {
self.playlist_scroll_offset = self.selected_playlist_index;
} else if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height {
self.playlist_scroll_offset = self.selected_playlist_index - effective_height + 1;
}
}
self.search_mode = false;
}
pub fn next_playlist_search_match(&mut self) {
if !self.playlist_search_matches.is_empty() {
self.playlist_search_match_index = (self.playlist_search_match_index + 1) % self.playlist_search_matches.len();
let match_idx = self.playlist_search_matches[self.playlist_search_match_index];
self.selected_playlist_index = match_idx;
// Scroll to show the match
// Account for "... X more below" indicator which takes one line
let visible_end = self.playlist_scroll_offset + self.playlist_visible_height;
let items_below = if visible_end < self.playlist.len() {
self.playlist.len() - visible_end
} else {
0
};
let effective_height = if items_below > 0 {
self.playlist_visible_height.saturating_sub(1)
} else {
self.playlist_visible_height
};
if self.selected_playlist_index < self.playlist_scroll_offset {
self.playlist_scroll_offset = self.selected_playlist_index;
} else if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height {
self.playlist_scroll_offset = self.selected_playlist_index - effective_height + 1;
}
}
}
pub fn prev_playlist_search_match(&mut self) {
if !self.playlist_search_matches.is_empty() {
if self.playlist_search_match_index == 0 {
self.playlist_search_match_index = self.playlist_search_matches.len() - 1;
} else {
self.playlist_search_match_index -= 1;
}
let match_idx = self.playlist_search_matches[self.playlist_search_match_index];
self.selected_playlist_index = match_idx;
// Scroll to show the match
// Account for "... X more below" indicator which takes one line
let visible_end = self.playlist_scroll_offset + self.playlist_visible_height;
let items_below = if visible_end < self.playlist.len() {
self.playlist.len() - visible_end
} else {
0
};
let effective_height = if items_below > 0 {
self.playlist_visible_height.saturating_sub(1)
} else {
self.playlist_visible_height
};
if self.selected_playlist_index < self.playlist_scroll_offset {
self.playlist_scroll_offset = self.selected_playlist_index;
} else if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height {
self.playlist_scroll_offset = self.selected_playlist_index - effective_height + 1;
}
}
self.exit_search_mode();
}
}
@ -1209,7 +915,9 @@ fn flatten_tree(nodes: &[FileTreeNode], depth: usize, expanded_dirs: &HashSet<Pa
let is_expanded = expanded_dirs.contains(&node.path);
result.push(FlattenedItem {
node: node.clone(),
path: node.path.clone(),
name: node.name.clone(),
is_dir: node.is_dir,
depth,
});
@ -1236,38 +944,44 @@ fn collect_files_from_node(node: &FileTreeNode) -> Vec<PathBuf> {
}
fn fuzzy_match(text: &str, query: &str) -> Option<i32> {
let text_lower = text.to_lowercase();
let query_lower = query.to_lowercase();
let mut text_chars = text_lower.chars();
// Avoid allocations by comparing chars directly with case-insensitive logic
let mut text_chars = text.chars();
let mut score = 0;
let mut prev_match_idx = 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 current_idx = prev_match_idx;
for text_char in text_chars.by_ref() {
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;
// Bonus for consecutive matches
if current_idx == prev_match_idx + 1 {
consecutive_bonus += 10;
consecutive_bonus += FUZZY_CONSECUTIVE_BONUS;
} else {
consecutive_bonus = 0;
}
// Bonus for matching at word start
if current_idx == 1 || text_lower.chars().nth(current_idx - 2).map_or(false, |c| !c.is_alphanumeric()) {
score += 15;
if current_idx == 1 || !prev_char.is_alphanumeric() {
score += FUZZY_WORD_START_BONUS;
}
score += consecutive_bonus;
// Penalty for gap
score -= (current_idx - prev_match_idx - 1) as i32;
prev_match_idx = current_idx;
prev_char = text_char;
break;
}
prev_char = text_char;
}
if !found {
@ -1283,7 +997,7 @@ fn collect_matching_paths(nodes: &[FileTreeNode], query: &str, matches: &mut Vec
if let Some(mut score) = fuzzy_match(&node.name, query) {
// Give folders a significant boost so they appear before files
if node.is_dir {
score += 50;
score += FUZZY_FOLDER_BONUS;
}
matches.push((node.path.clone(), score));
}

View File

@ -11,7 +11,7 @@ use ratatui::{
};
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
frame.render_widget(
Block::default().style(Theme::secondary()),
@ -28,17 +28,60 @@ pub fn render(frame: &mut Frame, state: &mut AppState, player: &Player) -> (Rect
])
.split(frame.area());
// Main content: left (files) | right (status + playlist)
let content_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(main_chunks[1]);
// Always use tab mode - show only the focused panel
let tab_mode = true;
// Build the title with focused panel in bold
let file_style = if !state.focus_playlist {
Style::default().fg(Theme::bright_foreground()).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Theme::bright_foreground())
};
let playlist_style = if state.focus_playlist {
Style::default().fg(Theme::bright_foreground()).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Theme::bright_foreground())
};
// Add playlist counter
let playlist_text = if !state.playlist.is_empty() {
format!("playlist [{}/{}]", state.playlist_index + 1, state.playlist.len())
} else {
"playlist (empty)".to_string()
};
let title = Line::from(vec![
Span::styled("files", file_style),
Span::raw(" | "),
Span::styled(playlist_text, playlist_style),
]);
// Create one border around the entire content area with fixed gray border
let main_block = Block::default()
.borders(Borders::ALL)
.title(title)
.style(Theme::widget_border_style());
let inner_area = main_block.inner(main_chunks[1]);
render_title_bar(frame, state, player, main_chunks[0]);
render_file_panel(frame, state, content_chunks[0]);
render_right_panel(frame, state, content_chunks[1]);
frame.render_widget(main_block, main_chunks[1]);
// Tab mode - show only focused panel
if state.focus_playlist {
render_right_panel(frame, state, inner_area, tab_mode);
} else {
render_file_panel(frame, state, inner_area, tab_mode);
}
render_status_bar(frame, state, player, main_chunks[2]);
// Show refreshing popup if scanning
if state.is_refreshing {
render_refresh_popup(frame, state.refresh_file_count);
}
// Show confirmation popup if needed
if state.show_refresh_confirm {
render_confirm_popup(frame, "Refresh library?", "This may take a while");
@ -50,7 +93,12 @@ pub fn render(frame: &mut Frame, state: &mut AppState, player: &Player) -> (Rect
}
// Return title bar area, file panel area, and playlist area for mouse event handling
(main_chunks[0], content_chunks[0], content_chunks[1])
// Use main_chunks[1] (full area) so mouse coordinates align properly
if state.focus_playlist {
(main_chunks[0], Rect::default(), main_chunks[1])
} else {
(main_chunks[0], main_chunks[1], Rect::default())
}
}
fn highlight_search_matches<'a>(text: &str, query: &str, is_selected: bool) -> Vec<Span<'a>> {
@ -62,7 +110,8 @@ fn highlight_search_matches<'a>(text: &str, query: &str, is_selected: bool) -> V
let mut current_segment = String::new();
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 ch_lower == query_ch {
@ -107,13 +156,13 @@ fn highlight_search_matches<'a>(text: &str, query: &str, is_selected: bool) -> V
spans
}
fn render_file_panel(frame: &mut Frame, state: &mut AppState, area: Rect) {
// Calculate visible height (subtract 2 for borders)
let visible_height = area.height.saturating_sub(2) as usize;
fn render_file_panel(frame: &mut Frame, state: &mut AppState, area: Rect, _tab_mode: bool) {
// Calculate visible height (no borders on individual panels now)
let visible_height = area.height as usize;
// Store visible height for keyboard navigation scroll calculations
state.file_panel_visible_height = visible_height;
let in_search = !state.focus_playlist && (state.search_mode || !state.search_matches.is_empty());
let in_search = !state.focus_playlist && state.search_mode;
let search_query = if in_search { state.search_query.to_lowercase() } else { String::new() };
// Calculate how many items are below the visible area
@ -141,15 +190,15 @@ fn render_file_panel(frame: &mut Frame, state: &mut AppState, area: Rect) {
.map(|(display_idx, item)| {
let idx = state.scroll_offset + display_idx;
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
// Only show selection bar when file panel has focus
let is_selected = !state.focus_playlist && idx == state.selected_index;
// Add icon for directories and files
let icon = if item.node.is_dir {
let is_expanded = state.expanded_dirs.contains(&item.node.path);
let icon = if item.is_dir {
let is_expanded = state.expanded_dirs.contains(&item.path);
// Nerd font folder icons: \u{eaf7} = open, \u{ea83} = closed
let icon_char = if is_expanded { "\u{eaf7} " } else { "\u{ea83} " };
@ -161,7 +210,7 @@ fn render_file_panel(frame: &mut Frame, state: &mut AppState, area: Rect) {
}
} else {
// File icons based on extension
let extension = item.node.path.extension()
let extension = item.path.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
@ -183,12 +232,12 @@ fn render_file_panel(frame: &mut Frame, state: &mut AppState, area: Rect) {
}
};
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 {
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 {
// Selection bar: yellow/orange when in search (typing or viewing results), blue otherwise
@ -197,7 +246,7 @@ fn render_file_panel(frame: &mut Frame, state: &mut AppState, area: Rect) {
} else {
Theme::selected()
}
} else if state.marked_files.contains(&item.node.path) {
} else if state.marked_files.contains(&item.path) {
Theme::marked()
} else {
Theme::secondary()
@ -225,14 +274,7 @@ fn render_file_panel(frame: &mut Frame, state: &mut AppState, area: Rect) {
items.push(more_item);
}
let list = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.title("files")
.style(Theme::widget_border_style())
.title_style(Theme::title_style()),
);
let list = List::new(items);
let mut list_state = ListState::default();
// Don't set selection to avoid automatic scrolling - we manage scroll manually
@ -241,9 +283,9 @@ fn render_file_panel(frame: &mut Frame, state: &mut AppState, area: Rect) {
frame.render_stateful_widget(list, area, &mut list_state);
}
fn render_right_panel(frame: &mut Frame, state: &mut AppState, area: Rect) {
// Calculate visible height (subtract 2 for borders)
let visible_height = area.height.saturating_sub(2) as usize;
fn render_right_panel(frame: &mut Frame, state: &mut AppState, area: Rect, _tab_mode: bool) {
// Calculate visible height (no borders on individual panels now)
let visible_height = area.height as usize;
// Store visible height for keyboard navigation scroll calculations
state.playlist_visible_height = visible_height;
@ -264,7 +306,7 @@ fn render_right_panel(frame: &mut Frame, state: &mut AppState, area: Rect) {
};
// Check if in playlist search mode
let in_playlist_search = state.focus_playlist && (state.search_mode || !state.playlist_tab_search_results.is_empty() || !state.playlist_search_matches.is_empty());
let in_playlist_search = state.focus_playlist && state.search_mode;
let playlist_search_query = if in_playlist_search { state.search_query.to_lowercase() } else { String::new() };
// Playlist panel (no longer need the player status box)
@ -284,12 +326,17 @@ fn render_right_panel(frame: &mut Frame, state: &mut AppState, area: Rect) {
let is_selected = state.focus_playlist && idx == state.selected_playlist_index;
let is_playing = idx == state.playlist_index;
// Add playing indicator arrow
let indicator = if is_playing { "" } else { " " };
// Build line with search highlighting if searching
let line = if in_playlist_search && !playlist_search_query.is_empty() {
Line::from(highlight_search_matches(&filename, &playlist_search_query, is_selected))
let mut line_spans = vec![Span::raw(indicator)];
if in_playlist_search && !playlist_search_query.is_empty() {
line_spans.extend(highlight_search_matches(&filename, &playlist_search_query, is_selected));
} else {
Line::from(filename)
};
line_spans.push(Span::raw(filename));
}
let line = Line::from(line_spans);
let style = if is_selected && is_playing {
// Both selected and playing: selection bar with bold
@ -327,20 +374,7 @@ fn render_right_panel(frame: &mut Frame, state: &mut AppState, area: Rect) {
playlist_items.push(more_item);
}
let playlist_title = if !state.playlist.is_empty() {
format!("playlist [{}/{}]", state.playlist_index + 1, state.playlist.len())
} else {
"playlist (empty)".to_string()
};
let playlist_widget = List::new(playlist_items)
.block(
Block::default()
.borders(Borders::ALL)
.title(playlist_title)
.style(Theme::widget_border_style())
.title_style(Theme::title_style()),
);
let playlist_widget = List::new(playlist_items);
let mut playlist_state = ListState::default();
// Don't set selection to avoid automatic scrolling - we manage scroll manually
@ -349,11 +383,18 @@ fn render_right_panel(frame: &mut Frame, state: &mut AppState, area: Rect) {
frame.render_stateful_widget(playlist_widget, area, &mut playlist_state);
}
fn render_title_bar(frame: &mut Frame, state: &AppState, _player: &Player, area: Rect) {
let background_color = match state.player_state {
fn render_title_bar(frame: &mut Frame, state: &AppState, player: &mut Player, area: Rect) {
// Get player state
let player_state = player.get_player_state().unwrap_or(PlayerState::Stopped);
// Title bar background color: red for error, gray for stopped/paused, green for playing
let background_color = if state.last_error.is_some() {
Theme::error()
} else {
match player_state {
PlayerState::Playing => Theme::success(), // Green for playing
PlayerState::Paused => Theme::highlight(), // Blue for paused
PlayerState::Stopped => Theme::dim_foreground(), // Gray for stopped
PlayerState::Paused | PlayerState::Stopped => Theme::border(), // Gray for paused/stopped
}
};
// Split the title bar into left and right sections
@ -378,24 +419,15 @@ fn render_title_bar(frame: &mut Frame, state: &AppState, _player: &Player, area:
// Right side: Status • Progress • Volume • Search (if active)
let mut right_spans = Vec::new();
if state.is_refreshing {
// Show only "Refreshing library..." when refreshing
right_spans.push(Span::styled(
"Refreshing library... ",
Style::default()
.fg(Theme::background())
.bg(background_color)
.add_modifier(Modifier::BOLD)
));
} else {
{
// Status (bold when playing)
let status_text = match state.player_state {
let status_text = match player_state {
PlayerState::Stopped => "Stopped",
PlayerState::Playing => "Playing",
PlayerState::Paused => "Paused",
};
let status_style = if state.player_state == PlayerState::Playing {
let status_style = if player_state == PlayerState::Playing {
Style::default()
.fg(Theme::background())
.bg(background_color)
@ -458,13 +490,25 @@ fn render_title_bar(frame: &mut Frame, state: &AppState, _player: &Player, area:
frame.render_widget(right_title, chunks[1]);
}
fn render_status_bar(frame: &mut Frame, state: &AppState, player: &Player, area: Rect) {
if state.search_mode {
fn render_status_bar(frame: &mut Frame, state: &AppState, player: &mut Player, area: Rect) {
// Calculate progress percentage for progress bar
let progress_percent = if state.current_duration > 0.0 {
(state.current_position / state.current_duration).clamp(0.0, 1.0)
} else {
0.0
};
// If playing and has duration, show progress bar with overlaid text
let player_state = player.get_player_state().unwrap_or(PlayerState::Stopped);
let show_progress_bar = player_state != PlayerState::Stopped && state.current_duration > 0.0;
// Determine text content based on mode
let status_text = if state.search_mode {
// Show search prompt with current query and match count - LEFT aligned
let search_text = if state.focus_playlist {
if state.focus_playlist {
// Searching in playlist
if !state.playlist_tab_search_results.is_empty() {
format!("/{}_ Playlist Search: {}/{}", state.search_query, state.playlist_tab_search_index + 1, state.playlist_tab_search_results.len())
if !state.playlist_search_results.is_empty() {
format!("/{}_ Playlist Search: {}/{}", state.search_query, state.playlist_search_result_index + 1, state.playlist_search_results.len())
} else if !state.search_query.is_empty() {
format!("/{}_ [no matches]", state.search_query)
} else {
@ -472,35 +516,29 @@ fn render_status_bar(frame: &mut Frame, state: &AppState, player: &Player, area:
}
} else {
// Searching in file panel
if !state.tab_search_results.is_empty() {
format!("/{}_ Search: {}/{}", state.search_query, state.tab_search_index + 1, state.tab_search_results.len())
if !state.search_results.is_empty() {
format!("/{}_ Search: {}/{}", state.search_query, state.search_result_index + 1, state.search_results.len())
} else if !state.search_query.is_empty() {
format!("/{}_ [no matches]", state.search_query)
} else {
format!("/{}_", state.search_query)
}
};
let status_bar = Paragraph::new(search_text)
.style(Style::default().fg(Color::White).bg(Theme::background()));
frame.render_widget(status_bar, area);
} else if !state.search_matches.is_empty() {
// Show search navigation when file search results are active
let search_text = format!("/{} Search: {}/{}", state.search_query, state.search_match_index + 1, state.search_matches.len());
let status_bar = Paragraph::new(search_text)
.style(Style::default().fg(Color::White).bg(Theme::background()));
frame.render_widget(status_bar, area);
} else if !state.playlist_search_matches.is_empty() {
// Show search navigation when playlist search results are active
let search_text = format!("/{} Playlist Search: {}/{}", state.search_query, state.playlist_search_match_index + 1, state.playlist_search_matches.len());
let status_bar = Paragraph::new(search_text)
.style(Style::default().fg(Color::White).bg(Theme::background()));
frame.render_widget(status_bar, area);
}
} else if state.visual_mode {
// Show visual mode indicator
let visual_text = format!("-- VISUAL -- {} files marked", state.marked_files.len());
let status_bar = Paragraph::new(visual_text)
.style(Style::default().fg(Theme::foreground()).bg(Theme::background()));
format!("-- VISUAL -- {} files marked", state.marked_files.len())
} else {
String::new()
};
// If we have status text (search/visual mode), show it without progress bar
if !status_text.is_empty() {
let status_bar = Paragraph::new(status_text)
.style(Style::default().fg(Color::White).bg(Theme::background()));
frame.render_widget(status_bar, area);
} else if show_progress_bar {
// Show progress bar with metadata text overlay
render_progress_bar(frame, state, player, area, progress_percent);
} else {
// Normal mode: show media metadata if playing
// Split into left (artist/album/title) and right (technical info)
@ -534,12 +572,6 @@ fn render_status_bar(frame: &mut Frame, state: &AppState, player: &Player, area:
right_parts.push(format!("{} Hz", samplerate));
}
if let Some(cache_dur) = player.cache_duration {
if cache_dur > 0.0 {
right_parts.push(format!("{:.1}s", cache_dur));
}
}
// Create layout for left and right sections
let chunks = Layout::default()
.direction(Direction::Horizontal)
@ -572,6 +604,171 @@ 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 right_parts = Vec::new();
// Right side: Bitrate | Codec | Sample rate (metrics that must always be visible)
if let Some(bitrate) = player.audio_bitrate {
right_parts.push(format!("{:.0} kbps", bitrate));
}
if let Some(ref codec) = player.audio_codec {
right_parts.push(codec.to_uppercase());
}
if let Some(samplerate) = player.sample_rate {
right_parts.push(format!("{} Hz", samplerate));
}
// Build right text
let right_text = if !right_parts.is_empty() {
format!("{} ", right_parts.join(" | "))
} else {
String::new()
};
// Calculate available space
let total_width = area.width as usize;
let right_text_len = right_text.chars().count();
// Reserve space: 1 char at start, gap between left and right
let available_for_left = total_width.saturating_sub(right_text_len).saturating_sub(2);
// Collect left side metadata
let mut left_fields = Vec::new();
if let Some(ref artist) = player.artist {
left_fields.push(("artist", artist.as_str()));
}
if let Some(ref album) = player.album {
left_fields.push(("album", album.as_str()));
}
if let Some(ref title) = player.media_title {
left_fields.push(("title", title.as_str()));
}
// Calculate space per field (divide available space among fields)
let left_text = if !left_fields.is_empty() {
let num_fields = left_fields.len();
let separator_space = (num_fields - 1) * 3; // " | " between fields
let available_for_fields = available_for_left.saturating_sub(separator_space);
let max_per_field = available_for_fields / num_fields;
// Truncate each field individually
let truncated_fields: Vec<String> = left_fields.iter().map(|(_name, value)| {
if value.chars().count() > max_per_field && max_per_field > 3 {
let mut s: String = value.chars().take(max_per_field - 3).collect();
s.push_str("...");
s
} else if value.chars().count() > max_per_field {
// Very tight space, just cut hard
value.chars().take(max_per_field).collect()
} else {
value.to_string()
}
}).collect();
format!(" {}", truncated_fields.join(" | "))
} else {
String::new()
};
// Calculate filled width based on progress
let filled_width = (total_width as f64 * progress_percent) as usize;
// Build the full line character by character with proper spacing
let left_chars: Vec<char> = left_text.chars().collect();
let right_chars: Vec<char> = right_text.chars().collect();
let right_start_pos = total_width.saturating_sub(right_chars.len());
// Build spans with progress bar background
let mut spans = Vec::new();
for i in 0..total_width {
// Determine which character to show
let ch = if i < left_chars.len() {
left_chars[i].to_string()
} else if i >= right_start_pos && i - right_start_pos < right_chars.len() {
right_chars[i - right_start_pos].to_string()
} else {
" ".to_string()
};
// Apply progress bar background
if i < filled_width {
// Filled portion - border color background with black text
spans.push(Span::styled(
ch,
Style::default()
.fg(Color::Black)
.bg(Theme::border())
));
} else {
// Unfilled portion - normal background
spans.push(Span::styled(
ch,
Style::default()
.fg(Theme::muted_text())
.bg(Theme::background())
));
}
}
let progress_line = Line::from(spans);
let progress_widget = Paragraph::new(progress_line);
frame.render_widget(progress_widget, area);
}
fn render_refresh_popup(frame: &mut Frame, file_count: usize) {
// Create centered popup area - bigger for two lines
let area = frame.area();
let popup_width = 50;
let popup_height = 5;
let popup_area = Rect {
x: (area.width.saturating_sub(popup_width)) / 2,
y: (area.height.saturating_sub(popup_height)) / 2,
width: popup_width.min(area.width),
height: popup_height.min(area.height),
};
// Use Clear widget to completely erase the background
frame.render_widget(Clear, popup_area);
// Render the popup block with solid background
let block = Block::default()
.borders(Borders::ALL)
.style(Style::default()
.bg(Theme::background())
.fg(Theme::bright_foreground()));
let inner = block.inner(popup_area);
frame.render_widget(block, popup_area);
// Build two-line message
let lines = if file_count > 0 {
vec![
Line::from("Refreshing library..."),
Line::from(""),
Line::from(format!("{} files found", file_count))
.style(Style::default().fg(Theme::highlight())),
]
} else {
vec![
Line::from("Refreshing library..."),
]
};
// Render message centered
let message_widget = Paragraph::new(lines)
.alignment(Alignment::Center)
.style(Style::default()
.fg(Theme::bright_foreground())
.bg(Theme::background()));
frame.render_widget(message_widget, inner);
}
fn render_confirm_popup(frame: &mut Frame, title: &str, message: &str) {
// Create centered popup area
let area = frame.area();

View File

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