feat: replace native voice select with custom styled dropdown
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user