feat: replace native voice select with custom styled dropdown
Some checks failed
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled

This commit is contained in:
Admin
2026-03-02 14:05:24 +05:00
parent 92f900a8a9
commit 0a479cfe22

View File

@@ -2418,13 +2418,39 @@ const chapterTmpl = `
<!-- Voice selector -->
<div>
<span class="block text-xs text-zinc-500 mb-1.5">Voice</span>
<select id="tts-voice"
onchange="onVoiceChange(this)"
class="w-full bg-zinc-800 border border-zinc-700 text-zinc-200 text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-amber-500 cursor-pointer">
<!-- hidden native select — JS reads/writes .value here -->
<select id="tts-voice" class="sr-only" aria-hidden="true" tabindex="-1">
{{range .Voices}}
<option value="{{.ID}}"{{if eq .ID $.DefaultVoice}} selected{{end}}>{{.Name}} ({{.Lang}} · {{.Gender}})</option>
<option value="{{.ID}}"{{if eq .ID $.DefaultVoice}} selected{{end}}>{{.Name}}</option>
{{end}}
</select>
<!-- custom dropdown trigger -->
<button type="button" id="voice-trigger"
onclick="toggleVoiceDropdown()"
class="w-full flex items-center justify-between gap-2 px-3 py-2 rounded-lg border border-zinc-700 bg-zinc-800 text-zinc-200 text-sm hover:border-zinc-500 transition-colors">
<span id="voice-trigger-label" class="truncate">{{range .Voices}}{{if eq .ID $.DefaultVoice}}{{.Name}} ({{.Lang}} · {{.Gender}}){{end}}{{end}}</span>
<svg id="voice-trigger-chevron" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" width="14" height="14" class="flex-shrink-0 text-zinc-500 transition-transform"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<!-- dropdown list -->
<div id="voice-dropdown" hidden
class="mt-1 rounded-lg border border-zinc-700 bg-zinc-900 overflow-hidden shadow-xl">
<div class="max-h-48 overflow-y-auto overscroll-contain">
{{range .Voices}}
<button type="button"
data-voice="{{.ID}}"
data-label="{{.Name}} ({{.Lang}} · {{.Gender}})"
onclick="selectVoiceOption(this)"
class="voice-opt w-full flex items-center justify-between gap-3 px-3 py-2 text-left text-sm text-zinc-300 hover:bg-zinc-800 hover:text-zinc-100 transition-colors
{{if eq .ID $.DefaultVoice}}bg-zinc-800/60 text-zinc-100{{end}}">
<span class="truncate">{{.Name}}</span>
<span class="text-xs text-zinc-500 flex-shrink-0">{{.Lang}} · {{.Gender}}</span>
</button>
{{end}}
</div>
</div>
</div>
</div>
@@ -2641,6 +2667,15 @@ const chapterTmpl = `
e.target !== chapterListBtn && !chapterListBtn.contains(e.target)) {
closeChapterList();
}
// close voice dropdown if clicking outside it
var dd = document.getElementById('voice-dropdown');
var trigger = document.getElementById('voice-trigger');
if (dd && !dd.hidden && trigger &&
!dd.contains(e.target) && e.target !== trigger && !trigger.contains(e.target)) {
dd.hidden = true;
var chevron = document.getElementById('voice-trigger-chevron');
if (chevron) chevron.style.transform = '';
}
};
document.addEventListener('click', window.__ttsClickOutside);
@@ -2966,10 +3001,53 @@ const chapterTmpl = `
}());
}());
// ── Voice change handler ──────────────────────────────────────────────────
window.onVoiceChange = function (sel) {
try { localStorage.setItem('tts_voice', sel.value); } catch(_) {}
// ── Custom voice dropdown ─────────────────────────────────────────────────
window.toggleVoiceDropdown = function () {
var dd = document.getElementById('voice-dropdown');
var chevron = document.getElementById('voice-trigger-chevron');
if (!dd) return;
var opening = dd.hidden;
dd.hidden = !opening;
if (chevron) chevron.style.transform = opening ? 'rotate(180deg)' : '';
};
window.selectVoiceOption = function (btn) {
var voiceSel = document.getElementById('tts-voice');
var label = document.getElementById('voice-trigger-label');
var dd = document.getElementById('voice-dropdown');
var chevron = document.getElementById('voice-trigger-chevron');
if (!voiceSel) return;
// update hidden select
voiceSel.value = btn.dataset.voice;
try { localStorage.setItem('tts_voice', btn.dataset.voice); } catch(_) {}
// update trigger label
if (label) label.textContent = btn.dataset.label;
// highlight active row
if (dd) {
dd.querySelectorAll('.voice-opt').forEach(function (b) {
var active = b === btn;
b.classList.toggle('bg-zinc-800/60', active);
b.classList.toggle('text-zinc-100', active);
});
dd.hidden = true;
}
if (chevron) chevron.style.transform = '';
// fire change event so existing listener persists prefetchFired reset
voiceSel.dispatchEvent(new Event('change'));
};
// sync trigger label from localStorage on load
(function syncVoiceTrigger() {
var saved = null;
try { saved = localStorage.getItem('tts_voice'); } catch(_) {}
if (!saved) return;
var btn = document.querySelector('.voice-opt[data-voice="' + saved + '"]');
if (btn) window.selectVoiceOption(btn);
})();
</script>`
func (s *Server) handleChapter(w http.ResponseWriter, r *http.Request) {