Implement Phase 1: Foundation and cache system

- Add Cargo project with TUI and async dependencies
- Implement cache-only architecture for low bandwidth operation
- Add file scanner with media type detection
- Create two-panel TUI layout (file tree and status)
- Add config file support for scan path management
- Implement XDG-compliant cache and config directories
- Add Gitea CI/CD workflow for automated releases
This commit is contained in:
Christoffer Martinsson 2025-12-06 12:32:17 +01:00
parent a1fd8eb6e5
commit 7ce264fd96
11 changed files with 1007 additions and 0 deletions

View File

@ -0,0 +1,122 @@
name: Build and Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
version:
description: 'Version to release (e.g., v0.1.0)'
required: true
default: 'v0.1.0'
jobs:
build-and-release:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
profile: minimal
override: true
- name: Install system dependencies
run: |
apt-get update
apt-get install -y pkg-config libssl-dev
- name: Build static binary
run: |
export RUSTFLAGS="-C target-feature=+crt-static"
cargo build --release --target x86_64-unknown-linux-gnu
- name: Create release directory
run: |
mkdir -p release
cp target/x86_64-unknown-linux-gnu/release/cm-player release/cm-player-linux-x86_64
- name: Create tarball
run: |
cd release
tar -czf cm-player-linux-x86_64.tar.gz cm-player-linux-x86_64
- name: Set version variable
id: version
run: |
if [ "${{ gitea.event_name }}" == "workflow_dispatch" ]; then
echo "VERSION=${{ gitea.event.inputs.version }}" >> $GITHUB_OUTPUT
else
echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
fi
- name: Create Release with curl
env:
GITEA_TOKEN: ${{ secrets.GITEATOKEN }}
run: |
VERSION="${{ steps.version.outputs.VERSION }}"
# Create release
curl -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"tag_name": "'$VERSION'",
"name": "cm-player '$VERSION'",
"body": "## cm-player '$VERSION'\n\nPre-built static binary for Linux x86_64:\n- cm-player-linux-x86_64 - Music and video TUI player\n- cm-player-linux-x86_64.tar.gz - Tarball"
}' \
"https://gitea.cmtec.se/api/v1/repos/cm/cm-player/releases"
# Get release ID
RELEASE_ID=$(curl -s -H "Authorization: token $GITEA_TOKEN" \
"https://gitea.cmtec.se/api/v1/repos/cm/cm-player/releases/tags/$VERSION" | \
grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2)
# Upload binaries
curl -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-F "attachment=@release/cm-player-linux-x86_64" \
"https://gitea.cmtec.se/api/v1/repos/cm/cm-player/releases/$RELEASE_ID/assets?name=cm-player-linux-x86_64"
curl -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-F "attachment=@release/cm-player-linux-x86_64.tar.gz" \
"https://gitea.cmtec.se/api/v1/repos/cm/cm-player/releases/$RELEASE_ID/assets?name=cm-player-linux-x86_64.tar.gz"
- name: Update NixOS Configuration
env:
GITEA_TOKEN: ${{ secrets.GITEATOKEN }}
run: |
VERSION="${{ steps.version.outputs.VERSION }}"
# Clone nixosbox repository
git clone https://$GITEA_TOKEN@gitea.cmtec.se/cm/nixosbox.git nixosbox-update
cd nixosbox-update
# Get hash for the new release tarball
TARBALL_URL="https://gitea.cmtec.se/cm/cm-player/releases/download/$VERSION/cm-player-linux-x86_64.tar.gz"
# Download tarball to get correct hash
curl -L -o cm-player.tar.gz "$TARBALL_URL"
# Convert sha256 hex to base64 for Nix hash format using Python
NEW_HASH=$(sha256sum cm-player.tar.gz | cut -d' ' -f1)
NIX_HASH="sha256-$(python3 -c "import base64, binascii; print(base64.b64encode(binascii.unhexlify('$NEW_HASH')).decode())")"
# Update the NixOS configuration
sed -i "s|version = \"v[^\"]*\"|version = \"$VERSION\"|" hosts/services/cm-player.nix
sed -i "s|sha256 = \"sha256-[^\"]*\"|sha256 = \"$NIX_HASH\"|" hosts/services/cm-player.nix
# Commit and push changes
git config user.name "Gitea Actions"
git config user.email "actions@gitea.cmtec.se"
git add hosts/services/cm-player.nix
git commit -m "Auto-update cm-player to $VERSION
- Update version to $VERSION with automated release
- Update tarball hash for new static binary
- Automated update from cm-player release workflow"
git push

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/target
Cargo.lock

