Initial commit — ferrosonic terminal Subsonic client

Terminal-based Subsonic music client in Rust featuring bit-perfect audio
playback via PipeWire sample rate switching, gapless playback, MPRIS2
desktop integration, cava audio visualizer with theme-matched gradients,
13 built-in color themes with custom TOML theme support, mouse controls,
artist/album browser, playlist support, and play queue management.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-27 21:43:26 +00:00
commit 12cc70e6ec
36 changed files with 11600 additions and 0 deletions

23
.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
# Build artifacts
/target/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# Claude Code
CLAUDE.md
CLAUDE.md2
# OS
.DS_Store
Thumbs.db
# Logs
*.log
# Config (may contain credentials)
config.toml

3270
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

62
Cargo.toml Normal file
View File

@@ -0,0 +1,62 @@
[package]
name = "ferrosonic"
version = "0.1.0"
edition = "2021"
description = "A terminal-based Subsonic music client with bit-perfect audio playback"
license = "MIT"
authors = ["ferrosonic contributors"]
[dependencies]
# Async runtime
tokio = { version = "1", features = ["full", "sync", "process", "signal"] }
# Terminal UI
ratatui = "0.29"
crossterm = "0.28"
tui-tree-widget = "0.22"
# HTTP client for Subsonic API
reqwest = { version = "0.12", features = ["json"] }
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
# Error handling
thiserror = "2"
anyhow = "1"
# D-Bus / MPRIS
zbus = "5"
mpris-server = "0.8"
# Unix utilities
libc = "0.2"
# Utilities
dirs = "6"
url = "2"
urlencoding = "2"
md5 = "0.7"
rand = "0.8"
unicode-width = "0.2"
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing-appender = "0.2"
# CLI
clap = { version = "4", features = ["derive"] }
# Async channels
futures = "0.3"
# Terminal emulator for cava integration
vt100 = "0.15"
[profile.release]
lto = true
codegen-units = 1
strip = true

322
README.md Normal file
View File

