speechify-clone/static/app.js
ifedan-ed fb8e1340f1 Add auth, Nextcloud, voice lock, saved docs, progress saving, and all new TTS voices
- Auth system: JWT login/register with Cloudflare Turnstile, admin + user roles
- Admin panel: user management (role/active toggle, delete), Nextcloud config
- 6 TTS models: OpenAI TTS / TTS-HD, ElevenLabs, Gemini 2.5 Flash/Pro
- Voice lock: prevent model/voice changes while audio is playing
- Nextcloud browser: PROPFIND WebDAV, folder navigation, PDF loading
- Saved documents: save/load with PUT/POST, progress auto-saves every 2s
- Resume playback: remembers sentence position per user per document
- Better sentence highlighting with glow effect

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 03:23:18 +02:00

701 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── STATE ──────────────────────────────────────────────────────────
const state = {
sentences: [], // [{text, isPara}]
currentIdx: 0,
isPlaying: false,
speed: 1.0,
model: 'openai-tts-1',
voice: 'nova',
fontSize: 18,
docId: null, // currently loaded saved-doc id
docTitle: '',
};
let currentAudio = null;
const audioCache = new Map();
let allModels = {};
let currentUser = null;
// ── 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');
const lockNotice = $('lock-notice');
// ── LOADING ────────────────────────────────────────────────────────
const showLoading = (msg = 'Loading…') => { loadingText.textContent = msg; loadingOv.classList.add('show'); };
const hideLoading = () => loadingOv.classList.remove('show');
// ── AUTH ───────────────────────────────────────────────────────────
async function initAuth() {
try {
const resp = await fetch('/api/auth/me');
if (resp.status === 401) { window.location.href = '/login'; return; }
currentUser = await resp.json();
} catch {
window.location.href = '/login';
return;
}
$('user-label').textContent = currentUser.username;
// Show admin panel link only for admins
const ddAdmin = $('dd-admin');
if (currentUser.role !== 'admin') ddAdmin.classList.add('hidden');
// User menu dropdown toggle
$('user-btn').addEventListener('click', e => {
e.stopPropagation();
$('user-dropdown').classList.toggle('open');
});
document.addEventListener('click', () => $('user-dropdown').classList.remove('open'));
}
function logout() {
fetch('/api/auth/logout', { method: 'POST' }).finally(() => {
window.location.href = '/login';
});
}
window.logout = logout;
// ── 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();
}
// Voice locking during playback
function setVoiceLock(locked) {
modelSelect.disabled = locked;
voiceSelect.disabled = locked;
lockNotice.classList.toggle('visible', locked);
}
modelSelect.addEventListener('change', () => {
if (state.isPlaying) return; // ignore if locked
refreshVoices();
});
voiceSelect.addEventListener('change', () => {
if (state.isPlaying) return;
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;
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 ──────────────────────────────────────────────────
async function loadDoc(text, title = 'Document', docId = null, startIdx = 0) {
state.sentences = splitSentences(text);
state.currentIdx = startIdx;
state.docId = docId;
state.docTitle = title;
audioCache.clear();
stopPlayback();
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`;
// Load saved progress if we have a docId and no explicit startIdx
if (docId && startIdx === 0) {
try {
const r = await fetch(`/api/progress/${docId}`);
if (r.ok) {
const p = await r.json();
if (p.sentence_index > 0) {
const realIdx = nextReal(p.sentence_index);
if (realIdx >= 0) {
state.currentIdx = realIdx;
highlight(realIdx);
}
}
}
} catch {}
} else if (startIdx > 0) {
highlight(startIdx);
}
}
// ── PROGRESS SAVING ────────────────────────────────────────────────
let progressSaveTimer = null;
function scheduleSaveProgress() {
if (!state.docId) return;
clearTimeout(progressSaveTimer);
progressSaveTimer = setTimeout(saveProgress, 2000);
}
async function saveProgress() {
if (!state.docId) return;
try {
await fetch(`/api/progress/${state.docId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sentence_index: state.currentIdx }),
});
} catch {}
}
// ── 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.onended = null; 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 && !s.isPara) nowPlaying.textContent = s.text;
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%';
scheduleSaveProgress();
}
// ── 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) { stopFinished(); 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 stopFinished() {
state.isPlaying = false;
stopAudio();
setVoiceLock(false);
updatePlayBtn();
document.querySelectorAll('.s.active').forEach(el => el.classList.remove('active'));
nowPlaying.textContent = 'Finished';
saveProgress();
}
function stopPlayback() {
state.isPlaying = false;
stopAudio();
setVoiceLock(false);
updatePlayBtn();
}
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) {
setVoiceLock(true);
const idx = state.sentences[state.currentIdx]?.isPara
? nextReal(state.currentIdx)
: state.currentIdx;
playSentence(idx >= 0 ? idx : nextReal(0));
} else {
stopAudio();
setVoiceLock(false);
nowPlaying.textContent = 'Paused';
saveProgress();
}
});
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-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('.tabs .tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tabs .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');
if (tab.dataset.tab === 'saved') loadSavedDocs();
});
});
// ── 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.json()).detail || 'Failed');
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.json()).detail || 'Failed');
const data = await resp.json();
loadDoc(data.text, data.title || file.name);
status.textContent = '✓ Loaded';
} catch (e) {
status.textContent = '✗ ' + e.message;
} finally {
hideLoading();
}
}
// ── NEXTCLOUD BROWSER ───────────────────────────────────────────────
let ncCurrentPath = '/';
async function ncBrowse() {
ncCurrentPath = '/';
await ncLoadPath('/');
}
window.ncBrowse = ncBrowse;
async function ncUp() {
if (ncCurrentPath === '/') return;
const parts = ncCurrentPath.replace(/\/$/, '').split('/');
parts.pop();
ncCurrentPath = parts.join('/') || '/';
await ncLoadPath(ncCurrentPath);
}
window.ncUp = ncUp;
async function ncNavigate(path) {
ncCurrentPath = path;
await ncLoadPath(path);
}
async function ncLoadPath(path) {
const fileList = $('nc-file-list');
const status = $('nc-status');
fileList.innerHTML = '<div class="status-text">Loading…</div>';
status.textContent = '';
$('nc-path-display').textContent = path;
try {
const resp = await fetch('/api/nextcloud/files?path=' + encodeURIComponent(path));
if (!resp.ok) {
const err = await resp.json();
throw new Error(err.detail || 'Failed to load');
}
const items = await resp.json();
fileList.innerHTML = '';
if (!items.length) {
fileList.innerHTML = '<div class="status-text">Empty folder</div>';
return;
}
for (const item of items) {
const div = document.createElement('div');
div.className = 'nc-item' + (item.is_dir ? ' dir' : '');
const icon = item.is_dir
? `<svg class="nc-icon" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>`
: `<svg class="nc-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>`;
div.innerHTML = `${icon}<span>${item.name}</span>`;
if (item.is_dir) {
div.addEventListener('click', () => ncNavigate(item.path));
} else {
div.addEventListener('click', () => ncOpenFile(item.path, item.name));
}
fileList.appendChild(div);
}
} catch (e) {
fileList.innerHTML = '<div class="status-text">Error loading</div>';
status.textContent = '✗ ' + e.message;
}
}
async function ncOpenFile(path, name) {
showLoading('Loading from Nextcloud…');
const status = $('nc-status');
try {
const resp = await fetch('/api/nextcloud/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path }),
});
if (!resp.ok) throw new Error((await resp.json()).detail || 'Failed');
const data = await resp.json();
loadDoc(data.text, name.replace(/\.pdf$/i, ''));
status.textContent = '✓ Loaded';
} catch (e) {
status.textContent = '✗ ' + e.message;
} finally {
hideLoading();
}
}
// ── SAVED DOCUMENTS ────────────────────────────────────────────────
async function loadSavedDocs() {
const list = $('saved-list');
list.innerHTML = '<div class="status-text">Loading…</div>';
try {
const resp = await fetch('/api/documents');
if (!resp.ok) throw new Error('Failed');
const docs = await resp.json();
list.innerHTML = '';
if (!docs.length) {
list.innerHTML = '<div class="status-text">No saved documents</div>';
return;
}
for (const doc of docs) {
const div = document.createElement('div');
div.className = 'saved-item';
const date = new Date(doc.created_at).toLocaleDateString();
div.innerHTML = `
<div class="saved-item-title">${escHtml(doc.title)}</div>
<div class="saved-item-meta">${escHtml(doc.source_type)} · ${date}</div>
`;
div.addEventListener('click', () => openSavedDoc(doc.id, doc.title));
list.appendChild(div);
}
} catch (e) {
list.innerHTML = '<div class="status-text">Error loading</div>';
}
}
window.loadSavedDocs = loadSavedDocs;
async function openSavedDoc(docId, title) {
showLoading('Loading document…');
try {
const resp = await fetch(`/api/documents/${docId}`);
if (!resp.ok) throw new Error('Not found');
const doc = await resp.json();
await loadDoc(doc.content, doc.title, doc.id, 0);
} catch (e) {
console.error('Failed to open doc:', e);
} finally {
hideLoading();
}
}
// Save current document
async function saveDoc() {
if (!state.sentences.length) return;
const btn = $('btn-save-doc');
btn.disabled = true;
btn.textContent = 'Saving…';
const fullText = state.sentences
.map(s => s.isPara ? '\n' : s.text)
.join(' ')
.replace(/ \n /g, '\n\n');
try {
const method = state.docId ? 'PUT' : 'POST';
const url = state.docId ? `/api/documents/${state.docId}` : '/api/documents';
const resp = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: state.docTitle, content: fullText }),
});
if (!resp.ok) throw new Error('Failed');
const doc = await resp.json();
state.docId = doc.id;
btn.textContent = '✓ Saved';
setTimeout(() => {
btn.innerHTML = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg> Save`;
btn.disabled = false;
}, 2000);
} catch {
btn.textContent = '✗ Error';
btn.disabled = false;
setTimeout(() => {
btn.innerHTML = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg> Save`;
}, 2000);
}
}
$('btn-save-doc').addEventListener('click', saveDoc);
function escHtml(str) {
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// ── 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', 'SELECT'].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 ───────────────────────────────────────────────────────────
async function boot() {
await initAuth();
await initVoices();
}
boot();