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:
parent
a1fd8eb6e5
commit
7ce264fd96
122
.gitea/workflows/release.yml
Normal file
122
.gitea/workflows/release.yml
Normal 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
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
/target
|
||||
Cargo.lock
|
||||
147
CLAUDE.md
Normal file
147
CLAUDE.md
Normal 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
38
Cargo.toml
Normal 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
89
src/cache/mod.rs
vendored
Normal 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
61
src/config/mod.rs
Normal 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
146
src/main.rs
Normal 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
38
src/player/mod.rs
Normal 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
131
src/scanner/mod.rs
Normal 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
92
src/state/mod.rs
Normal 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
141
src/ui/mod.rs
Normal 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]);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user