@@ -0,0 +1,322 @@
# Ferrosonic
A terminal-based Subsonic music client written in Rust, featuring bit-perfect audio playback, gapless transitions, and full desktop integration.
Ferrosonic is inspired by [Termsonic](https://git.sixfoisneuf.fr/termsonic/about/), the original terminal Subsonic client written in Go by [SixFoisNeuf](https://www.sixfoisneuf.fr/posts/termsonic-a-terminal-client-for-subsonic/). Ferrosonic is a ground-up rewrite in Rust with additional features including PipeWire sample rate switching for bit-perfect audio, MPRIS2 media controls, multiple color themes, and mouse support.
## Features
- **Bit-perfect audio** - Automatic PipeWire sample rate switching to match the source material (44.1kHz, 48kHz, 96kHz, 192kHz, etc.)
- **Gapless playback** - Seamless transitions between tracks with pre-buffered next track
- **MPRIS2 integration** - Full desktop media control support (play, pause, stop, next, previous, seek)
- **Artist/album browser** - Tree-based navigation with expandable artists and album listings
- **Playlist support** - Browse and play server playlists with shuffle capability
- **Play queue management** - Add, remove, reorder, shuffle, and clear queue history
- **Audio quality display** - Real-time display of sample rate, bit depth, codec format, and channel layout
- **Audio visualizer** - Integrated cava audio visualizer with theme-matched gradient colors
- **13 built-in themes** - Default, Monokai, Dracula, Nord, Gruvbox, Catppuccin, Solarized, Tokyo Night, Rosé Pine, Everforest, Kanagawa, One Dark, and Ayu Dark
- **Custom themes** - Create your own themes as TOML files in `~/.config/ferrosonic/themes/`
- **Mouse support** - Clickable buttons, tabs, lists, and progress bar seeking
- **Artist filtering** - Real-time search/filter on the artist list
- **Multi-disc album support** - Proper disc and track number display
- **Keyboard-driven** - Vim-style navigation (j/k) alongside arrow keys
## Screenshots
<!-- Add screenshots here -->
## Installation
### Dependencies
Ferrosonic requires the following at runtime:
| Dependency | Purpose | Required |
|---|---|---|
| **mpv** | Audio playback engine (via JSON IPC) | Yes |
| **PipeWire** | Automatic sample rate switching for bit-perfect audio | Recommended |
| **WirePlumber** | PipeWire session manager | Recommended |
| **D-Bus** | MPRIS2 desktop media controls | Recommended |
| **cava** | Audio visualizer | Optional |
Build dependencies (needed to compile from source):
| Dependency | Package (Arch) | Package (Fedora) | Package (Debian/Ubuntu) |
|---|---|---|---|
| **Rust toolchain** | `rustup` | via rustup.rs | via rustup.rs |
| **pkg-config** | `pkgconf` | `pkgconf-pkg-config` | `pkg-config` |
| **D-Bus dev headers** | `dbus` | `dbus-devel` | `libdbus-1-dev` |
### Arch Linux
```bash
# Install runtime dependencies
sudo pacman -S mpv pipewire wireplumber
# Install build dependencies
sudo pacman -S base-devel pkgconf dbus
# Build from source
git clone https://github.com/jaidaken/ferrosonic.git
cd ferrosonic
cargo build --release
# Binary is at target/release/ferrosonic
sudo cp target/release/ferrosonic /usr/local/bin/
```
### Fedora
```bash
# Install runtime dependencies
sudo dnf install mpv pipewire wireplumber
# Install build dependencies
sudo dnf install gcc pkgconf-pkg-config dbus-devel
# Install Rust toolchain if not already installed
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Build from source
git clone https://github.com/jaidaken/ferrosonic.git
cd ferrosonic
cargo build --release
sudo cp target/release/ferrosonic /usr/local/bin/
```
### Ubuntu / Debian
```bash
# Install runtime dependencies
sudo apt install mpv pipewire wireplumber
# Install build dependencies
sudo apt install build-essential pkg-config libdbus-1-dev
# Install Rust toolchain if not already installed
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Build from source
git clone https://github.com/jaidaken/ferrosonic.git
cd ferrosonic
cargo build --release
sudo cp target/release/ferrosonic /usr/local/bin/
```
### Generic (any Linux distribution)
Ensure `mpv` is installed and available in your `PATH`. PipeWire is optional but required for automatic sample rate switching.
```bash
# Install Rust toolchain
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Build
git clone https://github.com/jaidaken/ferrosonic.git
cd ferrosonic
cargo build --release
# Install
sudo cp target/release/ferrosonic /usr/local/bin/
```
## Usage
```bash
# Run with default config (~/.config/ferrosonic/config.toml)
ferrosonic
# Run with a custom config file
ferrosonic -c /path/to/config.toml
# Enable verbose/debug logging
ferrosonic -v
```
## Configuration
Configuration is stored at `~/.config/ferrosonic/config.toml`. You can edit it manually or configure the server connection through the application's Server page (F4).
```toml
BaseURL = "https://your-subsonic-server.com"
Username = "your-username"
Password = "your-password"
Theme = "Default"
```
| Field | Description |
|---|---|
| `BaseURL` | URL of your Subsonic-compatible server (Navidrome, Airsonic, Gonic, etc.) |
| `Username` | Your server username |
| `Password` | Your server password |
| `Theme` | Color theme name (e.g. `Default`, `Catppuccin`, `Tokyo Night`) |
Logs are written to `~/.config/ferrosonic/ferrosonic.log`.
## Keyboard Shortcuts
### Global
| Key | Action |
|---|---|
| `q` | Quit |
| `p` / `Space` | Toggle play/pause |
| `l` | Next track |
| `h` | Previous track |
| `Ctrl+R` | Refresh data from server |
| `t` | Cycle to next theme |
| `F1` | Artists page |
| `F2` | Queue page |
| `F3` | Playlists page |
| `F4` | Server configuration page |
| `F5` | Settings page |
### Artists Page (F1)
| Key | Action |
|---|---|
| `/` | Filter artists by name |
| `Esc` | Clear filter |
| `Up` / `k` | Move selection up |
| `Down` / `j` | Move selection down |
| `Left` / `Right` | Switch focus between tree and song list |
| `Enter` | Expand/collapse artist, or play album/song |
| `Backspace` | Return to tree from song list |
| `e` | Add selected item to end of queue |
| `n` | Add selected item as next in queue |
### Queue Page (F2)
| Key | Action |
|---|---|
| `Up` / `k` | Move selection up |
| `Down` / `j` | Move selection down |
| `Enter` | Play selected song |
| `d` | Remove selected song from queue |
| `J` (Shift+J) | Move selected song down |
| `K` (Shift+K) | Move selected song up |
| `r` | Shuffle queue (current song stays in place) |
| `c` | Clear played history (remove songs before current) |
### Playlists Page (F3)
| Key | Action |
|---|---|
| `Tab` / `Left` / `Right` | Switch focus between playlists and songs |
| `Up` / `k` | Move selection up |
| `Down` / `j` | Move selection down |
| `Enter` | Load playlist songs or play selected song |
| `e` | Add selected item to end of queue |
| `n` | Add selected song as next in queue |
| `r` | Shuffle play all songs in selected playlist |
### Server Page (F4)
| Key | Action |
|---|---|
| `Tab` | Move between fields |
| `Enter` | Test connection or Save configuration |
| `Backspace` | Delete character in text field |
### Settings Page (F5)
| Key | Action |
|---|---|
| `Up` / `Down` | Move between settings |
| `Left` | Previous option |
| `Right` / `Enter` | Next option |
Settings include theme selection and cava visualizer toggle. Changes are saved automatically.
## Mouse Support
- Click page tabs in the header to switch pages
- Click playback control buttons (Previous, Play, Pause, Stop, Next) in the header
- Click items in lists to select them
- Click the progress bar in the Now Playing widget to seek
## Audio Features
### Bit-Perfect Playback
Ferrosonic uses PipeWire's `pw-metadata` to automatically switch the system sample rate to match the source material. When a track at 96kHz starts playing, PipeWire is instructed to output at 96kHz, avoiding unnecessary resampling. The original sample rate is restored when the application exits.
### Gapless Playback
The next track in the queue is pre-loaded into MPV's internal playlist before the current track finishes, allowing seamless transitions with no gap or click between songs.
### Now Playing Display
The Now Playing widget shows:
- Artist, album, and track title
- Audio quality: format/codec, bit depth, sample rate, and channel layout
- Visual progress bar with elapsed/total time
## Themes
Ferrosonic ships with 13 themes. On first run, the built-in themes are written as TOML files to `~/.config/ferrosonic/themes/`.
| Theme | Description |
|---|---|
| **Default** | Cyan/yellow on dark background (hardcoded) |
| **Monokai** | Classic Monokai syntax highlighting palette |
| **Dracula** | Purple/pink Dracula color scheme |
| **Nord** | Arctic blue Nord palette |
| **Gruvbox** | Warm retro Gruvbox colors |
| **Catppuccin** | Soothing pastel Catppuccin Mocha palette |
| **Solarized** | Ethan Schoonover's Solarized Dark |
| **Tokyo Night** | Dark Tokyo Night color scheme |
| **Rosé Pine** | Soho vibes Rosé Pine palette |
| **Everforest** | Comfortable green Everforest Dark |
| **Kanagawa** | Dark Kanagawa wave palette |
| **One Dark** | Atom One Dark color scheme |
| **Ayu Dark** | Ayu Dark color scheme |
Change themes with `t` from any page, from the Settings page (F5), or by editing the `Theme` field in `config.toml`.
### Custom Themes
Create a `.toml` file in `~/.config/ferrosonic/themes/` and it will appear in the theme list. The filename becomes the display name (e.g. `my-theme.toml` becomes "My Theme").
```toml
[colors]
primary = "#89b4fa"
secondary = "#585b70"
accent = "#f9e2af"
artist = "#a6e3a1"
album = "#f5c2e7"
song = "#94e2d5"
muted = "#6c7086"
highlight_bg = "#45475a"
highlight_fg = "#cdd6f4"
success = "#a6e3a1"
error = "#f38ba8"
playing = "#f9e2af"
played = "#6c7086"
border_focused = "#89b4fa"
border_unfocused = "#45475a"
[cava]
gradient = ["#a6e3a1", "#94e2d5", "#89dceb", "#74c7ec", "#cba6f7", "#f5c2e7", "#f38ba8", "#f38ba8"]
horizontal_gradient = ["#f38ba8", "#eba0ac", "#fab387", "#f9e2af", "#a6e3a1", "#94e2d5", "#89b4fa", "#cba6f7"]
```
You can also edit the built-in theme files to customize them. They will not be overwritten unless deleted.
## Compatible Servers
Ferrosonic works with any server implementing the Subsonic API, including:
- [Navidrome](https://www.navidrome.org/)
- [Airsonic](https://airsonic.github.io/)
- [Airsonic-Advanced](https://github.com/airsonic-advanced/airsonic-advanced)
- [Gonic](https://github.com/sentriz/gonic)
- [Supysonic](https://github.com/spl0k/supysonic)
## Acknowledgements
Ferrosonic is inspired by [Termsonic](https://git.sixfoisneuf.fr/termsonic/about/) by SixFoisNeuf, a terminal Subsonic client written in Go. Ferrosonic builds on that concept with a Rust implementation, bit-perfect audio via PipeWire, and additional features.
## License
MIT

111
src/app/actions.rs Normal file
View File

@@ -0,0 +1,111 @@
//! Application actions and message passing
use crate::subsonic::models::{Album, Artist, Child, Playlist};
/// Actions that can be sent to the audio backend
#[derive(Debug, Clone)]
pub enum AudioAction {
/// Play a specific song by URL
Play { url: String, song: Child },
/// Pause playback
Pause,
/// Resume playback
Resume,
/// Toggle pause state
TogglePause,
/// Stop playback
Stop,
/// Seek to position (seconds)
Seek(f64),
/// Seek relative to current position
SeekRelative(f64),
/// Skip to next track
Next,
/// Skip to previous track
Previous,
/// Set volume (0-100)
SetVolume(i32),
}
/// Actions that can be sent to update the UI
#[derive(Debug, Clone)]
pub enum UiAction {
/// Update playback position
UpdatePosition { position: f64, duration: f64 },
/// Update playback state
UpdatePlaybackState(PlaybackStateUpdate),
/// Update audio properties
UpdateAudioProperties {
sample_rate: Option<u32>,
bit_depth: Option<u32>,
format: Option<String>,
},
/// Track ended (EOF from MPV)
TrackEnded,
/// Show notification
Notify { message: String, is_error: bool },
/// Artists loaded from server
ArtistsLoaded(Vec<Artist>),
/// Albums loaded for an artist
AlbumsLoaded {
artist_id: String,
albums: Vec<Album>,
},
/// Songs loaded for an album
SongsLoaded { album_id: String, songs: Vec<Child> },
/// Playlists loaded from server
PlaylistsLoaded(Vec<Playlist>),
/// Playlist songs loaded
PlaylistSongsLoaded {
playlist_id: String,
songs: Vec<Child>,
},
/// Server connection test result
ConnectionTestResult { success: bool, message: String },
/// Force redraw
Redraw,
}
/// Playback state update
#[derive(Debug, Clone, Copy)]
pub enum PlaybackStateUpdate {
Playing,
Paused,
Stopped,
}
/// Actions for the Subsonic client
#[derive(Debug, Clone)]
pub enum SubsonicAction {
/// Fetch all artists
FetchArtists,
/// Fetch albums for an artist
FetchAlbums { artist_id: String },
/// Fetch songs for an album
FetchAlbum { album_id: String },
/// Fetch all playlists
FetchPlaylists,
/// Fetch songs in a playlist
FetchPlaylist { playlist_id: String },
/// Test server connection
TestConnection,
}
/// Queue manipulation actions
#[derive(Debug, Clone)]
pub enum QueueAction {
/// Append songs to queue
Append(Vec<Child>),
/// Insert songs after current position
InsertNext(Vec<Child>),
/// Clear the queue
Clear,
/// Remove song at index
Remove(usize),
/// Move song from one index to another
Move { from: usize, to: usize },
/// Shuffle the queue (keeping current song in place)
Shuffle,
/// Play song at queue index
PlayIndex(usize),
}

2526
src/app/mod.rs Normal file

File diff suppressed because it is too large Load Diff

399
src/app/state.rs Normal file
View File

@@ -0,0 +1,399 @@
//! Shared application state
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::RwLock;
use ratatui::layout::Rect;
use crate::config::Config;
use crate::subsonic::models::{Album, Artist, Child, Playlist};
use crate::ui::theme::{ThemeColors, ThemeData};
/// Current page in the application
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Page {
#[default]
Artists,
Queue,
Playlists,
Server,
Settings,
}
impl Page {
pub fn index(&self) -> usize {
match self {
Page::Artists => 0,
Page::Queue => 1,
Page::Playlists => 2,
Page::Server => 3,
Page::Settings => 4,
}
}
pub fn from_index(index: usize) -> Self {
match index {
0 => Page::Artists,
1 => Page::Queue,
2 => Page::Playlists,
3 => Page::Server,
4 => Page::Settings,
_ => Page::Artists,
}
}
pub fn label(&self) -> &'static str {
match self {
Page::Artists => "Artists",
Page::Queue => "Queue",
Page::Playlists => "Playlists",
Page::Server => "Server",
Page::Settings => "Settings",
}
}
pub fn shortcut(&self) -> &'static str {
match self {
Page::Artists => "F1",
Page::Queue => "F2",
Page::Playlists => "F3",
Page::Server => "F4",
Page::Settings => "F5",
}
}
}
/// Playback state
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PlaybackState {
#[default]
Stopped,
Playing,
Paused,
}
/// Now playing information
#[derive(Debug, Clone, Default)]
pub struct NowPlaying {
/// Currently playing song
pub song: Option<Child>,
/// Playback state
pub state: PlaybackState,
/// Current position in seconds
pub position: f64,
/// Total duration in seconds
pub duration: f64,
/// Audio sample rate (Hz)
pub sample_rate: Option<u32>,
/// Audio bit depth
pub bit_depth: Option<u32>,
/// Audio format/codec
pub format: Option<String>,
/// Audio channel layout (e.g., "Stereo", "Mono", "5.1ch")
pub channels: Option<String>,
}
impl NowPlaying {
pub fn progress_percent(&self) -> f64 {
if self.duration > 0.0 {
(self.position / self.duration).clamp(0.0, 1.0)
} else {
0.0
}
}
pub fn format_position(&self) -> String {
format_duration(self.position)
}
pub fn format_duration(&self) -> String {
format_duration(self.duration)
}
}
/// Format duration in MM:SS or HH:MM:SS format
pub fn format_duration(seconds: f64) -> String {
let total_secs = seconds as u64;
let hours = total_secs / 3600;
let mins = (total_secs % 3600) / 60;
let secs = total_secs % 60;
if hours > 0 {
format!("{:02}:{:02}:{:02}", hours, mins, secs)
} else {
format!("{:02}:{:02}", mins, secs)
}
}
/// Artists page state
#[derive(Debug, Clone, Default)]
pub struct ArtistsState {
/// List of all artists
pub artists: Vec<Artist>,
/// Currently selected index in the tree (artists + expanded albums)
pub selected_index: Option<usize>,
/// Set of expanded artist IDs
pub expanded: std::collections::HashSet<String>,
/// Albums cached per artist ID
pub albums_cache: std::collections::HashMap<String, Vec<Album>>,
/// Songs in the selected album (shown in right pane)
pub songs: Vec<Child>,
/// Currently selected song index
pub selected_song: Option<usize>,
/// Artist filter text
pub filter: String,
/// Whether filter input is active
pub filter_active: bool,
/// Focus: 0 = tree, 1 = songs
pub focus: usize,
/// Scroll offset for the tree list (set after render)
pub tree_scroll_offset: usize,
/// Scroll offset for the songs list (set after render)
pub song_scroll_offset: usize,
}
/// Queue page state
#[derive(Debug, Clone, Default)]
pub struct QueueState {
/// Currently selected index in the queue
pub selected: Option<usize>,
/// Scroll offset for the queue list (set after render)
pub scroll_offset: usize,
}
/// Playlists page state
#[derive(Debug, Clone, Default)]
pub struct PlaylistsState {
/// List of all playlists
pub playlists: Vec<Playlist>,
/// Currently selected playlist index
pub selected_playlist: Option<usize>,
/// Songs in the selected playlist
pub songs: Vec<Child>,
/// Currently selected song index
pub selected_song: Option<usize>,
/// Focus: 0 = playlists, 1 = songs
pub focus: usize,
/// Scroll offset for the playlists list (set after render)
pub playlist_scroll_offset: usize,
/// Scroll offset for the songs list (set after render)
pub song_scroll_offset: usize,
}
/// Server page state (connection settings)
#[derive(Debug, Clone, Default)]
pub struct ServerState {
/// Currently focused field (0-4: URL, Username, Password, Test, Save)
pub selected_field: usize,
/// Edit values
pub base_url: String,
pub username: String,
pub password: String,
/// Status message
pub status: Option<String>,
}
/// Settings page state
#[derive(Debug, Clone)]
pub struct SettingsState {
/// Currently focused field (0=Theme, 1=Cava)
pub selected_field: usize,
/// Available themes (Default + loaded from files)
pub themes: Vec<ThemeData>,
/// Index of the currently selected theme in `themes`
pub theme_index: usize,
/// Cava visualizer enabled
pub cava_enabled: bool,
}
impl Default for SettingsState {
fn default() -> Self {
Self {
selected_field: 0,
themes: vec![ThemeData::default_theme()],
theme_index: 0,
cava_enabled: false,
}
}
}
impl SettingsState {
/// Current theme name
pub fn theme_name(&self) -> &str {
&self.themes[self.theme_index].name
}
/// Current theme colors
pub fn theme_colors(&self) -> &ThemeColors {
&self.themes[self.theme_index].colors
}
/// Current theme data
pub fn current_theme(&self) -> &ThemeData {
&self.themes[self.theme_index]
}
/// Cycle to next theme
pub fn next_theme(&mut self) {
self.theme_index = (self.theme_index + 1) % self.themes.len();
}
/// Cycle to previous theme
pub fn prev_theme(&mut self) {
self.theme_index = (self.theme_index + self.themes.len() - 1) % self.themes.len();
}
/// Set theme by name, returning true if found
pub fn set_theme_by_name(&mut self, name: &str) -> bool {
if let Some(idx) = self.themes.iter().position(|t| t.name.eq_ignore_ascii_case(name)) {
self.theme_index = idx;
true
} else {
self.theme_index = 0; // Fall back to Default
false
}
}
}
/// Notification/alert to display
#[derive(Debug, Clone)]
pub struct Notification {
pub message: String,
pub is_error: bool,
pub created_at: Instant,
}
/// Cached layout rectangles from the last render, used for mouse hit-testing.
/// Automatically updated every frame, so resize and visualiser toggle are handled.
#[derive(Debug, Clone, Default)]
pub struct LayoutAreas {
pub header: Rect,
pub cava: Option<Rect>,
pub content: Rect,
pub now_playing: Rect,
pub footer: Rect,
/// Left pane for dual-pane pages (Artists tree, Playlists list)
pub content_left: Option<Rect>,
/// Right pane for dual-pane pages (Songs list)
pub content_right: Option<Rect>,
}
/// Complete application state
#[derive(Debug, Default)]
pub struct AppState {
/// Application configuration
pub config: Config,
/// Current page
pub page: Page,
/// Now playing information
pub now_playing: NowPlaying,
/// Play queue (songs)
pub queue: Vec<Child>,
/// Current position in queue
pub queue_position: Option<usize>,
/// Artists page state
pub artists: ArtistsState,
/// Queue page state
pub queue_state: QueueState,
/// Playlists page state
pub playlists: PlaylistsState,
/// Server page state (connection settings)
pub server_state: ServerState,
/// Settings page state (app preferences)
pub settings_state: SettingsState,
/// Current notification
pub notification: Option<Notification>,
/// Whether the app should quit
pub should_quit: bool,
/// Cava visualizer screen content (rows of styled spans)
pub cava_screen: Vec<CavaRow>,
/// Whether the cava binary is available on the system
pub cava_available: bool,
/// Cached layout areas from last render (for mouse hit-testing)
pub layout: LayoutAreas,
}
/// A row of styled segments from cava's terminal output
#[derive(Debug, Clone, Default)]
pub struct CavaRow {
pub spans: Vec<CavaSpan>,
}
/// A styled text segment from cava's terminal output
#[derive(Debug, Clone)]
pub struct CavaSpan {
pub text: String,
pub fg: CavaColor,
pub bg: CavaColor,
}
/// Color from cava's terminal output
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum CavaColor {
#[default]
Default,
Indexed(u8),
Rgb(u8, u8, u8),
}
impl AppState {
pub fn new(config: Config) -> Self {
let mut state = Self {
config: config.clone(),
..Default::default()
};
// Initialize server page with current values
state.server_state.base_url = config.base_url.clone();
state.server_state.username = config.username.clone();
state.server_state.password = config.password.clone();
// Initialize cava from config
state.settings_state.cava_enabled = config.cava;
state
}
/// Get the currently playing song from the queue
pub fn current_song(&self) -> Option<&Child> {
self.queue_position.and_then(|pos| self.queue.get(pos))
}
/// Show a notification
pub fn notify(&mut self, message: impl Into<String>) {
self.notification = Some(Notification {
message: message.into(),
is_error: false,
created_at: Instant::now(),
});
}
/// Show an error notification
pub fn notify_error(&mut self, message: impl Into<String>) {
self.notification = Some(Notification {
message: message.into(),
is_error: true,
created_at: Instant::now(),
});
}
/// Check if notification should be auto-cleared (after 2 seconds)
pub fn check_notification_timeout(&mut self) {
if let Some(ref notif) = self.notification {
if notif.created_at.elapsed().as_secs() >= 2 {
self.notification = None;
}
}
}
/// Clear the notification
pub fn clear_notification(&mut self) {
self.notification = None;
}
}
/// Thread-safe shared state
pub type SharedState = Arc<RwLock<AppState>>;
/// Create new shared state
pub fn new_shared_state(config: Config) -> SharedState {
Arc::new(RwLock::new(AppState::new(config)))
}

7
src/audio/mod.rs Normal file
View File

@@ -0,0 +1,7 @@
//! Audio playback module
#![allow(dead_code)]
pub mod mpv;
pub mod pipewire;
pub mod queue;

434
src/audio/mpv.rs Normal file
View File

@@ -0,0 +1,434 @@
//! MPV controller via JSON IPC
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use tracing::{debug, info, trace};
use crate::config::paths::mpv_socket_path;
use crate::error::AudioError;
/// MPV IPC command
#[derive(Debug, Serialize)]
struct MpvCommand {
command: Vec<Value>,
request_id: u64,
}
/// MPV IPC response
#[derive(Debug, Deserialize)]
struct MpvResponse {
#[serde(default)]
request_id: Option<u64>,
#[serde(default)]
data: Option<Value>,
#[serde(default)]
error: String,
}
/// MPV event
#[derive(Debug, Deserialize)]
struct MpvEvent {
event: String,
#[serde(default)]
name: Option<String>,
#[serde(default)]
data: Option<Value>,
}
/// Events emitted by MPV
#[derive(Debug, Clone)]
pub enum MpvEvent2 {
/// Track reached end of file
EndFile,
/// Playback paused
Pause,
/// Playback resumed
Unpause,
/// Position changed (time in seconds)
TimePos(f64),
/// Audio properties changed
AudioProperties {
sample_rate: Option<u32>,
bit_depth: Option<u32>,
format: Option<String>,
},
/// MPV shut down
Shutdown,
}
/// MPV controller
pub struct MpvController {
/// Path to the IPC socket
socket_path: PathBuf,
/// MPV process handle
process: Option<Child>,
/// Request ID counter
request_id: AtomicU64,
/// Socket connection
socket: Option<UnixStream>,
}
impl MpvController {
/// Create a new MPV controller
pub fn new() -> Self {
Self {
socket_path: mpv_socket_path(),
process: None,
request_id: AtomicU64::new(1),
socket: None,
}
}
/// Start MPV process if not running
pub fn start(&mut self) -> Result<(), AudioError> {
if self.process.is_some() {
return Ok(());
}
// Remove existing socket if present
let _ = std::fs::remove_file(&self.socket_path);
info!("Starting MPV with socket: {}", self.socket_path.display());
let child = Command::new("mpv")
.arg("--idle") // Stay running when nothing playing
.arg("--no-video") // Audio only
.arg("--no-terminal") // No MPV UI
.arg("--gapless-audio=yes") // Gapless playback between tracks
.arg("--prefetch-playlist=yes") // Pre-buffer next track
.arg("--cache=yes") // Enable cache for network streams
.arg("--cache-secs=120") // Cache up to 2 minutes ahead
.arg("--demuxer-max-bytes=100MiB") // Allow large demuxer buffer
.arg(format!("--input-ipc-server={}", self.socket_path.display()))
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(AudioError::MpvSpawn)?;
self.process = Some(child);
// Wait for socket to become available
for _ in 0..50 {
if self.socket_path.exists() {
std::thread::sleep(Duration::from_millis(50));
break;
}
std::thread::sleep(Duration::from_millis(100));
}
if !self.socket_path.exists() {
return Err(AudioError::MpvIpc("Socket not created".to_string()));
}
self.connect()?;
info!("MPV started successfully");
Ok(())
}
/// Connect to the MPV socket
fn connect(&mut self) -> Result<(), AudioError> {
let stream = UnixStream::connect(&self.socket_path).map_err(AudioError::MpvSocket)?;
// Set read timeout
stream
.set_read_timeout(Some(Duration::from_millis(100)))
.map_err(AudioError::MpvSocket)?;
self.socket = Some(stream);
debug!("Connected to MPV socket");
Ok(())
}
/// Check if MPV is running
pub fn is_running(&self) -> bool {
self.socket.is_some()
}
/// Send a command to MPV
fn send_command(&mut self, args: Vec<Value>) -> Result<Option<Value>, AudioError> {
let socket = self.socket.as_mut().ok_or(AudioError::MpvNotRunning)?;
let request_id = self.request_id.fetch_add(1, Ordering::SeqCst);
let cmd = MpvCommand {
command: args,
request_id,
};
let json = serde_json::to_string(&cmd)?;
debug!("Sending MPV command: {}", json);
writeln!(socket, "{}", json).map_err(|e| AudioError::MpvIpc(e.to_string()))?;
socket
.flush()
.map_err(|e| AudioError::MpvIpc(e.to_string()))?;
// Read response
let mut reader = BufReader::new(socket.try_clone().map_err(AudioError::MpvSocket)?);
let mut line = String::new();
loop {
line.clear();
match reader.read_line(&mut line) {
Ok(0) => return Err(AudioError::MpvIpc("Socket closed".to_string())),
Ok(_) => {
if let Ok(resp) = serde_json::from_str::<MpvResponse>(&line) {
if resp.request_id == Some(request_id) {
if resp.error != "success" {
return Err(AudioError::MpvIpc(resp.error));
}
return Ok(resp.data);
}
}
// Log discarded events for diagnostics
if let Ok(event) = serde_json::from_str::<MpvEvent>(&line) {
trace!("MPV event: {:?}", event);
}
}
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
// Timeout, try again
continue;
}
Err(e) => return Err(AudioError::MpvIpc(e.to_string())),
}
}
}
/// Load and play a file/URL (replaces current playlist)
pub fn loadfile(&mut self, path: &str) -> Result<(), AudioError> {
info!("Loading: {}", path.split('?').next().unwrap_or(path));
self.send_command(vec![json!("loadfile"), json!(path), json!("replace")])?;
Ok(())
}
/// Append a file/URL to the playlist (for gapless playback)
pub fn loadfile_append(&mut self, path: &str) -> Result<(), AudioError> {
debug!(
"Appending to playlist: {}",
path.split('?').next().unwrap_or(path)
);
self.send_command(vec![json!("loadfile"), json!(path), json!("append")])?;
Ok(())
}
/// Clear the playlist except current track
pub fn playlist_clear(&mut self) -> Result<(), AudioError> {
debug!("Clearing playlist");
self.send_command(vec![json!("playlist-clear")])?;
Ok(())
}
/// Remove a specific entry from the playlist by index
pub fn playlist_remove(&mut self, index: usize) -> Result<(), AudioError> {
debug!("Removing playlist entry {}", index);
self.send_command(vec![json!("playlist-remove"), json!(index)])?;
Ok(())
}
/// Get current playlist position (0-indexed)
pub fn get_playlist_pos(&mut self) -> Result<Option<i64>, AudioError> {
let data = self.send_command(vec![json!("get_property"), json!("playlist-pos")])?;
Ok(data.and_then(|v| v.as_i64()))
}
/// Get playlist count
pub fn get_playlist_count(&mut self) -> Result<usize, AudioError> {
let data = self.send_command(vec![json!("get_property"), json!("playlist-count")])?;
Ok(data.and_then(|v| v.as_u64()).unwrap_or(0) as usize)
}
/// Pause playback
pub fn pause(&mut self) -> Result<(), AudioError> {
debug!("Pausing playback");
self.send_command(vec![json!("set_property"), json!("pause"), json!(true)])?;
Ok(())
}
/// Resume playback
pub fn resume(&mut self) -> Result<(), AudioError> {
debug!("Resuming playback");
self.send_command(vec![json!("set_property"), json!("pause"), json!(false)])?;
Ok(())
}
/// Toggle pause
pub fn toggle_pause(&mut self) -> Result<bool, AudioError> {
let paused = self.is_paused()?;
if paused {
self.resume()?;
} else {
self.pause()?;
}
Ok(!paused)
}
/// Check if paused
pub fn is_paused(&mut self) -> Result<bool, AudioError> {
let data = self.send_command(vec![json!("get_property"), json!("pause")])?;
Ok(data.and_then(|v| v.as_bool()).unwrap_or(false))
}
/// Stop playback
pub fn stop(&mut self) -> Result<(), AudioError> {
debug!("Stopping playback");
self.send_command(vec![json!("stop")])?;
Ok(())
}
/// Seek to position (seconds)
pub fn seek(&mut self, position: f64) -> Result<(), AudioError> {
debug!("Seeking to {:.1}s", position);
self.send_command(vec![json!("seek"), json!(position), json!("absolute")])?;
Ok(())
}
/// Seek relative to current position
pub fn seek_relative(&mut self, offset: f64) -> Result<(), AudioError> {
debug!("Seeking {:+.1}s", offset);
self.send_command(vec![json!("seek"), json!(offset), json!("relative")])?;
Ok(())
}
/// Get current playback position in seconds
pub fn get_time_pos(&mut self) -> Result<f64, AudioError> {
let data = self.send_command(vec![json!("get_property"), json!("time-pos")])?;
Ok(data.and_then(|v| v.as_f64()).unwrap_or(0.0))
}
/// Get total duration in seconds
pub fn get_duration(&mut self) -> Result<f64, AudioError> {
let data = self.send_command(vec![json!("get_property"), json!("duration")])?;
Ok(data.and_then(|v| v.as_f64()).unwrap_or(0.0))
}
/// Get volume (0-100)
pub fn get_volume(&mut self) -> Result<i32, AudioError> {
let data = self.send_command(vec![json!("get_property"), json!("volume")])?;
Ok(data
.and_then(|v| v.as_f64())
.map(|v| v as i32)
.unwrap_or(100))
}
/// Set volume (0-100)
pub fn set_volume(&mut self, volume: i32) -> Result<(), AudioError> {
debug!("Setting volume to {}", volume);
self.send_command(vec![
json!("set_property"),
json!("volume"),
json!(volume.clamp(0, 100)),
])?;
Ok(())
}
/// Get audio sample rate
pub fn get_sample_rate(&mut self) -> Result<Option<u32>, AudioError> {
let data = self.send_command(vec![
json!("get_property"),
json!("audio-params/samplerate"),
])?;
Ok(data.and_then(|v| v.as_u64()).map(|v| v as u32))
}
/// Get audio bit depth
pub fn get_bit_depth(&mut self) -> Result<Option<u32>, AudioError> {
// MPV returns format string like "s16" or "s32"
let data = self.send_command(vec![json!("get_property"), json!("audio-params/format")])?;
let format = data.and_then(|v| v.as_str().map(String::from));
Ok(format.and_then(|f| {
if f.contains("32") || f.contains("float") {
Some(32)
} else if f.contains("24") {
Some(24)
} else if f.contains("16") {
Some(16)
} else if f.contains("8") {
Some(8)
} else {
None
}
}))
}
/// Get audio format string
pub fn get_audio_format(&mut self) -> Result<Option<String>, AudioError> {
let data = self.send_command(vec![json!("get_property"), json!("audio-params/format")])?;
Ok(data.and_then(|v| v.as_str().map(String::from)))
}
/// Get audio channel layout
pub fn get_channels(&mut self) -> Result<Option<String>, AudioError> {
let data = self.send_command(vec![
json!("get_property"),
json!("audio-params/channel-count"),
])?;
let count = data.and_then(|v| v.as_u64()).map(|v| v as u32);
Ok(count.map(|c| match c {
1 => "Mono".to_string(),
2 => "Stereo".to_string(),
n => format!("{}ch", n),
}))
}
/// Get current filename/URL
pub fn get_path(&mut self) -> Result<Option<String>, AudioError> {
let data = self.send_command(vec![json!("get_property"), json!("path")])?;
Ok(data.and_then(|v| v.as_str().map(String::from)))
}
/// Check if anything is loaded
pub fn is_idle(&mut self) -> Result<bool, AudioError> {
let data = self.send_command(vec![json!("get_property"), json!("idle-active")])?;
Ok(data.and_then(|v| v.as_bool()).unwrap_or(true))
}
/// Check if current file has reached EOF
pub fn is_eof(&mut self) -> Result<bool, AudioError> {
let data = self.send_command(vec![json!("get_property"), json!("eof-reached")])?;
Ok(data.and_then(|v| v.as_bool()).unwrap_or(false))
}
/// Quit MPV
pub fn quit(&mut self) -> Result<(), AudioError> {
if self.socket.is_some() {
let _ = self.send_command(vec![json!("quit")]);
}
if let Some(mut child) = self.process.take() {
let _ = child.kill();
let _ = child.wait();
}
self.socket = None;
let _ = std::fs::remove_file(&self.socket_path);
info!("MPV shut down");
Ok(())
}
/// Observe a property for changes
pub fn observe_property(&mut self, id: u64, name: &str) -> Result<(), AudioError> {
self.send_command(vec![json!("observe_property"), json!(id), json!(name)])?;
Ok(())
}
}
impl Drop for MpvController {
fn drop(&mut self) {
let _ = self.quit();
}
}
impl Default for MpvController {
fn default() -> Self {
Self::new()
}
}

202
src/audio/pipewire.rs Normal file
View File

@@ -0,0 +1,202 @@
//! PipeWire sample rate control
use std::process::Command;
use tracing::{debug, error, info};
use crate::error::AudioError;
/// Default audio device ID for PipeWire
const DEFAULT_DEVICE_ID: u32 = 0;
/// PipeWire sample rate controller
pub struct PipeWireController {
/// Original sample rate before ferrosonic started
original_rate: Option<u32>,
/// Current forced sample rate
current_rate: Option<u32>,
}
impl PipeWireController {
/// Create a new PipeWire controller
pub fn new() -> Self {
let original_rate = Self::get_current_rate_internal().ok();
debug!("Original PipeWire sample rate: {:?}", original_rate);
Self {
original_rate,
current_rate: None,
}
}
/// Get current sample rate from PipeWire
fn get_current_rate_internal() -> Result<u32, AudioError> {
let output = Command::new("pw-metadata")
.arg("-n")
.arg("settings")
.arg("0")
.arg("clock.force-rate")
.output()
.map_err(|e| AudioError::PipeWire(format!("Failed to run pw-metadata: {}", e)))?;
let stdout = String::from_utf8_lossy(&output.stdout);
// Parse output like: "update: id:0 key:'clock.force-rate' value:'48000' type:''"
for line in stdout.lines() {
if line.contains("clock.force-rate") && line.contains("value:") {
if let Some(start) = line.find("value:'") {
let rest = &line[start + 7..];
if let Some(end) = rest.find('\'') {
let rate_str = &rest[..end];
if let Ok(rate) = rate_str.parse::<u32>() {
return Ok(rate);
}
}
}
}
}
// No forced rate, return default
Ok(0)
}
/// Get the current forced sample rate
pub fn get_current_rate(&self) -> Option<u32> {
self.current_rate
}
/// Set the sample rate
pub fn set_rate(&mut self, rate: u32) -> Result<(), AudioError> {
if self.current_rate == Some(rate) {
debug!("Sample rate already set to {}", rate);
return Ok(());
}
info!("Setting PipeWire sample rate to {} Hz", rate);
let output = Command::new("pw-metadata")
.arg("-n")
.arg("settings")
.arg("0")
.arg("clock.force-rate")
.arg(rate.to_string())
.output()
.map_err(|e| AudioError::PipeWire(format!("Failed to run pw-metadata: {}", e)))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(AudioError::PipeWire(format!(
"pw-metadata failed: {}",
stderr
)));
}
self.current_rate = Some(rate);
Ok(())
}
/// Restore original sample rate
pub fn restore_original(&mut self) -> Result<(), AudioError> {
if let Some(rate) = self.original_rate {
if rate > 0 {
info!("Restoring original sample rate: {} Hz", rate);
self.set_rate(rate)?;
} else {
info!("Clearing forced sample rate");
self.clear_forced_rate()?;
}
}
Ok(())
}
/// Clear the forced sample rate (let PipeWire use default)
pub fn clear_forced_rate(&mut self) -> Result<(), AudioError> {
info!("Clearing PipeWire forced sample rate");
let output = Command::new("pw-metadata")
.arg("-n")
.arg("settings")
.arg("0")
.arg("clock.force-rate")
.arg("0")
.output()
.map_err(|e| AudioError::PipeWire(format!("Failed to run pw-metadata: {}", e)))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(AudioError::PipeWire(format!(
"pw-metadata failed: {}",
stderr
)));
}
self.current_rate = None;
Ok(())
}
/// Check if PipeWire is available
pub fn is_available() -> bool {
Command::new("pw-metadata")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
/// Get the effective sample rate (from pw-metadata or system default)
pub fn get_effective_rate() -> Option<u32> {
// Try to get from PipeWire
let output = Command::new("pw-metadata")
.arg("-n")
.arg("settings")
.output()
.ok()?;
let stdout = String::from_utf8_lossy(&output.stdout);
// Look for clock.rate or clock.force-rate
for line in stdout.lines() {
if (line.contains("clock.rate") || line.contains("clock.force-rate"))
&& line.contains("value:")
{
if let Some(start) = line.find("value:'") {
let rest = &line[start + 7..];
if let Some(end) = rest.find('\'') {
let rate_str = &rest[..end];
if let Ok(rate) = rate_str.parse::<u32>() {
if rate > 0 {
return Some(rate);
}
}
}
}
}
}
None
}
}
impl Default for PipeWireController {
fn default() -> Self {
Self::new()
}
}
impl Drop for PipeWireController {
fn drop(&mut self) {
if let Err(e) = self.restore_original() {
error!("Failed to restore sample rate: {}", e);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_available() {
// This test just checks the function doesn't panic
let _ = PipeWireController::is_available();
}
}

321
src/audio/queue.rs Normal file
View File

@@ -0,0 +1,321 @@
//! Play queue management
use rand::seq::SliceRandom;
use tracing::debug;
use crate::subsonic::models::Child;
use crate::subsonic::SubsonicClient;
/// Play queue
#[derive(Debug, Clone, Default)]
pub struct PlayQueue {
/// Songs in the queue
songs: Vec<Child>,
/// Current position in the queue (None = stopped)
position: Option<usize>,
}
impl PlayQueue {
/// Create a new empty queue
pub fn new() -> Self {
Self::default()
}
/// Get the songs in the queue
pub fn songs(&self) -> &[Child] {
&self.songs
}
/// Get the current position
pub fn position(&self) -> Option<usize> {
self.position
}
/// Get the current song
pub fn current(&self) -> Option<&Child> {
self.position.and_then(|pos| self.songs.get(pos))
}
/// Get number of songs in queue
pub fn len(&self) -> usize {
self.songs.len()
}
/// Check if queue is empty
pub fn is_empty(&self) -> bool {
self.songs.is_empty()
}
/// Add songs to the end of the queue
pub fn append(&mut self, songs: impl IntoIterator<Item = Child>) {
self.songs.extend(songs);
debug!("Queue now has {} songs", self.songs.len());
}
/// Insert songs after the current position
pub fn insert_next(&mut self, songs: impl IntoIterator<Item = Child>) {
let insert_pos = self.position.map(|p| p + 1).unwrap_or(0);
let new_songs: Vec<_> = songs.into_iter().collect();
let count = new_songs.len();
for (i, song) in new_songs.into_iter().enumerate() {
self.songs.insert(insert_pos + i, song);
}
debug!("Inserted {} songs at position {}", count, insert_pos);
}
/// Clear the queue
pub fn clear(&mut self) {
self.songs.clear();
self.position = None;
debug!("Queue cleared");
}
/// Remove song at index
pub fn remove(&mut self, index: usize) -> Option<Child> {
if index >= self.songs.len() {
return None;
}
let song = self.songs.remove(index);
// Adjust position if needed
if let Some(pos) = self.position {
if index < pos {
self.position = Some(pos - 1);
} else if index == pos {
// Removed current song
if self.songs.is_empty() {
self.position = None;
} else if pos >= self.songs.len() {
self.position = Some(self.songs.len() - 1);
}
}
}
debug!("Removed song at index {}", index);
Some(song)
}
/// Move song from one position to another
pub fn move_song(&mut self, from: usize, to: usize) {
if from >= self.songs.len() || to >= self.songs.len() {
return;
}
let song = self.songs.remove(from);
self.songs.insert(to, song);
// Adjust position if needed
if let Some(pos) = self.position {
if from == pos {
self.position = Some(to);
} else if from < pos && to >= pos {
self.position = Some(pos - 1);
} else if from > pos && to <= pos {
self.position = Some(pos + 1);
}
}
debug!("Moved song from {} to {}", from, to);
}
/// Shuffle the queue, keeping current song in place
pub fn shuffle(&mut self) {
if self.songs.len() <= 1 {
return;
}
let mut rng = rand::thread_rng();
if let Some(pos) = self.position {
// Keep current song, shuffle the rest
let current = self.songs.remove(pos);
// Shuffle remaining songs
self.songs.shuffle(&mut rng);
// Put current song at the front
self.songs.insert(0, current);
self.position = Some(0);
} else {
// No current song, shuffle everything
self.songs.shuffle(&mut rng);
}
debug!("Queue shuffled");
}
/// Set current position
pub fn set_position(&mut self, position: Option<usize>) {
if let Some(pos) = position {
if pos < self.songs.len() {
self.position = Some(pos);
debug!("Position set to {}", pos);
}
} else {
self.position = None;
debug!("Position cleared");
}
}
/// Advance to next song
/// Returns true if there was a next song
pub fn next(&mut self) -> bool {
match self.position {
Some(pos) if pos + 1 < self.songs.len() => {
self.position = Some(pos + 1);
debug!("Advanced to position {}", pos + 1);
true
}
_ => {
self.position = None;
debug!("Reached end of queue");
false
}
}
}
/// Go to previous song
/// Returns true if there was a previous song
pub fn previous(&mut self) -> bool {
match self.position {
Some(pos) if pos > 0 => {
self.position = Some(pos - 1);
debug!("Went back to position {}", pos - 1);
true
}
_ => {
if !self.songs.is_empty() {
self.position = Some(0);
}
debug!("At start of queue");
false
}
}
}
/// Get stream URL for current song
pub fn current_stream_url(&self, client: &SubsonicClient) -> Option<String> {
self.current()
.and_then(|song| client.get_stream_url(&song.id).ok())
}
/// Get stream URL for song at index
pub fn stream_url_at(&self, index: usize, client: &SubsonicClient) -> Option<String> {
self.songs
.get(index)
.and_then(|song| client.get_stream_url(&song.id).ok())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_song(id: &str, title: &str) -> Child {
Child {
id: id.to_string(),
title: title.to_string(),
parent: None,
is_dir: false,
album: None,
artist: None,
track: None,
year: None,
genre: None,
cover_art: None,
size: None,
content_type: None,
suffix: None,
duration: None,
bit_rate: None,
path: None,
disc_number: None,
}
}
#[test]
fn test_append_and_len() {
let mut queue = PlayQueue::new();
assert!(queue.is_empty());
queue.append(vec![make_song("1", "Song 1"), make_song("2", "Song 2")]);
assert_eq!(queue.len(), 2);
}
#[test]
fn test_position_and_navigation() {
let mut queue = PlayQueue::new();
queue.append(vec![
make_song("1", "Song 1"),
make_song("2", "Song 2"),
make_song("3", "Song 3"),
]);
assert!(queue.current().is_none());
queue.set_position(Some(0));
assert_eq!(queue.current().unwrap().id, "1");
assert!(queue.next());
assert_eq!(queue.current().unwrap().id, "2");
assert!(queue.next());
assert_eq!(queue.current().unwrap().id, "3");
assert!(!queue.next());
assert!(queue.current().is_none());
}
#[test]
fn test_remove() {
let mut queue = PlayQueue::new();
queue.append(vec![
make_song("1", "Song 1"),
make_song("2", "Song 2"),
make_song("3", "Song 3"),
]);
queue.set_position(Some(1));
// Remove song before current
queue.remove(0);
assert_eq!(queue.position(), Some(0));
assert_eq!(queue.current().unwrap().id, "2");
// Remove current song
queue.remove(0);
assert_eq!(queue.current().unwrap().id, "3");
}
#[test]
fn test_insert_next() {
let mut queue = PlayQueue::new();
queue.append(vec![make_song("1", "Song 1"), make_song("3", "Song 3")]);
queue.set_position(Some(0));
queue.insert_next(vec![make_song("2", "Song 2")]);
assert_eq!(queue.songs[0].id, "1");
assert_eq!(queue.songs[1].id, "2");
assert_eq!(queue.songs[2].id, "3");
}
#[test]
fn test_move_song() {
let mut queue = PlayQueue::new();
queue.append(vec![
make_song("1", "Song 1"),
make_song("2", "Song 2"),
make_song("3", "Song 3"),
]);
queue.set_position(Some(0));
queue.move_song(0, 2);
assert_eq!(queue.songs[0].id, "2");
assert_eq!(queue.songs[1].id, "3");
assert_eq!(queue.songs[2].id, "1");
assert_eq!(queue.position(), Some(2));
}
}

165
src/config/mod.rs Normal file
View File

@@ -0,0 +1,165 @@
//! Configuration loading and management
pub mod paths;
use serde::{Deserialize, Serialize};
use std::path::Path;
use tracing::{debug, info, warn};
use crate::error::ConfigError;
/// Main application configuration
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config {
/// Subsonic server base URL
#[serde(rename = "BaseURL", default)]
pub base_url: String,
/// Username for authentication
#[serde(rename = "Username", default)]
pub username: String,
/// Password for authentication
#[serde(rename = "Password", default)]
pub password: String,
/// UI Theme name
#[serde(rename = "Theme", default)]
pub theme: String,
/// Enable cava audio visualizer
#[serde(rename = "Cava", default)]
pub cava: bool,
}
impl Config {
/// Create a new empty config
pub fn new() -> Self {
Self::default()
}
/// Load config from the default location
pub fn load_default() -> Result<Self, ConfigError> {
let path = paths::config_file().ok_or_else(|| ConfigError::NotFound {
path: "default config location".to_string(),
})?;
if path.exists() {
Self::load_from_file(&path)
} else {
info!("No config file found at {}, using defaults", path.display());
Ok(Self::new())
}
}
/// Load config from a specific file
pub fn load_from_file(path: &Path) -> Result<Self, ConfigError> {
debug!("Loading config from {}", path.display());
if !path.exists() {
return Err(ConfigError::NotFound {
path: path.display().to_string(),
});
}
let contents = std::fs::read_to_string(path)?;
let config: Config = toml::from_str(&contents)?;
debug!("Config loaded successfully");
Ok(config)
}
/// Save config to the default location
pub fn save_default(&self) -> Result<(), ConfigError> {
let path = paths::config_file().ok_or_else(|| ConfigError::NotFound {
path: "default config location".to_string(),
})?;
self.save_to_file(&path)
}
/// Save config to a specific file
pub fn save_to_file(&self, path: &Path) -> Result<(), ConfigError> {
debug!("Saving config to {}", path.display());
// Ensure parent directory exists
if let Some(parent) = path.parent() {
if !parent.exists() {
std::fs::create_dir_all(parent)?;
}
}
let contents = toml::to_string_pretty(self)?;
std::fs::write(path, contents)?;
info!("Config saved to {}", path.display());
Ok(())
}
/// Check if the config has valid server settings
pub fn is_configured(&self) -> bool {
!self.base_url.is_empty() && !self.username.is_empty() && !self.password.is_empty()
}
/// Validate the config
#[allow(dead_code)]
pub fn validate(&self) -> Result<(), ConfigError> {
if self.base_url.is_empty() {
return Err(ConfigError::MissingField {
field: "BaseURL".to_string(),
});
}
// Validate URL format
if url::Url::parse(&self.base_url).is_err() {
return Err(ConfigError::InvalidUrl {
url: self.base_url.clone(),
});
}
if self.username.is_empty() {
warn!("Username is empty");
}
if self.password.is_empty() {
warn!("Password is empty");
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn test_config_parse() {
let toml_content = r#"
BaseURL = "https://example.com"
Username = "testuser"
Password = "testpass"
"#;
let mut file = NamedTempFile::new().unwrap();
file.write_all(toml_content.as_bytes()).unwrap();
let config = Config::load_from_file(file.path()).unwrap();
assert_eq!(config.base_url, "https://example.com");
assert_eq!(config.username, "testuser");
assert_eq!(config.password, "testpass");
}
#[test]
fn test_is_configured() {
let mut config = Config::new();
assert!(!config.is_configured());
config.base_url = "https://example.com".to_string();
config.username = "user".to_string();
config.password = "pass".to_string();
assert!(config.is_configured());
}
}

46
src/config/paths.rs Normal file
View File

@@ -0,0 +1,46 @@
//! Platform-specific configuration paths
use std::path::PathBuf;
/// Get the default config directory for ferrosonic
pub fn config_dir() -> Option<PathBuf> {
dirs::config_dir().map(|p| p.join("ferrosonic"))
}
/// Get the default config file path
pub fn config_file() -> Option<PathBuf> {
config_dir().map(|p| p.join("config.toml"))
}
/// Get the themes directory path
pub fn themes_dir() -> Option<PathBuf> {
config_dir().map(|p| p.join("themes"))
}
/// Get the log file path
#[allow(dead_code)]
pub fn log_file() -> Option<PathBuf> {
config_dir().map(|p| p.join("ferrosonic.log"))
}
/// Get the MPV socket path
pub fn mpv_socket_path() -> PathBuf {
std::env::temp_dir().join("ferrosonic-mpv.sock")
}
/// Ensure the config directory exists
#[allow(dead_code)]
pub fn ensure_config_dir() -> std::io::Result<PathBuf> {
let dir = config_dir().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
"Could not determine config directory",
)
})?;
if !dir.exists() {
std::fs::create_dir_all(&dir)?;
}
Ok(dir)
}

115
src/error.rs Normal file
View File

@@ -0,0 +1,115 @@
//! Error types for ferrosonic
#![allow(dead_code)]
use thiserror::Error;
/// Main error type for ferrosonic
#[derive(Error, Debug)]
pub enum Error {
#[error("Configuration error: {0}")]
Config(#[from] ConfigError),
#[error("Subsonic API error: {0}")]
Subsonic(#[from] SubsonicError),
#[error("Audio playback error: {0}")]
Audio(#[from] AudioError),
#[error("UI error: {0}")]
Ui(#[from] UiError),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
/// Configuration-related errors
#[derive(Error, Debug)]
pub enum ConfigError {
#[error("Config file not found at {path}")]
NotFound { path: String },
#[error("Failed to parse config: {0}")]
Parse(#[from] toml::de::Error),
#[error("Failed to serialize config: {0}")]
Serialize(#[from] toml::ser::Error),
#[error("Missing required field: {field}")]
MissingField { field: String },
#[error("Invalid URL: {url}")]
InvalidUrl { url: String },
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
/// Subsonic API errors
#[derive(Error, Debug)]
pub enum SubsonicError {
#[error("HTTP request failed: {0}")]
Http(#[from] reqwest::Error),
#[error("API error {code}: {message}")]
Api { code: i32, message: String },
#[error("Authentication failed")]
AuthFailed,
#[error("Server not configured")]
NotConfigured,
#[error("Failed to parse response: {0}")]
Parse(String),
#[error("URL parse error: {0}")]
UrlParse(#[from] url::ParseError),
}
/// Audio playback errors
#[derive(Error, Debug)]
pub enum AudioError {
#[error("MPV not running")]
MpvNotRunning,
#[error("Failed to spawn MPV: {0}")]
MpvSpawn(std::io::Error),
#[error("MPV IPC error: {0}")]
MpvIpc(String),
#[error("MPV socket connection failed: {0}")]
MpvSocket(std::io::Error),
#[error("PipeWire command failed: {0}")]
PipeWire(String),
#[error("Queue is empty")]
QueueEmpty,
#[error("Invalid queue index: {index}")]
InvalidIndex { index: usize },
#[error("JSON serialization error: {0}")]
Json(#[from] serde_json::Error),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
/// UI-related errors
#[derive(Error, Debug)]
pub enum UiError {
#[error("Terminal initialization failed: {0}")]
TerminalInit(std::io::Error),
#[error("Render error: {0}")]
Render(std::io::Error),
#[error("Input error: {0}")]
Input(std::io::Error),
}
/// Result type alias using our Error
pub type Result<T> = std::result::Result<T, Error>;

131
src/main.rs Normal file
View File

@@ -0,0 +1,131 @@
//! Termsonic - A terminal-based Subsonic music client
//!
//! Features:
//! - Bit-perfect audio playback via MPV and PipeWire sample rate switching
//! - MPRIS2 desktop integration for media controls
//! - Browse artists, albums, and playlists
//! - Play queue with shuffle and reorder support
//! - Server configuration with connection testing
mod app;
mod audio;
mod config;
mod error;
mod mpris;
mod subsonic;
mod ui;
use clap::Parser;
use std::fs::{self, File};
use std::path::PathBuf;
use tracing::info;
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
use crate::app::App;
use crate::config::paths::config_dir;
use crate::config::Config;
/// Termsonic - Terminal Subsonic Music Client
#[derive(Parser, Debug)]
#[command(name = "ferrosonic")]
#[command(author, version, about, long_about = None)]
struct Args {
/// Path to config file
#[arg(short, long, value_name = "FILE")]
config: Option<PathBuf>,
/// Enable verbose/debug logging
#[arg(short, long)]
verbose: bool,
}
/// Initialize file-based logging
fn init_logging(verbose: bool) -> Option<tracing_appender::non_blocking::WorkerGuard> {
// Get log directory (same as config dir for consistency with Go version)
let log_dir = config_dir().unwrap_or_else(|| PathBuf::from("/tmp"));
// Create log directory if needed
if let Err(e) = fs::create_dir_all(&log_dir) {
eprintln!("Warning: Could not create log directory: {}", e);
return None;
}
let log_file = log_dir.join("ferrosonic.log");
// Open log file (truncate on each run)
let file = match File::create(&log_file) {
Ok(f) => f,
Err(e) => {
eprintln!("Warning: Could not create log file: {}", e);
return None;
}
};
let (non_blocking, guard) = tracing_appender::non_blocking(file);
let filter = if verbose {
EnvFilter::new("ferrosonic=debug")
} else {
EnvFilter::new("ferrosonic=info")
};
tracing_subscriber::registry()
.with(filter)
.with(
fmt::layer()
.with_writer(non_blocking)
.with_ansi(false)
.with_target(false),
)
.init();
if verbose {
eprintln!("Logging to: {}", log_file.display());
}
Some(guard)
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let args = Args::parse();
// Initialize file-based logging (keep guard alive for duration of program)
let _log_guard = init_logging(args.verbose);
info!("Termsonic starting...");
// Load configuration
let config = match args.config {
Some(path) => {
info!("Loading config from {}", path.display());
Config::load_from_file(&path)?
}
None => {
info!("Loading default config");
Config::load_default().unwrap_or_else(|e| {
info!("No config found ({}), using defaults", e);
Config::new()
})
}
};
info!(
"Server: {}",
if config.base_url.is_empty() {
"(not configured)"
} else {
&config.base_url
}
);
// Run the application
let mut app = App::new(config);
if let Err(e) = app.run().await {
tracing::error!("Application error: {}", e);
return Err(e.into());
}
info!("Termsonic exiting...");
Ok(())
}

5
src/mpris/mod.rs Normal file
View File

@@ -0,0 +1,5 @@
//! MPRIS2 D-Bus integration module
#![allow(dead_code)]
pub mod server;

372
src/mpris/server.rs Normal file
View File

@@ -0,0 +1,372 @@
//! MPRIS2 D-Bus server implementation
use mpris_server::{
zbus::{fdo, Result},
LoopStatus, Metadata, PlaybackRate, PlaybackStatus, PlayerInterface, Property, RootInterface,
Server, Time, TrackId, Volume,
};
use tokio::sync::mpsc;
use tracing::info;
use url::Url;
use crate::app::actions::AudioAction;
use crate::app::state::{NowPlaying, PlaybackState, SharedState};
use crate::config::Config;
use crate::subsonic::auth::generate_auth_params;
use crate::subsonic::models::Child;
/// API version for Subsonic
const API_VERSION: &str = "1.16.1";
/// Client name for Subsonic
const CLIENT_NAME: &str = "ferrosonic";
/// Build a cover art URL from config and cover art ID
fn build_cover_art_url(config: &Config, cover_art_id: &str) -> Option<String> {
if config.base_url.is_empty() || cover_art_id.is_empty() {
return None;
}
let (salt, token) = generate_auth_params(&config.password);
let mut url = Url::parse(&format!("{}/rest/getCoverArt", config.base_url)).ok()?;
url.query_pairs_mut()
.append_pair("id", cover_art_id)
.append_pair("u", &config.username)
.append_pair("t", &token)
.append_pair("s", &salt)
.append_pair("v", API_VERSION)
.append_pair("c", CLIENT_NAME);
Some(url.to_string())
}
/// MPRIS server instance name
const PLAYER_NAME: &str = "ferrosonic";
/// MPRIS2 player implementation
pub struct MprisPlayer {
state: SharedState,
audio_tx: mpsc::Sender<AudioAction>,
}
impl MprisPlayer {
pub fn new(state: SharedState, audio_tx: mpsc::Sender<AudioAction>) -> Self {
Self { state, audio_tx }
}
async fn get_state(&self) -> (NowPlaying, Option<Child>, Config) {
let state = self.state.read().await;
let now_playing = state.now_playing.clone();
let current_song = state.current_song().cloned();
let config = state.config.clone();
(now_playing, current_song, config)
}
}
impl RootInterface for MprisPlayer {
async fn raise(&self) -> fdo::Result<()> {
// We're a terminal app, can't raise
Ok(())
}
async fn quit(&self) -> fdo::Result<()> {
let mut state = self.state.write().await;
state.should_quit = true;
Ok(())
}
async fn can_quit(&self) -> fdo::Result<bool> {
Ok(true)
}
async fn fullscreen(&self) -> fdo::Result<bool> {
Ok(false)
}
async fn set_fullscreen(&self, _fullscreen: bool) -> Result<()> {
Ok(())
}
async fn can_set_fullscreen(&self) -> fdo::Result<bool> {
Ok(false)
}
async fn can_raise(&self) -> fdo::Result<bool> {
Ok(false)
}
async fn has_track_list(&self) -> fdo::Result<bool> {
Ok(false)
}
async fn identity(&self) -> fdo::Result<String> {
Ok("Termsonic".to_string())
}
async fn desktop_entry(&self) -> fdo::Result<String> {
Ok("ferrosonic".to_string())
}
async fn supported_uri_schemes(&self) -> fdo::Result<Vec<String>> {
Ok(vec!["http".to_string(), "https".to_string()])
}
async fn supported_mime_types(&self) -> fdo::Result<Vec<String>> {
Ok(vec![
"audio/mpeg".to_string(),
"audio/flac".to_string(),
"audio/ogg".to_string(),
"audio/wav".to_string(),
"audio/x-wav".to_string(),
])
}
}
impl PlayerInterface for MprisPlayer {
async fn next(&self) -> fdo::Result<()> {
let _ = self.audio_tx.send(AudioAction::Next).await;
Ok(())
}
async fn previous(&self) -> fdo::Result<()> {
let _ = self.audio_tx.send(AudioAction::Previous).await;
Ok(())
}
async fn pause(&self) -> fdo::Result<()> {
let _ = self.audio_tx.send(AudioAction::Pause).await;
Ok(())
}
async fn play_pause(&self) -> fdo::Result<()> {
let _ = self.audio_tx.send(AudioAction::TogglePause).await;
Ok(())
}
async fn stop(&self) -> fdo::Result<()> {
let _ = self.audio_tx.send(AudioAction::Stop).await;
Ok(())
}
async fn play(&self) -> fdo::Result<()> {
let _ = self.audio_tx.send(AudioAction::Resume).await;
Ok(())
}
async fn seek(&self, offset: Time) -> fdo::Result<()> {
let offset_secs = offset.as_micros() as f64 / 1_000_000.0;
let _ = self
.audio_tx
.send(AudioAction::SeekRelative(offset_secs))
.await;
Ok(())
}
async fn set_position(&self, _track_id: TrackId, position: Time) -> fdo::Result<()> {
let position_secs = position.as_micros() as f64 / 1_000_000.0;
let _ = self.audio_tx.send(AudioAction::Seek(position_secs)).await;
Ok(())
}
async fn open_uri(&self, _uri: String) -> fdo::Result<()> {
// Not supported for now
Ok(())
}
async fn playback_status(&self) -> fdo::Result<PlaybackStatus> {
let (now_playing, _, _) = self.get_state().await;
Ok(match now_playing.state {
PlaybackState::Playing => PlaybackStatus::Playing,
PlaybackState::Paused => PlaybackStatus::Paused,
PlaybackState::Stopped => PlaybackStatus::Stopped,
})
}
async fn loop_status(&self) -> fdo::Result<LoopStatus> {
Ok(LoopStatus::None)
}
async fn set_loop_status(&self, _loop_status: LoopStatus) -> Result<()> {
Ok(())
}
async fn rate(&self) -> fdo::Result<PlaybackRate> {
Ok(1.0)
}
async fn set_rate(&self, _rate: PlaybackRate) -> Result<()> {
Ok(())
}
async fn shuffle(&self) -> fdo::Result<bool> {
Ok(false)
}
async fn set_shuffle(&self, _shuffle: bool) -> Result<()> {
Ok(())
}
async fn metadata(&self) -> fdo::Result<Metadata> {
let (_now_playing, current_song, config) = self.get_state().await;
let mut metadata = Metadata::new();
if let Some(song) = current_song {
metadata.set_trackid(
Some(TrackId::try_from(format!("/org/mpris/MediaPlayer2/Track/{}", song.id)).ok())
.flatten(),
);
metadata.set_title(Some(song.title));
metadata.set_artist(song.artist.map(|a| vec![a]));
metadata.set_album(song.album);
if let Some(duration) = song.duration {
metadata.set_length(Some(Time::from_micros(duration as i64 * 1_000_000)));
}
if let Some(track) = song.track {
metadata.set_track_number(Some(track));
}
if let Some(disc) = song.disc_number {
metadata.set_disc_number(Some(disc));
}
// Add cover art URL
if let Some(ref cover_art_id) = song.cover_art {
if let Some(cover_url) = build_cover_art_url(&config, cover_art_id) {
metadata.set_art_url(Some(cover_url));
}
}
}
Ok(metadata)
}
async fn volume(&self) -> fdo::Result<Volume> {
Ok(1.0)
}
async fn set_volume(&self, volume: Volume) -> Result<()> {
let volume_int = (volume * 100.0) as i32;
let _ = self.audio_tx.send(AudioAction::SetVolume(volume_int)).await;
Ok(())
}
async fn position(&self) -> fdo::Result<Time> {
let (now_playing, _, _) = self.get_state().await;
Ok(Time::from_micros(
(now_playing.position * 1_000_000.0) as i64,
))
}
async fn minimum_rate(&self) -> fdo::Result<PlaybackRate> {
Ok(1.0)
}
async fn maximum_rate(&self) -> fdo::Result<PlaybackRate> {
Ok(1.0)
}
async fn can_go_next(&self) -> fdo::Result<bool> {
let state = self.state.read().await;
Ok(state
.queue_position
.map(|p| p + 1 < state.queue.len())
.unwrap_or(false))
}
async fn can_go_previous(&self) -> fdo::Result<bool> {
let state = self.state.read().await;
Ok(state.queue_position.map(|p| p > 0).unwrap_or(false))
}
async fn can_play(&self) -> fdo::Result<bool> {
let state = self.state.read().await;
Ok(!state.queue.is_empty())
}
async fn can_pause(&self) -> fdo::Result<bool> {
Ok(true)
}
async fn can_seek(&self) -> fdo::Result<bool> {
Ok(true)
}
async fn can_control(&self) -> fdo::Result<bool> {
Ok(true)
}
}
/// Start the MPRIS server
pub async fn start_mpris_server(
state: SharedState,
audio_tx: mpsc::Sender<AudioAction>,
) -> Result<Server<MprisPlayer>> {
info!("Starting MPRIS2 server");
let player = MprisPlayer::new(state, audio_tx);
let server = Server::new(PLAYER_NAME, player).await?;
info!(
"MPRIS2 server started as org.mpris.MediaPlayer2.{}",
PLAYER_NAME
);
Ok(server)
}
/// Update MPRIS properties when state changes
pub async fn update_mpris_properties(
server: &Server<MprisPlayer>,
state: &SharedState,
) -> Result<()> {
let state = state.read().await;
// Emit property changes
server
.properties_changed([
Property::PlaybackStatus(match state.now_playing.state {
PlaybackState::Playing => PlaybackStatus::Playing,
PlaybackState::Paused => PlaybackStatus::Paused,
PlaybackState::Stopped => PlaybackStatus::Stopped,
}),
Property::CanGoNext(
state
.queue_position
.map(|p| p + 1 < state.queue.len())
.unwrap_or(false),
),
Property::CanGoPrevious(state.queue_position.map(|p| p > 0).unwrap_or(false)),
])
.await?;
// Update metadata if we have a current song
if let Some(song) = state.current_song() {
let mut metadata = Metadata::new();
metadata.set_trackid(
Some(TrackId::try_from(format!("/org/mpris/MediaPlayer2/Track/{}", song.id)).ok())
.flatten(),
);
metadata.set_title(Some(song.title.clone()));
metadata.set_artist(song.artist.clone().map(|a| vec![a]));
metadata.set_album(song.album.clone());
if let Some(duration) = song.duration {
metadata.set_length(Some(Time::from_micros(duration as i64 * 1_000_000)));
}
// Add cover art URL
if let Some(ref cover_art_id) = song.cover_art {
if let Some(cover_url) = build_cover_art_url(&state.config, cover_art_id) {
metadata.set_art_url(Some(cover_url));
}
}
server
.properties_changed([Property::Metadata(metadata)])
.await?;
}
Ok(())
}

61
src/subsonic/auth.rs Normal file
View File

@@ -0,0 +1,61 @@
//! Subsonic authentication helpers
use rand::Rng;
/// Generate authentication parameters for Subsonic API requests
///
/// Subsonic uses token-based authentication:
/// - salt: random string
/// - token: md5(password + salt)
pub fn generate_auth_params(password: &str) -> (String, String) {
let salt = generate_salt();
let token = generate_token(password, &salt);
(salt, token)
}
/// Generate a random salt string
fn generate_salt() -> String {
let mut rng = rand::thread_rng();
(0..16)
.map(|_| {
let idx = rng.gen_range(0..36);
if idx < 10 {
(b'0' + idx) as char
} else {
(b'a' + idx - 10) as char
}
})
.collect()
}
/// Generate authentication token: md5(password + salt)
fn generate_token(password: &str, salt: &str) -> String {
let input = format!("{}{}", password, salt);
let digest = md5::compute(input.as_bytes());
format!("{:x}", digest)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_token() {
// Test with known values
let token = generate_token("sesame", "c19b2d");
assert_eq!(token, "26719a1196d2a940705a59634eb18eab");
}
#[test]
fn test_generate_salt_length() {
let salt = generate_salt();
assert_eq!(salt.len(), 16);
}
#[test]
fn test_auth_params() {
let (salt, token) = generate_auth_params("password");
assert_eq!(salt.len(), 16);
assert_eq!(token.len(), 32); // MD5 hex is 32 chars
}
}

384
src/subsonic/client.rs Normal file
View File

@@ -0,0 +1,384 @@
//! Subsonic API client
use reqwest::Client;
use tracing::{debug, info};
use url::Url;
use super::auth::generate_auth_params;
use super::models::*;
use crate::error::SubsonicError;
/// Client name sent to Subsonic server
const CLIENT_NAME: &str = "ferrosonic-rs";
/// API version we support
const API_VERSION: &str = "1.16.1";
/// Subsonic API client
#[derive(Clone)]
pub struct SubsonicClient {
/// Base URL of the Subsonic server
base_url: Url,
/// Username for authentication
username: String,
/// Password for authentication (stored for stream URLs)
password: String,
/// HTTP client
http: Client,
}
impl SubsonicClient {
/// Create a new Subsonic client
pub fn new(base_url: &str, username: &str, password: &str) -> Result<Self, SubsonicError> {
let base_url = Url::parse(base_url)?;
let http = Client::builder()
.user_agent(CLIENT_NAME)
.build()
.map_err(SubsonicError::Http)?;
Ok(Self {
base_url,
username: username.to_string(),
password: password.to_string(),
http,
})
}
/// Build URL with authentication parameters
fn build_url(&self, endpoint: &str) -> Result<Url, SubsonicError> {
let mut url = self.base_url.join(&format!("rest/{}", endpoint))?;
let (salt, token) = generate_auth_params(&self.password);
url.query_pairs_mut()
.append_pair("u", &self.username)
.append_pair("t", &token)
.append_pair("s", &salt)
.append_pair("v", API_VERSION)
.append_pair("c", CLIENT_NAME)
.append_pair("f", "json");
Ok(url)
}
/// Make an API request and parse the response
async fn request<T>(&self, endpoint: &str) -> Result<T, SubsonicError>
where
T: serde::de::DeserializeOwned,
{
let url = self.build_url(endpoint)?;
debug!(
"Requesting: {}",
url.as_str().split('?').next().unwrap_or("")
);
let response = self.http.get(url).send().await?;
let text = response.text().await?;
let parsed: SubsonicResponse<T> = serde_json::from_str(&text)
.map_err(|e| SubsonicError::Parse(format!("Failed to parse response: {}", e)))?;
let inner = parsed.subsonic_response;
if inner.status != "ok" {
if let Some(error) = inner.error {
return Err(SubsonicError::Api {
code: error.code,
message: error.message,
});
}
return Err(SubsonicError::Api {
code: 0,
message: "Unknown error".to_string(),
});
}
inner
.data
.ok_or_else(|| SubsonicError::Parse("Empty response data".to_string()))
}
/// Test connection to the server
pub async fn ping(&self) -> Result<(), SubsonicError> {
let url = self.build_url("ping")?;
debug!("Pinging server");
let response = self.http.get(url).send().await?;
let text = response.text().await?;
let parsed: SubsonicResponse<PingData> = serde_json::from_str(&text)
.map_err(|e| SubsonicError::Parse(format!("Failed to parse ping response: {}", e)))?;
if parsed.subsonic_response.status != "ok" {
if let Some(error) = parsed.subsonic_response.error {
return Err(SubsonicError::Api {
code: error.code,
message: error.message,
});
}
}
info!("Server ping successful");
Ok(())
}
/// Get all artists
pub async fn get_artists(&self) -> Result<Vec<Artist>, SubsonicError> {
let data: ArtistsData = self.request("getArtists").await?;
let artists: Vec<Artist> = data
.artists
.index
.into_iter()
.flat_map(|idx| idx.artist)
.collect();
debug!("Fetched {} artists", artists.len());
Ok(artists)
}
/// Get artist details with albums
pub async fn get_artist(&self, id: &str) -> Result<(Artist, Vec<Album>), SubsonicError> {
let url = self.build_url(&format!("getArtist?id={}", id))?;
debug!("Fetching artist: {}", id);
let response = self.http.get(url).send().await?;
let text = response.text().await?;
let parsed: SubsonicResponse<ArtistData> = serde_json::from_str(&text)
.map_err(|e| SubsonicError::Parse(format!("Failed to parse artist response: {}", e)))?;
if parsed.subsonic_response.status != "ok" {
if let Some(error) = parsed.subsonic_response.error {
return Err(SubsonicError::Api {
code: error.code,
message: error.message,
});
}
}
let detail = parsed
.subsonic_response
.data
.ok_or_else(|| SubsonicError::Parse("Empty artist data".to_string()))?
.artist;
let artist = Artist {
id: detail.id,
name: detail.name.clone(),
album_count: Some(detail.album.len() as i32),
cover_art: None,
};
debug!(
"Fetched artist {} with {} albums",
detail.name,
detail.album.len()
);
Ok((artist, detail.album))
}
/// Get album details with songs
pub async fn get_album(&self, id: &str) -> Result<(Album, Vec<Child>), SubsonicError> {
let url = self.build_url(&format!("getAlbum?id={}", id))?;
debug!("Fetching album: {}", id);
let response = self.http.get(url).send().await?;
let text = response.text().await?;
let parsed: SubsonicResponse<AlbumData> = serde_json::from_str(&text)
.map_err(|e| SubsonicError::Parse(format!("Failed to parse album response: {}", e)))?;
if parsed.subsonic_response.status != "ok" {
if let Some(error) = parsed.subsonic_response.error {
return Err(SubsonicError::Api {
code: error.code,
message: error.message,
});
}
}
let detail = parsed
.subsonic_response
.data
.ok_or_else(|| SubsonicError::Parse("Empty album data".to_string()))?
.album;
let album = Album {
id: detail.id,
name: detail.name.clone(),
artist: detail.artist,
artist_id: detail.artist_id,
cover_art: None,
song_count: Some(detail.song.len() as i32),
duration: None,
year: detail.year,
genre: None,
};
debug!(
"Fetched album {} with {} songs",
detail.name,
detail.song.len()
);
Ok((album, detail.song))
}
/// Get all playlists
pub async fn get_playlists(&self) -> Result<Vec<Playlist>, SubsonicError> {
let data: PlaylistsData = self.request("getPlaylists").await?;
let playlists = data.playlists.playlist;
debug!("Fetched {} playlists", playlists.len());
Ok(playlists)
}
/// Get playlist details with songs
pub async fn get_playlist(&self, id: &str) -> Result<(Playlist, Vec<Child>), SubsonicError> {
let url = self.build_url(&format!("getPlaylist?id={}", id))?;
debug!("Fetching playlist: {}", id);
let response = self.http.get(url).send().await?;
let text = response.text().await?;
let parsed: SubsonicResponse<PlaylistData> = serde_json::from_str(&text).map_err(|e| {
SubsonicError::Parse(format!("Failed to parse playlist response: {}", e))
})?;
if parsed.subsonic_response.status != "ok" {
if let Some(error) = parsed.subsonic_response.error {
return Err(SubsonicError::Api {
code: error.code,
message: error.message,
});
}
}
let detail = parsed
.subsonic_response
.data
.ok_or_else(|| SubsonicError::Parse("Empty playlist data".to_string()))?
.playlist;
let playlist = Playlist {
id: detail.id,
name: detail.name.clone(),
owner: detail.owner,
song_count: detail.song_count,
duration: detail.duration,
cover_art: None,
public: None,
comment: None,
};
debug!(
"Fetched playlist {} with {} songs",
detail.name,
detail.entry.len()
);
Ok((playlist, detail.entry))
}
/// Get stream URL for a song
///
/// Returns the full URL with authentication that can be passed to MPV
pub fn get_stream_url(&self, song_id: &str) -> Result<String, SubsonicError> {
let mut url = self.base_url.join("rest/stream")?;
let (salt, token) = generate_auth_params(&self.password);
url.query_pairs_mut()
.append_pair("id", song_id)
.append_pair("u", &self.username)
.append_pair("t", &token)
.append_pair("s", &salt)
.append_pair("v", API_VERSION)
.append_pair("c", CLIENT_NAME);
Ok(url.to_string())
}
/// Parse song ID from a stream URL
///
/// Useful for session restoration
pub fn parse_song_id_from_url(url: &str) -> Option<String> {
let parsed = Url::parse(url).ok()?;
parsed
.query_pairs()
.find(|(k, _)| k == "id")
.map(|(_, v)| v.to_string())
}
/// Get cover art URL for a given cover art ID
pub fn get_cover_art_url(&self, cover_art_id: &str) -> Result<String, SubsonicError> {
let (salt, token) = generate_auth_params(&self.password);
let mut url = Url::parse(&format!("{}/rest/getCoverArt", self.base_url))?;
url.query_pairs_mut()
.append_pair("id", cover_art_id)
.append_pair("u", &self.username)
.append_pair("t", &token)
.append_pair("s", &salt)
.append_pair("v", API_VERSION)
.append_pair("c", CLIENT_NAME);
Ok(url.to_string())
}
/// Search for artists, albums, and songs
pub async fn search(
&self,
query: &str,
) -> Result<(Vec<Artist>, Vec<Album>, Vec<Child>), SubsonicError> {
let url = self.build_url(&format!("search3?query={}", urlencoding::encode(query)))?;
debug!("Searching: {}", query);
let response = self.http.get(url).send().await?;
let text = response.text().await?;
let parsed: SubsonicResponse<SearchResult3Data> = serde_json::from_str(&text)
.map_err(|e| SubsonicError::Parse(format!("Failed to parse search response: {}", e)))?;
if parsed.subsonic_response.status != "ok" {
if let Some(error) = parsed.subsonic_response.error {
return Err(SubsonicError::Api {
code: error.code,
message: error.message,
});
}
}
let result = parsed
.subsonic_response
.data
.ok_or_else(|| SubsonicError::Parse("Empty search data".to_string()))?
.search_result3;
debug!(
"Search found {} artists, {} albums, {} songs",
result.artist.len(),
result.album.len(),
result.song.len()
);
Ok((result.artist, result.album, result.song))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_song_id() {
let url = "https://example.com/rest/stream?id=12345&u=user&t=token&s=salt&v=1.16.1&c=test";
let id = SubsonicClient::parse_song_id_from_url(url);
assert_eq!(id, Some("12345".to_string()));
}
#[test]
fn test_parse_song_id_missing() {
let url = "https://example.com/rest/stream?u=user";
let id = SubsonicClient::parse_song_id_from_url(url);
assert_eq!(id, None);
}
}

9
src/subsonic/mod.rs Normal file
View File

@@ -0,0 +1,9 @@
//! Subsonic API client module
#![allow(dead_code)]
pub mod auth;
pub mod client;
pub mod models;
pub use client::SubsonicClient;

235
src/subsonic/models.rs Normal file
View File

@@ -0,0 +1,235 @@
//! Subsonic API response models
use serde::{Deserialize, Serialize};
/// Wrapper for all Subsonic API responses
#[derive(Debug, Deserialize)]
pub struct SubsonicResponse<T> {
#[serde(rename = "subsonic-response")]
pub subsonic_response: SubsonicResponseInner<T>,
}
#[derive(Debug, Deserialize)]
pub struct SubsonicResponseInner<T> {
pub status: String,
pub version: String,
#[serde(default)]
pub error: Option<ApiError>,
#[serde(flatten)]
pub data: Option<T>,
}
/// API error response
#[derive(Debug, Deserialize)]
pub struct ApiError {
pub code: i32,
pub message: String,
}
/// Artists response wrapper
#[derive(Debug, Deserialize)]
pub struct ArtistsData {
pub artists: ArtistsIndex,
}
#[derive(Debug, Deserialize)]
pub struct ArtistsIndex {
#[serde(default)]
pub index: Vec<ArtistIndex>,
}
#[derive(Debug, Deserialize)]
pub struct ArtistIndex {
pub name: String,
#[serde(default)]
pub artist: Vec<Artist>,
}
/// Artist
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Artist {
pub id: String,
pub name: String,
#[serde(default, rename = "albumCount")]
pub album_count: Option<i32>,
#[serde(default, rename = "coverArt")]
pub cover_art: Option<String>,
}
/// Artist detail with albums
#[derive(Debug, Deserialize)]
pub struct ArtistData {
pub artist: ArtistDetail,
}
#[derive(Debug, Deserialize)]
pub struct ArtistDetail {
pub id: String,
pub name: String,
#[serde(default)]
pub album: Vec<Album>,
}
/// Album
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Album {
pub id: String,
pub name: String,
#[serde(default)]
pub artist: Option<String>,
#[serde(default, rename = "artistId")]
pub artist_id: Option<String>,
#[serde(default, rename = "coverArt")]
pub cover_art: Option<String>,
#[serde(default, rename = "songCount")]
pub song_count: Option<i32>,
#[serde(default)]
pub duration: Option<i32>,
#[serde(default)]
pub year: Option<i32>,
#[serde(default)]
pub genre: Option<String>,
}
/// Album detail with songs
#[derive(Debug, Deserialize)]
pub struct AlbumData {
pub album: AlbumDetail,
}
#[derive(Debug, Deserialize)]
pub struct AlbumDetail {
pub id: String,
pub name: String,
#[serde(default)]
pub artist: Option<String>,
#[serde(default, rename = "artistId")]
pub artist_id: Option<String>,
#[serde(default)]
pub year: Option<i32>,
#[serde(default)]
pub song: Vec<Child>,
}
/// Song/Media item (called "Child" in Subsonic API)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Child {
pub id: String,
#[serde(default)]
pub parent: Option<String>,
#[serde(default, rename = "isDir")]
pub is_dir: bool,
pub title: String,
#[serde(default)]
pub album: Option<String>,
#[serde(default)]
pub artist: Option<String>,
#[serde(default)]
pub track: Option<i32>,
#[serde(default)]
pub year: Option<i32>,
#[serde(default)]
pub genre: Option<String>,
#[serde(default, rename = "coverArt")]
pub cover_art: Option<String>,
#[serde(default)]
pub size: Option<i64>,
#[serde(default, rename = "contentType")]
pub content_type: Option<String>,
#[serde(default)]
pub suffix: Option<String>,
#[serde(default)]
pub duration: Option<i32>,
#[serde(default, rename = "bitRate")]
pub bit_rate: Option<i32>,
#[serde(default)]
pub path: Option<String>,
#[serde(default, rename = "discNumber")]
pub disc_number: Option<i32>,
}
impl Child {
/// Format duration as MM:SS
pub fn format_duration(&self) -> String {
match self.duration {
Some(d) => {
let mins = d / 60;
let secs = d % 60;
format!("{:02}:{:02}", mins, secs)
}
None => "--:--".to_string(),
}
}
}
/// Playlists response
#[derive(Debug, Deserialize)]
pub struct PlaylistsData {
pub playlists: PlaylistsInner,
}
#[derive(Debug, Deserialize)]
pub struct PlaylistsInner {
#[serde(default)]
pub playlist: Vec<Playlist>,
}
/// Playlist
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Playlist {
pub id: String,
pub name: String,
#[serde(default)]
pub owner: Option<String>,
#[serde(default, rename = "songCount")]
pub song_count: Option<i32>,
#[serde(default)]
pub duration: Option<i32>,
#[serde(default, rename = "coverArt")]
pub cover_art: Option<String>,
#[serde(default)]
pub public: Option<bool>,
#[serde(default)]
pub comment: Option<String>,
}
/// Playlist detail with songs
#[derive(Debug, Deserialize)]
pub struct PlaylistData {
pub playlist: PlaylistDetail,
}
#[derive(Debug, Deserialize)]
pub struct PlaylistDetail {
pub id: String,
pub name: String,
#[serde(default)]
pub owner: Option<String>,
#[serde(default, rename = "songCount")]
pub song_count: Option<i32>,
#[serde(default)]
pub duration: Option<i32>,
#[serde(default)]
pub entry: Vec<Child>,
}
/// Ping response (for testing connection)
#[derive(Debug, Deserialize)]
pub struct PingData {}
/// Search result
#[derive(Debug, Deserialize)]
pub struct SearchResult3Data {
#[serde(rename = "searchResult3")]
pub search_result3: SearchResult3,
}
#[derive(Debug, Deserialize)]
pub struct SearchResult3 {
#[serde(default)]
pub artist: Vec<Artist>,
#[serde(default)]
pub album: Vec<Album>,
#[serde(default)]
pub song: Vec<Child>,
}

144
src/ui/footer.rs Normal file
View File

@@ -0,0 +1,144 @@
//! Footer bar with keybind hints and status
use ratatui::{
buffer::Buffer,
layout::{Constraint, Layout, Rect},
style::Style,
text::{Line, Span},
widgets::Widget,
};
use crate::app::state::{Notification, Page};
use crate::ui::theme::ThemeColors;
/// Footer bar widget
pub struct Footer<'a> {
page: Page,
sample_rate: Option<u32>,
notification: Option<&'a Notification>,
colors: ThemeColors,
}
impl<'a> Footer<'a> {
pub fn new(page: Page, colors: ThemeColors) -> Self {
Self {
page,
sample_rate: None,
notification: None,
colors,
}
}
pub fn sample_rate(mut self, rate: Option<u32>) -> Self {
self.sample_rate = rate;
self
}
pub fn notification(mut self, notification: Option<&'a Notification>) -> Self {
self.notification = notification;
self
}
fn keybinds(&self) -> Vec<(&'static str, &'static str)> {
let mut binds = vec![
("q", "Quit"),
("p/Space", "Pause"),
("h", "Prev"),
("l", "Next"),
("t", "Theme"),
];
match self.page {
Page::Artists => {
binds.extend([
("/", "Filter"),
("←/→", "Focus"),
("e", "Add"),
("n", "Add next"),
("Enter", "Play"),
]);
}
Page::Queue => {
binds.extend([
("d", "Remove"),
("J/K", "Move"),
("r", "Shuffle"),
("c", "Clear history"),
("Enter", "Play"),
]);
}
Page::Playlists => {
binds.extend([
("←/→", "Focus"),
("e", "Add"),
("n", "Add next"),
("r", "Shuffle play"),
("Enter", "Play"),
]);
}
Page::Server => {
binds.extend([
("Tab", "Next field"),
("Enter", "Test/Save"),
("Ctrl+R", "Refresh"),
]);
}
Page::Settings => {
binds.extend([("←/→/Enter", "Change theme")]);
}
}
binds
}
}
impl Widget for Footer<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.height < 1 {
return;
}
let chunks = Layout::horizontal([Constraint::Min(40), Constraint::Length(30)]).split(area);
// Left side: keybinds or notification
if let Some(notif) = self.notification {
let style = if notif.is_error {
Style::default().fg(self.colors.error)
} else {
Style::default().fg(self.colors.success)
};
buf.set_string(chunks[0].x, chunks[0].y, &notif.message, style);
} else {
// Keybind hints
let binds = self.keybinds();
let mut spans = Vec::new();
for (i, (key, desc)) in binds.iter().enumerate() {
if i > 0 {
spans.push(Span::styled(
"",
Style::default().fg(self.colors.secondary),
));
}
spans.push(Span::styled(*key, Style::default().fg(self.colors.accent)));
spans.push(Span::raw(":"));
spans.push(Span::styled(*desc, Style::default().fg(self.colors.muted)));
}
let line = Line::from(spans);
buf.set_line(chunks[0].x, chunks[0].y, &line, chunks[0].width);
}
// Right side: sample rate / status
if let Some(rate) = self.sample_rate {
let rate_str = format!("{}kHz", rate / 1000);
let x = chunks[1].x + chunks[1].width.saturating_sub(rate_str.len() as u16);
buf.set_string(
x,
chunks[1].y,
&rate_str,
Style::default().fg(self.colors.success),
);
}
}
}

168
src/ui/header.rs Normal file
View File

@@ -0,0 +1,168 @@
//! Header bar with page tabs and playback controls
use ratatui::{
buffer::Buffer,
layout::{Constraint, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Tabs, Widget},
};
use crate::app::state::{Page, PlaybackState};
use crate::ui::theme::ThemeColors;
/// Header bar widget
pub struct Header {
current_page: Page,
playback_state: PlaybackState,
colors: ThemeColors,
}
impl Header {
pub fn new(current_page: Page, playback_state: PlaybackState, colors: ThemeColors) -> Self {
Self {
current_page,
playback_state,
colors,
}
}
}
impl Widget for Header {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.height < 1 {
return;
}
// Split header: [tabs] [playback controls]
let chunks = Layout::horizontal([Constraint::Min(40), Constraint::Length(30)]).split(area);
// Page tabs
let titles: Vec<Line> = vec![
Page::Artists,
Page::Queue,
Page::Playlists,
Page::Server,
Page::Settings,
]
.iter()
.map(|p: &Page| Line::from(format!("{} {}", p.shortcut(), p.label())))
.collect();
let tabs = Tabs::new(titles)
.select(self.current_page.index())
.highlight_style(
Style::default()
.fg(self.colors.primary)
.add_modifier(Modifier::BOLD),
)
.divider("");
tabs.render(chunks[0], buf);
// Playback controls
let nav_style = Style::default().fg(self.colors.muted);
let play_style = match self.playback_state {
PlaybackState::Playing => Style::default().fg(self.colors.accent),
_ => Style::default().fg(self.colors.muted),
};
let pause_style = match self.playback_state {
PlaybackState::Paused => Style::default().fg(self.colors.accent),
_ => Style::default().fg(self.colors.muted),
};
let stop_style = match self.playback_state {
PlaybackState::Stopped => Style::default().fg(self.colors.accent),
_ => Style::default().fg(self.colors.muted),
};
let controls = Line::from(vec![
Span::styled("", nav_style),
Span::raw(" "),
Span::styled("", play_style),
Span::raw(" "),
Span::styled("", pause_style),
Span::raw(" "),
Span::styled("", stop_style),
Span::raw(" "),
Span::styled("", nav_style),
]);
// Right-align controls - " ⏮ ▶ ⏸ ⏹ ⏭ " = 5*3 + 4*1 = 19
let controls_width = 19;
let x = chunks[1].x + chunks[1].width.saturating_sub(controls_width);
buf.set_line(x, chunks[1].y, &controls, controls_width);
}
}
/// Clickable region in the header
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HeaderRegion {
Tab(Page),
PrevButton,
PlayButton,
PauseButton,
StopButton,
NextButton,
}
impl Header {
/// Determine which region was clicked
pub fn region_at(area: Rect, x: u16, _y: u16) -> Option<HeaderRegion> {
let chunks = Layout::horizontal([Constraint::Min(40), Constraint::Length(30)]).split(area);
if x >= chunks[0].x && x < chunks[0].x + chunks[0].width {
// Tab area — compute actual tab positions matching Tabs widget rendering.
// Tabs renders: [pad][title][pad] [divider] [pad][title][pad] ...
// Default padding = 1 space each side. Divider = " │ " = 3 chars.
let pages = [
Page::Artists,
Page::Queue,
Page::Playlists,
Page::Server,
Page::Settings,
];
let divider_width: u16 = 3; // " │ "
let padding: u16 = 1; // 1 space each side
let rel_x = x - chunks[0].x;
let mut cursor: u16 = 0;
for (i, page) in pages.iter().enumerate() {
let label = format!("{} {}", page.shortcut(), page.label());
let tab_width = padding + label.len() as u16 + padding;
if rel_x >= cursor && rel_x < cursor + tab_width {
return Some(HeaderRegion::Tab(*page));
}
cursor += tab_width;
// Add divider (except after the last tab)
if i < pages.len() - 1 {
cursor += divider_width;
}
}
return None;
}
if x >= chunks[1].x && x < chunks[1].x + chunks[1].width {
// Controls area — rendered as spans:
// " ⏮ " + " " + " ▶ " + " " + " ⏸ " + " " + " ⏹ " + " " + " ⏭ "
// Each button span: space + icon + space = 3 display cells (icon is 1 cell)
// Gap spans: 1 space each
// Total: 5*3 + 4*1 = 19 display cells
let controls_width: u16 = 19;
let control_start = chunks[1].x + chunks[1].width.saturating_sub(controls_width);
if x >= control_start {
let offset = x - control_start;
// Layout: [0..2] ⏮ [3] gap [4..6] ▶ [7] gap [8..10] ⏸ [11] gap [12..14] ⏹ [15] gap [16..18] ⏭
return match offset {
0..=2 => Some(HeaderRegion::PrevButton),
4..=6 => Some(HeaderRegion::PlayButton),
8..=10 => Some(HeaderRegion::PauseButton),
12..=14 => Some(HeaderRegion::StopButton),
16..=18 => Some(HeaderRegion::NextButton),
_ => None,
};
}
}
None
}
}

112
src/ui/layout.rs Normal file
View File

@@ -0,0 +1,112 @@
//! Main layout and rendering
use ratatui::{
layout::{Constraint, Layout},
Frame,
};
use crate::app::state::{AppState, LayoutAreas, Page};
use super::footer::Footer;
use super::header::Header;
use super::pages;
use super::widgets::{CavaWidget, NowPlayingWidget};
/// Draw the entire UI
pub fn draw(frame: &mut Frame, state: &mut AppState) {
let area = frame.area();
let cava_active = state.settings_state.cava_enabled && !state.cava_screen.is_empty();
// Main layout:
// [Header] - 1 line
// [Cava] - ~40% (optional, only when cava is active)
// [Page Content] - flexible
// [Now Playing] - 7 lines
// [Footer] - 1 line
let (header_area, cava_area, content_area, now_playing_area, footer_area) = if cava_active {
let chunks = Layout::vertical([
Constraint::Length(1), // Header
Constraint::Percentage(40), // Cava visualizer — top half-ish
Constraint::Min(10), // Page content
Constraint::Length(7), // Now playing
Constraint::Length(1), // Footer
])
.split(area);
(chunks[0], Some(chunks[1]), chunks[2], chunks[3], chunks[4])
} else {
let chunks = Layout::vertical([
Constraint::Length(1), // Header
Constraint::Min(10), // Page content
Constraint::Length(7), // Now playing
Constraint::Length(1), // Footer
])
.split(area);
(chunks[0], None, chunks[1], chunks[2], chunks[3])
};
// Compute dual-pane splits for pages that use them
let (content_left, content_right) = match state.page {
Page::Artists | Page::Playlists => {
let panes = Layout::horizontal([
Constraint::Percentage(40),
Constraint::Percentage(60),
])
.split(content_area);
(Some(panes[0]), Some(panes[1]))
}
_ => (None, None),
};
// Store layout areas for mouse hit-testing
state.layout = LayoutAreas {
header: header_area,
cava: cava_area,
content: content_area,
now_playing: now_playing_area,
footer: footer_area,
content_left,
content_right,
};
// Render header
let colors = *state.settings_state.theme_colors();
let header = Header::new(state.page, state.now_playing.state, colors);
frame.render_widget(header, header_area);
// Render cava visualizer if active
if let Some(cava_rect) = cava_area {
let cava_widget = CavaWidget::new(&state.cava_screen);
frame.render_widget(cava_widget, cava_rect);
}
// Render current page
match state.page {
Page::Artists => {
pages::artists::render(frame, content_area, state);
}
Page::Queue => {
pages::queue::render(frame, content_area, state);
}
Page::Playlists => {
pages::playlists::render(frame, content_area, state);
}
Page::Server => {
pages::server::render(frame, content_area, state);
}
Page::Settings => {
pages::settings::render(frame, content_area, state);
}
}
// Render now playing
let now_playing = NowPlayingWidget::new(&state.now_playing, colors);
frame.render_widget(now_playing, now_playing_area);
// Render footer
let footer = Footer::new(state.page, colors)
.sample_rate(state.now_playing.sample_rate)
.notification(state.notification.as_ref());
frame.render_widget(footer, footer_area);
}

10
src/ui/mod.rs Normal file
View File

@@ -0,0 +1,10 @@
//! Terminal UI module
pub mod footer;
pub mod header;
pub mod layout;
pub mod pages;
pub mod theme;
pub mod widgets;
pub use layout::draw;

272
src/ui/pages/artists.rs Normal file
View File

@@ -0,0 +1,272 @@
//! Artists page with tree browser and song list
use ratatui::{
layout::{Constraint, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
Frame,
};
use crate::app::state::AppState;
use crate::ui::theme::ThemeColors;
use crate::subsonic::models::{Album, Artist};
/// A tree item - either an artist or an album
#[derive(Clone)]
pub enum TreeItem {
Artist { artist: Artist, expanded: bool },
Album { album: Album },
}
/// Build flattened tree items from state
pub fn build_tree_items(state: &AppState) -> Vec<TreeItem> {
let artists = &state.artists;
let mut items = Vec::new();
// Filter artists by name
let filtered_artists: Vec<_> = if artists.filter.is_empty() {
artists.artists.iter().collect()
} else {
let filter_lower = artists.filter.to_lowercase();
artists
.artists
.iter()
.filter(|a| a.name.to_lowercase().contains(&filter_lower))
.collect()
};
for artist in filtered_artists {
let is_expanded = artists.expanded.contains(&artist.id);
items.push(TreeItem::Artist {
artist: artist.clone(),
expanded: is_expanded,
});
// If expanded, add albums sorted by year (oldest first)
if is_expanded {
if let Some(albums) = artists.albums_cache.get(&artist.id) {
let mut sorted_albums: Vec<Album> = albums.iter().cloned().collect();
sorted_albums.sort_by(|a, b| {
// Albums with no year go last
match (a.year, b.year) {
(None, None) => std::cmp::Ordering::Equal,
(None, Some(_)) => std::cmp::Ordering::Greater,
(Some(_), None) => std::cmp::Ordering::Less,
(Some(y1), Some(y2)) => std::cmp::Ord::cmp(&y1, &y2),
}
});
for album in sorted_albums {
items.push(TreeItem::Album { album });
}
}
}
}
items
}
/// Render the artists page
pub fn render(frame: &mut Frame, area: Rect, state: &mut AppState) {
let colors = *state.settings_state.theme_colors();
// Split into two panes: [Tree Browser] [Song List]
let chunks =
Layout::horizontal([Constraint::Percentage(40), Constraint::Percentage(60)]).split(area);
render_tree(frame, chunks[0], state, &colors);
render_songs(frame, chunks[1], state, &colors);
}
/// Render the artist/album tree
fn render_tree(frame: &mut Frame, area: Rect, state: &mut AppState, colors: &ThemeColors) {
let artists = &state.artists;
let focused = artists.focus == 0;
let border_style = if focused {
Style::default().fg(colors.border_focused)
} else {
Style::default().fg(colors.border_unfocused)
};
let title = if artists.filter_active {
format!(" Artists (/{}) ", artists.filter)
} else if !artists.filter.is_empty() {
format!(" Artists [{}] ", artists.filter)
} else {
" Artists ".to_string()
};
let block = Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(border_style);
let tree_items = build_tree_items(state);
// Build list items from tree
let items: Vec<ListItem> = tree_items
.iter()
.enumerate()
.map(|(i, item)| {
let is_selected = Some(i) == artists.selected_index;
match item {
TreeItem::Artist {
artist,
expanded: _,
} => {
let style = if is_selected {
Style::default()
.fg(colors.artist)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(colors.artist)
};
ListItem::new(artist.name.clone()).style(style)
}
TreeItem::Album { album } => {
let style = if is_selected {
Style::default()
.fg(colors.album)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(colors.album)
};
// Indent albums with tree-style connector, show year in brackets
let year_str = album.year.map(|y| format!(" [{}]", y)).unwrap_or_default();
let text = format!(" └─ {}{}", album.name, year_str);
ListItem::new(text).style(style)
}
}
})
.collect();
let mut list = List::new(items).block(block);
if focused {
list = list.highlight_style(
Style::default()
.bg(colors.highlight_bg)
.add_modifier(Modifier::BOLD),
);
}
let mut list_state = ListState::default();
list_state.select(state.artists.selected_index);
frame.render_stateful_widget(list, area, &mut list_state);
state.artists.tree_scroll_offset = list_state.offset();
}
/// Render the song list for selected album
fn render_songs(frame: &mut Frame, area: Rect, state: &mut AppState, colors: &ThemeColors) {
let artists = &state.artists;
let focused = artists.focus == 1;
let border_style = if focused {
Style::default().fg(colors.border_focused)
} else {
Style::default().fg(colors.border_unfocused)
};
let title = if !artists.songs.is_empty() {
if let Some(album) = artists.songs.first().and_then(|s| s.album.as_ref()) {
format!(" {} ({}) ", album, artists.songs.len())
} else {
format!(" Songs ({}) ", artists.songs.len())
}
} else {
" Songs ".to_string()
};
let block = Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(border_style);
if artists.songs.is_empty() {
let hint = Paragraph::new("Select an album to view songs")
.style(Style::default().fg(colors.muted))
.block(block);
frame.render_widget(hint, area);
return;
}
// Check if album has multiple discs
let has_multiple_discs = artists
.songs
.iter()
.any(|s| s.disc_number.map(|d| d > 1).unwrap_or(false));
// Build song list items
let items: Vec<ListItem> = artists
.songs
.iter()
.enumerate()
.map(|(i, song)| {
let is_selected = Some(i) == artists.selected_song;
let is_playing = state
.current_song()
.map(|s| s.id == song.id)
.unwrap_or(false);
let indicator = if is_playing { "" } else { " " };
// Show disc.track format for multi-disc albums
let track = if has_multiple_discs {
match (song.disc_number, song.track) {
(Some(d), Some(t)) => format!("{}.{:02}. ", d, t),
(None, Some(t)) => format!("{:02}. ", t),
_ => String::new(),
}
} else {
song.track
.map(|t| format!("{:02}. ", t))
.unwrap_or_default()
};
let duration = song.format_duration();
let title = song.title.clone();
// Colors based on state
let (title_color, track_color, time_color) = if is_selected {
// When highlighted, use highlight foreground for readability
(
colors.highlight_fg,
colors.highlight_fg,
colors.highlight_fg,
)
} else if is_playing {
(colors.playing, colors.muted, colors.muted)
} else {
(colors.song, colors.muted, colors.muted)
};
let line = Line::from(vec![
Span::styled(indicator.to_string(), Style::default().fg(colors.playing)),
Span::styled(track, Style::default().fg(track_color)),
Span::styled(title, Style::default().fg(title_color)),
Span::styled(format!(" [{}]", duration), Style::default().fg(time_color)),
]);
ListItem::new(line)
})
.collect();
let mut list = List::new(items).block(block);
if focused {
list = list.highlight_style(
Style::default()
.bg(colors.highlight_bg)
.add_modifier(Modifier::BOLD),
);
}
let mut list_state = ListState::default();
list_state.select(artists.selected_song);
frame.render_stateful_widget(list, area, &mut list_state);
state.artists.song_scroll_offset = list_state.offset();
}

7
src/ui/pages/mod.rs Normal file
View File

@@ -0,0 +1,7 @@
//! UI page implementations
pub mod artists;
pub mod playlists;
pub mod queue;
pub mod server;
pub mod settings;

197
src/ui/pages/playlists.rs Normal file
View File

@@ -0,0 +1,197 @@
//! Playlists page with dual-panel browser
use ratatui::{
layout::{Constraint, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
Frame,
};
use crate::app::state::AppState;
use crate::ui::theme::ThemeColors;
/// Render the playlists page
pub fn render(frame: &mut Frame, area: Rect, state: &mut AppState) {
let colors = *state.settings_state.theme_colors();
// Split into two panes: [Playlists] [Songs]
let chunks =
Layout::horizontal([Constraint::Percentage(40), Constraint::Percentage(60)]).split(area);
render_playlists(frame, chunks[0], state, &colors);
render_songs(frame, chunks[1], state, &colors);
}
/// Render the playlists list
fn render_playlists(frame: &mut Frame, area: Rect, state: &mut AppState, colors: &ThemeColors) {
let playlists = &state.playlists;
let focused = playlists.focus == 0;
let border_style = if focused {
Style::default().fg(colors.border_focused)
} else {
Style::default().fg(colors.border_unfocused)
};
let block = Block::default()
.borders(Borders::ALL)
.title(format!(" Playlists ({}) ", playlists.playlists.len()))
.border_style(border_style);
if playlists.playlists.is_empty() {
let hint = Paragraph::new("No playlists found")
.style(Style::default().fg(colors.muted))
.block(block);
frame.render_widget(hint, area);
return;
}
let items: Vec<ListItem> = playlists
.playlists
.iter()
.enumerate()
.map(|(i, playlist)| {
let is_selected = playlists.selected_playlist == Some(i);
let count = playlist.song_count.unwrap_or(0);
let duration = playlist.duration.map(|d| {
let mins = d / 60;
let secs = d % 60;
format!("{}:{:02}", mins, secs)
});
let style = if is_selected {
Style::default()
.fg(colors.primary)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(colors.album)
};
let mut spans = vec![
Span::styled(&playlist.name, style),
Span::styled(
format!(" ({} songs)", count),
Style::default().fg(colors.muted),
),
];
if let Some(dur) = duration {
spans.push(Span::styled(
format!(" [{}]", dur),
Style::default().fg(colors.muted),
));
}
ListItem::new(Line::from(spans))
})
.collect();
let mut list = List::new(items).block(block);
if focused {
list = list
.highlight_style(
Style::default()
.bg(colors.highlight_bg)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("");
}
let mut list_state = ListState::default();
list_state.select(playlists.selected_playlist);
frame.render_stateful_widget(list, area, &mut list_state);
state.playlists.playlist_scroll_offset = list_state.offset();
}
/// Render the songs in selected playlist
fn render_songs(frame: &mut Frame, area: Rect, state: &mut AppState, colors: &ThemeColors) {
let playlists = &state.playlists;
let focused = playlists.focus == 1;
let border_style = if focused {
Style::default().fg(colors.border_focused)
} else {
Style::default().fg(colors.border_unfocused)
};
let title = if !playlists.songs.is_empty() {
format!(" Songs ({}) ", playlists.songs.len())
} else {
" Songs ".to_string()
};
let block = Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(border_style);
if playlists.songs.is_empty() {
let hint = Paragraph::new("Select a playlist to view songs")
.style(Style::default().fg(colors.muted))
.block(block);
frame.render_widget(hint, area);
return;
}
let items: Vec<ListItem> = playlists
.songs
.iter()
.enumerate()
.map(|(i, song)| {
let is_selected = playlists.selected_song == Some(i);
let is_playing = state
.current_song()
.map(|s| s.id == song.id)
.unwrap_or(false);
let indicator = if is_playing { "" } else { " " };
let artist = song.artist.clone().unwrap_or_default();
let duration = song.format_duration();
// Colors based on state
let (title_color, artist_color, time_color) = if is_selected {
(
colors.highlight_fg,
colors.highlight_fg,
colors.highlight_fg,
)
} else if is_playing {
(colors.playing, colors.muted, colors.muted)
} else {
(colors.song, colors.muted, colors.muted)
};
let line = Line::from(vec![
Span::styled(indicator, Style::default().fg(colors.playing)),
Span::styled(&song.title, Style::default().fg(title_color)),
if !artist.is_empty() {
Span::styled(format!(" - {}", artist), Style::default().fg(artist_color))
} else {
Span::raw("")
},
Span::styled(format!(" [{}]", duration), Style::default().fg(time_color)),
]);
ListItem::new(line)
})
.collect();
let mut list = List::new(items).block(block);
if focused {
list = list.highlight_style(
Style::default()
.bg(colors.highlight_bg)
.add_modifier(Modifier::BOLD),
);
}
let mut list_state = ListState::default();
list_state.select(playlists.selected_song);
frame.render_stateful_widget(list, area, &mut list_state);
state.playlists.song_scroll_offset = list_state.offset();
}

118
src/ui/pages/queue.rs Normal file
View File

@@ -0,0 +1,118 @@
//! Queue page showing current play queue
use ratatui::{
layout::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
Frame,
};
use crate::app::state::AppState;
/// Render the queue page
pub fn render(frame: &mut Frame, area: Rect, state: &mut AppState) {
let colors = *state.settings_state.theme_colors();
let block = Block::default()
.borders(Borders::ALL)
.title(format!(" Queue ({}) ", state.queue.len()))
.border_style(Style::default().fg(colors.border_focused));
if state.queue.is_empty() {
let hint = Paragraph::new("Queue is empty. Add songs from Artists or Playlists.")
.style(Style::default().fg(colors.muted))
.block(block);
frame.render_widget(hint, area);
return;
}
let items: Vec<ListItem> = state
.queue
.iter()
.enumerate()
.map(|(i, song)| {
let is_current = state.queue_position == Some(i);
let is_selected = state.queue_state.selected == Some(i);
let is_played = state.queue_position.map(|pos| i < pos).unwrap_or(false);
let indicator = if is_current { "" } else { " " };
let artist = song.artist.clone().unwrap_or_default();
let duration = song.format_duration();
// Show disc.track for songs with disc info
let track_info = match (song.disc_number, song.track) {
(Some(d), Some(t)) if d > 1 => format!(" [{}.{}]", d, t),
(_, Some(t)) => format!(" [#{}]", t),
_ => String::new(),
};
// Color scheme: played = muted, current = playing color, upcoming = song color
let (title_style, artist_style, number_style) = if is_current {
(
Style::default()
.fg(colors.playing)
.add_modifier(Modifier::BOLD),
Style::default().fg(colors.playing),
Style::default().fg(colors.playing),
)
} else if is_played {
(
if is_selected {
Style::default()
.fg(colors.played)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(colors.played)
},
Style::default().fg(colors.muted),
Style::default().fg(colors.muted),
)
} else if is_selected {
(
Style::default()
.fg(colors.primary)
.add_modifier(Modifier::BOLD),
Style::default().fg(colors.muted),
Style::default().fg(colors.muted),
)
} else {
(
Style::default().fg(colors.song),
Style::default().fg(colors.muted),
Style::default().fg(colors.muted),
)
};
let line = Line::from(vec![
Span::styled(format!("{:3}. ", i + 1), number_style),
Span::styled(indicator, Style::default().fg(colors.playing)),
Span::styled(song.title.clone(), title_style),
Span::styled(track_info, Style::default().fg(colors.muted)),
if !artist.is_empty() {
Span::styled(format!(" - {}", artist), artist_style)
} else {
Span::raw("")
},
Span::styled(
format!(" [{}]", duration),
Style::default().fg(colors.muted),
),
]);
ListItem::new(line)
})
.collect();
let list = List::new(items)
.block(block)
.highlight_style(Style::default().bg(colors.highlight_bg))
.highlight_symbol("");
let mut list_state = ListState::default();
list_state.select(state.queue_state.selected);
frame.render_stateful_widget(list, area, &mut list_state);
state.queue_state.scroll_offset = list_state.offset();
}

179
src/ui/pages/server.rs Normal file
View File

@@ -0,0 +1,179 @@
//! Server page with connection settings form
use ratatui::{
layout::{Constraint, Layout, Rect},
style::{Modifier, Style},
widgets::{Block, Borders, Paragraph},
Frame,
};
use crate::app::state::AppState;
use crate::ui::theme::ThemeColors;
/// Render the server page
pub fn render(frame: &mut Frame, area: Rect, state: &AppState) {
let colors = *state.settings_state.theme_colors();
let block = Block::default()
.borders(Borders::ALL)
.title(" Server Connection ")
.border_style(Style::default().fg(colors.border_focused));
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.height < 10 {
return;
}
let server = &state.server_state;
// Layout fields vertically with spacing
let chunks = Layout::vertical([
Constraint::Length(1), // Spacing
Constraint::Length(4), // Server URL (1 label + 3 field)
Constraint::Length(4), // Username (1 label + 3 field)
Constraint::Length(4), // Password (1 label + 3 field)
Constraint::Length(1), // Spacing
Constraint::Length(1), // Test button
Constraint::Length(1), // Save button
Constraint::Length(1), // Spacing
Constraint::Min(1), // Status
])
.split(inner);
// Server URL field - show cursor when selected (always editable)
render_field(
frame,
chunks[1],
"Server URL",
&server.base_url,
server.selected_field == 0,
server.selected_field == 0, // cursor when selected
&colors,
);
// Username field
render_field(
frame,
chunks[2],
"Username",
&server.username,
server.selected_field == 1,
server.selected_field == 1,
&colors,
);
// Password field
render_field(
frame,
chunks[3],
"Password",
&"*".repeat(server.password.len()),
server.selected_field == 2,
server.selected_field == 2,
&colors,
);
// Test button
render_button(
frame,
chunks[5],
"Test Connection",
server.selected_field == 3,
&colors,
);
// Save button
render_button(
frame,
chunks[6],
"Save",
server.selected_field == 4,
&colors,
);
// Status message
if let Some(ref status) = server.status {
let style: Style = if status.contains("failed") || status.contains("error") {
Style::default().fg(colors.error)
} else if status.contains("saved") || status.contains("success") {
Style::default().fg(colors.success)
} else {
Style::default().fg(colors.accent)
};
let status_text = Paragraph::new(status.as_str()).style(style);
frame.render_widget(status_text, chunks[8]);
}
}
/// Render a form field
fn render_field(
frame: &mut Frame,
area: Rect,
label: &str,
value: &str,
selected: bool,
editing: bool,
colors: &ThemeColors,
) {
let label_style = if selected {
Style::default()
.fg(colors.primary)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(colors.highlight_fg)
};
let value_style = if editing {
Style::default().fg(colors.accent)
} else if selected {
Style::default().fg(colors.primary)
} else {
Style::default().fg(colors.muted)
};
let border_style = if selected {
Style::default().fg(colors.border_focused)
} else {
Style::default().fg(colors.border_unfocused)
};
// Label on first line
let label_text = Paragraph::new(label).style(label_style);
frame.render_widget(label_text, Rect::new(area.x, area.y, area.width, 1));
// Value field with border on second line (height 3 = 1 top border + 1 content + 1 bottom border)
let field_area = Rect::new(area.x, area.y + 1, area.width.min(60), 3);
let display_value = if editing {
format!("{}", value)
} else {
value.to_string()
};
let field = Paragraph::new(display_value).style(value_style).block(
Block::default()
.borders(Borders::ALL)
.border_style(border_style),
);
frame.render_widget(field, field_area);
}
/// Render a button
fn render_button(frame: &mut Frame, area: Rect, label: &str, selected: bool, colors: &ThemeColors) {
let style = if selected {
Style::default()
.fg(colors.highlight_fg)
.bg(colors.primary)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(colors.muted)
};
let text = format!("[ {} ]", label);
let button = Paragraph::new(text).style(style);
frame.render_widget(button, area);
}

123
src/ui/pages/settings.rs Normal file
View File

@@ -0,0 +1,123 @@
//! Settings page with app preferences and theming
use ratatui::{
layout::{Constraint, Layout, Rect},
style::{Modifier, Style},
widgets::{Block, Borders, Paragraph},
Frame,
};
use crate::app::state::AppState;
use crate::ui::theme::ThemeColors;
/// Render the settings page
pub fn render(frame: &mut Frame, area: Rect, state: &AppState) {
let colors = *state.settings_state.theme_colors();
let block = Block::default()
.borders(Borders::ALL)
.title(" Settings ")
.border_style(Style::default().fg(colors.border_focused));
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.height < 8 {
return;
}
let settings = &state.settings_state;
// Layout fields vertically with spacing
let chunks = Layout::vertical([
Constraint::Length(1), // Spacing
Constraint::Length(2), // Theme selector
Constraint::Length(1), // Spacing
Constraint::Length(2), // Cava toggle
Constraint::Min(1), // Remaining space
])
.split(inner);
// Theme selector (field 0)
render_option(
frame,
chunks[1],
"Theme",
settings.theme_name(),
settings.selected_field == 0,
&colors,
);
// Cava toggle (field 1)
let cava_value = if !state.cava_available {
"Off (cava not found)"
} else if settings.cava_enabled {
"On"
} else {
"Off"
};
render_option(
frame,
chunks[3],
"Cava Visualizer",
cava_value,
settings.selected_field == 1,
&colors,
);
// Help text at bottom
let help_text = match settings.selected_field {
0 => "← → or Enter to change theme (auto-saves)",
1 if state.cava_available => "← → or Enter to toggle cava visualizer (auto-saves)",
1 => "cava is not installed on this system",
_ => "",
};
let help = Paragraph::new(help_text).style(Style::default().fg(colors.muted));
let help_area = Rect::new(
inner.x,
inner.y + inner.height.saturating_sub(2),
inner.width,
1,
);
frame.render_widget(help, help_area);
}
/// Render an option selector
fn render_option(
frame: &mut Frame,
area: Rect,
label: &str,
value: &str,
selected: bool,
colors: &ThemeColors,
) {
let label_style = if selected {
Style::default()
.fg(colors.primary)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(colors.highlight_fg)
};
let value_style = if selected {
Style::default().fg(colors.accent)
} else {
Style::default().fg(colors.muted)
};
// Label
let label_text = Paragraph::new(label).style(label_style);
frame.render_widget(label_text, Rect::new(area.x, area.y, area.width, 1));
// Value with arrows
let value_text = if selected {
format!("{}", value)
} else {
format!(" {}", value)
};
let value_para = Paragraph::new(value_text).style(value_style);
frame.render_widget(value_para, Rect::new(area.x, area.y + 1, area.width, 1));
}

553
src/ui/theme.rs Normal file
View File

@@ -0,0 +1,553 @@
//! Theme color definitions — file-based themes loaded from ~/.config/ferrosonic/themes/
use std::path::Path;
use ratatui::style::Color;
use serde::Deserialize;
use tracing::{error, info, warn};
use crate::config::paths;
/// Color palette for a theme
#[derive(Debug, Clone, Copy)]
pub struct ThemeColors {
/// Primary highlight color (focused elements, selected tabs)
pub primary: Color,
/// Secondary color (borders, less important elements)
pub secondary: Color,
/// Accent color (currently playing, important highlights)
pub accent: Color,
/// Artist names
pub artist: Color,
/// Album names
pub album: Color,
/// Song titles (default)
pub song: Color,
/// Muted text (track numbers, durations, hints)
pub muted: Color,
/// Selection/highlight background
pub highlight_bg: Color,
/// Text on highlighted background
pub highlight_fg: Color,
/// Success messages
pub success: Color,
/// Error messages
pub error: Color,
/// Playing indicator
pub playing: Color,
/// Played songs in queue
pub played: Color,
/// Border color (focused)
pub border_focused: Color,
/// Border color (unfocused)
pub border_unfocused: Color,
}
/// A loaded theme: display name + colors + cava gradients
#[derive(Debug, Clone)]
pub struct ThemeData {
/// Display name (e.g. "Catppuccin", "Default")
pub name: String,
/// UI colors
pub colors: ThemeColors,
/// Cava vertical gradient (8 hex strings)
pub cava_gradient: [String; 8],
/// Cava horizontal gradient (8 hex strings)
pub cava_horizontal_gradient: [String; 8],
}
// ── TOML deserialization structs ──────────────────────────────────────────────
#[derive(Deserialize)]
struct ThemeFile {
colors: ThemeFileColors,
cava: Option<ThemeFileCava>,
}
#[derive(Deserialize)]
struct ThemeFileColors {
primary: String,
secondary: String,
accent: String,
artist: String,
album: String,
song: String,
muted: String,
highlight_bg: String,
highlight_fg: String,
success: String,
error: String,
playing: String,
played: String,
border_focused: String,
border_unfocused: String,
}
#[derive(Deserialize)]
struct ThemeFileCava {
gradient: Option<Vec<String>>,
horizontal_gradient: Option<Vec<String>>,
}
// ── Hex color parsing ─────────────────────────────────────────────────────────
fn hex_to_color(hex: &str) -> Color {
let hex = hex.trim_start_matches('#');
if hex.len() == 6 {
if let (Ok(r), Ok(g), Ok(b)) = (
u8::from_str_radix(&hex[0..2], 16),
u8::from_str_radix(&hex[2..4], 16),
u8::from_str_radix(&hex[4..6], 16),
) {
return Color::Rgb(r, g, b);
}
}
warn!("Invalid hex color '{}', falling back to white", hex);
Color::White
}
fn parse_gradient(values: &[String], fallback: &[&str; 8]) -> [String; 8] {
let mut result: [String; 8] = std::array::from_fn(|i| fallback[i].to_string());
for (i, v) in values.iter().enumerate().take(8) {
result[i] = v.clone();
}
result
}
// ── ThemeData construction ────────────────────────────────────────────────────
impl ThemeData {
fn from_file_content(name: &str, content: &str) -> Result<Self, String> {
let file: ThemeFile =
toml::from_str(content).map_err(|e| format!("Failed to parse theme '{}': {}", name, e))?;
let c = &file.colors;
let colors = ThemeColors {
primary: hex_to_color(&c.primary),
secondary: hex_to_color(&c.secondary),
accent: hex_to_color(&c.accent),
artist: hex_to_color(&c.artist),
album: hex_to_color(&c.album),
song: hex_to_color(&c.song),
muted: hex_to_color(&c.muted),
highlight_bg: hex_to_color(&c.highlight_bg),
highlight_fg: hex_to_color(&c.highlight_fg),
success: hex_to_color(&c.success),
error: hex_to_color(&c.error),
playing: hex_to_color(&c.playing),
played: hex_to_color(&c.played),
border_focused: hex_to_color(&c.border_focused),
border_unfocused: hex_to_color(&c.border_unfocused),
};
let default_g: [&str; 8] = [
"#59cc33", "#cccc33", "#cc8033", "#cc5533",
"#cc3333", "#bb1111", "#990000", "#990000",
];
let default_h: [&str; 8] = [
"#c45161", "#e094a0", "#f2b6c0", "#f2dde1",
"#cbc7d8", "#8db7d2", "#5e62a9", "#434279",
];
let cava = file.cava.as_ref();
let cava_gradient = match cava.and_then(|c| c.gradient.as_ref()) {
Some(g) => parse_gradient(g, &default_g),
None => std::array::from_fn(|i| default_g[i].to_string()),
};
let cava_horizontal_gradient = match cava.and_then(|c| c.horizontal_gradient.as_ref()) {
Some(h) => parse_gradient(h, &default_h),
None => std::array::from_fn(|i| default_h[i].to_string()),
};
Ok(ThemeData {
name: name.to_string(),
colors,
cava_gradient,
cava_horizontal_gradient,
})
}
/// The hardcoded Default theme
pub fn default_theme() -> Self {
ThemeData {
name: "Default".to_string(),
colors: ThemeColors {
primary: Color::Cyan,
secondary: Color::DarkGray,
accent: Color::Yellow,
artist: Color::LightGreen,
album: Color::Magenta,
song: Color::Magenta,
muted: Color::Gray,
highlight_bg: Color::Rgb(102, 51, 153),
highlight_fg: Color::White,
success: Color::Green,
error: Color::Red,
playing: Color::LightGreen,
played: Color::Red,
border_focused: Color::Cyan,
border_unfocused: Color::DarkGray,
},
cava_gradient: [
"#59cc33".into(), "#cccc33".into(), "#cc8033".into(), "#cc5533".into(),
"#cc3333".into(), "#bb1111".into(), "#990000".into(), "#990000".into(),
],
cava_horizontal_gradient: [
"#c45161".into(), "#e094a0".into(), "#f2b6c0".into(), "#f2dde1".into(),
"#cbc7d8".into(), "#8db7d2".into(), "#5e62a9".into(), "#434279".into(),
],
}
}
}
// ── Loading ───────────────────────────────────────────────────────────────────
/// Load all themes: Default (hardcoded) + TOML files from themes dir (sorted alphabetically)
pub fn load_themes() -> Vec<ThemeData> {
let mut themes = vec![ThemeData::default_theme()];
if let Some(dir) = paths::themes_dir() {
if dir.is_dir() {
let mut entries: Vec<_> = std::fs::read_dir(&dir)
.into_iter()
.flatten()
.filter_map(|e| e.ok())
.filter(|e| {
e.path()
.extension()
.map_or(false, |ext| ext == "toml")
})
.collect();
entries.sort_by_key(|e| e.file_name());
for entry in entries {
let path = entry.path();
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown");
// Capitalize first letter for display name
let name = titlecase_filename(stem);
match std::fs::read_to_string(&path) {
Ok(content) => match ThemeData::from_file_content(&name, &content) {
Ok(theme) => {
info!("Loaded theme '{}' from {}", name, path.display());
themes.push(theme);
}
Err(e) => error!("{}", e),
},
Err(e) => error!("Failed to read {}: {}", path.display(), e),
}
}
}
}
themes
}
/// Convert a filename stem like "tokyo-night" or "rose_pine" to "Tokyo Night" or "Rose Pine"
fn titlecase_filename(s: &str) -> String {
s.split(|c: char| c == '-' || c == '_')
.filter(|w| !w.is_empty())
.map(|word| {
let mut chars = word.chars();
match chars.next() {
Some(first) => {
let upper: String = first.to_uppercase().collect();
upper + chars.as_str()
}
None => String::new(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
// ── Seeding built-in themes ───────────────────────────────────────────────────
/// Write the built-in themes as TOML files into the given directory.
/// Only writes files that don't already exist.
pub fn seed_default_themes(dir: &Path) {
if let Err(e) = std::fs::create_dir_all(dir) {
error!("Failed to create themes directory: {}", e);
return;
}
for (filename, content) in BUILTIN_THEMES {
let path = dir.join(filename);
if !path.exists() {
if let Err(e) = std::fs::write(&path, content) {
error!("Failed to write theme {}: {}", filename, e);
} else {
info!("Seeded theme file: {}", filename);
}
}
}
}
const BUILTIN_THEMES: &[(&str, &str)] = &[
("monokai.toml", r##"[colors]
primary = "#a6e22e"
secondary = "#75715e"
accent = "#fd971f"
artist = "#a6e22e"
album = "#f92672"
song = "#e6db74"
muted = "#75715e"
highlight_bg = "#49483e"
highlight_fg = "#f8f8f2"
success = "#a6e22e"
error = "#f92672"
playing = "#fd971f"
played = "#75715e"
border_focused = "#a6e22e"
border_unfocused = "#49483e"
[cava]
gradient = ["#a6e22e", "#e6db74", "#fd971f", "#fd971f", "#f92672", "#f92672", "#ae81ff", "#ae81ff"]
horizontal_gradient = ["#f92672", "#f92672", "#fd971f", "#e6db74", "#e6db74", "#a6e22e", "#a6e22e", "#66d9ef"]
"##),
("dracula.toml", r##"[colors]
primary = "#bd93f9"
secondary = "#6272a4"
accent = "#ffb86c"
artist = "#50fa7b"
album = "#ff79c6"
song = "#8be9fd"
muted = "#6272a4"
highlight_bg = "#44475a"
highlight_fg = "#f8f8f2"
success = "#50fa7b"
error = "#ff5555"
playing = "#ffb86c"
played = "#6272a4"
border_focused = "#bd93f9"
border_unfocused = "#44475a"
[cava]
gradient = ["#50fa7b", "#8be9fd", "#8be9fd", "#bd93f9", "#bd93f9", "#ff79c6", "#ff5555", "#ff5555"]
horizontal_gradient = ["#ff79c6", "#ff79c6", "#bd93f9", "#bd93f9", "#8be9fd", "#8be9fd", "#50fa7b", "#50fa7b"]
"##),
("nord.toml", r##"[colors]
primary = "#88c0d0"
secondary = "#4c566a"
accent = "#ebcb8b"
artist = "#a3be8c"
album = "#b48ead"
song = "#88c0d0"
muted = "#4c566a"
highlight_bg = "#434c5e"
highlight_fg = "#eceff4"
success = "#a3be8c"
error = "#bf616a"
playing = "#ebcb8b"
played = "#4c566a"
border_focused = "#88c0d0"
border_unfocused = "#3b4252"
[cava]
gradient = ["#a3be8c", "#88c0d0", "#88c0d0", "#81a1c1", "#81a1c1", "#5e81ac", "#b48ead", "#b48ead"]
horizontal_gradient = ["#bf616a", "#d08770", "#ebcb8b", "#a3be8c", "#88c0d0", "#81a1c1", "#5e81ac", "#b48ead"]
"##),
("gruvbox.toml", r##"[colors]
primary = "#d79921"
secondary = "#928374"
accent = "#fe8019"
artist = "#b8bb26"
album = "#d3869b"
song = "#83a598"
muted = "#928374"
highlight_bg = "#504945"
highlight_fg = "#ebdbb2"
success = "#b8bb26"
error = "#fb4934"
playing = "#fe8019"
played = "#928374"
border_focused = "#d79921"
border_unfocused = "#3c3836"
[cava]
gradient = ["#b8bb26", "#d79921", "#d79921", "#fe8019", "#fe8019", "#fb4934", "#cc241d", "#cc241d"]
horizontal_gradient = ["#cc241d", "#fb4934", "#fe8019", "#d79921", "#b8bb26", "#689d6a", "#458588", "#83a598"]
"##),
("catppuccin.toml", r##"[colors]
primary = "#89b4fa"
secondary = "#585b70"
accent = "#f9e2af"
artist = "#a6e3a1"
album = "#f5c2e7"
song = "#94e2d5"
muted = "#6c7086"
highlight_bg = "#45475a"
highlight_fg = "#cdd6f4"
success = "#a6e3a1"
error = "#f38ba8"
playing = "#f9e2af"
played = "#6c7086"
border_focused = "#89b4fa"
border_unfocused = "#45475a"
[cava]
gradient = ["#a6e3a1", "#94e2d5", "#89dceb", "#74c7ec", "#cba6f7", "#f5c2e7", "#f38ba8", "#f38ba8"]
horizontal_gradient = ["#f38ba8", "#eba0ac", "#fab387", "#f9e2af", "#a6e3a1", "#94e2d5", "#89b4fa", "#cba6f7"]
"##),
("solarized.toml", r##"[colors]
primary = "#268bd2"
secondary = "#586e75"
accent = "#b58900"
artist = "#859900"
album = "#d33682"
song = "#2aa198"
muted = "#586e75"
highlight_bg = "#073642"
highlight_fg = "#eee8d5"
success = "#859900"
error = "#dc322f"
playing = "#b58900"
played = "#586e75"
border_focused = "#268bd2"
border_unfocused = "#073642"
[cava]
gradient = ["#859900", "#b58900", "#b58900", "#cb4b16", "#cb4b16", "#dc322f", "#d33682", "#6c71c4"]
horizontal_gradient = ["#dc322f", "#cb4b16", "#b58900", "#859900", "#2aa198", "#268bd2", "#6c71c4", "#d33682"]
"##),
("tokyo-night.toml", r##"[colors]
primary = "#7aa2f7"
secondary = "#3d59a1"
accent = "#e0af68"
artist = "#9ece6a"
album = "#bb9af7"
song = "#7dcfff"
muted = "#565f89"
highlight_bg = "#292e42"
highlight_fg = "#c0caf5"
success = "#9ece6a"
error = "#f7768e"
playing = "#e0af68"
played = "#565f89"
border_focused = "#7aa2f7"
border_unfocused = "#292e42"
[cava]
gradient = ["#9ece6a", "#e0af68", "#e0af68", "#ff9e64", "#ff9e64", "#f7768e", "#bb9af7", "#bb9af7"]
horizontal_gradient = ["#f7768e", "#ff9e64", "#e0af68", "#9ece6a", "#73daca", "#7dcfff", "#7aa2f7", "#bb9af7"]
"##),
("rose-pine.toml", r##"[colors]
primary = "#c4a7e7"
secondary = "#6e6a86"
accent = "#f6c177"
artist = "#9ccfd8"
album = "#ebbcba"
song = "#31748f"
muted = "#6e6a86"
highlight_bg = "#393552"
highlight_fg = "#e0def4"
success = "#9ccfd8"
error = "#eb6f92"
playing = "#f6c177"
played = "#6e6a86"
border_focused = "#c4a7e7"
border_unfocused = "#393552"
[cava]
gradient = ["#31748f", "#9ccfd8", "#c4a7e7", "#c4a7e7", "#ebbcba", "#ebbcba", "#eb6f92", "#eb6f92"]
horizontal_gradient = ["#eb6f92", "#ebbcba", "#f6c177", "#f6c177", "#9ccfd8", "#c4a7e7", "#31748f", "#31748f"]
"##),
("everforest.toml", r##"[colors]
primary = "#a7c080"
secondary = "#859289"
accent = "#dbbc7f"
artist = "#83c092"
album = "#d699b6"
song = "#7fbbb3"
muted = "#859289"
highlight_bg = "#505851"
highlight_fg = "#d3c6aa"
success = "#a7c080"
error = "#e67e80"
playing = "#dbbc7f"
played = "#859289"
border_focused = "#a7c080"
border_unfocused = "#505851"
[cava]
gradient = ["#a7c080", "#dbbc7f", "#dbbc7f", "#e69875", "#e69875", "#e67e80", "#d699b6", "#d699b6"]
horizontal_gradient = ["#e67e80", "#e69875", "#dbbc7f", "#a7c080", "#83c092", "#7fbbb3", "#d699b6", "#d699b6"]
"##),
("kanagawa.toml", r##"[colors]
primary = "#7e9cd8"
secondary = "#54546d"
accent = "#e6c384"
artist = "#98bb6c"
album = "#957fb8"
song = "#7fb4ca"
muted = "#727169"
highlight_bg = "#363646"
highlight_fg = "#dcd7ba"
success = "#98bb6c"
error = "#ff5d62"
playing = "#e6c384"
played = "#727169"
border_focused = "#7e9cd8"
border_unfocused = "#363646"
[cava]
gradient = ["#98bb6c", "#e6c384", "#e6c384", "#ffa066", "#ffa066", "#ff5d62", "#957fb8", "#957fb8"]
horizontal_gradient = ["#ff5d62", "#ffa066", "#e6c384", "#98bb6c", "#7fb4ca", "#7e9cd8", "#957fb8", "#938aa9"]
"##),
("one-dark.toml", r##"[colors]
primary = "#61afef"
secondary = "#5c6370"
accent = "#e5c07b"
artist = "#98c379"
album = "#c678dd"
song = "#56b6c2"
muted = "#5c6370"
highlight_bg = "#3e4451"
highlight_fg = "#abb2bf"
success = "#98c379"
error = "#e06c75"
playing = "#e5c07b"
played = "#5c6370"
border_focused = "#61afef"
border_unfocused = "#3e4451"
[cava]
gradient = ["#98c379", "#e5c07b", "#e5c07b", "#d19a66", "#d19a66", "#e06c75", "#c678dd", "#c678dd"]
horizontal_gradient = ["#e06c75", "#d19a66", "#e5c07b", "#98c379", "#56b6c2", "#61afef", "#c678dd", "#c678dd"]
"##),
("ayu-dark.toml", r##"[colors]
primary = "#59c2ff"
secondary = "#6b788a"
accent = "#e6b450"
artist = "#aad94c"
album = "#d2a6ff"
song = "#95e6cb"
muted = "#6b788a"
highlight_bg = "#2f3846"
highlight_fg = "#bfc7d5"
success = "#aad94c"
error = "#f07178"
playing = "#e6b450"
played = "#6b788a"
border_focused = "#59c2ff"
border_unfocused = "#2f3846"
[cava]
gradient = ["#aad94c", "#e6b450", "#e6b450", "#ff8f40", "#ff8f40", "#f07178", "#d2a6ff", "#d2a6ff"]
horizontal_gradient = ["#f07178", "#ff8f40", "#e6b450", "#aad94c", "#95e6cb", "#59c2ff", "#d2a6ff", "#d2a6ff"]
"##),
];

61
src/ui/widgets/cava.rs Normal file
View File

@@ -0,0 +1,61 @@
//! Cava audio visualizer widget — renders captured noncurses output
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Style},
widgets::Widget,
};
use crate::app::state::{CavaColor, CavaRow};
pub struct CavaWidget<'a> {
screen: &'a [CavaRow],
}
impl<'a> CavaWidget<'a> {
pub fn new(screen: &'a [CavaRow]) -> Self {
Self { screen }
}
}
fn cava_color_to_ratatui(c: CavaColor) -> Option<Color> {
match c {
CavaColor::Default => None,
CavaColor::Indexed(i) => Some(Color::Indexed(i)),
CavaColor::Rgb(r, g, b) => Some(Color::Rgb(r, g, b)),
}
}
impl Widget for CavaWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.width == 0 || area.height == 0 || self.screen.is_empty() {
return;
}
for (row_idx, cava_row) in self.screen.iter().enumerate() {
if row_idx >= area.height as usize {
break;
}
let y = area.y + row_idx as u16;
let mut x = area.x;
for span in &cava_row.spans {
for ch in span.text.chars() {
if x >= area.x + area.width {
break;
}
let mut style = Style::default();
if let Some(fg) = cava_color_to_ratatui(span.fg) {
style = style.fg(fg);
}
if let Some(bg) = cava_color_to_ratatui(span.bg) {
style = style.bg(bg);
}
buf[(x, y)].set_char(ch).set_style(style);
x += 1;
}
}
}
}
}

8
src/ui/widgets/mod.rs Normal file
View File

@@ -0,0 +1,8 @@
//! Custom UI widgets
pub mod cava;
pub mod now_playing;
pub mod progress_bar;
pub use cava::CavaWidget;
pub use now_playing::NowPlayingWidget;

View File

@@ -0,0 +1,283 @@
//! Now playing display widget
use ratatui::{
buffer::Buffer,
layout::{Alignment, Constraint, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Widget},
};
use crate::app::state::NowPlaying;
use crate::ui::theme::ThemeColors;
/// Now playing panel widget
pub struct NowPlayingWidget<'a> {
now_playing: &'a NowPlaying,
focused: bool,
colors: ThemeColors,
}
impl<'a> NowPlayingWidget<'a> {
pub fn new(now_playing: &'a NowPlaying, colors: ThemeColors) -> Self {
Self {
now_playing,
focused: false,
colors,
}
}
#[allow(dead_code)]
pub fn focused(mut self, focused: bool) -> Self {
self.focused = focused;
self
}
}
impl Widget for NowPlayingWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
// Need at least 6 rows for full display
if area.height < 4 || area.width < 20 {
return;
}
let block = Block::default()
.borders(Borders::ALL)
.title(" Now Playing ")
.border_style(if self.focused {
Style::default().fg(self.colors.border_focused)
} else {
Style::default().fg(self.colors.border_unfocused)
});
let inner = block.inner(area);
block.render(area, buf);
if inner.height < 2 {
return;
}
// Check if something is playing
if self.now_playing.song.is_none() {
let no_track = Paragraph::new("No track playing")
.style(Style::default().fg(self.colors.muted))
.alignment(Alignment::Center);
no_track.render(inner, buf);
return;
}
let song = self.now_playing.song.as_ref().unwrap();
// Build centered lines like Go version:
// Line 1: Artist (green)
// Line 2: Album (purple/magenta)
// Line 3: Title (white, bold)
// Line 4: Quality info (gray)
// Line 5: Progress bar
let artist = song.artist.clone().unwrap_or_default();
let album = song.album.clone().unwrap_or_default();
let title = song.title.clone();
// Build quality string
let mut quality_parts = Vec::new();
if let Some(ref fmt) = self.now_playing.format {
quality_parts.push(fmt.to_string().to_uppercase());
}
if let Some(bits) = self.now_playing.bit_depth {
quality_parts.push(format!("{}-bit", bits));
}
if let Some(rate) = self.now_playing.sample_rate {
let khz = rate as f64 / 1000.0;
if khz == khz.floor() {
quality_parts.push(format!("{}kHz", khz as u32));
} else {
quality_parts.push(format!("{:.1}kHz", khz));
}
}
if let Some(ref channels) = self.now_playing.channels {
quality_parts.push(channels.to_string());
}
let quality = quality_parts.join("");
// Layout based on available height
if inner.height >= 5 {
// Full layout with separate lines
let chunks = Layout::vertical([
Constraint::Length(1), // Artist
Constraint::Length(1), // Album
Constraint::Length(1), // Title
Constraint::Length(1), // Quality
Constraint::Length(1), // Progress
])
.split(inner);
// Artist line (centered, artist color)
let artist_line = Line::from(vec![Span::styled(
&artist,
Style::default().fg(self.colors.artist),
)]);
Paragraph::new(artist_line)
.alignment(Alignment::Center)
.render(chunks[0], buf);
// Album line (centered, album color)
let album_line = Line::from(vec![Span::styled(
&album,
Style::default().fg(self.colors.album),
)]);
Paragraph::new(album_line)
.alignment(Alignment::Center)
.render(chunks[1], buf);
// Title line (centered, bold)
let title_line = Line::from(vec![Span::styled(
&title,
Style::default()
.fg(self.colors.highlight_fg)
.add_modifier(Modifier::BOLD),
)]);
Paragraph::new(title_line)
.alignment(Alignment::Center)
.render(chunks[2], buf);
// Quality line (centered, muted)
if !quality.is_empty() {
let quality_line = Line::from(vec![Span::styled(
&quality,
Style::default().fg(self.colors.muted),
)]);
Paragraph::new(quality_line)
.alignment(Alignment::Center)
.render(chunks[3], buf);
}
// Progress bar
render_progress_bar(
chunks[4],
buf,
self.now_playing.progress_percent(),
&self.now_playing.format_position(),
&self.now_playing.format_duration(),
&self.colors,
);
} else if inner.height >= 3 {
// Compact layout
let chunks = Layout::vertical([
Constraint::Length(1), // Artist - Title
Constraint::Length(1), // Album / Quality
Constraint::Length(1), // Progress
])
.split(inner);
// Combined artist - title line
let line1 = Line::from(vec![
Span::styled(
&title,
Style::default()
.fg(self.colors.highlight_fg)
.add_modifier(Modifier::BOLD),
),
Span::styled(" - ", Style::default().fg(self.colors.muted)),
Span::styled(&artist, Style::default().fg(self.colors.artist)),
]);
Paragraph::new(line1)
.alignment(Alignment::Center)
.render(chunks[0], buf);
// Album line
let line2 = Line::from(vec![Span::styled(
&album,
Style::default().fg(self.colors.album),
)]);
Paragraph::new(line2)
.alignment(Alignment::Center)
.render(chunks[1], buf);
// Progress bar
render_progress_bar(
chunks[2],
buf,
self.now_playing.progress_percent(),
&self.now_playing.format_position(),
&self.now_playing.format_duration(),
&self.colors,
);
} else {
// Minimal layout
let chunks = Layout::vertical([
Constraint::Length(1), // Title
Constraint::Length(1), // Progress
])
.split(inner);
let line1 = Line::from(vec![Span::styled(
&title,
Style::default().fg(self.colors.highlight_fg),
)]);
Paragraph::new(line1)
.alignment(Alignment::Center)
.render(chunks[0], buf);
render_progress_bar(
chunks[1],
buf,
self.now_playing.progress_percent(),
&self.now_playing.format_position(),
&self.now_playing.format_duration(),
&self.colors,
);
}
}
}
/// Render a simple progress bar
fn render_progress_bar(
area: Rect,
buf: &mut Buffer,
progress: f64,
pos: &str,
dur: &str,
colors: &ThemeColors,
) {
if area.width < 15 {
return;
}
// Format: "00:00 / 00:00 [════════════────────]"
let time_str = format!("{} / {}", pos, dur);
let time_width = time_str.len() as u16;
// Calculate positions - center the whole thing
let bar_width = area.width.saturating_sub(time_width + 3); // 2 spaces + some padding
let total_width = time_width + 2 + bar_width;
let start_x = area.x + (area.width.saturating_sub(total_width)) / 2;
// Draw time string
buf.set_string(
start_x,
area.y,
&time_str,
Style::default().fg(colors.highlight_fg),
);
// Draw progress bar
let bar_start = start_x + time_width + 2;
if bar_width > 0 {
let filled = (bar_width as f64 * progress) as u16;
// Draw filled portion (success color like Go version)
for x in bar_start..(bar_start + filled) {
buf[(x, area.y)]
.set_char('━')
.set_style(Style::default().fg(colors.success));
}
// Draw empty portion
for x in (bar_start + filled)..(bar_start + bar_width) {
buf[(x, area.y)]
.set_char('─')
.set_style(Style::default().fg(colors.muted));
}
}
}

View File

@@ -0,0 +1,165 @@
//! Progress bar widget with seek support
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Style},
widgets::Widget,
};
/// A horizontal progress bar with time display
#[allow(dead_code)]
pub struct ProgressBar<'a> {
/// Progress value (0.0 to 1.0)
progress: f64,
/// Current position formatted
position_text: &'a str,
/// Total duration formatted
duration_text: &'a str,
/// Filled portion style
filled_style: Style,
/// Empty portion style
empty_style: Style,
/// Text style
text_style: Style,
}
#[allow(dead_code)]
impl<'a> ProgressBar<'a> {
pub fn new(progress: f64, position_text: &'a str, duration_text: &'a str) -> Self {
Self {
progress: progress.clamp(0.0, 1.0),
position_text,
duration_text,
filled_style: Style::default().bg(Color::Blue),
empty_style: Style::default().bg(Color::DarkGray),
text_style: Style::default().fg(Color::White),
}
}
pub fn filled_style(mut self, style: Style) -> Self {
self.filled_style = style;
self
}
pub fn empty_style(mut self, style: Style) -> Self {
self.empty_style = style;
self
}
pub fn text_style(mut self, style: Style) -> Self {
self.text_style = style;
self
}
/// Calculate position from x coordinate within the bar area
pub fn position_from_x(area: Rect, x: u16) -> Option<f64> {
// Account for time text margins
let bar_start = area.x + 8; // "00:00 " prefix
let bar_end = area.x + area.width - 8; // " 00:00" suffix
if x >= bar_start && x < bar_end {
let bar_width = bar_end - bar_start;
let relative_x = x - bar_start;
Some(relative_x as f64 / bar_width as f64)
} else {
None
}
}
}
impl Widget for ProgressBar<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.width < 20 || area.height < 1 {
return;
}
// Format: "00:00 [==========----------] 00:00"
let pos_width = self.position_text.len();
let dur_width = self.duration_text.len();
// Draw position text
buf.set_string(area.x, area.y, self.position_text, self.text_style);
// Draw duration text
let dur_x = area.x + area.width - dur_width as u16;
buf.set_string(dur_x, area.y, self.duration_text, self.text_style);
// Calculate bar area
let bar_x = area.x + pos_width as u16 + 1;
let bar_width = area
.width
.saturating_sub((pos_width + dur_width + 2) as u16);
if bar_width > 0 {
let filled_width = (bar_width as f64 * self.progress) as u16;
// Draw filled portion
for x in bar_x..(bar_x + filled_width) {
buf[(x, area.y)].set_char('━').set_style(self.filled_style);
}
// Draw empty portion
for x in (bar_x + filled_width)..(bar_x + bar_width) {
buf[(x, area.y)].set_char('─').set_style(self.empty_style);
}
}
}
}
/// Vertical gauge (for volume, etc.)
#[allow(dead_code)]
pub struct VerticalBar {
/// Value (0.0 to 1.0)
value: f64,
/// Filled style
filled_style: Style,
/// Empty style
empty_style: Style,
}
#[allow(dead_code)]
impl VerticalBar {
pub fn new(value: f64) -> Self {
Self {
value: value.clamp(0.0, 1.0),
filled_style: Style::default().bg(Color::Blue),
empty_style: Style::default().bg(Color::DarkGray),
}
}
pub fn filled_style(mut self, style: Style) -> Self {
self.filled_style = style;
self
}
pub fn empty_style(mut self, style: Style) -> Self {
self.empty_style = style;
self
}
}
impl Widget for VerticalBar {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.height < 1 || area.width < 1 {
return;
}
let filled_height = (area.height as f64 * self.value) as u16;
let empty_start = area.y + area.height - filled_height;
// Draw empty portion (top)
for y in area.y..empty_start {
for x in area.x..(area.x + area.width) {
buf[(x, y)].set_char('░').set_style(self.empty_style);
}
}
// Draw filled portion (bottom)
for y in empty_start..(area.y + area.height) {
for x in area.x..(area.x + area.width) {
buf[(x, y)].set_char('█').set_style(self.filled_style);
}
}
}
}