Initial commit: Speechify clone with multi-provider TTS
This commit is contained in:
commit
6458b101e1
6 changed files with 1107 additions and 0 deletions
BIN
__pycache__/main.cpython-312.pyc
Normal file
BIN
__pycache__/main.cpython-312.pyc
Normal file
Binary file not shown.
248
main.py
Normal file
248
main.py
Normal file
|
|
@ -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)
|
||||
8
requirements.txt
Normal file
8
requirements.txt
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
fastapi
|
||||
uvicorn
|
||||
httpx
|
||||
python-multipart
|
||||
pymupdf
|
||||
beautifulsoup4
|
||||
html2text
|
||||
requests
|
||||
419
static/app.js
Normal file
419
static/app.js
Normal file
|
|
@ -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();
|
||||
181
static/index.html
Normal file
181
static/index.html
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Speechify</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
|
||||
<!-- Top Bar -->
|
||||
<header class="topbar">
|
||||
<div class="topbar-left">
|
||||
<div class="logo">
|
||||
<svg width="30" height="30" viewBox="0 0 30 30" fill="none">
|
||||
<rect width="30" height="30" rx="9" fill="#7c3aed"/>
|
||||
<polygon points="11,8 23,15 11,22" fill="white"/>
|
||||
</svg>
|
||||
<span>Speechify</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<button class="btn-icon" id="btn-sidebar-toggle" title="Toggle Input Panel">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<line x1="9" y1="3" x2="9" y2="21"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn-icon" id="btn-settings-toggle" title="Voice Settings">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="main-layout">
|
||||
|
||||
<!-- Left Sidebar: Input -->
|
||||
<aside class="sidebar-left" id="sidebar-left">
|
||||
<div class="sidebar-header"><h3>Add Content</h3></div>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab active" data-tab="text">Text</button>
|
||||
<button class="tab" data-tab="pdf">PDF</button>
|
||||
<button class="tab" data-tab="url">URL</button>
|
||||
</div>
|
||||
|
||||
<!-- Text Tab -->
|
||||
<div class="tab-content active" id="tab-text">
|
||||
<textarea id="text-input" placeholder="Paste your text here..."></textarea>
|
||||
<button class="btn-primary" id="btn-load-text">Load Text</button>
|
||||
</div>
|
||||
|
||||
<!-- PDF Tab -->
|
||||
<div class="tab-content" id="tab-pdf">
|
||||
<div class="upload-area" id="upload-area">
|
||||
<svg width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
|
||||
<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"/>
|
||||
<line x1="12" y1="18" x2="12" y2="12"/>
|
||||
<line x1="9" y1="15" x2="15" y2="15"/>
|
||||
</svg>
|
||||
<p>Drop PDF here or<br><strong>click to upload</strong></p>
|
||||
<input type="file" id="pdf-input" accept=".pdf" hidden />
|
||||
</div>
|
||||
<div id="pdf-status" class="status-text"></div>
|
||||
</div>
|
||||
|
||||
<!-- URL Tab -->
|
||||
<div class="tab-content" id="tab-url">
|
||||
<input type="url" id="url-input" class="text-field" placeholder="https://example.com/article" />
|
||||
<button class="btn-primary" id="btn-load-url">Fetch Article</button>
|
||||
<div id="url-status" class="status-text"></div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="content-area" id="content-area">
|
||||
<div id="document-header" class="document-header" style="display:none">
|
||||
<h1 class="document-title" id="document-title"></h1>
|
||||
<p class="document-meta" id="document-meta"></p>
|
||||
</div>
|
||||
<div class="text-viewer" id="text-viewer">
|
||||
<div class="empty-state">
|
||||
<svg width="60" height="60" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
|
||||
<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"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
</svg>
|
||||
<p>Load a document to get started</p>
|
||||
<span>Paste text, upload a PDF, or enter a URL</span>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Right Sidebar: Voice Settings -->
|
||||
<aside class="sidebar-right" id="sidebar-right">
|
||||
<div class="sidebar-header"><h3>Voice Settings</h3></div>
|
||||
|
||||
<div class="setting-group">
|
||||
<label class="setting-label">TTS Model</label>
|
||||
<select id="model-select" class="select"></select>
|
||||
</div>
|
||||
|
||||
<div class="setting-group">
|
||||
<label class="setting-label">Voice</label>
|
||||
<select id="voice-select" class="select"></select>
|
||||
</div>
|
||||
|
||||
<div class="setting-group">
|
||||
<label class="setting-label">Speed — <span id="speed-label">1x</span></label>
|
||||
<input type="range" id="speed-slider" min="0.5" max="3" step="0.25" value="1" class="slider" />
|
||||
<div class="speed-presets">
|
||||
<button class="speed-btn" data-speed="0.75">0.75×</button>
|
||||
<button class="speed-btn active" data-speed="1">1×</button>
|
||||
<button class="speed-btn" data-speed="1.25">1.25×</button>
|
||||
<button class="speed-btn" data-speed="1.5">1.5×</button>
|
||||
<button class="speed-btn" data-speed="2">2×</button>
|
||||
<button class="speed-btn" data-speed="3">3×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-group">
|
||||
<label class="setting-label">Font Size</label>
|
||||
<div class="font-size-row">
|
||||
<button class="btn-sz" id="font-decrease">A−</button>
|
||||
<span id="font-size-label">18px</span>
|
||||
<button class="btn-sz" id="font-increase">A+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-group">
|
||||
<button class="btn-secondary" id="btn-preview">▶ Preview Voice</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
</div><!-- /main-layout -->
|
||||
|
||||
<!-- Player Bar -->
|
||||
<div class="player-bar">
|
||||
<div class="player-left">
|
||||
<p class="now-playing" id="now-playing">No document loaded</p>
|
||||
</div>
|
||||
<div class="player-center">
|
||||
<button class="ctrl-btn" id="btn-prev" title="Previous sentence (←)">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6 8.5 6V6z"/></svg>
|
||||
</button>
|
||||
<button class="play-btn" id="btn-play" title="Play / Pause (Space)">
|
||||
<svg class="icon-play" width="26" height="26" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
||||
<svg class="icon-pause" width="26" height="26" viewBox="0 0 24 24" fill="currentColor" style="display:none"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
|
||||
</button>
|
||||
<button class="ctrl-btn" id="btn-next" title="Next sentence (→)">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M6 18l8.5-6L6 6v12zm2.5-6 5.5 4V8l-5.5 4zM16 6h2v12h-2z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="player-right">
|
||||
<span class="progress-text"><span id="cur-sent">0</span> / <span id="tot-sent">0</span></span>
|
||||
<div class="progress-track" id="progress-track">
|
||||
<div class="progress-fill" id="progress-fill"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /app -->
|
||||
|
||||
<!-- Loading overlay -->
|
||||
<div class="loading-overlay" id="loading-overlay">
|
||||
<div class="spinner"></div>
|
||||
<p id="loading-text">Loading…</p>
|
||||
</div>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
251
static/style.css
Normal file
251
static/style.css
Normal file
|
|
@ -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; }
|
||||
Loading…
Reference in a new issue