From 88df6ad5100677cc00f5317b52661d16238edd35 Mon Sep 17 00:00:00 2001 From: mixa Date: Thu, 12 Feb 2026 19:43:06 +0300 Subject: [PATCH] webui-cam/screen separation + betterfile transfer ui --- src/file_transfer/mod.rs | 19 +++++++++- src/tui/file_panel.rs | 46 +++++++++++++++------- web/app.js | 82 ++++++++++++++++++++++++++-------------- 3 files changed, 102 insertions(+), 45 deletions(-) diff --git a/src/file_transfer/mod.rs b/src/file_transfer/mod.rs index c7fe9db..6afc296 100644 --- a/src/file_transfer/mod.rs +++ b/src/file_transfer/mod.rs @@ -30,6 +30,7 @@ pub enum TransferState { Transferring { bytes_transferred: u64, total_size: u64, + start_time: std::time::Instant, }, /// Transfer completed successfully. Complete, @@ -200,6 +201,7 @@ impl FileTransferManager { let mut offset: u64 = 0; let total_size = tokio::fs::metadata(file_path).await?.len(); let mut buf = vec![0u8; CHUNK_SIZE]; + let start_time = std::time::Instant::now(); loop { let n = file.read(&mut buf).await?; @@ -223,6 +225,7 @@ impl FileTransferManager { info.state = TransferState::Transferring { bytes_transferred: offset, total_size, + start_time, }; } } @@ -359,6 +362,7 @@ impl FileTransferManager { .await?; let mut received: u64 = 0; + let start_time = std::time::Instant::now(); loop { let chunk_msg: FileStreamMessage = decode_framed(recv).await?; @@ -373,6 +377,7 @@ impl FileTransferManager { info.state = TransferState::Transferring { bytes_transferred: received, total_size: offer.size, + start_time, }; } } @@ -424,19 +429,29 @@ impl FileTransferManager { TransferState::Transferring { bytes_transferred, total_size, + start_time, } => { let pct = if *total_size > 0 { (*bytes_transferred as f64 / *total_size as f64 * 100.0) as u8 } else { 0 }; + let elapsed = start_time.elapsed().as_secs_f64(); + let speed_bps = if elapsed > 0.0 { + *bytes_transferred as f64 / elapsed + } else { + 0.0 + }; + let speed_mbps = speed_bps / (1024.0 * 1024.0); + format!( - "{} {} {}% ({}/{})", + "{} {} {}% ({}/{}) {:.1} MB/s", direction, info.file_name, pct, format_bytes(*bytes_transferred), - format_bytes(*total_size) + format_bytes(*total_size), + speed_mbps ) } TransferState::Complete => { diff --git a/src/tui/file_panel.rs b/src/tui/file_panel.rs index 9801be0..6a03657 100644 --- a/src/tui/file_panel.rs +++ b/src/tui/file_panel.rs @@ -46,14 +46,38 @@ pub fn render(frame: &mut Frame, area: Rect, file_mgr: &FileTransferManager, app format!("[{}] {} (Failed: {})", id_short, info.file_name, e), app.theme.error ), - TransferState::Transferring { bytes_transferred, .. } => { - let pct = if info.file_size > 0 { - (*bytes_transferred as f64 / info.file_size as f64) * 100.0 + TransferState::Transferring { bytes_transferred, total_size, start_time } => { + let pct = if *total_size > 0 { + (*bytes_transferred as f64 / *total_size as f64) * 100.0 } else { 0.0 }; + + // Calc speed & ETA + let elapsed = start_time.elapsed().as_secs_f64(); + let speed_bps = if elapsed > 0.0 { + *bytes_transferred as f64 / elapsed + } else { + 0.0 + }; + let speed_mbps = speed_bps / (1024.0 * 1024.0); + + let eta_str = if speed_bps > 0.0 && *total_size > *bytes_transferred { + let remaining_bytes = total_size - bytes_transferred; + let eta_secs = remaining_bytes as f64 / speed_bps; + format!("{:.0}s left", eta_secs) + } else { + "Calculating...".to_string() + }; + + // Progress Bar + let width = 15; + let filled = (pct / 100.0 * width as f64) as usize; + let bar: String = (0..width).map(|i| if i < filled { '█' } else { '░' }).collect(); + ( - format!("[{}] {} ({:.1}%)", id_short, info.file_name, pct), + format!("[{}] {} ⏳ {} {:.1}% ({:.1} MB/s) - {}", + id_short, info.file_name, bar, pct, speed_mbps, eta_str), app.theme.info ) }, @@ -65,9 +89,8 @@ pub fn render(frame: &mut Frame, area: Rect, file_mgr: &FileTransferManager, app let now = std::time::Instant::now(); if *expires_at > now { let remaining = expires_at.duration_since(now).as_secs(); - let total_timeout = 60u64; // Assuming default, or we could store initial timeout in State if needed. - // Spinner + // spinner (braille) const SPINNER: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; let millis = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -75,15 +98,10 @@ pub fn render(frame: &mut Frame, area: Rect, file_mgr: &FileTransferManager, app .as_millis(); let spin_idx = (millis / 100) as usize % SPINNER.len(); let spinner = SPINNER[spin_idx]; - - // Progress bar for timeout (reversed: full then empty) - // Or empty then full? Usually timeout bars shrink. - let width = 10; - let filled = (remaining as u64 * width as u64) / total_timeout; - let bar: String = (0..width).map(|i| if i < filled { '█' } else { '░' }).collect(); - + + // Removed progress bar as requested ("leave the braile and seconds") ( - format!("[{}] {} {} {} {}s left", id_short, info.file_name, spinner, bar, remaining), + format!("[{}] {} {} {}s left", id_short, info.file_name, spinner, remaining), app.theme.warning ) } else { diff --git a/web/app.js b/web/app.js index a01d26c..c7f5460 100644 --- a/web/app.js +++ b/web/app.js @@ -16,6 +16,12 @@ let audioCtx = null; const SAMPLE_RATE = 48000; // --- Remote Peer State --- +// Map const peers = new Map(); // Initialize shared AudioContext for playback @@ -71,52 +77,67 @@ ws.onmessage = (event) => { // Get or Create Peer let peer = peers.get(peerId); if (!peer) { - peer = createPeer(peerId); + peer = { + id: peerId, + nextStartTime: 0, + cam: null, + screen: null + }; peers.set(peerId, peer); } if (header === 0) { // Audio handleRemoteAudio(peer, payload); } else if (header === 1) { // Video (Camera) - handleRemoteVideo(peer, payload); + handleRemoteVideo(peer, payload, 'cam'); } else if (header === 2) { // Screen - handleRemoteVideo(peer, payload); // Treat screen same as video for grids + handleRemoteVideo(peer, payload, 'screen'); // Treat screen separate } } }; -function createPeer(peerId) { +function getOrCreateCard(peer, type) { + if (peer[type]) return peer[type]; + const card = document.createElement('div'); card.className = 'peer-card'; - card.id = `peer-${peerId}`; + card.id = `peer-${peer.id}-${type}`; // Video/Image element const img = document.createElement('img'); img.className = 'peer-video'; - img.alt = `Video from ${peerId}`; + img.alt = `${type} from ${peer.id}`; card.appendChild(img); // Overlay info const info = document.createElement('div'); info.className = 'peer-info'; + let label = peer.id.substring(0, 8); + if (type === 'screen') label += " (Screen)"; + info.innerHTML = ` -
- ${peerId.substring(0, 8)}... +
+ ${label} `; card.appendChild(info); videoGrid.appendChild(card); - return { - id: peerId, + const cardObj = { + card: card, imgElement: img, statusElement: info.querySelector('.peer-status'), - nextStartTime: 0, - lastActivity: Date.now() + activityTimeout: null }; + + peer[type] = cardObj; + return cardObj; } function handleRemoteAudio(peer, arrayBuffer) { + // Audio usually associated with Camera card. If no cam card, create one (placeholder). + const cardObj = getOrCreateCard(peer, 'cam'); + const ctx = getAudioContext(); const float32Data = new Float32Array(arrayBuffer); const buffer = ctx.createBuffer(1, float32Data.length, SAMPLE_RATE); @@ -139,34 +160,33 @@ function handleRemoteAudio(peer, arrayBuffer) { peer.nextStartTime += buffer.duration; // Visual indicator - peer.lastActivity = Date.now(); - updatePeerActivity(peer, true); + updatePeerActivity(cardObj, true); } -function handleRemoteVideo(peer, arrayBuffer) { +function handleRemoteVideo(peer, arrayBuffer, type) { + const cardObj = getOrCreateCard(peer, type); + const blob = new Blob([arrayBuffer], { type: 'image/jpeg' }); const url = URL.createObjectURL(blob); - const prevUrl = peer.imgElement.src; - peer.imgElement.onload = () => { + const prevUrl = cardObj.imgElement.src; + cardObj.imgElement.onload = () => { if (prevUrl && prevUrl.startsWith('blob:')) { URL.revokeObjectURL(prevUrl); } }; - peer.imgElement.src = url; + cardObj.imgElement.src = url; - peer.lastActivity = Date.now(); - updatePeerActivity(peer, false); + updatePeerActivity(cardObj, false); } -let activityTimeout; -function updatePeerActivity(peer, isAudio) { +function updatePeerActivity(cardObj, isAudio) { if (isAudio) { - peer.statusElement.classList.add('speaking'); + cardObj.statusElement.classList.add('speaking'); // Debounce removal - if (peer.activityTimeout) clearTimeout(peer.activityTimeout); - peer.activityTimeout = setTimeout(() => { - peer.statusElement.classList.remove('speaking'); + if (cardObj.activityTimeout) clearTimeout(cardObj.activityTimeout); + cardObj.activityTimeout = setTimeout(() => { + cardObj.statusElement.classList.remove('speaking'); }, 200); } } @@ -190,8 +210,10 @@ toggleMicBtn.addEventListener('click', async () => { stopMic(); updateButton(toggleMicBtn, false, 'mic', 'mic_off'); } else { - await startMic(); - updateButton(toggleMicBtn, true, 'mic', 'mic_off'); + const success = await startMic(); + if (success) { + updateButton(toggleMicBtn, true, 'mic', 'mic_off'); + } } }); @@ -246,9 +268,11 @@ async function startMic() { mute.gain.value = 0; micScriptProcessor.connect(mute); mute.connect(ctx.destination); + return true; } catch (err) { console.error('Error starting mic:', err); - alert('Mic access failed'); + alert('Mic access failed: ' + err.message); + return false; } }