Compare commits

...

31 Commits
v0.1.4 ... main

Author SHA1 Message Date
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
ffe7cd0090 Fix time display to update smoothly
All checks were successful
Build and Release / build-and-release (push) Successful in 54s
Change position update logic to only trigger redraw when the
displayed value (rounded to seconds) changes, not when the raw
float value changes. This eliminates jumpy time display and
reduces unnecessary redraws.
2025-12-09 12:33:52 +01:00
907a734be3 Remove Cache prefix from cache duration display
All checks were successful
Build and Release / build-and-release (push) Successful in 55s
Display cache duration as "1.5s" instead of "Cache:1.5s" in
bottom status bar for cleaner presentation alongside other
technical metrics.
2025-12-09 12:23:04 +01:00
135700ce02 Update cache metric refresh rate to match other metadata
All checks were successful
Build and Release / build-and-release (push) Successful in 54s
Move cache duration update from update_properties (~10Hz) to
update_metadata (~0.5Hz) to match the refresh rate of codec,
bitrate, and sample rate. All bottom status bar metrics now
update at the same frequency.
2025-12-09 12:06:59 +01:00
ea72368841 Remove buffer mode feature and relocate cache metrics
All checks were successful
Build and Release / build-and-release (push) Successful in 56s
- Remove buffer mode toggle (Normal/Large/Huge) as demuxer settings
  do not significantly impact local file playback
- Move cache duration metric from title bar to bottom status bar
- Display cache alongside codec, bitrate, and sample rate info
- Remove 'b' key binding and Buffer context menu option
- Update version to 0.1.15
2025-12-09 11:51:51 +01:00
ed6765039c Add nerd font icons and UI polish
All checks were successful
Build and Release / build-and-release (push) Successful in 1m18s
- Add nerd font file type icons:
  - Folder icons: closed/open folders with visual state
  - Music files: icon in green (mp3, flac, wav, ogg, etc.)
  - Video files: icon in yellow (mp4, mkv, avi, mov, etc.)
- Add spacing after icons for better readability

- Add "Refresh" option to title bar right-click menu
- Make all context menus more compact (13 chars wide)

- Change panel titles to lowercase:
  - "Media Files" → "files"
  - "Playlist" → "playlist"
- Remove bold styling from focused panel titles

- All icons show as bold black on selection bar
- Folders show in blue, music in green, video in yellow
2025-12-08 23:07:28 +01:00
f60ff02b2a Improve playlist handling and add UI enhancements
All checks were successful
Build and Release / build-and-release (push) Successful in 1m19s
- Fix cascading track failures when files can't be loaded
- Add position check (>0.5s) to prevent auto-advance on failed tracks
- Fix playlist scroll jumping when browsing while tracks auto-advance
- Only auto-scroll playlist when not focused on playlist panel
- Fix selector bar getting hidden by "... X more below" indicator
- Add effective height calculation for all navigation functions
- Fix Ctrl-U/D page scrolling in both panels

- Add play mode system (Normal/Loop) with 'm' key toggle
- Add Shift+R to randomize playlist order
- Add right-click context menus:
  - Playlist: Remove, Randomise
  - Title bar: Stop, Loop, Refresh
- Make context menus more compact (13 chars wide)
- Show play mode indicator in title bar ([Loop])

- Add incremental search support in playlist panel
- Fix search scrolling to account for bottom indicator
- Show search query in status bar when navigating results
- Change search text to white color

- Improve double-click detection and single-click selection
- Add focus indicators (bold title for active panel)
- Hide selector bar in inactive panel
2025-12-08 19:53:32 +01:00
59f9f548c1 Add playlist bounds validation and search mode visual indicator
All checks were successful
Build and Release / build-and-release (push) Successful in 1m1s
- Add bounds checking to prevent accessing invalid playlist indices
- Yellow/orange selection bar when in search mode
- Validate playlist index after navigation operations
- Handle empty playlists gracefully
2025-12-07 16:02:44 +01:00
248c5701fb Refactor mouse and keyboard handlers with shared actions
All checks were successful
Build and Release / build-and-release (push) Successful in 1m24s
- Extract common action functions to eliminate code duplication
- Mouse left-click toggles folder open/close
- Mouse right-click plays selection (identical to Enter key)
- Add confirmation popup for library refresh operation
- Improve popup visibility with Clear widget
2025-12-07 13:56:52 +01:00
0cef231cd3 Add metadata display and optimize CPU usage
All checks were successful
Build and Release / build-and-release (push) Successful in 58s
Metadata features:
- Display artist, album, title in bottom status bar (left-aligned)
- Display bitrate, codec, sample rate in bottom status bar (right-aligned)
- Fetch metadata from MPV using metadata/by-key properties
- Support both lowercase and uppercase metadata tags

Performance optimizations:
- Split property updates: fast updates (position/duration) vs slow (metadata)
- Update metadata only every 2 seconds instead of every 100ms
- Skip MPV property updates entirely when stopped
- Conditional UI redraws - only when state actually changes
- Variable poll rate: 200ms when stopped, 100ms when playing
- Position update throttling (0.5s minimum change)
- Reduces CPU usage from constant 10 FPS to ~0.1 FPS when idle
2025-12-06 23:07:33 +01:00
ae80e9a5db Improve search mode UX and fix playback bugs
All checks were successful
Build and Release / build-and-release (push) Successful in 51s
Search mode improvements:
- Search results persist until explicitly cleared
- Bold black highlighted chars on selection bar
- Fix fuzzy match scoring to select first occurrence
- Search info moved to bottom status bar

Keybinding changes:
- J/K for next/prev track (was n/p)
- H/L for seeking (was arrow keys)
- Simplified status bar shortcuts

UI improvements:
- Dynamic title bar color (green=playing, blue=paused, gray=stopped)
- White bold text for current playlist item
- Removed mouse capture for terminal text selection

Bug fixes:
- Fix auto-advance triggering multiple times when restarting from stopped state
2025-12-06 22:14:57 +01:00
1b07026b68 Replace playlist selection with bold colored text
All checks were successful
Build and Release / build-and-release (push) Successful in 59s
- Current track shown with bold text instead of selection highlight
- Green bold for playing, blue for paused, yellow for stopped
- Cleaner visual appearance
- Bump version to 0.1.8
2025-12-06 17:40:06 +01:00
f9534bacf3 Implement vim-style navigation and visual mode
All checks were successful
Build and Release / build-and-release (push) Successful in 51s
- Add vim-style h key: closes folders and jumps to parent
- Implement stop() method in player for proper playback stopping
- Add space key to restart playback when stopped
- Add playlist color coding: green (playing), blue (paused), yellow (stopped)
- Fix n/p keys to preserve player state when switching tracks
- Implement vim-style visual mode with v key for multi-file selection
- Visual mode exits automatically on play/add actions
- Remove unused play_previous method
- Bump version to 0.1.7
2025-12-06 17:35:11 +01:00
006aeb0c90 Add comprehensive technical README
- Document architecture and core components
- Include all keybindings and features
- Explain fuzzy search algorithm and MPV IPC integration
- Detail automated release process and CI/CD workflow
- Add troubleshooting section
- Include installation and configuration instructions
2025-12-06 16:56:15 +01:00
586c0b88eb Change stop key to 's' for better compatibility
All checks were successful
Build and Release / build-and-release (push) Successful in 59s
- Replace Shift+Space with 's' key for stop functionality
- Update status bar shortcuts to reflect the change
- Bump version to 0.1.5
2025-12-06 16:42:44 +01:00
8e2989731e Add Shift+Space to stop playback
- Shift+Space now stops playback and resets position
- Updated status bar to show stop shortcut
- Shortened status bar text to fit stop command
2025-12-06 16:30:28 +01:00
11 changed files with 3512 additions and 534 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.4"
version = "0.1.33"
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"
@ -31,6 +28,9 @@ thiserror = "1.0"
tracing = "0.1"
tracing-subscriber = "0.3"
# Random
rand = "0.8"
[profile.release]
opt-level = 3
lto = true

276
README.md
View File

