diff --git a/desktop/src-tauri/src/engine.rs b/desktop/src-tauri/src/engine.rs index 205d3c7..9b951c4 100644 --- a/desktop/src-tauri/src/engine.rs +++ b/desktop/src-tauri/src/engine.rs @@ -653,6 +653,17 @@ impl CallEngine { let mut last_written: usize = 0; let mut decode_errs: u64 = 0; let mut first_packet_logged = false; + // Phase 5.6: media health watchdog — track consecutive + // heartbeat ticks where recv_fr hasn't advanced. If + // media doesn't arrive for 3 consecutive heartbeats + // (6s), emit a user-facing "media-degraded" call-event + // so the UI can show a warning like "No audio — try + // reconnecting?". Covers the case where P2P direct + // established but the underlying network path died + // (e.g., phone switched from WiFi to LTE mid-call). + let mut last_recv_fr_for_watchdog: u64 = 0; + let mut no_recv_ticks: u32 = 0; + let mut media_degraded_emitted = false; loop { if !recv_r.load(Ordering::Relaxed) { @@ -905,6 +916,59 @@ impl CallEngine { "codec": format!("{:?}", current_codec), }), ); + + // Media health watchdog: if recv_fr hasn't + // advanced in 3 consecutive heartbeats (6s) and + // we've been "connected" for at least 4s (give + // the first few frames time to arrive), emit a + // user-facing "media-degraded" event so the UI + // can show "No audio — connection may be lost". + if fr == last_recv_fr_for_watchdog { + no_recv_ticks += 1; + } else { + no_recv_ticks = 0; + if media_degraded_emitted { + // Was degraded but recovered — clear + // the banner. + media_degraded_emitted = false; + let _ = recv_app.emit( + "call-event", + serde_json::json!({ + "kind": "media-recovered", + }), + ); + crate::emit_call_debug( + &recv_app, + "media:recovered", + serde_json::json!({}), + ); + } + } + last_recv_fr_for_watchdog = fr; + + if no_recv_ticks >= 3 && !media_degraded_emitted { + media_degraded_emitted = true; + tracing::warn!( + recv_fr = fr, + no_recv_ticks, + "media watchdog: no inbound packets for 6s" + ); + let _ = recv_app.emit( + "call-event", + serde_json::json!({ + "kind": "media-degraded", + }), + ); + crate::emit_call_debug( + &recv_app, + "media:no_recv_timeout", + serde_json::json!({ + "recv_fr": fr, + "no_recv_ticks": no_recv_ticks, + }), + ); + } + heartbeat = std::time::Instant::now(); } } diff --git a/desktop/src/main.ts b/desktop/src/main.ts index 4897f70..8796faf 100644 --- a/desktop/src/main.ts +++ b/desktop/src/main.ts @@ -887,6 +887,9 @@ function showConnectScreen() { connectBtn.textContent = "Connect"; levelBar.style.width = "0%"; directCallPeer = null; + // Clear the media-degraded banner if present + const banner = document.getElementById("media-degraded-banner"); + if (banner) banner.remove(); if (statusInterval) { clearInterval(statusInterval); statusInterval = null; } } @@ -1088,6 +1091,42 @@ listen("call-event", (event: any) => { const { kind } = event.payload; if (kind === "room-update") pollStatus(); if (kind === "disconnected" && !userDisconnected) pollStatus(); + + // Phase 5.6: media health watchdog — show/clear a warning + // banner when the media path dies (e.g., P2P direct + // established but the network path changed, or cross-relay + // media forwarding isn't working). + if (kind === "media-degraded") { + // Show a warning banner on the call screen. Don't auto- + // disconnect — the user might be on a briefly-unstable + // network and recovery is possible (the engine tracks + // "media-recovered" and clears the banner if packets + // resume). + let banner = document.getElementById("media-degraded-banner"); + if (!banner) { + banner = document.createElement("div"); + banner.id = "media-degraded-banner"; + banner.style.cssText = + "background:rgba(239,68,68,0.15);color:var(--red);padding:8px 12px;" + + "border-radius:8px;text-align:center;font-size:13px;margin:8px 0;"; + banner.innerHTML = + '⚠ No audio — connection may be lost.
' + + 'Try hanging up and reconnecting, or switch to a different relay.'; + // Insert at the top of the call screen, below the header + const participants = document.getElementById("participants"); + const directView = document.getElementById("direct-call-view"); + const insertBefore = (directView && !directView.classList.contains("hidden")) + ? directView + : participants; + if (insertBefore?.parentNode) { + insertBefore.parentNode.insertBefore(banner, insertBefore); + } + } + } + if (kind === "media-recovered") { + const banner = document.getElementById("media-degraded-banner"); + if (banner) banner.remove(); + } }); // ── Settings ──