webui-cam/screen separation + betterfile transfer ui

This commit is contained in:
mixa
2026-02-12 19:43:06 +03:00
parent 3962999aeb
commit 88df6ad510
3 changed files with 102 additions and 45 deletions

View File

@@ -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 => {

View File

@@ -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 {

View File

@@ -16,6 +16,12 @@ let audioCtx = null;
const SAMPLE_RATE = 48000;
// --- Remote Peer State ---
// Map<peerId, {
// id: string,
// nextStartTime: number,
// cam: { card: HTMLElement, img: HTMLElement, status: HTMLElement } | null,
// screen: { card: HTMLElement, img: HTMLElement, status: HTMLElement } | null,
// }>
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 = `
<div class="peer-status" id="status-${peerId}"></div>
<span class="peer-name">${peerId.substring(0, 8)}...</span>
<div class="peer-status" id="status-${peer.id}-${type}"></div>
<span class="peer-name">${label}</span>
`;
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;
}
}