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