147
CLAUDE.md Normal file
View File

@ -0,0 +1,147 @@
# CM Player - Music and Video TUI
## Overview
A high-performance Rust-based TUI player for playing music and video files. Built to use mpv.
## Key features
- Scan and cache file structure
- Made for use on low bandwidh vpn connections
- Use same TUI layout/theme as cm-dashboard
- Left panel is file structure
- Right panel is media status
## Architecture
### Cache-Only Operation
**CRITICAL:** Left panel shows ONLY cached data. Never browse filesystem directly during operation.
**Configuration:** `~/.config/cm-player/config.toml`
```toml
[scan_paths]
paths = [
"/media/music",
"/media/videos"
]
```
**Cache Structure:** `~/.cache/cm-player/`
- `file_tree.json` - Full cached file structure
- `metadata.json` - File metadata (duration, codec, size)
**Workflow:**
1. User configures paths in config.toml
2. Scanner scans configured paths → builds cache
3. Left panel displays cached tree (instant, no filesystem access)
4. Playback uses cached file paths
5. Rescan command/keybinding to refresh cache
### Implementation Plan
**Phase 1: Foundation + Cache System (CRITICAL)**
- Create Cargo.toml with dependencies (ratatui, crossterm, libmpv-rs, tokio, serde)
- Set up cache directory structure (XDG_CACHE_HOME/cm-player/)
- Implement cache serialization/deserialization
- File metadata cache schema (path, size, duration, codec, hash)
- Config file parsing (scan paths)
- Basic TUI two-panel layout
- State management with cache integration
**Phase 2: File Scanner with Caching**
- Directory traversal with media filtering (.mp3, .mp4, .mkv, .flac, etc.)
- Write all metadata to cache immediately
- Load from cache on startup (instant)
- Background refresh/validation
- Display cached structure in left panel with tree navigation
**Phase 3: MPV Integration**
- Initialize libmpv
- Playback from cached file paths
- Status display in right panel
- Event handling (position, duration, state)
**Phase 4: Player Controls**
- Keyboard shortcuts (space: pause, q: quit, arrows: navigate/seek, r: rescan)
- Volume control
- Progress bar
- Playlist/queue (cached)
**Phase 5: Polish**
- Error handling
- Bandwidth optimization validation
- CI integration with release workflow
## Automated Binary Release System
CM Player uses automated binary releases instead of source builds.
### Creating New Releases
```bash
cd ~/projects/cm-player
git tag v0.1.X
git push origin v0.1.X
```
This automatically:
- Builds static binaries with `RUSTFLAGS="-C target-feature=+crt-static"`
- Creates GitHub-style release with tarball
- Uploads binaries via Gitea API
### NixOS Configuration Updates
Edit `~/projects/nixosbox/hosts/services/cm-player.nix`:
```nix
version = "v0.1.X";
src = pkgs.fetchurl {
url = "https://gitea.cmtec.se/cm/cm-player/releases/download/${version}/cm-player-linux-x86_64.tar.gz";
sha256 = "sha256-NEW_HASH_HERE";
};
```
## Important Communication Guidelines
Keep responses concise and focused. Avoid extensive implementation summaries unless requested.
## Commit Message Guidelines
**NEVER mention:**
- Claude or any AI assistant names
- Automation or AI-generated content
- Any reference to automated code generation
**ALWAYS:**
- Focus purely on technical changes and their purpose
- Use standard software development commit message format
- Describe what was changed and why, not how it was created
- Write from the perspective of a human developer
**Examples:**
- ❌ "Generated with Claude Code"
- ❌ "AI-assisted implementation"
- ❌ "Automated refactoring"
- ✅ "Implement maintenance mode for backup operations"
- ✅ "Restructure storage widget with improved layout"
- ✅ "Update CPU thresholds to production values"
## Implementation Rules
**NEVER:**
- Copy/paste ANY code from legacy implementations
- Create files unless absolutely necessary for achieving goals
- Create documentation files unless explicitly requested
**ALWAYS:**
- Prefer editing existing files to creating new ones
- Follow existing code conventions and patterns
- Use existing libraries and utilities
- Follow security best practices

