feat: add notification history dropdown and track fetching

- Add notifications dropdown in topbar with click-to-expand details
- Implement GetAlbum gRPC call for fetching track details
- Add track caching to avoid duplicate requests
- Guard against albums with empty IDs from server
- Increase notification TTL from 4s to 6s
- Add grpcurl to flake.nix for debugging
This commit is contained in:
Alexander
2026-05-09 11:19:24 +02:00
parent e77e854d2e
commit f7660436c2
13 changed files with 508 additions and 139 deletions
+1
View File
@@ -62,6 +62,7 @@
rustfmt rustfmt
protobuf protobuf
grpcurl
opencode opencode
]; ];
+244 -9
View File
@@ -9,9 +9,10 @@ use ratatui::{
}; };
use crate::data::{ use crate::data::{
Album, AlbumStatus, Artist, CalendarEntry, HistoryEntry, MonitorState, QueueEntry, WantedEntry, Album, AlbumStatus, Artist, CalendarEntry, HistoryEntry, MonitorState, QueueEntry, Track,
WantedEntry,
}; };
use crate::grpc::{AlbumDetail, ArtistSummary, GrpcResponse}; use crate::grpc::{AlbumDetail, ArtistSummary, GrpcResponse, TrackDetail};
use crate::theme; use crate::theme;
use crate::ui::library::{LibraryFocus, LibraryState, render_library}; use crate::ui::library::{LibraryFocus, LibraryState, render_library};
use crate::ui::modals::{ModalKind, render_help_modal, render_quit_modal}; use crate::ui::modals::{ModalKind, render_help_modal, render_quit_modal};
@@ -80,10 +81,15 @@ pub struct App {
pub history_state: ListState, pub history_state: ListState,
pub calendar: Vec<CalendarEntry>, pub calendar: Vec<CalendarEntry>,
pub notifications: NotificationManager, pub notifications: NotificationManager,
pub notifications_open: bool,
pub notifications_scroll: usize,
pub notifications_expanded: Option<u64>,
topbar_area: Rect, topbar_area: Rect,
main_area: Rect, main_area: Rect,
statusbar_area: Rect, statusbar_area: Rect,
tab_areas: Vec<Rect>, tab_areas: Vec<Rect>,
notifications_btn_area: Rect,
notifications_dropdown_area: Rect,
} }
impl Default for App { impl Default for App {
@@ -112,10 +118,15 @@ impl Default for App {
history_state, history_state,
calendar, calendar,
notifications: NotificationManager::new(), notifications: NotificationManager::new(),
notifications_open: false,
notifications_scroll: 0,
notifications_expanded: None,
topbar_area: Rect::default(), topbar_area: Rect::default(),
main_area: Rect::default(), main_area: Rect::default(),
statusbar_area: Rect::default(), statusbar_area: Rect::default(),
tab_areas: Vec::new(), tab_areas: Vec::new(),
notifications_btn_area: Rect::default(),
notifications_dropdown_area: Rect::default(),
} }
} }
} }
@@ -147,8 +158,19 @@ impl App {
let queue_count = self.queue.len(); let queue_count = self.queue.len();
let wanted_count = self.wanted.len(); let wanted_count = self.wanted.len();
let notification_count = self.notifications.history_count();
self.tab_areas = render_topbar(frame, chunks[0], self.tab, queue_count, wanted_count); let topbar = render_topbar(
frame,
chunks[0],
self.tab,
queue_count,
wanted_count,
notification_count,
self.notifications_open,
);
self.tab_areas = topbar.tabs;
self.notifications_btn_area = topbar.notifications;
self.render_main_content(frame, chunks[1]); self.render_main_content(frame, chunks[1]);
@@ -163,6 +185,123 @@ impl App {
} }
self.notifications.render(frame, area); self.notifications.render(frame, area);
if self.notifications_open {
self.render_notifications_dropdown(frame);
}
}
fn render_notifications_dropdown(&mut self, frame: &mut Frame) {
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
use crate::ui::notifications::render_notification_item;
let history = self.notifications.history();
if history.is_empty() {
return;
}
let dropdown_width = 52u16;
let max_items = 8usize;
let item_height = 3u16;
if let Some(expanded_id) = self.notifications_expanded {
if let Some(notif) = history.iter().find(|n| n.id == expanded_id) {
let detail_lines = notif.detail.as_ref().map(|d| d.lines().count()).unwrap_or(1);
let dropdown_height = (6 + detail_lines as u16).min(20);
let x = self.notifications_btn_area.x.saturating_sub(dropdown_width - self.notifications_btn_area.width);
let y = self.topbar_area.y + 1;
let dropdown_area = Rect::new(
x.max(0),
y,
dropdown_width.min(self.size.width),
dropdown_height.min(self.size.height - y),
);
self.notifications_dropdown_area = dropdown_area;
frame.render_widget(Clear, dropdown_area);
let border_color = notif.kind.color();
let block = Block::default()
.borders(Borders::ALL)
.border_style(ratatui::style::Style::default().fg(border_color))
.style(ratatui::style::Style::default().bg(theme::BG1));
let inner = block.inner(dropdown_area);
frame.render_widget(block, dropdown_area);
use ratatui::text::{Line, Span};
use ratatui::style::{Style, Modifier};
let elapsed = notif.created_at.elapsed().as_secs();
let time_str = if elapsed < 60 {
format!("{}s ago", elapsed)
} else if elapsed < 3600 {
format!("{}m ago", elapsed / 60)
} else {
format!("{}h ago", elapsed / 3600)
};
let mut lines = vec![
Line::from(vec![
Span::styled("", Style::default().fg(theme::GRAY)),
Span::styled(&notif.icon, Style::default().fg(border_color)),
Span::raw(" "),
Span::styled(
notif.kind.label(),
Style::default().fg(theme::FG1).add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(time_str, Style::default().fg(theme::GRAY)),
]),
Line::from(""),
Line::from(Span::styled(&notif.title, Style::default().fg(theme::FG1))),
];
if let Some(detail) = &notif.detail {
lines.push(Line::from(""));
for line in detail.lines() {
lines.push(Line::from(Span::styled(line, Style::default().fg(theme::FG2))));
}
}
let para = Paragraph::new(lines).wrap(Wrap { trim: false });
frame.render_widget(para, inner);
return;
}
}
let visible_count = history.len().min(max_items);
let dropdown_height = (visible_count as u16 * item_height) + 2;
let x = self.notifications_btn_area.x.saturating_sub(dropdown_width - self.notifications_btn_area.width);
let y = self.topbar_area.y + 1;
let dropdown_area = Rect::new(
x.max(0),
y,
dropdown_width.min(self.size.width),
dropdown_height.min(self.size.height - y),
);
self.notifications_dropdown_area = dropdown_area;
frame.render_widget(Clear, dropdown_area);
let block = Block::default()
.borders(Borders::ALL)
.border_style(ratatui::style::Style::default().fg(theme::GRAY))
.style(ratatui::style::Style::default().bg(theme::BG1));
let inner = block.inner(dropdown_area);
frame.render_widget(block, dropdown_area);
let start_idx = self.notifications_scroll;
let end_idx = (start_idx + max_items).min(history.len());
for (i, notif) in history.iter().rev().skip(start_idx).take(end_idx - start_idx).enumerate() {
let item_y = inner.y + (i as u16 * item_height);
let item_area = Rect::new(inner.x, item_y, inner.width, item_height);
render_notification_item(frame, item_area, notif);
}
} }
fn render_main_content(&mut self, frame: &mut Frame, area: Rect) { fn render_main_content(&mut self, frame: &mut Frame, area: Rect) {
@@ -221,6 +360,10 @@ impl App {
} }
pub fn handle_escape(&mut self) { pub fn handle_escape(&mut self) {
if self.notifications_open {
self.notifications_open = false;
return;
}
if self.modal.is_some() { if self.modal.is_some() {
self.modal = None; self.modal = None;
} }
@@ -236,6 +379,27 @@ impl App {
return; return;
} }
if self.notifications_open {
let in_dropdown = x >= self.notifications_dropdown_area.x
&& x < self.notifications_dropdown_area.x + self.notifications_dropdown_area.width
&& y >= self.notifications_dropdown_area.y
&& y < self.notifications_dropdown_area.y + self.notifications_dropdown_area.height;
let in_btn = x >= self.notifications_btn_area.x
&& x < self.notifications_btn_area.x + self.notifications_btn_area.width
&& y == self.notifications_btn_area.y;
if in_btn {
self.notifications_open = false;
self.notifications_expanded = None;
} else if in_dropdown {
self.handle_notification_click(x, y);
} else {
self.notifications_open = false;
self.notifications_expanded = None;
}
return;
}
if y == self.topbar_area.y { if y == self.topbar_area.y {
self.handle_topbar_click(x); self.handle_topbar_click(x);
return; return;
@@ -251,6 +415,15 @@ impl App {
} }
fn handle_topbar_click(&mut self, x: u16) { fn handle_topbar_click(&mut self, x: u16) {
if x >= self.notifications_btn_area.x
&& x < self.notifications_btn_area.x + self.notifications_btn_area.width
{
self.notifications_open = !self.notifications_open;
self.notifications_scroll = 0;
self.notifications_expanded = None;
return;
}
for (i, area) in self.tab_areas.iter().enumerate() { for (i, area) in self.tab_areas.iter().enumerate() {
if x >= area.x && x < area.x + area.width { if x >= area.x && x < area.x + area.width {
if let Some(tab) = Tab::ALL.get(i) { if let Some(tab) = Tab::ALL.get(i) {
@@ -261,6 +434,25 @@ impl App {
} }
} }
fn handle_notification_click(&mut self, _x: u16, y: u16) {
if self.notifications_expanded.is_some() {
self.notifications_expanded = None;
return;
}
let inner_y = self.notifications_dropdown_area.y + 1;
let rel_y = y.saturating_sub(inner_y) as usize;
let item_height = 3usize;
let clicked_idx = rel_y / item_height;
let history = self.notifications.history();
let actual_idx = self.notifications_scroll + clicked_idx;
if let Some(notif) = history.iter().rev().nth(actual_idx) {
self.notifications_expanded = Some(notif.id);
}
}
fn handle_main_click(&mut self, x: u16, y: u16) { fn handle_main_click(&mut self, x: u16, y: u16) {
let rel_y = y.saturating_sub(self.main_area.y) as usize; let rel_y = y.saturating_sub(self.main_area.y) as usize;
@@ -309,21 +501,19 @@ impl App {
let tracks_start_row = ALBUMS_START_ROW + albums_section_height + DIVIDER_HEIGHT; let tracks_start_row = ALBUMS_START_ROW + albums_section_height + DIVIDER_HEIGHT;
if rel_y < tracks_start_row { if rel_y < tracks_start_row {
if let Some(artist) = self.library.selected_artist() { if let Some(artist) = self.library.selected_artist()
if album_row < artist.albums.len() { && album_row < artist.albums.len() {
self.library.album_state.select(Some(album_row)); self.library.album_state.select(Some(album_row));
self.library.track_state.select(Some(0)); self.library.track_state.select(Some(0));
self.library.focus = LibraryFocus::Albums; self.library.focus = LibraryFocus::Albums;
} }
}
} else { } else {
let track_row = rel_y - tracks_start_row; let track_row = rel_y - tracks_start_row;
if let Some(album) = self.library.selected_album() { if let Some(album) = self.library.selected_album()
if track_row < album.total as usize { && track_row < album.total as usize {
self.library.track_state.select(Some(track_row)); self.library.track_state.select(Some(track_row));
self.library.focus = LibraryFocus::Tracks; self.library.focus = LibraryFocus::Tracks;
} }
}
} }
} }
} }
@@ -335,6 +525,16 @@ impl App {
return; return;
} }
if self.notifications_open {
let max_scroll = self.notifications.history_count().saturating_sub(8);
if delta > 0 {
self.notifications_scroll = (self.notifications_scroll + 1).min(max_scroll);
} else {
self.notifications_scroll = self.notifications_scroll.saturating_sub(1);
}
return;
}
match self.tab { match self.tab {
Tab::Library => { Tab::Library => {
self.scroll_library_list(delta); self.scroll_library_list(delta);
@@ -432,11 +632,19 @@ impl App {
"", "",
); );
} }
GrpcResponse::Album { album, tracks } => {
let converted: Vec<Track> = tracks.into_iter().map(convert_track).collect();
self.library.cache_tracks(album.id, converted);
}
GrpcResponse::Error(msg) => { GrpcResponse::Error(msg) => {
self.set_error(msg); self.set_error(msg);
} }
} }
} }
pub fn pending_album_fetch(&mut self) -> Option<String> {
self.library.needs_fetch()
}
} }
fn convert_artist(summary: ArtistSummary) -> Artist { fn convert_artist(summary: ArtistSummary) -> Artist {
@@ -512,6 +720,33 @@ fn parse_year(date_str: &str) -> u16 {
.unwrap_or(0) .unwrap_or(0)
} }
fn convert_track(detail: TrackDetail) -> Track {
let have = detail.file.is_some();
let quality = detail
.file
.as_ref()
.map(|f| f.format.clone())
.unwrap_or_else(|| "".to_string());
let duration = format_duration(detail.duration_ms);
Track {
id: detail.id,
number: detail.track_number as u16,
disc: detail.disc_number as u16,
title: detail.title,
duration,
have,
quality,
}
}
fn format_duration(ms: i32) -> String {
let total_secs = ms / 1000;
let mins = total_secs / 60;
let secs = total_secs % 60;
format!("{}:{:02}", mins, secs)
}
fn scroll_list_state(state: &mut ListState, len: usize, delta: i32) { fn scroll_list_state(state: &mut ListState, len: usize, delta: i32) {
if len == 0 { if len == 0 {
return; return;
+2 -1
View File
@@ -60,10 +60,11 @@ pub struct Album {
pub status: AlbumStatus, pub status: AlbumStatus,
} }
/// A track on an album.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Track { pub struct Track {
pub id: String,
pub number: u16, pub number: u16,
pub disc: u16,
pub title: String, pub title: String,
pub duration: String, pub duration: String,
pub have: bool, pub have: bool,
+25 -1
View File
@@ -3,16 +3,21 @@ use tonic::transport::Channel;
use crate::proto::music_agregator_v1::music_agregator_service_client::MusicAgregatorServiceClient; use crate::proto::music_agregator_v1::music_agregator_service_client::MusicAgregatorServiceClient;
pub use crate::proto::music_agregator_v1::{AlbumDetail, ArtistSummary, GetArtistsRequest}; pub use crate::proto::music_agregator_v1::{
AlbumDetail, ArtistSummary, GetAlbumRequest, GetArtistsRequest, TrackDetail,
};
#[derive(Debug)] #[derive(Debug)]
pub enum GrpcRequest { pub enum GrpcRequest {
GetArtists, GetArtists,
GetAlbum { album_id: String },
} }
#[derive(Debug)] #[derive(Debug)]
#[allow(dead_code, clippy::large_enum_variant)]
pub enum GrpcResponse { pub enum GrpcResponse {
Artists(Vec<ArtistSummary>), Artists(Vec<ArtistSummary>),
Album { album: AlbumDetail, tracks: Vec<TrackDetail> },
Error(String), Error(String),
} }
@@ -36,6 +41,21 @@ impl GrpcClient {
let response = self.music.get_artists(GetArtistsRequest {}).await?; let response = self.music.get_artists(GetArtistsRequest {}).await?;
Ok(response.into_inner().artists) Ok(response.into_inner().artists)
} }
pub async fn get_album(
&mut self,
album_id: String,
) -> Result<(AlbumDetail, Vec<TrackDetail>), tonic::Status> {
let response = self
.music
.get_album(GetAlbumRequest { album_id })
.await?;
let inner = response.into_inner();
let album = inner.album.ok_or_else(|| {
tonic::Status::not_found("Album not found in response")
})?;
Ok((album, inner.tracks))
}
} }
pub fn spawn_grpc_worker( pub fn spawn_grpc_worker(
@@ -61,6 +81,10 @@ pub fn spawn_grpc_worker(
Ok(artists) => GrpcResponse::Artists(artists), Ok(artists) => GrpcResponse::Artists(artists),
Err(e) => GrpcResponse::Error(e.to_string()), Err(e) => GrpcResponse::Error(e.to_string()),
}, },
GrpcRequest::GetAlbum { album_id } => match client.get_album(album_id).await {
Ok((album, tracks)) => GrpcResponse::Album { album, tracks },
Err(e) => GrpcResponse::Error(e.to_string()),
}
}; };
if resp_tx.send(response).await.is_err() { if resp_tx.send(response).await.is_err() {
+7 -2
View File
@@ -69,7 +69,7 @@ async fn run() -> Result<()> {
let (grpc_tx, mut grpc_rx) = spawn_grpc_worker(config.grpc_addr()); let (grpc_tx, mut grpc_rx) = spawn_grpc_worker(config.grpc_addr());
if grpc_tx.send(GrpcRequest::GetArtists).await.is_err() { if grpc_tx.try_send(GrpcRequest::GetArtists).is_err() {
app.set_error("Failed to send initial request".to_string()); app.set_error("Failed to send initial request".to_string());
} }
@@ -80,6 +80,10 @@ async fn run() -> Result<()> {
app.handle_grpc_response(response); app.handle_grpc_response(response);
} }
if let Some(album_id) = app.pending_album_fetch() {
let _ = grpc_tx.try_send(GrpcRequest::GetAlbum { album_id });
}
if event::poll(TICK_RATE)? { if event::poll(TICK_RATE)? {
match event::read()? { match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => { Event::Key(key) if key.kind == KeyEventKind::Press => {
@@ -92,7 +96,8 @@ async fn run() -> Result<()> {
} else if key.code == KeyCode::Char('r') } else if key.code == KeyCode::Char('r')
&& key.modifiers.contains(KeyModifiers::CONTROL) && key.modifiers.contains(KeyModifiers::CONTROL)
{ {
let _ = grpc_tx.send(GrpcRequest::GetArtists).await; app.library.clear_cache();
let _ = grpc_tx.try_send(GrpcRequest::GetArtists);
} }
} }
Event::Mouse(mouse) => match mouse.kind { Event::Mouse(mouse) => match mouse.kind {
+1
View File
@@ -1,3 +1,4 @@
#[allow(clippy::enum_variant_names)]
pub mod music_agregator_v1 { pub mod music_agregator_v1 {
include!("music_agregator.v1.rs"); include!("music_agregator.v1.rs");
} }
+82 -85
View File
@@ -1,5 +1,7 @@
#![allow(dead_code)] #![allow(dead_code)]
use std::collections::HashMap;
use ratatui::{ use ratatui::{
Frame, Frame,
layout::{Constraint, Layout, Rect}, layout::{Constraint, Layout, Rect},
@@ -23,10 +25,13 @@ pub enum LibraryFocus {
pub struct LibraryState { pub struct LibraryState {
pub artists: Vec<Artist>, pub artists: Vec<Artist>,
pub tracks: Vec<Track>,
pub focus: LibraryFocus, pub focus: LibraryFocus,
pub artist_state: ListState, pub artist_state: ListState,
pub album_state: ListState, pub album_state: ListState,
pub track_state: ListState, pub track_state: ListState,
tracks_cache: HashMap<String, Vec<Track>>,
pending_album_id: Option<String>,
} }
impl LibraryState { impl LibraryState {
@@ -45,10 +50,13 @@ impl LibraryState {
Self { Self {
artists, artists,
tracks: Vec::new(),
focus: LibraryFocus::Artists, focus: LibraryFocus::Artists,
artist_state, artist_state,
album_state, album_state,
track_state, track_state,
tracks_cache: HashMap::new(),
pending_album_id: None,
} }
} }
@@ -66,27 +74,24 @@ impl LibraryState {
pub fn move_up(&mut self) { pub fn move_up(&mut self) {
match self.focus { match self.focus {
LibraryFocus::Artists => { LibraryFocus::Artists => {
if let Some(i) = self.artist_state.selected() { if let Some(i) = self.artist_state.selected()
if i > 0 { && i > 0 {
self.artist_state.select(Some(i - 1)); self.artist_state.select(Some(i - 1));
self.reset_album_selection(); self.reset_album_selection();
} }
}
} }
LibraryFocus::Albums => { LibraryFocus::Albums => {
if let Some(i) = self.album_state.selected() { if let Some(i) = self.album_state.selected()
if i > 0 { && i > 0 {
self.album_state.select(Some(i - 1)); self.album_state.select(Some(i - 1));
self.reset_track_selection(); self.reset_track_selection();
} }
}
} }
LibraryFocus::Tracks => { LibraryFocus::Tracks => {
if let Some(i) = self.track_state.selected() { if let Some(i) = self.track_state.selected()
if i > 0 { && i > 0 {
self.track_state.select(Some(i - 1)); self.track_state.select(Some(i - 1));
} }
}
} }
} }
} }
@@ -95,32 +100,29 @@ impl LibraryState {
match self.focus { match self.focus {
LibraryFocus::Artists => { LibraryFocus::Artists => {
let max = self.artists.len().saturating_sub(1); let max = self.artists.len().saturating_sub(1);
if let Some(i) = self.artist_state.selected() { if let Some(i) = self.artist_state.selected()
if i < max { && i < max {
self.artist_state.select(Some(i + 1)); self.artist_state.select(Some(i + 1));
self.reset_album_selection(); self.reset_album_selection();
} }
}
} }
LibraryFocus::Albums => { LibraryFocus::Albums => {
let max = self let max = self
.selected_artist() .selected_artist()
.map(|a| a.albums.len().saturating_sub(1)) .map(|a| a.albums.len().saturating_sub(1))
.unwrap_or(0); .unwrap_or(0);
if let Some(i) = self.album_state.selected() { if let Some(i) = self.album_state.selected()
if i < max { && i < max {
self.album_state.select(Some(i + 1)); self.album_state.select(Some(i + 1));
self.reset_track_selection(); self.reset_track_selection();
} }
}
} }
LibraryFocus::Tracks => { LibraryFocus::Tracks => {
let max = self.track_count().saturating_sub(1); let max = self.track_count().saturating_sub(1);
if let Some(i) = self.track_state.selected() { if let Some(i) = self.track_state.selected()
if i < max { && i < max {
self.track_state.select(Some(i + 1)); self.track_state.select(Some(i + 1));
} }
}
} }
} }
} }
@@ -383,13 +385,12 @@ fn render_detail_pane(frame: &mut Frame, area: Rect, state: &mut LibraryState) {
frame.render_widget(Paragraph::new(album_divider), chunks[1]); frame.render_widget(Paragraph::new(album_divider), chunks[1]);
let selected_artist_idx = state.artist_state.selected(); let selected_artist_idx = state.artist_state.selected();
if let Some(idx) = selected_artist_idx { if let Some(idx) = selected_artist_idx
if let Some(artist) = state.artists.get(idx) { && let Some(artist) = state.artists.get(idx) {
let albums = artist.albums.clone(); let albums = artist.albums.clone();
let focus = state.focus; let focus = state.focus;
render_albums_list(frame, chunks[2], &albums, focus, &mut state.album_state); render_albums_list(frame, chunks[2], &albums, focus, &mut state.album_state);
} }
}
let album_title = state let album_title = state
.selected_album() .selected_album()
@@ -536,9 +537,8 @@ fn render_albums_list(
fn render_tracks_list(frame: &mut Frame, area: Rect, state: &mut LibraryState) { fn render_tracks_list(frame: &mut Frame, area: Rect, state: &mut LibraryState) {
let focused = state.focus == LibraryFocus::Tracks; let focused = state.focus == LibraryFocus::Tracks;
let tracks = state.get_tracks();
if tracks.is_empty() { if state.tracks.is_empty() {
let msg = Paragraph::new(Span::styled( let msg = Paragraph::new(Span::styled(
"(no album selected)", "(no album selected)",
Style::default().fg(theme::GRAY), Style::default().fg(theme::GRAY),
@@ -547,7 +547,8 @@ fn render_tracks_list(frame: &mut Frame, area: Rect, state: &mut LibraryState) {
return; return;
} }
let items: Vec<ListItem> = tracks let items: Vec<ListItem> = state
.tracks
.iter() .iter()
.map(|track| { .map(|track| {
let (icon_char, icon_style) = track_icon(track.have); let (icon_char, icon_style) = track_icon(track.have);
@@ -569,11 +570,11 @@ fn render_tracks_list(frame: &mut Frame, area: Rect, state: &mut LibraryState) {
Span::styled(format!("{} ", icon_char), icon_style), Span::styled(format!("{} ", icon_char), icon_style),
Span::styled(num_str, Style::default().fg(theme::GRAY)), Span::styled(num_str, Style::default().fg(theme::GRAY)),
Span::raw(" "), Span::raw(" "),
Span::styled(&track.title, title_style), Span::styled(track.title.clone(), title_style),
Span::raw(" "), Span::raw(" "),
Span::styled(&track.duration, Style::default().fg(theme::GRAY)), Span::styled(track.duration.clone(), Style::default().fg(theme::GRAY)),
Span::raw(" "), Span::raw(" "),
Span::styled(&track.quality, quality_style), Span::styled(track.quality.clone(), quality_style),
]) ])
.into() .into()
}) })
@@ -590,64 +591,60 @@ fn render_tracks_list(frame: &mut Frame, area: Rect, state: &mut LibraryState) {
} }
impl LibraryState { impl LibraryState {
pub fn get_tracks(&self) -> Vec<Track> { pub fn get_tracks(&self) -> &[Track] {
let Some(album) = self.selected_album() else { &self.tracks
return Vec::new(); }
};
tracks_for(album) pub fn cache_tracks(&mut self, album_id: String, tracks: Vec<Track>) {
self.tracks_cache.insert(album_id.clone(), tracks);
if self.pending_album_id.as_ref() == Some(&album_id) {
self.pending_album_id = None;
self.load_tracks_from_cache(&album_id);
}
}
pub fn load_tracks_from_cache(&mut self, album_id: &str) {
if let Some(tracks) = self.tracks_cache.get(album_id) {
self.tracks = tracks.clone();
self.track_state.select(if self.tracks.is_empty() { None } else { Some(0) });
}
}
pub fn get_cached_tracks(&self, album_id: &str) -> Option<&Vec<Track>> {
self.tracks_cache.get(album_id)
}
pub fn needs_fetch(&mut self) -> Option<String> {
let current_album_id = self.selected_album().map(|a| a.id.clone())?;
if current_album_id.is_empty() {
return None;
}
if self.tracks_cache.contains_key(&current_album_id) {
if self.pending_album_id.as_ref() != Some(&current_album_id) {
self.load_tracks_from_cache(&current_album_id);
}
return None;
}
if self.pending_album_id.as_ref() == Some(&current_album_id) {
return None;
}
self.pending_album_id = Some(current_album_id.clone());
self.tracks.clear();
Some(current_album_id)
}
pub fn selected_album_id(&self) -> Option<String> {
self.selected_album().map(|a| a.id.clone())
}
pub fn clear_cache(&mut self) {
self.tracks_cache.clear();
self.pending_album_id = None;
self.tracks.clear();
} }
} }
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()
}
+99 -25
View File
@@ -12,8 +12,9 @@ use ratatui::{
use crate::theme; use crate::theme;
const NOTIFICATION_TTL_SECS: u64 = 4; const NOTIFICATION_TTL_SECS: u64 = 6;
const MAX_VISIBLE: usize = 5; const MAX_VISIBLE: usize = 5;
const MAX_HISTORY: usize = 100;
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NotifKind { pub enum NotifKind {
@@ -24,7 +25,7 @@ pub enum NotifKind {
} }
impl NotifKind { impl NotifKind {
fn color(self) -> ratatui::style::Color { pub fn color(self) -> ratatui::style::Color {
match self { match self {
NotifKind::Info => theme::BLUE, NotifKind::Info => theme::BLUE,
NotifKind::Success => theme::GREEN, NotifKind::Success => theme::GREEN,
@@ -32,8 +33,18 @@ impl NotifKind {
NotifKind::Error => theme::RED, NotifKind::Error => theme::RED,
} }
} }
pub fn label(self) -> &'static str {
match self {
NotifKind::Info => "Info",
NotifKind::Success => "Success",
NotifKind::Warn => "Warning",
NotifKind::Error => "Error",
}
}
} }
#[derive(Clone)]
pub struct Notification { pub struct Notification {
pub id: u64, pub id: u64,
pub title: String, pub title: String,
@@ -44,7 +55,8 @@ pub struct Notification {
} }
pub struct NotificationManager { pub struct NotificationManager {
notifications: Vec<Notification>, active: Vec<Notification>,
history: Vec<Notification>,
next_id: u64, next_id: u64,
} }
@@ -57,7 +69,8 @@ impl Default for NotificationManager {
impl NotificationManager { impl NotificationManager {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
notifications: Vec::new(), active: Vec::new(),
history: Vec::new(),
next_id: 1, next_id: 1,
} }
} }
@@ -78,22 +91,28 @@ impl NotificationManager {
created_at: Instant::now(), created_at: Instant::now(),
}; };
self.next_id += 1; self.next_id += 1;
self.notifications.push(notification);
self.history.push(notification.clone());
while self.notifications.len() > MAX_VISIBLE * 2 { if self.history.len() > MAX_HISTORY {
self.notifications.remove(0); self.history.remove(0);
} }
self.active.push(notification);
} }
pub fn tick(&mut self) { pub fn tick(&mut self) {
let now = Instant::now(); let now = Instant::now();
self.notifications self.active
.retain(|n| now.duration_since(n.created_at).as_secs() < NOTIFICATION_TTL_SECS); .retain(|n| now.duration_since(n.created_at).as_secs() < NOTIFICATION_TTL_SECS);
} }
pub fn history(&self) -> &[Notification] {
&self.history
}
pub fn render(&self, frame: &mut Frame, area: Rect) { pub fn render(&self, frame: &mut Frame, area: Rect) {
let visible: Vec<&Notification> = self let visible: Vec<&Notification> = self
.notifications .active
.iter() .iter()
.rev() .rev()
.take(MAX_VISIBLE) .take(MAX_VISIBLE)
@@ -106,7 +125,7 @@ impl NotificationManager {
return; return;
} }
let notif_width = 36u16.min(area.width); let notif_width = 50u16.min(area.width.saturating_sub(4));
let notif_height = 3u16; let notif_height = 3u16;
let spacing = 1u16; let spacing = 1u16;
let total_height = visible.len() as u16 * (notif_height + spacing); let total_height = visible.len() as u16 * (notif_height + spacing);
@@ -150,16 +169,13 @@ impl NotificationManager {
])]; ])];
if let Some(detail) = &notif.detail { if let Some(detail) = &notif.detail {
let mut d = detail.clone(); let max_len = inner.width.saturating_sub(2) as usize;
let max_len = inner.width.saturating_sub(4) as usize; let d = if detail.len() > max_len {
if d.len() > max_len { format!("{}", &detail[..max_len.saturating_sub(1)])
d.truncate(max_len.saturating_sub(1)); } else {
d.push('…'); detail.clone()
} };
lines.push(Line::from(Span::styled( lines.push(Line::from(Span::styled(d, Style::default().fg(theme::GRAY))));
d,
Style::default().fg(theme::GRAY),
)));
} }
let para = Paragraph::new(lines); let para = Paragraph::new(lines);
@@ -167,11 +183,69 @@ impl NotificationManager {
} }
} }
pub fn len(&self) -> usize { pub fn active_count(&self) -> usize {
self.notifications.len() self.active.len()
} }
pub fn is_empty(&self) -> bool { pub fn history_count(&self) -> usize {
self.notifications.is_empty() self.history.len()
}
}
pub fn render_notification_item(frame: &mut Frame, area: Rect, notif: &Notification) {
let border_color = notif.kind.color();
let block = Block::default()
.borders(Borders::LEFT)
.border_style(Style::default().fg(border_color))
.style(Style::default().bg(theme::BG1));
let inner = block.inner(area);
frame.render_widget(block, area);
let elapsed = Instant::now().duration_since(notif.created_at).as_secs();
let timestamp = format_elapsed(elapsed);
let mut lines = vec![Line::from(vec![
Span::styled(&notif.icon, Style::default().fg(border_color)),
Span::raw(" "),
Span::styled(
notif.kind.label(),
Style::default()
.fg(theme::FG1)
.add_modifier(ratatui::style::Modifier::BOLD),
),
Span::raw(" "),
Span::styled(timestamp, Style::default().fg(theme::GRAY)),
])];
if let Some(detail) = &notif.detail {
let max_len = inner.width.saturating_sub(2) as usize;
let d = if detail.len() > max_len {
format!("{}", &detail[..max_len.saturating_sub(1)])
} else {
detail.clone()
};
lines.push(Line::from(Span::styled(d, Style::default().fg(theme::GRAY))));
} else {
let max_len = inner.width.saturating_sub(2) as usize;
let title = if notif.title.len() > max_len {
format!("{}", &notif.title[..max_len.saturating_sub(1)])
} else {
notif.title.clone()
};
lines.push(Line::from(Span::styled(title, Style::default().fg(theme::GRAY))));
}
let para = Paragraph::new(lines);
frame.render_widget(para, inner);
}
fn format_elapsed(secs: u64) -> String {
if secs < 60 {
format!("{}s", secs)
} else if secs < 3600 {
format!("{}m", secs / 60)
} else {
format!("{}h", secs / 3600)
} }
} }
+2 -3
View File
@@ -79,8 +79,8 @@ impl Widget for Pane<'_> {
let block = self.build_block(); let block = self.build_block();
block.render(area, buf); block.render(area, buf);
if let Some(footer) = self.footer { if let Some(footer) = self.footer
if area.height > 2 { && area.height > 2 {
let footer_y = area.y + area.height - 1; let footer_y = area.y + area.height - 1;
let footer_x = area.x + 2; let footer_x = area.x + 2;
let footer_width = area.width.saturating_sub(4); let footer_width = area.width.saturating_sub(4);
@@ -89,7 +89,6 @@ impl Widget for Pane<'_> {
buf.set_line(footer_x, footer_y, &footer, footer_width); buf.set_line(footer_x, footer_y, &footer, footer_width);
} }
} }
}
} }
} }
+1 -1
View File
@@ -18,7 +18,7 @@ pub fn progress_bar(have: u16, total: u16, width: usize, status: AlbumStatus) ->
let filled_count = if total == 0 { let filled_count = if total == 0 {
0 0
} else { } else {
((have as usize * width) + total as usize - 1) / total as usize (have as usize * width).div_ceil(total as usize)
}; };
let empty_count = width.saturating_sub(filled_count); let empty_count = width.saturating_sub(filled_count);
+1 -1
View File
@@ -12,7 +12,7 @@ use crate::theme;
fn get_free_space() -> String { fn get_free_space() -> String {
match statvfs("/") { match statvfs("/") {
Ok(stat) => { Ok(stat) => {
let free_bytes = stat.blocks_available() as u64 * stat.fragment_size() as u64; let free_bytes = stat.blocks_available() * stat.fragment_size();
let free_gb = free_bytes as f64 / (1024.0 * 1024.0 * 1024.0); let free_gb = free_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
if free_gb >= 1000.0 { if free_gb >= 1000.0 {
format!("{:.1} TB free", free_gb / 1024.0) format!("{:.1} TB free", free_gb / 1024.0)
+41 -8
View File
@@ -9,13 +9,20 @@ use ratatui::{
use crate::app::Tab; use crate::app::Tab;
use crate::theme; use crate::theme;
pub struct TopbarAreas {
pub tabs: Vec<Rect>,
pub notifications: Rect,
}
pub fn render_topbar( pub fn render_topbar(
frame: &mut Frame, frame: &mut Frame,
area: Rect, area: Rect,
active_tab: Tab, active_tab: Tab,
queue_count: usize, queue_count: usize,
wanted_count: usize, wanted_count: usize,
) -> Vec<Rect> { notification_count: usize,
notifications_open: bool,
) -> TopbarAreas {
let mut spans = Vec::new(); let mut spans = Vec::new();
let mut tab_areas = Vec::new(); let mut tab_areas = Vec::new();
let mut current_x = area.x; let mut current_x = area.x;
@@ -99,16 +106,42 @@ pub fn render_topbar(
tab_areas.push(Rect::new(tab_start, area.y, tab_width, 1)); tab_areas.push(Rect::new(tab_start, area.y, tab_width, 1));
} }
let used_width = current_x - area.x; let notif_text = if notification_count > 0 {
let remaining = area.width.saturating_sub(used_width) as usize; format!(" ● Notifications ({}) ", notification_count)
spans.push(Span::styled( } else {
" ".repeat(remaining), " ● Notifications ".to_string()
Style::default().bg(theme::BG1), };
)); let notif_width = notif_text.len() as u16;
let notif_x = area.x + area.width - notif_width;
let remaining = (notif_x - current_x) as usize;
if remaining > 0 {
spans.push(Span::styled(
" ".repeat(remaining),
Style::default().bg(theme::BG1),
));
}
let notif_style = if notifications_open {
Style::default()
.fg(theme::YELLOW)
.bg(theme::BG0)
.add_modifier(Modifier::BOLD)
} else if notification_count > 0 {
Style::default().fg(theme::YELLOW).bg(theme::BG1)
} else {
Style::default().fg(theme::FG3).bg(theme::BG1)
};
spans.push(Span::styled(&notif_text, notif_style));
let notifications_area = Rect::new(notif_x, area.y, notif_width, 1);
let line = Line::from(spans); let line = Line::from(spans);
let paragraph = Paragraph::new(line).style(Style::default().bg(theme::BG1)); let paragraph = Paragraph::new(line).style(Style::default().bg(theme::BG1));
frame.render_widget(paragraph, area); frame.render_widget(paragraph, area);
tab_areas TopbarAreas {
tabs: tab_areas,
notifications: notifications_area,
}
} }
+2 -3
View File
@@ -70,12 +70,11 @@ pub fn render_calendar(frame: &mut Frame, area: Rect, calendar: &[CalendarEntry]
let events: Vec<CalendarEntry> = calendar let events: Vec<CalendarEntry> = calendar
.iter() .iter()
.filter(|e| { .filter(|e| {
if let Some(day_str) = e.date.split('-').nth(2) { if let Some(day_str) = e.date.split('-').nth(2)
if let Ok(day) = day_str.parse::<u8>() { && let Ok(day) = day_str.parse::<u8>() {
let month_match = e.date.contains(&format!("{:04}-{:02}", year, month)); let month_match = e.date.contains(&format!("{:04}-{:02}", year, month));
return month_match && day == d; return month_match && day == d;
} }
}
false false
}) })
.cloned() .cloned()