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

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);
}
}
}
}