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

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"]
"##),
];