almost downloading complete

This commit is contained in:
2026-02-16 14:35:00 -06:00
parent a1e1ae7e7d
commit 6d5b4eec1d
4 changed files with 929 additions and 5 deletions

View File

@@ -16,6 +16,9 @@ use std::os::unix::net::UnixStream;
use serde_json::Value;
use crate::core::downloads;
use crate::core::downloads::{DownloadCommand, DownloadManager, DownloadStatus};
use crossterm::{
event::{self, Event, KeyCode, KeyEventKind},
execute,
@@ -26,6 +29,7 @@ use ratatui::{
symbols::merge::MergeStrategy,
widgets::{Block, List, ListItem, ListState, Paragraph, Wrap, Gauge},
style::{Modifier, Style},
text::Line,
Frame, Terminal,
};
use ratatui::backend::CrosstermBackend;
@@ -175,6 +179,9 @@ struct App {
mpv_state_rx: Option<mpsc::Receiver<MpvState>>,
debug_mpv: bool,
mpv_now_playing: Option<String>,
downloads: Option<downloads::DownloadManager>,
downloads_focus: bool,
downloads_selected: usize,
autoplay: bool,
autoplay_view_depth: usize,
@@ -246,6 +253,9 @@ impl Default for App {
autoplay_next_index: None,
autoplay_eof_latched: false,
autoplay_prev_time_pos: None,
downloads: None,
downloads_focus: false,
downloads_selected: 0,
}
}
}
@@ -335,6 +345,11 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::Result<
last_tick = Instant::now();
}
}
if let Some(dm) = &app.downloads {
dm.send(downloads::DownloadCommand::Shutdown);
}
Ok(())
}
@@ -502,14 +517,20 @@ fn handle_key(app: &mut App, code: KeyCode) {
}
if matches!(code, KeyCode::Char('D')) {
app.debug_mpv = !app.debug_mpv;
app.status = format!("mpv debug: {}", if app.debug_mpv { "ON" } else { "OFF" });
app.downloads_focus = !app.downloads_focus;
app.downloads_selected = 0;
app.status = if app.downloads_focus { "downloads: focus".into() } else { "downloads: unfocus".into() };
return;
}
// mpv controls (only when not typing into search/login)
if app.search.is_none() && !matches!(app.current_view(), View::Login) {
match code {
KeyCode::Char('M') => {
app.debug_mpv = !app.debug_mpv;
app.status = format!("mpv debug: {}", if app.debug_mpv { "ON" } else { "OFF" });
return;
}
KeyCode::Char('a') => {
if let Some(mpv) = &app.mpv {
if let Err(e) = mpv_cycle_audio(mpv) {
@@ -754,7 +775,106 @@ fn handle_key(app: &mut App, code: KeyCode) {
}
}
// downloads focus overrides normal navigation
let mut handled_by_downloads = false;
if app.downloads_focus {
match code {
KeyCode::Up | KeyCode::Char('k') => {
if app.downloads_selected > 0 {
app.downloads_selected -= 1;
}
handled_by_downloads = true;
}
KeyCode::Down | KeyCode::Char('j') => {
let len = app
.downloads
.as_ref()
.and_then(|dm| dm.state.lock().ok())
.map(|st| st.queue.len())
.unwrap_or(0);
if len > 0 && app.downloads_selected + 1 < len {
app.downloads_selected += 1;
}
handled_by_downloads = true;
}
KeyCode::Esc => {
app.downloads_focus = false;
handled_by_downloads = true;
}
_ => {}
}
}
// If downloads handled the key, skip normal navigation handling
if handled_by_downloads {
return;
}
match code {
KeyCode::Char('d') => {
// downloads: focus => cancel/remove, otherwise enqueue selected item
if app.downloads_focus {
if let Some(dm) = &app.downloads {
let idx = app.downloads_selected;
if idx == 0 {
dm.send(downloads::DownloadCommand::Cancel { index: 0 });
app.status = "download: cancel active".into();
} else {
dm.send(downloads::DownloadCommand::RemoveQueued { index: idx });
app.status = "download: removed".into();
}
} else {
app.status = "downloads not available".into();
}
return;
}
let cfg = match &app.config {
Some(c) => c.clone(),
None => { app.status = "No config loaded".into(); return; }
};
let user_id = match cfg.user_id.as_deref() {
Some(u) => u,
None => { app.status = "No user_id (login first)".into(); return; }
};
let (it) = match app.current_view() {
View::Library { items, selected, .. } if !items.is_empty() => items[*selected].clone(),
_ => { app.status = "Nothing to download here".into(); return; }
};
let dm = match &app.downloads {
Some(dm) => dm,
None => {
// try start downloads now if we have a token
if cfg.access_token.is_some() {
app.downloads = Some(downloads::DownloadManager::start(cfg.clone()));
app.downloads.as_ref().unwrap()
} else {
app.status = "Not connected".into();
return;
}
}
};
let jf = match core::jellyfin::JellyfinClient::from_config(&cfg) {
Ok(c) => c,
Err(e) => { app.status = format!("downloads: {e}"); return; }
};
match downloads::expand_for_enqueue(&jf, user_id, &it) {
Ok(items) => {
let n = items.len();
dm.send(downloads::DownloadCommand::Enqueue(items));
app.status = format!("queued {n}");
}
Err(e) => app.status = format!("queue failed: {e}"),
}
return;
}
KeyCode::Char('q') => app.should_exit = true,
//Enter code Block
@@ -1358,6 +1478,8 @@ fn ui(frame: &mut Frame, app: &App) {
Paragraph::new(line).block(Block::bordered().merge_borders(MergeStrategy::Exact)),
panes.left_output,
);
} else if app.downloads_focus || app.downloads.as_ref().map(|d| d.state.lock().map(|s| !s.queue.is_empty()).unwrap_or(false)).unwrap_or(false) {
render_downloads_panel(frame, app, panes.left_output);
} else if app.mpv.is_some() {
// mpv panel takes over the whole bottom-left area
render_mpv_panel(frame, app, panes.left_output);
@@ -1382,7 +1504,10 @@ fn ui(frame: &mut Frame, app: &App) {
.unwrap_or("-");
let right_text = format!(
"{connected}\n\np/P: play/autoplay\nr/R: resume/autoplay\nq: quit\n/help: more help"
"{connected}\n\np/P: play/autoplay
r/R: resume/autoplay
d: download / cancel
D: focus downloads\nq: quit\n/help: more help"
);
frame.render_widget(
@@ -2208,6 +2333,89 @@ fn mpv_progress(st: &MpvState) -> (u16, String) {
(p_u16, format!("{cur} / {dur} ({pct:0.1}%)"))
}
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",
};
let active_prefix = if i == 0 { "" } else { " " };
let prog = if matches!(it.status, DownloadStatus::Downloading) {
format!(" {}B", it.bytes_downloaded)
} else {
String::new()
};
rows.push(ListItem::new(format!(
"{active_prefix}{status}: {}{prog}",
it.title
)));
}
// Optional footer message (only if we still have room)
if let Some(msg) = &st.last_message {
if rows.len() < max_rows {
rows.push(ListItem::new(format!("{msg}")));
}
}
}
}
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 {
"Downloads (focus)"
} else {
"Downloads"
};
let list = List::new(rows)
.block(Block::bordered().title(title).merge_borders(MergeStrategy::Exact))
.highlight_symbol(">> ")
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
// THIS is the missing piece: selection state for the list
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);
}
fn render_mpv_panel(frame: &mut Frame, app: &App, area: Rect) {
// Make the bar exactly 1 row and don't depend on bordered blocks.
let [c0, c1, c2, c3, c4, c5] = Layout::vertical([