#![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, pub focus: LibraryFocus, pub artist_state: ListState, pub album_state: ListState, pub track_state: ListState, } impl LibraryState { pub fn new(artists: Vec) -> 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 { 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 = 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 = 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 = 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 { let Some(album) = self.selected_album() else { return Vec::new(); }; tracks_for(album) } } fn tracks_for(album: &Album) -> Vec { 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() }