diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html index b28d625..00adfa5 100644 --- a/web/zebra-spaces.html +++ b/web/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');