feat: initial release v0.1.0-beta - complete TUI audio player for Navidrome
This commit is contained in:
@@ -0,0 +1 @@
|
||||
/target
|
||||
Generated
+3105
File diff suppressed because it is too large
Load Diff
+42
@@ -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"
|
||||
@@ -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.
|
||||
@@ -0,0 +1,153 @@
|
||||
# 🎵 rhyth-rs (v0.1.0-beta)
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://www.rust-lang.org/)
|
||||
[](https://www.navidrome.org/)
|
||||
[](https://opensubsonic.netlify.app/)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](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
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod decoder;
|
||||
pub mod player;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub use player::{AudioPlayer, PlaybackProgress, PlaybackState, TrackMetadata};
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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}%"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -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", ¶ms)
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod client;
|
||||
pub mod models;
|
||||
|
||||
pub use client::SubsonicClient;
|
||||
#[allow(unused_imports)]
|
||||
pub use models::*;
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod header;
|
||||
pub mod library;
|
||||
pub mod modals;
|
||||
pub mod player_bar;
|
||||
pub mod settings;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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) }))
|
||||
}
|
||||
Reference in New Issue
Block a user