almost downloading complete
This commit is contained in:
214
src/main.rs
214
src/main.rs
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user