Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Id14b6f82d3b9f3c27bee9c214a1bdedc6a6a6964
238 lines
7 KiB
Rust
238 lines
7 KiB
Rust
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<Line> = 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);
|
|
}
|