@ -1,2 +1,278 @@
# cm-player
A high-performance terminal UI music and video player written in Rust, designed for low-bandwidth VPN environments with cache-only operation.
## Features
- **Cache-only architecture**: Scans media directories once and operates from cache, ideal for low-bandwidth VPN connections
- **Vim-style navigation**: Familiar keybindings (j/k/h/l) for efficient navigation
- **Incremental fuzzy search**: Real-time search with Tab completion, similar to vim's command-line search
- **Directory tree browsing**: Collapsible folder structure with lazy scrolling
- **Playlist management**: Mark multiple files (vim-style 'v' key) to create custom playlists
- **MPV integration**: Video and audio playback via MPV subprocess with IPC control
- **Dual-panel layout**: File browser on left, playlist on right
- **Auto-advance**: Automatically plays next track when current track ends
## Architecture
### Core Components
**State Management** (`src/state/mod.rs`)
- Maintains flattened file tree from cache for efficient rendering
- Handles directory expansion/collapse state
- Manages playlist and playback position
- Implements fuzzy search with folder priority boost
**MPV IPC Integration** (`src/player/mod.rs`)
- Spawns MPV as subprocess with Unix socket IPC
- Automatic process respawn when MPV exits (user closes window)
- Non-blocking socket communication with JSON protocol
- Tracks playback state, position, duration via property observation
**Cache System** (`src/cache/mod.rs`)
- XDG-compliant cache storage: `~/.cache/cm-player/`
- Serializes directory tree structure with serde_json
- Supports manual refresh with 'r' key
- Scans multiple configured paths
**UI Rendering** (`src/ui/mod.rs`)
- Three-section layout: title bar (1 line) | content | status bar (1 line)
- Ratatui framework with crossterm backend
- Theme matching cm-dashboard color scheme
- Real-time playback progress in title bar
### Technical Details
**Fuzzy Search Algorithm**
```rust
// Scoring system:
// - Characters must appear in order (not necessarily consecutive)
// - Consecutive matches: +10 bonus per character
// - Word boundary matches: +15 bonus
// - Gap penalty: -1 per character between matches
// - Folder priority: +50 score boost
// - Length bonus: 100 - text_length
```
**MPV Communication**
- IPC socket: `/tmp/cm-player-{pid}.sock`
- Commands: `{ "command": ["loadfile", "path"] }`
- Property observation: `time-pos`, `duration`, `pause`, `idle-active`
- Process lifecycle: spawn -> connect -> command -> observe -> respawn on exit
**Lazy Scrolling**
- Selection stays in place until reaching viewport edge
- Scroll offset only updates when necessary
- Page navigation with Ctrl-D/U (half-page jumps)
## Installation
### Pre-built Binary (NixOS)
```nix
# hosts/common/cm-player.nix is automatically included
# mpv is installed as dependency
```
### From Release
```bash
curl -L https://gitea.cmtec.se/cm/cm-player/releases/latest/download/cm-player-linux-x86_64 -o cm-player
chmod +x cm-player
sudo mv cm-player /usr/local/bin/
```
### Build from Source
```bash
git clone https://gitea.cmtec.se/cm/cm-player.git
cd cm-player
cargo build --release
./target/release/cm-player
```
**Static linking** (for portability):
```bash
export RUSTFLAGS="-C target-feature=+crt-static"
cargo build --release --target x86_64-unknown-linux-gnu
```
## Configuration
### Config File
Location: `~/.config/cm-player/config.toml`
```toml
[scan_paths]
paths = [
"/mnt/music",
"/mnt/movie",
"/mnt/tv"
]
```
### First Run
1. Create config file with media paths
2. Launch cm-player
3. Press 'r' to scan directories and build cache
4. Cache stored at `~/.cache/cm-player/cache.json`
## Keybindings
### Navigation
- `j` / `k` / `↓` / `↑` - Move selection down/up
- `h` - Collapse directory
- `l` - Expand directory
- `Ctrl-D` - Page down (half page)
- `Ctrl-U` - Page up (half page)
### Search
- `/` - Enter search mode (fuzzy find)
- `Tab` - Next search result
- `Shift-Tab` - Previous search result
- `Enter` - Execute search and enable n/N cycling
- `n` - Next search match (after Enter) or next track
- `N` - Previous search match
- `Esc` - Clear search results
### Playlist
- `v` - Mark/unmark file or directory
- `a` - Add marked files to playlist
- `c` - Clear playlist
### Playback
- `Enter` - Play selected file/folder (uses marks if any)
- `Space` - Pause/Resume
- `s` - Stop playback
- `p` - Previous track
- `n` - Next track (when not in search results)
- `←` / `→` - Seek backward/forward 10 seconds
- `+` / `=` - Increase volume
- `-` - Decrease volume
### System
- `r` - Rescan media directories and rebuild cache
- `q` - Quit
## Development
### Project Structure
```
cm-player/
├── src/
│ ├── main.rs # Event loop and key handling
│ ├── state/mod.rs # App state and search logic
│ ├── player/mod.rs # MPV IPC integration
│ ├── ui/
│ │ ├── mod.rs # TUI rendering
│ │ └── theme.rs # Color scheme
│ ├── cache/mod.rs # Cache serialization
│ ├── scanner/mod.rs # Directory scanning
│ └── config/mod.rs # Config file handling
├── .gitea/workflows/
│ └── release.yml # CI/CD pipeline
└── Cargo.toml
```
### Dependencies
- **ratatui** - Terminal UI framework
- **crossterm** - Terminal backend
- **tokio** - Async runtime for event loop
- **serde/serde_json** - Cache serialization
- **walkdir** - Directory traversal
- **anyhow/thiserror** - Error handling
### Logging
Logs written to `/tmp/cm-player.log` to avoid interfering with TUI:
```rust
tracing::info!("Playing: {:?}", path);
tail -f /tmp/cm-player.log
```
## Release Process
### Automated CI/CD
Releases are fully automated via Gitea Actions:
```bash
# Update version in Cargo.toml
vim Cargo.toml
# Commit and tag
git add Cargo.toml
git commit -m "Bump version to v0.1.X"
git push
git tag v0.1.X
git push origin v0.1.X
```
**Workflow steps**:
1. Build static binary with `RUSTFLAGS="-C target-feature=+crt-static"`
2. Create GitHub-style release on Gitea
3. Upload binary and tarball as release assets
4. Calculate SHA256 hash from local tarball
5. Clone nixosbox repository
6. Update `hosts/common/cm-player.nix` with new version and hash
7. Commit and push to nixosbox
### Manual NixOS Update
If workflow fails, update manually:
```bash
cd ~/projects/nixosbox
# Download and hash the tarball
curl -L https://gitea.cmtec.se/cm/cm-player/releases/download/v0.1.X/cm-player-linux-x86_64.tar.gz -o /tmp/cm-player.tar.gz
sha256sum /tmp/cm-player.tar.gz | cut -d' ' -f1
# Convert hex to base64: python3 -c "import base64, binascii; print(base64.b64encode(binascii.unhexlify('HEX_HASH')).decode())"
# Edit hosts/common/cm-player.nix
vim hosts/common/cm-player.nix
# Update version and sha256
git add hosts/common/cm-player.nix
git commit -m "Update cm-player to v0.1.X"
git push
```
## MPV Configuration
Default MPV flags:
- `--idle` - Keep MPV running between tracks
- `--no-terminal` - Disable MPV's terminal output
- `--profile=fast` - Use fast decoding profile
- `--audio-display=no` - Disable cover art for audio files
- `--input-ipc-server=/tmp/cm-player-{pid}.sock` - Enable IPC
## Troubleshooting
### MPV window closes immediately
Check MPV installation: `mpv --version`
### Scan shows no files
- Verify paths in `~/.config/cm-player/config.toml`
- Press 'r' to rescan
- Check logs: `tail -f /tmp/cm-player.log`
### Hash mismatch on NixOS rebuild
- Wait for workflow to complete before rebuilding
- Workflow calculates hash from local tarball, not downloaded
- Check release assets are fully uploaded: https://gitea.cmtec.se/cm/cm-player/releases
### Video playback issues
MPV process respawns automatically if closed. If issues persist:
1. Stop playback with 's'
2. Restart cm-player
3. Check `/tmp/cm-player.log` for errors
## License
Copyright (c) 2025 CM Tech

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;
@ -7,17 +8,28 @@ mod ui;
use anyhow::{Context, Result};
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent, MouseEventKind, EnableMouseCapture, DisableMouseCapture},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
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 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,74 +61,604 @@ 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
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
// Restore terminal (always run cleanup, even if result is Err)
let cleanup_result = (|| -> Result<()> {
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
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
}
async fn run_app<B: ratatui::backend::Backend>(
/// 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.is_dir {
let path = item.path.clone();
if state.expanded_dirs.contains(&path) {
// Folder is open, close it
state.collapse_selected();
} else {
// Folder is closed, open it
state.expand_selected();
}
}
}
}
fn action_play_selection(state: &mut AppState, player: &mut player::Player, skip_position_update: &mut bool) -> Result<()> {
state.play_selection();
if let Some(ref path) = state.current_file {
*skip_position_update = true; // Skip position update after track change
player.play(path)?;
// Explicitly resume playback in case MPV was paused
player.resume()?;
// 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 {
state.visual_mode = false;
state.marked_files.clear();
}
Ok(())
}
fn action_toggle_play_pause(state: &mut AppState, player: &mut player::Player) -> Result<()> {
if let Some(player_state) = player.get_player_state() {
match player_state {
PlayerState::Playing => {
player.pause()?;
tracing::info!("Paused");
}
PlayerState::Paused => {
player.resume()?;
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());
if let Some(ref path) = state.current_file {
player.play(path)?;
player.resume()?;
tracing::info!("Restarting playback: {:?}", path);
}
}
}
}
}
Ok(())
}
fn action_stop(state: &mut AppState, player: &mut player::Player, skip_position_update: &mut bool) -> Result<()> {
player.stop()?;
state.current_position = 0.0;
state.current_duration = 0.0;
*skip_position_update = true; // Prevent auto-advance on manual stop
tracing::info!("Stopped");
Ok(())
}
fn action_volume_up(state: &mut AppState, player: &mut player::Player) -> Result<()> {
state.volume = (state.volume + 5).min(100);
player.set_volume(state.volume)?;
tracing::info!("Volume: {}%", state.volume);
Ok(())
}
fn action_volume_down(state: &mut AppState, player: &mut player::Player) -> Result<()> {
state.volume = (state.volume - 5).max(0);
player.set_volume(state.volume)?;
tracing::info!("Volume: {}%", state.volume);
Ok(())
}
fn action_volume_set(state: &mut AppState, player: &mut player::Player, volume: i64) -> Result<()> {
state.volume = volume.clamp(0, 100);
player.set_volume(state.volume)?;
tracing::info!("Volume: {}%", state.volume);
Ok(())
}
fn action_seek(player: &mut player::Player, seconds: f64) -> Result<()> {
if player.get_player_state() != Some(PlayerState::Stopped) {
player.seek(seconds)?;
tracing::info!("Seek {}s", seconds);
}
Ok(())
}
fn action_remove_from_playlist(state: &mut AppState, player: &mut player::Player) -> Result<()> {
let was_playing_removed = state.playlist_index == state.selected_playlist_index;
let was_playing = player.get_player_state() == Some(PlayerState::Playing);
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 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 {
// Check if mpv process died (e.g., user closed video window)
if !player.is_process_alive() && state.player_state != PlayerState::Stopped {
state.player_state = PlayerState::Stopped;
state.current_position = 0.0;
state.current_duration = 0.0;
}
let mut state_changed = false;
// Update player properties from MPV
player.update_properties();
// Update position and duration from player
state.current_position = player.get_position().unwrap_or(0.0);
state.current_duration = player.get_duration().unwrap_or(0.0);
// Check if track ended and play next
if player.is_idle() && state.player_state == PlayerState::Playing {
if state.playlist_index + 1 < state.playlist.len() {
state.play_next();
if let Some(ref path) = state.current_file {
player.play(path)?;
// 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;
}
} else {
state.player_state = PlayerState::Stopped;
}
}
terminal.draw(|f| ui::render(f, state))?;
if event::poll(std::time::Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
handle_key_event(terminal, state, player, key).await?;
// Check if mpv process died (e.g., user closed video window)
if !player.is_process_alive() {
if let Some(player_state) = player.get_player_state() {
if player_state != PlayerState::Stopped {
state.current_position = 0.0;
state.current_duration = 0.0;
state_changed = true;
}
}
}
// 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 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);
// Only update if displayed value (rounded to seconds) changed
let old_display_secs = last_position as u32;
let new_display_secs = new_position as u32;
if new_display_secs != old_display_secs {
state.current_position = new_position;
last_position = new_position;
state_changed = true;
}
if state.current_duration != new_duration {
state.current_duration = new_duration;
state_changed = true;
}
}
}
// Save current state for next iteration
previous_player_state = Some(player_state);
// Only redraw if something changed or forced
if needs_redraw || state_changed {
terminal.draw(|f| {
let areas = ui::render(f, state, player);
title_bar_area = areas.0;
file_panel_area = areas.1;
playlist_area = areas.2;
})?;
needs_redraw = false;
}
// Poll for events - use longer timeout when stopped to reduce CPU
let poll_duration = if player_state == PlayerState::Stopped {
std::time::Duration::from_millis(POLL_DURATION_STOPPED_MS)
} else {
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, &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, &mut skip_position_update)?;
needs_redraw = true; // Force redraw after mouse event
}
_ => {}
}
}
if state.should_quit {
break;
}
@ -125,24 +667,83 @@ 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
let cache_dir = cache::get_cache_dir()?;
// 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"));
// Perform fresh scan
let new_cache = scanner::scan_paths(&state.config.scan_paths.paths)?;
new_cache.save(&cache_dir)?;
// Replace old cache completely
state.cache = new_cache;
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;
}
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
state.show_refresh_confirm = false;
}
_ => {}
}
return Ok(());
}
// Handle search mode separately
if state.search_mode {
match key.code {
KeyCode::Char(c) => {
state.append_search_char(c);
if state.focus_playlist {
state.append_playlist_search_char(c);
} else {
state.append_search_char(c);
}
}
KeyCode::Backspace => {
state.backspace_search();
if state.focus_playlist {
state.backspace_playlist_search();
} else {
state.backspace_search();
}
}
KeyCode::Tab => {
state.tab_search_next();
if state.focus_playlist {
state.playlist_tab_search_next();
} else {
state.tab_search_next();
}
}
KeyCode::BackTab => {
state.tab_search_prev();
if state.focus_playlist {
state.playlist_tab_search_prev();
} else {
state.tab_search_prev();
}
}
KeyCode::Enter => {
state.execute_search();
if state.focus_playlist {
state.execute_playlist_search();
} else {
state.execute_search();
}
}
KeyCode::Esc => {
state.exit_search_mode();
@ -152,134 +753,512 @@ async fn handle_key_event<B: ratatui::backend::Backend>(terminal: &mut Terminal<
return Ok(());
}
// Handle context menu navigation if menu is shown
if let Some(ref mut menu) = state.context_menu {
use crate::state::ContextMenuType;
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if menu.selected_index > 0 {
menu.selected_index -= 1;
}
}
KeyCode::Down | KeyCode::Char('j') => {
let max_items = match menu.menu_type {
ContextMenuType::FilePanel => 2,
ContextMenuType::Playlist => 2,
ContextMenuType::TitleBar => 3,
};
if menu.selected_index < max_items - 1 {
menu.selected_index += 1;
}
}
KeyCode::Enter => {
// Execute selected action
let menu_type = menu.menu_type;
let selected = menu.selected_index;
state.context_menu = None;
handle_context_menu_action(menu_type, selected, state, player, skip_position_update)?;
}
KeyCode::Esc => {
state.context_menu = None;
}
_ => {}
}
return Ok(());
}
match (key.code, key.modifiers) {
(KeyCode::Char('q'), _) => {
state.should_quit = true;
}
(KeyCode::Char('/'), _) => {
state.enter_search_mode();
if state.search_mode && state.search_query.is_empty() {
// Exit search mode only if search query is blank
state.exit_search_mode();
} else if !state.search_mode {
state.enter_search_mode();
}
}
(KeyCode::Esc, _) => {
state.search_matches.clear();
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 stopped, start from current index (0), otherwise go to next
if state.player_state == PlayerState::Stopped && !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)?;
tracing::info!("Starting playlist: {:?}", path);
}
} else {
state.play_next();
if let Some(ref path) = state.current_file {
player.play(path)?;
tracing::info!("Next track: {:?}", path);
}
}
} else if !state.playlist_search_matches.is_empty() {
state.next_playlist_search_match();
}
}
(KeyCode::Char('N'), KeyModifiers::SHIFT) => {
state.prev_search_match();
if !state.search_matches.is_empty() {
state.prev_search_match();
} else if !state.playlist_search_matches.is_empty() {
state.prev_playlist_search_match();
}
}
(KeyCode::Char('J'), KeyModifiers::SHIFT) => {
// Next track
action_navigate_track(state, player, 1, skip_position_update)?;
}
(KeyCode::Char('K'), KeyModifiers::SHIFT) => {
// Previous track
action_navigate_track(state, player, -1, skip_position_update)?;
}
(KeyCode::Char('d'), KeyModifiers::CONTROL) => {
state.page_down();
if state.focus_playlist {
state.playlist_page_down();
} else {
state.page_down();
}
}
(KeyCode::Char('u'), KeyModifiers::CONTROL) => {
state.page_up();
if state.focus_playlist {
state.playlist_page_up();
} else {
state.page_up();
}
}
(KeyCode::Tab, _) => {
// Switch focus between file panel and playlist panel
state.focus_playlist = !state.focus_playlist;
}
(KeyCode::Char('k'), _) | (KeyCode::Up, _) => {
state.move_selection_up();
if state.focus_playlist {
state.move_playlist_selection_up();
} else {
state.move_selection_up();
}
}
(KeyCode::Char('j'), _) | (KeyCode::Down, _) => {
state.move_selection_down();
if state.focus_playlist {
state.move_playlist_selection_down(state.playlist_visible_height);
} else {
state.move_selection_down();
}
}
(KeyCode::Char('h'), _) => {
state.collapse_selected();
(KeyCode::Char('h'), _) | (KeyCode::Left, _) => {
if !state.focus_playlist {
state.collapse_selected();
}
}
(KeyCode::Char('l'), _) => {
state.expand_selected();
(KeyCode::Char('l'), _) | (KeyCode::Right, _) => {
if !state.focus_playlist {
state.expand_selected();
}
}
(KeyCode::Char('v'), _) => {
state.toggle_mark();
if !state.focus_playlist {
state.toggle_mark();
}
}
(KeyCode::Char('a'), _) => {
state.add_to_playlist();
if !state.focus_playlist {
state.add_to_playlist();
if state.visual_mode {
state.visual_mode = false;
state.marked_files.clear();
}
}
}
(KeyCode::Char('c'), _) => {
state.clear_playlist();
}
(KeyCode::Char('p'), _) => {
state.play_previous();
if let Some(ref path) = state.current_file {
player.play(path)?;
tracing::info!("Previous track: {:?}", path);
(KeyCode::Char('d'), _) => {
if state.focus_playlist {
action_remove_from_playlist(state, player)?;
}
}
(KeyCode::Enter, _) => {
state.play_selection();
if let Some(ref path) = state.current_file {
player.play(path)?;
tracing::info!("Playing: {:?} (playlist: {} tracks)", path, state.playlist.len());
if state.focus_playlist {
if state.selected_playlist_index < state.playlist.len() {
action_play_from_playlist(state, player, false, skip_position_update)?;
}
} else {
action_play_selection(state, player, skip_position_update)?;
}
}
(KeyCode::Char('s'), _) => {
action_stop(state, player, skip_position_update)?;
}
(KeyCode::Char('m'), _) => {
state.cycle_play_mode();
tracing::info!("Play mode: {:?}", state.play_mode);
}
(KeyCode::Char('R'), KeyModifiers::SHIFT) => {
state.shuffle_playlist();
tracing::info!("Playlist shuffled");
}
(KeyCode::Char(' '), _) => {
match state.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 => {}
}
action_toggle_play_pause(state, player)?;
}
(KeyCode::Left, _) => {
if state.player_state != PlayerState::Stopped {
player.seek(-10.0)?;
tracing::info!("Seek backward 10s");
}
(KeyCode::Char('H'), KeyModifiers::SHIFT) => {
action_seek(player, -10.0)?;
}
(KeyCode::Right, _) => {
if state.player_state != PlayerState::Stopped {
player.seek(10.0)?;
tracing::info!("Seek forward 10s");
}
(KeyCode::Char('L'), KeyModifiers::SHIFT) => {
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.is_refreshing = true;
terminal.draw(|f| ui::render(f, state))?; // Show "Refreshing library..." immediately
tracing::info!("Rescanning...");
let cache_dir = cache::get_cache_dir()?;
let new_cache = scanner::scan_paths(&state.config.scan_paths.paths)?;
new_cache.save(&cache_dir)?;
state.cache = new_cache;
state.refresh_flattened_items();
state.is_refreshing = false;
tracing::info!("Rescan complete");
state.show_refresh_confirm = true;
}
_ => {}
}
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, skip_position_update: &mut bool) -> Result<()> {
use crossterm::event::MouseButton;
use crate::state::ContextMenuType;
let x = mouse.column;
let y = mouse.row;
// Handle context menu if open (like cm-dashboard)
if let Some(ref menu) = state.context_menu.clone() {
// Calculate popup bounds
let items = match menu.menu_type {
ContextMenuType::FilePanel => 2,
ContextMenuType::Playlist => 2,
ContextMenuType::TitleBar => 3,
};
let popup_width = 13;
let popup_height = items as u16 + 2; // +2 for borders
// Get screen dimensions
let screen_width = title_bar_area.width.max(file_panel_area.width + playlist_area.width);
let screen_height = title_bar_area.height + file_panel_area.height.max(playlist_area.height) + 1;
let popup_x = if menu.x + popup_width < screen_width {
menu.x
} else {
screen_width.saturating_sub(popup_width)
};
let popup_y = if menu.y + popup_height < screen_height {
menu.y
} else {
screen_height.saturating_sub(popup_height)
};
let popup_area = ratatui::layout::Rect {
x: popup_x,
y: popup_y,
width: popup_width,
height: popup_height,
};
// Check if mouse is in popup area
let in_popup = x >= popup_area.x
&& x < popup_area.x + popup_area.width
&& y >= popup_area.y
&& y < popup_area.y + popup_area.height;
// Update selected index on mouse move
if matches!(mouse.kind, MouseEventKind::Moved) {
if in_popup {
let relative_y = y.saturating_sub(popup_y + 1) as usize; // +1 for top border
if relative_y < items {
if let Some(ref mut menu) = state.context_menu {
menu.selected_index = relative_y;
}
}
}
return Ok(());
}
// Handle left click
if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
if in_popup {
// Click inside popup - execute action
let relative_y = y.saturating_sub(popup_y + 1) as usize; // +1 for top border
if relative_y < items {
let menu_type = menu.menu_type;
let selected = relative_y;
state.context_menu = None;
handle_context_menu_action(menu_type, selected, state, player, skip_position_update)?;
}
return Ok(());
} else {
// Click outside popup - close it
state.context_menu = None;
return Ok(());
}
}
// Any other event while popup is open - don't process panels
return Ok(());
}
match mouse.kind {
MouseEventKind::ScrollDown => {
// Check which panel the mouse is over
if x >= title_bar_area.x
&& x < title_bar_area.x + title_bar_area.width
&& y >= title_bar_area.y
&& y < title_bar_area.y + title_bar_area.height
{
// Scroll on title bar = decrease volume
action_volume_down(state, player)?;
} else if x >= playlist_area.x
&& x < playlist_area.x + playlist_area.width
&& y >= playlist_area.y
&& y < playlist_area.y + playlist_area.height
{
// Scroll playlist
let visible_height = playlist_area.height.saturating_sub(2) as usize;
state.scroll_playlist_down(visible_height);
} else {
// Scroll file panel
let visible_height = file_panel_area.height.saturating_sub(2) as usize;
state.scroll_view_down(visible_height);
}
}
MouseEventKind::ScrollUp => {
// Check which panel the mouse is over
if x >= title_bar_area.x
&& x < title_bar_area.x + title_bar_area.width
&& y >= title_bar_area.y
&& y < title_bar_area.y + title_bar_area.height
{
// Scroll on title bar = increase volume
action_volume_up(state, player)?;
} else if x >= playlist_area.x
&& x < playlist_area.x + playlist_area.width
&& y >= playlist_area.y
&& y < playlist_area.y + playlist_area.height
{
// Scroll playlist
state.scroll_playlist_up();
} else {
// Scroll file panel
state.scroll_view_up();
}
}
MouseEventKind::Down(button) => {
// Check if click is on title bar
if x >= title_bar_area.x
&& x < title_bar_area.x + title_bar_area.width
&& y >= title_bar_area.y
&& y < title_bar_area.y + title_bar_area.height
{
match button {
MouseButton::Left => {
// Left click on title bar = play/pause toggle
action_toggle_play_pause(state, player)?;
}
MouseButton::Right => {
// Right click on title bar = show context menu
use crate::state::{ContextMenu, ContextMenuType};
state.context_menu = Some(ContextMenu {
menu_type: ContextMenuType::TitleBar,
x,
y,
selected_index: 0,
});
}
_ => {}
}
}
// 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 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
if clicked_index < state.flattened_items.len() {
state.selected_index = clicked_index;
state.focus_playlist = false; // Switch focus to file panel
// Handle different mouse buttons
match button {
MouseButton::Left => {
// Detect double-click (same item within 500ms)
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() < DOUBLE_CLICK_MS
} else {
false
};
if is_double_click {
// Double click = toggle folder or play file
if let Some(item) = state.get_selected_item() {
if item.is_dir {
action_toggle_folder(state);
} else {
action_play_selection(state, player, skip_position_update)?;
}
}
// Reset click tracking after action
state.last_click_time = None;
state.last_click_index = None;
} else {
// Single click = just select
state.last_click_time = Some(now);
state.last_click_index = Some(clicked_index);
state.last_click_is_playlist = false;
}
}
MouseButton::Right => {
// Right click = show context menu at mouse position
use crate::state::{ContextMenu, ContextMenuType};
state.context_menu = Some(ContextMenu {
menu_type: ContextMenuType::FilePanel,
x,
y,
selected_index: 0,
});
}
_ => {}
}
}
}
// Check if click is within playlist area
else if x >= playlist_area.x
&& x < playlist_area.x + playlist_area.width
&& y >= playlist_area.y
&& y < playlist_area.y + playlist_area.height
{
// 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
let actual_track = state.playlist_scroll_offset + clicked_track;
match button {
MouseButton::Left => {
if actual_track < state.playlist.len() {
state.selected_playlist_index = actual_track;
state.focus_playlist = true; // Switch focus to playlist
// Detect double-click (same track within 500ms)
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() < DOUBLE_CLICK_MS
} else {
false
};
if is_double_click {
// Double click = play the track (preserve pause state)
state.selected_playlist_index = actual_track;
action_play_from_playlist(state, player, true, skip_position_update)?;
// Reset click tracking after action
state.last_click_time = None;
state.last_click_index = None;
} else {
// Single click = just select
state.last_click_time = Some(now);
state.last_click_index = Some(actual_track);
state.last_click_is_playlist = true;
}
}
}
MouseButton::Right => {
// Right click shows context menu at mouse position
if actual_track < state.playlist.len() {
state.selected_playlist_index = actual_track;
state.focus_playlist = true; // Switch focus to playlist
use crate::state::{ContextMenu, ContextMenuType};
state.context_menu = Some(ContextMenu {
menu_type: ContextMenuType::Playlist,
x,
y,
selected_index: 0,
});
}
}
_ => {}
}
}
}
_ => {}
}
Ok(())
}