38
Cargo.toml Normal file
View File

@ -0,0 +1,38 @@
[package]
name = "cm-player"
version = "0.1.0"
edition = "2021"
[dependencies]
# TUI
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"
toml = "0.8"
# File system
walkdir = "2.5"
dirs = "5.0"
# MPV player
libmpv = "2.0"
# Error handling
anyhow = "1.0"
thiserror = "1.0"
# Logging
tracing = "0.1"
tracing-subscriber = "0.3"
[profile.release]
opt-level = 3
lto = true
codegen-units = 1
strip = true

89
src/cache/mod.rs vendored Normal file
View File

@ -0,0 +1,89 @@
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
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,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileTreeNode {
pub name: String,
pub path: PathBuf,
pub is_dir: bool,
pub children: Vec<FileTreeNode>,
pub metadata: Option<FileMetadata>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Cache {
pub file_tree: Vec<FileTreeNode>,
pub metadata: HashMap<PathBuf, FileMetadata>,
}
impl Cache {
pub fn new() -> Self {
Self {
file_tree: Vec::new(),
metadata: HashMap::new(),
}
}
pub fn load(cache_dir: &Path) -> Result<Self> {
let tree_path = cache_dir.join("file_tree.json");
let metadata_path = cache_dir.join("metadata.json");
if !tree_path.exists() || !metadata_path.exists() {
return Ok(Self::new());
}
let tree_data = fs::read_to_string(&tree_path)
.context("Failed to read file_tree.json")?;
let file_tree: Vec<FileTreeNode> = serde_json::from_str(&tree_data)
.context("Failed to parse file_tree.json")?;
let metadata_data = fs::read_to_string(&metadata_path)
.context("Failed to read metadata.json")?;
let metadata: HashMap<PathBuf, FileMetadata> = serde_json::from_str(&metadata_data)
.context("Failed to parse metadata.json")?;
Ok(Self {
file_tree,
metadata,
})
}
pub fn save(&self, cache_dir: &Path) -> Result<()> {
fs::create_dir_all(cache_dir)
.context("Failed to create cache directory")?;
let tree_path = cache_dir.join("file_tree.json");
let tree_data = serde_json::to_string_pretty(&self.file_tree)
.context("Failed to serialize file_tree")?;
fs::write(&tree_path, tree_data)
.context("Failed to write file_tree.json")?;
let metadata_path = cache_dir.join("metadata.json");
let metadata_data = serde_json::to_string_pretty(&self.metadata)
.context("Failed to serialize metadata")?;
fs::write(&metadata_path, metadata_data)
.context("Failed to write metadata.json")?;
Ok(())
}
}
pub fn get_cache_dir() -> Result<PathBuf> {
let cache_home = dirs::cache_dir()
.context("Failed to get cache directory")?;
Ok(cache_home.join("cm-player"))
}

61
src/config/mod.rs Normal file
View File

@ -0,0 +1,61 @@
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScanPaths {
pub paths: Vec<PathBuf>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub scan_paths: ScanPaths,
}
impl Default for Config {
fn default() -> Self {
Self {
scan_paths: ScanPaths {
paths: vec![],
},
}
}
}
impl Config {
pub fn load(config_path: &Path) -> Result<Self> {
if !config_path.exists() {
let default_config = Self::default();
default_config.save(config_path)?;
return Ok(default_config);
}
let config_data = fs::read_to_string(config_path)
.context("Failed to read config file")?;
let config: Config = toml::from_str(&config_data)
.context("Failed to parse config file")?;
Ok(config)
}
pub fn save(&self, config_path: &Path) -> Result<()> {
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent)
.context("Failed to create config directory")?;
}
let config_data = toml::to_string_pretty(self)
.context("Failed to serialize config")?;
fs::write(config_path, config_data)
.context("Failed to write config file")?;
Ok(())
}
}
pub fn get_config_path() -> Result<PathBuf> {
let config_home = dirs::config_dir()
.context("Failed to get config directory")?;
Ok(config_home.join("cm-player").join("config.toml"))
}

146
src/main.rs Normal file
View File

