feat: replace speed slider with preset buttons, replace voice grid with dropdown
This commit is contained in:
@@ -2332,21 +2332,19 @@ const chapterTmpl = `
|
||||
hidden
|
||||
class="fixed inset-x-4 top-1/2 -translate-y-1/2 max-h-[75vh] overflow-y-auto sm:absolute sm:inset-x-auto sm:top-auto sm:translate-y-0 sm:bottom-[calc(100%+0.25rem)] sm:right-[max(0.5rem,calc(50%-32rem+0.5rem))] sm:w-80 sm:max-h-[80vh] bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl z-[100]">
|
||||
|
||||
<!-- hidden native select keeps existing JS working unchanged -->
|
||||
<select id="tts-voice" class="sr-only" aria-hidden="true" tabindex="-1">
|
||||
{{range .Voices}}
|
||||
<option value="{{.ID}}"{{if eq .ID $.DefaultVoice}} selected{{end}}>{{.Name}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
|
||||
<div class="p-4 space-y-4">
|
||||
|
||||
<!-- Speed -->
|
||||
<label class="block">
|
||||
<span class="block text-xs text-zinc-500 mb-1.5">Speed — <span id="tts-speed-label">1.0×</span></span>
|
||||
<input id="tts-speed" type="range" min="0.5" max="2" step="0.1" value="1"
|
||||
class="w-full accent-amber-500 cursor-pointer" />
|
||||
</label>
|
||||
<div>
|
||||
<span class="block text-xs text-zinc-500 mb-1.5">Speed</span>
|
||||
<div id="speed-btns" class="flex gap-1.5">
|
||||
<button type="button" data-speed="1" onclick="selectSpeed(this)" class="speed-btn flex-1 py-1 rounded-lg border text-xs font-medium transition-colors border-zinc-700 bg-zinc-800 text-zinc-300 hover:border-zinc-500 hover:text-zinc-100">1×</button>
|
||||
<button type="button" data-speed="1.25" onclick="selectSpeed(this)" class="speed-btn flex-1 py-1 rounded-lg border text-xs font-medium transition-colors border-zinc-700 bg-zinc-800 text-zinc-300 hover:border-zinc-500 hover:text-zinc-100">1.25×</button>
|
||||
<button type="button" data-speed="1.5" onclick="selectSpeed(this)" class="speed-btn flex-1 py-1 rounded-lg border text-xs font-medium transition-colors border-amber-500 bg-amber-500/10 text-amber-300">1.5×</button>
|
||||
<button type="button" data-speed="1.75" onclick="selectSpeed(this)" class="speed-btn flex-1 py-1 rounded-lg border text-xs font-medium transition-colors border-zinc-700 bg-zinc-800 text-zinc-300 hover:border-zinc-500 hover:text-zinc-100">1.75×</button>
|
||||
<button type="button" data-speed="2" onclick="selectSpeed(this)" class="speed-btn flex-1 py-1 rounded-lg border text-xs font-medium transition-colors border-zinc-700 bg-zinc-800 text-zinc-300 hover:border-zinc-500 hover:text-zinc-100">2×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toggles -->
|
||||
<label class="flex items-center justify-between gap-3 cursor-pointer select-none">
|
||||
@@ -2361,23 +2359,16 @@ const chapterTmpl = `
|
||||
<!-- Divider -->
|
||||
<div class="border-t border-zinc-800"></div>
|
||||
|
||||
<!-- Voice grid -->
|
||||
<!-- Voice selector -->
|
||||
<div>
|
||||
<span class="block text-xs text-zinc-500 mb-2">Voice</span>
|
||||
<div id="voice-grid" class="grid grid-cols-2 gap-1.5">
|
||||
<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">
|
||||
{{range .Voices}}
|
||||
<button type="button"
|
||||
data-voice="{{.ID}}"
|
||||
onclick="selectVoice(this)"
|
||||
class="voice-btn flex items-center gap-2 px-2.5 py-1.5 rounded-lg border text-left transition-colors
|
||||
{{if eq .ID $.DefaultVoice}}border-amber-500 bg-amber-500/10 text-amber-300{{else}}border-zinc-700 bg-zinc-800 text-zinc-300 hover:border-zinc-500 hover:text-zinc-100{{end}}">
|
||||
<span class="flex-1 min-w-0">
|
||||
<span class="block text-[0.8rem] font-medium leading-tight truncate">{{.Name}}</span>
|
||||
<span class="block text-[0.65rem] text-zinc-500 leading-tight">{{.Lang}} · {{.Gender}}</span>
|
||||
</span>
|
||||
</button>
|
||||
<option value="{{.ID}}"{{if eq .ID $.DefaultVoice}} selected{{end}}>{{.Name}} ({{.Lang}} · {{.Gender}})</option>
|
||||
{{end}}
|
||||
</div>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -2421,8 +2412,7 @@ const chapterTmpl = `
|
||||
var statusEl = document.getElementById('tts-status');
|
||||
var statusBar = document.getElementById('tts-status-bar');
|
||||
var voiceSel = document.getElementById('tts-voice');
|
||||
var speedSlider = document.getElementById('tts-speed');
|
||||
var speedLabel = document.getElementById('tts-speed-label');
|
||||
var speedBtns = document.querySelectorAll('.speed-btn');
|
||||
var autoplayChk = document.getElementById('tts-autoplay');
|
||||
var autoscrollChk = document.getElementById('tts-autoscroll');
|
||||
var article = document.getElementById('chapter-article');
|
||||
@@ -2537,9 +2527,26 @@ const chapterTmpl = `
|
||||
|
||||
// ── localStorage settings ─────────────────────────────────────────────────
|
||||
var LS_SPEED = 'tts_speed', LS_VOICE = 'tts_voice', LS_AUTONEXT = 'tts_autonext', LS_AUTOSCROLL = 'tts_autoscroll';
|
||||
function selectSpeed(btn) {
|
||||
speedBtns.forEach(function (b) {
|
||||
var active = b === btn;
|
||||
b.classList.toggle('speed-active', active);
|
||||
b.classList.toggle('border-amber-500', active);
|
||||
b.classList.toggle('bg-amber-500/10', active);
|
||||
b.classList.toggle('text-amber-300', active);
|
||||
b.classList.toggle('border-zinc-700', !active);
|
||||
b.classList.toggle('bg-zinc-800', !active);
|
||||
b.classList.toggle('text-zinc-300', !active);
|
||||
});
|
||||
try { localStorage.setItem(LS_SPEED, btn.dataset.speed); } catch(_) {}
|
||||
prefetchFired = false;
|
||||
}
|
||||
window.selectSpeed = selectSpeed;
|
||||
(function loadSettings() {
|
||||
var spd = localStorage.getItem(LS_SPEED);
|
||||
if (spd !== null) { speedSlider.value = spd; speedLabel.textContent = parseFloat(spd).toFixed(1) + '\u00D7'; }
|
||||
var activeSpd = spd || '1.5';
|
||||
var btn = document.querySelector('.speed-btn[data-speed="' + activeSpd + '"]');
|
||||
if (btn) selectSpeed(btn);
|
||||
var vc = localStorage.getItem(LS_VOICE);
|
||||
if (vc !== null) { var opt = voiceSel.querySelector('option[value="' + vc + '"]'); if (opt) voiceSel.value = vc; }
|
||||
var an = localStorage.getItem(LS_AUTONEXT);
|
||||
@@ -2547,12 +2554,6 @@ const chapterTmpl = `
|
||||
var as = localStorage.getItem(LS_AUTOSCROLL);
|
||||
if (as !== null) autoscrollChk.checked = as !== 'false';
|
||||
})();
|
||||
speedSlider.addEventListener('input', function () {
|
||||
if (stale()) return;
|
||||
speedLabel.textContent = parseFloat(speedSlider.value).toFixed(1) + '\u00D7';
|
||||
localStorage.setItem(LS_SPEED, speedSlider.value);
|
||||
prefetchFired = false;
|
||||
});
|
||||
voiceSel.addEventListener('change', function () { if (stale()) return; localStorage.setItem(LS_VOICE, voiceSel.value); prefetchFired = false; });
|
||||
autoplayChk.addEventListener('change', function () { if (stale()) return; localStorage.setItem(LS_AUTONEXT, autoplayChk.checked ? 'true' : 'false'); });
|
||||
autoscrollChk.addEventListener('change', function () { if (stale()) return; localStorage.setItem(LS_AUTOSCROLL, autoscrollChk.checked ? 'true' : 'false'); });
|
||||
@@ -2589,11 +2590,15 @@ const chapterTmpl = `
|
||||
var SVG_PLAY = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="18" height="18"><path d="M8 5v14l11-7z"/></svg>';
|
||||
var SVG_PAUSE = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="18" height="18"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>';
|
||||
function setPlayIcon(icon) { playerPlayIcon.innerHTML = icon; }
|
||||
function getSpeed() {
|
||||
var b = document.querySelector('.speed-btn.speed-active');
|
||||
return b ? parseFloat(b.dataset.speed) : 1.5;
|
||||
}
|
||||
|
||||
function setGenerating() {
|
||||
setPlayIcon('\u231B');
|
||||
playerPlayBtn.disabled = true;
|
||||
voiceSel.disabled = true; speedSlider.disabled = true;
|
||||
voiceSel.disabled = true; speedBtns.forEach(function(b){b.disabled=true;});
|
||||
setBadge(playerStateBadge, 'generating');
|
||||
playerStateBadge.hidden = false;
|
||||
playerTitle.textContent = 'Ch.\u00a0' + CHAPTER_N;
|
||||
@@ -2604,7 +2609,7 @@ const chapterTmpl = `
|
||||
if (stale()) return;
|
||||
setPlayIcon(SVG_PAUSE);
|
||||
playerPlayBtn.disabled = false;
|
||||
voiceSel.disabled = false; speedSlider.disabled = false;
|
||||
voiceSel.disabled = false; speedBtns.forEach(function(b){b.disabled=false;});
|
||||
setBadge(playerStateBadge, 'playing');
|
||||
playerStateBadge.hidden = false;
|
||||
var pct = (audio.duration && isFinite(audio.duration))
|
||||
@@ -2631,7 +2636,7 @@ const chapterTmpl = `
|
||||
highlightPara(-1);
|
||||
setPlayIcon(SVG_PLAY);
|
||||
playerPlayBtn.disabled = false;
|
||||
voiceSel.disabled = false; speedSlider.disabled = false;
|
||||
voiceSel.disabled = false; speedBtns.forEach(function(b){b.disabled=false;});
|
||||
if (genStartTime > 0) {
|
||||
var elapsed = Math.round((Date.now() - genStartTime) / 1000);
|
||||
var label = elapsed >= 60 ? Math.floor(elapsed/60) + 'm ' + (elapsed%60) + 's' : elapsed + 's';
|
||||
@@ -2652,7 +2657,7 @@ const chapterTmpl = `
|
||||
highlightPara(-1);
|
||||
setPlayIcon(SVG_PLAY);
|
||||
playerPlayBtn.disabled = false;
|
||||
voiceSel.disabled = false; speedSlider.disabled = false;
|
||||
voiceSel.disabled = false; speedBtns.forEach(function(b){b.disabled=false;});
|
||||
setBadge(playerStateBadge, 'error');
|
||||
playerStateBadge.hidden = false;
|
||||
playerTitle.textContent = 'Error';
|
||||
@@ -2679,7 +2684,7 @@ const chapterTmpl = `
|
||||
fetch('/ui/audio/' + SLUG + '/' + chapterN, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ voice: voiceSel.value, speed: parseFloat(speedSlider.value) }),
|
||||
body: JSON.stringify({ voice: voiceSel.value, speed: getSpeed() }),
|
||||
signal: ctrl.signal
|
||||
})
|
||||
.then(function (res) {
|
||||
@@ -2714,7 +2719,7 @@ const chapterTmpl = `
|
||||
fetch('/ui/audio/' + SLUG + '/' + NEXT_N, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ voice: voiceSel.value, speed: parseFloat(speedSlider.value) })
|
||||
body: JSON.stringify({ voice: voiceSel.value, speed: getSpeed() })
|
||||
})
|
||||
.then(function (res) { return res.ok ? res.json() : Promise.reject(res.status); })
|
||||
.then(function (data) { if (!stale()) { prefetchedUrl = (data && data.url) ? data.url : null; setNextState('ready'); } })
|
||||
@@ -2842,33 +2847,10 @@ const chapterTmpl = `
|
||||
}());
|
||||
}());
|
||||
|
||||
// ── Voice card picker ─────────────────────────────────────────────────────
|
||||
window.selectVoice = function (btn) {
|
||||
var voiceSel = document.getElementById('tts-voice');
|
||||
var grid = document.getElementById('voice-grid');
|
||||
if (!voiceSel || !grid) return;
|
||||
voiceSel.value = btn.dataset.voice;
|
||||
try { localStorage.setItem('tts_voice', btn.dataset.voice); } catch(_) {}
|
||||
grid.querySelectorAll('.voice-btn').forEach(function (b) {
|
||||
var active = b === btn;
|
||||
b.classList.toggle('border-amber-500', active);
|
||||
b.classList.toggle('bg-amber-500/10', active);
|
||||
b.classList.toggle('text-amber-300', active);
|
||||
b.classList.toggle('border-zinc-700', !active);
|
||||
b.classList.toggle('bg-zinc-800', !active);
|
||||
b.classList.toggle('text-zinc-300', !active);
|
||||
});
|
||||
// ── Voice change handler ──────────────────────────────────────────────────
|
||||
window.onVoiceChange = function (sel) {
|
||||
try { localStorage.setItem('tts_voice', sel.value); } catch(_) {}
|
||||
};
|
||||
(function syncVoiceGrid() {
|
||||
var voiceSel = document.getElementById('tts-voice');
|
||||
var grid = document.getElementById('voice-grid');
|
||||
if (!voiceSel || !grid) return;
|
||||
var saved = null;
|
||||
try { saved = localStorage.getItem('tts_voice'); } catch(_) {}
|
||||
if (!saved) return;
|
||||
var btn = grid.querySelector('[data-voice="' + saved + '"]');
|
||||
if (btn) window.selectVoice(btn);
|
||||
})();
|
||||
</script>`
|
||||
|
||||
func (s *Server) handleChapter(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
Reference in New Issue
Block a user