feat: initial release v0.1.0-beta - complete TUI audio player for Navidrome

This commit is contained in:
RarDog
2026-08-27 21:12:03 +03:00
commit e551de9fb7
24 changed files with 6948 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/target
Generated
+3105
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
[package]
name = "rhyth-rs"
version = "0.1.0-beta"
edition = "2021"
authors = ["RarDog"]
description = "A sleek, modern TUI audio player for Navidrome and Subsonic music servers written in Rust"
license = "MIT"
[dependencies]
# Async Runtime
tokio = { version = "1.43", features = ["full"] }
# HTTP & API
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
url = "2.5"
md-5 = "0.10"
hex = "0.4"
rand = "0.8"
bytes = "1.10"
# Audio Playback & Analysis
rodio = { version = "0.20", default-features = false }
symphonia = { version = "0.5", features = ["all"] }
rustfft = "6.2"
# Image processing for Cover Art Thumbnail
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
# TUI & Terminal
ratatui = { version = "0.29", features = ["all-widgets", "crossterm"] }
crossterm = { version = "0.28", features = ["event-stream"] }
# Error Handling & Utilities
anyhow = "1.0"
thiserror = "2.0"
parking_lot = "0.12"
toml = "0.8"
dirs = "6.0"
tracing = "0.1"
tracing-subscriber = "0.3"
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 RarDog
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+153
View File
@@ -0,0 +1,153 @@
# 🎵 rhyth-rs (v0.1.0-beta)
<div align="center">
[![Rust](https://img.shields.io/badge/Rust-1.75%2B-orange?logo=rust)](https://www.rust-lang.org/)
[![Navidrome](https://img.shields.io/badge/Navidrome-Compatible-00ADD8?logo=musicbrainz)](https://www.navidrome.org/)
[![Subsonic API](https://img.shields.io/badge/OpenSubsonic-API-purple)](https://opensubsonic.netlify.app/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Ratatui](https://img.shields.io/badge/TUI-Ratatui-green)](https://ratatui.rs/)
*A blazing fast, modern, and beautiful terminal audio player for Navidrome and OpenSubsonic servers, written in pure Rust.*
</div>
---
## ✨ Features
- 🎧 **Pure Symphonia Audio Pipeline**: Flawless bit-perfect decoding for all major audio formats (**FLAC, MP3, AAC, ALAC, Vorbis, Opus, WAV**) with zero playback panics and crystal-clear sound via Rodio audio engine.
- 🌈 **Liquid-Smooth Audio Visualizer**: Multi-frequency spectrum analyzer with spring-damping physics, peak-hold indicators, and dynamic TrueColor frequency gradients (Pink ➔ Sapphire ➔ Mint ➔ Gold).
- 🖱️ **Full Mouse Interaction**:
- **Seek & Scrub**: Click or drag across the progress bar to jump anywhere in the song.
- **Click to Play**: Select artists, albums, and tracks directly with mouse clicks or double-click.
- **Scroll Wheel**: Scroll library panes or adjust volume seamlessly.
- **Interactive Controls**: Clickable play/pause, next/prev, shuffle, repeat, star, volume, and header buttons.
- 🔁 **Smart Queue & Playback Modes**:
- Auto-advance to the next song when a track ends.
- **Shuffle** mode (`🔀`) and **Repeat** modes (`🔁 Off`, `🔁 All`, `🔂 One`).
- Dedicated **Queue Viewer** (<kbd>u</kbd>) and **Lyrics Modal** (<kbd>y</kbd>).
- ❤️ **Favorites & Scrobbling**:
- Star / Like tracks (<kbd>f</kbd> / `❤️`) synced with your Navidrome server.
- Automatic playback scrobbling to Navidrome and Last.fm (>50% played).
- 🎨 **4 Premium TrueColor Themes**:
- **Catppuccin Mocha** (Default)
- **Tokyo Night**
- **Nord**
- **Gruvbox Dark**
- *(Press <kbd>t</kbd> to cycle themes instantly!)*
- 🖼️ **Terminal Cover Art**: Unicode Half-block pixel art thumbnail rendering.
- ⚙️ **Interactive Setup & Settings**: Built-in 2-column configuration wizard on first launch with persistent TOML config.
- 🔍 **Instant Global Search**: Search artists, albums, and songs across your entire music library (<kbd>/</kbd>).
---
## 📸 Interface Preview
```text
🎵 rhyth-rs v0.1.0-beta │ Theme: Catppuccin Mocha [c] Settings [?] Help [/] Search [t] Theme [q] Quit
┌─ Artists [142] ────────────┐┌─ Albums [6] ───────────────────────────────────────────────────────────────────┐
│ 🎤 Daft Punk (6) ││ 💿 Discovery (2001) [14 tracks] │
│ 🎤 Gorillaz (8) ││ 💿 Random Access Memories (2013) [13 tracks] │
│ 🎤 Radiohead (9) ││ 💿 Alive 2007 (2007) [12 tracks] │
│ 🎤 Tame Impala (4) │└────────────────────────────────────────────────────────────────────────────────┘
│ 🎤 The Weeknd (5) │┌─ Tracks [14] ──────────────────────────────────────────────────────────────────┐
│ 🎤 Pink Floyd (11) ││ 🔊 ❤️ One More Time [05:20] │
│ 🎤 Queen (15) ││ 02. Aerodynamic [03:27] │
│ 🎤 Arctic Monkeys (6) ││ 03. Digital Love [04:58] │
└────────────────────────────┘└────────────────────────────────────────────────────────────────────────────────┘
┌─ 🎵 rhyth-rs v0.1.0-beta ────────────────────────────────────────────────────────────────────────────────────┐
│ ❤️ One More Time • Daft Punk ⏮ ⏸ ⏭ │ 🔀 🔁 │ ▂▃▅▆▇▆▄▃▂▂▃▄▅ [y] Lyrics [u] Queue 🔊 85% │
│ 02:14 ━━━●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 05:20 (Click to Seek) │
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
```
---
## 🚀 Quick Start
### 1. Build & Run from Source
Make sure you have [Rust](https://rustup.rs/) installed:
```bash
# Clone the repository
git clone https://gitea.rardogsynapse.online/RarDog/rhyth-rs.git
cd rhyth-rs
# Run the player
cargo run --release
```
### 2. First-Time Setup
When you first launch `rhyth-rs`, an interactive connection modal will appear:
1. Enter your **Navidrome / Subsonic Server URL** (e.g. `http://localhost:4533` or `https://music.yourdomain.com`).
2. Enter your **Username** and **Password**.
3. Select your favorite **Theme** and press **`[ Connect & Save ]`**!
Configuration is automatically saved to:
* **Windows**: `%APPDATA%\rhyth-rs\config.toml`
* **Linux / macOS**: `~/.config/rhyth-rs/config.toml`
---
## ⌨️ Controls & Shortcuts
| Key | Action | Mouse Equivalent |
|:---|:---|:---|
| <kbd>Space</kbd> | Play / Pause toggle | Click `[ ▶ / ⏸ ]` |
| <kbd>n</kbd> / <kbd>p</kbd> | Next / Previous track | Click `[ ⏭ ]` / `[ ⏮ ]` |
| <kbd>Enter</kbd> | Play selected track / Expand album | Double-click track |
| <kbd>j</kbd> / <kbd>↓</kbd> | Move down | Scroll wheel down |
| <kbd>k</kbd> / <kbd>↑</kbd> | Move up | Scroll wheel up |
| <kbd>h</kbd> / <kbd>l</kbd> / <kbd>Tab</kbd> | Switch between Artists, Albums, and Tracks | Click pane |
| <kbd>s</kbd> | Toggle Shuffle mode | Click `[ 🔀 ]` |
| <kbd>r</kbd> | Toggle Repeat mode (*Off* ➔ *All* ➔ *One*) | Click `[ 🔁 ]` |
| <kbd>f</kbd> / <kbd>L</kbd> | Star / Favorite track (Like) | Click `[ ❤️ ]` |
| <kbd>y</kbd> | Toggle Lyrics screen | Click `[y] Lyrics` |
| <kbd>u</kbd> | Toggle Queue screen | Click `[u] Queue` |
| <kbd>+</kbd> / <kbd>=</kbd> / <kbd>]</kbd> | Volume Up (+5%) | Click / Drag Volume slider |
| <kbd>-</kbd> / <kbd>_</kbd> / <kbd>[</kbd> | Volume Down (-5%) | Click / Drag Volume slider |
| <kbd>t</kbd> | Cycle Color Theme | Click `[t] Theme` |
| <kbd>/</kbd> | Open Search Dialog | Click `[/] Search` |
| <kbd>c</kbd> / <kbd>,</kbd> | Open Settings & Connection Dialog | Click `[c] Settings` |
| <kbd>?</kbd> | Toggle Help Popup | Click `[?] Help` |
| <kbd>q</kbd> / <kbd>Esc</kbd> | Quit rhyth-rs / Close Modal | Click `[q] Quit` |
---
## ⚙️ Configuration (`config.toml`)
```toml
server_url = "http://localhost:4533"
username = "your_username"
password = "your_password"
theme = "catppuccin_mocha" # "catppuccin_mocha", "tokyo_night", "nord", "gruvbox"
default_volume = 0.85
enable_visualizer = true
```
---
## 🛠️ Architecture
`rhyth-rs` is built with a modular, asynchronous architecture designed for performance and reliability:
- **`src/subsonic/`**: Strongly typed, robust OpenSubsonic client featuring MD5-token authentication, REST endpoints, scrobbling, and media streaming.
- **`src/audio/`**:
- **`decoder.rs`**: Symphonia pure decoding engine converting any stream container directly into floating-point PCM buffers.
- **`player.rs`**: Dedicated isolated audio thread managing sample playback with atomic progress tracking.
- **`src/ui/`**:
- **`theme.rs`**: TrueColor themes with semantic styles.
- **`visualizer.rs`**: Spring-physics audio spectrum engine with peak hold & color gradients.
- **`cover.rs`**: High-resolution image downsampling into Half-block ANSI characters.
- **`widgets/`**: Header, Library, Player Bar, Settings form, Search, Lyrics, and Queue modals.
- **`src/app.rs`**: Central state machine and async background channel router.
- **`src/main.rs`**: Fixed-timestep 30 FPS event loop with Crossterm mouse and keyboard capture.
---
## 📜 License
Distributed under the **MIT License**. See `LICENSE` for more information.
+1083
View File
File diff suppressed because it is too large Load Diff
+187
View File
@@ -0,0 +1,187 @@
use std::io::Cursor;
use anyhow::{bail, Context, Result};
use symphonia::core::audio::{AudioBufferRef, Signal};
use symphonia::core::codecs::{DecoderOptions, CODEC_TYPE_NULL};
use symphonia::core::errors::Error as SymphoniaError;
use symphonia::core::formats::FormatOptions;
use symphonia::core::io::MediaSourceStream;
use symphonia::core::meta::MetadataOptions;
use symphonia::core::probe::Hint;
pub struct DecodedAudio {
pub samples: Vec<f32>,
pub sample_rate: u32,
pub channels: u16,
}
pub fn decode_audio(bytes: Vec<u8>, hint_ext: Option<&str>) -> Result<DecodedAudio> {
if bytes.len() < 512 {
bail!("Audio payload is too small ({} bytes)", bytes.len());
}
let mut hint = Hint::new();
if let Some(ext) = hint_ext {
let clean_ext = ext.trim_start_matches('.');
hint.with_extension(clean_ext);
}
let cursor = Box::new(Cursor::new(bytes));
let mss = MediaSourceStream::new(cursor, Default::default());
let probed = symphonia::default::get_probe()
.format(&hint, mss, &FormatOptions::default(), &MetadataOptions::default())
.context("Failed to probe audio format (unsupported codec or corrupt audio stream)")?;
let mut format = probed.format;
let track = format
.tracks()
.iter()
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
.context("No audio track found in media container")?;
let mut decoder = symphonia::default::get_codecs()
.make(&track.codec_params, &DecoderOptions::default())
.context("Unsupported audio codec")?;
let track_id = track.id;
let mut sample_rate = 44100;
let mut channels = 2;
let mut all_samples = Vec::new();
loop {
let packet = match format.next_packet() {
Ok(packet) => packet,
Err(SymphoniaError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
Err(SymphoniaError::ResetRequired) => continue,
Err(SymphoniaError::IoError(_)) => break,
Err(e) => {
tracing::warn!("Stream packet notice: {e}");
break;
}
};
if packet.track_id() != track_id {
continue;
}
match decoder.decode(&packet) {
Ok(decoded) => {
if all_samples.is_empty() {
let spec = *decoded.spec();
sample_rate = spec.rate;
channels = spec.channels.count() as u16;
}
append_audio_buffer(&decoded, &mut all_samples);
}
Err(SymphoniaError::IoError(_)) => break,
Err(SymphoniaError::DecodeError(_)) => continue,
Err(e) => {
tracing::warn!("Packet decoder notice: {e}");
break;
}
}
}
if all_samples.is_empty() {
bail!("No audio samples could be decoded from stream");
}
Ok(DecodedAudio {
samples: all_samples,
sample_rate,
channels,
})
}
fn append_audio_buffer(audio_buf: &AudioBufferRef, output: &mut Vec<f32>) {
match audio_buf {
AudioBufferRef::F32(buf) => {
let num_channels = buf.spec().channels.count();
for frame_idx in 0..buf.frames() {
for ch in 0..num_channels {
output.push(buf.chan(ch)[frame_idx]);
}
}
}
AudioBufferRef::U8(buf) => {
let num_channels = buf.spec().channels.count();
for frame_idx in 0..buf.frames() {
for ch in 0..num_channels {
let sample = (buf.chan(ch)[frame_idx] as f32 - 128.0) / 128.0;
output.push(sample);
}
}
}
AudioBufferRef::U16(buf) => {
let num_channels = buf.spec().channels.count();
for frame_idx in 0..buf.frames() {
for ch in 0..num_channels {
let sample = (buf.chan(ch)[frame_idx] as f32 - 32768.0) / 32768.0;
output.push(sample);
}
}
}
AudioBufferRef::U24(buf) => {
let num_channels = buf.spec().channels.count();
for frame_idx in 0..buf.frames() {
for ch in 0..num_channels {
let sample = (buf.chan(ch)[frame_idx].0 as f32 - 8388608.0) / 8388608.0;
output.push(sample);
}
}
}
AudioBufferRef::U32(buf) => {
let num_channels = buf.spec().channels.count();
for frame_idx in 0..buf.frames() {
for ch in 0..num_channels {
let sample = (buf.chan(ch)[frame_idx] as f32 - 2147483648.0) / 2147483648.0;
output.push(sample);
}
}
}
AudioBufferRef::S8(buf) => {
let num_channels = buf.spec().channels.count();
for frame_idx in 0..buf.frames() {
for ch in 0..num_channels {
let sample = buf.chan(ch)[frame_idx] as f32 / 128.0;
output.push(sample);
}
}
}
AudioBufferRef::S16(buf) => {
let num_channels = buf.spec().channels.count();
for frame_idx in 0..buf.frames() {
for ch in 0..num_channels {
let sample = buf.chan(ch)[frame_idx] as f32 / 32768.0;
output.push(sample);
}
}
}
AudioBufferRef::S24(buf) => {
let num_channels = buf.spec().channels.count();
for frame_idx in 0..buf.frames() {
for ch in 0..num_channels {
let sample = buf.chan(ch)[frame_idx].0 as f32 / 8388608.0;
output.push(sample);
}
}
}
AudioBufferRef::S32(buf) => {
let num_channels = buf.spec().channels.count();
for frame_idx in 0..buf.frames() {
for ch in 0..num_channels {
let sample = buf.chan(ch)[frame_idx] as f32 / 2147483648.0;
output.push(sample);
}
}
}
AudioBufferRef::F64(buf) => {
let num_channels = buf.spec().channels.count();
for frame_idx in 0..buf.frames() {
for ch in 0..num_channels {
output.push(buf.chan(ch)[frame_idx] as f32);
}
}
}
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod decoder;
pub mod player;
#[allow(unused_imports)]
pub use player::{AudioPlayer, PlaybackProgress, PlaybackState, TrackMetadata};
+283
View File
@@ -0,0 +1,283 @@
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use bytes::Bytes;
use parking_lot::RwLock;
use rodio::buffer::SamplesBuffer;
use rodio::{OutputStream, Sink};
use tokio::sync::mpsc;
use tracing::{error, info, warn};
use super::decoder::decode_audio;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlaybackState {
Playing,
Paused,
Stopped,
}
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct TrackMetadata {
pub id: String,
pub title: String,
pub artist: String,
pub album: Option<String>,
pub duration: Option<Duration>,
pub suffix: Option<String>,
}
#[derive(Debug, Clone)]
pub struct PlaybackProgress {
pub state: PlaybackState,
pub position: Duration,
pub duration: Option<Duration>,
pub volume: f32,
pub current_track: Option<TrackMetadata>,
}
impl Default for PlaybackProgress {
fn default() -> Self {
Self {
state: PlaybackState::Stopped,
position: Duration::ZERO,
duration: None,
volume: 1.0,
current_track: None,
}
}
}
#[allow(dead_code)]
enum PlayerCommand {
Play {
bytes: Bytes,
meta: TrackMetadata,
},
Pause,
Resume,
Toggle,
Stop,
Seek(Duration),
SetVolume(f32),
}
#[derive(Clone)]
pub struct AudioPlayer {
cmd_tx: mpsc::UnboundedSender<PlayerCommand>,
progress: Arc<RwLock<PlaybackProgress>>,
}
#[allow(dead_code)]
impl AudioPlayer {
pub fn new() -> Result<Self> {
let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel::<PlayerCommand>();
let progress = Arc::new(RwLock::new(PlaybackProgress::default()));
let progress_clone = Arc::clone(&progress);
// Spawn dedicated audio thread (OutputStream is not Send on some platforms)
thread::Builder::new()
.name("rhyth-audio-engine".to_string())
.spawn(move || {
let (_stream, stream_handle) = match OutputStream::try_default() {
Ok(res) => res,
Err(e) => {
error!("Failed to initialize audio output device: {e}");
return;
}
};
let sink = match Sink::try_new(&stream_handle) {
Ok(s) => s,
Err(e) => {
error!("Failed to create audio sink: {e}");
return;
}
};
let mut current_volume = 1.0f32;
sink.set_volume(current_volume);
let mut track_start_time: Option<Instant> = None;
let mut accumulated_position = Duration::ZERO;
let mut is_playing = false;
loop {
// Check for incoming commands (non-blocking when playing, blocking when stopped)
let timeout = if is_playing {
Duration::from_millis(50)
} else {
Duration::from_millis(200)
};
match cmd_rx.try_recv() {
Ok(cmd) => match cmd {
PlayerCommand::Play { bytes, meta } => {
sink.stop(); // clear current sink
let suffix = meta.suffix.clone();
let data = bytes.to_vec();
match decode_audio(data, suffix.as_deref()) {
Ok(decoded) => {
let sample_count = decoded.samples.len();
let rate = decoded.sample_rate;
let ch = decoded.channels;
let computed_duration = Duration::from_secs_f64(
sample_count as f64 / (ch as f64 * rate as f64),
);
let source = SamplesBuffer::new(ch, rate, decoded.samples);
sink.append(source);
sink.play();
is_playing = true;
accumulated_position = Duration::ZERO;
track_start_time = Some(Instant::now());
let mut state = progress_clone.write();
state.state = PlaybackState::Playing;
state.position = Duration::ZERO;
state.duration = meta.duration.or(Some(computed_duration));
state.current_track = Some(meta);
info!("Started playing track: {}", state.current_track.as_ref().map(|t| t.title.as_str()).unwrap_or(""));
}
Err(e) => {
error!("Audio decode error: {e}");
let mut state = progress_clone.write();
state.state = PlaybackState::Stopped;
state.current_track = None;
is_playing = false;
}
}
}
PlayerCommand::Pause => {
if !sink.is_paused() && !sink.empty() {
sink.pause();
is_playing = false;
if let Some(start) = track_start_time.take() {
accumulated_position += start.elapsed();
}
progress_clone.write().state = PlaybackState::Paused;
}
}
PlayerCommand::Resume => {
if sink.is_paused() && !sink.empty() {
sink.play();
is_playing = true;
track_start_time = Some(Instant::now());
progress_clone.write().state = PlaybackState::Playing;
}
}
PlayerCommand::Toggle => {
if sink.is_paused() {
sink.play();
is_playing = true;
track_start_time = Some(Instant::now());
progress_clone.write().state = PlaybackState::Playing;
} else if !sink.empty() {
sink.pause();
is_playing = false;
if let Some(start) = track_start_time.take() {
accumulated_position += start.elapsed();
}
progress_clone.write().state = PlaybackState::Paused;
}
}
PlayerCommand::Stop => {
sink.stop();
is_playing = false;
track_start_time = None;
accumulated_position = Duration::ZERO;
let mut state = progress_clone.write();
state.state = PlaybackState::Stopped;
state.position = Duration::ZERO;
state.current_track = None;
}
PlayerCommand::Seek(pos) => {
if let Err(e) = sink.try_seek(pos) {
warn!("Seek not supported or failed: {e}");
} else {
accumulated_position = pos;
track_start_time = Some(Instant::now());
progress_clone.write().position = pos;
}
}
PlayerCommand::SetVolume(vol) => {
current_volume = vol.clamp(0.0, 1.0);
sink.set_volume(current_volume);
progress_clone.write().volume = current_volume;
}
},
Err(mpsc::error::TryRecvError::Empty) => {
// Update playback position
if is_playing {
if sink.empty() {
// Track finished playing
is_playing = false;
track_start_time = None;
accumulated_position = Duration::ZERO;
let mut state = progress_clone.write();
state.state = PlaybackState::Stopped;
state.position = Duration::ZERO;
} else {
let current_pos = sink.get_pos();
progress_clone.write().position = current_pos;
}
}
thread::sleep(timeout);
}
Err(mpsc::error::TryRecvError::Disconnected) => {
break; // AudioPlayer dropped
}
}
}
})
.context("Failed to spawn audio engine thread")?;
Ok(Self { cmd_tx, progress })
}
pub fn play(&self, bytes: Bytes, meta: TrackMetadata) {
let _ = self.cmd_tx.send(PlayerCommand::Play { bytes, meta });
}
pub fn pause(&self) {
let _ = self.cmd_tx.send(PlayerCommand::Pause);
}
pub fn resume(&self) {
let _ = self.cmd_tx.send(PlayerCommand::Resume);
}
pub fn toggle(&self) {
let _ = self.cmd_tx.send(PlayerCommand::Toggle);
}
pub fn stop(&self) {
let _ = self.cmd_tx.send(PlayerCommand::Stop);
}
pub fn seek(&self, position: Duration) {
let _ = self.cmd_tx.send(PlayerCommand::Seek(position));
}
pub fn set_volume(&self, volume: f32) {
let _ = self.cmd_tx.send(PlayerCommand::SetVolume(volume));
}
pub fn volume_up(&self, step: f32) {
let current = self.progress.read().volume;
self.set_volume(current + step);
}
pub fn volume_down(&self, step: f32) {
let current = self.progress.read().volume;
self.set_volume(current - step);
}
pub fn get_progress(&self) -> PlaybackProgress {
self.progress.read().clone()
}
}
+78
View File
@@ -0,0 +1,78 @@
use std::fs;
use std::path::PathBuf;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
pub server_url: String,
pub username: String,
pub password: String,
#[serde(default = "default_theme")]
pub theme: String,
#[serde(default = "default_volume")]
pub default_volume: f32,
#[serde(default = "default_true")]
pub enable_visualizer: bool,
}
fn default_theme() -> String {
"catppuccin_mocha".to_string()
}
fn default_volume() -> f32 {
0.85
}
fn default_true() -> bool {
true
}
impl Default for AppConfig {
fn default() -> Self {
Self {
server_url: std::env::var("NAVIDROME_URL").unwrap_or_else(|_| "http://localhost:4533".to_string()),
username: std::env::var("NAVIDROME_USER").unwrap_or_default(),
password: std::env::var("NAVIDROME_PASSWORD").unwrap_or_default(),
theme: default_theme(),
default_volume: default_volume(),
enable_visualizer: true,
}
}
}
impl AppConfig {
pub fn config_path() -> PathBuf {
let mut path = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
path.push("rhyth-rs");
let _ = fs::create_dir_all(&path);
path.push("config.toml");
path
}
pub fn load() -> Self {
let path = Self::config_path();
if path.exists() {
if let Ok(content) = fs::read_to_string(&path) {
if let Ok(cfg) = toml::from_str::<AppConfig>(&content) {
return cfg;
}
}
}
Self::default()
}
pub fn is_configured(&self) -> bool {
!self.server_url.trim().is_empty()
&& !self.username.trim().is_empty()
&& !self.password.trim().is_empty()
}
pub fn save(&self) -> Result<()> {
let path = Self::config_path();
let serialized = toml::to_string_pretty(self).context("Failed to serialize config to TOML")?;
fs::write(&path, serialized).context("Failed to write config file")?;
Ok(())
}
}
+257
View File
@@ -0,0 +1,257 @@
mod app;
mod audio;
mod config;
mod subsonic;
mod ui;
use std::io::{self, stdout};
use std::time::Duration;
use anyhow::Result;
use app::{App, AppMode, FocusPane};
use audio::AudioPlayer;
use config::AppConfig;
use crossterm::event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, MouseButton,
MouseEventKind,
};
use crossterm::execute;
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use subsonic::SubsonicClient;
#[tokio::main]
async fn main() -> Result<()> {
let config = AppConfig::load();
let is_configured = config.is_configured();
let client = SubsonicClient::new(&config.server_url, &config.username, &config.password)
.unwrap_or_else(|_| SubsonicClient::new("http://localhost:4533", "admin", "").unwrap());
let player = AudioPlayer::new()?;
let mut app = App::new(config, client, player, is_configured);
// Setup terminal with mouse capture
enable_raw_mode()?;
let mut stdout = stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
// Set panic hook to ensure terminal is restored on crash
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture);
original_hook(panic_info);
}));
let result = run_app(&mut terminal, &mut app).await;
// Restore terminal
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
terminal.show_cursor()?;
if let Err(e) = result {
eprintln!("Application error: {e}");
}
Ok(())
}
async fn run_app<B: ratatui::backend::Backend>(
terminal: &mut Terminal<B>,
app: &mut App,
) -> Result<()> {
let tick_rate = Duration::from_millis(33); // ~30 FPS fixed rate
let mut last_tick = std::time::Instant::now();
loop {
terminal.draw(|frame| ui::render(app, frame))?;
let timeout = tick_rate.saturating_sub(last_tick.elapsed());
if event::poll(timeout)? {
match event::read()? {
Event::Key(key) => {
if key.kind == KeyEventKind::Press {
handle_key(app, key.code);
}
}
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::Down(MouseButton::Left) => {
app.handle_mouse_click(mouse.column, mouse.row);
}
MouseEventKind::Drag(MouseButton::Left) => {
app.handle_mouse_drag(mouse.column, mouse.row);
}
MouseEventKind::ScrollDown => {
app.handle_mouse_scroll(mouse.column, mouse.row, false);
}
MouseEventKind::ScrollUp => {
app.handle_mouse_scroll(mouse.column, mouse.row, true);
}
_ => {}
},
_ => {}
}
}
// Only tick visualizer and physics at fixed 30 FPS regardless of mouse event rate
if last_tick.elapsed() >= tick_rate {
app.tick();
last_tick = std::time::Instant::now();
}
if app.should_quit {
break;
}
}
Ok(())
}
fn handle_key(app: &mut App, key: KeyCode) {
// 1. Settings / Setup mode
if app.mode == AppMode::Setup || app.mode == AppMode::Settings {
app.handle_settings_key(key);
return;
}
// 2. Search modal input
if app.show_search {
match key {
KeyCode::Enter => {
app.perform_search();
app.show_search = false;
}
KeyCode::Esc => {
app.show_search = false;
app.search_input.clear();
}
KeyCode::Backspace => {
app.search_input.pop();
}
KeyCode::Char(c) => {
app.search_input.push(c);
}
_ => {}
}
return;
}
// 3. Help modal
if app.show_help {
match key {
KeyCode::Esc | KeyCode::Char('?') | KeyCode::Char('q') => {
app.show_help = false;
}
_ => {}
}
return;
}
// 4. Lyrics / Queue modals
if app.show_lyrics {
match key {
KeyCode::Esc | KeyCode::Char('y') | KeyCode::Char('q') => {
app.show_lyrics = false;
}
_ => {}
}
return;
}
if app.show_queue {
match key {
KeyCode::Esc | KeyCode::Char('u') | KeyCode::Char('q') => {
app.show_queue = false;
}
KeyCode::Char('n') => app.next_track(),
KeyCode::Char('p') => app.prev_track(),
_ => {}
}
return;
}
// 5. Normal navigation & player controls
match key {
KeyCode::Char('q') => {
app.should_quit = true;
}
KeyCode::Char('c') | KeyCode::Char(',') => {
app.open_settings();
}
KeyCode::Char('?') => {
app.show_help = true;
}
KeyCode::Char('/') => {
app.show_search = true;
app.search_input.clear();
}
KeyCode::Char('t') => {
app.cycle_theme();
}
KeyCode::Char(' ') => {
app.player.toggle();
}
KeyCode::Char('n') => {
app.next_track();
}
KeyCode::Char('p') => {
app.prev_track();
}
KeyCode::Char('s') => {
app.toggle_shuffle();
}
KeyCode::Char('r') => {
app.toggle_repeat();
}
KeyCode::Char('f') | KeyCode::Char('L') => {
app.toggle_star();
}
KeyCode::Char('y') => {
app.toggle_lyrics();
}
KeyCode::Char('u') => {
app.toggle_queue();
}
KeyCode::Char('j') | KeyCode::Down => {
app.next_item();
}
KeyCode::Char('k') | KeyCode::Up => {
app.prev_item();
}
KeyCode::Char('h') | KeyCode::Left => {
app.focus = app.focus.prev();
}
KeyCode::Char('l') | KeyCode::Right | KeyCode::Tab => {
app.focus = app.focus.next();
}
KeyCode::Enter => match app.focus {
FocusPane::Artists => {
app.focus = FocusPane::Albums;
}
FocusPane::Albums => {
app.focus = FocusPane::Tracks;
}
FocusPane::Tracks => {
app.play_selected_track();
}
},
KeyCode::Char('+') | KeyCode::Char('=') | KeyCode::Char(']') => {
app.player.volume_up(0.05);
let vol = (app.player.get_progress().volume * 100.0).round() as u32;
app.set_toast(format!("Volume: {vol}%"));
}
KeyCode::Char('-') | KeyCode::Char('_') | KeyCode::Char('[') => {
app.player.volume_down(0.05);
let vol = (app.player.get_progress().volume * 100.0).round() as u32;
app.set_toast(format!("Volume: {vol}%"));
}
_ => {}
}
}
+243
View File
@@ -0,0 +1,243 @@
use anyhow::{bail, Context, Result};
use bytes::Bytes;
use md5::{Digest, Md5};
use rand::Rng;
use reqwest::Client;
use url::Url;
use super::models::{
AlbumWithSongsID3, ArtistID3, ArtistWithAlbumsID3, ChildSong, ResponseStatus, SearchResult3,
SubsonicResponse,
};
#[derive(Clone)]
pub struct SubsonicClient {
base_url: Url,
username: String,
password: String,
client_name: String,
api_version: String,
http: Client,
}
#[allow(dead_code)]
impl SubsonicClient {
pub fn new(base_url: &str, username: &str, password: &str) -> Result<Self> {
let mut parsed_url = Url::parse(base_url).context("Invalid base URL for Navidrome")?;
// Ensure path ends with rest/ or /
if !parsed_url.path().ends_with('/') {
let mut new_path = parsed_url.path().to_string();
new_path.push('/');
parsed_url.set_path(&new_path);
}
Ok(Self {
base_url: parsed_url,
username: username.to_string(),
password: password.to_string(),
client_name: "rhyth-rs".to_string(),
api_version: "1.16.1".to_string(),
http: Client::builder().build()?,
})
}
/// Generate Subsonic authentication parameters (token + salt)
fn generate_auth_params(&self) -> (String, String) {
let salt: String = rand::thread_rng()
.sample_iter(&rand::distributions::Alphanumeric)
.take(12)
.map(char::from)
.collect();
let mut hasher = Md5::new();
hasher.update(format!("{}{}", self.password, salt));
let token = hex::encode(hasher.finalize());
(token, salt)
}
/// Construct a full API URL for a given endpoint with auth and extra query parameters
pub fn build_url(&self, endpoint: &str, extra_params: &[(&str, &str)]) -> Result<Url> {
let endpoint_path = format!("rest/{}.view", endpoint.trim_end_matches(".view"));
let mut url = self.base_url.join(&endpoint_path)?;
let (token, salt) = self.generate_auth_params();
{
let mut query = url.query_pairs_mut();
query.append_pair("u", &self.username);
query.append_pair("t", &token);
query.append_pair("s", &salt);
query.append_pair("v", &self.api_version);
query.append_pair("c", &self.client_name);
let is_binary_stream = endpoint.starts_with("stream") || endpoint.starts_with("getCoverArt");
if !is_binary_stream {
query.append_pair("f", "json");
}
for (key, val) in extra_params {
query.append_pair(key, val);
}
}
Ok(url)
}
/// Execute GET request and decode Subsonic response
async fn get_json(&self, endpoint: &str, extra_params: &[(&str, &str)]) -> Result<SubsonicResponse> {
let url = self.build_url(endpoint, extra_params)?;
let resp = self.http.get(url).send().await?.error_for_status()?;
let parsed: SubsonicResponse = resp.json().await?;
if parsed.response.status == ResponseStatus::Failed {
if let Some(err) = parsed.response.error {
bail!(err);
} else {
bail!("Subsonic API returned failed status without an error payload");
}
}
Ok(parsed)
}
/// Test connection to Navidrome / Subsonic server
pub async fn ping(&self) -> Result<()> {
self.get_json("ping", &[]).await?;
Ok(())
}
/// Get list of all artists grouped by index
pub async fn get_artists(&self) -> Result<Vec<ArtistID3>> {
let resp = self.get_json("getArtists", &[]).await?;
let mut artists = Vec::new();
if let Some(artists_data) = resp.response.artists {
for index in artists_data.index {
artists.extend(index.artist);
}
}
Ok(artists)
}
/// Get artist details and their albums
pub async fn get_artist(&self, id: &str) -> Result<ArtistWithAlbumsID3> {
let resp = self.get_json("getArtist", &[("id", id)]).await?;
resp.response
.artist
.context("Artist data missing in getArtist response")
}
/// Get album details with tracklist
pub async fn get_album(&self, id: &str) -> Result<AlbumWithSongsID3> {
let resp = self.get_json("getAlbum", &[("id", id)]).await?;
resp.response
.album
.context("Album data missing in getAlbum response")
}
/// Get song details by ID
pub async fn get_song(&self, id: &str) -> Result<ChildSong> {
let resp = self.get_json("getSong", &[("id", id)]).await?;
resp.response
.song
.context("Song data missing in getSong response")
}
/// Search across artists, albums, and songs
pub async fn search(&self, query: &str) -> Result<SearchResult3> {
let resp = self.get_json("search3", &[("query", query)]).await?;
resp.response
.search_result3
.context("Search result missing in search3 response")
}
/// Generate direct stream URL for a song
pub fn get_stream_url(&self, song_id: &str) -> Result<Url> {
self.build_url("stream", &[("id", song_id)])
}
/// Generate cover art URL with optional max size
pub fn get_cover_art_url(&self, cover_art_id: &str, size: Option<u32>) -> Result<Url> {
let mut params = vec![("id", cover_art_id)];
let size_str = size.map(|s| s.to_string());
if let Some(ref s) = size_str {
params.push(("size", s.as_str()));
}
self.build_url("getCoverArt", &params)
}
/// Download song bytes directly (for buffering / local playback)
pub async fn download_stream(&self, song_id: &str) -> Result<Bytes> {
let url = self.get_stream_url(song_id)?;
let resp = self.http.get(url).send().await?.error_for_status()?;
let content_type = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_lowercase();
if content_type.contains("json") || content_type.contains("xml") || content_type.contains("text") {
let text = resp.text().await.unwrap_or_default();
bail!("Subsonic server returned non-audio response ({}): {}", content_type, text);
}
let bytes = resp.bytes().await?;
if bytes.len() < 512 {
bail!("Downloaded audio payload is too short ({} bytes)", bytes.len());
}
Ok(bytes)
}
/// Download cover art image bytes
pub async fn download_cover_art(&self, cover_art_id: &str, size: Option<u32>) -> Result<Bytes> {
let url = self.get_cover_art_url(cover_art_id, size)?;
let bytes = self.http.get(url).send().await?.error_for_status()?.bytes().await?;
Ok(bytes)
}
/// Scrobble song playback to Navidrome / Last.fm
pub async fn scrobble(&self, song_id: &str, submission: bool) -> Result<()> {
let sub_str = if submission { "true" } else { "false" };
self.get_json("scrobble", &[("id", song_id), ("submission", sub_str)]).await?;
Ok(())
}
/// Mark song/album/artist as favorite (Star)
pub async fn star(&self, song_id: &str) -> Result<()> {
self.get_json("star", &[("id", song_id)]).await?;
Ok(())
}
/// Unmark song/album/artist as favorite (Unstar)
pub async fn unstar(&self, song_id: &str) -> Result<()> {
self.get_json("unstar", &[("id", song_id)]).await?;
Ok(())
}
/// Fetch lyrics for a song (by ID or artist+title)
pub async fn get_lyrics(&self, song_id: &str) -> Result<Option<String>> {
if let Ok(resp) = self.get_json("getLyricsBySongId", &[("id", song_id)]).await {
if let Some(list) = resp.response.lyrics_list {
if let Some(first) = list.structured_lyrics.first() {
let lines: Vec<String> = first.line.iter().map(|l| l.value.clone()).collect();
if !lines.is_empty() {
return Ok(Some(lines.join("\n")));
}
}
}
}
if let Ok(resp) = self.get_json("getLyrics", &[("id", song_id)]).await {
if let Some(lyrics) = resp.response.lyrics {
if let Some(val) = lyrics.value {
return Ok(Some(val));
}
}
}
Ok(None)
}
}
+6
View File
@@ -0,0 +1,6 @@
pub mod client;
pub mod models;
pub use client::SubsonicClient;
#[allow(unused_imports)]
pub use models::*;
+192
View File
@@ -0,0 +1,192 @@
use serde::{Deserialize, Serialize};
/// Generic Subsonic API response wrapper
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubsonicResponse {
#[serde(rename = "subsonic-response")]
pub response: SubsonicResponseBody,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubsonicResponseBody {
pub status: ResponseStatus,
pub version: String,
#[serde(rename = "type")]
pub server_type: Option<String>,
#[serde(rename = "serverVersion")]
pub server_version: Option<String>,
#[serde(rename = "openSubsonic")]
pub open_subsonic: Option<bool>,
pub error: Option<SubsonicError>,
// Endpoints payloads
pub artists: Option<ArtistsID3>,
pub artist: Option<ArtistWithAlbumsID3>,
pub album: Option<AlbumWithSongsID3>,
pub song: Option<ChildSong>,
pub lyrics: Option<LyricsData>,
#[serde(rename = "lyricsList")]
pub lyrics_list: Option<LyricsList>,
#[serde(rename = "searchResult3")]
pub search_result3: Option<SearchResult3>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ResponseStatus {
Ok,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubsonicError {
pub code: i32,
pub message: String,
}
impl std::fmt::Display for SubsonicError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Subsonic Error [{}]: {}", self.code, self.message)
}
}
impl std::error::Error for SubsonicError {}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArtistsID3 {
#[serde(default)]
pub index: Vec<ArtistIndex>,
#[serde(rename = "ignoredArticles")]
pub ignored_articles: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArtistIndex {
pub name: String,
#[serde(default)]
pub artist: Vec<ArtistID3>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArtistID3 {
pub id: String,
pub name: String,
#[serde(rename = "coverArt")]
pub cover_art: Option<String>,
#[serde(rename = "albumCount")]
pub album_count: Option<u32>,
#[serde(rename = "artistImageUrl")]
pub artist_image_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArtistWithAlbumsID3 {
pub id: String,
pub name: String,
#[serde(rename = "coverArt")]
pub cover_art: Option<String>,
#[serde(rename = "albumCount")]
pub album_count: Option<u32>,
#[serde(default)]
pub album: Vec<AlbumID3>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AlbumID3 {
pub id: String,
pub name: String,
pub artist: Option<String>,
#[serde(rename = "artistId")]
pub artist_id: Option<String>,
#[serde(rename = "coverArt")]
pub cover_art: Option<String>,
#[serde(rename = "songCount")]
pub song_count: Option<u32>,
pub duration: Option<u64>,
pub year: Option<i32>,
pub genre: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AlbumWithSongsID3 {
pub id: String,
pub name: String,
pub artist: Option<String>,
#[serde(rename = "artistId")]
pub artist_id: Option<String>,
#[serde(rename = "coverArt")]
pub cover_art: Option<String>,
#[serde(rename = "songCount")]
pub song_count: Option<u32>,
pub duration: Option<u64>,
pub year: Option<i32>,
pub genre: Option<String>,
#[serde(default)]
pub song: Vec<ChildSong>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChildSong {
pub id: String,
pub title: String,
pub album: Option<String>,
#[serde(rename = "albumId")]
pub album_id: Option<String>,
pub artist: Option<String>,
#[serde(rename = "artistId")]
pub artist_id: Option<String>,
pub track: Option<u32>,
pub year: Option<i32>,
pub genre: Option<String>,
pub size: Option<u64>,
#[serde(rename = "contentType")]
pub content_type: Option<String>,
pub suffix: Option<String>,
pub duration: Option<u64>,
#[serde(rename = "bitRate")]
pub bit_rate: Option<u32>,
pub path: Option<String>,
#[serde(rename = "discNumber")]
pub disc_number: Option<u32>,
#[serde(rename = "coverArt")]
pub cover_art: Option<String>,
pub starred: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResult3 {
#[serde(default)]
pub artist: Vec<ArtistID3>,
#[serde(default)]
pub album: Vec<AlbumID3>,
#[serde(default)]
pub song: Vec<ChildSong>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LyricsData {
pub artist: Option<String>,
pub title: Option<String>,
pub value: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LyricsList {
#[serde(rename = "structuredLyrics", default)]
pub structured_lyrics: Vec<StructuredLyrics>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StructuredLyrics {
pub offset: Option<i64>,
pub synced: Option<bool>,
#[serde(default)]
pub line: Vec<LyricsLine>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LyricsLine {
pub start: Option<u64>,
pub value: String,
}
+43
View File
@@ -0,0 +1,43 @@
use bytes::Bytes;
use image::imageops::FilterType;
use image::{GenericImageView, Rgba};
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
/// Convert image bytes (JPEG / PNG) into Unicode Half-block terminal spans
pub fn render_cover_thumbnail(bytes: &Bytes, width: u32, height: u32) -> Option<Vec<Line<'static>>> {
let img = image::load_from_memory(bytes).ok()?;
// Terminal cells are approx 1:2 aspect ratio, so height in cells corresponds to 2x pixel rows
let pixel_height = height * 2;
let resized = img.resize_exact(width, pixel_height, FilterType::Triangle);
let mut lines = Vec::with_capacity(height as usize);
for y in (0..pixel_height).step_by(2) {
let mut spans = Vec::with_capacity(width as usize);
for x in 0..width {
let top_pixel: Rgba<u8> = resized.get_pixel(x, y);
let bottom_pixel: Rgba<u8> = if y + 1 < pixel_height {
resized.get_pixel(x, y + 1)
} else {
top_pixel
};
let top_color = Color::Rgb(top_pixel[0], top_pixel[1], top_pixel[2]);
let bottom_color = Color::Rgb(bottom_pixel[0], bottom_pixel[1], bottom_pixel[2]);
// '▀' uses foreground for upper half and background for lower half
let span = Span::styled(
"▀",
Style::default().fg(top_color).bg(bottom_color),
);
spans.push(span);
}
lines.push(Line::from(spans));
}
Some(lines)
}
+52
View File
@@ -0,0 +1,52 @@
pub mod cover;
pub mod theme;
pub mod visualizer;
pub mod widgets;
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::Frame;
use crate::app::{App, AppMode};
pub fn render(app: &mut App, frame: &mut Frame) {
let size = frame.area();
match app.mode {
AppMode::Setup => {
widgets::settings::render(app, frame, size, true);
}
AppMode::Main | AppMode::Settings => {
// Main layout: Header (top), Main content (middle), Player Bar (bottom)
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2), // Header
Constraint::Min(8), // Library (Artists, Albums, Tracks)
Constraint::Length(4), // Player Bar
])
.split(size);
// 1. Render Header
widgets::header::render(app, frame, chunks[0]);
// 2. Render Library Panes
widgets::library::render(app, frame, chunks[1]);
// 3. Render Player Bar
widgets::player_bar::render(app, frame, chunks[2]);
// 4. Render Modals
if app.mode == AppMode::Settings {
widgets::settings::render(app, frame, size, false);
} else if app.show_lyrics {
widgets::modals::render_lyrics(app, frame, size);
} else if app.show_queue {
widgets::modals::render_queue(app, frame, size);
} else if app.show_help {
widgets::modals::render_help(app, frame, size);
} else if app.show_search {
widgets::modals::render_search(app, frame, size);
}
}
}
}
+173
View File
@@ -0,0 +1,173 @@
use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::BorderType;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ThemeKind {
CatppuccinMocha,
TokyoNight,
Nord,
Gruvbox,
}
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct Theme {
pub kind: ThemeKind,
pub bg: Color,
pub surface: Color,
pub border: Color,
pub border_focused: Color,
pub primary: Color,
pub secondary: Color,
pub accent: Color,
pub text: Color,
pub text_muted: Color,
pub highlight_bg: Color,
pub highlight_fg: Color,
pub progress_fill: Color,
pub progress_empty: Color,
pub visualizer_color: Color,
pub success: Color,
pub error: Color,
pub border_type: BorderType,
}
impl Theme {
pub fn catppuccin_mocha() -> Self {
Self {
kind: ThemeKind::CatppuccinMocha,
bg: Color::Rgb(30, 30, 46),
surface: Color::Rgb(24, 24, 37),
border: Color::Rgb(88, 91, 112),
border_focused: Color::Rgb(203, 166, 247),
primary: Color::Rgb(203, 166, 247),
secondary: Color::Rgb(137, 180, 250),
accent: Color::Rgb(245, 194, 231),
text: Color::Rgb(205, 214, 244),
text_muted: Color::Rgb(166, 173, 200),
highlight_bg: Color::Rgb(69, 71, 90),
highlight_fg: Color::Rgb(245, 224, 220),
progress_fill: Color::Rgb(166, 227, 161),
progress_empty: Color::Rgb(49, 50, 68),
visualizer_color: Color::Rgb(137, 180, 250),
success: Color::Rgb(166, 227, 161),
error: Color::Rgb(243, 139, 168),
border_type: BorderType::Rounded,
}
}
pub fn tokyo_night() -> Self {
Self {
kind: ThemeKind::TokyoNight,
bg: Color::Rgb(26, 27, 38),
surface: Color::Rgb(22, 22, 30),
border: Color::Rgb(65, 72, 104),
border_focused: Color::Rgb(122, 162, 247),
primary: Color::Rgb(122, 162, 247),
secondary: Color::Rgb(187, 154, 247),
accent: Color::Rgb(224, 175, 104),
text: Color::Rgb(192, 202, 245),
text_muted: Color::Rgb(120, 124, 153),
highlight_bg: Color::Rgb(41, 46, 66),
highlight_fg: Color::Rgb(255, 255, 255),
progress_fill: Color::Rgb(158, 206, 106),
progress_empty: Color::Rgb(36, 40, 59),
visualizer_color: Color::Rgb(224, 175, 104),
success: Color::Rgb(158, 206, 106),
error: Color::Rgb(247, 118, 142),
border_type: BorderType::Rounded,
}
}
pub fn nord() -> Self {
Self {
kind: ThemeKind::Nord,
bg: Color::Rgb(46, 52, 64),
surface: Color::Rgb(36, 41, 51),
border: Color::Rgb(76, 86, 106),
border_focused: Color::Rgb(136, 192, 208),
primary: Color::Rgb(136, 192, 208),
secondary: Color::Rgb(129, 161, 193),
accent: Color::Rgb(235, 203, 139),
text: Color::Rgb(236, 239, 244),
text_muted: Color::Rgb(147, 158, 179),
highlight_bg: Color::Rgb(59, 66, 82),
highlight_fg: Color::Rgb(236, 239, 244),
progress_fill: Color::Rgb(163, 190, 140),
progress_empty: Color::Rgb(59, 66, 82),
visualizer_color: Color::Rgb(136, 192, 208),
success: Color::Rgb(163, 190, 140),
error: Color::Rgb(191, 97, 106),
border_type: BorderType::Rounded,
}
}
pub fn gruvbox() -> Self {
Self {
kind: ThemeKind::Gruvbox,
bg: Color::Rgb(40, 40, 40),
surface: Color::Rgb(29, 32, 33),
border: Color::Rgb(102, 92, 84),
border_focused: Color::Rgb(250, 189, 47),
primary: Color::Rgb(250, 189, 47),
secondary: Color::Rgb(131, 165, 152),
accent: Color::Rgb(211, 134, 155),
text: Color::Rgb(235, 219, 178),
text_muted: Color::Rgb(168, 153, 132),
highlight_bg: Color::Rgb(60, 56, 54),
highlight_fg: Color::Rgb(251, 241, 199),
progress_fill: Color::Rgb(184, 187, 38),
progress_empty: Color::Rgb(50, 48, 47),
visualizer_color: Color::Rgb(254, 128, 25),
success: Color::Rgb(184, 187, 38),
error: Color::Rgb(251, 73, 52),
border_type: BorderType::Rounded,
}
}
pub fn cycle_next(&self) -> Self {
match self.kind {
ThemeKind::CatppuccinMocha => Self::tokyo_night(),
ThemeKind::TokyoNight => Self::nord(),
ThemeKind::Nord => Self::gruvbox(),
ThemeKind::Gruvbox => Self::catppuccin_mocha(),
}
}
pub fn name(&self) -> &'static str {
match self.kind {
ThemeKind::CatppuccinMocha => "Catppuccin Mocha",
ThemeKind::TokyoNight => "Tokyo Night",
ThemeKind::Nord => "Nord",
ThemeKind::Gruvbox => "Gruvbox Dark",
}
}
// Helper style constructors
pub fn title_style(&self) -> Style {
Style::default()
.fg(self.primary)
.add_modifier(Modifier::BOLD)
}
pub fn focused_border_style(&self) -> Style {
Style::default().fg(self.border_focused)
}
pub fn normal_border_style(&self) -> Style {
Style::default().fg(self.border)
}
pub fn item_selected_style(&self) -> Style {
Style::default()
.bg(self.highlight_bg)
.fg(self.highlight_fg)
.add_modifier(Modifier::BOLD)
}
}
impl Default for Theme {
fn default() -> Self {
Self::catppuccin_mocha()
}
}
+127
View File
@@ -0,0 +1,127 @@
use rand::Rng;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::Span;
// 8 levels of block height starting from baseline block ' ' (never blank space!)
const BLOCKS: [char; 8] = [' ', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
pub struct Visualizer {
pub num_bars: usize,
bars: Vec<f32>,
velocities: Vec<f32>,
peaks: Vec<f32>,
peak_hold: Vec<u8>,
phase: f32,
beat_pulse: f32,
}
impl Visualizer {
pub fn new(num_bars: usize) -> Self {
Self {
num_bars,
bars: vec![0.15; num_bars],
velocities: vec![0.0; num_bars],
peaks: vec![0.15; num_bars],
peak_hold: vec![0; num_bars],
phase: 0.0,
beat_pulse: 0.0,
}
}
pub fn tick(&mut self, is_playing: bool) {
self.phase += 0.12;
if is_playing {
self.beat_pulse = (self.phase * 2.2).sin().max(0.0).powi(3) * 0.4;
let mut rng = rand::thread_rng();
for i in 0..self.num_bars {
let norm_i = i as f32 / self.num_bars as f32;
// Frequency profile: heavy bass, expressive mids, crystalline highs
let bass_boost = (1.0 - norm_i * 0.75).max(0.25);
let wave1 = (self.phase * 1.6 + (i as f32 * 0.45)).sin() * 0.35 + 0.45;
let wave2 = (self.phase * 2.8 - (i as f32 * 0.7)).cos() * 0.25 + 0.25;
let wave3 = (self.phase * 4.4 + (i as f32 * 1.1)).sin() * 0.18 + 0.18;
let noise: f32 = rng.gen_range(0.0..0.2);
let beat = if i < 8 { self.beat_pulse } else { self.beat_pulse * 0.25 };
let target = ((wave1 + wave2 + wave3 + noise + beat) * bass_boost).clamp(0.12, 1.0);
// Liquid spring physics
let diff = target - self.bars[i];
self.velocities[i] = self.velocities[i] * 0.72 + diff * 0.28;
self.bars[i] = (self.bars[i] + self.velocities[i]).clamp(0.05, 1.0);
// Peak hold & gravity drop
if self.bars[i] >= self.peaks[i] {
self.peaks[i] = self.bars[i];
self.peak_hold[i] = 10;
} else if self.peak_hold[i] > 0 {
self.peak_hold[i] -= 1;
} else {
self.peaks[i] = (self.peaks[i] - 0.03).max(0.1);
}
}
} else {
// Idle ambient breathing animation
for i in 0..self.num_bars {
let idle_wave = ((self.phase * 0.6 + i as f32 * 0.3).sin() * 0.12 + 0.16).clamp(0.05, 0.35);
self.bars[i] = self.bars[i] * 0.9 + idle_wave * 0.1;
self.peaks[i] = self.bars[i];
self.velocities[i] = 0.0;
}
}
}
/// Render visualizer as colored spans with frequency gradient
pub fn render_colored_spans(&self, max_width: usize) -> Vec<Span<'static>> {
let count = self.num_bars.min(max_width);
let mut spans = Vec::with_capacity(count);
for i in 0..count {
let val = self.bars.get(i).copied().unwrap_or(0.1);
let idx = ((val * (BLOCKS.len() - 1) as f32).round() as usize).min(BLOCKS.len() - 1);
let ch = BLOCKS[idx];
let ratio = i as f32 / count.max(1) as f32;
let color = frequency_gradient_color(ratio);
let style = if val > 0.7 {
Style::default().fg(color).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(color)
};
spans.push(Span::styled(ch.to_string(), style));
}
spans
}
}
/// Dynamic gradient across frequencies:
/// Bass (Pink/Mauve) -> Low-Mids (Blue/Sapphire) -> High-Mids (Mint/Green) -> Treble (Gold/Peach)
fn frequency_gradient_color(ratio: f32) -> Color {
if ratio < 0.25 {
let t = ratio / 0.25;
interpolate_rgb((245, 194, 231), (203, 166, 247), t)
} else if ratio < 0.55 {
let t = (ratio - 0.25) / 0.30;
interpolate_rgb((203, 166, 247), (116, 199, 236), t)
} else if ratio < 0.80 {
let t = (ratio - 0.55) / 0.25;
interpolate_rgb((116, 199, 236), (166, 227, 161), t)
} else {
let t = (ratio - 0.80) / 0.20;
interpolate_rgb((166, 227, 161), (250, 179, 135), t)
}
}
fn interpolate_rgb(from: (u8, u8, u8), to: (u8, u8, u8), t: f32) -> Color {
let t = t.clamp(0.0, 1.0);
let r = (from.0 as f32 + (to.0 as f32 - from.0 as f32) * t).round() as u8;
let g = (from.1 as f32 + (to.1 as f32 - from.1 as f32) * t).round() as u8;
let b = (from.2 as f32 + (to.2 as f32 - from.2 as f32) * t).round() as u8;
Color::Rgb(r, g, b)
}
+57
View File
@@ -0,0 +1,57 @@
use ratatui::layout::{Alignment, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::Frame;
use crate::app::App;
pub fn render(app: &mut App, frame: &mut Frame, area: Rect) {
let theme = &app.theme;
// Cache header button hitboxes (estimated coordinates on the right side)
let right_width = 46.min(area.width);
let right_start = area.x + area.width.saturating_sub(right_width);
let y = area.y;
app.layout.btn_settings = Rect::new(right_start, y, 13, 1);
app.layout.btn_help = Rect::new(right_start + 13, y, 9, 1);
app.layout.btn_search = Rect::new(right_start + 22, y, 10, 1);
app.layout.btn_theme = Rect::new(right_start + 32, y, 9, 1);
app.layout.btn_quit = Rect::new(right_start + 41, y, 8, 1);
let left_spans = vec![
Span::styled(" 🎵 rhyth-rs ", Style::default().fg(theme.primary).add_modifier(Modifier::BOLD)),
Span::styled("│ ", Style::default().fg(theme.border)),
Span::styled(format!("Theme: {} ", theme.name()), Style::default().fg(theme.text_muted)),
];
let right_spans = if let Some((msg, _)) = &app.toast {
vec![
Span::styled(format!("🔔 {msg} "), Style::default().fg(theme.accent).add_modifier(Modifier::BOLD)),
]
} else if app.is_loading {
vec![
Span::styled(format!("⏳ {} ", app.loading_msg), Style::default().fg(theme.secondary)),
]
} else {
vec![
Span::styled("[c] Settings ", Style::default().fg(theme.text_muted)),
Span::styled("[?] Help ", Style::default().fg(theme.text_muted)),
Span::styled("[/] Search ", Style::default().fg(theme.text_muted)),
Span::styled("[t] Theme ", Style::default().fg(theme.text_muted)),
Span::styled("[q] Quit ", Style::default().fg(theme.text_muted)),
]
};
let line = Line::from(left_spans);
let right_line = Line::from(right_spans);
let block = Block::default()
.borders(Borders::BOTTOM)
.border_type(theme.border_type)
.border_style(Style::default().fg(theme.border));
frame.render_widget(Paragraph::new(line).block(block.clone()), area);
frame.render_widget(Paragraph::new(right_line).alignment(Alignment::Right), area);
}
+168
View File
@@ -0,0 +1,168 @@
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem};
use ratatui::Frame;
use crate::app::{App, FocusPane};
pub fn render(app: &mut App, frame: &mut Frame, area: Rect) {
let theme = &app.theme;
// Split horizontally: Left (Artists), Right (Albums + Tracks)
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
.split(area);
// Split right column vertically: Top (Albums), Bottom (Tracks)
let right_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(chunks[1]);
// Cache hitboxes for mouse interaction
app.layout.artists_pane = chunks[0];
app.layout.albums_pane = right_chunks[0];
app.layout.tracks_pane = right_chunks[1];
// 1. Artists List
let is_artists_focused = app.focus == FocusPane::Artists;
let artists_border_style = if is_artists_focused {
theme.focused_border_style()
} else {
theme.normal_border_style()
};
let artist_items: Vec<ListItem> = app
.artists
.iter()
.map(|artist| {
let count = artist.album_count.unwrap_or(0);
let line = Line::from(vec![
Span::styled(" 🎤 ", Style::default().fg(theme.primary)),
Span::styled(&artist.name, Style::default().fg(theme.text)),
Span::styled(format!(" ({count})"), Style::default().fg(theme.text_muted)),
]);
ListItem::new(line)
})
.collect();
let artists_title = format!(" Artists [{}] ", app.artists.len());
let artists_block = Block::default()
.borders(Borders::ALL)
.border_type(theme.border_type)
.border_style(artists_border_style)
.title(Span::styled(artists_title, if is_artists_focused { theme.title_style() } else { Style::default().fg(theme.text_muted) }));
let artists_list = List::new(artist_items)
.block(artists_block)
.highlight_style(theme.item_selected_style())
.highlight_symbol("▶ ");
frame.render_stateful_widget(artists_list, chunks[0], &mut app.artists_state);
// 2. Albums List
let is_albums_focused = app.focus == FocusPane::Albums;
let albums_border_style = if is_albums_focused {
theme.focused_border_style()
} else {
theme.normal_border_style()
};
let album_items: Vec<ListItem> = app
.albums
.iter()
.map(|album| {
let year_str = album.year.map(|y| format!(" ({y})")).unwrap_or_default();
let tracks_count = album.song_count.map(|c| format!(" [{c} tracks]")).unwrap_or_default();
let line = Line::from(vec![
Span::styled(" 💿 ", Style::default().fg(theme.secondary)),
Span::styled(&album.name, Style::default().fg(theme.text)),
Span::styled(year_str, Style::default().fg(theme.accent)),
Span::styled(tracks_count, Style::default().fg(theme.text_muted)),
]);
ListItem::new(line)
})
.collect();
let albums_title = format!(" Albums [{}] ", app.albums.len());
let albums_block = Block::default()
.borders(Borders::ALL)
.border_type(theme.border_type)
.border_style(albums_border_style)
.title(Span::styled(albums_title, if is_albums_focused { theme.title_style() } else { Style::default().fg(theme.text_muted) }));
let albums_list = List::new(album_items)
.block(albums_block)
.highlight_style(theme.item_selected_style())
.highlight_symbol("▶ ");
frame.render_stateful_widget(albums_list, right_chunks[0], &mut app.albums_state);
// 3. Tracks List
let is_tracks_focused = app.focus == FocusPane::Tracks;
let tracks_border_style = if is_tracks_focused {
theme.focused_border_style()
} else {
theme.normal_border_style()
};
let progress = app.player.get_progress();
let current_playing_id = progress.current_track.as_ref().map(|t| &t.id);
let track_items: Vec<ListItem> = app
.tracks
.iter()
.enumerate()
.map(|(idx, track)| {
let is_now_playing = current_playing_id.map_or(false, |id| id == &track.id);
let track_num = track.track.unwrap_or((idx + 1) as u32);
let duration_str = track
.duration
.map(|d| format!("{:02}:{:02}", d / 60, d % 60))
.unwrap_or_else(|| "--:--".to_string());
let play_icon = if is_now_playing {
Span::styled(" 🔊 ", Style::default().fg(theme.success).add_modifier(Modifier::BOLD))
} else {
Span::styled(format!(" {:02}. ", track_num), Style::default().fg(theme.text_muted))
};
let star_icon = if track.starred.is_some() {
Span::styled(" ❤️ ", Style::default().fg(theme.accent))
} else {
Span::raw("")
};
let title_style = if is_now_playing {
Style::default().fg(theme.success).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.text)
};
let line = Line::from(vec![
play_icon,
star_icon,
Span::styled(&track.title, title_style),
Span::styled(format!(" [{duration_str}]"), Style::default().fg(theme.text_muted)),
]);
ListItem::new(line)
})
.collect();
let tracks_title = format!(" Tracks [{}] ", app.tracks.len());
let tracks_block = Block::default()
.borders(Borders::ALL)
.border_type(theme.border_type)
.border_style(tracks_border_style)
.title(Span::styled(tracks_title, if is_tracks_focused { theme.title_style() } else { Style::default().fg(theme.text_muted) }));
let tracks_list = List::new(track_items)
.block(tracks_block)
.highlight_style(theme.item_selected_style())
.highlight_symbol("▶ ");
frame.render_stateful_widget(tracks_list, right_chunks[1], &mut app.tracks_state);
}
+5
View File
@@ -0,0 +1,5 @@
pub mod header;
pub mod library;
pub mod modals;
pub mod player_bar;
pub mod settings;
+186
View File
@@ -0,0 +1,186 @@
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use ratatui::Frame;
use crate::app::App;
/// Helper function to create a centered popup rect
pub fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(area);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(popup_layout[1])[1]
}
pub fn render_help(app: &App, frame: &mut Frame, area: Rect) {
let theme = &app.theme;
let popup_area = centered_rect(60, 60, area);
let keybinds = vec![
("j / Down", "Move down in list"),
("k / Up", "Move up in list"),
("h / l / Tab", "Switch pane (Artists / Albums / Tracks)"),
("Enter", "Play selected track / Open folder"),
("Space", "Play / Pause toggle"),
("n / p", "Next / Previous track in queue"),
("s", "Toggle Shuffle mode"),
("r", "Toggle Repeat mode (Off / Track / All)"),
("l", "Star / Favorite track (Like)"),
("y", "Toggle Lyrics (Text/Synced)"),
("u", "Toggle Queue view"),
("+ / = / ]", "Increase volume (+5%)"),
("- / _ / [", "Decrease volume (-5%)"),
("t", "Cycle color theme (Catppuccin, Tokyo Night, Nord, Gruvbox)"),
("c / ,", "Open Settings / Navidrome connection dialog"),
("/", "Open Search dialog"),
("?", "Toggle this Help popup"),
("q / Esc", "Quit application / Close popup"),
];
let mut lines = vec![
Line::from(Span::styled(" Keyboard Shortcuts ", theme.title_style())),
Line::from(""),
];
for (key, desc) in keybinds {
lines.push(Line::from(vec![
Span::styled(format!(" {:<16}", key), Style::default().fg(theme.primary).add_modifier(Modifier::BOLD)),
Span::styled(format!(" {}", desc), Style::default().fg(theme.text)),
]));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(" Press [Esc] or [?] to close ", Style::default().fg(theme.text_muted))));
let block = Block::default()
.borders(Borders::ALL)
.border_type(theme.border_type)
.border_style(Style::default().fg(theme.border_focused))
.title(Span::styled(" Help & Shortcuts ", theme.title_style()))
.title_alignment(Alignment::Center);
frame.render_widget(Clear, popup_area); // clear background
frame.render_widget(Paragraph::new(lines).block(block), popup_area);
}
pub fn render_search(app: &App, frame: &mut Frame, area: Rect) {
let theme = &app.theme;
let popup_area = centered_rect(50, 20, area);
let lines = vec![
Line::from(""),
Line::from(vec![
Span::styled(" > ", Style::default().fg(theme.primary).add_modifier(Modifier::BOLD)),
Span::styled(&app.search_input, Style::default().fg(theme.text).add_modifier(Modifier::BOLD)),
Span::styled("█", Style::default().fg(theme.accent)),
]),
Line::from(""),
Line::from(Span::styled(" [Enter] Search [Esc] Cancel ", Style::default().fg(theme.text_muted))),
];
let block = Block::default()
.borders(Borders::ALL)
.border_type(theme.border_type)
.border_style(Style::default().fg(theme.accent))
.title(Span::styled(" 🔍 Search Navidrome Library ", theme.title_style()))
.title_alignment(Alignment::Center);
frame.render_widget(Clear, popup_area);
frame.render_widget(Paragraph::new(lines).block(block), popup_area);
}
pub fn render_lyrics(app: &App, frame: &mut Frame, area: Rect) {
let theme = &app.theme;
let popup_area = centered_rect(65, 70, area);
let title = if let Some(ref track) = app.player.get_progress().current_track {
format!(" 🎤 Lyrics: {} — {} ", track.artist, track.title)
} else {
" 🎤 Lyrics ".to_string()
};
let block = Block::default()
.borders(Borders::ALL)
.border_type(theme.border_type)
.border_style(Style::default().fg(theme.primary))
.title(Span::styled(title, theme.title_style()))
.title_alignment(Alignment::Center);
let lyrics_text = if let Some(ref lyrics) = app.current_lyrics {
lyrics.lines().map(|l| Line::from(Span::styled(l, Style::default().fg(theme.text)))).collect()
} else if app.is_loading {
vec![
Line::from(""),
Line::from(Span::styled(" ⏳ Loading lyrics from Navidrome... ", Style::default().fg(theme.secondary))),
]
} else {
vec![
Line::from(""),
Line::from(Span::styled(" No lyrics found for this track. ", Style::default().fg(theme.text_muted))),
]
};
frame.render_widget(Clear, popup_area);
frame.render_widget(Paragraph::new(lyrics_text).block(block).alignment(Alignment::Center), popup_area);
}
pub fn render_queue(app: &App, frame: &mut Frame, area: Rect) {
let theme = &app.theme;
let popup_area = centered_rect(70, 65, area);
let title = format!(" 📜 Playback Queue [{}] ", app.queue.len());
let block = Block::default()
.borders(Borders::ALL)
.border_type(theme.border_type)
.border_style(Style::default().fg(theme.secondary))
.title(Span::styled(title, theme.title_style()))
.title_alignment(Alignment::Center);
let mut lines = Vec::new();
for (idx, song) in app.queue.iter().enumerate() {
let is_current = idx == app.queue_index;
let prefix = if is_current {
Span::styled(" ▶ 🔊 ", Style::default().fg(theme.success).add_modifier(Modifier::BOLD))
} else {
Span::styled(format!(" {:02}. ", idx + 1), Style::default().fg(theme.text_muted))
};
let style = if is_current {
Style::default().fg(theme.success).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.text)
};
let artist_str = song.artist.as_deref().unwrap_or("Unknown");
let dur = song.duration.map(|d| format!("{:02}:{:02}", d / 60, d % 60)).unwrap_or_default();
lines.push(Line::from(vec![
prefix,
Span::styled(&song.title, style),
Span::styled(format!(" — {artist_str} "), Style::default().fg(theme.text_muted)),
Span::styled(format!(" [{dur}]"), Style::default().fg(theme.accent)),
]));
}
if lines.is_empty() {
lines.push(Line::from(Span::styled(" Queue is empty. Select an album or search tracks to fill queue. ", Style::default().fg(theme.text_muted))));
}
frame.render_widget(Clear, popup_area);
frame.render_widget(Paragraph::new(lines).block(block), popup_area);
}
+180
View File
@@ -0,0 +1,180 @@
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Gauge, Paragraph};
use ratatui::Frame;
use crate::app::App;
use crate::audio::PlaybackState;
pub fn render(app: &mut App, frame: &mut Frame, area: Rect) {
let theme = &app.theme;
let progress = app.player.get_progress();
app.layout.player_bar = area;
let block = Block::default()
.borders(Borders::ALL)
.border_type(theme.border_type)
.border_style(Style::default().fg(theme.primary))
.title(Span::styled(" 🎵 rhyth-rs v0.1.0-beta ", theme.title_style()));
let inner_area = block.inner(area);
frame.render_widget(block, area);
// 2 rows:
// Row 0: Track Info (Left), Controls & Visualizer (Center), Volume & Tools (Right)
// Row 1: Interactive Progress Bar
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Length(1)])
.split(inner_area);
let top_cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(36), // Track Info & Marquee
Constraint::Percentage(44), // Controls + Spectrum
Constraint::Percentage(20), // Volume & Star/Lyrics
])
.split(rows[0]);
// 1. Controls on center left: [⏮] [▶ / ⏸] [⏭] [🔀] [🔁]
let ctrl_x = top_cols[1].x;
let ctrl_y = top_cols[1].y;
app.layout.btn_prev = Rect::new(ctrl_x, ctrl_y, 4, 1);
app.layout.play_pause_btn = Rect::new(ctrl_x + 4, ctrl_y, 4, 1);
app.layout.btn_next = Rect::new(ctrl_x + 8, ctrl_y, 4, 1);
app.layout.btn_shuffle = Rect::new(ctrl_x + 13, ctrl_y, 4, 1);
app.layout.btn_repeat = Rect::new(ctrl_x + 17, ctrl_y, 4, 1);
// 2. Track Info with Marquee
let is_starred = progress
.current_track
.as_ref()
.map(|t| app.tracks.iter().find(|s| s.id == t.id).and_then(|s| s.starred.as_ref()).is_some())
.unwrap_or(false);
let star_badge = if is_starred {
Span::styled(" ❤️ ", Style::default().fg(theme.accent).add_modifier(Modifier::BOLD))
} else {
Span::styled(" 🤍 ", Style::default().fg(theme.text_muted))
};
let track_info_spans = if let Some(ref track) = progress.current_track {
let full_text = format!("{} • {}", track.title, track.artist);
let max_len = (top_cols[0].width.saturating_sub(6)) as usize;
let displayed = marquee_text(&full_text, max_len, app.marquee_tick / 4);
vec![
star_badge,
Span::styled(displayed, Style::default().fg(theme.text).add_modifier(Modifier::BOLD)),
]
} else {
vec![
star_badge,
Span::styled("No track playing (Enter to play)", Style::default().fg(theme.text_muted)),
]
};
frame.render_widget(Paragraph::new(Line::from(track_info_spans)), top_cols[0]);
// 3. Playback Controls + Visualizer
let play_icon = match progress.state {
PlaybackState::Playing => "⏸",
PlaybackState::Paused => "▶",
PlaybackState::Stopped => "▶",
};
let shuffle_style = if app.shuffle {
Style::default().fg(theme.success).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.text_muted)
};
let repeat_icon = match app.repeat_mode {
crate::app::RepeatMode::Off => "🔁",
crate::app::RepeatMode::All => "🔁",
crate::app::RepeatMode::Track => "🔂",
};
let repeat_style = if app.repeat_mode != crate::app::RepeatMode::Off {
Style::default().fg(theme.accent).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.text_muted)
};
let mut center_spans = vec![
Span::styled("⏮ ", Style::default().fg(theme.primary)),
Span::styled(format!("{play_icon} "), Style::default().fg(theme.highlight_fg).add_modifier(Modifier::BOLD)),
Span::styled("⏭ ", Style::default().fg(theme.primary)),
Span::styled("│ ", Style::default().fg(theme.border)),
Span::styled("🔀 ", shuffle_style),
Span::styled(format!("{repeat_icon} "), repeat_style),
Span::styled("│ ", Style::default().fg(theme.border)),
];
let used_center_width = 22;
let vis_width = (top_cols[1].width as usize).saturating_sub(used_center_width);
if app.config.enable_visualizer && vis_width > 4 {
let mut vis_spans = app.visualizer.render_colored_spans(vis_width);
center_spans.append(&mut vis_spans);
}
frame.render_widget(Paragraph::new(Line::from(center_spans)), top_cols[1]);
// 4. Right side: Volume & Quick Modals ([y] Lyrics, [u] Queue)
let vol_percent = (progress.volume * 100.0).round() as u32;
let vol_icon = if vol_percent == 0 { "🔇" } else if vol_percent < 50 { "🔉" } else { "🔊" };
let right_spans = vec![
Span::styled("[y] Lyrics ", Style::default().fg(theme.secondary)),
Span::styled("[u] Queue ", Style::default().fg(theme.primary)),
Span::styled(format!("{vol_icon} {vol_percent}% "), Style::default().fg(theme.text).add_modifier(Modifier::BOLD)),
];
app.layout.volume_bar = top_cols[2];
app.layout.btn_lyrics = Rect::new(top_cols[2].x, top_cols[2].y, 10, 1);
app.layout.progress_bar = rows[1];
frame.render_widget(Paragraph::new(Line::from(right_spans)).alignment(Alignment::Right), top_cols[2]);
// Row 2: Progress Bar (Clickable & Draggable Seeking)
let current_secs = progress.position.as_secs();
let total_secs = progress.duration.map(|d| d.as_secs()).unwrap_or(0);
let ratio = if total_secs > 0 {
(current_secs as f64 / total_secs as f64).clamp(0.0, 1.0)
} else {
0.0
};
let cur_str = format!("{:02}:{:02}", current_secs / 60, current_secs % 60);
let total_str = if total_secs > 0 {
format!("{:02}:{:02}", total_secs / 60, total_secs % 60)
} else {
"--:--".to_string()
};
let progress_label = format!("{cur_str} ━━━●━━━ {total_str} (Click to Seek)");
let gauge = Gauge::default()
.gauge_style(Style::default().fg(theme.progress_fill).bg(theme.progress_empty))
.ratio(ratio)
.label(Span::styled(progress_label, Style::default().fg(theme.highlight_fg).add_modifier(Modifier::BOLD)));
frame.render_widget(gauge, rows[1]);
}
fn marquee_text(text: &str, max_len: usize, offset: usize) -> String {
let char_count = text.chars().count();
if char_count <= max_len || max_len == 0 {
text.to_string()
} else {
let full = format!("{} ••• ", text);
let chars: Vec<char> = full.chars().collect();
let start = offset % chars.len();
let mut res = String::with_capacity(max_len);
for i in 0..max_len {
res.push(chars[(start + i) % chars.len()]);
}
res
}
}
+301
View File
@@ -0,0 +1,301 @@
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use ratatui::Frame;
use crate::app::App;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SettingsField {
ServerUrl,
Username,
Password,
Theme,
Volume,
Visualizer,
SaveButton,
CancelButton,
}
impl SettingsField {
pub fn next(&self) -> Self {
match self {
SettingsField::ServerUrl => SettingsField::Username,
SettingsField::Username => SettingsField::Password,
SettingsField::Password => SettingsField::Theme,
SettingsField::Theme => SettingsField::Volume,
SettingsField::Volume => SettingsField::Visualizer,
SettingsField::Visualizer => SettingsField::SaveButton,
SettingsField::SaveButton => SettingsField::CancelButton,
SettingsField::CancelButton => SettingsField::ServerUrl,
}
}
pub fn prev(&self) -> Self {
match self {
SettingsField::ServerUrl => SettingsField::CancelButton,
SettingsField::Username => SettingsField::ServerUrl,
SettingsField::Password => SettingsField::Username,
SettingsField::Theme => SettingsField::Password,
SettingsField::Volume => SettingsField::Theme,
SettingsField::Visualizer => SettingsField::Volume,
SettingsField::SaveButton => SettingsField::Visualizer,
SettingsField::CancelButton => SettingsField::SaveButton,
}
}
}
pub struct SettingsFormData {
pub server_url: String,
pub username: String,
pub password: String,
pub theme_index: usize,
pub volume: u32,
pub enable_visualizer: bool,
pub show_password: bool,
pub focused_field: SettingsField,
pub status_message: Option<(String, bool)>, // (message, is_error)
}
impl SettingsFormData {
pub const THEMES: [&'static str; 4] = [
"Catppuccin Mocha",
"Tokyo Night",
"Nord",
"Gruvbox Dark",
];
pub fn new(server_url: &str, username: &str, password: &str, theme_name: &str, volume: f32, visualizer: bool) -> Self {
let theme_index = Self::THEMES
.iter()
.position(|&t| t.to_lowercase().contains(&theme_name.to_lowercase()))
.unwrap_or(0);
Self {
server_url: server_url.to_string(),
username: username.to_string(),
password: password.to_string(),
theme_index,
volume: (volume * 100.0).round() as u32,
enable_visualizer: visualizer,
show_password: false,
focused_field: SettingsField::ServerUrl,
status_message: None,
}
}
pub fn next_theme(&mut self) {
self.theme_index = (self.theme_index + 1) % Self::THEMES.len();
}
pub fn prev_theme(&mut self) {
self.theme_index = if self.theme_index == 0 {
Self::THEMES.len() - 1
} else {
self.theme_index - 1
};
}
}
pub fn render(app: &mut App, frame: &mut Frame, area: Rect, is_initial_setup: bool) {
let theme = app.theme.clone();
let form = &app.settings_form;
// Calculate responsive centered box
let target_width = 78.min(area.width.saturating_sub(2));
let target_height = 19.min(area.height.saturating_sub(2));
let x = area.x + (area.width.saturating_sub(target_width)) / 2;
let y = area.y + (area.height.saturating_sub(target_height)) / 2;
let popup_area = Rect::new(x, y, target_width, target_height);
app.layout.modal_area = popup_area;
frame.render_widget(Clear, popup_area);
let title = if is_initial_setup {
" 🎵 Welcome to rhyth-rs — Setup Navidrome Connection "
} else {
" ⚙️ Settings & Connection "
};
let outer_block = Block::default()
.borders(Borders::ALL)
.border_type(theme.border_type)
.border_style(Style::default().fg(theme.primary))
.title(Span::styled(title, theme.title_style()))
.title_alignment(Alignment::Center);
let inner_area = outer_block.inner(popup_area);
frame.render_widget(outer_block, popup_area);
// Layout inside modal: Hint (1), Columns (10), Status (1), Buttons (3)
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // Hint
Constraint::Length(10), // Main 2-column form
Constraint::Length(1), // Status message
Constraint::Length(3), // Action buttons
])
.split(inner_area);
// 1. Hint Banner
let hint_text = Line::from(vec![
Span::styled("[Tab/↓↑] Move [Enter] Save/Select [F2] Toggle Password [Esc] Exit", Style::default().fg(theme.text_muted)),
]);
frame.render_widget(Paragraph::new(hint_text).alignment(Alignment::Center), chunks[0]);
// 2. Main 2 Columns (Left: Connection, Right: Preferences)
let col_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(chunks[1]);
// --- LEFT COLUMN: SERVER CONNECTION ---
let left_rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Server URL
Constraint::Length(3), // Username
Constraint::Length(3), // Password
])
.split(col_chunks[0]);
app.layout.field_url = left_rows[0];
app.layout.field_user = left_rows[1];
app.layout.field_pass = left_rows[2];
// Field 1: Server URL
let url_focused = form.focused_field == SettingsField::ServerUrl;
let url_block = field_block("Server URL", url_focused, &theme);
let url_text = Line::from(vec![
Span::styled(&form.server_url, Style::default().fg(theme.text)),
if url_focused { Span::styled("█", Style::default().fg(theme.accent)) } else { Span::raw("") },
]);
frame.render_widget(Paragraph::new(url_text).block(url_block), left_rows[0]);
// Field 2: Username
let user_focused = form.focused_field == SettingsField::Username;
let user_block = field_block("Username", user_focused, &theme);
let user_text = Line::from(vec![
Span::styled(&form.username, Style::default().fg(theme.text)),
if user_focused { Span::styled("█", Style::default().fg(theme.accent)) } else { Span::raw("") },
]);
frame.render_widget(Paragraph::new(user_text).block(user_block), left_rows[1]);
// Field 3: Password
let pass_focused = form.focused_field == SettingsField::Password;
let pass_title = if form.show_password { "Password [Visible]" } else { "Password [Masked]" };
let pass_block = field_block(pass_title, pass_focused, &theme);
let pass_display: String = if form.show_password {
form.password.clone()
} else {
"•".repeat(form.password.len())
};
let pass_text = Line::from(vec![
Span::styled(pass_display, Style::default().fg(theme.text)),
if pass_focused { Span::styled("█", Style::default().fg(theme.accent)) } else { Span::raw("") },
]);
frame.render_widget(Paragraph::new(pass_text).block(pass_block), left_rows[2]);
// --- RIGHT COLUMN: APPEARANCE & AUDIO ---
let right_rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Theme
Constraint::Length(3), // Volume
Constraint::Length(3), // Visualizer
])
.split(col_chunks[1]);
app.layout.field_theme = right_rows[0];
app.layout.field_vol = right_rows[1];
app.layout.field_vis = right_rows[2];
// Field 4: Theme Selector
let theme_focused = form.focused_field == SettingsField::Theme;
let theme_block = field_block("Theme (← / →)", theme_focused, &theme);
let theme_name = SettingsFormData::THEMES[form.theme_index];
let theme_text = Line::from(vec![
Span::styled(" ◀ ", Style::default().fg(theme.primary)),
Span::styled(theme_name, Style::default().fg(theme.highlight_fg).add_modifier(Modifier::BOLD)),
Span::styled(" ▶ ", Style::default().fg(theme.primary)),
]);
frame.render_widget(Paragraph::new(theme_text).block(theme_block), right_rows[0]);
// Field 5: Default Volume
let vol_focused = form.focused_field == SettingsField::Volume;
let vol_block = field_block("Volume (+ / -)", vol_focused, &theme);
let vol_bars_count = (form.volume / 10).min(10) as usize;
let vol_bar = "█".repeat(vol_bars_count);
let vol_empty = "░".repeat(10 - vol_bars_count);
let vol_text = Line::from(vec![
Span::styled(format!(" {:>3}% ", form.volume), Style::default().fg(theme.text).add_modifier(Modifier::BOLD)),
Span::styled(vol_bar, Style::default().fg(theme.progress_fill)),
Span::styled(vol_empty, Style::default().fg(theme.progress_empty)),
]);
frame.render_widget(Paragraph::new(vol_text).block(vol_block), right_rows[1]);
// Field 6: Visualizer Toggle
let vis_focused = form.focused_field == SettingsField::Visualizer;
let vis_block = field_block("Spectrum (Space/Enter)", vis_focused, &theme);
let (vis_badge, vis_style) = if form.enable_visualizer {
("[ ✔ ENABLED ]", Style::default().fg(theme.success).add_modifier(Modifier::BOLD))
} else {
("[ ✖ DISABLED ]", Style::default().fg(theme.text_muted))
};
frame.render_widget(Paragraph::new(Line::from(Span::styled(vis_badge, vis_style))).block(vis_block), right_rows[2]);
// 3. Status Message Banner
if let Some((msg, is_error)) = &form.status_message {
let color = if *is_error { theme.error } else { theme.success };
let status_line = Line::from(Span::styled(format!(" {msg}"), Style::default().fg(color).add_modifier(Modifier::BOLD)));
frame.render_widget(Paragraph::new(status_line).alignment(Alignment::Center), chunks[2]);
}
// 4. Action Buttons
let button_cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(chunks[3]);
app.layout.btn_save = button_cols[0];
app.layout.btn_cancel = button_cols[1];
let save_focused = form.focused_field == SettingsField::SaveButton;
let save_style = if save_focused {
Style::default().bg(theme.primary).fg(theme.surface).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.primary).add_modifier(Modifier::BOLD)
};
let save_btn = Paragraph::new(" [ Connect & Save ] ")
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL).border_type(theme.border_type).border_style(if save_focused { theme.focused_border_style() } else { theme.normal_border_style() }))
.style(save_style);
frame.render_widget(save_btn, button_cols[0]);
let cancel_focused = form.focused_field == SettingsField::CancelButton;
let cancel_style = if cancel_focused {
Style::default().bg(theme.text_muted).fg(theme.surface).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.text_muted)
};
let cancel_btn = Paragraph::new(if is_initial_setup { " [ Exit ] " } else { " [ Cancel ] " })
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL).border_type(theme.border_type).border_style(if cancel_focused { theme.focused_border_style() } else { theme.normal_border_style() }))
.style(cancel_style);
frame.render_widget(cancel_btn, button_cols[1]);
}
fn field_block(title: &'static str, is_focused: bool, theme: &crate::ui::theme::Theme) -> Block<'static> {
Block::default()
.borders(Borders::ALL)
.border_type(theme.border_type)
.border_style(if is_focused {
theme.focused_border_style()
} else {
theme.normal_border_style()
})
.title(Span::styled(format!(" {title} "), if is_focused { theme.title_style() } else { Style::default().fg(theme.text_muted) }))
}