v3
This commit is contained in:
1 parent
76e758339f
commit
7d2bdfafc3
4 files changed
+512
-526
No files matched your search
@@ -67,6 +67,7 @@ def _random_ua():
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
def _base_opts(extra=None):
|
def _base_opts(extra=None):
|
||||||
opts = {
|
opts = {
|
||||||
|
"quiet": True,
|
||||||
"cachedir": CACHE_DIR,
|
"cachedir": CACHE_DIR,
|
||||||
"js_runtimes": {"node": {"executable": "/usr/bin/node"}},
|
"js_runtimes": {"node": {"executable": "/usr/bin/node"}},
|
||||||
"http_headers": {"User-Agent": _random_ua()},
|
"http_headers": {"User-Agent": _random_ua()},
|
||||||
|
|||||||
+207
@@ -0,0 +1,207 @@
|
|||||||
|
let selectedMediaType = 'video';
|
||||||
|
let selectedOutputFormat = 'mp4';
|
||||||
|
let pollTimer = null;
|
||||||
|
let debounceTimer = null;
|
||||||
|
let currentFetchController = null;
|
||||||
|
|
||||||
|
let availableFormats = { audio: [], video: [] };
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Only fire /info when the input looks like a real supported URL.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const URL_RE = /^https?:\/\/(www\.)?(youtube\.com\/(watch|shorts|live)|youtu\.be|vimeo\.com|dailymotion\.com|twitch\.tv|soundcloud\.com)/i;
|
||||||
|
|
||||||
|
function looksLikeVideoUrl(str) {
|
||||||
|
try { new URL(str); } catch { return false; }
|
||||||
|
return URL_RE.test(str);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Format chips
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function loadFormats() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/formats');
|
||||||
|
availableFormats = await res.json();
|
||||||
|
} catch {
|
||||||
|
availableFormats = {
|
||||||
|
audio: ['mp3','aac','m4a','opus','flac','wav','vorbis','alac'],
|
||||||
|
video: ['mp4','mkv','webm','mov','avi','flv'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
renderFormatChips();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMediaType(type) {
|
||||||
|
selectedMediaType = type;
|
||||||
|
document.querySelectorAll('.fmt-btn').forEach(b => b.classList.toggle('active', b.dataset.fmt === type));
|
||||||
|
renderFormatChips();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFormatChips() {
|
||||||
|
const list = selectedMediaType === 'audio' ? availableFormats.audio : availableFormats.video;
|
||||||
|
const def = list[0] || (selectedMediaType === 'audio' ? 'mp3' : 'mp4');
|
||||||
|
selectedOutputFormat = def;
|
||||||
|
document.getElementById('format-chips').innerHTML = list.map(f =>
|
||||||
|
`<button class="chip${f === def ? ' selected' : ''}" onclick="selectFormat(this,'${f}')">${f}</button>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectFormat(el, f) {
|
||||||
|
document.querySelectorAll('#format-chips .chip').forEach(c => c.classList.remove('selected'));
|
||||||
|
el.classList.add('selected');
|
||||||
|
selectedOutputFormat = f;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// UI helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function fmtDuration(s) {
|
||||||
|
if (!s) return '';
|
||||||
|
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
|
||||||
|
return h
|
||||||
|
? `${h}:${String(m).padStart(2,'0')}:${String(sec).padStart(2,'0')}`
|
||||||
|
: `${m}:${String(sec).padStart(2,'0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showStatus(msg, type) {
|
||||||
|
const el = document.getElementById('status-msg');
|
||||||
|
el.textContent = msg; el.className = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearStatus() {
|
||||||
|
const el = document.getElementById('status-msg');
|
||||||
|
el.className = ''; el.textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Auto-fetch video info (debounced, abortable, URL-guarded)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function fetchInfo() {
|
||||||
|
const url = document.getElementById('url-input').value.trim();
|
||||||
|
|
||||||
|
if (!looksLikeVideoUrl(url)) {
|
||||||
|
document.getElementById('info-box').classList.remove('visible');
|
||||||
|
clearStatus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentFetchController) currentFetchController.abort();
|
||||||
|
currentFetchController = new AbortController();
|
||||||
|
|
||||||
|
clearStatus();
|
||||||
|
document.getElementById('info-box').classList.remove('visible');
|
||||||
|
document.getElementById('url-spinner').classList.add('active');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/info', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url }),
|
||||||
|
signal: currentFetchController.signal,
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.error) { showStatus('⚠ ' + data.error, 'error'); return; }
|
||||||
|
|
||||||
|
document.getElementById('thumb').src = data.thumbnail || '';
|
||||||
|
document.getElementById('vid-title').textContent = data.title || 'Unknown';
|
||||||
|
document.getElementById('vid-meta').textContent =
|
||||||
|
[data.uploader, fmtDuration(data.duration)].filter(Boolean).join(' · ');
|
||||||
|
document.getElementById('info-box').classList.add('visible');
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
if (e.name !== 'AbortError') showStatus('⚠ Could not reach server.', 'error');
|
||||||
|
} finally {
|
||||||
|
document.getElementById('url-spinner').classList.remove('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Download
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function startDownload() {
|
||||||
|
const url = document.getElementById('url-input').value.trim();
|
||||||
|
if (!url) { showStatus('⚠ Please enter a URL.', 'error'); return; }
|
||||||
|
clearStatus();
|
||||||
|
|
||||||
|
if (currentFetchController) { currentFetchController.abort(); currentFetchController = null; }
|
||||||
|
document.getElementById('url-spinner').classList.remove('active');
|
||||||
|
|
||||||
|
const btn = document.getElementById('start-btn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="spin"></span> Starting...';
|
||||||
|
|
||||||
|
document.getElementById('download-btn-wrap').style.display = 'none';
|
||||||
|
document.getElementById('progress-box').classList.add('visible');
|
||||||
|
document.getElementById('progress-label').textContent = 'Downloading...';
|
||||||
|
document.getElementById('progress-fill').style.width = '0%';
|
||||||
|
document.getElementById('prog-pct').textContent = '0%';
|
||||||
|
document.getElementById('prog-speed').textContent = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/download', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url, format: selectedMediaType, chosen_format: selectedOutputFormat }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.error) { showStatus('⚠ ' + data.error, 'error'); resetBtn(); return; }
|
||||||
|
pollStatus(data.job_id);
|
||||||
|
} catch {
|
||||||
|
showStatus('⚠ Server error.', 'error');
|
||||||
|
resetBtn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pollStatus(jobId) {
|
||||||
|
if (pollTimer) clearInterval(pollTimer);
|
||||||
|
pollTimer = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const data = await (await fetch('/status/' + jobId)).json();
|
||||||
|
|
||||||
|
if (data.progress !== undefined) {
|
||||||
|
document.getElementById('progress-fill').style.width = data.progress + '%';
|
||||||
|
document.getElementById('prog-pct').textContent = data.progress + '%';
|
||||||
|
if (data.speed) document.getElementById('prog-speed').textContent =
|
||||||
|
data.speed + (data.eta ? ' · ETA ' + data.eta : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.status === 'done') {
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
document.getElementById('progress-label').textContent = 'Complete!';
|
||||||
|
document.getElementById('prog-speed').textContent = '';
|
||||||
|
showStatus('✓ Download ready: ' + (data.title || 'file'), 'success');
|
||||||
|
document.getElementById('dl-link').href = '/file/' + jobId;
|
||||||
|
document.getElementById('dl-link').download = true;
|
||||||
|
document.getElementById('download-btn-wrap').style.display = 'flex';
|
||||||
|
resetBtn();
|
||||||
|
} else if (data.status === 'error') {
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
showStatus('⚠ ' + (data.error || 'Unknown error'), 'error');
|
||||||
|
document.getElementById('progress-box').classList.remove('visible');
|
||||||
|
resetBtn();
|
||||||
|
}
|
||||||
|
} catch { /* ignore transient poll errors */ }
|
||||||
|
}, 800);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetBtn() {
|
||||||
|
const btn = document.getElementById('start-btn');
|
||||||
|
btn.disabled = false; btn.innerHTML = 'Download';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Event listeners
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
document.getElementById('url-input').addEventListener('input', () => {
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(fetchInfo, 1200);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('url-input').addEventListener('paste', () => {
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(fetchInfo, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
loadFormats();
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #0a0a0a;
|
||||||
|
--surface: #111111;
|
||||||
|
--border: #222222;
|
||||||
|
--accent: #e8ff47;
|
||||||
|
--accent2: #ff6b35;
|
||||||
|
--text: #f0f0f0;
|
||||||
|
--muted: #666666;
|
||||||
|
--radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: 'Syne', sans-serif;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 60px 20px 80px;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(ellipse 80% 50% at 50% -10%, rgba(232,255,71,0.07) 0%, transparent 70%),
|
||||||
|
repeating-linear-gradient(0deg, transparent, transparent 39px, rgba(255,255,255,0.02) 39px, rgba(255,255,255,0.02) 40px),
|
||||||
|
repeating-linear-gradient(90deg, transparent, transparent 39px, rgba(255,255,255,0.02) 39px, rgba(255,255,255,0.02) 40px);
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 56px;
|
||||||
|
animation: fadeDown 0.6s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-size: clamp(3rem, 8vw, 5.5rem);
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
line-height: 1;
|
||||||
|
background: linear-gradient(135deg, var(--accent) 0%, var(--accent2) 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagline {
|
||||||
|
font-family: 'Space Mono', monospace;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 640px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 36px;
|
||||||
|
animation: fadeUp 0.5s ease 0.1s both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-label {
|
||||||
|
font-family: 'Space Mono', monospace;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"] {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: 'Space Mono', monospace;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
padding: 12px 42px 12px 14px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
input[type="text"]:focus { border-color: var(--accent); }
|
||||||
|
input[type="text"]::placeholder { color: var(--muted); }
|
||||||
|
|
||||||
|
.url-spinner {
|
||||||
|
position: absolute;
|
||||||
|
right: 14px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.6s linear infinite;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.url-spinner.active { display: block; }
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
font-family: 'Syne', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn-accent { background: var(--accent); color: #000; }
|
||||||
|
.btn-accent:hover { background: #d4eb3a; transform: translateY(-1px); }
|
||||||
|
.btn-accent:active { transform: translateY(0); }
|
||||||
|
.btn-accent:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
|
||||||
|
|
||||||
|
#info-box {
|
||||||
|
display: none;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
gap: 14px;
|
||||||
|
animation: fadeUp 0.3s ease both;
|
||||||
|
}
|
||||||
|
#info-box.visible { display: flex; }
|
||||||
|
|
||||||
|
.thumb-wrap {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 100px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
.thumb-wrap img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
|
||||||
|
.info-meta { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
.info-title {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-sub {
|
||||||
|
font-family: 'Space Mono', monospace;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-toggle {
|
||||||
|
display: flex;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fmt-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 11px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: 'Syne', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
transition: all 0.15s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
.fmt-btn.active { background: var(--accent); color: #000; }
|
||||||
|
.fmt-btn:not(.active):hover { color: var(--text); }
|
||||||
|
|
||||||
|
#format-row { margin-bottom: 28px; }
|
||||||
|
|
||||||
|
.format-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
font-family: 'Space Mono', monospace;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--muted);
|
||||||
|
transition: all 0.15s;
|
||||||
|
background: transparent;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.chip.selected { border-color: var(--accent); color: var(--accent); background: rgba(232,255,71,0.07); }
|
||||||
|
.chip:hover:not(.selected) { border-color: #444; color: var(--text); }
|
||||||
|
|
||||||
|
#progress-box { display: none; margin-bottom: 24px; }
|
||||||
|
#progress-box.visible { display: block; }
|
||||||
|
|
||||||
|
.progress-bar-bg {
|
||||||
|
height: 6px;
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 10px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--accent), var(--accent2));
|
||||||
|
border-radius: 3px;
|
||||||
|
width: 0%;
|
||||||
|
transition: width 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-meta {
|
||||||
|
font-family: 'Space Mono', monospace;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: var(--muted);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
#status-msg {
|
||||||
|
font-family: 'Space Mono', monospace;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#status-msg.error {
|
||||||
|
background: rgba(255,80,80,0.1);
|
||||||
|
border: 1px solid rgba(255,80,80,0.3);
|
||||||
|
color: #ff8080;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
#status-msg.success {
|
||||||
|
background: rgba(232,255,71,0.08);
|
||||||
|
border: 1px solid rgba(232,255,71,0.3);
|
||||||
|
color: var(--accent);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-row { display: flex; gap: 10px; justify-content: flex-end; }
|
||||||
|
#download-btn-wrap { display: none; }
|
||||||
|
|
||||||
|
.spin {
|
||||||
|
display: inline-block;
|
||||||
|
width: 12px; height: 12px;
|
||||||
|
border: 2px solid rgba(0,0,0,0.3);
|
||||||
|
border-top-color: #000;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.6s linear infinite;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
@keyframes fadeDown { from { opacity:0; transform:translateY(-20px); } to { opacity:1; transform:translateY(0); } }
|
||||||
|
@keyframes fadeUp { from { opacity:0; transform:translateY(16px); } to { opacity:1; transform:translateY(0); } }
|
||||||
|
|
||||||
|
footer {
|
||||||
|
margin-top: 48px;
|
||||||
|
font-family: 'Space Mono', monospace;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: #333;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-align: center;
|
||||||
|
animation: fadeUp 0.5s ease 0.3s both;
|
||||||
|
}
|
||||||
+9
-526
@@ -3,308 +3,10 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>VDROP — Video Downloader</title>
|
<title>VDROP</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:ital,wght@0,400;0,700;1,400&family=Syne:wght@400;600;700;800&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:ital,wght@0,400;0,700;1,400&family=Syne:wght@400;600;700;800&display=swap" rel="stylesheet" />
|
||||||
<style>
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}" />
|
||||||
:root {
|
|
||||||
--bg: #0a0a0a;
|
|
||||||
--surface: #111111;
|
|
||||||
--border: #222222;
|
|
||||||
--accent: #e8ff47;
|
|
||||||
--accent2: #ff6b35;
|
|
||||||
--text: #f0f0f0;
|
|
||||||
--muted: #666666;
|
|
||||||
--radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
||||||
|
|
||||||
body {
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
font-family: 'Syne', sans-serif;
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
padding: 60px 20px 80px;
|
|
||||||
background-image:
|
|
||||||
radial-gradient(ellipse 80% 50% at 50% -10%, rgba(232,255,71,0.07) 0%, transparent 70%),
|
|
||||||
repeating-linear-gradient(0deg, transparent, transparent 39px, rgba(255,255,255,0.02) 39px, rgba(255,255,255,0.02) 40px),
|
|
||||||
repeating-linear-gradient(90deg, transparent, transparent 39px, rgba(255,255,255,0.02) 39px, rgba(255,255,255,0.02) 40px);
|
|
||||||
}
|
|
||||||
|
|
||||||
header {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 56px;
|
|
||||||
animation: fadeDown 0.6s ease both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
font-size: clamp(3rem, 8vw, 5.5rem);
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: -0.03em;
|
|
||||||
line-height: 1;
|
|
||||||
background: linear-gradient(135deg, var(--accent) 0%, var(--accent2) 100%);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
background-clip: text;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tagline {
|
|
||||||
font-family: 'Space Mono', monospace;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
letter-spacing: 0.2em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 640px;
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 36px;
|
|
||||||
animation: fadeUp 0.5s ease 0.1s both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-label {
|
|
||||||
font-family: 'Space Mono', monospace;
|
|
||||||
font-size: 0.65rem;
|
|
||||||
letter-spacing: 0.18em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--muted);
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.url-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
margin-bottom: 28px;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="text"] {
|
|
||||||
flex: 1;
|
|
||||||
background: var(--bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
color: var(--text);
|
|
||||||
font-family: 'Space Mono', monospace;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
padding: 12px 42px 12px 14px;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="text"]:focus { border-color: var(--accent); }
|
|
||||||
input[type="text"]::placeholder { color: var(--muted); }
|
|
||||||
|
|
||||||
.url-spinner {
|
|
||||||
position: absolute;
|
|
||||||
right: 14px;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
border: 2px solid var(--border);
|
|
||||||
border-top-color: var(--accent);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.6s linear infinite;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.url-spinner.active { display: block; }
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
font-family: 'Syne', sans-serif;
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
padding: 12px 20px;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-accent { background: var(--accent); color: #000; }
|
|
||||||
.btn-accent:hover { background: #d4eb3a; transform: translateY(-1px); }
|
|
||||||
.btn-accent:active { transform: translateY(0); }
|
|
||||||
.btn-accent:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
|
|
||||||
|
|
||||||
#info-box {
|
|
||||||
display: none;
|
|
||||||
background: var(--bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 16px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
gap: 14px;
|
|
||||||
animation: fadeUp 0.3s ease both;
|
|
||||||
}
|
|
||||||
#info-box.visible { display: flex; }
|
|
||||||
|
|
||||||
.thumb-wrap {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 100px;
|
|
||||||
height: 60px;
|
|
||||||
border-radius: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--border);
|
|
||||||
}
|
|
||||||
.thumb-wrap img { width: 100%; height: 100%; object-fit: cover; }
|
|
||||||
|
|
||||||
.info-meta { flex: 1; min-width: 0; }
|
|
||||||
|
|
||||||
.info-title {
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
line-height: 1.3;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-sub {
|
|
||||||
font-family: 'Space Mono', monospace;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.format-toggle {
|
|
||||||
display: flex;
|
|
||||||
background: var(--bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
overflow: hidden;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fmt-btn {
|
|
||||||
flex: 1;
|
|
||||||
padding: 11px;
|
|
||||||
text-align: center;
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: 'Syne', sans-serif;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--muted);
|
|
||||||
transition: all 0.15s;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 7px;
|
|
||||||
}
|
|
||||||
.fmt-btn.active { background: var(--accent); color: #000; }
|
|
||||||
.fmt-btn:not(.active):hover { color: var(--text); }
|
|
||||||
|
|
||||||
#format-row { margin-bottom: 28px; }
|
|
||||||
|
|
||||||
.format-chips {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chip {
|
|
||||||
font-family: 'Space Mono', monospace;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
padding: 6px 14px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 20px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--muted);
|
|
||||||
transition: all 0.15s;
|
|
||||||
background: transparent;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
.chip.selected { border-color: var(--accent); color: var(--accent); background: rgba(232,255,71,0.07); }
|
|
||||||
.chip:hover:not(.selected) { border-color: #444; color: var(--text); }
|
|
||||||
|
|
||||||
#progress-box { display: none; margin-bottom: 24px; }
|
|
||||||
#progress-box.visible { display: block; }
|
|
||||||
|
|
||||||
.progress-bar-bg {
|
|
||||||
height: 6px;
|
|
||||||
background: var(--border);
|
|
||||||
border-radius: 3px;
|
|
||||||
overflow: hidden;
|
|
||||||
margin: 10px 0 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-bar-fill {
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(90deg, var(--accent), var(--accent2));
|
|
||||||
border-radius: 3px;
|
|
||||||
width: 0%;
|
|
||||||
transition: width 0.4s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-meta {
|
|
||||||
font-family: 'Space Mono', monospace;
|
|
||||||
font-size: 0.68rem;
|
|
||||||
color: var(--muted);
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
#status-msg {
|
|
||||||
font-family: 'Space Mono', monospace;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
padding: 10px 14px;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
margin-bottom: 20px;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
#status-msg.error {
|
|
||||||
background: rgba(255,80,80,0.1);
|
|
||||||
border: 1px solid rgba(255,80,80,0.3);
|
|
||||||
color: #ff8080;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
#status-msg.success {
|
|
||||||
background: rgba(232,255,71,0.08);
|
|
||||||
border: 1px solid rgba(232,255,71,0.3);
|
|
||||||
color: var(--accent);
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-row { display: flex; gap: 10px; justify-content: flex-end; }
|
|
||||||
#download-btn-wrap { display: none; }
|
|
||||||
|
|
||||||
.spin {
|
|
||||||
display: inline-block;
|
|
||||||
width: 12px; height: 12px;
|
|
||||||
border: 2px solid rgba(0,0,0,0.3);
|
|
||||||
border-top-color: #000;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.6s linear infinite;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
|
||||||
@keyframes fadeDown { from { opacity:0; transform:translateY(-20px); } to { opacity:1; transform:translateY(0); } }
|
|
||||||
@keyframes fadeUp { from { opacity:0; transform:translateY(16px); } to { opacity:1; transform:translateY(0); } }
|
|
||||||
|
|
||||||
footer {
|
|
||||||
margin-top: 48px;
|
|
||||||
font-family: 'Space Mono', monospace;
|
|
||||||
font-size: 0.65rem;
|
|
||||||
color: #333;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
text-align: center;
|
|
||||||
animation: fadeUp 0.5s ease 0.3s both;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
@@ -315,7 +17,7 @@
|
|||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|
||||||
<div class="section-label">Paste URL</div>
|
<div class="section-label">Ingresar URL</div>
|
||||||
<div class="url-row">
|
<div class="url-row">
|
||||||
<input type="text" id="url-input" placeholder="https://youtube.com/watch?v=..." autocomplete="off" spellcheck="false" />
|
<input type="text" id="url-input" placeholder="https://youtube.com/watch?v=..." autocomplete="off" spellcheck="false" />
|
||||||
<div class="url-spinner" id="url-spinner"></div>
|
<div class="url-spinner" id="url-spinner"></div>
|
||||||
@@ -331,7 +33,7 @@
|
|||||||
|
|
||||||
<div id="status-msg"></div>
|
<div id="status-msg"></div>
|
||||||
|
|
||||||
<div class="section-label">Type</div>
|
<div class="section-label">Tipo</div>
|
||||||
<div class="format-toggle" id="fmt-toggle">
|
<div class="format-toggle" id="fmt-toggle">
|
||||||
<button class="fmt-btn active" data-fmt="video" onclick="setMediaType('video')">
|
<button class="fmt-btn active" data-fmt="video" onclick="setMediaType('video')">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="15" height="14" rx="2"/><polygon points="17 8 22 12 17 16 17 8"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="15" height="14" rx="2"/><polygon points="17 8 22 12 17 16 17 8"/></svg>
|
||||||
@@ -339,17 +41,17 @@
|
|||||||
</button>
|
</button>
|
||||||
<button class="fmt-btn" data-fmt="audio" onclick="setMediaType('audio')">
|
<button class="fmt-btn" data-fmt="audio" onclick="setMediaType('audio')">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
||||||
Audio Only
|
Solo Audio
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="format-row">
|
<div id="format-row">
|
||||||
<div class="section-label" id="format-label">Output Format</div>
|
<div class="section-label" id="format-label">Format De Salida</div>
|
||||||
<div class="format-chips" id="format-chips"></div>
|
<div class="format-chips" id="format-chips"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="progress-box">
|
<div id="progress-box">
|
||||||
<div class="section-label" id="progress-label">Downloading...</div>
|
<div class="section-label" id="progress-label">Descargando...</div>
|
||||||
<div class="progress-bar-bg">
|
<div class="progress-bar-bg">
|
||||||
<div class="progress-bar-fill" id="progress-fill"></div>
|
<div class="progress-bar-fill" id="progress-fill"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -363,232 +65,13 @@
|
|||||||
<div id="download-btn-wrap">
|
<div id="download-btn-wrap">
|
||||||
<a id="dl-link" class="btn btn-accent" href="#">⬇ Save File</a>
|
<a id="dl-link" class="btn btn-accent" href="#">⬇ Save File</a>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-accent" id="start-btn" onclick="startDownload()">Download</button>
|
<button class="btn btn-accent" id="start-btn" onclick="startDownload()">Descargar</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer>yt-dlp + flask · for personal use only · respect copyright</footer>
|
<footer>yt-dlp + flask · for personal use only · respect copyright</footer>
|
||||||
|
|
||||||
<script>
|
<script src="{{ url_for('static', filename='app.js') }}"></script>
|
||||||
let selectedMediaType = 'video';
|
|
||||||
let selectedOutputFormat = 'mp4';
|
|
||||||
let pollTimer = null;
|
|
||||||
let debounceTimer = null;
|
|
||||||
let currentFetchController = null; // lets us abort in-flight /info requests
|
|
||||||
|
|
||||||
let availableFormats = { audio: [], video: [] };
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Helpers to detect a recognisable video URL before bothering the server.
|
|
||||||
// Covers youtube.com/watch, youtu.be, and common short-link patterns.
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
const URL_RE = /^https?:\/\/(www\.)?(youtube\.com\/(watch|shorts|live)|youtu\.be|vimeo\.com|dailymotion\.com|twitch\.tv|soundcloud\.com)/i;
|
|
||||||
|
|
||||||
function looksLikeVideoUrl(str) {
|
|
||||||
try { new URL(str); } catch { return false; }
|
|
||||||
return URL_RE.test(str);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Format chips
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
async function loadFormats() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/formats');
|
|
||||||
availableFormats = await res.json();
|
|
||||||
} catch {
|
|
||||||
availableFormats = {
|
|
||||||
audio: ['mp3','aac','m4a','opus','flac','wav','vorbis','alac'],
|
|
||||||
video: ['mp4','mkv','webm','mov','avi','flv'],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
renderFormatChips();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setMediaType(type) {
|
|
||||||
selectedMediaType = type;
|
|
||||||
document.querySelectorAll('.fmt-btn').forEach(b => b.classList.toggle('active', b.dataset.fmt === type));
|
|
||||||
renderFormatChips();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderFormatChips() {
|
|
||||||
const list = selectedMediaType === 'audio' ? availableFormats.audio : availableFormats.video;
|
|
||||||
const def = list[0] || (selectedMediaType === 'audio' ? 'mp3' : 'mp4');
|
|
||||||
selectedOutputFormat = def;
|
|
||||||
document.getElementById('format-chips').innerHTML = list.map(f =>
|
|
||||||
`<button class="chip${f === def ? ' selected' : ''}" onclick="selectFormat(this,'${f}')">${f}</button>`
|
|
||||||
).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectFormat(el, f) {
|
|
||||||
document.querySelectorAll('#format-chips .chip').forEach(c => c.classList.remove('selected'));
|
|
||||||
el.classList.add('selected');
|
|
||||||
selectedOutputFormat = f;
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// UI helpers
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
function fmtDuration(s) {
|
|
||||||
if (!s) return '';
|
|
||||||
const h = Math.floor(s/3600), m = Math.floor((s%3600)/60), sec = s%60;
|
|
||||||
return h
|
|
||||||
? `${h}:${String(m).padStart(2,'0')}:${String(sec).padStart(2,'0')}`
|
|
||||||
: `${m}:${String(sec).padStart(2,'0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function showStatus(msg, type) {
|
|
||||||
const el = document.getElementById('status-msg');
|
|
||||||
el.textContent = msg; el.className = type;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearStatus() {
|
|
||||||
const el = document.getElementById('status-msg');
|
|
||||||
el.className = ''; el.textContent = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Auto-fetch video info
|
|
||||||
//
|
|
||||||
// Anti-throttle measures on the frontend side:
|
|
||||||
// • Only fires when the URL matches a known video pattern — typing a URL
|
|
||||||
// character by character won't trigger dozens of requests.
|
|
||||||
// • 1200 ms debounce on typing (much longer than before).
|
|
||||||
// • 400 ms debounce on paste (user pasted a complete URL, fire faster).
|
|
||||||
// • Aborts any in-flight request before starting a new one, so rapidly
|
|
||||||
// changing the URL never queues up multiple simultaneous /info calls.
|
|
||||||
// • Backend caches results for 10 min, so navigating away and back is free.
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
async function fetchInfo() {
|
|
||||||
const url = document.getElementById('url-input').value.trim();
|
|
||||||
|
|
||||||
if (!looksLikeVideoUrl(url)) {
|
|
||||||
document.getElementById('info-box').classList.remove('visible');
|
|
||||||
clearStatus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Abort previous in-flight request
|
|
||||||
if (currentFetchController) currentFetchController.abort();
|
|
||||||
currentFetchController = new AbortController();
|
|
||||||
|
|
||||||
clearStatus();
|
|
||||||
document.getElementById('info-box').classList.remove('visible');
|
|
||||||
document.getElementById('url-spinner').classList.add('active');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/info', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type':'application/json'},
|
|
||||||
body: JSON.stringify({ url }),
|
|
||||||
signal: currentFetchController.signal,
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
if (data.error) { showStatus('⚠ ' + data.error, 'error'); return; }
|
|
||||||
|
|
||||||
document.getElementById('thumb').src = data.thumbnail || '';
|
|
||||||
document.getElementById('vid-title').textContent = data.title || 'Unknown';
|
|
||||||
document.getElementById('vid-meta').textContent =
|
|
||||||
[data.uploader, fmtDuration(data.duration)].filter(Boolean).join(' · ');
|
|
||||||
document.getElementById('info-box').classList.add('visible');
|
|
||||||
|
|
||||||
} catch (e) {
|
|
||||||
if (e.name !== 'AbortError') showStatus('⚠ Could not reach server.', 'error');
|
|
||||||
} finally {
|
|
||||||
document.getElementById('url-spinner').classList.remove('active');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Download
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
async function startDownload() {
|
|
||||||
const url = document.getElementById('url-input').value.trim();
|
|
||||||
if (!url) { showStatus('⚠ Please enter a URL.', 'error'); return; }
|
|
||||||
clearStatus();
|
|
||||||
|
|
||||||
// Abort any pending info fetch so it doesn't interfere
|
|
||||||
if (currentFetchController) { currentFetchController.abort(); currentFetchController = null; }
|
|
||||||
document.getElementById('url-spinner').classList.remove('active');
|
|
||||||
|
|
||||||
const btn = document.getElementById('start-btn');
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.innerHTML = '<span class="spin"></span> Starting...';
|
|
||||||
|
|
||||||
document.getElementById('download-btn-wrap').style.display = 'none';
|
|
||||||
document.getElementById('progress-box').classList.add('visible');
|
|
||||||
document.getElementById('progress-label').textContent = 'Downloading...';
|
|
||||||
document.getElementById('progress-fill').style.width = '0%';
|
|
||||||
document.getElementById('prog-pct').textContent = '0%';
|
|
||||||
document.getElementById('prog-speed').textContent = '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/download', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type':'application/json'},
|
|
||||||
body: JSON.stringify({ url, format: selectedMediaType, chosen_format: selectedOutputFormat }),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.error) { showStatus('⚠ ' + data.error, 'error'); resetBtn(); return; }
|
|
||||||
pollStatus(data.job_id);
|
|
||||||
} catch {
|
|
||||||
showStatus('⚠ Server error.', 'error');
|
|
||||||
resetBtn();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function pollStatus(jobId) {
|
|
||||||
if (pollTimer) clearInterval(pollTimer);
|
|
||||||
pollTimer = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
const data = await (await fetch('/status/' + jobId)).json();
|
|
||||||
|
|
||||||
if (data.progress !== undefined) {
|
|
||||||
document.getElementById('progress-fill').style.width = data.progress + '%';
|
|
||||||
document.getElementById('prog-pct').textContent = data.progress + '%';
|
|
||||||
if (data.speed) document.getElementById('prog-speed').textContent =
|
|
||||||
data.speed + (data.eta ? ' · ETA ' + data.eta : '');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.status === 'done') {
|
|
||||||
clearInterval(pollTimer);
|
|
||||||
document.getElementById('progress-label').textContent = 'Complete!';
|
|
||||||
document.getElementById('prog-speed').textContent = '';
|
|
||||||
showStatus('✓ Download ready: ' + (data.title || 'file'), 'success');
|
|
||||||
document.getElementById('dl-link').href = '/file/' + jobId;
|
|
||||||
document.getElementById('dl-link').download = true;
|
|
||||||
document.getElementById('download-btn-wrap').style.display = 'flex';
|
|
||||||
resetBtn();
|
|
||||||
} else if (data.status === 'error') {
|
|
||||||
clearInterval(pollTimer);
|
|
||||||
showStatus('⚠ ' + (data.error || 'Unknown error'), 'error');
|
|
||||||
document.getElementById('progress-box').classList.remove('visible');
|
|
||||||
resetBtn();
|
|
||||||
}
|
|
||||||
} catch { /* ignore transient poll errors */ }
|
|
||||||
}, 800);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetBtn() {
|
|
||||||
const btn = document.getElementById('start-btn');
|
|
||||||
btn.disabled = false; btn.innerHTML = 'Download';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Typing: long debounce + URL pattern guard = very few actual requests
|
|
||||||
document.getElementById('url-input').addEventListener('input', () => {
|
|
||||||
clearTimeout(debounceTimer);
|
|
||||||
debounceTimer = setTimeout(fetchInfo, 1200);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Paste: user pasted a complete URL, fire faster
|
|
||||||
document.getElementById('url-input').addEventListener('paste', () => {
|
|
||||||
clearTimeout(debounceTimer);
|
|
||||||
debounceTimer = setTimeout(fetchInfo, 400);
|
|
||||||
});
|
|
||||||
|
|
||||||
loadFormats();
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Reference in new issue
Block a user