419 lines
16 KiB
JavaScript
419 lines
16 KiB
JavaScript
// ── STATE ──────────────────────────────────────────────────────────
|
||
const state = {
|
||
sentences: [], // [{text, isPara}]
|
||
currentIdx: 0,
|
||
isPlaying: false,
|
||
speed: 1.0,
|
||
model: 'openai-tts-1',
|
||
voice: 'nova',
|
||
fontSize: 18,
|
||
};
|
||
|
||
let currentAudio = null;
|
||
const audioCache = new Map(); // idx → blob URL
|
||
let allModels = {};
|
||
|
||
// ── DOM ────────────────────────────────────────────────────────────
|
||
const $ = id => document.getElementById(id);
|
||
const textViewer = $('text-viewer');
|
||
const modelSelect = $('model-select');
|
||
const voiceSelect = $('voice-select');
|
||
const speedSlider = $('speed-slider');
|
||
const speedLabel = $('speed-label');
|
||
const btnPlay = $('btn-play');
|
||
const btnPrev = $('btn-prev');
|
||
const btnNext = $('btn-next');
|
||
const nowPlaying = $('now-playing');
|
||
const curSent = $('cur-sent');
|
||
const totSent = $('tot-sent');
|
||
const progressFill = $('progress-fill');
|
||
const loadingOv = $('loading-overlay');
|
||
const loadingText = $('loading-text');
|
||
|
||
// ── LOADING ────────────────────────────────────────────────────────
|
||
const showLoading = (msg = 'Loading…') => { loadingText.textContent = msg; loadingOv.classList.add('show'); };
|
||
const hideLoading = () => loadingOv.classList.remove('show');
|
||
|
||
// ── VOICES INIT ────────────────────────────────────────────────────
|
||
async function initVoices() {
|
||
const resp = await fetch('/api/voices');
|
||
allModels = await resp.json();
|
||
|
||
modelSelect.innerHTML = '';
|
||
for (const [id, info] of Object.entries(allModels)) {
|
||
const o = document.createElement('option');
|
||
o.value = id;
|
||
o.textContent = `${info.name} — ${info.quality}`;
|
||
modelSelect.appendChild(o);
|
||
}
|
||
modelSelect.value = 'openai-tts-1';
|
||
refreshVoices();
|
||
}
|
||
|
||
function refreshVoices() {
|
||
state.model = modelSelect.value;
|
||
const voices = allModels[state.model]?.voices || [];
|
||
voiceSelect.innerHTML = '';
|
||
for (const v of voices) {
|
||
const o = document.createElement('option');
|
||
o.value = v.id;
|
||
o.textContent = v.name;
|
||
voiceSelect.appendChild(o);
|
||
}
|
||
state.voice = voiceSelect.value;
|
||
audioCache.clear();
|
||
}
|
||
|
||
modelSelect.addEventListener('change', refreshVoices);
|
||
voiceSelect.addEventListener('change', () => { state.voice = voiceSelect.value; audioCache.clear(); });
|
||
|
||
// ── SENTENCE SPLITTING ─────────────────────────────────────────────
|
||
function splitSentences(text) {
|
||
text = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||
const paras = text.split(/\n{2,}/);
|
||
const result = [];
|
||
|
||
for (let pi = 0; pi < paras.length; pi++) {
|
||
const para = paras[pi].trim();
|
||
if (!para) continue;
|
||
|
||
// Split on sentence-ending punctuation followed by space + capital
|
||
const parts = para.split(/(?<=[.!?…])\s+(?=[A-Z"'"'(])/);
|
||
for (const part of parts) {
|
||
const t = part.trim();
|
||
if (t) result.push({ text: t, isPara: false });
|
||
}
|
||
if (pi < paras.length - 1) result.push({ text: '', isPara: true });
|
||
}
|
||
return result;
|
||
}
|
||
|
||
// ── RENDER TEXT ────────────────────────────────────────────────────
|
||
function renderText(sentences) {
|
||
textViewer.innerHTML = '';
|
||
sentences.forEach((s, i) => {
|
||
if (s.isPara) {
|
||
textViewer.appendChild(Object.assign(document.createElement('span'), { className: 'para-gap' }));
|
||
return;
|
||
}
|
||
const span = document.createElement('span');
|
||
span.className = 's';
|
||
span.dataset.i = i;
|
||
span.textContent = s.text + ' ';
|
||
span.addEventListener('click', () => jumpTo(i));
|
||
textViewer.appendChild(span);
|
||
});
|
||
}
|
||
|
||
// ── LOAD DOCUMENT ──────────────────────────────────────────────────
|
||
function loadDoc(text, title = 'Document') {
|
||
state.sentences = splitSentences(text);
|
||
state.currentIdx = 0;
|
||
audioCache.clear();
|
||
stopAudio();
|
||
|
||
const real = state.sentences.filter(s => !s.isPara).length;
|
||
$('document-header').style.display = '';
|
||
$('document-title').textContent = title;
|
||
$('document-meta').textContent = `${real} sentences`;
|
||
totSent.textContent = real;
|
||
curSent.textContent = 0;
|
||
progressFill.style.width = '0%';
|
||
nowPlaying.textContent = 'Ready — press play';
|
||
|
||
renderText(state.sentences);
|
||
textViewer.style.fontSize = `${state.fontSize}px`;
|
||
}
|
||
|
||
// ── AUDIO ──────────────────────────────────────────────────────────
|
||
async function generateAudio(idx) {
|
||
if (audioCache.has(idx)) return audioCache.get(idx);
|
||
|
||
const s = state.sentences[idx];
|
||
const resp = await fetch('/api/tts', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ text: s.text, model: state.model, voice: state.voice, speed: state.speed }),
|
||
});
|
||
if (!resp.ok) throw new Error(await resp.text());
|
||
|
||
const blob = await resp.blob();
|
||
const url = URL.createObjectURL(blob);
|
||
audioCache.set(idx, url);
|
||
return url;
|
||
}
|
||
|
||
function prefetch(fromIdx, count = 3) {
|
||
let n = 0, i = fromIdx + 1;
|
||
while (n < count && i < state.sentences.length) {
|
||
const s = state.sentences[i];
|
||
if (!s.isPara && !audioCache.has(i)) generateAudio(i).catch(() => {});
|
||
if (!s.isPara) n++;
|
||
i++;
|
||
}
|
||
}
|
||
|
||
function stopAudio() {
|
||
if (currentAudio) { currentAudio.pause(); currentAudio = null; }
|
||
}
|
||
|
||
// ── HIGHLIGHT ──────────────────────────────────────────────────────
|
||
function highlight(idx) {
|
||
document.querySelectorAll('.s.active').forEach(el => el.classList.remove('active'));
|
||
const el = document.querySelector(`.s[data-i="${idx}"]`);
|
||
if (el) {
|
||
el.classList.add('active');
|
||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
}
|
||
state.currentIdx = idx;
|
||
|
||
const s = state.sentences[idx];
|
||
if (s) nowPlaying.textContent = s.text;
|
||
|
||
// Update counter
|
||
const realIdx = state.sentences.slice(0, idx + 1).filter(s => !s.isPara).length;
|
||
const realTotal = state.sentences.filter(s => !s.isPara).length;
|
||
curSent.textContent = realIdx;
|
||
progressFill.style.width = realTotal ? `${(realIdx / realTotal) * 100}%` : '0%';
|
||
}
|
||
|
||
// ── NAVIGATION HELPERS ─────────────────────────────────────────────
|
||
function nextReal(from) {
|
||
for (let i = from; i < state.sentences.length; i++)
|
||
if (!state.sentences[i].isPara) return i;
|
||
return -1;
|
||
}
|
||
function prevReal(from) {
|
||
for (let i = from; i >= 0; i--)
|
||
if (!state.sentences[i].isPara) return i;
|
||
return -1;
|
||
}
|
||
|
||
// ── PLAYBACK ───────────────────────────────────────────────────────
|
||
async function playSentence(idx) {
|
||
if (idx < 0 || idx >= state.sentences.length) { stop(); return; }
|
||
|
||
const s = state.sentences[idx];
|
||
if (s.isPara) { playSentence(nextReal(idx + 1)); return; }
|
||
|
||
highlight(idx);
|
||
stopAudio();
|
||
|
||
try {
|
||
const url = await generateAudio(idx);
|
||
if (!state.isPlaying) return;
|
||
|
||
const audio = new Audio(url);
|
||
audio.playbackRate = state.speed;
|
||
currentAudio = audio;
|
||
prefetch(idx);
|
||
|
||
audio.play().catch(console.error);
|
||
audio.onended = () => {
|
||
if (state.isPlaying) playSentence(nextReal(idx + 1));
|
||
};
|
||
audio.onerror = () => {
|
||
if (state.isPlaying) playSentence(nextReal(idx + 1));
|
||
};
|
||
} catch (e) {
|
||
console.error('TTS error:', e);
|
||
if (state.isPlaying) playSentence(nextReal(idx + 1));
|
||
}
|
||
}
|
||
|
||
function stop() {
|
||
state.isPlaying = false;
|
||
stopAudio();
|
||
updatePlayBtn();
|
||
document.querySelectorAll('.s.active').forEach(el => el.classList.remove('active'));
|
||
nowPlaying.textContent = 'Finished';
|
||
}
|
||
|
||
function updatePlayBtn() {
|
||
btnPlay.querySelector('.icon-play').style.display = state.isPlaying ? 'none' : '';
|
||
btnPlay.querySelector('.icon-pause').style.display = state.isPlaying ? '' : 'none';
|
||
}
|
||
|
||
function jumpTo(idx) {
|
||
state.currentIdx = idx;
|
||
if (state.isPlaying) {
|
||
stopAudio();
|
||
playSentence(idx);
|
||
} else {
|
||
highlight(idx);
|
||
}
|
||
}
|
||
|
||
// ── PLAYER CONTROLS ────────────────────────────────────────────────
|
||
btnPlay.addEventListener('click', () => {
|
||
if (!state.sentences.length) return;
|
||
state.isPlaying = !state.isPlaying;
|
||
updatePlayBtn();
|
||
if (state.isPlaying) {
|
||
const idx = state.sentences[state.currentIdx]?.isPara
|
||
? nextReal(state.currentIdx)
|
||
: state.currentIdx;
|
||
playSentence(idx >= 0 ? idx : nextReal(0));
|
||
} else {
|
||
stopAudio();
|
||
nowPlaying.textContent = 'Paused';
|
||
}
|
||
});
|
||
|
||
btnNext.addEventListener('click', () => {
|
||
const n = nextReal(state.currentIdx + 1);
|
||
if (n >= 0) jumpTo(n);
|
||
});
|
||
|
||
btnPrev.addEventListener('click', () => {
|
||
const p = prevReal(state.currentIdx - 1);
|
||
if (p >= 0) jumpTo(p);
|
||
});
|
||
|
||
// Progress bar click
|
||
$('progress-track').addEventListener('click', e => {
|
||
const rect = e.currentTarget.getBoundingClientRect();
|
||
const ratio = (e.clientX - rect.left) / rect.width;
|
||
const reals = state.sentences.map((s, i) => s.isPara ? null : i).filter(i => i !== null);
|
||
const target = reals[Math.floor(ratio * reals.length)];
|
||
if (target !== undefined) jumpTo(target);
|
||
});
|
||
|
||
// ── SPEED ──────────────────────────────────────────────────────────
|
||
function setSpeed(v) {
|
||
state.speed = v;
|
||
speedSlider.value = v;
|
||
speedLabel.textContent = `${v}×`;
|
||
if (currentAudio) currentAudio.playbackRate = v;
|
||
document.querySelectorAll('.speed-btn').forEach(b => {
|
||
b.classList.toggle('active', parseFloat(b.dataset.speed) === v);
|
||
});
|
||
}
|
||
|
||
speedSlider.addEventListener('input', () => setSpeed(parseFloat(speedSlider.value)));
|
||
document.querySelectorAll('.speed-btn').forEach(b => {
|
||
b.addEventListener('click', () => setSpeed(parseFloat(b.dataset.speed)));
|
||
});
|
||
|
||
// ── FONT SIZE ──────────────────────────────────────────────────────
|
||
$('font-increase').addEventListener('click', () => {
|
||
state.fontSize = Math.min(state.fontSize + 2, 34);
|
||
textViewer.style.fontSize = `${state.fontSize}px`;
|
||
$('font-size-label').textContent = `${state.fontSize}px`;
|
||
});
|
||
$('font-decrease').addEventListener('click', () => {
|
||
state.fontSize = Math.max(state.fontSize - 2, 12);
|
||
textViewer.style.fontSize = `${state.fontSize}px`;
|
||
$('font-size-label').textContent = `${state.fontSize}px`;
|
||
});
|
||
|
||
// ── SIDEBAR TOGGLES ────────────────────────────────────────────────
|
||
$('btn-sidebar-toggle').addEventListener('click', () => $('sidebar-left').classList.toggle('collapsed'));
|
||
$('btn-settings-toggle').addEventListener('click', () => $('sidebar-right').classList.toggle('collapsed'));
|
||
|
||
// ── TABS ───────────────────────────────────────────────────────────
|
||
document.querySelectorAll('.tab').forEach(tab => {
|
||
tab.addEventListener('click', () => {
|
||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
|
||
tab.classList.add('active');
|
||
$(`tab-${tab.dataset.tab}`).classList.add('active');
|
||
});
|
||
});
|
||
|
||
// ── TEXT INPUT ─────────────────────────────────────────────────────
|
||
$('btn-load-text').addEventListener('click', () => {
|
||
const text = $('text-input').value.trim();
|
||
if (text) loadDoc(text, 'Pasted Text');
|
||
});
|
||
|
||
// ── URL INPUT ──────────────────────────────────────────────────────
|
||
$('btn-load-url').addEventListener('click', async () => {
|
||
const url = $('url-input').value.trim();
|
||
if (!url) return;
|
||
const status = $('url-status');
|
||
showLoading('Fetching article…');
|
||
try {
|
||
const resp = await fetch('/api/extract-url', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ url }),
|
||
});
|
||
if (!resp.ok) throw new Error(await resp.text());
|
||
const data = await resp.json();
|
||
loadDoc(data.text, data.title || url);
|
||
status.textContent = '✓ Loaded';
|
||
} catch (e) {
|
||
status.textContent = '✗ ' + e.message;
|
||
} finally {
|
||
hideLoading();
|
||
}
|
||
});
|
||
|
||
// ── PDF INPUT ──────────────────────────────────────────────────────
|
||
const uploadArea = $('upload-area');
|
||
const pdfInput = $('pdf-input');
|
||
|
||
uploadArea.addEventListener('click', () => pdfInput.click());
|
||
uploadArea.addEventListener('dragover', e => { e.preventDefault(); uploadArea.classList.add('drag-over'); });
|
||
uploadArea.addEventListener('dragleave', () => uploadArea.classList.remove('drag-over'));
|
||
uploadArea.addEventListener('drop', e => {
|
||
e.preventDefault(); uploadArea.classList.remove('drag-over');
|
||
const f = e.dataTransfer.files[0];
|
||
if (f?.type === 'application/pdf') uploadPDF(f);
|
||
});
|
||
pdfInput.addEventListener('change', () => { if (pdfInput.files[0]) uploadPDF(pdfInput.files[0]); });
|
||
|
||
async function uploadPDF(file) {
|
||
const status = $('pdf-status');
|
||
showLoading('Extracting PDF…');
|
||
const form = new FormData();
|
||
form.append('file', file);
|
||
try {
|
||
const resp = await fetch('/api/extract-pdf', { method: 'POST', body: form });
|
||
if (!resp.ok) throw new Error(await resp.text());
|
||
const data = await resp.json();
|
||
loadDoc(data.text, data.title || file.name);
|
||
status.textContent = '✓ Loaded';
|
||
} catch (e) {
|
||
status.textContent = '✗ ' + e.message;
|
||
} finally {
|
||
hideLoading();
|
||
}
|
||
}
|
||
|
||
// ── VOICE PREVIEW ─────────────────────────────────────────────────
|
||
$('btn-preview').addEventListener('click', async () => {
|
||
showLoading('Generating preview…');
|
||
try {
|
||
const resp = await fetch('/api/tts', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
text: 'Hello! This is a preview of the selected voice. How does it sound to you?',
|
||
model: state.model, voice: state.voice, speed: state.speed,
|
||
}),
|
||
});
|
||
const blob = await resp.blob();
|
||
const url = URL.createObjectURL(blob);
|
||
const a = new Audio(url);
|
||
a.playbackRate = state.speed;
|
||
a.play();
|
||
a.onended = () => URL.revokeObjectURL(url);
|
||
} catch (e) {
|
||
console.error('Preview failed:', e);
|
||
} finally {
|
||
hideLoading();
|
||
}
|
||
});
|
||
|
||
// ── KEYBOARD SHORTCUTS ─────────────────────────────────────────────
|
||
document.addEventListener('keydown', e => {
|
||
if (['INPUT', 'TEXTAREA'].includes(e.target.tagName)) return;
|
||
if (e.code === 'Space') { e.preventDefault(); btnPlay.click(); }
|
||
else if (e.code === 'ArrowRight') btnNext.click();
|
||
else if (e.code === 'ArrowLeft') btnPrev.click();
|
||
});
|
||
|
||
// ── BOOT ───────────────────────────────────────────────────────────
|
||
initVoices();
|