From 7ce264fd964ce49d29c5aa762af48bab8d211a4e Mon Sep 17 00:00:00 2001 From: Christoffer Martinsson Date: Sat, 6 Dec 2025 12:32:17 +0100 Subject: [PATCH] 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 --- .gitea/workflows/release.yml | 122 +++++++++++++++++++++++++++++ .gitignore | 2 + CLAUDE.md | 147 +++++++++++++++++++++++++++++++++++ Cargo.toml | 38 +++++++++ src/cache/mod.rs | 89 +++++++++++++++++++++ src/config/mod.rs | 61 +++++++++++++++ src/main.rs | 146 ++++++++++++++++++++++++++++++++++ src/player/mod.rs | 38 +++++++++ src/scanner/mod.rs | 131 +++++++++++++++++++++++++++++++ src/state/mod.rs | 92 ++++++++++++++++++++++ src/ui/mod.rs | 141 +++++++++++++++++++++++++++++++++ 11 files changed, 1007 insertions(+) create mode 100644 .gitea/workflows/release.yml create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 Cargo.toml create mode 100644 src/cache/mod.rs create mode 100644 src/config/mod.rs create mode 100644 src/main.rs create mode 100644 src/player/mod.rs create mode 100644 src/scanner/mod.rs create mode 100644 src/state/mod.rs create mode 100644 src/ui/mod.rs diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..c8abe88 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96ef6c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b2b3e0c --- /dev/null +++ b/CLAUDE.md @@ -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 diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..844dc3a --- /dev/null +++ b/Cargo.toml @@ -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 diff --git a/src/cache/mod.rs b/src/cache/mod.rs new file mode 100644 index 0000000..d012608 --- /dev/null +++ b/src/cache/mod.rs @@ -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, + pub codec: Option, + pub hash: Option, + 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, + pub metadata: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Cache { + pub file_tree: Vec, + pub metadata: HashMap, +} + +impl Cache { + pub fn new() -> Self { + Self { + file_tree: Vec::new(), + metadata: HashMap::new(), + } + } + + pub fn load(cache_dir: &Path) -> Result { + 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 = 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 = 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 { + let cache_home = dirs::cache_dir() + .context("Failed to get cache directory")?; + Ok(cache_home.join("cm-player")) +} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..a377a1f --- /dev/null +++ b/src/config/mod.rs @@ -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, +} + +#[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 { + 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 { + let config_home = dirs::config_dir() + .context("Failed to get config directory")?; + Ok(config_home.join("cm-player").join("config.toml")) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..07cc520 --- /dev/null +++ b/src/main.rs @@ -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( + terminal: &mut Terminal, + 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(()) +} diff --git a/src/player/mod.rs b/src/player/mod.rs new file mode 100644 index 0000000..bafac66 --- /dev/null +++ b/src/player/mod.rs @@ -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 { + 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(()) + } +} diff --git a/src/scanner/mod.rs b/src/scanner/mod.rs new file mode 100644 index 0000000..2f6cc90 --- /dev/null +++ b/src/scanner/mod.rs @@ -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 { + 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 { + 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); + } +} diff --git a/src/state/mod.rs b/src/state/mod.rs new file mode 100644 index 0000000..ceb5dd7 --- /dev/null +++ b/src/state/mod.rs @@ -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, + pub current_position: f64, + pub current_duration: f64, + pub volume: i64, + pub should_quit: bool, + pub flattened_items: Vec, +} + +#[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 { + 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 +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..628fc03 --- /dev/null +++ b/src/ui/mod.rs @@ -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 = 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]); +}