Files
ui-agregator/src/ui/library.rs
T
Alexander e77e854d2e feat: add gRPC client with config-based server address and album support
- Add tonic/prost gRPC client connecting to music-agregator service
- Add config.yaml for configurable server host/port
- Add build.rs for proto compilation from music-agregator
- Update Artist/Album models to match proto with MonitorState enum
- Convert album list from GetArtists response
- Fix album click selection with correct layout offsets
- Improve monitor state icons for better visibility
2026-05-08 23:10:15 +02:00

654 lines
21 KiB
Rust

#![allow(dead_code)]
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{List, ListItem, ListState, Paragraph},
};
use crate::data::{Album, AlbumStatus, Artist, MonitorState, Track};
use crate::theme;
use crate::ui::pane::{Pane, section_divider};
use crate::ui::progress_bar::progress_bar;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LibraryFocus {
#[default]
Artists,
Albums,
Tracks,
}
pub struct LibraryState {
pub artists: Vec<Artist>,
pub focus: LibraryFocus,
pub artist_state: ListState,
pub album_state: ListState,
pub track_state: ListState,
}
impl LibraryState {
pub fn new(artists: Vec<Artist>) -> Self {
let mut artist_state = ListState::default();
let mut album_state = ListState::default();
let mut track_state = ListState::default();
if !artists.is_empty() {
artist_state.select(Some(0));
if !artists[0].albums.is_empty() {
album_state.select(Some(0));
track_state.select(Some(0));
}
}
Self {
artists,
focus: LibraryFocus::Artists,
artist_state,
album_state,
track_state,
}
}
pub fn selected_artist(&self) -> Option<&Artist> {
self.artist_state
.selected()
.and_then(|i| self.artists.get(i))
}
pub fn selected_album(&self) -> Option<&Album> {
self.selected_artist()
.and_then(|a| self.album_state.selected().and_then(|i| a.albums.get(i)))
}
pub fn move_up(&mut self) {
match self.focus {
LibraryFocus::Artists => {
if let Some(i) = self.artist_state.selected() {
if i > 0 {
self.artist_state.select(Some(i - 1));
self.reset_album_selection();
}
}
}
LibraryFocus::Albums => {
if let Some(i) = self.album_state.selected() {
if i > 0 {
self.album_state.select(Some(i - 1));
self.reset_track_selection();
}
}
}
LibraryFocus::Tracks => {
if let Some(i) = self.track_state.selected() {
if i > 0 {
self.track_state.select(Some(i - 1));
}
}
}
}
}
pub fn move_down(&mut self) {
match self.focus {
LibraryFocus::Artists => {
let max = self.artists.len().saturating_sub(1);
if let Some(i) = self.artist_state.selected() {
if i < max {
self.artist_state.select(Some(i + 1));
self.reset_album_selection();
}
}
}
LibraryFocus::Albums => {
let max = self
.selected_artist()
.map(|a| a.albums.len().saturating_sub(1))
.unwrap_or(0);
if let Some(i) = self.album_state.selected() {
if i < max {
self.album_state.select(Some(i + 1));
self.reset_track_selection();
}
}
}
LibraryFocus::Tracks => {
let max = self.track_count().saturating_sub(1);
if let Some(i) = self.track_state.selected() {
if i < max {
self.track_state.select(Some(i + 1));
}
}
}
}
}
fn track_count(&self) -> usize {
self.selected_album().map(|a| a.total as usize).unwrap_or(0)
}
pub fn focus_left(&mut self) {
match self.focus {
LibraryFocus::Artists => {}
LibraryFocus::Albums => self.focus = LibraryFocus::Artists,
LibraryFocus::Tracks => self.focus = LibraryFocus::Albums,
}
}
pub fn focus_right(&mut self) {
match self.focus {
LibraryFocus::Artists => {
if self.selected_artist().is_some() {
self.focus = LibraryFocus::Albums;
}
}
LibraryFocus::Albums => {
if self.selected_album().is_some() {
self.focus = LibraryFocus::Tracks;
}
}
LibraryFocus::Tracks => {}
}
}
pub fn cycle_focus(&mut self) {
self.focus = match self.focus {
LibraryFocus::Artists => LibraryFocus::Albums,
LibraryFocus::Albums => LibraryFocus::Tracks,
LibraryFocus::Tracks => LibraryFocus::Artists,
};
}
fn reset_album_selection(&mut self) {
if let Some(artist) = self.selected_artist() {
if !artist.albums.is_empty() {
self.album_state.select(Some(0));
} else {
self.album_state.select(None);
}
}
self.reset_track_selection();
}
fn reset_track_selection(&mut self) {
if self.selected_album().is_some() {
self.track_state.select(Some(0));
} else {
self.track_state.select(None);
}
}
pub fn artist_count(&self) -> usize {
self.artists.len()
}
pub fn selected_artist_index(&self) -> Option<usize> {
self.artist_state.selected()
}
}
fn status_icon(status: AlbumStatus, monitored: bool) -> (char, Style) {
if !monitored {
return ('◌', Style::default().fg(theme::GRAY));
}
match status {
AlbumStatus::Complete => ('●', Style::default().fg(theme::GREEN)),
AlbumStatus::Partial => ('◐', Style::default().fg(theme::YELLOW)),
AlbumStatus::Wanted => ('○', Style::default().fg(theme::RED)),
AlbumStatus::Unmonitored => ('◌', Style::default().fg(theme::GRAY)),
}
}
fn monitor_state_icon(state: MonitorState, status: AlbumStatus) -> (char, Style) {
match state {
MonitorState::Monitored => match status {
AlbumStatus::Complete => ('✓', Style::default().fg(theme::GREEN)),
AlbumStatus::Partial => ('◐', Style::default().fg(theme::YELLOW)),
AlbumStatus::Wanted => ('!', Style::default().fg(theme::RED)),
AlbumStatus::Unmonitored => ('-', Style::default().fg(theme::GRAY)),
},
MonitorState::Unmonitored => ('-', Style::default().fg(theme::GRAY)),
MonitorState::Excluded => ('x', Style::default().fg(theme::RED)),
MonitorState::Unspecified => ('?', Style::default().fg(theme::GRAY)),
}
}
fn track_icon(have: bool) -> (char, Style) {
if have {
('✓', Style::default().fg(theme::GREEN))
} else {
('✗', Style::default().fg(theme::RED))
}
}
fn artist_status(artist: &Artist) -> AlbumStatus {
let total: u16 = artist.albums.iter().map(|a| a.total).sum();
let have: u16 = artist.albums.iter().map(|a| a.have).sum();
if have == total {
AlbumStatus::Complete
} else if have == 0 {
AlbumStatus::Wanted
} else {
AlbumStatus::Partial
}
}
fn fmt_size(gb: f64) -> String {
if gb >= 1.0 {
format!("{:.1} GB", gb)
} else {
format!("{} MB", (gb * 1024.0).round() as u32)
}
}
pub fn render_library(frame: &mut Frame, area: Rect, state: &mut LibraryState) {
let chunks = Layout::horizontal([Constraint::Length(32), Constraint::Fill(1)]).split(area);
render_artists_pane(frame, chunks[0], state);
render_detail_pane(frame, chunks[1], state);
}
fn render_artists_pane(frame: &mut Frame, area: Rect, state: &mut LibraryState) {
let focused = state.focus == LibraryFocus::Artists;
let artist_count = state.artists.len();
let total_albums: usize = state.artists.iter().map(|a| a.albums.len()).sum();
let total_size: f64 = state.artists.iter().map(|a| a.size_gb).sum();
let footer = Line::from(vec![
Span::styled(
format!("{} artists · {} alb", artist_count, total_albums),
Style::default().fg(theme::GRAY),
),
Span::raw(" "),
Span::styled(fmt_size(total_size), Style::default().fg(theme::GRAY)),
]);
let artist_count_str = artist_count.to_string();
let pane = Pane::new("Artists")
.meta(&artist_count_str)
.focused(focused)
.footer(footer);
let block = pane.build_block();
let inner = block.inner(area);
frame.render_widget(block, area);
let items: Vec<ListItem> = state
.artists
.iter()
.map(|artist| {
let status = artist_status(artist);
let (icon_char, icon_style) = monitor_state_icon(artist.monitor_state, status);
let total: u16 = artist.albums.iter().map(|a| a.total).sum();
let have: u16 = artist.albums.iter().map(|a| a.have).sum();
let mut name_text = artist.name.clone();
let count_str = format!("{}/{}", have, total);
let name_width = inner.width as usize - 2 - count_str.len() - 2;
if name_text.len() > name_width {
name_text.truncate(name_width.saturating_sub(1));
name_text.push('…');
}
let padding = name_width.saturating_sub(name_text.len());
Line::from(vec![
Span::styled(format!("{} ", icon_char), icon_style),
Span::styled(name_text, Style::default().fg(theme::FG1)),
Span::raw(" ".repeat(padding)),
Span::styled(count_str, Style::default().fg(theme::GRAY)),
])
.into()
})
.collect();
let highlight_style = if focused {
Style::default().bg(theme::YELLOW).fg(theme::BG0)
} else {
Style::default().bg(theme::SELECT_BG).fg(theme::FG1)
};
let list = List::new(items).highlight_style(highlight_style);
frame.render_stateful_widget(list, inner, &mut state.artist_state);
}
fn render_detail_pane(frame: &mut Frame, area: Rect, state: &mut LibraryState) {
let focused = state.focus == LibraryFocus::Albums || state.focus == LibraryFocus::Tracks;
let artist = state.selected_artist();
let meta = artist
.map(|a| {
format!(
"{} · {}",
a.country,
a.genres.first().map(|s| s.as_str()).unwrap_or("")
)
})
.unwrap_or_default();
let have_tracks: u16 = artist
.map(|a| a.albums.iter().map(|al| al.have).sum())
.unwrap_or(0);
let total_tracks: u16 = artist
.map(|a| a.albums.iter().map(|al| al.total).sum())
.unwrap_or(0);
let footer = if artist.is_some() {
Line::from(vec![Span::styled(
format!("{}/{} tracks", have_tracks, total_tracks),
Style::default().fg(theme::GRAY),
)])
} else {
Line::from("")
};
let pane = Pane::new("Detail")
.meta(&meta)
.focused(focused)
.footer(footer);
let block = pane.build_block();
let inner = block.inner(area);
frame.render_widget(block, area);
let Some(artist) = artist else {
let msg = Paragraph::new(Span::styled(
"No artist selected",
Style::default().fg(theme::GRAY),
));
frame.render_widget(msg, inner);
return;
};
let chunks = Layout::vertical([
Constraint::Length(6),
Constraint::Length(1),
Constraint::Percentage(40),
Constraint::Length(1),
Constraint::Fill(1),
])
.split(inner);
render_artist_header(frame, chunks[0], artist);
let albums_count = artist.albums.len();
let albums_label = format!("{} releases", albums_count);
let album_divider = section_divider("albums", Some(&albums_label));
frame.render_widget(Paragraph::new(album_divider), chunks[1]);
let selected_artist_idx = state.artist_state.selected();
if let Some(idx) = selected_artist_idx {
if let Some(artist) = state.artists.get(idx) {
let albums = artist.albums.clone();
let focus = state.focus;
render_albums_list(frame, chunks[2], &albums, focus, &mut state.album_state);
}
}
let album_title = state
.selected_album()
.map(|a| a.title.clone())
.unwrap_or_default();
let track_counts = state
.selected_album()
.map(|a| format!("{}/{}", a.have, a.total))
.unwrap_or_default();
let track_label = format!("tracks · {}", album_title);
let track_divider = section_divider(&track_label, Some(&track_counts));
frame.render_widget(Paragraph::new(track_divider), chunks[3]);
render_tracks_list(frame, chunks[4], state);
}
fn render_artist_header(frame: &mut Frame, area: Rect, artist: &Artist) {
let have: u16 = artist.albums.iter().map(|a| a.have).sum();
let total: u16 = artist.albums.iter().map(|a| a.total).sum();
let (status_icon, status_text, status_style) = match artist.monitor_state {
MonitorState::Monitored => (
Span::styled("", Style::default().fg(theme::GREEN)),
"Monitored",
Style::default().fg(theme::FG2),
),
MonitorState::Unmonitored => (
Span::styled("", Style::default().fg(theme::GRAY)),
"Unmonitored",
Style::default().fg(theme::GRAY),
),
MonitorState::Excluded => (
Span::styled("", Style::default().fg(theme::RED)),
"Excluded",
Style::default().fg(theme::RED),
),
MonitorState::Unspecified => (
Span::styled("? ", Style::default().fg(theme::GRAY)),
"Unknown",
Style::default().fg(theme::GRAY),
),
};
let lines = vec![
Line::from(Span::styled(
&artist.name,
Style::default()
.fg(theme::YELLOW)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(vec![
Span::styled("status ", Style::default().fg(theme::GRAY)),
status_icon,
Span::styled(status_text, status_style),
Span::raw(" "),
Span::styled("path ", Style::default().fg(theme::GRAY)),
Span::styled(&artist.path, Style::default().fg(theme::AQUA)),
]),
Line::from(vec![
Span::styled("quality ", Style::default().fg(theme::GRAY)),
Span::styled(&artist.quality, Style::default().fg(theme::FG1)),
Span::raw(" "),
Span::styled("size ", Style::default().fg(theme::GRAY)),
Span::styled(fmt_size(artist.size_gb), Style::default().fg(theme::FG1)),
]),
Line::from(vec![
Span::styled("albums ", Style::default().fg(theme::GRAY)),
Span::styled(
artist.albums.len().to_string(),
Style::default().fg(theme::FG1),
),
Span::raw(" "),
Span::styled("tracks ", Style::default().fg(theme::GRAY)),
Span::styled(have.to_string(), Style::default().fg(theme::FG1)),
Span::styled(format!(" / {}", total), Style::default().fg(theme::GRAY)),
]),
];
let paragraph = Paragraph::new(lines).style(Style::default().bg(theme::BG0));
frame.render_widget(paragraph, area);
}
fn render_albums_list(
frame: &mut Frame,
area: Rect,
albums: &[Album],
focus: LibraryFocus,
album_state: &mut ListState,
) {
let focused = focus == LibraryFocus::Albums;
let items: Vec<ListItem> = albums
.iter()
.map(|album| {
let (icon_char, icon_style) = status_icon(album.status, album.monitored);
let type_str = format!("[{}]", album.album_type);
let year_str = album.year.to_string();
let progress = progress_bar(album.have, album.total, 10, album.status);
let count_str = format!("{}/{}", album.have, album.total);
let quality_style = if album.quality == "" {
Style::default().fg(theme::GRAY)
} else {
Style::default().fg(theme::AQUA)
};
let title_width =
area.width as usize - 2 - type_str.len() - 1 - 4 - 1 - 10 - 1 - 5 - 1 - 8;
let mut title = album.title.clone();
if title.len() > title_width {
title.truncate(title_width.saturating_sub(1));
title.push('…');
}
let mut spans = vec![
Span::styled(format!("{} ", icon_char), icon_style),
Span::styled(title, Style::default().fg(theme::FG1)),
Span::raw(" "),
Span::styled(type_str, Style::default().fg(theme::GRAY)),
Span::raw(" "),
Span::styled(year_str, Style::default().fg(theme::GRAY)),
Span::raw(" "),
];
spans.extend(progress.spans);
spans.push(Span::raw(" "));
spans.push(Span::styled(count_str, Style::default().fg(theme::GRAY)));
spans.push(Span::raw(" "));
spans.push(Span::styled(&album.quality, quality_style));
Line::from(spans).into()
})
.collect();
let highlight_style = if focused {
Style::default().bg(theme::YELLOW).fg(theme::BG0)
} else {
Style::default().bg(theme::SELECT_BG).fg(theme::FG1)
};
let list = List::new(items).highlight_style(highlight_style);
frame.render_stateful_widget(list, area, album_state);
}
fn render_tracks_list(frame: &mut Frame, area: Rect, state: &mut LibraryState) {
let focused = state.focus == LibraryFocus::Tracks;
let tracks = state.get_tracks();
if tracks.is_empty() {
let msg = Paragraph::new(Span::styled(
"(no album selected)",
Style::default().fg(theme::GRAY),
));
frame.render_widget(msg, area);
return;
}
let items: Vec<ListItem> = tracks
.iter()
.map(|track| {
let (icon_char, icon_style) = track_icon(track.have);
let num_str = format!("{:02}", track.number);
let title_style = if track.have {
Style::default().fg(theme::FG1)
} else {
Style::default().fg(theme::GRAY)
};
let quality_style = if track.have {
Style::default().fg(theme::AQUA)
} else {
Style::default().fg(theme::RED)
};
Line::from(vec![
Span::styled(format!("{} ", icon_char), icon_style),
Span::styled(num_str, Style::default().fg(theme::GRAY)),
Span::raw(" "),
Span::styled(&track.title, title_style),
Span::raw(" "),
Span::styled(&track.duration, Style::default().fg(theme::GRAY)),
Span::raw(" "),
Span::styled(&track.quality, quality_style),
])
.into()
})
.collect();
let highlight_style = if focused {
Style::default().bg(theme::YELLOW).fg(theme::BG0)
} else {
Style::default().bg(theme::SELECT_BG).fg(theme::FG1)
};
let list = List::new(items).highlight_style(highlight_style);
frame.render_stateful_widget(list, area, &mut state.track_state);
}
impl LibraryState {
pub fn get_tracks(&self) -> Vec<Track> {
let Some(album) = self.selected_album() else {
return Vec::new();
};
tracks_for(album)
}
}
fn tracks_for(album: &Album) -> Vec<Track> {
let titles = [
"Opening",
"Curtain Call",
"Half-Light",
"Polaroid",
"Switchback",
"Slow Dancer",
"The Inheritance",
"Glassworks",
"Interlude",
"Aftermath",
"Static",
"Returner",
"Dust Bowl",
"Postcard",
"Late Reply",
"Honeymoon",
"Northern Lights",
"Cold Open",
"Coda",
"Reprise",
];
(0..album.total)
.map(|i| {
let idx = i as usize;
let m = 2 + ((idx * 7) % 5);
let s = (idx * 13) % 60;
let title_idx = idx % titles.len();
let title = if idx >= titles.len() {
format!("{} II", titles[title_idx])
} else {
titles[title_idx].to_string()
};
let have = i < album.have;
let quality = if have {
album.quality.clone()
} else {
"".to_string()
};
Track {
number: i + 1,
title,
duration: format!("{}:{:02}", m, s),
have,
quality,
}
})
.collect()
}