Download Functionality Feature Complete

This commit is contained in:
2026-02-16 15:44:26 -06:00
parent 1a8ed911d5
commit 6fe294f19b

View File

@@ -1562,7 +1562,8 @@ fn ui(frame: &mut Frame, app: &App) {
"{connected}\n\np/P: play/autoplay "{connected}\n\np/P: play/autoplay
r/R: resume/autoplay r/R: resume/autoplay
d: download / cancel d: download / cancel
D: focus downloads\nq: quit\n/help: more help" q: quit
/help: more help"
); );
frame.render_widget( frame.render_widget(
@@ -2389,82 +2390,134 @@ fn mpv_progress(st: &MpvState) -> (u16, String) {
} }
fn render_downloads_panel(frame: &mut Frame, app: &App, area: Rect) { fn render_downloads_panel(frame: &mut Frame, app: &App, area: Rect) {
// Build rows
let mut rows: Vec<ListItem> = Vec::new();
if let Some(dm) = &app.downloads {
match dm.state.lock() {
Ok(st) => {
if st.queue.is_empty() {
rows.push(ListItem::new("(empty)"));
} else {
// Fit to available height inside borders
let max_rows = area.height.saturating_sub(2) as usize;
for (i, it) in st.queue.iter().enumerate().take(max_rows) {
let status = match &it.status {
DownloadStatus::Queued => "queued",
DownloadStatus::Downloading => "downloading",
DownloadStatus::Finalizing => "finalizing",
DownloadStatus::Completed => "done",
DownloadStatus::Cancelled => "cancelled",
DownloadStatus::Failed(_) => "failed",
};
// Always mark the active (front) item with ▶ like before
let active_prefix = if i == 0 { "" } else { " " };
// If downloading, show "NN% (ETA)" BEFORE the title
let prog_prefix = if matches!(it.status, DownloadStatus::Downloading) {
format_download_pct_eta(it.bytes_downloaded, it.bytes_total, it.added_at)
} else {
String::new()
};
rows.push(ListItem::new(format!(
"{active_prefix}{status}: {prog_prefix}{}",
it.title
)));
}
// NOTE: intentionally removed the footer "— {msg}" line
// because you said its unnecessary.
}
}
Err(_) => rows.push(ListItem::new("(downloads lock poisoned)")),
}
} else {
rows.push(ListItem::new("(downloads disabled)"));
}
// Create the list widget
let title = if app.downloads_focus { let title = if app.downloads_focus {
"Downloads (focus)" "Downloads (focus)"
} else { } else {
"Downloads" "Downloads"
}; };
// rows available inside the border
let viewport = area.height.saturating_sub(2) as usize;
let mut rows: Vec<ListItem> = Vec::new();
let mut state = ListState::default();
// Helper: "basic" scrolling window (no centering)
// Ensures selected is always visible within [start, start+viewport)
fn basic_window(len: usize, selected: usize, viewport: usize) -> (usize, usize) {
if len == 0 || viewport == 0 {
return (0, 0);
}
let viewport = viewport.min(len);
let mut start = 0usize;
// If selected is below the visible window, scroll down just enough
if selected >= start + viewport {
start = selected + 1 - viewport;
}
// If selected is above the visible window, scroll up just enough
if selected < start {
start = selected;
}
// Clamp
if start + viewport > len {
start = len - viewport;
}
(start, start + viewport)
}
// Empty states
if app.downloads.is_none() {
rows.push(ListItem::new("(downloads disabled)"));
state.select(None);
let list = List::new(rows)
.block(Block::bordered().title(title).merge_borders(MergeStrategy::Exact))
.highlight_symbol(">> ")
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
frame.render_stateful_widget(list, area, &mut state);
return;
}
let dm = app.downloads.as_ref().unwrap();
match dm.state.lock() {
Err(_) => {
rows.push(ListItem::new("(downloads lock poisoned)"));
state.select(None);
}
Ok(st) => {
let len = st.queue.len();
if len == 0 {
rows.push(ListItem::new("(empty)"));
state.select(None);
} else {
// Clamp selection
let selected = app.downloads_selected.min(len.saturating_sub(1));
// Basic scrolling window
let (start, end) = basic_window(len, selected, viewport);
for i in start..end {
let it = match st.queue.get(i) {
Some(v) => v,
None => continue,
};
let status = match &it.status {
DownloadStatus::Queued => "queued",
DownloadStatus::Downloading => "downloading",
DownloadStatus::Finalizing => "finalizing",
DownloadStatus::Completed => "done",
DownloadStatus::Cancelled => "cancelled",
DownloadStatus::Failed(_) => "failed",
};
let active_prefix = if i == 0 { "" } else { " " };
// Prefer aria2-derived info if you have it; otherwise it will show --% (--:--)
let prog_prefix = if matches!(it.status, DownloadStatus::Downloading) {
// uses the fields you now populate from aria2 RPC
// (bytes_downloaded, bytes_total, eta_secs)
let pct_str = match it.bytes_total {
Some(t) if t > 0 => {
let pct = (it.bytes_downloaded as f64 / t as f64) * 100.0;
format!("{:0.0}%", pct.clamp(0.0, 100.0))
}
_ => "--%".to_string(),
};
let eta_str = match it.eta_secs {
Some(s) => fmt_hms(s as f64),
None => "--:--".to_string(),
};
format!("{pct_str} ({eta_str}) ")
} else {
String::new()
};
rows.push(ListItem::new(format!(
"{active_prefix}{status}: {prog_prefix}{}",
it.title
)));
}
// selection must be relative to the window slice
state.select(Some(selected - start));
}
}
}
let list = List::new(rows) let list = List::new(rows)
.block(Block::bordered().title(title).merge_borders(MergeStrategy::Exact)) .block(Block::bordered().title(title).merge_borders(MergeStrategy::Exact))
.highlight_symbol(">> ") .highlight_symbol(">> ")
.highlight_style(Style::default().add_modifier(Modifier::REVERSED)); .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
let mut state = ListState::default();
// Clamp selection so it never goes out of bounds
let len = app
.downloads
.as_ref()
.and_then(|dm| dm.state.lock().ok().map(|st| st.queue.len()))
.unwrap_or(0);
if len == 0 {
state.select(None);
} else {
let sel = app.downloads_selected.min(len.saturating_sub(1));
state.select(Some(sel));
}
frame.render_stateful_widget(list, area, &mut state); frame.render_stateful_widget(list, area, &mut state);
} }