From 794f10a9a5b26a467bbe7848f6f660a3f5369fa2 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Thu, 4 Jun 2026 12:36:09 -0400 Subject: [PATCH] =?UTF-8?q?zebra-spaces:=20persist=20mic/speaker=20by=20LA?= =?UTF-8?q?BEL=20=E2=80=94=20survive=20Firefox=20pre-permission=20+=20Chro?= =?UTF-8?q?me=20ID=20rotation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- web/zebra-spaces.html | 72 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 65 insertions(+), 7 deletions(-) diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html index 7bf089e..0a1b028 100644 --- a/web/zebra-spaces.html +++ b/web/zebra-spaces.html @@ -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