View File

@ -13,8 +13,13 @@ 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>,
pub audio_codec: Option<String>,
pub audio_bitrate: Option<f64>,
pub sample_rate: Option<i64>,
pub cache_duration: Option<f64>,
}
impl Player {
@ -30,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())
@ -39,76 +45,53 @@ 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,
audio_codec: None,
audio_bitrate: None,
sample_rate: None,
cache_duration: None,
})
}
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();
self.socket = Some(stream);
}
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);
}
// 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
return Err(anyhow::anyhow!("Failed to connect: {}", e));
}
};
// Set non-blocking and timeout to prevent hangs on reads/writes
stream.set_nonblocking(true)?;
stream.set_read_timeout(Some(Duration::from_millis(100)))?;
stream.set_write_timeout(Some(Duration::from_millis(100)))?;
self.socket = Some(stream);
tracing::debug!("Connected to MPV socket successfully");
}
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;
// Wait for socket to be created and mpv to be ready
std::thread::sleep(Duration::from_millis(800));
tracing::info!("MPV process respawned");
Ok(())
}
fn send_command(&mut self, command: &str, args: &[Value]) -> Result<()> {
self.connect()?;
@ -125,7 +108,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(());
@ -137,90 +119,187 @@ 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.position = 0.0;
self.duration = 0.0;
Ok(())
}
pub fn set_volume(&mut self, volume: i64) -> Result<()> {
self.send_command("set_property", &[json!("volume"), json!(volume)])?;
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())));
// 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())));
// Audio codec
self.audio_codec = results.get("audio-codec-name")
.and_then(|v| v.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));
// Sample rate
self.sample_rate = results.get("audio-params/samplerate")
.and_then(|v| v.as_i64());
// Cache duration
self.cache_duration = results.get("demuxer-cache-duration")
.and_then(|v| v.as_f64());
}
pub fn get_position(&self) -> Option<f64> {
@ -231,8 +310,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 {
@ -241,14 +334,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

@ -63,7 +63,10 @@ pub fn scan_directory(root_path: &Path) -> Result<FileTreeNode> {
if entry.file_type().is_dir() {
// Recursively scan subdirectories
if let Ok(child_node) = scan_directory(path) {
node.children.push(child_node);
// 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) {
// Add media file
@ -78,9 +81,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),
};

View File

@ -1,7 +1,28 @@
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 {
@ -10,12 +31,34 @@ pub enum PlayerState {
Paused,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlayMode {
Normal, // Play through once
Loop, // Repeat playlist
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContextMenu {
pub menu_type: ContextMenuType,
pub x: u16,
pub y: u16,
pub selected_index: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ContextMenuType {
FilePanel, // Shows "Play" and "Add"
Playlist, // Shows "Remove" and "Randomise"
TitleBar, // Shows "Stop" and "Loop"
}
pub struct AppState {
pub cache: Cache,
pub config: Config,
pub selected_index: usize,
pub scroll_offset: usize,
pub player_state: PlayerState,
pub file_panel_visible_height: usize,
pub playlist_visible_height: usize,
pub current_file: Option<PathBuf>,
pub current_position: f64,
pub current_duration: f64,
@ -26,18 +69,36 @@ pub struct AppState {
pub marked_files: HashSet<PathBuf>,
pub playlist: Vec<PathBuf>,
pub playlist_index: usize,
pub playlist_scroll_offset: usize,
pub selected_playlist_index: usize,
pub is_refreshing: bool,
pub search_mode: bool,
pub search_query: String,
pub search_matches: Vec<usize>,
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 visual_mode: bool,
pub visual_anchor: usize,
pub saved_expanded_dirs: HashSet<PathBuf>,
pub show_refresh_confirm: bool,
pub focus_playlist: bool,
pub last_click_time: Option<Instant>,
pub last_click_index: Option<usize>,
pub last_click_is_playlist: bool,
pub context_menu: Option<ContextMenu>,
pub play_mode: PlayMode,
}
#[derive(Debug, Clone)]
pub struct FlattenedItem {
pub node: FileTreeNode,
pub path: PathBuf,
pub name: String,
pub is_dir: bool,
pub depth: usize,
}
@ -53,7 +114,8 @@ impl AppState {
config,
selected_index: 0,
scroll_offset: 0,
player_state: PlayerState::Stopped,
file_panel_visible_height: 20,
playlist_visible_height: 20,
current_file: None,
current_position: 0.0,
current_duration: 0.0,
@ -64,6 +126,8 @@ impl AppState {
marked_files: HashSet::new(),
playlist: Vec::new(),
playlist_index: 0,
playlist_scroll_offset: 0,
selected_playlist_index: 0,
is_refreshing: false,
search_mode: false,
search_query: String::new(),
@ -71,6 +135,20 @@ impl AppState {
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,
visual_mode: false,
visual_anchor: 0,
saved_expanded_dirs: HashSet::new(),
show_refresh_confirm: false,
focus_playlist: false,
last_click_time: None,
last_click_index: None,
last_click_is_playlist: false,
context_menu: None,
play_mode: PlayMode::Normal,
}
}
@ -81,58 +159,249 @@ impl AppState {
if self.selected_index < self.scroll_offset {
self.scroll_offset = self.selected_index;
}
// Update visual selection if in visual mode
if self.visual_mode {
self.update_visual_selection();
}
}
}
pub fn move_selection_down(&mut self) {
if self.selected_index < self.flattened_items.len().saturating_sub(1) {
self.selected_index += 1;
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 {
self.scroll_offset = self.selected_index - effective_height + 1;
}
// Update visual selection if in visual mode
if self.visual_mode {
self.update_visual_selection();
}
}
}
pub fn update_scroll_offset(&mut self, visible_height: usize) {
// Scroll down when selection reaches bottom
if self.selected_index >= self.scroll_offset + visible_height {
self.scroll_offset = self.selected_index - visible_height + 1;
pub fn scroll_view_up(&mut self) {
// Scroll view up without changing selection
if self.scroll_offset > 0 {
self.scroll_offset -= 1;
}
// Scroll up when selection reaches top
if self.selected_index < self.scroll_offset {
self.scroll_offset = self.selected_index;
}
pub fn scroll_view_down(&mut self, visible_height: usize) {
// Scroll view down without changing selection
let max_scroll = self.flattened_items.len().saturating_sub(visible_height);
if self.scroll_offset < max_scroll {
self.scroll_offset += 1;
}
}
pub fn scroll_playlist_up(&mut self) {
// Scroll playlist view up
if self.playlist_scroll_offset > 0 {
self.playlist_scroll_offset -= 1;
}
}
pub fn scroll_playlist_down(&mut self, visible_height: usize) {
// Scroll playlist view down
let max_scroll = self.playlist.len().saturating_sub(visible_height);
if self.playlist_scroll_offset < max_scroll {
self.playlist_scroll_offset += 1;
}
}
pub fn update_playlist_scroll(&mut self, visible_height: usize) {
// Auto-scroll playlist to keep current track visible
if self.playlist_index >= self.playlist_scroll_offset + visible_height {
// Track is below visible area, scroll down
self.playlist_scroll_offset = self.playlist_index - visible_height + 1;
} else if self.playlist_index < self.playlist_scroll_offset {
// Track is above visible area, scroll up
self.playlist_scroll_offset = self.playlist_index;
}
}
pub fn move_playlist_selection_up(&mut self) {
if self.selected_playlist_index > 0 {
self.selected_playlist_index -= 1;
// Scroll up when selection reaches top
if self.selected_playlist_index < self.playlist_scroll_offset {
self.playlist_scroll_offset = self.selected_playlist_index;
}
}
}
pub fn move_playlist_selection_down(&mut self, visible_height: usize) {
if self.selected_playlist_index < self.playlist.len().saturating_sub(1) {
self.selected_playlist_index += 1;
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 {
self.playlist_scroll_offset = self.selected_playlist_index - effective_height + 1;
}
}
}
pub fn remove_selected_playlist_item(&mut self) {
if self.selected_playlist_index < self.playlist.len() {
self.playlist.remove(self.selected_playlist_index);
// Adjust playlist_index if necessary
if self.playlist_index > self.selected_playlist_index {
self.playlist_index -= 1;
} else if self.playlist_index == self.selected_playlist_index {
// Keep same index (which is now the next track)
if self.playlist_index >= self.playlist.len() && !self.playlist.is_empty() {
self.playlist_index = self.playlist.len() - 1;
}
}
// Adjust selected_playlist_index if at end
if self.selected_playlist_index >= self.playlist.len() && !self.playlist.is_empty() {
self.selected_playlist_index = self.playlist.len() - 1;
}
}
}
pub fn playlist_page_down(&mut self) {
// Move down by half page (vim Ctrl-D behavior)
let half_page = self.playlist_visible_height / 2;
let new_index = (self.selected_playlist_index + half_page).min(self.playlist.len().saturating_sub(1));
self.selected_playlist_index = new_index;
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 {
self.playlist_scroll_offset = self.selected_playlist_index - effective_height + 1;
}
}
pub fn playlist_page_up(&mut self) {
// Move up by half page (vim Ctrl-U behavior)
let half_page = self.playlist_visible_height / 2;
let new_index = self.selected_playlist_index.saturating_sub(half_page);
self.selected_playlist_index = new_index;
// Adjust scroll if needed
if self.selected_playlist_index < self.playlist_scroll_offset {
self.playlist_scroll_offset = self.selected_playlist_index;
}
}
pub fn page_down(&mut self) {
// Move down by half page (vim Ctrl-D behavior)
let half_page = 10; // Default half page size
let half_page = self.file_panel_visible_height / 2;
let new_index = (self.selected_index + half_page).min(self.flattened_items.len().saturating_sub(1));
self.selected_index = new_index;
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 {
self.scroll_offset = self.selected_index - effective_height + 1;
}
}
pub fn page_up(&mut self) {
// Move up by half page (vim Ctrl-U behavior)
let half_page = 10; // Default half page size
let half_page = self.file_panel_visible_height / 2;
let new_index = self.selected_index.saturating_sub(half_page);
self.selected_index = new_index;
// Adjust scroll if needed
if self.selected_index < self.scroll_offset {
self.scroll_offset = self.selected_index;
}
}
pub fn get_selected_item(&self) -> Option<&FlattenedItem> {
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) {
if let Some(item) = self.get_selected_item() {
if item.node.is_dir {
let path = item.node.path.clone();
self.expanded_dirs.remove(&path);
self.rebuild_flattened_items();
let item = self.get_selected_item().cloned();
if let Some(item) = item {
if item.is_dir {
let path = item.path.clone();
let was_expanded = self.expanded_dirs.contains(&path);
if was_expanded {
// Close the expanded folder and keep selection on it
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.path == path) {
self.selected_index = idx;
}
} else {
// Folder is collapsed, close parent instead and jump to it
if let Some(parent) = 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.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.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.path == parent_buf) {
self.selected_index = parent_idx;
}
}
}
}
}
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();
}
@ -140,13 +409,37 @@ impl AppState {
}
pub fn toggle_mark(&mut self) {
if let Some(item) = self.get_selected_item() {
if !item.node.is_dir {
let path = item.node.path.clone();
if self.marked_files.contains(&path) {
self.marked_files.remove(&path);
} else {
self.marked_files.insert(path);
if self.visual_mode {
// Exit visual mode and mark all files in range
self.update_visual_selection();
self.visual_mode = false;
} else {
// Enter visual mode
self.visual_mode = true;
self.visual_anchor = self.selected_index;
// Clear previous marks when entering visual mode
self.marked_files.clear();
// Mark current file
if let Some(item) = self.get_selected_item() {
if !item.is_dir {
self.marked_files.insert(item.path.clone());
}
}
}
}
fn update_visual_selection(&mut self) {
// Clear marks
self.marked_files.clear();
// Mark all files between anchor and current position
let start = self.visual_anchor.min(self.selected_index);
let end = self.visual_anchor.max(self.selected_index);
for i in start..=end {
if let Some(item) = self.flattened_items.get(i) {
if !item.is_dir {
self.marked_files.insert(item.path.clone());
}
}
}
@ -155,36 +448,30 @@ impl AppState {
pub fn clear_playlist(&mut self) {
self.playlist.clear();
self.playlist_index = 0;
self.player_state = PlayerState::Stopped;
self.current_file = None;
}
pub fn add_to_playlist(&mut self) {
// Add marked files or current selection to playlist
if !self.marked_files.is_empty() {
// Add marked files
for path in &self.marked_files {
if !self.playlist.contains(path) {
self.playlist.push(path.clone());
}
}
self.playlist.sort();
// Add marked files (allow duplicates)
let mut files: Vec<PathBuf> = self.marked_files.iter().cloned().collect();
files.sort();
self.playlist.extend(files);
} else if let Some(item) = self.get_selected_item() {
let node = item.node.clone();
if node.is_dir {
// Add all files in directory
let files = collect_files_from_node(&node);
for path in files {
if !self.playlist.contains(&path) {
self.playlist.push(path);
}
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);
files.sort();
self.playlist.extend(files);
}
self.playlist.sort();
} else {
// Add single file
if !self.playlist.contains(&node.path) {
self.playlist.push(node.path.clone());
}
// Add single file (allow duplicates)
self.playlist.push(path);
}
}
}
@ -196,53 +483,191 @@ impl AppState {
self.playlist = self.marked_files.iter().cloned().collect();
self.playlist.sort();
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 playlist
self.current_file = None;
}
} 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);
self.playlist_index = 0;
if let Some(first) = self.playlist.first() {
self.current_file = Some(first.clone());
self.player_state = PlayerState::Playing;
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());
} else {
// Empty directory
self.current_file = None;
}
}
} 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) {
if self.playlist_index + 1 < self.playlist.len() {
self.playlist_index += 1;
self.current_file = Some(self.playlist[self.playlist_index].clone());
self.player_state = PlayerState::Playing;
pub fn play_next(&mut self) -> bool {
if self.playlist.is_empty() {
return false;
}
match self.play_mode {
PlayMode::Normal => {
// Play through once, stop at end
if self.playlist_index + 1 < self.playlist.len() {
self.playlist_index += 1;
if self.playlist_index < self.playlist.len() {
self.current_file = Some(self.playlist[self.playlist_index].clone());
return true; // Should continue playing
}
}
// 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());
true // Should continue playing
}
}
}
pub fn play_previous(&mut self) {
if self.playlist_index > 0 {
self.playlist_index -= 1;
self.current_file = Some(self.playlist[self.playlist_index].clone());
self.player_state = PlayerState::Playing;
pub fn cycle_play_mode(&mut self) {
self.play_mode = match self.play_mode {
PlayMode::Normal => PlayMode::Loop,
PlayMode::Loop => PlayMode::Normal,
};
}
pub fn shuffle_playlist(&mut self) {
if self.playlist.is_empty() {
return;
}
use rand::seq::SliceRandom;
let mut rng = rand::thread_rng();
// Remember the currently playing track
let current_track = if self.playlist_index < self.playlist.len() {
Some(self.playlist[self.playlist_index].clone())
} else {
None
};
// Shuffle the playlist
self.playlist.shuffle(&mut rng);
// Find the new position of the currently playing track
if let Some(track) = current_track {
if let Some(new_index) = self.playlist.iter().position(|p| p == &track) {
self.playlist_index = new_index;
self.selected_playlist_index = new_index;
} else {
self.playlist_index = 0;
self.selected_playlist_index = 0;
}
} else {
self.playlist_index = 0;
self.selected_playlist_index = 0;
}
}
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 rebuild_flattened_items(&mut self) {
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) {
self.flattened_items = flatten_tree(&self.cache.file_tree, 0, &self.expanded_dirs);
if self.selected_index >= self.flattened_items.len() {
self.selected_index = self.flattened_items.len().saturating_sub(1);
@ -252,16 +677,39 @@ impl AppState {
pub fn enter_search_mode(&mut self) {
self.search_mode = true;
self.search_query.clear();
self.search_matches.clear();
self.search_match_index = 0;
self.tab_search_results.clear();
self.tab_search_index = 0;
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;
} else {
// Clear file search state
self.search_matches.clear();
self.search_match_index = 0;
self.tab_search_results.clear();
self.tab_search_index = 0;
// Save current folder state
self.saved_expanded_dirs = self.expanded_dirs.clone();
}
}
pub fn exit_search_mode(&mut self) {
self.search_mode = false;
self.tab_search_results.clear();
self.tab_search_index = 0;
if self.focus_playlist {
// Clear playlist search state
self.playlist_tab_search_results.clear();
self.playlist_tab_search_index = 0;
} else {
// Clear file search state
self.tab_search_results.clear();
self.tab_search_index = 0;
// Restore folder state from before search
self.expanded_dirs = self.saved_expanded_dirs.clone();
self.rebuild_flattened_items();
}
}
pub fn append_search_char(&mut self, c: char) {
@ -278,6 +726,7 @@ impl AppState {
if self.search_query.is_empty() {
self.tab_search_results.clear();
self.tab_search_index = 0;
// Don't rebuild tree on every keystroke - only when exiting search
return;
}
@ -291,30 +740,57 @@ impl AppState {
return;
}
// Sort by score (highest first)
matching_paths_with_scores.sort_by(|a, b| b.1.cmp(&a.1));
// 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)));
// Store all matches for tab completion
self.tab_search_results = matching_paths_with_scores.iter().map(|(path, _)| path.clone()).collect();
self.tab_search_results = indexed_matches.iter().map(|(path, _, _)| path.clone()).collect();
self.tab_search_index = 0;
// Expand parent directories of ALL matches (not just best match)
// This ensures folders deep in the tree become visible
for (path, _) in &matching_paths_with_scores {
let mut parent = path.parent();
// Only expand and rebuild if this is a new best match
let best_match = self.tab_search_results[0].clone();
// Check if we need to expand folders for this match
let needs_expand = best_match.ancestors()
.skip(1) // Skip the file itself
.any(|p| !self.expanded_dirs.contains(p));
if needs_expand {
// Close all folders and expand only for the best match
self.expanded_dirs.clear();
let mut parent = best_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();
}
// Rebuild flattened items
self.rebuild_flattened_items();
// Find the best match in the flattened list and jump to it
let best_match = &self.tab_search_results[0];
if let Some(idx) = self.flattened_items.iter().position(|item| &item.node.path == best_match) {
if let Some(idx) = self.flattened_items.iter().position(|item| item.path == best_match) {
self.selected_index = idx;
// Scroll to show the match
let effective_height = calculate_effective_height(
self.scroll_offset,
self.file_panel_visible_height,
self.flattened_items.len()
);
if self.selected_index < self.scroll_offset {
self.scroll_offset = self.selected_index;
} else if self.selected_index >= self.scroll_offset + effective_height {
self.scroll_offset = self.selected_index - effective_height + 1;
}
}
}
@ -324,7 +800,7 @@ impl AppState {
return;
}
// Collect all matching paths with scores
// 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);
@ -333,35 +809,43 @@ impl AppState {
return;
}
// Sort by score (highest first)
matching_paths_with_scores.sort_by(|a, b| b.1.cmp(&a.1));
// 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();
// Expand all parent directories
for path in &matching_paths {
let mut parent = path.parent();
// 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();
// Rebuild flattened items
self.rebuild_flattened_items();
// Find indices of matches in the flattened list
self.search_matches = matching_paths
.iter()
.filter_map(|path| {
self.flattened_items
.iter()
.position(|item| &item.node.path == path)
})
.collect();
if !self.search_matches.is_empty() {
self.search_match_index = 0;
self.selected_index = self.search_matches[0];
// Find first match in flattened list
if let Some(idx) = self.flattened_items.iter().position(|item| item.path == first_match) {
self.selected_index = idx;
}
}
self.search_mode = false;
@ -370,7 +854,43 @@ impl AppState {
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();
self.selected_index = self.search_matches[self.search_match_index];
let target_path = self.search_matches[self.search_match_index].clone();
// Close all folders and expand only for this match
self.expanded_dirs.clear();
let mut parent = target_path.parent();
while let Some(p) = parent {
self.expanded_dirs.insert(p.to_path_buf());
parent = p.parent();
}
// Rebuild flattened items
self.rebuild_flattened_items();
// Find the path in current flattened items
if let Some(idx) = self.flattened_items.iter().position(|item| item.path == target_path) {
self.selected_index = idx;
// Scroll to show the match
// Account for "... X more below" indicator which takes one line
let visible_end = self.scroll_offset + self.file_panel_visible_height;
let items_below = if visible_end < self.flattened_items.len() {
self.flattened_items.len() - visible_end
} else {
0
};
let effective_height = if items_below > 0 {
self.file_panel_visible_height.saturating_sub(1)
} else {
self.file_panel_visible_height
};
if self.selected_index < self.scroll_offset {
self.scroll_offset = self.selected_index;
} else if self.selected_index >= self.scroll_offset + effective_height {
self.scroll_offset = self.selected_index - effective_height + 1;
}
}
}
}
@ -381,7 +901,43 @@ impl AppState {
} else {
self.search_match_index -= 1;
}
self.selected_index = self.search_matches[self.search_match_index];
let target_path = self.search_matches[self.search_match_index].clone();
// Close all folders and expand only for this match
self.expanded_dirs.clear();
let mut parent = target_path.parent();
while let Some(p) = parent {
self.expanded_dirs.insert(p.to_path_buf());
parent = p.parent();
}
// Rebuild flattened items
self.rebuild_flattened_items();
// Find the path in current flattened items
if let Some(idx) = self.flattened_items.iter().position(|item| item.path == target_path) {
self.selected_index = idx;
// Scroll to show the match
// Account for "... X more below" indicator which takes one line
let visible_end = self.scroll_offset + self.file_panel_visible_height;
let items_below = if visible_end < self.flattened_items.len() {
self.flattened_items.len() - visible_end
} else {
0
};
let effective_height = if items_below > 0 {
self.file_panel_visible_height.saturating_sub(1)
} else {
self.file_panel_visible_height
};
if self.selected_index < self.scroll_offset {
self.scroll_offset = self.selected_index;
} else if self.selected_index >= self.scroll_offset + effective_height {
self.scroll_offset = self.selected_index - effective_height + 1;
}
}
}
}
@ -394,7 +950,8 @@ impl AppState {
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();
// Expand parent directories
// 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());
@ -405,8 +962,21 @@ impl AppState {
self.rebuild_flattened_items();
// Find and select the match
if let Some(idx) = self.flattened_items.iter().position(|item| item.node.path == next_match) {
if let Some(idx) = self.flattened_items.iter().position(|item| item.path == next_match) {
self.selected_index = idx;
// Scroll to show the match
let effective_height = calculate_effective_height(
self.scroll_offset,
self.file_panel_visible_height,
self.flattened_items.len()
);
if self.selected_index < self.scroll_offset {
self.scroll_offset = self.selected_index;
} else if self.selected_index >= self.scroll_offset + effective_height {
self.scroll_offset = self.selected_index - effective_height + 1;
}
}
}
@ -423,7 +993,8 @@ impl AppState {
}
let prev_match = self.tab_search_results[self.tab_search_index].clone();
// Expand parent directories
// 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());
@ -434,8 +1005,251 @@ impl AppState {
self.rebuild_flattened_items();
// Find and select the match
if let Some(idx) = self.flattened_items.iter().position(|item| item.node.path == prev_match) {
if let Some(idx) = self.flattened_items.iter().position(|item| item.path == prev_match) {
self.selected_index = idx;
// Scroll to show the match
let effective_height = calculate_effective_height(
self.scroll_offset,
self.file_panel_visible_height,
self.flattened_items.len()
);
if self.selected_index < self.scroll_offset {
self.scroll_offset = self.selected_index;
} else if self.selected_index >= self.scroll_offset + effective_height {
self.scroll_offset = self.selected_index - effective_height + 1;
}
}
}
pub fn append_playlist_search_char(&mut self, c: char) {
self.search_query.push(c);
self.perform_playlist_incremental_search();
}
pub fn backspace_playlist_search(&mut self) {
self.search_query.pop();
self.perform_playlist_incremental_search();
}
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;
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.playlist_tab_search_results.clear();
self.playlist_tab_search_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;
// 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
let effective_height = calculate_effective_height(
self.playlist_scroll_offset,
self.playlist_visible_height,
self.playlist.len()
);
if self.selected_playlist_index < self.playlist_scroll_offset {
self.playlist_scroll_offset = self.selected_playlist_index;
} else if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height {
self.playlist_scroll_offset = self.selected_playlist_index - effective_height + 1;
}
}
pub fn playlist_tab_search_next(&mut self) {
if self.playlist_tab_search_results.is_empty() {
return;
}
// Cycle to next match
self.playlist_tab_search_index = (self.playlist_tab_search_index + 1) % self.playlist_tab_search_results.len();
let next_match_idx = self.playlist_tab_search_results[self.playlist_tab_search_index];
self.selected_playlist_index = next_match_idx;
// Scroll to show the match
let effective_height = calculate_effective_height(
self.playlist_scroll_offset,
self.playlist_visible_height,
self.playlist.len()
);
if self.selected_playlist_index < self.playlist_scroll_offset {
self.playlist_scroll_offset = self.selected_playlist_index;
} else if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height {
self.playlist_scroll_offset = self.selected_playlist_index - effective_height + 1;
}
}
pub fn playlist_tab_search_prev(&mut self) {
if self.playlist_tab_search_results.is_empty() {
return;
}
// Cycle to previous match
if self.playlist_tab_search_index == 0 {
self.playlist_tab_search_index = self.playlist_tab_search_results.len() - 1;
} else {
self.playlist_tab_search_index -= 1;
}
let prev_match_idx = self.playlist_tab_search_results[self.playlist_tab_search_index];
self.selected_playlist_index = prev_match_idx;
// Scroll to show the match
let effective_height = calculate_effective_height(
self.playlist_scroll_offset,
self.playlist_visible_height,
self.playlist.len()
);
if self.selected_playlist_index < self.playlist_scroll_offset {
self.playlist_scroll_offset = self.selected_playlist_index;
} else if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height {
self.playlist_scroll_offset = self.selected_playlist_index - effective_height + 1;
}
}
pub fn 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;
}
}
}
@ -448,7 +1262,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,
});
@ -475,38 +1291,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 {
@ -514,9 +1336,6 @@ fn fuzzy_match(text: &str, query: &str) -> Option<i32> {
}
}
// Bonus for shorter strings (better matches)
score += 100 - text_lower.len() as i32;
Some(score)
}
@ -525,7 +1344,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

@ -1,16 +1,17 @@
mod theme;
use crate::state::{AppState, PlayerState};
use crate::player::Player;
use crate::state::{AppState, PlayerState, ContextMenu, ContextMenuType, PlayMode};
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Clear},
Frame,
};
use theme::Theme;
pub fn render(frame: &mut Frame, state: &mut AppState) {
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()),
@ -27,108 +28,369 @@ pub fn render(frame: &mut Frame, state: &mut AppState) {
])
.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;
render_title_bar(frame, state, main_chunks[0]);
render_file_panel(frame, state, content_chunks[0]);
render_right_panel(frame, state, content_chunks[1]);
render_status_bar(frame, state, main_chunks[2]);
}
// 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())
};
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;
state.update_scroll_offset(visible_height);
let playlist_style = if state.focus_playlist {
Style::default().fg(Theme::bright_foreground()).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Theme::bright_foreground())
};
let items: Vec<ListItem> = state
.flattened_items
.iter()
.enumerate()
.map(|(idx, item)| {
let indent = " ".repeat(item.depth);
let mark = if state.marked_files.contains(&item.node.path) { "* " } else { "" };
let suffix = if item.node.is_dir { "/" } else { "" };
let text = format!("{}{}{}{}", indent, mark, item.node.name, suffix);
let style = if idx == state.selected_index {
Theme::selected()
} else if item.node.is_dir {
Theme::directory()
} else if state.marked_files.contains(&item.node.path) {
Theme::marked()
} else {
Theme::secondary()
};
ListItem::new(text).style(style)
})
.collect();
let list = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.title("Media Files (Cached)")
.style(Theme::widget_border_style())
.title_style(Theme::title_style()),
);
let mut list_state = ListState::default();
list_state.select(Some(state.selected_index));
*list_state.offset_mut() = state.scroll_offset;
frame.render_stateful_widget(list, area, &mut list_state);
}
fn render_right_panel(frame: &mut Frame, state: &AppState, area: Rect) {
// Playlist panel (no longer need the player status box)
let playlist_items: Vec<ListItem> = state
.playlist
.iter()
.enumerate()
.map(|(idx, path)| {
let filename = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| path.to_string_lossy().to_string());
let style = if idx == state.playlist_index && state.player_state != PlayerState::Stopped {
Theme::selected()
} else {
Theme::secondary()
};
ListItem::new(filename).style(style)
})
.collect();
let playlist_title = if !state.playlist.is_empty() {
// 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 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 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
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]);
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_info_popup(frame, "Refreshing library...");
}
// Show confirmation popup if needed
if state.show_refresh_confirm {
render_confirm_popup(frame, "Refresh library?", "This may take a while");
}
// Show context menu if needed
if let Some(ref menu) = state.context_menu {
render_context_menu(frame, menu);
}
// Return title bar area, file panel area, and playlist area for mouse event handling
// 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>> {
let query_lower = query.to_lowercase();
let mut spans = Vec::new();
let mut query_chars = query_lower.chars();
let mut current_query_char = query_chars.next();
let mut current_segment = String::new();
for ch in text.chars() {
// 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 {
// Found a match - flush current segment
if !current_segment.is_empty() {
spans.push(Span::raw(current_segment.clone()));
current_segment.clear();
}
// Add matched character with styling
if is_selected {
// On selected row: bold black text on selection bar (yellow or blue)
spans.push(Span::styled(
ch.to_string(),
Style::default()
.fg(Theme::background())
.add_modifier(Modifier::BOLD),
));
} else {
// Other rows: just green text
spans.push(Span::styled(
ch.to_string(),
Style::default().fg(Theme::success()),
));
}
// Move to next query character
current_query_char = query_chars.next();
} else {
// No match - add to current segment
current_segment.push(ch);
}
} else {
// No more query characters to match
current_segment.push(ch);
}
}
// Flush remaining segment
if !current_segment.is_empty() {
spans.push(Span::raw(current_segment));
}
spans
}
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 search_query = if in_search { state.search_query.to_lowercase() } else { String::new() };
// Calculate how many items are below the visible area
let total_items = state.flattened_items.len();
let visible_end = state.scroll_offset + visible_height;
let items_below = if visible_end < total_items {
total_items - visible_end
} else {
0
};
// Reserve one line for "X more below" if needed
let list_visible_height = if items_below > 0 {
visible_height.saturating_sub(1)
} else {
visible_height
};
let mut items: Vec<ListItem> = state
.flattened_items
.iter()
.skip(state.scroll_offset)
.take(list_visible_height)
.enumerate()
.map(|(display_idx, item)| {
let idx = state.scroll_offset + display_idx;
let indent = " ".repeat(item.depth);
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.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} " };
// Bold black icon on selection bar, blue otherwise
if is_selected {
Span::styled(icon_char, Style::default().fg(Theme::background()).add_modifier(Modifier::BOLD))
} else {
Span::styled(icon_char, Style::default().fg(Theme::highlight()))
}
} else {
// File icons based on extension
let extension = item.path.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
let (icon_char, color) = match extension.as_str() {
// Audio files - music note icon
"mp3" | "flac" | "wav" | "ogg" | "m4a" | "aac" | "wma" | "opus" =>
("\u{f0e2a} ", Theme::success()), //
// Video files - film icon
"mp4" | "mkv" | "avi" | "mov" | "webm" | "flv" | "wmv" | "m4v" =>
("\u{f1c8} ", Theme::warning()), //
_ => (" ", Theme::foreground()),
};
if is_selected {
Span::styled(icon_char, Style::default().fg(Theme::background()).add_modifier(Modifier::BOLD))
} else {
Span::styled(icon_char, Style::default().fg(color))
}
};
let name_spans = if in_search && !search_query.is_empty() {
highlight_search_matches(&item.name, &search_query, is_selected)
} else {
vec![Span::raw(&item.name)]
};
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
if in_search {
Theme::search_selected()
} else {
Theme::selected()
}
} else if state.marked_files.contains(&item.path) {
Theme::marked()
} else {
Theme::secondary()
};
let mut line_spans = vec![
Span::raw(indent),
Span::raw(mark),
icon,
];
line_spans.extend(name_spans);
line_spans.push(Span::raw(suffix));
let line = Line::from(line_spans);
ListItem::new(line).style(base_style)
})
.collect();
// Add "... X more below" message if content was truncated
if items_below > 0 {
let more_text = format!("... {} more below", items_below);
let more_item = ListItem::new(more_text)
.style(Style::default().fg(Theme::dim_foreground()).bg(Theme::background()));
items.push(more_item);
}
let list = List::new(items);
let mut list_state = ListState::default();
// Don't set selection to avoid automatic scrolling - we manage scroll manually
// Just set the offset (always 0 since we manually slice the items)
*list_state.offset_mut() = 0;
frame.render_stateful_widget(list, area, &mut list_state);
}
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;
// Calculate how many items are below the visible area
let total_playlist = state.playlist.len();
let visible_end = state.playlist_scroll_offset + visible_height;
let items_below = if visible_end < total_playlist {
total_playlist - visible_end
} else {
0
};
// Reserve one line for "X more below" if needed
let list_visible_height = if items_below > 0 {
visible_height.saturating_sub(1)
} else {
visible_height
};
// 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 playlist_search_query = if in_playlist_search { state.search_query.to_lowercase() } else { String::new() };
// Playlist panel (no longer need the player status box)
let mut playlist_items: Vec<ListItem> = state
.playlist
.iter()
.skip(state.playlist_scroll_offset)
.take(list_visible_height)
.enumerate()
.map(|(display_idx, path)| {
let idx = state.playlist_scroll_offset + display_idx;
let filename = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| path.to_string_lossy().to_string());
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 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_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
if in_playlist_search {
Theme::search_selected().add_modifier(Modifier::BOLD)
} else {
Theme::selected().add_modifier(Modifier::BOLD)
}
} else if is_selected {
// Selection bar when playlist is focused
if in_playlist_search {
Theme::search_selected()
} else {
Theme::selected()
}
} else if is_playing {
// Current playing file: white bold
Style::default()
.fg(Theme::bright_foreground())
.bg(Theme::background())
.add_modifier(Modifier::BOLD)
} else {
Theme::secondary()
};
ListItem::new(line).style(style)
})
.collect();
// Add "... X more below" message if content was truncated
if items_below > 0 {
let more_text = format!("... {} more below", items_below);
let more_item = ListItem::new(more_text)
.style(Style::default().fg(Theme::dim_foreground()).bg(Theme::background()));
playlist_items.push(more_item);
}
let playlist_widget = List::new(playlist_items);
let mut playlist_state = ListState::default();
if state.player_state != PlayerState::Stopped && !state.playlist.is_empty() {
playlist_state.select(Some(state.playlist_index));
}
// Don't set selection to avoid automatic scrolling - we manage scroll manually
// Just set the offset (always 0 since we manually slice the items)
*playlist_state.offset_mut() = 0;
frame.render_stateful_widget(playlist_widget, area, &mut playlist_state);
}
fn render_title_bar(frame: &mut Frame, state: &AppState, area: Rect) {
let background_color = Theme::success(); // Green for normal operation
fn render_title_bar(frame: &mut Frame, state: &AppState, player: &mut Player, area: Rect) {
// Default to stopped if we can't query MPV
let player_state = player.get_player_state().unwrap_or(PlayerState::Stopped);
let background_color = match player_state {
PlayerState::Playing => Theme::success(), // Green for playing
PlayerState::Paused => Theme::highlight(), // Blue for paused
PlayerState::Stopped => Theme::dim_foreground(), // Gray for stopped
};
// Split the title bar into left and right sections
let chunks = Layout::default()
@ -152,24 +414,15 @@ fn render_title_bar(frame: &mut Frame, state: &AppState, area: Rect) {
// 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)
@ -182,6 +435,20 @@ fn render_title_bar(frame: &mut Frame, state: &AppState, area: Rect) {
right_spans.push(Span::styled(status_text.to_string(), status_style));
// Play mode indicator
let mode_text = match state.play_mode {
PlayMode::Normal => "",
PlayMode::Loop => " [Loop]",
};
if !mode_text.is_empty() {
right_spans.push(Span::styled(
mode_text.to_string(),
Style::default()
.fg(Theme::background())
.bg(background_color)
));
}
// Progress
let progress_text = if state.current_duration > 0.0 {
let position_mins = (state.current_position / 60.0) as u32;
@ -205,29 +472,11 @@ fn render_title_bar(frame: &mut Frame, state: &AppState, area: Rect) {
// Volume
right_spans.push(Span::styled(
format!(" • Vol: {}%", state.volume),
format!(" • Vol: {}% ", state.volume),
Style::default()
.fg(Theme::background())
.bg(background_color)
));
// Add search info if active
if !state.search_matches.is_empty() {
right_spans.push(Span::styled(
format!(" • Search: {}/{} ", state.search_match_index + 1, state.search_matches.len()),
Style::default()
.fg(Theme::background())
.bg(background_color)
.add_modifier(Modifier::BOLD)
));
} else {
right_spans.push(Span::styled(
" ",
Style::default()
.fg(Theme::background())
.bg(background_color)
));
}
}
let right_title = Paragraph::new(Line::from(right_spans))
@ -236,25 +485,393 @@ fn render_title_bar(frame: &mut Frame, state: &AppState, area: Rect) {
frame.render_widget(right_title, chunks[1]);
}
fn render_status_bar(frame: &mut Frame, state: &AppState, area: Rect) {
if state.search_mode {
// Show search prompt with current query and match count - LEFT aligned
let search_text = if !state.tab_search_results.is_empty() {
format!("/{}_ Search: {}/{}", state.search_query, state.tab_search_index + 1, state.tab_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(Theme::foreground()).bg(Theme::background()));
frame.render_widget(status_bar, area);
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 {
// Normal mode shortcuts (always shown when not in search mode)
let shortcuts = "/: Search • v: Mark • a: Add to Playlist • c: Clear Playlist • Enter: Play • Space: Pause • ←→: Seek • +/-: Volume • n/p: Next/Prev • r: Rescan • q: Quit";
let status_bar = Paragraph::new(shortcuts)
.style(Style::default().fg(Theme::muted_text()).bg(Theme::background()))
.alignment(Alignment::Center);
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
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())
} else if !state.search_query.is_empty() {
format!("/{}_ [no matches]", state.search_query)
} else {
format!("/{}_", state.search_query)
}
} 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())
} else if !state.search_query.is_empty() {
format!("/{}_ [no matches]", state.search_query)
} else {
format!("/{}_", state.search_query)
}
}
} else if !state.search_matches.is_empty() {
// Show search navigation when file search results are active
format!("/{} Search: {}/{}", state.search_query, state.search_match_index + 1, state.search_matches.len())
} else if !state.playlist_search_matches.is_empty() {
// Show search navigation when playlist search results are active
format!("/{} Playlist Search: {}/{}", state.search_query, state.playlist_search_match_index + 1, state.playlist_search_matches.len())
} else if state.visual_mode {
// Show visual mode indicator
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)
let mut left_parts = Vec::new();
let mut right_parts = Vec::new();
// Left side: Artist | Album | Title
if let Some(ref artist) = player.artist {
left_parts.push(artist.clone());
}
if let Some(ref album) = player.album {
left_parts.push(album.clone());
}
if let Some(ref title) = player.media_title {
left_parts.push(title.clone());
}
// Right side: Bitrate | Codec | Sample rate | Cache
if let Some(bitrate) = player.audio_bitrate {
right_parts.push(format!("{:.0} kbps", bitrate));
}
if let Some(ref codec) = player.audio_codec {
right_parts.push(codec.to_uppercase());
}
if let Some(samplerate) = player.sample_rate {
right_parts.push(format!("{} Hz", samplerate));
}
// Create layout for left and right sections
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(area);
// Left side text
let left_text = if !left_parts.is_empty() {
format!(" {}", left_parts.join(" | "))
} else {
String::new()
};
let left_bar = Paragraph::new(left_text)
.style(Style::default().fg(Theme::muted_text()).bg(Theme::background()))
.alignment(Alignment::Left);
frame.render_widget(left_bar, chunks[0]);
// Right side text
let right_text = if !right_parts.is_empty() {
format!("{} ", right_parts.join(" | "))
} else {
String::new()
};
let right_bar = Paragraph::new(right_text)
.style(Style::default().fg(Theme::muted_text()).bg(Theme::background()))
.alignment(Alignment::Right);
frame.render_widget(right_bar, chunks[1]);
}
}
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_info_popup(frame: &mut Frame, message: &str) {
// Create centered popup area - smaller than confirm popup
let area = frame.area();
let popup_width = 40;
let popup_height = 3;
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);
// Render message centered
let message_widget = Paragraph::new(message)
.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();
let popup_width = 52;
let popup_height = 7;
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()
.title(title)
.borders(Borders::ALL)
.style(Style::default()
.bg(Theme::background())
.fg(Theme::bright_foreground()))
.title_style(Style::default()
.fg(Theme::bright_foreground())
.add_modifier(Modifier::BOLD));
let inner = block.inner(popup_area);
frame.render_widget(block, popup_area);
// Render message and prompt
let text_area = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // Empty line
Constraint::Length(1), // Message
Constraint::Length(1), // Empty line
Constraint::Length(1), // Prompt
])
.split(inner);
let message_text = Paragraph::new(message)
.style(Style::default()
.fg(Theme::bright_foreground())
.bg(Theme::background()))
.alignment(Alignment::Center);
frame.render_widget(message_text, text_area[1]);
let prompt_text = Paragraph::new("Press 'y' to confirm or 'n' to cancel")
.style(Style::default()
.fg(Theme::foreground())
.bg(Theme::background()))
.alignment(Alignment::Center);
frame.render_widget(prompt_text, text_area[3]);
}
fn render_context_menu(frame: &mut Frame, menu: &ContextMenu) {
// Determine menu items based on type
let items = match menu.menu_type {
ContextMenuType::FilePanel => vec!["Play", "Add"],
ContextMenuType::Playlist => vec!["Remove", "Randomise"],
ContextMenuType::TitleBar => vec!["Stop", "Loop", "Refresh"],
};
// Calculate popup size
let width = 13;
let height = items.len() as u16 + 2; // +2 for borders
// Position popup near click location, but keep it on screen
let screen_width = frame.area().width;
let screen_height = frame.area().height;
let x = if menu.x + width < screen_width {
menu.x
} else {
screen_width.saturating_sub(width)
};
let y = if menu.y + height < screen_height {
menu.y
} else {
screen_height.saturating_sub(height)
};
let popup_area = Rect {
x,
y,
width,
height,
};
// Create menu items with selection highlight
let menu_items: Vec<ListItem> = items
.iter()
.enumerate()
.map(|(i, item)| {
let style = if i == menu.selected_index {
Style::default()
.fg(Color::Black)
.bg(Color::White)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Theme::bright_foreground())
};
ListItem::new(*item).style(style)
})
.collect();
let menu_list = List::new(menu_items)
.block(
Block::default()
.borders(Borders::ALL)
.style(Style::default().bg(Theme::background()).fg(Theme::bright_foreground()))
);
// Clear the area and render menu
frame.render_widget(Clear, popup_area);
frame.render_widget(menu_list, popup_area);
}

View File

@ -47,10 +47,6 @@ impl Theme {
Self::dim_foreground()
}
pub fn border_title() -> Color {
Self::bright_foreground()
}
pub fn highlight() -> Color {
Self::normal_blue()
}
@ -68,12 +64,6 @@ impl Theme {
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())
@ -86,15 +76,15 @@ impl Theme {
.bg(Self::highlight())
}
pub fn directory() -> Style {
Style::default()
.fg(Self::normal_blue())
.bg(Self::background())
}
pub fn marked() -> Style {
Style::default()
.fg(Self::warning())
.bg(Self::background())
}
pub fn search_selected() -> Style {
Style::default()
.fg(Self::background())
.bg(Self::warning())
}
}