- PDF pages rendered by PDF.js directly from File object (no conversion shown) - Each page gets a transparent overlay canvas for drawing highlights - Word positions extracted from PDF.js getTextContent() in PDF coordinate space - convertToViewportPoint() maps PDF space → canvas pixels correctly (handles y-flip) - On each sentence: purple highlight rectangles drawn over matching words on the PDF - Sentence→word mapping via character-position search through word stream - PDF rendering and text extraction run in parallel (pages appear as they render) - Nextcloud PDFs use pdf_b64 → PDF.js rendering path - Removed iframe approach entirely Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
950 lines
34 KiB
JavaScript
950 lines
34 KiB
JavaScript
// ── STATE ──────────────────────────────────────────────────────────
|
||
const state = {
|
||
sentences: [], // [{text, isPara}]
|
||
currentIdx: 0,
|
||
isPlaying: false,
|
||
speed: 1.0,
|
||
model: 'openai-tts-1',
|
||
voice: 'nova',
|
||
fontSize: 18,
|
||
docId: null,
|
||
docTitle: '',
|
||
// PDF state
|
||
pdfB64: null, // base64 PDF data
|
||
pageTexts: [], // text per page for page-sentence mapping
|
||
sentencePage: [], // maps sentence index → page index (0-based)
|
||
currentView: 'text', // 'text' | 'pdf'
|
||
};
|
||
|
||
let currentAudio = null;
|
||
const audioCache = new Map();
|
||
let allModels = {};
|
||
let currentUser = null;
|
||
|
||
// PDF.js state
|
||
const PDF_SCALE = 1.5;
|
||
let pdfJsDoc = null;
|
||
let pdfAllWords = []; // [{page, x, y, w, h, str}]
|
||
let pdfWordMap = []; // sentence_idx → [word_idx, ...]
|
||
let pageOverlays = []; // [{overlay, wrap}] per page
|
||
|
||
// ── 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');
|
||
|
||
// Email verification reminder
|
||
if (!currentUser.email_verified) {
|
||
$('verify-banner').classList.add('show');
|
||
}
|
||
|
||
// 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;
|
||
|
||
async function resendVerification() {
|
||
const btn = $('verify-banner').querySelector('button');
|
||
btn.textContent = 'Sending…';
|
||
btn.disabled = true;
|
||
try {
|
||
await fetch('/api/auth/resend-verification', { method: 'POST' });
|
||
$('verify-banner').querySelector('span').textContent = '✓ Verification email sent — check your inbox.';
|
||
} finally {
|
||
btn.remove();
|
||
}
|
||
}
|
||
window.resendVerification = resendVerification;
|
||
|
||
// ── 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);
|
||
});
|
||
}
|
||
|
||
// ── VIEW SWITCHING ─────────────────────────────────────────────────
|
||
function setView(mode) {
|
||
state.currentView = mode;
|
||
const contentArea = $('content-area');
|
||
|
||
if (mode === 'pdf') {
|
||
$('text-viewer').style.display = 'none';
|
||
$('pdf-viewer').style.display = 'flex';
|
||
contentArea.classList.add('pdf-mode');
|
||
$('btn-view-pdf').classList.add('active');
|
||
$('btn-view-text').classList.remove('active');
|
||
$('doc-hint').textContent = 'Viewing original PDF · switch to Text to click sentences';
|
||
} else {
|
||
$('text-viewer').style.display = '';
|
||
$('pdf-viewer').style.display = 'none';
|
||
contentArea.classList.remove('pdf-mode');
|
||
$('btn-view-text').classList.add('active');
|
||
$('btn-view-pdf').classList.remove('active');
|
||
$('doc-hint').textContent = 'Click any sentence to jump there · Space to play/pause · ← → to skip';
|
||
}
|
||
}
|
||
window.setView = setView;
|
||
|
||
// ── PDF.js RENDERING + HIGHLIGHTING ───────────────────────────────
|
||
async function renderPDF(source) {
|
||
// source: File object OR base64 string (from Nextcloud)
|
||
pdfjsLib.GlobalWorkerOptions.workerSrc =
|
||
'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
|
||
|
||
let data;
|
||
if (typeof source === 'string') {
|
||
const bin = atob(source);
|
||
data = new Uint8Array(bin.length);
|
||
for (let i = 0; i < bin.length; i++) data[i] = bin.charCodeAt(i);
|
||
} else {
|
||
data = new Uint8Array(await source.arrayBuffer());
|
||
}
|
||
|
||
pdfJsDoc = await pdfjsLib.getDocument({ data }).promise;
|
||
pageOverlays = [];
|
||
pdfAllWords = [];
|
||
|
||
const container = $('pdf-pages');
|
||
container.innerHTML = '';
|
||
|
||
for (let pageNum = 1; pageNum <= pdfJsDoc.numPages; pageNum++) {
|
||
const page = await pdfJsDoc.getPage(pageNum);
|
||
const vp = page.getViewport({ scale: PDF_SCALE });
|
||
|
||
// Page wrapper (relative so overlay sits on top)
|
||
const wrap = document.createElement('div');
|
||
wrap.className = 'pdf-page-wrap';
|
||
wrap.style.cssText = `position:relative;width:${vp.width}px;max-width:100%;flex-shrink:0;`;
|
||
|
||
// Render canvas
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = vp.width;
|
||
canvas.height = vp.height;
|
||
canvas.style.cssText = 'display:block;width:100%;';
|
||
await page.render({ canvasContext: canvas.getContext('2d'), viewport: vp }).promise;
|
||
|
||
// Transparent overlay for highlights
|
||
const overlay = document.createElement('canvas');
|
||
overlay.width = vp.width;
|
||
overlay.height = vp.height;
|
||
overlay.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;';
|
||
|
||
wrap.appendChild(canvas);
|
||
wrap.appendChild(overlay);
|
||
container.appendChild(wrap);
|
||
pageOverlays.push({ overlay, wrap });
|
||
|
||
// Extract word positions (PDF user-space → canvas pixels)
|
||
const tc = await page.getTextContent();
|
||
for (const item of tc.items) {
|
||
const s = item.str.trim();
|
||
if (!s) continue;
|
||
// convertToViewportPoint flips y: PDF has y-up, canvas has y-down
|
||
const [cx, cy] = vp.convertToViewportPoint(item.transform[4], item.transform[5]);
|
||
const wPx = Math.abs(item.width * PDF_SCALE);
|
||
const hPx = Math.abs(item.height * PDF_SCALE);
|
||
pdfAllWords.push({
|
||
page: pageNum - 1,
|
||
x: cx, y: cy - hPx, // cy is baseline; move up by height to get top
|
||
w: wPx, h: hPx,
|
||
str: s,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// Map each sentence to the word indices it covers in pdfAllWords
|
||
function buildPDFWordMap(sentences) {
|
||
const strs = pdfAllWords.map(w => w.str);
|
||
const allText = strs.join(' ');
|
||
|
||
// Pre-compute character start of every word
|
||
const charStarts = [];
|
||
let pos = 0;
|
||
for (const s of strs) { charStarts.push(pos); pos += s.length + 1; }
|
||
|
||
pdfWordMap = [];
|
||
let searchFrom = 0;
|
||
|
||
for (const sent of sentences) {
|
||
if (sent.isPara) { pdfWordMap.push([]); continue; }
|
||
|
||
const needle = sent.text.replace(/\s+/g, ' ').trim();
|
||
let found = allText.indexOf(needle, searchFrom);
|
||
// Fallback: try first 50 chars (handles slight extraction differences)
|
||
if (found === -1) found = allText.indexOf(needle.slice(0, 50), searchFrom);
|
||
if (found === -1) { pdfWordMap.push([]); continue; }
|
||
|
||
const end = found + needle.length;
|
||
const idxs = [];
|
||
for (let i = 0; i < charStarts.length; i++) {
|
||
if (charStarts[i] + strs[i].length > found && charStarts[i] < end) idxs.push(i);
|
||
}
|
||
pdfWordMap.push(idxs);
|
||
searchFrom = Math.max(0, found - 5);
|
||
}
|
||
}
|
||
|
||
function clearPDFHighlights() {
|
||
for (const { overlay } of pageOverlays) {
|
||
overlay.getContext('2d').clearRect(0, 0, overlay.width, overlay.height);
|
||
}
|
||
}
|
||
|
||
function highlightPDFSentence(sentIdx) {
|
||
clearPDFHighlights();
|
||
const idxs = pdfWordMap[sentIdx] || [];
|
||
if (!idxs.length) return;
|
||
|
||
// Draw highlight rects grouped by page
|
||
const byPage = {};
|
||
for (const wi of idxs) {
|
||
const w = pdfAllWords[wi];
|
||
(byPage[w.page] ??= []).push(w);
|
||
}
|
||
for (const [pg, words] of Object.entries(byPage)) {
|
||
const entry = pageOverlays[Number(pg)];
|
||
if (!entry) continue;
|
||
const ctx = entry.overlay.getContext('2d');
|
||
ctx.fillStyle = 'rgba(124,58,237,0.38)';
|
||
for (const w of words) {
|
||
ctx.fillRect(w.x - 1, w.y - 1, w.w + 2, w.h + 2);
|
||
}
|
||
}
|
||
|
||
// Scroll to page of first word
|
||
const firstWord = pdfAllWords[idxs[0]];
|
||
if (firstWord !== undefined) {
|
||
const entry = pageOverlays[firstWord.page];
|
||
if (entry) entry.wrap.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||
}
|
||
}
|
||
|
||
// ── LOAD DOCUMENT ──────────────────────────────────────────────────
|
||
async function loadDoc(text, title = 'Document', docId = null, startIdx = 0, pdfB64 = null, pageTexts = []) {
|
||
state.sentences = splitSentences(text);
|
||
state.currentIdx = startIdx;
|
||
state.docId = docId;
|
||
state.docTitle = title;
|
||
state.pdfB64 = pdfB64;
|
||
state.pageTexts = pageTexts;
|
||
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`;
|
||
|
||
// Set up PDF view (for saved Nextcloud docs that return pdf_b64)
|
||
const viewToggle = $('view-toggle');
|
||
if (pdfB64) {
|
||
viewToggle.style.display = '';
|
||
setView('pdf');
|
||
renderPDF(pdfB64).then(() => buildPDFWordMap(state.sentences)).catch(console.error);
|
||
} else {
|
||
viewToggle.style.display = 'none';
|
||
setView('text');
|
||
pdfJsDoc = null; pdfAllWords = []; pdfWordMap = []; pageOverlays = [];
|
||
$('pdf-pages').innerHTML = '';
|
||
}
|
||
|
||
// 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();
|
||
|
||
// Highlight directly on PDF canvas
|
||
if (state.currentView === 'pdf' && pdfWordMap.length) {
|
||
highlightPDFSentence(idx);
|
||
}
|
||
}
|
||
|
||
// ── 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') handlePDF(f);
|
||
});
|
||
pdfInput.addEventListener('change', () => { if (pdfInput.files[0]) handlePDF(pdfInput.files[0]); });
|
||
|
||
async function handlePDF(file) {
|
||
const status = $('pdf-status');
|
||
const title = file.name.replace(/\.pdf$/i, '');
|
||
|
||
// Show header immediately in PDF mode
|
||
$('document-header').style.display = '';
|
||
$('document-title').textContent = title;
|
||
$('document-meta').textContent = 'Loading…';
|
||
$('view-toggle').style.display = '';
|
||
$('pdf-pages').innerHTML = '';
|
||
setView('pdf');
|
||
stopPlayback();
|
||
audioCache.clear();
|
||
state.sentences = []; state.docId = null; state.docTitle = title;
|
||
pdfJsDoc = null; pdfAllWords = []; pdfWordMap = []; pageOverlays = [];
|
||
nowPlaying.textContent = 'Loading PDF…';
|
||
status.textContent = 'Loading…';
|
||
|
||
// Run PDF rendering (shows pages as they complete) and text extraction in parallel
|
||
const form = new FormData();
|
||
form.append('file', file);
|
||
|
||
const renderPromise = renderPDF(file); // reads File directly, no server
|
||
const extractPromise = fetch('/api/extract-pdf', { method: 'POST', body: form })
|
||
.then(r => r.ok ? r.json() : r.json().then(e => Promise.reject(e.detail)));
|
||
|
||
try {
|
||
// Text extraction usually finishes before all pages render
|
||
const data = await extractPromise;
|
||
|
||
state.sentences = splitSentences(data.text);
|
||
state.docTitle = data.title || title;
|
||
state.currentIdx = 0;
|
||
|
||
const real = state.sentences.filter(s => !s.isPara).length;
|
||
$('document-meta').textContent = `${real} sentences`;
|
||
$('document-title').textContent = state.docTitle;
|
||
totSent.textContent = real;
|
||
curSent.textContent = 0;
|
||
progressFill.style.width = '0%';
|
||
|
||
// Render text view in background for switching
|
||
renderText(state.sentences);
|
||
textViewer.style.fontSize = `${state.fontSize}px`;
|
||
|
||
// Wait for PDF rendering to finish, then build word→sentence map
|
||
await renderPromise;
|
||
buildPDFWordMap(state.sentences);
|
||
|
||
nowPlaying.textContent = 'Ready — press play';
|
||
status.textContent = '✓ Ready';
|
||
} catch (e) {
|
||
await renderPromise.catch(() => {});
|
||
status.textContent = '✗ ' + (e.message || e);
|
||
nowPlaying.textContent = 'Error — check PDF tab is visible';
|
||
}
|
||
}
|
||
|
||
// ── 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 handles pdf_b64 via PDF.js
|
||
await loadDoc(data.text, data.title || name.replace(/\.pdf$/i, ''), null, 0, data.pdf_b64 || null, data.page_texts || []);
|
||
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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||
}
|
||
|
||
// ── 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();
|