@ -0,0 +1,146 @@
mod cache;
mod config;
mod player;
mod scanner;
mod state;
mod ui;
use anyhow::{Context, Result};
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal};
use state::{AppState, PlayerState};
use std::io;
use tracing_subscriber;
#[tokio::main]
async fn main() -> Result<()> {
// Initialize logging
tracing_subscriber::fmt::init();
// Get config and cache paths
let config_path = config::get_config_path()?;
let cache_dir = cache::get_cache_dir()?;
// Load config
let config = config::Config::load(&config_path)
.context("Failed to load config")?;
tracing::info!("Loaded config from {:?}", config_path);
// Load cache
let mut cache = cache::Cache::load(&cache_dir)
.context("Failed to load cache")?;
tracing::info!("Loaded cache from {:?}", cache_dir);
// If cache is empty and we have scan paths, perform initial scan
if cache.file_tree.is_empty() && !config.scan_paths.paths.is_empty() {
tracing::info!("Cache is empty, performing initial scan...");
cache = scanner::scan_paths(&config.scan_paths.paths)?;
cache.save(&cache_dir)?;
tracing::info!("Initial scan complete, cache saved");
}
// Initialize player
let _player = player::Player::new()?;
// Initialize app state
let mut state = AppState::new(cache, config);
// Setup terminal
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
// Run app
let result = run_app(&mut terminal, &mut state).await;
// Restore terminal
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
result
}
async fn run_app<B: ratatui::backend::Backend>(
terminal: &mut Terminal<B>,
state: &mut AppState,
) -> Result<()> {
loop {
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(state, key.code).await?;
}
}
}
if state.should_quit {
break;
}
}
Ok(())
}
async fn handle_key_event(state: &mut AppState, key_code: KeyCode) -> Result<()> {
match key_code {
KeyCode::Char('q') => {
state.should_quit = true;
}
KeyCode::Up => {
state.move_selection_up();
}
KeyCode::Down => {
state.move_selection_down();
}
KeyCode::Enter => {
if let Some(item) = state.get_selected_item() {
if !item.node.is_dir {
let path = item.node.path.clone();
state.current_file = Some(path.clone());
state.player_state = PlayerState::Playing;
tracing::info!("Playing: {:?}", path);
}
}
}
KeyCode::Char(' ') => {
match state.player_state {
PlayerState::Playing => {
state.player_state = PlayerState::Paused;
tracing::info!("Paused");
}
PlayerState::Paused => {
state.player_state = PlayerState::Playing;
tracing::info!("Resumed");
}
PlayerState::Stopped => {}
}
}
KeyCode::Char('r') => {
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();
tracing::info!("Rescan complete");
}
_ => {}
}
Ok(())
}

38
src/player/mod.rs Normal file
View File

@ -0,0 +1,38 @@
// Player module - MPV integration placeholder
// Full implementation in Phase 3
use anyhow::Result;
use std::path::Path;
pub struct Player {
// MPV instance will be added in Phase 3
}
impl Player {
pub fn new() -> Result<Self> {
Ok(Self {})
}
pub fn play(&mut self, _path: &Path) -> Result<()> {
// TODO: Implement MPV playback in Phase 3
tracing::info!("Play called (not yet implemented)");
Ok(())
}
pub fn pause(&mut self) -> Result<()> {
// TODO: Implement pause in Phase 3
tracing::info!("Pause called (not yet implemented)");
Ok(())
}
pub fn stop(&mut self) -> Result<()> {
// TODO: Implement stop in Phase 3
tracing::info!("Stop called (not yet implemented)");
Ok(())
}
pub fn set_volume(&mut self, _volume: i64) -> Result<()> {
// TODO: Implement volume control in Phase 3
Ok(())
}
}

131
src/scanner/mod.rs Normal file
View File

