zebra-report: deploy label-based mic/speaker device persistence

This commit is contained in:
russell@unturf.com 2026-06-04 12:36:11 -04:00
parent 9288555d8e
commit 6c214a19fb
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>