feat: replace speed slider with preset buttons, replace voice grid with dropdown
This commit is contained in:
@@ -2332,21 +2332,19 @@ const chapterTmpl = `
|
|||||||
hidden
|
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]">
|
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">
|
<div class="p-4 space-y-4">
|
||||||
|
|
||||||
<!-- Speed -->
|
<!-- Speed -->
|
||||||
<label class="block">
|
<div>
|
||||||
<span class="block text-xs text-zinc-500 mb-1.5">Speed — <span id="tts-speed-label">1.0×</span></span>
|
<span class="block text-xs text-zinc-500 mb-1.5">Speed</span>
|
||||||
<input id="tts-speed" type="range" min="0.5" max="2" step="0.1" value="1"
|
<div id="speed-btns" class="flex gap-1.5">
|
||||||
class="w-full accent-amber-500 cursor-pointer" />
|
<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>
|
||||||
</label>
|
<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 -->
|
<!-- Toggles -->
|
||||||
<label class="flex items-center justify-between gap-3 cursor-pointer select-none">
|
<label class="flex items-center justify-between gap-3 cursor-pointer select-none">
|
||||||
@@ -2361,23 +2359,16 @@ const chapterTmpl = `
|
|||||||
<!-- Divider -->
|
<!-- Divider -->
|
||||||
<div class="border-t border-zinc-800"></div>
|
<div class="border-t border-zinc-800"></div>
|
||||||
|
|
||||||
<!-- Voice grid -->
|
<!-- Voice selector -->
|
||||||
<div>
|
<div>
|
||||||
<span class="block text-xs text-zinc-500 mb-2">Voice</span>
|
<span class="block text-xs text-zinc-500 mb-1.5">Voice</span>
|
||||||
<div id="voice-grid" class="grid grid-cols-2 gap-1.5">
|
<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}}
|
{{range .Voices}}
|
||||||
<button type="button"
|
<option value="{{.ID}}"{{if eq .ID $.DefaultVoice}} selected{{end}}>{{.Name}} ({{.Lang}} · {{.Gender}})</option>
|
||||||
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>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -2421,8 +2412,7 @@ const chapterTmpl = `
|
|||||||
var statusEl = document.getElementById('tts-status');
|
var statusEl = document.getElementById('tts-status');
|
||||||
var statusBar = document.getElementById('tts-status-bar');
|
var statusBar = document.getElementById('tts-status-bar');
|
||||||
var voiceSel = document.getElementById('tts-voice');
|
var voiceSel = document.getElementById('tts-voice');
|
||||||
var speedSlider = document.getElementById('tts-speed');
|
var speedBtns = document.querySelectorAll('.speed-btn');
|
||||||
var speedLabel = document.getElementById('tts-speed-label');
|
|
||||||
var autoplayChk = document.getElementById('tts-autoplay');
|
var autoplayChk = document.getElementById('tts-autoplay');
|
||||||
var autoscrollChk = document.getElementById('tts-autoscroll');
|
var autoscrollChk = document.getElementById('tts-autoscroll');
|
||||||
var article = document.getElementById('chapter-article');
|
var article = document.getElementById('chapter-article');
|
||||||
@@ -2537,9 +2527,26 @@ const chapterTmpl = `
|
|||||||
|
|
||||||
// ── localStorage settings ─────────────────────────────────────────────────
|
// ── localStorage settings ─────────────────────────────────────────────────
|
||||||
var LS_SPEED = 'tts_speed', LS_VOICE = 'tts_voice', LS_AUTONEXT = 'tts_autonext', LS_AUTOSCROLL = 'tts_autoscroll';
|
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() {
|
(function loadSettings() {
|
||||||
var spd = localStorage.getItem(LS_SPEED);
|
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);
|
var vc = localStorage.getItem(LS_VOICE);
|
||||||
if (vc !== null) { var opt = voiceSel.querySelector('option[value="' + vc + '"]'); if (opt) voiceSel.value = vc; }
|
if (vc !== null) { var opt = voiceSel.querySelector('option[value="' + vc + '"]'); if (opt) voiceSel.value = vc; }
|
||||||
var an = localStorage.getItem(LS_AUTONEXT);
|
var an = localStorage.getItem(LS_AUTONEXT);
|
||||||
@@ -2547,12 +2554,6 @@ const chapterTmpl = `
|
|||||||
var as = localStorage.getItem(LS_AUTOSCROLL);
|
var as = localStorage.getItem(LS_AUTOSCROLL);
|
||||||
if (as !== null) autoscrollChk.checked = as !== 'false';
|
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; });
|
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'); });
|
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'); });
|
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_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>';
|
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 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() {
|
function setGenerating() {
|
||||||
setPlayIcon('\u231B');
|
setPlayIcon('\u231B');
|
||||||
playerPlayBtn.disabled = true;
|
playerPlayBtn.disabled = true;
|
||||||
voiceSel.disabled = true; speedSlider.disabled = true;
|
voiceSel.disabled = true; speedBtns.forEach(function(b){b.disabled=true;});
|
||||||
setBadge(playerStateBadge, 'generating');
|
setBadge(playerStateBadge, 'generating');
|
||||||
playerStateBadge.hidden = false;
|
playerStateBadge.hidden = false;
|
||||||
playerTitle.textContent = 'Ch.\u00a0' + CHAPTER_N;
|
playerTitle.textContent = 'Ch.\u00a0' + CHAPTER_N;
|
||||||
@@ -2604,7 +2609,7 @@ const chapterTmpl = `
|
|||||||
if (stale()) return;
|
if (stale()) return;
|
||||||
setPlayIcon(SVG_PAUSE);
|
setPlayIcon(SVG_PAUSE);
|
||||||
playerPlayBtn.disabled = false;
|
playerPlayBtn.disabled = false;
|
||||||
voiceSel.disabled = false; speedSlider.disabled = false;
|
voiceSel.disabled = false; speedBtns.forEach(function(b){b.disabled=false;});
|
||||||
setBadge(playerStateBadge, 'playing');
|
setBadge(playerStateBadge, 'playing');
|
||||||
playerStateBadge.hidden = false;
|
playerStateBadge.hidden = false;
|
||||||
var pct = (audio.duration && isFinite(audio.duration))
|
var pct = (audio.duration && isFinite(audio.duration))
|
||||||
@@ -2631,7 +2636,7 @@ const chapterTmpl = `
|
|||||||
highlightPara(-1);
|
highlightPara(-1);
|
||||||
setPlayIcon(SVG_PLAY);
|
setPlayIcon(SVG_PLAY);
|
||||||
playerPlayBtn.disabled = false;
|
playerPlayBtn.disabled = false;
|
||||||
voiceSel.disabled = false; speedSlider.disabled = false;
|
voiceSel.disabled = false; speedBtns.forEach(function(b){b.disabled=false;});
|
||||||
if (genStartTime > 0) {
|
if (genStartTime > 0) {
|
||||||
var elapsed = Math.round((Date.now() - genStartTime) / 1000);
|
var elapsed = Math.round((Date.now() - genStartTime) / 1000);
|
||||||
var label = elapsed >= 60 ? Math.floor(elapsed/60) + 'm ' + (elapsed%60) + 's' : elapsed + 's';
|
var label = elapsed >= 60 ? Math.floor(elapsed/60) + 'm ' + (elapsed%60) + 's' : elapsed + 's';
|
||||||
@@ -2652,7 +2657,7 @@ const chapterTmpl = `
|
|||||||
highlightPara(-1);
|
highlightPara(-1);
|
||||||
setPlayIcon(SVG_PLAY);
|
setPlayIcon(SVG_PLAY);
|
||||||
playerPlayBtn.disabled = false;
|
playerPlayBtn.disabled = false;
|
||||||
voiceSel.disabled = false; speedSlider.disabled = false;
|
voiceSel.disabled = false; speedBtns.forEach(function(b){b.disabled=false;});
|
||||||
setBadge(playerStateBadge, 'error');
|
setBadge(playerStateBadge, 'error');
|
||||||
playerStateBadge.hidden = false;
|
playerStateBadge.hidden = false;
|
||||||
playerTitle.textContent = 'Error';
|
playerTitle.textContent = 'Error';
|
||||||
@@ -2679,7 +2684,7 @@ const chapterTmpl = `
|
|||||||
fetch('/ui/audio/' + SLUG + '/' + chapterN, {
|
fetch('/ui/audio/' + SLUG + '/' + chapterN, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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
|
signal: ctrl.signal
|
||||||
})
|
})
|
||||||
.then(function (res) {
|
.then(function (res) {
|
||||||
@@ -2714,7 +2719,7 @@ const chapterTmpl = `
|
|||||||
fetch('/ui/audio/' + SLUG + '/' + NEXT_N, {
|
fetch('/ui/audio/' + SLUG + '/' + NEXT_N, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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 (res) { return res.ok ? res.json() : Promise.reject(res.status); })
|
||||||
.then(function (data) { if (!stale()) { prefetchedUrl = (data && data.url) ? data.url : null; setNextState('ready'); } })
|
.then(function (data) { if (!stale()) { prefetchedUrl = (data && data.url) ? data.url : null; setNextState('ready'); } })
|
||||||
@@ -2842,33 +2847,10 @@ const chapterTmpl = `
|
|||||||
}());
|
}());
|
||||||
}());
|
}());
|
||||||
|
|
||||||
// ── Voice card picker ─────────────────────────────────────────────────────
|
// ── Voice change handler ──────────────────────────────────────────────────
|
||||||
window.selectVoice = function (btn) {
|
window.onVoiceChange = function (sel) {
|
||||||
var voiceSel = document.getElementById('tts-voice');
|
try { localStorage.setItem('tts_voice', sel.value); } catch(_) {}
|
||||||
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);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
(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>`
|
</script>`
|
||||||
|
|
||||||
func (s *Server) handleChapter(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleChapter(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
Reference in New Issue
Block a user