zebra-spaces: persist mic/speaker by LABEL — survive Firefox pre-permission + Chrome ID rotation

Two regressions in the existing localStorage-restore path that forced
the host to re-pick the monitor input after every hard refresh:

1. Pre-permission Firefox returns deviceId='' for every device in
   enumerateDevices(). refreshMicList ran at page load BEFORE the
   entry-click gesture granted gUM, every match against the saved
   micDeviceId failed, and the `else: micDeviceId = sel.value` clobber
   silently reset the saved selection to ''. After that, getMic()
   picked the default mic instead of the monitor.

   Fix: detect the all-empty case (allEmpty) and bail out — preserve
   the saved selection until a real post-permission enumerate runs.

2. Chrome (and other browsers in some configs) rotates deviceIds
   across browser sessions for privacy. Saved deviceId stops matching
   anything. Old code fell through to the clobber.

   Fix: also save the human-readable label (e.g. "Monitor of WH-
   1000XM5") and fall back to label-match when deviceId doesn't
   resolve. When the label matches, refresh micDeviceId to the
   current session's value + persist the new deviceId.

Two new localStorage keys: MIC_LABEL_KEY, SPK_LABEL_KEY. Change
handlers strip the 'input '/'output ' prefix from the option's
textContent before saving. Same pattern applied to both refreshMic-
List and refreshSpeakerList.

Doesn't help when the saved label also doesn't match any current
device (e.g. headphones unplugged) — sel.value defaults to first
device, same as before. But the common case fox 2026-06-04 hit
("monitor selected, hard refresh, monitor not restored, manual re-
pick needed") is now zero-tap.
This commit is contained in:
Russell Ballestrini 2026-06-04 12:36:09 -04:00
parent 165d85759d
commit 794f10a9a5
No known key found for this signature in database

View file

@ -1331,7 +1331,9 @@ const ID_KEY = 'zebra-spaces-id-v1';
const HANDLE_KEY = 'zebra-spaces-handle-v1';
const MUSIC_MODE_KEY = 'zebra-spaces-music-mode-v1';
const MIC_DEV_KEY = 'zebra-spaces-mic-device-v1';
const MIC_LABEL_KEY = 'zebra-spaces-mic-label-v1';
const SPK_DEV_KEY = 'zebra-spaces-spk-device-v1';
const SPK_LABEL_KEY = 'zebra-spaces-spk-label-v1';
const CAM_DEV_KEY = 'zebra-spaces-cam-device-v1';
const THEME_KEY = 'zebra-theme-v1';
/* sessionStorage (per-tab) — tracks which call this tab is in so a
@ -1568,10 +1570,12 @@ $('handle').value = myHandle;
* last-picked mic + camera deviceIds. Declared up here so the restore
* runs before their downstream `let` would put them in the temporal
* dead zone; downstream code now reads from these existing bindings. */
let musicMode = false, micDeviceId = '', speakerDeviceId = '', cameraDeviceId = '';
let musicMode = false, micDeviceId = '', micDeviceLabel = '', speakerDeviceId = '', speakerDeviceLabel = '', cameraDeviceId = '';
try { musicMode = localStorage.getItem(MUSIC_MODE_KEY) === '1'; } catch(_){}
try { micDeviceId = localStorage.getItem(MIC_DEV_KEY) || ''; } catch(_){}
try { micDeviceLabel = localStorage.getItem(MIC_LABEL_KEY) || ''; } catch(_){}
try { speakerDeviceId = localStorage.getItem(SPK_DEV_KEY) || ''; } catch(_){}
try { speakerDeviceLabel = localStorage.getItem(SPK_LABEL_KEY) || ''; } catch(_){}
try { cameraDeviceId = localStorage.getItem(CAM_DEV_KEY) || ''; } catch(_){}
if ($('music-mode')) $('music-mode').checked = musicMode;
renderIdentity();
@ -2956,8 +2960,37 @@ async function refreshMicList(){
o.value = m.deviceId; o.textContent = 'input ' + (m.label || ('microphone '+(i+1)));
sel.appendChild(o);
});
if (micDeviceId && mics.some(m=>m.deviceId===micDeviceId)) sel.value = micDeviceId;
else micDeviceId = sel.value;
/* Pre-permission Firefox returns empty deviceIds for every device; the
* old `else: micDeviceId = sel.value` clobbered our saved selection to
* '' the moment we ran this on page load (before any gUM grant). After
* that, getMic() picked the default mic instead of the saved monitor,
* and the host had to re-pick the input every refresh.
*
* Detection: if every enumerated device has deviceId === '', we're
* still pre-permission — DON'T touch the saved micDeviceId. Wait until
* a real enumerate (post-getMic) before fixing up sel.value.
*
* Also fall back to LABEL-match when the saved deviceId doesn't match
* but the saved label does (Chrome rotates deviceIds across sessions,
* label is more stable). When a label match wins, refresh micDeviceId
* to the current session's deviceId so micConstraints() works. */
const allEmpty = mics.every(m => !m.deviceId);
if (allEmpty) return; /* pre-permission — preserve saved selection */
if (micDeviceId && mics.some(m=>m.deviceId===micDeviceId)){
sel.value = micDeviceId;
return;
}
if (micDeviceLabel){
const byLabel = mics.find(m => m.label === micDeviceLabel);
if (byLabel){
micDeviceId = byLabel.deviceId;
try { localStorage.setItem(MIC_DEV_KEY, micDeviceId); } catch(_){}
sel.value = micDeviceId;
logLine('', 'mic input restored by label match: '+micDeviceLabel);
return;
}
}
micDeviceId = sel.value;
} catch(e){ logLine('err','could not list inputs: '+e.message); }
}
/* Speaker output picker — routes peer audio to a specific sink (studio
@ -2987,8 +3020,27 @@ async function refreshSpeakerList(){
opt.value = o.deviceId; opt.textContent = 'output ' + (o.label || ('speaker '+(i+1)));
sel.appendChild(opt);
});
if (speakerDeviceId && outs.some(o=>o.deviceId===speakerDeviceId)) sel.value = speakerDeviceId;
else speakerDeviceId = sel.value;
/* same restore pattern as refreshMicList: don't clobber the saved
* speaker selection while we're pre-permission (all deviceIds empty),
* and fall back to label-match when deviceId rotation invalidates the
* saved id. */
const allEmpty = outs.every(o => !o.deviceId);
if (allEmpty) return;
if (speakerDeviceId && outs.some(o=>o.deviceId===speakerDeviceId)){
sel.value = speakerDeviceId;
return;
}
if (speakerDeviceLabel){
const byLabel = outs.find(o => o.label === speakerDeviceLabel);
if (byLabel){
speakerDeviceId = byLabel.deviceId;
try { localStorage.setItem(SPK_DEV_KEY, speakerDeviceId); } catch(_){}
sel.value = speakerDeviceId;
logLine('', 'output restored by label match: '+speakerDeviceLabel);
return;
}
}
speakerDeviceId = sel.value;
} catch(e){ logLine('err','could not list outputs: '+e.message); }
}
/* Apply current speakerDeviceId to a single <audio> element. Safe to call
@ -5045,13 +5097,19 @@ $('btn-mute').addEventListener('click', () => {
});
$('mic-select').addEventListener('change', async (e) => {
micDeviceId = e.target.value;
/* Also persist the human-readable label so we can re-resolve the
* same device across Chrome's per-session deviceId rotation. */
micDeviceLabel = (e.target.selectedOptions[0] && e.target.selectedOptions[0].textContent || '').replace(/^input\s+/, '');
try { localStorage.setItem(MIC_DEV_KEY, micDeviceId); } catch(_){}
try { localStorage.setItem(MIC_LABEL_KEY, micDeviceLabel); } catch(_){}
if (micStream){ try { await applyMicMode(); } catch(err){ logLine('err','input switch failed: '+err.message); await refreshMicList(); } }
});
if ($('speaker-select')){
$('speaker-select').addEventListener('change', async (e) => {
speakerDeviceId = e.target.value;
speakerDeviceLabel = (e.target.selectedOptions[0] && e.target.selectedOptions[0].textContent || '').replace(/^output\s+/, '');
try { localStorage.setItem(SPK_DEV_KEY, speakerDeviceId); } catch(_){}
try { localStorage.setItem(SPK_LABEL_KEY, speakerDeviceLabel); } catch(_){}
await applySinkToAll();
logLine('', 'output: '+(e.target.selectedOptions[0]?.textContent || 'default'));
});
@ -5375,8 +5433,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">
<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">55fa6a71aa44a14506015e88e724c10b</span><br>
sha256 <span class="stamp-sha">13041569d8641958fa844222165b9b6c2bd7af8e0750ee26af4545b74de1d5ba</span><br>
md5 <span class="stamp-md5">bd33f463261d6f66f7d1db7743c84b2d</span><br>
sha256 <span class="stamp-sha">bf69db896f0f32a8bc22838551c0403ead65d36e0e64abcc0f5b66d58e14f6bc</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>
</footer>