diff --git a/zebra-report/host-your-own.html b/zebra-report/host-your-own.html index 15af14a..9385b5c 100644 --- a/zebra-report/host-your-own.html +++ b/zebra-report/host-your-own.html @@ -507,9 +507,9 @@ handle /zebra-spaces-sfu* { reverse_proxy localhost:8092 { flush_interval -1

diff --git a/zebra-report/how-it-works.html b/zebra-report/how-it-works.html index b3ba742..83f40f4 100644 --- a/zebra-report/how-it-works.html +++ b/zebra-report/how-it-works.html @@ -341,9 +341,9 @@ try {

diff --git a/zebra-report/index.html b/zebra-report/index.html index 5a3438b..d0af6ea 100644 --- a/zebra-report/index.html +++ b/zebra-report/index.html @@ -1747,9 +1747,9 @@ logLine('sys', 'chat content lives in encrypted SRTP audio. no IP packets carry })(); diff --git a/zebra-report/zebra-audio.html b/zebra-report/zebra-audio.html index f744be0..64fe765 100644 --- a/zebra-report/zebra-audio.html +++ b/zebra-report/zebra-audio.html @@ -747,9 +747,9 @@ else wirePuppet(); diff --git a/zebra-report/zebra-spaces.html b/zebra-report/zebra-spaces.html index d32fe3f..5984ad4 100644 --- a/zebra-report/zebra-spaces.html +++ b/zebra-report/zebra-spaces.html @@ -1702,6 +1702,38 @@ function watchVideoTrackForRemoval(track, removeFn, windowMs){ track.addEventListener('unmute', onUnmute); } +/* watchFirstFrame — log a warning if a freshly-attached SFU video track + * never unmutes within the deadline. Fresh remote tracks start `muted` + * until the first RTP packet arrives; if the decoder never gets a + * keyframe (PLI dropped in UDP, publisher encoder stalled, codec + * mismatch), the tile renders permanently black with no clear signal in + * the log. This emits one line so we can tell next session which path + * actually broke. No state change — purely diagnostic. */ +function watchFirstFrame(track, kind, pubHex){ + if (!track) return; + if (!track.muted){ + /* already flowing — nothing to wait for */ + return; + } + const deadline = 2500; + const start = Date.now(); + let done = false; + const settle = (note) => { + if (done) return; + done = true; + track.removeEventListener('unmute', onUnmute); + if (note) logLine('', note); + }; + const onUnmute = () => settle('first frame: kind=' + kind + ' pub=' + pubHex + ' in ' + (Date.now()-start) + 'ms'); + track.addEventListener('unmute', onUnmute); + setTimeout(() => { + if (done) return; + if (track.readyState !== 'live'){ settle(null); return; } + if (!track.muted){ settle('first frame: kind=' + kind + ' pub=' + pubHex + ' (unmute event missed)'); return; } + settle('err: ' + kind + ' from ' + pubHex + ' still black after ' + deadline + 'ms — no keyframe?'); + }, deadline); +} + /* Spotlight model — every tile keeps its thumbnail permanently in the * cameras column (cameras above, screens-thumbs below). When a tile is * spotlit, a SECOND larger tile renders in the middle slot pointing at @@ -2505,6 +2537,7 @@ function handleRemoteSfuTrack(ev){ screenStreams.set(pubHex, s); renderScreenTile(pubHex, s); watchVideoTrackForRemoval(ev.track, () => { if (screenStreams.get(pubHex) === s) removeScreenTile(pubHex); }, VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS); + watchFirstFrame(ev.track, kind, pubHex); return; } if (kind === 'camera'){ @@ -2512,6 +2545,7 @@ function handleRemoteSfuTrack(ev){ cameraStreams.set(pubHex, s); renderCameraTile(pubHex, s); watchVideoTrackForRemoval(ev.track, () => { if (cameraStreams.get(pubHex) === s) removeCameraTile(pubHex); }, VIDEO_REMOVE_MUTE_WINDOW_MS); + watchFirstFrame(ev.track, kind, pubHex); return; } if (kind === 'game'){ @@ -2522,6 +2556,7 @@ function handleRemoteSfuTrack(ev){ gameStreams.set(pubHex, s); renderVideoTile('gameshare', pubHex, s); watchVideoTrackForRemoval(ev.track, () => { if (gameStreams.get(pubHex) === s) removeVideoTile('gameshare', pubHex); }, VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS); + watchFirstFrame(ev.track, kind, pubHex); return; } if (kind !== 'mic'){ @@ -3728,7 +3763,8 @@ function renderRoom(){ /* boot allowed against anyone except host; cohosts also can't boot cohosts (host only) */ if (m.role !== 'host' && !(m.role === 'cohost' && myRole !== 'host')){ const b = document.createElement('button'); b.className='small'; - b.textContent = 'boot'; b.onclick = () => modBoot(m.uuid); + b.textContent = 'boot'; + b.onclick = () => modBoot(m.uuid).catch(e => logLine('err','boot: '+e.message)); acts.appendChild(b); } } @@ -4171,8 +4207,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');