pub mod admin; pub mod audit; pub mod books; pub mod collections; pub mod database; pub mod detail; pub mod duplicates; pub mod import; pub mod library; pub mod metadata_edit; pub mod playlists; pub mod queue; pub mod search; pub mod settings; pub mod statistics; pub mod tags; pub mod tasks; use ratatui::{ Frame, layout::{Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Paragraph, Tabs}, }; use crate::app::{AppState, View}; /// Format a file size in bytes into a human-readable string. #[expect( clippy::cast_precision_loss, reason = "file sizes beyond 2^52 bytes are unlikely in practice" )] pub fn format_size(bytes: u64) -> String { if bytes < 1024 { format!("{bytes} B") } else if bytes < 1024 * 1024 { format!("{:.1} KB", bytes as f64 / 1024.0) } else if bytes < 1024 * 1024 * 1024 { format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)) } else { format!("{:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0)) } } /// Format duration in seconds into hh:mm:ss format. #[expect( clippy::cast_possible_truncation, clippy::cast_sign_loss, reason = "duration seconds are always non-negative and within u64 range" )] pub fn format_duration(secs: f64) -> String { let total = secs as u64; let h = total / 3600; let m = (total % 3600) / 60; let s = total % 60; if h > 0 { format!("{h:02}:{m:02}:{s:02}") } else { format!("{m:02}:{s:02}") } } /// Trim a timestamp string to just the date portion (YYYY-MM-DD). pub fn format_date(timestamp: &str) -> &str { // Timestamps are typically "2024-01-15T10:30:00Z" or similar if timestamp.len() >= 10 { ×tamp[..10] } else { timestamp } } /// Return a color based on media type string. pub fn media_type_color(media_type: &str) -> Color { match media_type { t if t.starts_with("audio") => Color::Green, t if t.starts_with("video") => Color::Magenta, t if t.starts_with("image") => Color::Yellow, t if t.starts_with("application/pdf") => Color::Red, t if t.starts_with("text") => Color::Cyan, _ => Color::White, } } pub fn render(f: &mut Frame, state: &AppState) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(3), Constraint::Min(0), Constraint::Length(1), ]) .split(f.area()); render_tabs(f, state, chunks[0]); match state.current_view { View::Library => library::render(f, state, chunks[1]), View::Search => search::render(f, state, chunks[1]), View::Detail => detail::render(f, state, chunks[1]), View::Tags => tags::render(f, state, chunks[1]), View::Collections => collections::render(f, state, chunks[1]), View::Audit => audit::render(f, state, chunks[1]), View::Import => import::render(f, state, chunks[1]), View::Settings => settings::render(f, state, chunks[1]), View::Duplicates => duplicates::render(f, state, chunks[1]), View::Database => database::render(f, state, chunks[1]), View::MetadataEdit => metadata_edit::render(f, state, chunks[1]), View::Queue => queue::render(f, state, chunks[1]), View::Statistics => statistics::render(f, state, chunks[1]), View::Tasks => tasks::render(f, state, chunks[1]), View::Books => books::render(f, state, chunks[1]), View::Playlists => playlists::render(f, state, chunks[1]), View::Admin => admin::render(f, state, chunks[1]), } render_status_bar(f, state, chunks[2]); } fn render_tabs(f: &mut Frame, state: &AppState, area: Rect) { let titles: Vec = vec![ "Library", "Search", "Tags", "Collections", "Audit", "Books", "Queue", "Stats", "Tasks", "Playlists", "Admin", ] .into_iter() .map(|t| Line::from(Span::styled(t, Style::default().fg(Color::White)))) .collect(); let selected = match state.current_view { View::Library | View::Detail | View::Import | View::Settings | View::MetadataEdit => 0, View::Search => 1, View::Tags => 2, View::Collections => 3, View::Audit | View::Duplicates | View::Database => 4, View::Books => 5, View::Queue => 6, View::Statistics => 7, View::Tasks => 8, View::Playlists => 9, View::Admin => 10, }; let tabs = Tabs::new(titles) .block(Block::default().borders(Borders::ALL).title(" Pinakes ")) .select(selected) .style(Style::default().fg(Color::Gray)) .highlight_style( Style::default() .fg(Color::Cyan) .add_modifier(Modifier::BOLD), ); f.render_widget(tabs, area); } fn render_status_bar(f: &mut Frame, state: &AppState, area: Rect) { let status = state.status_message.as_ref().map_or_else( || { match state.current_view { View::Tags => { " q:Quit j/k:Nav Home/End:Top/Bot n:New d:Delete r:Refresh \ Tab:Switch" .to_string() }, View::Collections => { " q:Quit j/k:Nav Home/End:Top/Bot d:Delete r:Refresh Tab:Switch" .to_string() }, View::Audit => { " q:Quit j/k:Nav Home/End:Top/Bot r:Refresh Tab:Switch" .to_string() }, View::Detail => { " q:Quit Esc:Back o:Open e:Edit p:Page +:Tag -:Untag f:Fav \ R:Rate c:Comment E:Enrich U:Subtitles t:Transcode r:Refresh" .to_string() }, View::Playlists => { " q:Quit j/k:Nav n:New d:Delete Enter:Items S:Shuffle Esc:Back" .to_string() }, View::Admin => " q:Quit j/k:Nav Tab:Switch tab d:Del user/device \ w:Test webhook r:Refresh Esc:Back" .to_string(), View::Import => { " Enter:Import Esc:Cancel s:Scan libraries ?:Help".to_string() }, View::Settings => " q:Quit Esc:Back ?:Help".to_string(), View::Duplicates => " q:Quit j/k:Nav r:Refresh Esc:Back".to_string(), View::Database => " q:Quit v:Vacuum r:Refresh Esc:Back".to_string(), View::MetadataEdit => { " Tab:Next field Enter:Save Esc:Cancel".to_string() }, View::Queue => { " q:Quit j/k:Nav Enter:Play d:Remove N:Next P:Prev R:Repeat \ S:Shuffle C:Clear" .to_string() }, View::Statistics => " q:Quit r:Refresh Esc:Back ?:Help".to_string(), View::Tasks => { " q:Quit j/k:Nav Enter:Toggle R:Run Now r:Refresh Esc:Back" .to_string() }, View::Books => { " q:Quit j/k:Nav Home/End:Top/Bot Tab:Sub-view r:Refresh \ Esc:Back" .to_string() }, _ => { " q:Quit /:Search i:Import o:Open t:Tags c:Coll a:Audit \ b:Books D:Dupes B:DB Q:Queue X:Stats T:Tasks ?:Help" .to_string() }, } }, String::clone, ); let paragraph = Paragraph::new(Line::from(Span::styled( status, Style::default().fg(Color::DarkGray), ))); f.render_widget(paragraph, area); }