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:
+244
-9
@@ -9,9 +9,10 @@ use ratatui::{
|
||||
};
|
||||
|
||||
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::ui::library::{LibraryFocus, LibraryState, render_library};
|
||||
use crate::ui::modals::{ModalKind, render_help_modal, render_quit_modal};
|
||||
@@ -80,10 +81,15 @@ pub struct App {
|
||||
pub history_state: ListState,
|
||||
pub calendar: Vec<CalendarEntry>,
|
||||
pub notifications: NotificationManager,
|
||||
pub notifications_open: bool,
|
||||
pub notifications_scroll: usize,
|
||||
pub notifications_expanded: Option<u64>,
|
||||
topbar_area: Rect,
|
||||
main_area: Rect,
|
||||
statusbar_area: Rect,
|
||||
tab_areas: Vec<Rect>,
|
||||
notifications_btn_area: Rect,
|
||||
notifications_dropdown_area: Rect,
|
||||
}
|
||||
|
||||
impl Default for App {
|
||||
@@ -112,10 +118,15 @@ impl Default for App {
|
||||
history_state,
|
||||
calendar,
|
||||
notifications: NotificationManager::new(),
|
||||
notifications_open: false,
|
||||
notifications_scroll: 0,
|
||||
notifications_expanded: None,
|
||||
topbar_area: Rect::default(),
|
||||
main_area: Rect::default(),
|
||||
statusbar_area: Rect::default(),
|
||||
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 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]);
|
||||
|
||||
@@ -163,6 +185,123 @@ impl App {
|
||||
}
|
||||
|
||||
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(¬if.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(¬if.title, Style::default().fg(theme::FG1))),
|
||||
];
|
||||
|
||||
if let Some(detail) = ¬if.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) {
|
||||
@@ -221,6 +360,10 @@ impl App {
|
||||
}
|
||||
|
||||
pub fn handle_escape(&mut self) {
|
||||
if self.notifications_open {
|
||||
self.notifications_open = false;
|
||||
return;
|
||||
}
|
||||
if self.modal.is_some() {
|
||||
self.modal = None;
|
||||
}
|
||||
@@ -236,6 +379,27 @@ impl App {
|
||||
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 {
|
||||
self.handle_topbar_click(x);
|
||||
return;
|
||||
@@ -251,6 +415,15 @@ impl App {
|
||||
}
|
||||
|
||||
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() {
|
||||
if x >= area.x && x < area.x + area.width {
|
||||
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) {
|
||||
let rel_y = y.saturating_sub(self.main_area.y) as usize;
|
||||
|
||||
@@ -309,24 +501,22 @@ impl App {
|
||||
let tracks_start_row = ALBUMS_START_ROW + albums_section_height + DIVIDER_HEIGHT;
|
||||
|
||||
if rel_y < tracks_start_row {
|
||||
if let Some(artist) = self.library.selected_artist() {
|
||||
if album_row < artist.albums.len() {
|
||||
if let Some(artist) = self.library.selected_artist()
|
||||
&& album_row < artist.albums.len() {
|
||||
self.library.album_state.select(Some(album_row));
|
||||
self.library.track_state.select(Some(0));
|
||||
self.library.focus = LibraryFocus::Albums;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let track_row = rel_y - tracks_start_row;
|
||||
if let Some(album) = self.library.selected_album() {
|
||||
if track_row < album.total as usize {
|
||||
if let Some(album) = self.library.selected_album()
|
||||
&& track_row < album.total as usize {
|
||||
self.library.track_state.select(Some(track_row));
|
||||
self.library.focus = LibraryFocus::Tracks;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn select_list_item(&self, _state: &mut ListState, _len: usize, _rel_y: usize) {}
|
||||
|
||||
@@ -335,6 +525,16 @@ impl App {
|
||||
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 {
|
||||
Tab::Library => {
|
||||
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) => {
|
||||
self.set_error(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pending_album_fetch(&mut self) -> Option<String> {
|
||||
self.library.needs_fetch()
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_artist(summary: ArtistSummary) -> Artist {
|
||||
@@ -512,6 +720,33 @@ fn parse_year(date_str: &str) -> u16 {
|
||||
.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) {
|
||||
if len == 0 {
|
||||
return;
|
||||
|
||||
+2
-1
@@ -60,10 +60,11 @@ pub struct Album {
|
||||
pub status: AlbumStatus,
|
||||
}
|
||||
|
||||
/// A track on an album.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Track {
|
||||
pub id: String,
|
||||
pub number: u16,
|
||||
pub disc: u16,
|
||||
pub title: String,
|
||||
pub duration: String,
|
||||
pub have: bool,
|
||||
|
||||
+25
-1
@@ -3,16 +3,21 @@ use tonic::transport::Channel;
|
||||
|
||||
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)]
|
||||
pub enum GrpcRequest {
|
||||
GetArtists,
|
||||
GetAlbum { album_id: String },
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[allow(dead_code, clippy::large_enum_variant)]
|
||||
pub enum GrpcResponse {
|
||||
Artists(Vec<ArtistSummary>),
|
||||
Album { album: AlbumDetail, tracks: Vec<TrackDetail> },
|
||||
Error(String),
|
||||
}
|
||||
|
||||
@@ -36,6 +41,21 @@ impl GrpcClient {
|
||||
let response = self.music.get_artists(GetArtistsRequest {}).await?;
|
||||
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(
|
||||
@@ -61,6 +81,10 @@ pub fn spawn_grpc_worker(
|
||||
Ok(artists) => GrpcResponse::Artists(artists),
|
||||
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() {
|
||||
|
||||
+7
-2
@@ -69,7 +69,7 @@ async fn run() -> Result<()> {
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -80,6 +80,10 @@ async fn run() -> Result<()> {
|
||||
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)? {
|
||||
match event::read()? {
|
||||
Event::Key(key) if key.kind == KeyEventKind::Press => {
|
||||
@@ -92,7 +96,8 @@ async fn run() -> Result<()> {
|
||||
} else if key.code == KeyCode::Char('r')
|
||||
&& 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 {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#[allow(clippy::enum_variant_names)]
|
||||
pub mod music_agregator_v1 {
|
||||
include!("music_agregator.v1.rs");
|
||||
}
|
||||
|
||||
+82
-85
@@ -1,5 +1,7 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use ratatui::{
|
||||
Frame,
|
||||
layout::{Constraint, Layout, Rect},
|
||||
@@ -23,10 +25,13 @@ pub enum LibraryFocus {
|
||||
|
||||
pub struct LibraryState {
|
||||
pub artists: Vec<Artist>,
|
||||
pub tracks: Vec<Track>,
|
||||
pub focus: LibraryFocus,
|
||||
pub artist_state: ListState,
|
||||
pub album_state: ListState,
|
||||
pub track_state: ListState,
|
||||
tracks_cache: HashMap<String, Vec<Track>>,
|
||||
pending_album_id: Option<String>,
|
||||
}
|
||||
|
||||
impl LibraryState {
|
||||
@@ -45,10 +50,13 @@ impl LibraryState {
|
||||
|
||||
Self {
|
||||
artists,
|
||||
tracks: Vec::new(),
|
||||
focus: LibraryFocus::Artists,
|
||||
artist_state,
|
||||
album_state,
|
||||
track_state,
|
||||
tracks_cache: HashMap::new(),
|
||||
pending_album_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,64 +74,58 @@ impl LibraryState {
|
||||
pub fn move_up(&mut self) {
|
||||
match self.focus {
|
||||
LibraryFocus::Artists => {
|
||||
if let Some(i) = self.artist_state.selected() {
|
||||
if i > 0 {
|
||||
if let Some(i) = self.artist_state.selected()
|
||||
&& 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 {
|
||||
if let Some(i) = self.album_state.selected()
|
||||
&& 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 {
|
||||
if let Some(i) = self.track_state.selected()
|
||||
&& 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 {
|
||||
if let Some(i) = self.artist_state.selected()
|
||||
&& 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 {
|
||||
if let Some(i) = self.album_state.selected()
|
||||
&& 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 {
|
||||
if let Some(i) = self.track_state.selected()
|
||||
&& 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)
|
||||
@@ -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]);
|
||||
|
||||
let selected_artist_idx = state.artist_state.selected();
|
||||
if let Some(idx) = selected_artist_idx {
|
||||
if let Some(artist) = state.artists.get(idx) {
|
||||
if let Some(idx) = selected_artist_idx
|
||||
&& 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()
|
||||
@@ -536,9 +537,8 @@ fn render_albums_list(
|
||||
|
||||
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() {
|
||||
if state.tracks.is_empty() {
|
||||
let msg = Paragraph::new(Span::styled(
|
||||
"(no album selected)",
|
||||
Style::default().fg(theme::GRAY),
|
||||
@@ -547,7 +547,8 @@ fn render_tracks_list(frame: &mut Frame, area: Rect, state: &mut LibraryState) {
|
||||
return;
|
||||
}
|
||||
|
||||
let items: Vec<ListItem> = tracks
|
||||
let items: Vec<ListItem> = state
|
||||
.tracks
|
||||
.iter()
|
||||
.map(|track| {
|
||||
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(num_str, Style::default().fg(theme::GRAY)),
|
||||
Span::raw(" "),
|
||||
Span::styled(&track.title, title_style),
|
||||
Span::styled(track.title.clone(), title_style),
|
||||
Span::raw(" "),
|
||||
Span::styled(&track.duration, Style::default().fg(theme::GRAY)),
|
||||
Span::styled(track.duration.clone(), Style::default().fg(theme::GRAY)),
|
||||
Span::raw(" "),
|
||||
Span::styled(&track.quality, quality_style),
|
||||
Span::styled(track.quality.clone(), quality_style),
|
||||
])
|
||||
.into()
|
||||
})
|
||||
@@ -590,64 +591,60 @@ fn render_tracks_list(frame: &mut Frame, area: Rect, state: &mut LibraryState) {
|
||||
}
|
||||
|
||||
impl LibraryState {
|
||||
pub fn get_tracks(&self) -> Vec<Track> {
|
||||
let Some(album) = self.selected_album() else {
|
||||
return Vec::new();
|
||||
};
|
||||
pub fn get_tracks(&self) -> &[Track] {
|
||||
&self.tracks
|
||||
}
|
||||
|
||||
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(¤t_album_id) {
|
||||
if self.pending_album_id.as_ref() != Some(¤t_album_id) {
|
||||
self.load_tracks_from_cache(¤t_album_id);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
if self.pending_album_id.as_ref() == Some(¤t_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()
|
||||
}
|
||||
|
||||
+98
-24
@@ -12,8 +12,9 @@ use ratatui::{
|
||||
|
||||
use crate::theme;
|
||||
|
||||
const NOTIFICATION_TTL_SECS: u64 = 4;
|
||||
const NOTIFICATION_TTL_SECS: u64 = 6;
|
||||
const MAX_VISIBLE: usize = 5;
|
||||
const MAX_HISTORY: usize = 100;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum NotifKind {
|
||||
@@ -24,7 +25,7 @@ pub enum NotifKind {
|
||||
}
|
||||
|
||||
impl NotifKind {
|
||||
fn color(self) -> ratatui::style::Color {
|
||||
pub fn color(self) -> ratatui::style::Color {
|
||||
match self {
|
||||
NotifKind::Info => theme::BLUE,
|
||||
NotifKind::Success => theme::GREEN,
|
||||
@@ -32,8 +33,18 @@ impl NotifKind {
|
||||
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 id: u64,
|
||||
pub title: String,
|
||||
@@ -44,7 +55,8 @@ pub struct Notification {
|
||||
}
|
||||
|
||||
pub struct NotificationManager {
|
||||
notifications: Vec<Notification>,
|
||||
active: Vec<Notification>,
|
||||
history: Vec<Notification>,
|
||||
next_id: u64,
|
||||
}
|
||||
|
||||
@@ -57,7 +69,8 @@ impl Default for NotificationManager {
|
||||
impl NotificationManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
notifications: Vec::new(),
|
||||
active: Vec::new(),
|
||||
history: Vec::new(),
|
||||
next_id: 1,
|
||||
}
|
||||
}
|
||||
@@ -78,22 +91,28 @@ impl NotificationManager {
|
||||
created_at: Instant::now(),
|
||||
};
|
||||
self.next_id += 1;
|
||||
self.notifications.push(notification);
|
||||
|
||||
while self.notifications.len() > MAX_VISIBLE * 2 {
|
||||
self.notifications.remove(0);
|
||||
self.history.push(notification.clone());
|
||||
if self.history.len() > MAX_HISTORY {
|
||||
self.history.remove(0);
|
||||
}
|
||||
|
||||
self.active.push(notification);
|
||||
}
|
||||
|
||||
pub fn tick(&mut self) {
|
||||
let now = Instant::now();
|
||||
self.notifications
|
||||
self.active
|
||||
.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) {
|
||||
let visible: Vec<&Notification> = self
|
||||
.notifications
|
||||
.active
|
||||
.iter()
|
||||
.rev()
|
||||
.take(MAX_VISIBLE)
|
||||
@@ -106,7 +125,7 @@ impl NotificationManager {
|
||||
return;
|
||||
}
|
||||
|
||||
let notif_width = 36u16.min(area.width);
|
||||
let notif_width = 50u16.min(area.width.saturating_sub(4));
|
||||
let notif_height = 3u16;
|
||||
let spacing = 1u16;
|
||||
let total_height = visible.len() as u16 * (notif_height + spacing);
|
||||
@@ -150,16 +169,13 @@ impl NotificationManager {
|
||||
])];
|
||||
|
||||
if let Some(detail) = ¬if.detail {
|
||||
let mut d = detail.clone();
|
||||
let max_len = inner.width.saturating_sub(4) as usize;
|
||||
if d.len() > max_len {
|
||||
d.truncate(max_len.saturating_sub(1));
|
||||
d.push('…');
|
||||
}
|
||||
lines.push(Line::from(Span::styled(
|
||||
d,
|
||||
Style::default().fg(theme::GRAY),
|
||||
)));
|
||||
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))));
|
||||
}
|
||||
|
||||
let para = Paragraph::new(lines);
|
||||
@@ -167,11 +183,69 @@ impl NotificationManager {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.notifications.len()
|
||||
pub fn active_count(&self) -> usize {
|
||||
self.active.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.notifications.is_empty()
|
||||
pub fn history_count(&self) -> usize {
|
||||
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(¬if.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) = ¬if.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!("{}…", ¬if.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
@@ -79,8 +79,8 @@ impl Widget for Pane<'_> {
|
||||
let block = self.build_block();
|
||||
block.render(area, buf);
|
||||
|
||||
if let Some(footer) = self.footer {
|
||||
if area.height > 2 {
|
||||
if let Some(footer) = self.footer
|
||||
&& area.height > 2 {
|
||||
let footer_y = area.y + area.height - 1;
|
||||
let footer_x = area.x + 2;
|
||||
let footer_width = area.width.saturating_sub(4);
|
||||
@@ -90,7 +90,6 @@ impl Widget for Pane<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn section_divider<'a>(label: &'a str, right: Option<&'a str>) -> Line<'a> {
|
||||
|
||||
@@ -18,7 +18,7 @@ pub fn progress_bar(have: u16, total: u16, width: usize, status: AlbumStatus) ->
|
||||
let filled_count = if total == 0 {
|
||||
0
|
||||
} 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);
|
||||
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ use crate::theme;
|
||||
fn get_free_space() -> String {
|
||||
match statvfs("/") {
|
||||
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);
|
||||
if free_gb >= 1000.0 {
|
||||
format!("{:.1} TB free", free_gb / 1024.0)
|
||||
|
||||
+37
-4
@@ -9,13 +9,20 @@ use ratatui::{
|
||||
use crate::app::Tab;
|
||||
use crate::theme;
|
||||
|
||||
pub struct TopbarAreas {
|
||||
pub tabs: Vec<Rect>,
|
||||
pub notifications: Rect,
|
||||
}
|
||||
|
||||
pub fn render_topbar(
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
active_tab: Tab,
|
||||
queue_count: usize,
|
||||
wanted_count: usize,
|
||||
) -> Vec<Rect> {
|
||||
notification_count: usize,
|
||||
notifications_open: bool,
|
||||
) -> TopbarAreas {
|
||||
let mut spans = Vec::new();
|
||||
let mut tab_areas = Vec::new();
|
||||
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));
|
||||
}
|
||||
|
||||
let used_width = current_x - area.x;
|
||||
let remaining = area.width.saturating_sub(used_width) as usize;
|
||||
let notif_text = if notification_count > 0 {
|
||||
format!(" ● Notifications ({}) ", notification_count)
|
||||
} else {
|
||||
" ● Notifications ".to_string()
|
||||
};
|
||||
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(¬if_text, notif_style));
|
||||
|
||||
let notifications_area = Rect::new(notif_x, area.y, notif_width, 1);
|
||||
|
||||
let line = Line::from(spans);
|
||||
let paragraph = Paragraph::new(line).style(Style::default().bg(theme::BG1));
|
||||
frame.render_widget(paragraph, area);
|
||||
|
||||
tab_areas
|
||||
TopbarAreas {
|
||||
tabs: tab_areas,
|
||||
notifications: notifications_area,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,12 +70,11 @@ pub fn render_calendar(frame: &mut Frame, area: Rect, calendar: &[CalendarEntry]
|
||||
let events: Vec<CalendarEntry> = calendar
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
if let Some(day_str) = e.date.split('-').nth(2) {
|
||||
if let Ok(day) = day_str.parse::<u8>() {
|
||||
if let Some(day_str) = e.date.split('-').nth(2)
|
||||
&& let Ok(day) = day_str.parse::<u8>() {
|
||||
let month_match = e.date.contains(&format!("{:04}-{:02}", year, month));
|
||||
return month_match && day == d;
|
||||
}
|
||||
}
|
||||
false
|
||||
})
|
||||
.cloned()
|
||||
|
||||
Reference in New Issue
Block a user