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 ──