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