webui-cam/screen separation + betterfile transfer ui
This commit is contained in:
@@ -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 => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
82
web/app.js
82
web/app.js
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user