feat: add download info pane to TUI
Splits the downloads area 60/40 with a new info pane that adapts to the selected download's status: header (filename, user, size, status, save path, bitrate, length) plus queued-specific fields (queue position, peer slots, peer upload speed) or live progress/speed/ETA when transferring. Plumbs queue position via a new ClientOperation::PlaceInQueueUpdate so PlaceInQueueResponse messages from peers reach DownloadStore, and threads search-time metadata (bitrate, length, peer slots/speed) onto Download through a new download_with_metadata entry point.
This commit is contained in:
@@ -245,6 +245,18 @@ impl PeerActor {
|
|||||||
"[peer:{}] Place in queue response - file: {}, place: {}",
|
"[peer:{}] Place in queue response - file: {}, place: {}",
|
||||||
username, filename, place
|
username, filename, place
|
||||||
);
|
);
|
||||||
|
if let Err(e) =
|
||||||
|
self.client_channel.send(ClientOperation::PlaceInQueueUpdate {
|
||||||
|
username: username.clone(),
|
||||||
|
filename,
|
||||||
|
place,
|
||||||
|
})
|
||||||
|
{
|
||||||
|
error!(
|
||||||
|
"[peer:{}] failed to forward PlaceInQueueUpdate: {}",
|
||||||
|
username, e
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
PeerMessage::SetUsername(username) => {
|
PeerMessage::SetUsername(username) => {
|
||||||
trace!(
|
trace!(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use crate::actor::ActorHandle;
|
use crate::actor::ActorHandle;
|
||||||
use crate::actor::server_actor::{PeerAddress, ServerActor, ServerMessage};
|
use crate::actor::server_actor::{PeerAddress, ServerActor, ServerMessage};
|
||||||
use crate::download_store::{DownloadStore, collect_failed_tokens};
|
use crate::download_store::{DownloadStore, collect_failed_tokens};
|
||||||
use crate::types::DownloadStatus;
|
use crate::types::{DownloadMetadata, DownloadStatus};
|
||||||
use crate::utils::logger;
|
use crate::utils::logger;
|
||||||
use crate::{
|
use crate::{
|
||||||
Transfer,
|
Transfer,
|
||||||
@@ -84,6 +84,11 @@ pub enum ClientOperation {
|
|||||||
obfuscated_port: u16,
|
obfuscated_port: u16,
|
||||||
},
|
},
|
||||||
UploadFailed(String, String),
|
UploadFailed(String, String),
|
||||||
|
PlaceInQueueUpdate {
|
||||||
|
username: String,
|
||||||
|
filename: String,
|
||||||
|
place: u32,
|
||||||
|
},
|
||||||
SetServerSender(Sender<ServerMessage>),
|
SetServerSender(Sender<ServerMessage>),
|
||||||
}
|
}
|
||||||
pub struct ClientContext {
|
pub struct ClientContext {
|
||||||
@@ -159,6 +164,8 @@ fn test_client_context_downloads() {
|
|||||||
download_directory: "test".to_string(),
|
download_directory: "test".to_string(),
|
||||||
status: DownloadStatus::Queued,
|
status: DownloadStatus::Queued,
|
||||||
sender: mpsc::channel().0,
|
sender: mpsc::channel().0,
|
||||||
|
queue_position: None,
|
||||||
|
metadata: Default::default(),
|
||||||
};
|
};
|
||||||
context.add_download(download);
|
context.add_download(download);
|
||||||
assert!(context.get_download_by_token(123).is_some());
|
assert!(context.get_download_by_token(123).is_some());
|
||||||
@@ -191,6 +198,8 @@ fn test_client_pause_and_resume_download() {
|
|||||||
speed_bytes_per_sec: 10.0,
|
speed_bytes_per_sec: 10.0,
|
||||||
},
|
},
|
||||||
sender: download_sender,
|
sender: download_sender,
|
||||||
|
queue_position: None,
|
||||||
|
metadata: Default::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
client.context.write().unwrap().add_download(download);
|
client.context.write().unwrap().add_download(download);
|
||||||
@@ -245,6 +254,8 @@ fn test_client_removes_only_queued_downloads() {
|
|||||||
download_directory: "test".to_string(),
|
download_directory: "test".to_string(),
|
||||||
status: DownloadStatus::Queued,
|
status: DownloadStatus::Queued,
|
||||||
sender: mpsc::channel().0,
|
sender: mpsc::channel().0,
|
||||||
|
queue_position: None,
|
||||||
|
metadata: Default::default(),
|
||||||
};
|
};
|
||||||
let active_download = Download {
|
let active_download = Download {
|
||||||
username: "peer".to_string(),
|
username: "peer".to_string(),
|
||||||
@@ -258,6 +269,8 @@ fn test_client_removes_only_queued_downloads() {
|
|||||||
speed_bytes_per_sec: 10.0,
|
speed_bytes_per_sec: 10.0,
|
||||||
},
|
},
|
||||||
sender: mpsc::channel().0,
|
sender: mpsc::channel().0,
|
||||||
|
queue_position: None,
|
||||||
|
metadata: Default::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -556,6 +569,23 @@ impl Client {
|
|||||||
username: String,
|
username: String,
|
||||||
size: u64,
|
size: u64,
|
||||||
download_directory: String,
|
download_directory: String,
|
||||||
|
) -> Result<(Download, Receiver<DownloadStatus>)> {
|
||||||
|
self.download_with_metadata(
|
||||||
|
filename,
|
||||||
|
username,
|
||||||
|
size,
|
||||||
|
download_directory,
|
||||||
|
DownloadMetadata::default(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn download_with_metadata(
|
||||||
|
&self,
|
||||||
|
filename: String,
|
||||||
|
username: String,
|
||||||
|
size: u64,
|
||||||
|
download_directory: String,
|
||||||
|
metadata: DownloadMetadata,
|
||||||
) -> Result<(Download, Receiver<DownloadStatus>)> {
|
) -> Result<(Download, Receiver<DownloadStatus>)> {
|
||||||
info!("[client] Downloading {} from {}", filename, username);
|
info!("[client] Downloading {} from {}", filename, username);
|
||||||
|
|
||||||
@@ -575,6 +605,8 @@ impl Client {
|
|||||||
download_directory,
|
download_directory,
|
||||||
status: DownloadStatus::Queued,
|
status: DownloadStatus::Queued,
|
||||||
sender: download_sender,
|
sender: download_sender,
|
||||||
|
queue_position: None,
|
||||||
|
metadata,
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut context = self.context.write_safe()?;
|
let mut context = self.context.write_safe()?;
|
||||||
@@ -1025,6 +1057,8 @@ impl Client {
|
|||||||
.download_directory,
|
.download_directory,
|
||||||
status: download.status.clone(),
|
status: download.status.clone(),
|
||||||
sender: download.sender.clone(),
|
sender: download.sender.clone(),
|
||||||
|
queue_position: download.queue_position,
|
||||||
|
metadata: download.metadata.clone(),
|
||||||
});
|
});
|
||||||
context.remove_download(old_token);
|
context.remove_download(old_token);
|
||||||
}
|
}
|
||||||
@@ -1039,6 +1073,29 @@ impl Client {
|
|||||||
Some(&filename),
|
Some(&filename),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
ClientOperation::PlaceInQueueUpdate {
|
||||||
|
username,
|
||||||
|
filename,
|
||||||
|
place,
|
||||||
|
} => match client_context.write_safe() {
|
||||||
|
Ok(mut ctx) => {
|
||||||
|
let updated = ctx
|
||||||
|
.downloads
|
||||||
|
.update_queue_position(
|
||||||
|
&username, &filename, place,
|
||||||
|
);
|
||||||
|
if !updated {
|
||||||
|
debug!(
|
||||||
|
"[client] PlaceInQueueUpdate: no matching download for {}/{}",
|
||||||
|
username, filename
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => error!(
|
||||||
|
"[client] PlaceInQueueUpdate write: {}",
|
||||||
|
e
|
||||||
|
),
|
||||||
|
},
|
||||||
ClientOperation::SetServerSender(sender) => {
|
ClientOperation::SetServerSender(sender) => {
|
||||||
match client_context.write_safe() {
|
match client_context.write_safe() {
|
||||||
Ok(mut ctx) => {
|
Ok(mut ctx) => {
|
||||||
|
|||||||
@@ -50,6 +50,19 @@ impl DownloadStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn update_queue_position(
|
||||||
|
&mut self,
|
||||||
|
username: &str,
|
||||||
|
filename: &str,
|
||||||
|
position: u32,
|
||||||
|
) -> bool {
|
||||||
|
let Some(download) = self.get_by_file_mut(username, filename) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
download.queue_position = Some(position);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
pub fn remove_queued_by_file(
|
pub fn remove_queued_by_file(
|
||||||
&mut self,
|
&mut self,
|
||||||
username: &str,
|
username: &str,
|
||||||
@@ -150,6 +163,8 @@ mod tests {
|
|||||||
download_directory: "test".to_string(),
|
download_directory: "test".to_string(),
|
||||||
status,
|
status,
|
||||||
sender: mpsc::channel().0,
|
sender: mpsc::channel().0,
|
||||||
|
queue_position: None,
|
||||||
|
metadata: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +182,24 @@ mod tests {
|
|||||||
assert!(store.list().is_empty());
|
assert!(store.list().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_queue_position_sets_field_when_match() {
|
||||||
|
let mut store = DownloadStore::new();
|
||||||
|
let mut download = make_download(1, DownloadStatus::Queued);
|
||||||
|
download.username = "peer".to_string();
|
||||||
|
download.filename = "song.mp3".to_string();
|
||||||
|
store.add(download);
|
||||||
|
|
||||||
|
assert!(store.update_queue_position("peer", "song.mp3", 42));
|
||||||
|
assert_eq!(
|
||||||
|
store.get_by_token(1).unwrap().queue_position,
|
||||||
|
Some(42)
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(!store.update_queue_position("peer", "missing.mp3", 1));
|
||||||
|
assert!(!store.update_queue_position("other", "song.mp3", 1));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pause_then_resume_in_progress_download() {
|
fn pause_then_resume_in_progress_download() {
|
||||||
let mut store = DownloadStore::new();
|
let mut store = DownloadStore::new();
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
use std::env;
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::{self, Read, Write};
|
use std::io::{self, Read, Write};
|
||||||
use std::net::TcpStream;
|
use std::net::TcpStream;
|
||||||
@@ -12,6 +11,7 @@ use crate::client::ClientContext;
|
|||||||
use crate::message::server::MessageFactory;
|
use crate::message::server::MessageFactory;
|
||||||
use crate::trace;
|
use crate::trace;
|
||||||
use crate::types::{Download, DownloadStatus};
|
use crate::types::{Download, DownloadStatus};
|
||||||
|
use crate::utils::path::expand_tilde;
|
||||||
|
|
||||||
const START_DOWNLOAD: [u8; 8] =
|
const START_DOWNLOAD: [u8; 8] =
|
||||||
[0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
|
[0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||||
@@ -72,18 +72,6 @@ impl From<io::Error> for DownloadError {
|
|||||||
struct FileManager;
|
struct FileManager;
|
||||||
|
|
||||||
impl FileManager {
|
impl FileManager {
|
||||||
fn expand_path(path: &str) -> PathBuf {
|
|
||||||
if let Some(stripped) = path.strip_prefix('~') {
|
|
||||||
if let Ok(home) = env::var("HOME") {
|
|
||||||
PathBuf::from(home).join(stripped.trim_start_matches('/'))
|
|
||||||
} else {
|
|
||||||
PathBuf::from(path)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
PathBuf::from(path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_filename_from_path(full_path: &str) -> &str {
|
fn extract_filename_from_path(full_path: &str) -> &str {
|
||||||
full_path
|
full_path
|
||||||
.split(['/', '\\'])
|
.split(['/', '\\'])
|
||||||
@@ -414,7 +402,7 @@ impl DownloadPeer {
|
|||||||
download: &Download,
|
download: &Download,
|
||||||
) -> Result<String, DownloadError> {
|
) -> Result<String, DownloadError> {
|
||||||
let download_directory = &download.download_directory;
|
let download_directory = &download.download_directory;
|
||||||
let mut expanded_path = FileManager::expand_path(download_directory);
|
let mut expanded_path = expand_tilde(download_directory);
|
||||||
|
|
||||||
if !expanded_path.is_dir() {
|
if !expanded_path.is_dir() {
|
||||||
expanded_path = expanded_path
|
expanded_path = expanded_path
|
||||||
|
|||||||
@@ -87,6 +87,14 @@ pub struct Transfer {
|
|||||||
pub filename: String,
|
pub filename: String,
|
||||||
pub size: u64,
|
pub size: u64,
|
||||||
}
|
}
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct DownloadMetadata {
|
||||||
|
pub bitrate: Option<u32>,
|
||||||
|
pub length_seconds: Option<u32>,
|
||||||
|
pub peer_upload_speed: Option<u32>,
|
||||||
|
pub peer_free_slots: Option<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Download {
|
pub struct Download {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
@@ -96,6 +104,8 @@ pub struct Download {
|
|||||||
pub download_directory: String,
|
pub download_directory: String,
|
||||||
pub status: DownloadStatus,
|
pub status: DownloadStatus,
|
||||||
pub sender: Sender<DownloadStatus>,
|
pub sender: Sender<DownloadStatus>,
|
||||||
|
pub queue_position: Option<u32>,
|
||||||
|
pub metadata: DownloadMetadata,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Download {
|
impl Download {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
pub mod logger;
|
pub mod logger;
|
||||||
pub mod lock;
|
pub mod lock;
|
||||||
pub mod md5;
|
pub mod md5;
|
||||||
|
pub mod path;
|
||||||
pub mod thread_pool;
|
pub mod thread_pool;
|
||||||
pub mod zlib;
|
pub mod zlib;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
pub fn expand_tilde(path: &str) -> PathBuf {
|
||||||
|
if let Some(stripped) = path.strip_prefix('~')
|
||||||
|
&& let Ok(home) = std::env::var("HOME")
|
||||||
|
{
|
||||||
|
return PathBuf::from(home).join(stripped.trim_start_matches('/'));
|
||||||
|
}
|
||||||
|
PathBuf::from(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_tilde_returned_as_is() {
|
||||||
|
assert_eq!(expand_tilde("/abs/path"), PathBuf::from("/abs/path"));
|
||||||
|
assert_eq!(expand_tilde("rel/path"), PathBuf::from("rel/path"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tilde_expanded_when_home_set() {
|
||||||
|
// SAFETY: tests run single-threaded by default in this crate; if you
|
||||||
|
// ever run with --test-threads >1 this needs serialization.
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var("HOME", "/home/test");
|
||||||
|
}
|
||||||
|
assert_eq!(expand_tilde("~"), PathBuf::from("/home/test"));
|
||||||
|
assert_eq!(
|
||||||
|
expand_tilde("~/Downloads"),
|
||||||
|
PathBuf::from("/home/test/Downloads")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,4 +6,5 @@ pub struct FileDisplayData {
|
|||||||
pub speed: u32,
|
pub speed: u32,
|
||||||
pub slots: u8,
|
pub slots: u8,
|
||||||
pub bitrate: Option<u32>,
|
pub bitrate: Option<u32>,
|
||||||
|
pub length_seconds: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ impl FileSelector {
|
|||||||
speed: result.speed,
|
speed: result.speed,
|
||||||
slots: result.slots,
|
slots: result.slots,
|
||||||
bitrate: get_bitrate(&file.attribs),
|
bitrate: get_bitrate(&file.attribs),
|
||||||
|
length_seconds: file.attribs.get(&1).copied(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ use crate::models::{
|
|||||||
SearchStatus,
|
SearchStatus,
|
||||||
};
|
};
|
||||||
use crate::ui::panes::{
|
use crate::ui::panes::{
|
||||||
ResultsPaneParams, render_downloads_pane, render_results_pane,
|
ResultsPaneParams, render_download_info_pane, render_downloads_pane,
|
||||||
render_searches_pane,
|
render_results_pane, render_searches_pane,
|
||||||
};
|
};
|
||||||
use crate::ui::{
|
use crate::ui::{
|
||||||
border_style, border_type, format_shortcuts_styled, render_download_stats,
|
border_style, border_type, format_shortcuts_styled, render_download_stats,
|
||||||
@@ -151,9 +151,16 @@ impl MainTui {
|
|||||||
])
|
])
|
||||||
.split(content_chunks[1]);
|
.split(content_chunks[1]);
|
||||||
|
|
||||||
|
// Split bottom-right into Downloads list and Info pane
|
||||||
|
let downloads_chunks = Layout::horizontal([
|
||||||
|
Constraint::Percentage(60), // Downloads list
|
||||||
|
Constraint::Percentage(40), // Info pane
|
||||||
|
])
|
||||||
|
.split(right_chunks[1]);
|
||||||
|
|
||||||
// Store results and downloads pane areas
|
// Store results and downloads pane areas
|
||||||
self.state.results_pane_area = Some(right_chunks[0]);
|
self.state.results_pane_area = Some(right_chunks[0]);
|
||||||
self.state.downloads_pane_area = Some(right_chunks[1]);
|
self.state.downloads_pane_area = Some(downloads_chunks[0]);
|
||||||
|
|
||||||
// Render Results pane
|
// Render Results pane
|
||||||
let results_items = if self.state.results_filter_query.is_empty() {
|
let results_items = if self.state.results_filter_query.is_empty() {
|
||||||
@@ -185,12 +192,25 @@ impl MainTui {
|
|||||||
// Render Downloads pane
|
// Render Downloads pane
|
||||||
render_downloads_pane(
|
render_downloads_pane(
|
||||||
frame,
|
frame,
|
||||||
right_chunks[1],
|
downloads_chunks[0],
|
||||||
&self.state.downloads,
|
&self.state.downloads,
|
||||||
&mut self.state.downloads_table_state,
|
&mut self.state.downloads_table_state,
|
||||||
self.state.focused_pane == FocusedPane::Downloads,
|
self.state.focused_pane == FocusedPane::Downloads,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Render Info pane next to downloads
|
||||||
|
let selected_download = self
|
||||||
|
.state
|
||||||
|
.downloads_table_state
|
||||||
|
.selected()
|
||||||
|
.and_then(|idx| self.state.downloads.get(idx));
|
||||||
|
render_download_info_pane(
|
||||||
|
frame,
|
||||||
|
downloads_chunks[1],
|
||||||
|
selected_download,
|
||||||
|
self.state.focused_pane == FocusedPane::Downloads,
|
||||||
|
);
|
||||||
|
|
||||||
// Render command bar if active (vim-style, above shortcuts)
|
// Render command bar if active (vim-style, above shortcuts)
|
||||||
if self.state.command_bar_active {
|
if self.state.command_bar_active {
|
||||||
self.render_command_bar(frame, main_chunks[2]);
|
self.render_command_bar(frame, main_chunks[2]);
|
||||||
@@ -906,6 +926,7 @@ impl MainTui {
|
|||||||
speed: result.speed,
|
speed: result.speed,
|
||||||
slots: result.slots,
|
slots: result.slots,
|
||||||
bitrate: file.attribs.get(&0).copied(),
|
bitrate: file.attribs.get(&0).copied(),
|
||||||
|
length_seconds: file.attribs.get(&1).copied(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -933,14 +954,12 @@ impl MainTui {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn queue_selected_downloads(&mut self) {
|
fn queue_selected_downloads(&mut self) {
|
||||||
let selected_files: Vec<(String, String, u64)> = self
|
let selected_files: Vec<FileDisplayData> = self
|
||||||
.state
|
.state
|
||||||
.results_selected_indices
|
.results_selected_indices
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|&idx| self.state.results_items.get(idx))
|
.filter_map(|&idx| self.state.results_items.get(idx))
|
||||||
.map(|file| {
|
.cloned()
|
||||||
(file.filename.clone(), file.username.clone(), file.size)
|
|
||||||
})
|
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if selected_files.is_empty() {
|
if selected_files.is_empty() {
|
||||||
@@ -958,12 +977,19 @@ impl MainTui {
|
|||||||
let sender = self.state.downloads_sender_channel.clone().unwrap();
|
let sender = self.state.downloads_sender_channel.clone().unwrap();
|
||||||
|
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
for (filename, username, size) in selected_files.into_iter() {
|
for file in selected_files.into_iter() {
|
||||||
match client.download(
|
let metadata = soulseek_rs::types::DownloadMetadata {
|
||||||
filename.clone(),
|
bitrate: file.bitrate,
|
||||||
username.clone(),
|
length_seconds: file.length_seconds,
|
||||||
size,
|
peer_upload_speed: Some(file.speed),
|
||||||
|
peer_free_slots: Some(file.slots),
|
||||||
|
};
|
||||||
|
match client.download_with_metadata(
|
||||||
|
file.filename.clone(),
|
||||||
|
file.username.clone(),
|
||||||
|
file.size,
|
||||||
download_dir.clone(),
|
download_dir.clone(),
|
||||||
|
metadata,
|
||||||
) {
|
) {
|
||||||
Ok((download, rx)) => {
|
Ok((download, rx)) => {
|
||||||
let _ = sender.send((download, rx));
|
let _ = sender.send((download, rx));
|
||||||
@@ -971,7 +997,7 @@ impl MainTui {
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"Failed to start download for {}: {}",
|
"Failed to start download for {}: {}",
|
||||||
filename, e
|
file.filename, e
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
use crate::models::DownloadEntry;
|
||||||
|
use crate::ui::{
|
||||||
|
border_style, border_type, dimmed_style, error_style, format_bytes,
|
||||||
|
format_progress_bar, format_speed, info_style, inactive_style,
|
||||||
|
primary_style, success_style, warning_style,
|
||||||
|
};
|
||||||
|
use ratatui::{
|
||||||
|
Frame,
|
||||||
|
layout::Rect,
|
||||||
|
style::Style,
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Borders, Paragraph, Wrap},
|
||||||
|
};
|
||||||
|
use soulseek_rs::DownloadStatus;
|
||||||
|
use soulseek_rs::utils::path::expand_tilde;
|
||||||
|
|
||||||
|
const LABEL_WIDTH: usize = 20;
|
||||||
|
|
||||||
|
pub fn render_download_info_pane(
|
||||||
|
frame: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
selected: Option<&DownloadEntry>,
|
||||||
|
focused: bool,
|
||||||
|
) {
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(border_style(focused))
|
||||||
|
.border_type(border_type(focused))
|
||||||
|
.title("[Info]");
|
||||||
|
|
||||||
|
let Some(entry) = selected else {
|
||||||
|
let paragraph = Paragraph::new(
|
||||||
|
Line::from(Span::styled(
|
||||||
|
"Select a download for details.",
|
||||||
|
dimmed_style(),
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.block(block);
|
||||||
|
frame.render_widget(paragraph, area);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let lines = build_info_lines(&entry.download);
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new(lines).block(block).wrap(Wrap { trim: false });
|
||||||
|
frame.render_widget(paragraph, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_info_lines(
|
||||||
|
download: &soulseek_rs::types::Download,
|
||||||
|
) -> Vec<Line<'static>> {
|
||||||
|
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||||
|
|
||||||
|
let (basename, parent_dir) = split_filename(&download.filename);
|
||||||
|
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
basename,
|
||||||
|
primary_style().add_modifier(ratatui::style::Modifier::BOLD),
|
||||||
|
)));
|
||||||
|
if !parent_dir.is_empty() {
|
||||||
|
lines.push(Line::from(Span::styled(parent_dir, dimmed_style())));
|
||||||
|
}
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
|
||||||
|
lines.push(label_value("User", &download.username));
|
||||||
|
lines.push(label_value("Size", &format_bytes(download.size)));
|
||||||
|
|
||||||
|
let (status_text, status_style) = match &download.status {
|
||||||
|
DownloadStatus::Queued => ("Queued".to_string(), inactive_style()),
|
||||||
|
DownloadStatus::InProgress { .. } => {
|
||||||
|
("In progress".to_string(), warning_style())
|
||||||
|
}
|
||||||
|
DownloadStatus::Paused { .. } => ("Paused".to_string(), info_style()),
|
||||||
|
DownloadStatus::Completed => ("Completed".to_string(), success_style()),
|
||||||
|
DownloadStatus::Failed => ("Failed".to_string(), error_style()),
|
||||||
|
DownloadStatus::TimedOut => ("Timed out".to_string(), error_style()),
|
||||||
|
};
|
||||||
|
lines.push(label_value_styled("Status", status_text, status_style));
|
||||||
|
|
||||||
|
let save_path = expand_tilde(&download.download_directory)
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
lines.push(label_value("Save to", &save_path));
|
||||||
|
|
||||||
|
if let Some(bitrate) = download.metadata.bitrate {
|
||||||
|
lines.push(label_value("Bitrate", &format!("{} kbps", bitrate)));
|
||||||
|
}
|
||||||
|
if let Some(length) = download.metadata.length_seconds {
|
||||||
|
lines.push(label_value("Length", &format_duration(length)));
|
||||||
|
}
|
||||||
|
|
||||||
|
match &download.status {
|
||||||
|
DownloadStatus::Queued => {
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
let position_text = match download.queue_position {
|
||||||
|
Some(p) => format!("#{}", p),
|
||||||
|
None => "unknown".to_string(),
|
||||||
|
};
|
||||||
|
lines.push(label_value("Queue position", &position_text));
|
||||||
|
|
||||||
|
if let Some(slots) = download.metadata.peer_free_slots {
|
||||||
|
let slot_text = if slots > 0 {
|
||||||
|
format!("{} available", slots)
|
||||||
|
} else {
|
||||||
|
"all busy".to_string()
|
||||||
|
};
|
||||||
|
lines.push(label_value("Peer slots", &slot_text));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(speed) = download.metadata.peer_upload_speed {
|
||||||
|
lines.push(label_value(
|
||||||
|
"Peer upload speed",
|
||||||
|
&format_speed(speed as f64),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DownloadStatus::InProgress {
|
||||||
|
bytes_downloaded,
|
||||||
|
total_bytes,
|
||||||
|
speed_bytes_per_sec,
|
||||||
|
} => {
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
push_progress_lines(&mut lines, *bytes_downloaded, *total_bytes);
|
||||||
|
lines.push(label_value("Speed", &format_speed(*speed_bytes_per_sec)));
|
||||||
|
|
||||||
|
if *speed_bytes_per_sec > 0.0 && *total_bytes > *bytes_downloaded {
|
||||||
|
let remaining = *total_bytes - *bytes_downloaded;
|
||||||
|
let eta_secs = (remaining as f64 / speed_bytes_per_sec) as u32;
|
||||||
|
lines.push(label_value("ETA", &format_duration(eta_secs)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DownloadStatus::Paused {
|
||||||
|
bytes_downloaded,
|
||||||
|
total_bytes,
|
||||||
|
} => {
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
push_progress_lines(&mut lines, *bytes_downloaded, *total_bytes);
|
||||||
|
}
|
||||||
|
DownloadStatus::Completed
|
||||||
|
| DownloadStatus::Failed
|
||||||
|
| DownloadStatus::TimedOut => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
|
||||||
|
fn label_value(label: &str, value: &str) -> Line<'static> {
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
format!("{:<width$}", label, width = LABEL_WIDTH),
|
||||||
|
dimmed_style(),
|
||||||
|
),
|
||||||
|
Span::styled(value.to_string(), primary_style()),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn label_value_styled(
|
||||||
|
label: &str,
|
||||||
|
value: String,
|
||||||
|
value_style: Style,
|
||||||
|
) -> Line<'static> {
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
format!("{:<width$}", label, width = LABEL_WIDTH),
|
||||||
|
dimmed_style(),
|
||||||
|
),
|
||||||
|
Span::styled(value, value_style),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_progress_lines(
|
||||||
|
lines: &mut Vec<Line<'static>>,
|
||||||
|
bytes_downloaded: u64,
|
||||||
|
total_bytes: u64,
|
||||||
|
) {
|
||||||
|
let (progress, percent) = if total_bytes > 0 {
|
||||||
|
let ratio = bytes_downloaded as f64 / total_bytes as f64;
|
||||||
|
(ratio, (ratio * 100.0) as u8)
|
||||||
|
} else {
|
||||||
|
(0.0, 0)
|
||||||
|
};
|
||||||
|
lines.push(format_progress_bar(progress, 20, percent));
|
||||||
|
lines.push(label_value(
|
||||||
|
"Downloaded",
|
||||||
|
&format!(
|
||||||
|
"{} / {}",
|
||||||
|
format_bytes(bytes_downloaded),
|
||||||
|
format_bytes(total_bytes)
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn split_filename(path: &str) -> (String, String) {
|
||||||
|
let normalized = path.replace('\\', "/");
|
||||||
|
if let Some(idx) = normalized.rfind('/') {
|
||||||
|
let (parent, basename) = normalized.split_at(idx);
|
||||||
|
(basename.trim_start_matches('/').to_string(), parent.to_string())
|
||||||
|
} else {
|
||||||
|
(normalized, String::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_duration(seconds: u32) -> String {
|
||||||
|
let hours = seconds / 3600;
|
||||||
|
let minutes = (seconds % 3600) / 60;
|
||||||
|
let secs = seconds % 60;
|
||||||
|
if hours > 0 {
|
||||||
|
format!("{}h {:02}m {:02}s", hours, minutes, secs)
|
||||||
|
} else if minutes > 0 {
|
||||||
|
format!("{}m {:02}s", minutes, secs)
|
||||||
|
} else {
|
||||||
|
format!("{}s", secs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_duration_seconds_only() {
|
||||||
|
assert_eq!(format_duration(0), "0s");
|
||||||
|
assert_eq!(format_duration(45), "45s");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_duration_minutes_and_seconds() {
|
||||||
|
assert_eq!(format_duration(60), "1m 00s");
|
||||||
|
assert_eq!(format_duration(125), "2m 05s");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_duration_hours_minutes_seconds() {
|
||||||
|
assert_eq!(format_duration(3600), "1h 00m 00s");
|
||||||
|
assert_eq!(format_duration(3725), "1h 02m 05s");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn split_filename_with_backslash_path() {
|
||||||
|
let (basename, parent) =
|
||||||
|
split_filename("@@drive\\Music\\Album\\track.mp3");
|
||||||
|
assert_eq!(basename, "track.mp3");
|
||||||
|
assert_eq!(parent, "@@drive/Music/Album");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn split_filename_with_forward_slash_path() {
|
||||||
|
let (basename, parent) = split_filename("/home/user/track.mp3");
|
||||||
|
assert_eq!(basename, "track.mp3");
|
||||||
|
assert_eq!(parent, "/home/user");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn split_filename_with_no_separator() {
|
||||||
|
let (basename, parent) = split_filename("track.mp3");
|
||||||
|
assert_eq!(basename, "track.mp3");
|
||||||
|
assert_eq!(parent, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
|
mod download_info_pane;
|
||||||
mod downloads_pane;
|
mod downloads_pane;
|
||||||
mod results_pane;
|
mod results_pane;
|
||||||
mod searches_pane;
|
mod searches_pane;
|
||||||
|
|
||||||
|
pub use download_info_pane::render_download_info_pane;
|
||||||
pub use downloads_pane::render_downloads_pane;
|
pub use downloads_pane::render_downloads_pane;
|
||||||
pub use results_pane::{ResultsPaneParams, render_results_pane};
|
pub use results_pane::{ResultsPaneParams, render_results_pane};
|
||||||
pub use searches_pane::render_searches_pane;
|
pub use searches_pane::render_searches_pane;
|
||||||
|
|||||||
Reference in New Issue
Block a user