feat(call): media health watchdog — warn user when no audio arrives
When a P2P direct call establishes successfully but the underlying
network path dies (phone switched from WiFi to LTE mid-call, or
cross-relay media forwarding isn't working), the call stays up
silently with recv_fr frozen at 0. No feedback to the user.
New watchdog in the Android recv task: tracks consecutive
heartbeat ticks (2s each) where recv_fr hasn't advanced. After 3
ticks (6s) with no new packets, emits:
- call-event { kind: "media-degraded" } — user-facing warning
banner: "No audio — connection may be lost. Try hanging up and
reconnecting, or switch to a different relay."
- call-debug media:no_recv_timeout for the debug log
If packets resume (recv_fr advances), clears the banner via:
- call-event { kind: "media-recovered" }
JS listener creates/removes a red-tinted banner dynamically at
the top of the call screen. Banner is also cleaned up on
showConnectScreen (call end).
This covers:
- Direct P2P that established on WiFi but died when the phone
switched to LTE (stale NAT mapping, unreachable peer)
- Cross-relay calls where federation media isn't forwarding
(relay not upgraded, not federated, etc.)
- Any other "connected but silent" scenario
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -653,6 +653,17 @@ impl CallEngine {
|
|||||||
let mut last_written: usize = 0;
|
let mut last_written: usize = 0;
|
||||||
let mut decode_errs: u64 = 0;
|
let mut decode_errs: u64 = 0;
|
||||||
let mut first_packet_logged = false;
|
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 {
|
loop {
|
||||||
if !recv_r.load(Ordering::Relaxed) {
|
if !recv_r.load(Ordering::Relaxed) {
|
||||||
@@ -905,6 +916,59 @@ impl CallEngine {
|
|||||||
"codec": format!("{:?}", current_codec),
|
"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();
|
heartbeat = std::time::Instant::now();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -887,6 +887,9 @@ function showConnectScreen() {
|
|||||||
connectBtn.textContent = "Connect";
|
connectBtn.textContent = "Connect";
|
||||||
levelBar.style.width = "0%";
|
levelBar.style.width = "0%";
|
||||||
directCallPeer = null;
|
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; }
|
if (statusInterval) { clearInterval(statusInterval); statusInterval = null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1088,6 +1091,42 @@ listen("call-event", (event: any) => {
|
|||||||
const { kind } = event.payload;
|
const { kind } = event.payload;
|
||||||
if (kind === "room-update") pollStatus();
|
if (kind === "room-update") pollStatus();
|
||||||
if (kind === "disconnected" && !userDisconnected) 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.<br>' +
|
||||||
|
'<small style="color:var(--text-dim)">Try hanging up and reconnecting, or switch to a different relay.</small>';
|
||||||
|
// 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 ──
|
// ── Settings ──
|
||||||
|
|||||||
Reference in New Issue
Block a user