zebra-spaces: applyPublishStateUI — share/stop button visibility from publishSpec FSM

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.
This commit is contained in:
Russell Ballestrini 2026-06-04 13:58:04 -04:00
parent 07d0af91d6
commit 10ccea9cb6
No known key found for this signature in database

View file

@ -2547,8 +2547,8 @@ async function sfuPublishScreen(){
/* render a muted local preview so the publisher sees what they're /* render a muted local preview so the publisher sees what they're
* sharing — SFU does not echo the publisher's own stream back */ * sharing — SFU does not echo the publisher's own stream back */
renderScreenTile(myKeys.pubHex, stream, { local: true }); renderScreenTile(myKeys.pubHex, stream, { local: true });
$('btn-screen-share').classList.add('hidden'); /* share / stop button visibility is driven by applyPublishStateUI
$('btn-screen-stop').classList.remove('hidden'); * via the publishSpec observer (pubs.screen state). */
} }
/* sfuRebuildScreenPC — silent recovery path used by the screen /* sfuRebuildScreenPC — silent recovery path used by the screen
* publisher's onconnectionstatechange when the PC fails BUT the * 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(_){} try { await fetch(SFU_BASE + '/unpublish?room=' + encodeURIComponent(roomID) + '&peer=' + pid, { method:'POST' }); } catch(_){}
} }
fsm.send('DONE'); fsm.send('DONE');
$('btn-screen-share').classList.remove('hidden'); /* share / stop visibility is driven by applyPublishStateUI from
$('btn-screen-stop').classList.add('hidden'); * the publishSpec 'off' state. */
logLine('', 'screen share stopped'); logLine('', 'screen share stopped');
} }
@ -2766,8 +2766,8 @@ async function sfuPublishCamera(){
() => { sfuCameraPC = null; sfuCameraPeerID = null; sfuCameraStream = null; fsm.send('STOP'); fsm.send('DONE'); }); () => { sfuCameraPC = null; sfuCameraPeerID = null; sfuCameraStream = null; fsm.send('STOP'); fsm.send('DONE'); });
logLine('', 'sfu: camera on as '+sfuCameraPeerID); logLine('', 'sfu: camera on as '+sfuCameraPeerID);
renderCameraTile(myKeys.pubHex, stream, { local: true }); renderCameraTile(myKeys.pubHex, stream, { local: true });
$('btn-camera-share').classList.add('hidden'); /* share / stop visibility is driven by applyPublishStateUI via
$('btn-camera-stop').classList.remove('hidden'); * the publishSpec observer (pubs.camera state). */
try { sessionStorage.setItem(ACTIVE_CAM_KEY, '1'); } catch(_){} try { sessionStorage.setItem(ACTIVE_CAM_KEY, '1'); } catch(_){}
} }
async function sfuUnpublishCamera(){ async function sfuUnpublishCamera(){
@ -2782,8 +2782,8 @@ async function sfuUnpublishCamera(){
try { await fetch(SFU_BASE + '/unpublish?room=' + encodeURIComponent(roomID) + '&peer=' + pid, { method:'POST' }); } catch(_){} try { await fetch(SFU_BASE + '/unpublish?room=' + encodeURIComponent(roomID) + '&peer=' + pid, { method:'POST' }); } catch(_){}
} }
fsm.send('DONE'); fsm.send('DONE');
$('btn-camera-share').classList.remove('hidden'); /* share / stop visibility is driven by applyPublishStateUI from
$('btn-camera-stop').classList.add('hidden'); * the publishSpec 'off' state. */
logLine('', 'camera off'); logLine('', 'camera off');
try { sessionStorage.removeItem(ACTIVE_CAM_KEY); } catch(_){} 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). /* Publish-FSM trace observers — one per kind (mic / screen / camera).
* Logs every transition so the page log shows the publish lifecycle * Logs every transition so the page log shows the publish lifecycle
* synchronously with the imperative sfuPublish* / sfuUnpublish* flow. * 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). */
for (const kind of ['mic', 'screen', 'camera']){ for (const kind of ['mic', 'screen', 'camera']){
const m = roomMachines.pubs[kind]; const m = roomMachines.pubs[kind];
if (!m) continue; 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; let ws = null, wantConnected = false, sigKey = null, sigReconnect = null;
function send(obj){ if (ws && ws.readyState===1) ws.send(JSON.stringify(obj)); } 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');
<footer style="margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace"> <footer style="margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
<span id="pi-seal" style="color:#777;cursor:default;user-select:none" title="">page integrity</span> &nbsp;·&nbsp; built <span class="stamp-date">2026-06-04</span><br> <span id="pi-seal" style="color:#777;cursor:default;user-select:none" title="">page integrity</span> &nbsp;·&nbsp; built <span class="stamp-date">2026-06-04</span><br>
md5 <span class="stamp-md5">bf20196804c9e6115349da69eae1a7ce</span><br> md5 <span class="stamp-md5">019065ca233443b76d222b5a51544179</span><br>
sha256 <span class="stamp-sha">19a14b121c397a48bce3dacec5ef2f2c7b35d4112e5f959339e7072a612367bb</span><br> sha256 <span class="stamp-sha">b4d78f2ff6a88a511abfacce8af816db2c6d235f80e0ebcd154abad002463e27</span><br>
<span style="color:#bbb">hashes are of this page with these two fields zeroed — to verify, blank them and re-hash</span><br> <span style="color:#bbb">hashes are of this page with these two fields zeroed — to verify, blank them and re-hash</span><br>
<span style="color:#bbb">one self-contained file — <strong>save a copy</strong> and verify against these hashes; point at your own servers with ?signal= and ?turncred=, or <a href="host-your-own.html" style="color:#999">host your own community</a></span> <span style="color:#bbb">one self-contained file — <strong>save a copy</strong> and verify against these hashes; point at your own servers with ?signal= and ?turncred=, or <a href="host-your-own.html" style="color:#999">host your own community</a></span>
</footer> </footer>