From 10ccea9cb67f202584c7feea1fce769b4c52e51a Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Thu, 4 Jun 2026 13:58:04 -0400 Subject: [PATCH] =?UTF-8?q?zebra-spaces:=20applyPublishStateUI=20=E2=80=94?= =?UTF-8?q?=20share/stop=20button=20visibility=20from=20publishSpec=20FSM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on 07d0af9 (publishSpec wired to imperative flow) — adds an observer that derives btn-{screen,camera}-share / btn-{kind}-stop visibility from the publishSpec state instead of from scattered classList writes in sfuPublish/sfuUnpublish. The state→UI mapping: - off / failed → share button visible, stop hidden - acquiring / negotiating / live / stopping → stop visible, share hidden (stopping is treated as "still sharing" to avoid flicker during teardown) Removed four classList writes (two in sfuPublishScreen+sfuUnpublishScreen, two in sfuPublishCamera+sfuUnpublishCamera). The buttons now reflect the FSM rather than whoever last touched them — if the FSM transitions because of the silent rebuild (sfuRebuildScreenPC) or the watchPublishPC rebuild path or any future state-driver, the buttons follow automatically. Mic has no share/stop button (it's always on for speakers, off for listeners), so applyPublishStateUI early-returns for that kind. All 88 fsm + 12 self-listener tests still green. --- web/zebra-spaces.html | 50 ++++++++++++++++++++++++++++++++----------- 1 file changed, 37 insertions(+), 13 deletions(-) diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html index 45570cc..bd174e4 100644 --- a/web/zebra-spaces.html +++ b/web/zebra-spaces.html @@ -2547,8 +2547,8 @@ async function sfuPublishScreen(){ /* render a muted local preview so the publisher sees what they're * sharing — SFU does not echo the publisher's own stream back */ renderScreenTile(myKeys.pubHex, stream, { local: true }); - $('btn-screen-share').classList.add('hidden'); - $('btn-screen-stop').classList.remove('hidden'); + /* share / stop button visibility is driven by applyPublishStateUI + * via the publishSpec observer (pubs.screen state). */ } /* sfuRebuildScreenPC — silent recovery path used by the screen * publisher's onconnectionstatechange when the PC fails BUT the @@ -2626,8 +2626,8 @@ async function sfuUnpublishScreen(){ try { await fetch(SFU_BASE + '/unpublish?room=' + encodeURIComponent(roomID) + '&peer=' + pid, { method:'POST' }); } catch(_){} } fsm.send('DONE'); - $('btn-screen-share').classList.remove('hidden'); - $('btn-screen-stop').classList.add('hidden'); + /* share / stop visibility is driven by applyPublishStateUI from + * the publishSpec 'off' state. */ logLine('', 'screen share stopped'); } @@ -2766,8 +2766,8 @@ async function sfuPublishCamera(){ () => { sfuCameraPC = null; sfuCameraPeerID = null; sfuCameraStream = null; fsm.send('STOP'); fsm.send('DONE'); }); logLine('', 'sfu: camera on as '+sfuCameraPeerID); renderCameraTile(myKeys.pubHex, stream, { local: true }); - $('btn-camera-share').classList.add('hidden'); - $('btn-camera-stop').classList.remove('hidden'); + /* share / stop visibility is driven by applyPublishStateUI via + * the publishSpec observer (pubs.camera state). */ try { sessionStorage.setItem(ACTIVE_CAM_KEY, '1'); } catch(_){} } async function sfuUnpublishCamera(){ @@ -2782,8 +2782,8 @@ async function sfuUnpublishCamera(){ try { await fetch(SFU_BASE + '/unpublish?room=' + encodeURIComponent(roomID) + '&peer=' + pid, { method:'POST' }); } catch(_){} } fsm.send('DONE'); - $('btn-camera-share').classList.remove('hidden'); - $('btn-camera-stop').classList.add('hidden'); + /* share / stop visibility is driven by applyPublishStateUI from + * the publishSpec 'off' state. */ logLine('', 'camera off'); try { sessionStorage.removeItem(ACTIVE_CAM_KEY); } catch(_){} } @@ -3690,9 +3690,7 @@ roomMachines.call.observe(({ state, prev, ctx }) => { /* Publish-FSM trace observers — one per kind (mic / screen / camera). * Logs every transition so the page log shows the publish lifecycle - * synchronously with the imperative sfuPublish* / sfuUnpublish* flow. - * Future side-effect migrations can hang off these observers (the - * imperative code path will shrink as transitions take over). */ + * synchronously with the imperative sfuPublish* / sfuUnpublish* flow. */ for (const kind of ['mic', 'screen', 'camera']){ const m = roomMachines.pubs[kind]; if (!m) continue; @@ -3702,6 +3700,32 @@ for (const kind of ['mic', 'screen', 'camera']){ }); } +/* Share-button visibility observer — derives btn-{kind}-share / + * btn-{kind}-stop visibility from the publishSpec state. Single + * source of truth: the buttons reflect the FSM, not whoever last + * called sfuPublishX. Fox 2026-06-04 directive — every system as a + * state machine, scattered classList writes converge into one + * observer per FSM. */ +function applyPublishStateUI(kind, state){ + const share = $('btn-'+kind+'-share'); + const stop = $('btn-'+kind+'-stop'); + if (!share || !stop) return; + /* sharing = anywhere between START and STOP. The 'stopping' state + * is transient and we treat it as "still showing the stop button" + * so the UI doesn't flicker during teardown. */ + const isSharing = state === 'acquiring' || state === 'negotiating' || state === 'live' || state === 'stopping'; + share.classList.toggle('hidden', isSharing); + stop.classList.toggle('hidden', !isSharing); +} +for (const kind of ['screen', 'camera']){ + const m = roomMachines.pubs[kind]; + if (!m) continue; + m.observe(({ state, prev }) => { + if (prev === null || state === prev) return; + try { applyPublishStateUI(kind, state); } catch(e){ logLine('err','pub.'+kind+' UI: '+e.message); } + }); +} + let ws = null, wantConnected = false, sigKey = null, sigReconnect = null; function send(obj){ if (ws && ws.readyState===1) ws.send(JSON.stringify(obj)); } @@ -5768,8 +5792,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');