@ -0,0 +1,131 @@
use crate::cache::{Cache, FileMetadata, FileTreeNode};
use anyhow::Result;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
const AUDIO_EXTENSIONS: &[&str] = &["mp3", "flac", "wav", "ogg", "m4a", "aac", "opus", "wma"];
const VIDEO_EXTENSIONS: &[&str] = &["mp4", "mkv", "avi", "mov", "wmv", "flv", "webm", "m4v"];
pub fn is_media_file(path: &Path) -> bool {
if let Some(ext) = path.extension() {
let ext_str = ext.to_string_lossy().to_lowercase();
AUDIO_EXTENSIONS.contains(&ext_str.as_str()) || VIDEO_EXTENSIONS.contains(&ext_str.as_str())
} else {
false
}
}
pub fn is_audio_file(path: &Path) -> bool {
if let Some(ext) = path.extension() {
let ext_str = ext.to_string_lossy().to_lowercase();
AUDIO_EXTENSIONS.contains(&ext_str.as_str())
} else {
false
}
}
pub fn is_video_file(path: &Path) -> bool {
if let Some(ext) = path.extension() {
let ext_str = ext.to_string_lossy().to_lowercase();
VIDEO_EXTENSIONS.contains(&ext_str.as_str())
} else {
false
}
}
pub fn scan_directory(root_path: &Path) -> Result<FileTreeNode> {
let name = root_path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let mut node = FileTreeNode {
name,
path: root_path.to_path_buf(),
is_dir: true,
children: Vec::new(),
metadata: None,
};
let mut entries: Vec<_> = WalkDir::new(root_path)
.max_depth(1)
.min_depth(1)
.into_iter()
.filter_map(|e| e.ok())
.collect();
entries.sort_by_key(|e| e.path().to_path_buf());
for entry in entries {
let path = entry.path();
if entry.file_type().is_dir() {
// Recursively scan subdirectories
if let Ok(child_node) = scan_directory(path) {
node.children.push(child_node);
}
} else if is_media_file(path) {
// Add media file
let file_name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
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),
};
let file_node = FileTreeNode {
name: file_name,
path: path.to_path_buf(),
is_dir: false,
children: Vec::new(),
metadata: Some(metadata),
};
node.children.push(file_node);
}
}
Ok(node)
}
pub fn scan_paths(paths: &[PathBuf]) -> Result<Cache> {
let mut cache = Cache::new();
for path in paths {
if path.exists() {
tracing::info!("Scanning path: {:?}", path);
let tree_node = scan_directory(path)?;
// Collect all metadata from the tree
collect_metadata(&tree_node, &mut cache);
cache.file_tree.push(tree_node);
} else {
tracing::warn!("Path does not exist: {:?}", path);
}
}
Ok(cache)
}
fn collect_metadata(node: &FileTreeNode, cache: &mut Cache) {
if let Some(ref metadata) = node.metadata {
cache.metadata.insert(node.path.clone(), metadata.clone());
}
for child in &node.children {
collect_metadata(child, cache);
}
}

92
src/state/mod.rs Normal file
View File

@ -0,0 +1,92 @@
use crate::cache::{Cache, FileTreeNode};
use crate::config::Config;
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlayerState {
Stopped,
Playing,
Paused,
}
pub struct AppState {
pub cache: Cache,
pub config: Config,
pub selected_index: usize,
pub scroll_offset: usize,
pub player_state: PlayerState,
pub current_file: Option<PathBuf>,
pub current_position: f64,
pub current_duration: f64,
pub volume: i64,
pub should_quit: bool,
pub flattened_items: Vec<FlattenedItem>,
}
#[derive(Debug, Clone)]
pub struct FlattenedItem {
pub node: FileTreeNode,
pub depth: usize,
pub is_expanded: bool,
}
impl AppState {
pub fn new(cache: Cache, config: Config) -> Self {
let flattened_items = flatten_tree(&cache.file_tree, 0);
Self {
cache,
config,
selected_index: 0,
scroll_offset: 0,
player_state: PlayerState::Stopped,
current_file: None,
current_position: 0.0,
current_duration: 0.0,
volume: 100,
should_quit: false,
flattened_items,
}
}
pub fn move_selection_up(&mut self) {
if self.selected_index > 0 {
self.selected_index -= 1;
}
}
pub fn move_selection_down(&mut self) {
if self.selected_index < self.flattened_items.len().saturating_sub(1) {
self.selected_index += 1;
}
}
pub fn get_selected_item(&self) -> Option<&FlattenedItem> {
self.flattened_items.get(self.selected_index)
}
pub fn refresh_flattened_items(&mut self) {
self.flattened_items = flatten_tree(&self.cache.file_tree, 0);
if self.selected_index >= self.flattened_items.len() {
self.selected_index = self.flattened_items.len().saturating_sub(1);
}
}
}
fn flatten_tree(nodes: &[FileTreeNode], depth: usize) -> Vec<FlattenedItem> {
let mut result = Vec::new();
for node in nodes {
result.push(FlattenedItem {
node: node.clone(),
depth,
is_expanded: true, // For now, all directories are expanded
});
if node.is_dir && !node.children.is_empty() {
result.extend(flatten_tree(&node.children, depth + 1));
}
}
result
}

