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:
@@ -62,6 +62,7 @@
|
|||||||
rustfmt
|
rustfmt
|
||||||
|
|
||||||
protobuf
|
protobuf
|
||||||
|
grpcurl
|
||||||
|
|
||||||
opencode
|
opencode
|
||||||
];
|
];
|
||||||
|
|||||||
+244
-9
@@ -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(¬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) {
|
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
@@ -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
@@ -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
@@ -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,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
@@ -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(¤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;
|
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);
|
|
||||||
|
|
||||||
while self.notifications.len() > MAX_VISIBLE * 2 {
|
self.history.push(notification.clone());
|
||||||
self.notifications.remove(0);
|
if self.history.len() > MAX_HISTORY {
|
||||||
|
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) = ¬if.detail {
|
if let Some(detail) = ¬if.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(¬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();
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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(¬if_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,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user