diff --git a/zebra-report/zebra-spaces.html b/zebra-report/zebra-spaces.html
index b28d625..00adfa5 100644
--- a/zebra-report/zebra-spaces.html
+++ b/zebra-report/zebra-spaces.html
@@ -2495,14 +2495,27 @@ async function sfuPublishScreen(){
if (s.track.kind === 'video') setSenderMaxBitrate(s, 6000000);
if (s.track.kind === 'audio') setSenderMaxBitrate(s, 256000);
}
- /* Screen share CANNOT auto-rebuild like mic + camera — getDisplayMedia
- * requires a fresh user gesture (transient activation). Auto-calling
- * sfuPublishScreen() after a 'failed' state just fires the picker
- * with no gesture and gets rejected. Instead: tear down on failed
- * + surface the share button so the user can re-trigger themselves. */
+ /* Screen share recovery is asymmetric to mic/camera. getDisplayMedia
+ * needs a fresh user gesture, so we CAN'T auto-fire the picker after
+ * a 'failed'. BUT: the existing MediaStream usually survives a PC
+ * failure — the browser's "you are sharing" indicator stays on, the
+ * tracks remain in readyState=live. In that case we can rebuild the
+ * RTCPeerConnection while reusing sfuScreenStream, no picker needed,
+ * no gesture needed. Only fall back to manual re-share if the
+ * underlying tracks have actually ended (user clicked browser-native
+ * "stop sharing", source window closed, etc.) — fox 2026-06-04. */
pc.onconnectionstatechange = () => {
if (pc.connectionState !== 'failed' || sfuScreenPC !== pc) return;
- logLine('err', 'sfu screen publish PC failed — tap "share screen" to re-share (browsers need a fresh click for screen capture)');
+ const tracksLive = sfuScreenStream && sfuScreenStream.getTracks().every(t => t.readyState === 'live');
+ if (tracksLive){
+ logLine('err', 'sfu screen publish PC failed — tracks still live, rebuilding silently');
+ sfuRebuildScreenPC().catch(e => {
+ logLine('err','silent screen rebuild failed: '+e.message+' — tap "share screen" to re-share');
+ sfuUnpublishScreen().catch(()=>{});
+ });
+ return;
+ }
+ logLine('err', 'sfu screen publish PC failed — tracks ended; tap "share screen" to re-share (browsers need a fresh click for screen capture)');
sfuUnpublishScreen().catch(()=>{});
};
logLine('', 'sfu: sharing screen as '+sfuScreenPeerID);
@@ -2512,6 +2525,70 @@ async function sfuPublishScreen(){
$('btn-screen-share').classList.add('hidden');
$('btn-screen-stop').classList.remove('hidden');
}
+/* sfuRebuildScreenPC — silent recovery path used by the screen
+ * publisher's onconnectionstatechange when the PC fails BUT the
+ * underlying MediaStream is still alive. Reuses sfuScreenStream
+ * verbatim, builds a fresh PC, re-publishes to the SFU. No
+ * getDisplayMedia call → no user gesture required → no picker
+ * popping up mid-share. Caller decides what to do on failure (the
+ * onconnectionstatechange handler falls back to sfuUnpublishScreen
+ * which surfaces the manual "share screen" button). */
+async function sfuRebuildScreenPC(){
+ if (!sfuScreenStream || !myKeys || !roomID) throw new Error('no live stream to rebuild against');
+ /* tear down the dead PC first; sfuScreenPC must be null'd BEFORE
+ * pc.close() so the dead PC's stale onconnectionstatechange (which
+ * may fire one more time during teardown) sees sfuScreenPC===null
+ * and bails out instead of recursing into another rebuild. */
+ const oldPC = sfuScreenPC;
+ const oldPid = sfuScreenPeerID;
+ sfuScreenPC = null;
+ sfuScreenPeerID = null;
+ if (oldPC){ try { oldPC.close(); } catch(_){} }
+ /* unpublish the old peer_id at the SFU so it doesn't keep a stale
+ * publisher entry around that fights the new one for the same
+ * (pubkey, kind=screen) slot. */
+ if (oldPid){
+ try { await fetch(SFU_BASE + '/unpublish?room=' + encodeURIComponent(roomID) + '&peer=' + oldPid, { method:'POST' }); } catch(_){}
+ }
+ const pc = new RTCPeerConnection(rtcConfig);
+ for (const tr of sfuScreenStream.getTracks()){
+ pc.addTrack(tr, sfuScreenStream);
+ }
+ const offer = await pc.createOffer();
+ offer.sdp = preferStereoOpus(offer.sdp, 256000, { music: true });
+ await pc.setLocalDescription(offer);
+ await waitForIceGathering(pc);
+ const url = SFU_BASE + '/publish?room=' + encodeURIComponent(roomID)
+ + '&pub=' + myKeys.pubHex + '&kind=screen';
+ const res = await fetch(url, { method:'POST', headers:{'Content-Type':'application/json'},
+ body: JSON.stringify({ sdp: pc.localDescription.sdp }) });
+ if (res.status === 403){ pc.close(); handleBlocked('publish-screen'); throw new Error('blocked'); }
+ if (!res.ok){ pc.close(); throw new Error('sfu publish-screen http '+res.status); }
+ const ans = await res.json();
+ await pc.setRemoteDescription({ type:'answer', sdp: ans.sdp });
+ sfuScreenPC = pc; sfuScreenPeerID = ans.peer_id;
+ for (const s of pc.getSenders()){
+ if (!s.track) continue;
+ if (s.track.kind === 'video') setSenderMaxBitrate(s, 6000000);
+ if (s.track.kind === 'audio') setSenderMaxBitrate(s, 256000);
+ }
+ pc.onconnectionstatechange = () => {
+ if (pc.connectionState !== 'failed' || sfuScreenPC !== pc) return;
+ const tracksLive = sfuScreenStream && sfuScreenStream.getTracks().every(t => t.readyState === 'live');
+ if (tracksLive){
+ logLine('err', 'sfu screen publish PC failed — tracks still live, rebuilding silently');
+ sfuRebuildScreenPC().catch(e => {
+ logLine('err','silent screen rebuild failed: '+e.message+' — tap "share screen" to re-share');
+ sfuUnpublishScreen().catch(()=>{});
+ });
+ return;
+ }
+ logLine('err', 'sfu screen publish PC failed — tracks ended; tap "share screen" to re-share (browsers need a fresh click for screen capture)');
+ sfuUnpublishScreen().catch(()=>{});
+ };
+ logLine('', 'sfu: screen PC rebuilt silently — sharing as '+sfuScreenPeerID);
+}
+
async function sfuUnpublishScreen(){
if (!sfuScreenPC && !sfuScreenStream) return;
const pid = sfuScreenPeerID;
@@ -5576,8 +5653,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');