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:
272
src/ui/pages/artists.rs
Normal file
272
src/ui/pages/artists.rs
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user