141
src/ui/mod.rs Normal file
View File

@ -0,0 +1,141 @@
use crate::state::{AppState, PlayerState};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, Paragraph},
Frame,
};
pub fn render(frame: &mut Frame, state: &AppState) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(frame.area());
render_file_panel(frame, state, chunks[0]);
render_status_panel(frame, state, chunks[1]);
}
fn render_file_panel(frame: &mut Frame, state: &AppState, area: Rect) {
let items: Vec<ListItem> = state
.flattened_items
.iter()
.enumerate()
.map(|(idx, item)| {
let indent = " ".repeat(item.depth);
let prefix = if item.node.is_dir { "📁 " } else if item.node.metadata.as_ref().map(|m| m.is_video).unwrap_or(false) { "🎥 " } else { "🎵 " };
let text = format!("{}{}{}", indent, prefix, item.node.name);
let style = if idx == state.selected_index {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else if item.node.is_dir {
Style::default().fg(Color::Blue)
} else {
Style::default()
};
ListItem::new(text).style(style)
})
.collect();
let list = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.title("Media Files (Cached)")
.style(Style::default().fg(Color::White)),
);
frame.render_widget(list, area);
}
fn render_status_panel(frame: &mut Frame, state: &AppState, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Min(0),
])
.split(area);
// Player state
let state_text = match state.player_state {
PlayerState::Stopped => "⏹ Stopped",
PlayerState::Playing => "▶ Playing",
PlayerState::Paused => "⏸ Paused",
};
let state_widget = Paragraph::new(state_text)
.block(Block::default().borders(Borders::ALL).title("Status"))
.style(Style::default().fg(Color::White));
frame.render_widget(state_widget, chunks[0]);
// Current file
let current_file = state
.current_file
.as_ref()
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "None".to_string());
let file_widget = Paragraph::new(current_file)
.block(Block::default().borders(Borders::ALL).title("Current File"))
.style(Style::default().fg(Color::White));
frame.render_widget(file_widget, chunks[1]);
// Progress
let progress_text = if state.current_duration > 0.0 {
let position_mins = (state.current_position / 60.0) as u32;
let position_secs = (state.current_position % 60.0) as u32;
let duration_mins = (state.current_duration / 60.0) as u32;
let duration_secs = (state.current_duration % 60.0) as u32;
format!(
"{:02}:{:02} / {:02}:{:02}",
position_mins, position_secs, duration_mins, duration_secs
)
} else {
"00:00 / 00:00".to_string()
};
let progress_widget = Paragraph::new(progress_text)
.block(Block::default().borders(Borders::ALL).title("Progress"))
.style(Style::default().fg(Color::White));
frame.render_widget(progress_widget, chunks[2]);
// Volume
let volume_text = format!("{}%", state.volume);
let volume_widget = Paragraph::new(volume_text)
.block(Block::default().borders(Borders::ALL).title("Volume"))
.style(Style::default().fg(Color::White));
frame.render_widget(volume_widget, chunks[3]);
// Help
let help_text = vec![
Line::from(""),
Line::from(vec![
Span::styled("↑/↓", Style::default().fg(Color::Cyan)),
Span::raw(" Navigate"),
]),
Line::from(vec![
Span::styled("Enter", Style::default().fg(Color::Cyan)),
Span::raw(" Play"),
]),
Line::from(vec![
Span::styled("Space", Style::default().fg(Color::Cyan)),
Span::raw(" Pause/Resume"),
]),
Line::from(vec![
Span::styled("r", Style::default().fg(Color::Cyan)),
Span::raw(" Rescan"),
]),
Line::from(vec![
Span::styled("q", Style::default().fg(Color::Cyan)),
Span::raw(" Quit"),
]),
];
let help_widget = Paragraph::new(help_text)
.block(Block::default().borders(Borders::ALL).title("Help"))
.style(Style::default().fg(Color::White));
frame.render_widget(help_widget, chunks[4]);
}