diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html
index 2df97b6..c46f6bf 100644
--- a/web/zebra-spaces.html
+++ b/web/zebra-spaces.html
@@ -928,19 +928,29 @@ async function sfuSubscribe(){
const kind = sid.slice(colon + 1);
/* skip echo of our own publish — we already render a local preview */
if (myKeys && pubHex === myKeys.pubHex) return;
+ /* video kinds: when the publisher unshares, the SFU stops the
+ * transceiver and the corresponding remote track fires 'ended'.
+ * Wire that to the tile remover so the listener's UI matches the
+ * publisher's state instead of holding a frozen last frame. */
if (kind === 'screen'){
screenStreams.set(pubHex, ev.streams[0]);
renderScreenTile(pubHex, ev.streams[0]);
+ ev.track.addEventListener('ended', () => removeScreenTile(pubHex));
return;
}
if (kind === 'camera'){
cameraStreams.set(pubHex, ev.streams[0]);
renderCameraTile(pubHex, ev.streams[0]);
+ ev.track.addEventListener('ended', () => removeCameraTile(pubHex));
return;
}
logLine('', 'sfu: unknown kind '+kind+' from '+shortHex(pubHex));
return;
}
+ /* mic audio — set a small jitter-buffer hint so brief network jitter
+ * doesn't cause audible drops. 100ms is enough to absorb typical
+ * Wi-Fi micro-bursts without making conversation feel laggy. */
+ try { ev.receiver.playoutDelayHint = 0.1; } catch(_){}
const pubHex = sid;
/* cache stream by publisher pubkey so it survives the member's session
* uuid changing across leave/rejoin — see flushSfuStreams */
@@ -1113,7 +1123,15 @@ async function setSenderMaxBitrate(sender, bps){
* has to be raised explicitly for music to actually use the headroom. */
function preferStereoOpus(sdp, maxAvgBps){
return sdp.replace(/a=fmtp:(\d+) ([^\r\n]*minptime=10[^\r\n]*)/g, (m, pt, fmtp) => {
- const want = { 'stereo': '1', 'sprop-stereo': '1', 'maxaveragebitrate': String(maxAvgBps) };
+ /* useinbandfec=1: forward error correction so a single dropped
+ * packet doesn't audibly chop — Opus reconstructs from FEC.
+ * usedtx=1: discontinuous transmission elides silence so the
+ * bandwidth budget goes to actual audio (cuts congestion). */
+ const want = {
+ 'stereo': '1', 'sprop-stereo': '1',
+ 'maxaveragebitrate': String(maxAvgBps),
+ 'useinbandfec': '1', 'usedtx': '1',
+ };
const parts = fmtp.split(';').map(s => s.trim()).filter(Boolean);
const seen = new Set();
for (let i = 0; i < parts.length; i++){
@@ -1526,6 +1544,9 @@ async function connectToPeer(uuid, weOffer){
let a = remoteAudio.get(uuid);
if (!a){ a = document.createElement('audio'); a.autoplay = true; document.body.appendChild(a); remoteAudio.set(uuid, a); }
a.srcObject = ev.streams[0] || new MediaStream([ev.track]);
+ /* 100ms jitter buffer absorbs typical Wi-Fi micro-bursts without
+ * adding perceptible conversation lag */
+ try { ev.receiver.playoutDelayHint = 0.1; } catch(_){}
stopMeter(uuid); startMeter(uuid, a.srcObject);
};
pc.onicecandidate = (ev) => { /* using waitForIceGathering pattern, candidates ignored */ };
@@ -1878,8 +1899,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');