Fix tuner auto-open flash: opt-in + persist (issue E, stage 1/3) (#655)

The tuner self-closes on song:play; autoplay fires it right after a song
switch, so an auto-opened tuner flashed shut ~1s later. An arrangement
switch (which never arms autoplay) instead persisted — the opposite
tester reports, and not the mic.

- New opt-in setting autoOpenOnTuningChange (tuner Settings, default OFF)
- An auto-opened tuner persists: it ignores the autoplay song:play, stray
  outside-clicks, and same-screen re-emits, closing only via the new
  in-panel x / Skip buttons or leaving the song. A manual open keeps the
  classic click-away / play-to-close behaviour.
- Adds the panel's first in-box close (x + contextual Skip).
- All in the tuner plugin; no core app.js changes.

Default (opt-in vs opt-out) is teed up for Byron to flip one boolean.
Staged follow-ups: E1.5 = instrument-coverage smart prompting + badge
cue; E2 = holdAutoplay gate.

Tests: tests/js/tuner_auto_open.test.js (opt-in gate, persist mode,
play/click-proofing).


Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ChrisBeWithYou
2026-07-01 10:24:39 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent bc4d2a3592
commit 6bfd92aa06
6 changed files with 120 additions and 13 deletions
+2
View File
@@ -37,6 +37,7 @@ def setup(app: FastAPI, context: dict):
"showFloatingButton": True,
"visualizationMode": "default",
"audioInputMode": "auto",
"autoOpenOnTuningChange": False,
}
if not config_file.exists():
return defaults
@@ -55,6 +56,7 @@ def setup(app: FastAPI, context: dict):
res["visualizationMode"] = str(data.get("visualizationMode", "default"))
raw_mode = str(data.get("audioInputMode", "auto"))
res["audioInputMode"] = raw_mode if raw_mode in ("auto", "browser") else "auto"
res["autoOpenOnTuningChange"] = bool(data.get("autoOpenOnTuningChange", False))
if not isinstance(res["customTunings"], dict):
res["customTunings"] = {}
+34 -8
View File
@@ -142,6 +142,12 @@
async function _maybeAutoOpenOnTuningChange() {
if (!document.getElementById('player')?.classList.contains('active')) return;
// Opt-in (default off): only auto-open when the user enabled it in the
// tuner settings. Ensure config is loaded so the first song:ready after
// boot still reads the real flag; fail closed if it can't load.
if (!_state._serverConfig) { try { await loadConfig(); } catch (_) { /* */ } }
if (!_state._serverConfig || !_state._serverConfig.autoOpenOnTuningChange) return;
const songInfo = window.highway?.getSongInfo?.() || window.feedBack?.currentSong;
if (!songInfo) return;
@@ -167,7 +173,7 @@
_lastAutoOpenSessionKey = sessionKey;
try {
await window.tuner.enable();
await window.tuner.enable({ auto: true });
if (myGen !== _autoOpenGeneration) return;
} catch (e) {
console.warn('Tuner: auto-open failed:', e && e.message ? e.message : e);
@@ -341,8 +347,15 @@
}
}
async function enable() {
async function enable(opts) {
if (_state.enabled) return;
// An AUTO-open (the "this song needs a different tuning" nudge) must
// PERSIST: it is NOT dismissed by the autoplay song:play that follows
// song entry, a stray click, or a same-screen re-emit — only by the
// Skip/× buttons or leaving the song. A manual open keeps the classic
// click-away / play-to-close behaviour.
const auto = !!(opts && opts.auto);
_state.autoOpened = auto;
await _loadScript('/api/plugins/tuner/utils/tuning-utils.js');
await _loadScript('/api/plugins/tuner/utils/audio.js');
await _loadScript('/api/plugins/tuner/utils/ui.js');
@@ -371,15 +384,27 @@
_state.uiContainer.classList.add('flex');
_tunerUIApi.positionPanel();
_tunerUIApi.updateFreeTuneUI();
// "Skip" is the auto-open nudge's explicit dismiss; hidden for a manual
// open (the × / click-away already close those).
if (_state.skipBtn) _state.skipBtn.classList.toggle('hidden', !auto);
// Close when clicking outside the panel. Deferred so the badge's
// opening click doesn't bubble up to the document and fire immediately.
if (_outsideClickClose) document.removeEventListener('click', _outsideClickClose);
_outsideClickClose = () => { if (_state.enabled) disable(); };
setTimeout(() => { if (_outsideClickClose) document.addEventListener('click', _outsideClickClose, { once: true }); }, 0);
// Close when clicking outside the panel. Deferred so the badge's opening
// click doesn't bubble up to the document and fire immediately. Skipped
// for an auto-open: the user never clicked to open it, so their first
// unrelated click must not dismiss it (it persists until Skip/×/leave).
if (!auto) {
if (_outsideClickClose) document.removeEventListener('click', _outsideClickClose);
_outsideClickClose = () => { if (_state.enabled) disable(); };
setTimeout(() => { if (_outsideClickClose) document.addEventListener('click', _outsideClickClose, { once: true }); }, 0);
}
if (window.feedBack && !_onScreenChanged) {
_onScreenChanged = () => { disable(); };
// Auto-opened: close only when we actually LEAVE the song — a player
// re-emit while staying put must not tear down the nudge. Manual:
// unchanged (any screen change closes it).
_onScreenChanged = () => {
if (!_state.autoOpened || !document.getElementById('player')?.classList.contains('active')) disable();
};
_onSongReady = () => {
_tunerUIApi.renderTuningOptions();
if (_state.selectedTuningName === '_current') _syncCurrentTuning();
@@ -409,6 +434,7 @@
const wasEnabled = _state.enabled;
const onPlayer = document.getElementById('player')?.classList.contains('active');
_state.enabled = false;
_state.autoOpened = false;
_state.manualTargetFreq = null;
if (_outsideClickClose) { document.removeEventListener('click', _outsideClickClose); _outsideClickClose = null; }
if (_state.activeViz) { _state.activeViz.destroy(); _state.activeViz = null; }
+19
View File
@@ -10,6 +10,17 @@
</label>
</div>
<div class="flex items-center justify-between bg-dark-900/50 p-3 rounded-xl border border-gray-800/50">
<div>
<h3 class="text-sm font-medium text-gray-200">Auto-open on tuning change</h3>
<p class="text-[11px] text-gray-500">When a song (or arrangement) needs a different tuning, pop the tuner open automatically. It stays open until you Skip or close it.</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="tuner-auto-open" class="sr-only peer" onchange="window._tunerToggleAutoOpen(this.checked)">
<div class="w-9 h-5 bg-gray-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-accent"></div>
</label>
</div>
<script>
if (window.feedBackDesktop && window.feedBackDesktop.isDesktop) {
document.currentScript.insertAdjacentHTML('beforebegin', `
@@ -107,6 +118,9 @@
const browserAudioToggle = document.getElementById('tuner-force-browser-audio');
if (browserAudioToggle) browserAudioToggle.checked = config.audioInputMode === 'browser';
const autoOpenToggle = document.getElementById('tuner-auto-open');
if (autoOpenToggle) autoOpenToggle.checked = config.autoOpenOnTuningChange === true;
render();
} catch (e) { console.error('Tuner settings: load failed', e); }
}
@@ -121,6 +135,11 @@
save();
};
window._tunerToggleAutoOpen = (enabled) => {
config.autoOpenOnTuningChange = enabled;
save();
};
async function save(opts) {
try {
await fetch('/api/plugins/tuner/config', {
+25 -1
View File
@@ -565,6 +565,16 @@ window._tunerUI = function(state, actions) {
title.textContent = 'TUNER';
header.appendChild(title);
// Explicit close (the panel had no in-box dismiss before; persist mode
// needs one). Mirrors the settings gear on the opposite side.
const closeBtn = document.createElement('button');
closeBtn.className = 'absolute left-0 text-fb-textDim hover:text-fb-text transition-colors text-lg leading-none';
closeBtn.setAttribute('aria-label', 'Close tuner');
closeBtn.title = 'Close';
closeBtn.textContent = '×';
closeBtn.onclick = () => actions.disable();
header.appendChild(closeBtn);
const settingsBtn = document.createElement('button');
settingsBtn.className = 'absolute right-0 text-fb-textDim hover:text-fb-text transition-colors';
settingsBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>`;
@@ -624,6 +634,16 @@ window._tunerUI = function(state, actions) {
state.vizContainer.className = 'w-full';
state.uiContainer.appendChild(state.vizContainer);
// Auto-open nudge's explicit dismiss (hidden unless auto-opened; enable()
// toggles it). Closes the same way as the × — disable().
const skipBtn = document.createElement('button');
skipBtn.className = 'tuner-skip-btn hidden w-full mt-3 text-[11px] text-fb-textDim hover:text-fb-text border border-fb-border/40 hover:border-fb-border/70 rounded-lg py-1.5 transition-colors';
skipBtn.textContent = 'Skip';
skipBtn.title = 'Dismiss the tuner for this song';
skipBtn.onclick = () => actions.disable();
state.skipBtn = skipBtn;
state.uiContainer.appendChild(skipBtn);
document.body.appendChild(state.uiContainer);
state.uiContainer.addEventListener('click', (e) => e.stopPropagation());
}
@@ -683,7 +703,11 @@ window._tunerUI = function(state, actions) {
const handlePlay = () => {
updateFloatingButtonVisibility();
if (state.enabled) actions.disable();
// A manually-opened tuner closes when playback starts (you don't tune
// while playing). An AUTO-opened tuner PERSISTS through the autoplay
// song:play that immediately follows song entry — that auto-close was
// the "opens then vanishes ~1s later" flash. It closes via Skip/×/leave.
if (state.enabled && !state.autoOpened) actions.disable();
};
const handleStop = () => updateFloatingButtonVisibility();