From 6458b101e176acbbdffb85d9b2147d932f6e91b0 Mon Sep 17 00:00:00 2001 From: ifedan-ed Date: Sat, 11 Apr 2026 02:27:05 +0200 Subject: [PATCH] Initial commit: Speechify clone with multi-provider TTS --- __pycache__/main.cpython-312.pyc | Bin 0 -> 8733 bytes main.py | 248 ++++++++++++++++++ requirements.txt | 8 + static/app.js | 419 +++++++++++++++++++++++++++++++ static/index.html | 181 +++++++++++++ static/style.css | 251 ++++++++++++++++++ 6 files changed, 1107 insertions(+) create mode 100644 __pycache__/main.cpython-312.pyc create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 static/app.js create mode 100644 static/index.html create mode 100644 static/style.css diff --git a/__pycache__/main.cpython-312.pyc b/__pycache__/main.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..32aa12f919c167edef3d5f49cf0c3bfed9335adf GIT binary patch literal 8733 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 WPhScxG{39=H`4<*cxH|HApidzCyD(4 literal 0 HcmV?d00001 diff --git a/main.py b/main.py new file mode 100644 index 0000000..22d9421 --- /dev/null +++ b/main.py @@ -0,0 +1,248 @@ +from fastapi import FastAPI, UploadFile, File, HTTPException +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse, Response +from pydantic import BaseModel +import httpx +import re +import os + +app = FastAPI(title="Speechify Clone") + +LITELLM_BASE = "http://localhost:4000" +LITELLM_KEY = "sk-c5c58adcc8dda288067f034ee927be23509142d274fe48418c25009dfb507a52" + +TTS_MODELS = { + "openai-tts-1": { + "name": "OpenAI TTS", + "quality": "Standard", + "voices": [ + {"id": "alloy", "name": "Alloy"}, + {"id": "echo", "name": "Echo"}, + {"id": "fable", "name": "Fable"}, + {"id": "onyx", "name": "Onyx"}, + {"id": "nova", "name": "Nova"}, + {"id": "shimmer", "name": "Shimmer"}, + ] + }, + "openai-tts-1-hd": { + "name": "OpenAI TTS HD", + "quality": "High Definition", + "voices": [ + {"id": "alloy", "name": "Alloy"}, + {"id": "echo", "name": "Echo"}, + {"id": "fable", "name": "Fable"}, + {"id": "onyx", "name": "Onyx"}, + {"id": "nova", "name": "Nova"}, + {"id": "shimmer", "name": "Shimmer"}, + ] + }, + "elevenlabs-v3": { + "name": "ElevenLabs v3", + "quality": "Premium", + "voices": [ + {"id": "Rachel", "name": "Rachel"}, + {"id": "Drew", "name": "Drew"}, + {"id": "Clyde", "name": "Clyde"}, + {"id": "Paul", "name": "Paul"}, + {"id": "Domi", "name": "Domi"}, + {"id": "Dave", "name": "Dave"}, + {"id": "Fin", "name": "Fin"}, + {"id": "Bella", "name": "Bella"}, + {"id": "Antoni", "name": "Antoni"}, + {"id": "Thomas", "name": "Thomas"}, + {"id": "Charlie", "name": "Charlie"}, + {"id": "Emily", "name": "Emily"}, + {"id": "Elli", "name": "Elli"}, + {"id": "Callum", "name": "Callum"}, + {"id": "Patrick", "name": "Patrick"}, + {"id": "Harry", "name": "Harry"}, + {"id": "Liam", "name": "Liam"}, + {"id": "Dorothy", "name": "Dorothy"}, + {"id": "Josh", "name": "Josh"}, + {"id": "Arnold", "name": "Arnold"}, + {"id": "Adam", "name": "Adam"}, + {"id": "Sam", "name": "Sam"}, + ] + }, + "elevenlabs-multilingual": { + "name": "ElevenLabs Multilingual", + "quality": "Premium Multilingual", + "voices": [ + {"id": "Rachel", "name": "Rachel"}, + {"id": "Drew", "name": "Drew"}, + {"id": "Clyde", "name": "Clyde"}, + {"id": "Paul", "name": "Paul"}, + {"id": "Domi", "name": "Domi"}, + {"id": "Dave", "name": "Dave"}, + {"id": "Fin", "name": "Fin"}, + {"id": "Bella", "name": "Bella"}, + {"id": "Antoni", "name": "Antoni"}, + {"id": "Thomas", "name": "Thomas"}, + {"id": "Charlie", "name": "Charlie"}, + {"id": "Emily", "name": "Emily"}, + {"id": "Elli", "name": "Elli"}, + {"id": "Callum", "name": "Callum"}, + {"id": "Patrick", "name": "Patrick"}, + {"id": "Harry", "name": "Harry"}, + {"id": "Liam", "name": "Liam"}, + {"id": "Dorothy", "name": "Dorothy"}, + {"id": "Josh", "name": "Josh"}, + {"id": "Arnold", "name": "Arnold"}, + {"id": "Adam", "name": "Adam"}, + {"id": "Sam", "name": "Sam"}, + ] + }, + "vertex-gemini-2.5-flash-tts": { + "name": "Gemini 2.5 Flash TTS", + "quality": "Fast", + "voices": [ + {"id": "Aoede", "name": "Aoede"}, + {"id": "Charon", "name": "Charon"}, + {"id": "Fenrir", "name": "Fenrir"}, + {"id": "Kore", "name": "Kore"}, + {"id": "Puck", "name": "Puck"}, + {"id": "Orbit", "name": "Orbit"}, + {"id": "Zephyr", "name": "Zephyr"}, + {"id": "Autonoe", "name": "Autonoe"}, + {"id": "Enceladus", "name": "Enceladus"}, + {"id": "Laomedeia", "name": "Laomedeia"}, + {"id": "Achernar", "name": "Achernar"}, + {"id": "Rasalgethi", "name": "Rasalgethi"}, + {"id": "Schedar", "name": "Schedar"}, + {"id": "Sulafat", "name": "Sulafat"}, + ] + }, + "vertex-gemini-2.5-pro-tts": { + "name": "Gemini 2.5 Pro TTS", + "quality": "High Quality", + "voices": [ + {"id": "Aoede", "name": "Aoede"}, + {"id": "Charon", "name": "Charon"}, + {"id": "Fenrir", "name": "Fenrir"}, + {"id": "Kore", "name": "Kore"}, + {"id": "Puck", "name": "Puck"}, + {"id": "Orbit", "name": "Orbit"}, + {"id": "Zephyr", "name": "Zephyr"}, + {"id": "Autonoe", "name": "Autonoe"}, + {"id": "Enceladus", "name": "Enceladus"}, + {"id": "Laomedeia", "name": "Laomedeia"}, + {"id": "Achernar", "name": "Achernar"}, + {"id": "Rasalgethi", "name": "Rasalgethi"}, + {"id": "Schedar", "name": "Schedar"}, + {"id": "Sulafat", "name": "Sulafat"}, + ] + }, +} + + +class TTSRequest(BaseModel): + text: str + model: str + voice: str + speed: float = 1.0 + + +class URLRequest(BaseModel): + url: str + + +@app.get("/api/voices") +async def get_voices(): + return TTS_MODELS + + +@app.post("/api/tts") +async def text_to_speech(req: TTSRequest): + payload = { + "model": req.model, + "input": req.text, + "voice": req.voice, + } + if req.speed != 1.0: + payload["speed"] = req.speed + + async with httpx.AsyncClient(timeout=60.0) as client: + resp = await client.post( + f"{LITELLM_BASE}/v1/audio/speech", + json=payload, + headers={"Authorization": f"Bearer {LITELLM_KEY}"}, + ) + if resp.status_code != 200: + raise HTTPException(status_code=resp.status_code, detail=resp.text) + + return Response( + content=resp.content, + media_type="audio/mpeg", + ) + + +@app.post("/api/extract-url") +async def extract_from_url(req: URLRequest): + try: + from bs4 import BeautifulSoup + import html2text + + async with httpx.AsyncClient(timeout=20.0, follow_redirects=True) as client: + resp = await client.get( + req.url, + headers={"User-Agent": "Mozilla/5.0 (compatible; SpeechifyClone/1.0)"}, + ) + + soup = BeautifulSoup(resp.text, "html.parser") + + for tag in soup(["script", "style", "nav", "footer", "header", "aside", "iframe"]): + tag.decompose() + + main = ( + soup.find("article") + or soup.find("main") + or soup.find(id="content") + or soup.find(class_="content") + or soup.body + ) + + h = html2text.HTML2Text() + h.ignore_links = True + h.ignore_images = True + h.body_width = 0 + + text = h.handle(str(main)) + text = re.sub(r'\n{3,}', '\n\n', text).strip() + title = soup.title.string.strip() if soup.title else req.url + + return {"text": text, "title": title} + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@app.post("/api/extract-pdf") +async def extract_from_pdf(file: UploadFile = File(...)): + try: + import fitz # PyMuPDF + + content = await file.read() + doc = fitz.open(stream=content, filetype="pdf") + + pages = [] + for page in doc: + pages.append(page.get_text()) + + text = "\n\n".join(pages) + text = re.sub(r'\n{3,}', '\n\n', text).strip() + + return {"text": text, "title": file.filename} + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + +app.mount("/static", StaticFiles(directory="static"), name="static") + + +@app.get("/") +async def root(): + return FileResponse("static/index.html") + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8080, reload=True) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0c325b7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +fastapi +uvicorn +httpx +python-multipart +pymupdf +beautifulsoup4 +html2text +requests diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..d0e848a --- /dev/null +++ b/static/app.js @@ -0,0 +1,419 @@ +// ── 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(); diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..3278c12 --- /dev/null +++ b/static/index.html @@ -0,0 +1,181 @@ + + + + + + Speechify + + + + + + +
+ + +
+
+ +
+
+ + +
+
+ +
+ + + + + +
+ +
+
+ + + + + + +

Load a document to get started

+ Paste text, upload a PDF, or enter a URL +
+
+
+ + + + +
+ + +
+
+

No document loaded

+
+
+ + + +
+
+ 0 / 0 +
+
+
+
+
+ +
+ + +
+
+

Loading…

+
+ + + + diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..86564af --- /dev/null +++ b/static/style.css @@ -0,0 +1,251 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #0d0d0d; + --bg2: #161616; + --bg3: #212121; + --bg4: #2a2a2a; + --accent: #7c3aed; + --accent-h: #9d6fff; + --text: #e8e8e8; + --text-m: #888; + --text-d: #444; + --hl: rgba(124,58,237,.18); + --hl-active: rgba(124,58,237,.42); + --border: #232323; + --topbar-h: 54px; + --player-h: 76px; + --sidebar-w: 270px; +} + +body { + font-family: 'Inter', system-ui, sans-serif; + background: var(--bg); + color: var(--text); + height: 100vh; + overflow: hidden; +} + +/* ── TOP BAR ─────────────────────────────── */ +.topbar { + height: var(--topbar-h); + background: var(--bg2); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 18px; + flex-shrink: 0; +} +.logo { display: flex; align-items: center; gap: 10px; font-size: 17px; font-weight: 700; } +.topbar-right { display: flex; gap: 6px; } + +/* ── MAIN LAYOUT ──────────────────────────── */ +.app { display: flex; flex-direction: column; height: 100vh; } +.main-layout { + display: flex; + flex: 1; + overflow: hidden; + height: calc(100vh - var(--topbar-h) - var(--player-h)); +} + +/* ── SIDEBARS ─────────────────────────────── */ +.sidebar-left, .sidebar-right { + width: var(--sidebar-w); + background: var(--bg2); + display: flex; + flex-direction: column; + flex-shrink: 0; + overflow: hidden; + transition: width .25s ease; +} +.sidebar-left { border-right: 1px solid var(--border); } +.sidebar-right { border-left: 1px solid var(--border); overflow-y: auto; } +.sidebar-left.collapsed, .sidebar-right.collapsed { width: 0; } + +.sidebar-header { + padding: 14px 16px 10px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} +.sidebar-header h3 { + font-size: 11px; font-weight: 600; + text-transform: uppercase; letter-spacing: .6px; color: var(--text-m); +} + +/* ── TABS ─────────────────────────────────── */ +.tabs { display: flex; padding: 10px 10px 0; gap: 4px; flex-shrink: 0; } +.tab { + flex: 1; padding: 7px 4px; background: none; border: none; + border-radius: 7px; color: var(--text-m); font-size: 13px; + font-weight: 500; cursor: pointer; transition: all .15s; font-family: inherit; +} +.tab:hover { background: var(--bg3); color: var(--text); } +.tab.active { background: var(--accent); color: #fff; } + +.tab-content { display: none; flex-direction: column; gap: 10px; padding: 12px; flex: 1; overflow: hidden; } +.tab-content.active { display: flex; } + +/* ── FORM ELEMENTS ────────────────────────── */ +textarea, .text-field { + background: var(--bg3); border: 1px solid var(--border); + border-radius: 8px; color: var(--text); font-family: inherit; + font-size: 13px; line-height: 1.6; padding: 10px 12px; outline: none; + transition: border-color .15s; width: 100%; +} +textarea { resize: none; flex: 1; min-height: 0; } +textarea:focus, .text-field:focus { border-color: var(--accent); } + +/* ── UPLOAD AREA ──────────────────────────── */ +.upload-area { + border: 2px dashed var(--border); border-radius: 10px; + padding: 28px 16px; display: flex; flex-direction: column; + align-items: center; gap: 10px; cursor: pointer; + color: var(--text-m); transition: all .15s; text-align: center; +} +.upload-area strong { color: var(--text); } +.upload-area:hover, .upload-area.drag-over { + border-color: var(--accent); background: rgba(124,58,237,.06); color: var(--text); +} +.upload-area p { font-size: 13px; line-height: 1.5; } + +/* ── BUTTONS ──────────────────────────────── */ +.btn-primary { + background: var(--accent); color: #fff; border: none; border-radius: 8px; + padding: 10px; font-family: inherit; font-size: 14px; font-weight: 500; + cursor: pointer; transition: background .15s; width: 100%; flex-shrink: 0; +} +.btn-primary:hover { background: var(--accent-h); } + +.btn-secondary { + background: var(--bg3); color: var(--text); border: 1px solid var(--border); + border-radius: 8px; padding: 10px; font-family: inherit; font-size: 13px; + font-weight: 500; cursor: pointer; transition: all .15s; width: 100%; +} +.btn-secondary:hover { border-color: var(--accent); color: var(--accent-h); } + +.btn-icon { + background: none; border: none; color: var(--text-m); cursor: pointer; + padding: 7px; border-radius: 6px; display: flex; align-items: center; transition: all .15s; +} +.btn-icon:hover { background: var(--bg3); color: var(--text); } + +.btn-sz { + background: var(--bg3); border: 1px solid var(--border); color: var(--text); + cursor: pointer; padding: 4px 12px; border-radius: 6px; font-size: 13px; + font-weight: 600; transition: all .15s; font-family: inherit; +} +.btn-sz:hover { border-color: var(--accent); color: var(--accent-h); } + +/* ── STATUS TEXT ─────────────────────────── */ +.status-text { font-size: 12px; color: var(--text-m); min-height: 18px; text-align: center; } + +/* ── MAIN CONTENT AREA ───────────────────── */ +.content-area { + flex: 1; overflow-y: auto; padding: 44px 56px; +} +.document-header { margin-bottom: 28px; } +.document-title { font-size: 22px; font-weight: 700; margin-bottom: 6px; } +.document-meta { font-size: 13px; color: var(--text-m); } + +.text-viewer { font-size: 18px; line-height: 1.85; color: var(--text); } + +.empty-state { + display: flex; flex-direction: column; align-items: center; + gap: 14px; padding: 80px 40px; color: var(--text-d); text-align: center; +} +.empty-state p { font-size: 16px; color: var(--text-m); } +.empty-state span { font-size: 13px; } + +/* Sentence spans */ +.s { + cursor: pointer; border-radius: 4px; padding: 2px 1px; + transition: background .12s; display: inline; +} +.s:hover { background: var(--hl); } +.s.active { background: var(--hl-active); color: #fff; border-radius: 4px; } +.para-gap { display: block; height: .9em; } + +/* ── SETTINGS SIDEBAR ────────────────────── */ +.setting-group { padding: 16px; border-bottom: 1px solid var(--border); } +.setting-label { + display: block; font-size: 11px; font-weight: 600; + text-transform: uppercase; letter-spacing: .5px; color: var(--text-m); margin-bottom: 10px; +} +.select { + width: 100%; background: var(--bg3); border: 1px solid var(--border); + border-radius: 8px; color: var(--text); font-family: inherit; font-size: 13px; + padding: 8px 10px; outline: none; cursor: pointer; transition: border-color .15s; +} +.select:focus { border-color: var(--accent); } + +.slider { width: 100%; accent-color: var(--accent); cursor: pointer; margin-bottom: 10px; display: block; } + +.speed-presets { display: flex; gap: 4px; flex-wrap: wrap; } +.speed-btn { + background: var(--bg3); border: 1px solid var(--border); color: var(--text-m); + border-radius: 6px; padding: 4px 8px; font-size: 12px; font-family: inherit; + cursor: pointer; transition: all .15s; +} +.speed-btn:hover, .speed-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; } + +.font-size-row { display: flex; align-items: center; gap: 12px; } +.font-size-row span { flex: 1; text-align: center; font-size: 13px; color: var(--text-m); } + +/* ── PLAYER BAR ─────────────────────────── */ +.player-bar { + height: var(--player-h); background: var(--bg2); + border-top: 1px solid var(--border); display: flex; + align-items: center; padding: 0 24px; gap: 20px; flex-shrink: 0; +} +.player-left { flex: 1; min-width: 0; } +.now-playing { + font-size: 13px; color: var(--text-m); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.player-center { display: flex; align-items: center; gap: 14px; } + +.ctrl-btn { + background: none; border: none; color: var(--text-m); cursor: pointer; + padding: 8px; border-radius: 50%; display: flex; align-items: center; + transition: all .15s; +} +.ctrl-btn:hover { background: var(--bg3); color: var(--text); } + +.play-btn { + width: 50px; height: 50px; border-radius: 50%; border: none; + background: var(--accent); color: #fff; cursor: pointer; + display: flex; align-items: center; justify-content: center; + transition: all .15s; flex-shrink: 0; +} +.play-btn:hover { background: var(--accent-h); transform: scale(1.05); } + +.player-right { flex: 1; display: flex; flex-direction: column; align-items: flex-end; gap: 6px; } +.progress-text { font-size: 12px; color: var(--text-m); } +.progress-track { + width: 180px; height: 4px; background: var(--bg4); + border-radius: 2px; cursor: pointer; overflow: hidden; +} +.progress-fill { + height: 100%; background: var(--accent); border-radius: 2px; + width: 0%; transition: width .3s ease; +} + +/* ── LOADING OVERLAY ─────────────────────── */ +.loading-overlay { + display: none; position: fixed; inset: 0; + background: rgba(0,0,0,.65); z-index: 200; + flex-direction: column; align-items: center; justify-content: center; gap: 16px; +} +.loading-overlay.show { display: flex; } +.spinner { + width: 38px; height: 38px; border: 3px solid var(--bg4); + border-top-color: var(--accent); border-radius: 50%; + animation: spin .7s linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } + +/* ── SCROLLBAR ───────────────────────────── */ +::-webkit-scrollbar { width: 5px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--bg4); border-radius: 3px; }