initial commit
This commit is contained in:
parent
2d7b278666
commit
54184439a4
95 changed files with 1289 additions and 22591 deletions
8
web/.dockerignore
Normal file
8
web/.dockerignore
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
web/backend/downloads/
|
||||
web/backend/cookies.txt
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.venv/
|
||||
.git/
|
||||
*.egg-info/
|
||||
24
web/README.md
Normal file
24
web/README.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# yt-dlp Web UI
|
||||
|
||||
## Setup
|
||||
|
||||
**1. Install backend dependencies** (from the `web/backend` folder):
|
||||
```bash
|
||||
pip install -r web/backend/requirements.txt
|
||||
```
|
||||
|
||||
**2. Install yt-dlp** (from the repo root):
|
||||
```bash
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
**3. Start the backend:**
|
||||
```bash
|
||||
uvicorn web.backend.main:app --reload --port 8000
|
||||
```
|
||||
|
||||
**4. Open the frontend:**
|
||||
|
||||
Just open `web/frontend/index.html` in your browser — no build step needed.
|
||||
|
||||
> Make sure `ffmpeg` is installed and on your PATH for merging video+audio streams.
|
||||
290
web/backend/main.py
Normal file
290
web/backend/main.py
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
import sys
|
||||
import json
|
||||
import uuid
|
||||
import asyncio
|
||||
import httpx
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import StreamingResponse, Response, JSONResponse, FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
import yt_dlp
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
return JSONResponse(status_code=500, content={"detail": str(exc)})
|
||||
|
||||
DOWNLOAD_DIR = Path(__file__).parent / "downloads"
|
||||
DOWNLOAD_DIR.mkdir(exist_ok=True)
|
||||
FRONTEND_DIR = Path(__file__).parent.parent / "frontend"
|
||||
|
||||
FORMAT_PRESETS = {
|
||||
"best": "bestvideo+bestaudio/bestvideo/best",
|
||||
"1080p": "bestvideo[height<=1080]+bestaudio/bestvideo[height<=1080]/best",
|
||||
"720p": "bestvideo[height<=720]+bestaudio/bestvideo[height<=720]/best",
|
||||
"480p": "bestvideo[height<=480]+bestaudio/bestvideo[height<=480]/best",
|
||||
"audio": "bestaudio/best",
|
||||
}
|
||||
|
||||
# file_id -> { status, percent, eta, speed, filepath, ... }
|
||||
_progress: dict[str, dict] = {}
|
||||
# file_id -> asyncio.Task
|
||||
_tasks: dict[str, asyncio.Task] = {}
|
||||
|
||||
|
||||
class InfoRequest(BaseModel):
|
||||
url: str
|
||||
|
||||
class DownloadRequest(BaseModel):
|
||||
url: str
|
||||
preset: str = "best"
|
||||
format_id: str | None = None
|
||||
file_id: str | None = None
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def index():
|
||||
return FileResponse(FRONTEND_DIR / "index.html")
|
||||
|
||||
|
||||
@app.post("/info")
|
||||
async def get_info(req: InfoRequest):
|
||||
ydl_opts = {"quiet": True, "no_warnings": True, "skip_download": True}
|
||||
info = None
|
||||
try:
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = await asyncio.to_thread(ydl.extract_info, req.url, download=False)
|
||||
except Exception:
|
||||
pass
|
||||
if not info:
|
||||
try:
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = await asyncio.to_thread(ydl.extract_info, req.url, download=False)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
if not info:
|
||||
raise HTTPException(status_code=400, detail="Could not extract video info.")
|
||||
|
||||
thumbnails = info.get("thumbnails") or []
|
||||
thumbnail_url = None
|
||||
if thumbnails:
|
||||
ranked = sorted(
|
||||
(t for t in thumbnails if t.get("url")),
|
||||
key=lambda t: (t.get("width") or 0) * (t.get("height") or 0),
|
||||
reverse=True,
|
||||
)
|
||||
thumbnail_url = ranked[0]["url"] if ranked else None
|
||||
thumbnail_url = thumbnail_url or info.get("thumbnail")
|
||||
|
||||
formats = [
|
||||
{
|
||||
"format_id": f.get("format_id"),
|
||||
"ext": f.get("ext"),
|
||||
"resolution": f.get("resolution") or f.get("format_note") or "unknown",
|
||||
"filesize": f.get("filesize") or f.get("filesize_approx"),
|
||||
"vcodec": f.get("vcodec"),
|
||||
"acodec": f.get("acodec"),
|
||||
"fps": f.get("fps"),
|
||||
"height": f.get("height") or 0,
|
||||
"tbr": f.get("tbr") or 0,
|
||||
}
|
||||
for f in (info.get("formats") or [])
|
||||
if f.get("ext") != "mhtml"
|
||||
and f.get("protocol") != "mhtml"
|
||||
and (f.get("vcodec", "none") != "none" or f.get("acodec", "none") != "none")
|
||||
]
|
||||
formats.sort(key=lambda f: (f["height"], f["tbr"]), reverse=True)
|
||||
|
||||
if not formats:
|
||||
raise HTTPException(status_code=400, detail="No downloadable formats found.")
|
||||
|
||||
return {
|
||||
"title": info.get("title"),
|
||||
"thumbnail": f"/thumbnail?url={thumbnail_url}" if thumbnail_url else None,
|
||||
"duration": info.get("duration"),
|
||||
"uploader": info.get("uploader"),
|
||||
"view_count": info.get("view_count"),
|
||||
"like_count": info.get("like_count"),
|
||||
"upload_date": info.get("upload_date"),
|
||||
"formats": formats,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/thumbnail")
|
||||
async def proxy_thumbnail(url: str):
|
||||
try:
|
||||
async with httpx.AsyncClient(follow_redirects=True, timeout=10) as client:
|
||||
r = await client.get(url, headers={"User-Agent": "Mozilla/5.0"})
|
||||
return Response(content=r.content, media_type=r.headers.get("content-type", "image/jpeg"))
|
||||
except Exception:
|
||||
raise HTTPException(status_code=502, detail="Could not fetch thumbnail")
|
||||
|
||||
|
||||
async def _run_download(file_id: str, url: str, fmt: str, output_template: str):
|
||||
"""Runs yt-dlp in a thread and updates _progress. Cancellable via task cancellation."""
|
||||
cancelled = asyncio.Event()
|
||||
|
||||
def progress_hook(d):
|
||||
if cancelled.is_set():
|
||||
raise yt_dlp.utils.DownloadError("Cancelled")
|
||||
status = d.get("status")
|
||||
if status == "downloading":
|
||||
downloaded = d.get("downloaded_bytes", 0)
|
||||
total = d.get("total_bytes") or d.get("total_bytes_estimate") or 0
|
||||
pct = round((downloaded / total) * 100, 1) if total else 0
|
||||
eta = d.get("eta")
|
||||
speed = d.get("speed")
|
||||
_progress[file_id].update({
|
||||
"status": "downloading",
|
||||
"percent": pct,
|
||||
"eta": int(eta) if eta else None,
|
||||
"speed": round(speed / 1024 / 1024, 2) if speed else None,
|
||||
})
|
||||
elif status == "finished":
|
||||
_progress[file_id].update({"status": "finished", "percent": 100, "eta": 0})
|
||||
|
||||
ydl_opts = {
|
||||
"quiet": True,
|
||||
"no_warnings": True,
|
||||
"format": fmt,
|
||||
"outtmpl": output_template,
|
||||
"merge_output_format": "mp4",
|
||||
"progress_hooks": [progress_hook],
|
||||
}
|
||||
|
||||
try:
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = await asyncio.to_thread(ydl.extract_info, url, download=True)
|
||||
return info
|
||||
except asyncio.CancelledError:
|
||||
cancelled.set()
|
||||
_progress[file_id]["status"] = "cancelled"
|
||||
raise
|
||||
except Exception as e:
|
||||
_progress[file_id]["status"] = "error"
|
||||
_progress[file_id]["error"] = str(e)
|
||||
raise
|
||||
|
||||
|
||||
@app.post("/download")
|
||||
async def download_video(req: DownloadRequest):
|
||||
file_id = req.file_id or str(uuid.uuid4())
|
||||
output_template = str(DOWNLOAD_DIR / f"{file_id}.%(ext)s")
|
||||
fmt = req.format_id if req.format_id else FORMAT_PRESETS.get(req.preset, FORMAT_PRESETS["best"])
|
||||
|
||||
_progress[file_id] = {
|
||||
"status": "starting", "percent": 0, "eta": None, "speed": None,
|
||||
"url": req.url, "fmt": fmt, "output_template": output_template,
|
||||
}
|
||||
|
||||
task = asyncio.create_task(_run_download(file_id, req.url, fmt, output_template))
|
||||
_tasks[file_id] = task
|
||||
|
||||
try:
|
||||
info = await task
|
||||
except asyncio.CancelledError:
|
||||
return JSONResponse({"file_id": file_id, "status": "cancelled"})
|
||||
except Exception as e:
|
||||
_tasks.pop(file_id, None)
|
||||
raise HTTPException(status_code=400, detail=_progress.get(file_id, {}).get("error", str(e)))
|
||||
|
||||
_tasks.pop(file_id, None)
|
||||
|
||||
matches = list(DOWNLOAD_DIR.glob(f"{file_id}.*"))
|
||||
if not matches:
|
||||
raise HTTPException(status_code=500, detail="Download failed: file not found")
|
||||
|
||||
filepath = matches[0]
|
||||
title = info.get("title") or file_id
|
||||
filename_ascii = "".join(c for c in title if c.isascii() and (c.isalnum() or c in " ._-()")).strip()
|
||||
if not filename_ascii:
|
||||
filename_ascii = file_id
|
||||
filename_ascii += filepath.suffix
|
||||
filename_encoded = urllib.parse.quote(title + filepath.suffix)
|
||||
|
||||
_progress[file_id].update({
|
||||
"status": "ready",
|
||||
"percent": 100,
|
||||
"filepath": str(filepath),
|
||||
"filename_ascii": filename_ascii,
|
||||
"filename_encoded": filename_encoded,
|
||||
})
|
||||
|
||||
return JSONResponse({"file_id": file_id, "status": "ready"})
|
||||
|
||||
|
||||
@app.post("/cancel/{file_id}")
|
||||
async def cancel_download(file_id: str):
|
||||
task = _tasks.get(file_id)
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
# Clean up partial files
|
||||
for f in DOWNLOAD_DIR.glob(f"{file_id}*"):
|
||||
f.unlink(missing_ok=True)
|
||||
_progress[file_id] = {"status": "cancelled", "percent": 0}
|
||||
return JSONResponse({"status": "cancelled"})
|
||||
return JSONResponse({"status": "not_found"})
|
||||
|
||||
|
||||
@app.get("/file/{file_id}")
|
||||
async def serve_file(file_id: str):
|
||||
meta = _progress.get(file_id)
|
||||
if not meta or meta.get("status") != "ready":
|
||||
raise HTTPException(status_code=404, detail="File not ready or already downloaded")
|
||||
|
||||
filepath = Path(meta["filepath"])
|
||||
if not filepath.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
filename_ascii = meta["filename_ascii"]
|
||||
filename_encoded = meta["filename_encoded"]
|
||||
|
||||
def iterfile():
|
||||
with open(filepath, "rb") as f:
|
||||
yield from f
|
||||
filepath.unlink(missing_ok=True)
|
||||
_progress.pop(file_id, None)
|
||||
|
||||
return StreamingResponse(
|
||||
iterfile(),
|
||||
media_type="video/mp4",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename=\"{filename_ascii}\"; filename*=UTF-8''{filename_encoded}",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/progress/{file_id}")
|
||||
async def progress_stream(file_id: str, request: Request):
|
||||
async def event_generator():
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
data = _progress.get(file_id)
|
||||
if data:
|
||||
yield f"data: {json.dumps(data)}\n\n"
|
||||
if data.get("status") in ("ready", "cancelled", "error"):
|
||||
break
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
return StreamingResponse(
|
||||
event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")
|
||||
3
web/backend/requirements.txt
Normal file
3
web/backend/requirements.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
fastapi>=0.110.0
|
||||
uvicorn[standard]>=0.29.0
|
||||
httpx>=0.27.0
|
||||
309
web/frontend/app.js
Normal file
309
web/frontend/app.js
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
const API = 'http://localhost:8000';
|
||||
|
||||
const urlInput = document.getElementById('url-input');
|
||||
const clearBtn = document.getElementById('clear-btn');
|
||||
const fetchBtn = document.getElementById('fetch-btn');
|
||||
const errorMsg = document.getElementById('error-msg');
|
||||
const preview = document.getElementById('preview');
|
||||
const thumbnail = document.getElementById('thumbnail');
|
||||
const videoTitle = document.getElementById('video-title');
|
||||
const uploaderEl = document.getElementById('uploader');
|
||||
const durationEl = document.getElementById('duration');
|
||||
const formatSection = document.getElementById('format-section');
|
||||
const formatSelect = document.getElementById('format-select');
|
||||
const downloadBtn = document.getElementById('download-btn');
|
||||
const progressWrap = document.getElementById('progress-wrap');
|
||||
const progressFill = document.getElementById('progress-fill');
|
||||
const progressLabel = document.getElementById('progress-label');
|
||||
const cancelBtn = document.getElementById('cancel-btn');
|
||||
|
||||
let currentFileId = null;
|
||||
let currentAbort = null;
|
||||
let currentSse = null;
|
||||
|
||||
// --- Cancel ---
|
||||
cancelBtn.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Abort the pending fetch
|
||||
if (currentAbort) { currentAbort.abort(); currentAbort = null; }
|
||||
// Close SSE
|
||||
if (currentSse) { currentSse.close(); currentSse = null; }
|
||||
// Tell backend to kill the task and clean up
|
||||
if (currentFileId) {
|
||||
fetch(`${API}/cancel/${currentFileId}`, { method: 'POST' }).catch(() => {});
|
||||
currentFileId = null;
|
||||
}
|
||||
|
||||
progressWrap.style.display = 'none';
|
||||
setProgress(0, '');
|
||||
setLoading(downloadBtn, false);
|
||||
});
|
||||
|
||||
// --- Reset UI ---
|
||||
function resetUI() {
|
||||
urlInput.value = '';
|
||||
clearBtn.style.display = 'none';
|
||||
errorMsg.style.display = 'none';
|
||||
preview.style.display = 'none';
|
||||
formatSection.style.display = 'none';
|
||||
downloadBtn.style.display = 'none';
|
||||
progressWrap.style.display = 'none';
|
||||
thumbnail.src = '';
|
||||
}
|
||||
window.addEventListener('pageshow', resetUI);
|
||||
|
||||
// --- Helpers ---
|
||||
function showError(msg) {
|
||||
errorMsg.textContent = msg;
|
||||
errorMsg.style.display = 'block';
|
||||
}
|
||||
function hideError() { errorMsg.style.display = 'none'; }
|
||||
|
||||
function setLoading(btn, loading, text) {
|
||||
btn.disabled = loading;
|
||||
btn.style.opacity = loading ? '0.6' : '1';
|
||||
btn.style.cursor = loading ? 'wait' : '';
|
||||
if (text) btn.dataset.label = btn.dataset.label || btn.textContent.trim();
|
||||
if (loading && text) {
|
||||
btn.textContent = text;
|
||||
} else if (!loading && btn.dataset.label) {
|
||||
if (btn.id === 'download-btn') {
|
||||
btn.innerHTML = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> Download`;
|
||||
} else {
|
||||
btn.textContent = btn.dataset.label;
|
||||
}
|
||||
delete btn.dataset.label;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(secs) {
|
||||
if (!secs) return null;
|
||||
const h = Math.floor(secs / 3600);
|
||||
const m = Math.floor((secs % 3600) / 60);
|
||||
const s = secs % 60;
|
||||
return h > 0
|
||||
? `${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`
|
||||
: `${m}:${String(s).padStart(2,'0')}`;
|
||||
}
|
||||
|
||||
function formatCount(n) {
|
||||
if (n >= 1e9) return (n / 1e9).toFixed(1) + 'B';
|
||||
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
|
||||
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
|
||||
return n.toString();
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (!bytes) return null;
|
||||
if (bytes >= 1e9) return (bytes / 1e9).toFixed(1) + ' GB';
|
||||
if (bytes >= 1e6) return (bytes / 1e6).toFixed(1) + ' MB';
|
||||
return (bytes / 1e3).toFixed(0) + ' KB';
|
||||
}
|
||||
|
||||
function formatLabel(f) {
|
||||
const parts = [];
|
||||
const res = f.resolution || '';
|
||||
if (res && res !== 'unknown') parts.push(res);
|
||||
if (f.ext) parts.push(f.ext.toUpperCase());
|
||||
const hasVideo = f.vcodec && f.vcodec !== 'none';
|
||||
const hasAudio = f.acodec && f.acodec !== 'none';
|
||||
if (!hasVideo && hasAudio) parts.push('audio only');
|
||||
if (f.fps && hasVideo) parts.push(`${f.fps}fps`);
|
||||
const size = formatBytes(f.filesize);
|
||||
if (size) parts.push(`~${size}`);
|
||||
return parts.join(' · ') || f.format_id;
|
||||
}
|
||||
|
||||
function setProgress(pct, label) {
|
||||
progressFill.style.width = pct + '%';
|
||||
progressLabel.innerHTML = label;
|
||||
}
|
||||
|
||||
function formatEta(secs) {
|
||||
if (secs == null || secs < 0) return '';
|
||||
if (secs < 60) return `ETA ${secs}s`;
|
||||
const m = Math.floor(secs / 60);
|
||||
const s = secs % 60;
|
||||
return `ETA ${m}m ${s}s`;
|
||||
}
|
||||
|
||||
// --- URL input ---
|
||||
urlInput.addEventListener('input', () => {
|
||||
clearBtn.style.display = urlInput.value ? 'flex' : 'none';
|
||||
});
|
||||
clearBtn.addEventListener('click', () => { resetUI(); urlInput.focus(); });
|
||||
urlInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') fetchBtn.click(); });
|
||||
|
||||
// --- Fetch info ---
|
||||
fetchBtn.addEventListener('click', async () => {
|
||||
const url = urlInput.value.trim();
|
||||
if (!url) { showError('Please paste a video URL first.'); return; }
|
||||
|
||||
hideError();
|
||||
preview.style.display = 'none';
|
||||
formatSection.style.display = 'none';
|
||||
downloadBtn.style.display = 'none';
|
||||
progressWrap.style.display = 'none';
|
||||
setLoading(fetchBtn, true, 'Fetching…');
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/info`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.detail || 'Failed to fetch video info.');
|
||||
renderPreview(data);
|
||||
renderFormats(data.formats || []);
|
||||
preview.style.display = 'flex';
|
||||
formatSection.style.display = 'flex';
|
||||
downloadBtn.style.display = 'flex';
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
} finally {
|
||||
setLoading(fetchBtn, false);
|
||||
}
|
||||
});
|
||||
|
||||
function renderPreview(info) {
|
||||
thumbnail.src = info.thumbnail || '';
|
||||
thumbnail.alt = info.title || 'Video thumbnail';
|
||||
thumbnail.style.display = info.thumbnail ? 'block' : 'none';
|
||||
videoTitle.textContent = info.title || 'Unknown title';
|
||||
uploaderEl.textContent = info.uploader || '';
|
||||
uploaderEl.style.display = info.uploader ? '' : 'none';
|
||||
const dur = formatDuration(info.duration);
|
||||
durationEl.textContent = dur || '';
|
||||
durationEl.style.display = dur ? '' : 'none';
|
||||
|
||||
const viewsEl = document.getElementById('views');
|
||||
const likesEl = document.getElementById('likes');
|
||||
const dateEl = document.getElementById('upload-date');
|
||||
viewsEl.textContent = info.view_count ? '👁 ' + formatCount(info.view_count) : '';
|
||||
viewsEl.style.display = info.view_count ? '' : 'none';
|
||||
likesEl.textContent = info.like_count ? '👍 ' + formatCount(info.like_count) : '';
|
||||
likesEl.style.display = info.like_count ? '' : 'none';
|
||||
if (info.upload_date && info.upload_date.length === 8) {
|
||||
const d = info.upload_date;
|
||||
const formatted = new Date(`${d.slice(0,4)}-${d.slice(4,6)}-${d.slice(6,8)}`).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
dateEl.textContent = '📅 ' + formatted;
|
||||
dateEl.style.display = '';
|
||||
} else {
|
||||
dateEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function renderFormats(formats) {
|
||||
formatSelect.innerHTML = '';
|
||||
const sorted = [...formats].sort((a, b) =>
|
||||
(b.height || 0) - (a.height || 0) || (b.tbr || 0) - (a.tbr || 0)
|
||||
);
|
||||
const combined = sorted.filter(f => f.vcodec !== 'none' && f.acodec !== 'none');
|
||||
const videoOnly = sorted.filter(f => f.vcodec !== 'none' && f.acodec === 'none');
|
||||
const audioOnly = sorted.filter(f => f.vcodec === 'none' && f.acodec !== 'none');
|
||||
|
||||
const addGroup = (label, items) => {
|
||||
if (!items.length) return;
|
||||
const group = document.createElement('optgroup');
|
||||
group.label = label;
|
||||
items.forEach(f => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = f.format_id;
|
||||
opt.textContent = formatLabel(f);
|
||||
group.appendChild(opt);
|
||||
});
|
||||
formatSelect.appendChild(group);
|
||||
};
|
||||
|
||||
if ((videoOnly[0]?.height || 0) >= (combined[0]?.height || 0)) {
|
||||
addGroup('Video only', videoOnly);
|
||||
addGroup('Video + Audio', combined);
|
||||
} else {
|
||||
addGroup('Video + Audio', combined);
|
||||
addGroup('Video only', videoOnly);
|
||||
}
|
||||
addGroup('Audio only', audioOnly);
|
||||
if (sorted.length) formatSelect.value = sorted[0].format_id;
|
||||
}
|
||||
|
||||
// --- Download ---
|
||||
downloadBtn.addEventListener('click', async () => {
|
||||
const url = urlInput.value.trim();
|
||||
if (!url) return;
|
||||
|
||||
hideError();
|
||||
setLoading(downloadBtn, true, 'Downloading…');
|
||||
progressWrap.style.display = 'block';
|
||||
setProgress(0, '<span class="meta">Starting…</span>');
|
||||
|
||||
const fileId = crypto.randomUUID();
|
||||
currentFileId = fileId;
|
||||
|
||||
// SSE for live progress
|
||||
const sse = new EventSource(`${API}/progress/${fileId}`);
|
||||
currentSse = sse;
|
||||
sse.onmessage = (e) => {
|
||||
const d = JSON.parse(e.data);
|
||||
if (d.status === 'downloading') {
|
||||
const pct = d.percent || 0;
|
||||
const eta = d.eta != null ? formatEta(d.eta) : '';
|
||||
const speed = d.speed != null ? `${d.speed} MB/s` : '';
|
||||
const meta = [speed, eta].filter(Boolean).join(' · ');
|
||||
setProgress(pct, `<span class="pct">${pct}%</span>${meta ? ` <span class="meta">${meta}</span>` : ''}`);
|
||||
} else if (d.status === 'finished') {
|
||||
setProgress(100, '<span class="pct">100%</span> <span class="meta">Processing…</span>');
|
||||
} else if (d.status === 'cancelled') {
|
||||
sse.close();
|
||||
} else if (d.status === 'error') {
|
||||
sse.close();
|
||||
showError(d.error || 'Download failed.');
|
||||
progressWrap.style.display = 'none';
|
||||
setLoading(downloadBtn, false);
|
||||
}
|
||||
};
|
||||
sse.onerror = () => sse.close();
|
||||
|
||||
// AbortController so cancel can kill the fetch
|
||||
const abort = new AbortController();
|
||||
currentAbort = abort;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/download`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url, format_id: formatSelect.value, file_id: fileId }),
|
||||
signal: abort.signal,
|
||||
});
|
||||
|
||||
sse.close();
|
||||
currentSse = null;
|
||||
currentAbort = null;
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({ detail: 'Download failed.' }));
|
||||
throw new Error(data.detail || 'Download failed.');
|
||||
}
|
||||
|
||||
// Only trigger file download if not cancelled
|
||||
if (currentFileId === fileId) {
|
||||
setProgress(100, '<span class="pct">✓</span> <span class="meta">Done — saving to your downloads</span>');
|
||||
// Open in new tab so current page never navigates away
|
||||
window.open(`${API}/file/${fileId}`, '_blank');
|
||||
setTimeout(() => { progressWrap.style.display = 'none'; }, 2500);
|
||||
}
|
||||
} catch (err) {
|
||||
sse.close();
|
||||
currentSse = null;
|
||||
currentAbort = null;
|
||||
if (err.name !== 'AbortError') {
|
||||
showError(err.message);
|
||||
progressWrap.style.display = 'none';
|
||||
}
|
||||
} finally {
|
||||
if (currentFileId === fileId) currentFileId = null;
|
||||
setLoading(downloadBtn, false);
|
||||
}
|
||||
});
|
||||
135
web/frontend/index.html
Normal file
135
web/frontend/index.html
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>yt-dlp Web</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 36 36'><rect width='36' height='36' rx='10' fill='%236366f1'/><path d='M11 12l14 6-14 6V12z' fill='white'/></svg>" />
|
||||
<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@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-orbs">
|
||||
<div class="orb orb-1"></div>
|
||||
<div class="orb orb-2"></div>
|
||||
<div class="orb orb-3"></div>
|
||||
</div>
|
||||
|
||||
<main class="container">
|
||||
<header>
|
||||
<div class="logo">
|
||||
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" aria-hidden="true">
|
||||
<rect width="36" height="36" rx="10" fill="url(#grad)"/>
|
||||
<path d="M11 12l14 6-14 6V12z" fill="white"/>
|
||||
<defs>
|
||||
<linearGradient id="grad" x1="0" y1="0" x2="36" y2="36" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#6366f1"/>
|
||||
<stop offset="1" stop-color="#8b5cf6"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<span>yt-dlp web</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="hero">
|
||||
<h1>Download any video,<br /><span class="gradient-text">instantly.</span></h1>
|
||||
<p class="subtitle">Paste a YouTube (or any supported) link below and grab your video.</p>
|
||||
</section>
|
||||
|
||||
<div class="card" id="main-card">
|
||||
<div class="input-row">
|
||||
<div class="input-wrap">
|
||||
<svg class="input-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/>
|
||||
<path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/>
|
||||
</svg>
|
||||
<input
|
||||
type="url"
|
||||
id="url-input"
|
||||
placeholder="https://www.youtube.com/watch?v=..."
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
aria-label="Video URL"
|
||||
/>
|
||||
<button class="clear-btn" id="clear-btn" aria-label="Clear input">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn-primary" id="fetch-btn" aria-label="Fetch video info">
|
||||
Fetch
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="error-msg" class="error-msg" role="alert" hidden></div>
|
||||
|
||||
<!-- Video preview -->
|
||||
<div id="preview" class="preview" hidden>
|
||||
<img id="thumbnail" class="thumbnail" alt="Video thumbnail" />
|
||||
<div class="meta">
|
||||
<h2 id="video-title" class="video-title"></h2>
|
||||
<div class="meta-row">
|
||||
<span id="uploader" class="tag"></span>
|
||||
<span id="duration" class="tag"></span>
|
||||
<span id="upload-date" class="tag"></span>
|
||||
<span id="views" class="tag"></span>
|
||||
<span id="likes" class="tag"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Format selector -->
|
||||
<div id="format-section" class="format-section" hidden>
|
||||
<label for="format-select" class="format-label">Format</label>
|
||||
<div class="select-wrap">
|
||||
<select id="format-select" aria-label="Select download format"></select>
|
||||
<svg class="select-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Download button -->
|
||||
<button class="btn-download" id="download-btn" hidden aria-label="Download video">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
|
||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
Download
|
||||
</button>
|
||||
|
||||
<!-- Progress -->
|
||||
<div id="progress-wrap" class="progress-wrap" hidden>
|
||||
<div class="progress-header">
|
||||
<span id="progress-label" class="progress-label"></span>
|
||||
</div>
|
||||
<div class="progress-row">
|
||||
<div class="progress-bar"><div class="progress-fill" id="progress-fill"></div></div>
|
||||
<button id="cancel-btn" type="button" class="progress-btn cancel" aria-label="Cancel download">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<p>Powered by <a href="https://github.com/yt-dlp/yt-dlp" target="_blank" rel="noopener">yt-dlp</a></p>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// Runs before app.js — resets any browser-restored state immediately
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
const input = document.getElementById('url-input');
|
||||
if (input) input.value = '';
|
||||
});
|
||||
</script>
|
||||
<script src="/static/app.js?v=4"></script>
|
||||
</body>
|
||||
</html>
|
||||
380
web/frontend/style.css
Normal file
380
web/frontend/style.css
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #0a0a0f;
|
||||
--surface: rgba(255,255,255,0.04);
|
||||
--surface-hover: rgba(255,255,255,0.07);
|
||||
--border: rgba(255,255,255,0.08);
|
||||
--border-focus: rgba(99,102,241,0.6);
|
||||
--text: #f1f1f5;
|
||||
--text-muted: #8b8b9e;
|
||||
--primary: #6366f1;
|
||||
--primary-hover: #4f52e0;
|
||||
--success: #22c55e;
|
||||
--error: #f87171;
|
||||
--radius: 14px;
|
||||
--radius-sm: 8px;
|
||||
}
|
||||
|
||||
html { font-size: 16px; }
|
||||
|
||||
body {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Ambient background orbs */
|
||||
.bg-orbs { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
|
||||
.orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(80px);
|
||||
opacity: 0.18;
|
||||
}
|
||||
.orb-1 { width: 500px; height: 500px; background: #6366f1; top: -150px; left: -100px; }
|
||||
.orb-2 { width: 400px; height: 400px; background: #8b5cf6; bottom: -100px; right: -80px; }
|
||||
.orb-3 { width: 300px; height: 300px; background: #06b6d4; top: 40%; left: 50%; transform: translateX(-50%); }
|
||||
|
||||
.container {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
padding: 24px 16px 48px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
header { display: flex; justify-content: center; }
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
/* Hero */
|
||||
.hero { text-align: center; }
|
||||
.hero h1 {
|
||||
font-size: clamp(2rem, 6vw, 2.8rem);
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, #6366f1, #a78bfa, #38bdf8);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
.subtitle {
|
||||
margin-top: 12px;
|
||||
color: var(--text-muted);
|
||||
font-size: 1rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Card */
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
/* Input row */
|
||||
.input-row { display: flex; gap: 10px; }
|
||||
.input-wrap {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.input-icon {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
color: var(--text-muted);
|
||||
pointer-events: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#url-input {
|
||||
width: 100%;
|
||||
background: rgba(255,255,255,0.05);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
padding: 12px 40px 12px 42px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
#url-input::placeholder { color: var(--text-muted); }
|
||||
#url-input:focus {
|
||||
border-color: var(--border-focus);
|
||||
box-shadow: 0 0 0 3px rgba(99,102,241,0.15);
|
||||
}
|
||||
.clear-btn {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
display: none;
|
||||
align-items: center;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.clear-btn:hover { color: var(--text); }
|
||||
|
||||
/* Buttons */
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
padding: 12px 22px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
transition: background 0.2s, transform 0.1s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-primary:hover { background: var(--primary-hover); }
|
||||
.btn-primary:active { transform: scale(0.97); }
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
|
||||
|
||||
.btn-download {
|
||||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
padding: 14px 24px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
transition: opacity 0.2s, transform 0.1s, box-shadow 0.2s;
|
||||
box-shadow: 0 4px 20px rgba(99,102,241,0.3);
|
||||
}
|
||||
.btn-download:hover { opacity: 0.9; box-shadow: 0 6px 28px rgba(99,102,241,0.45); }
|
||||
.btn-download:active { transform: scale(0.98); }
|
||||
.btn-download:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
|
||||
|
||||
/* Spinner */
|
||||
.btn-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255,255,255,0.3);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
display: inline-block;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Error */
|
||||
.error-msg {
|
||||
background: rgba(248,113,113,0.1);
|
||||
border: 1px solid rgba(248,113,113,0.25);
|
||||
color: var(--error);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 12px 14px;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Preview */
|
||||
.preview {
|
||||
display: none;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
.preview.visible { display: flex; }.thumbnail {
|
||||
width: 140px;
|
||||
height: 80px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius-sm);
|
||||
flex-shrink: 0;
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
.meta { flex: 1; min-width: 0; }
|
||||
.video-title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.meta-row { display: flex; gap: 8px; margin-top: 8px; flex-wrap: wrap; }
|
||||
.tag {
|
||||
background: rgba(255,255,255,0.07);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
padding: 3px 10px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Format selector */
|
||||
.format-section { display: flex; flex-direction: column; gap: 10px; }
|
||||
.format-label { font-size: 0.8rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.06em; }
|
||||
.select-wrap { position: relative; }
|
||||
.select-wrap select {
|
||||
width: 100%;
|
||||
background: rgba(255,255,255,0.05);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
padding: 11px 40px 11px 14px;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.select-wrap select:focus {
|
||||
border-color: var(--border-focus);
|
||||
box-shadow: 0 0 0 3px rgba(99,102,241,0.15);
|
||||
}
|
||||
.select-wrap select option { background: #1a1a2e; color: var(--text); }
|
||||
.select-arrow {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Progress */
|
||||
.progress-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
animation: fadeIn 0.3s ease;
|
||||
background: rgba(99,102,241,0.06);
|
||||
border: 1px solid rgba(99,102,241,0.15);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.progress-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.progress-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.progress-bar {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: rgba(255,255,255,0.07);
|
||||
border-radius: 99px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-actions { display: flex; gap: 8px; flex-shrink: 0; }
|
||||
.progress-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-family: inherit;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s, transform 0.1s;
|
||||
}
|
||||
.progress-btn:hover { opacity: 0.8; }
|
||||
.progress-btn:active { transform: scale(0.96); }
|
||||
.progress-btn.cancel {
|
||||
background: rgba(248,113,113,0.12);
|
||||
border: 1px solid rgba(248,113,113,0.25);
|
||||
color: var(--error);
|
||||
}
|
||||
.progress-bar {
|
||||
height: 6px;
|
||||
background: rgba(255,255,255,0.07);
|
||||
border-radius: 99px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #6366f1, #a78bfa, #38bdf8);
|
||||
background-size: 200% 100%;
|
||||
border-radius: 99px;
|
||||
width: 0%;
|
||||
transition: width 0.5s ease;
|
||||
animation: shimmer 2s linear infinite;
|
||||
}
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% center; }
|
||||
100% { background-position: -200% center; }
|
||||
}
|
||||
.progress-label {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.01em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.progress-label .pct {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.progress-label .meta {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
footer { text-align: center; font-size: 0.8rem; color: var(--text-muted); }
|
||||
footer a { color: var(--text-muted); text-decoration: underline; text-underline-offset: 3px; }
|
||||
footer a:hover { color: var(--text); }
|
||||
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.input-row { flex-direction: column; }
|
||||
.btn-primary { width: 100%; justify-content: center; }
|
||||
.preview { flex-direction: column; }
|
||||
.thumbnail { width: 100%; height: 180px; }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue