ytdl / index.html
Z User
fix: gradio.Server with demo variable + proper Space config
5603df0
Raw
History Blame Contribute Delete
48 kB
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>YTDL - YouTube Downloader</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" rel="stylesheet">
<style>
/* ===== CSS Variables / Themes ===== */
:root {
--red: #ff2d2d;
--red-dark: #cc0000;
--red-glow: rgba(255, 45, 45, 0.25);
--radius: 16px;
--radius-sm: 10px;
--radius-xs: 6px;
--transition: 0.25s cubic-bezier(0.4, 0, 0.2, 1);
--font: 'Segoe UI', system-ui, -apple-system, sans-serif;
--mono: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
}
[data-theme="dark"] {
--bg: #0a0a0f;
--bg-elevated: #12121a;
--bg-card: #16161f;
--bg-card-hover: #1c1c28;
--bg-input: #1a1a26;
--border: #2a2a3a;
--border-focus: #ff2d2d;
--text: #f0f0f5;
--text-secondary: #8888a0;
--text-muted: #55556a;
--success: #22c55e;
--success-bg: rgba(34, 197, 94, 0.1);
--warning: #f59e0b;
--warning-bg: rgba(245, 158, 11, 0.1);
--error: #ef4444;
--error-bg: rgba(239, 68, 68, 0.1);
--info: #3b82f6;
--info-bg: rgba(59, 130, 246, 0.1);
--gradient-1: radial-gradient(ellipse at 20% 50%, rgba(255, 45, 45, 0.08) 0%, transparent 50%);
--gradient-2: radial-gradient(ellipse at 80% 20%, rgba(139, 92, 246, 0.06) 0%, transparent 50%);
--shadow: 0 8px 32px rgba(0,0,0,0.4);
--shadow-sm: 0 2px 8px rgba(0,0,0,0.3);
}
[data-theme="light"] {
--bg: #f5f5fa;
--bg-elevated: #ffffff;
--bg-card: #ffffff;
--bg-card-hover: #f8f8fc;
--bg-input: #f0f0f5;
--border: #e0e0ea;
--border-focus: #ff2d2d;
--text: #111118;
--text-secondary: #666680;
--text-muted: #9999aa;
--success: #16a34a;
--success-bg: rgba(22, 163, 74, 0.08);
--warning: #d97706;
--warning-bg: rgba(217, 119, 6, 0.08);
--error: #dc2626;
--error-bg: rgba(220, 38, 38, 0.08);
--info: #2563eb;
--info-bg: rgba(37, 99, 235, 0.08);
--gradient-1: radial-gradient(ellipse at 20% 50%, rgba(255, 45, 45, 0.05) 0%, transparent 50%);
--gradient-2: radial-gradient(ellipse at 80% 20%, rgba(139, 92, 246, 0.04) 0%, transparent 50%);
--shadow: 0 8px 32px rgba(0,0,0,0.08);
--shadow-sm: 0 2px 8px rgba(0,0,0,0.05);
}
/* ===== Reset ===== */
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
font-family: var(--font);
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
flex-direction: column;
line-height: 1.6;
transition: background var(--transition), color var(--transition);
}
a { color: var(--red); text-decoration: none; }
a:hover { text-decoration: underline; }
button { cursor: pointer; font-family: inherit; }
input, select { font-family: inherit; }
/* ===== Scrollbar ===== */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
/* ===== Background Effects ===== */
.bg-effects {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
overflow: hidden;
}
.bg-effects::before { content: ''; position: absolute; inset: 0; background: var(--gradient-1); }
.bg-effects::after { content: ''; position: absolute; inset: 0; background: var(--gradient-2); }
/* ===== Navbar ===== */
.navbar {
position: sticky;
top: 0;
z-index: 100;
background: color-mix(in srgb, var(--bg-elevated) 85%, transparent);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border-bottom: 1px solid var(--border);
padding: 0 1.5rem;
height: 60px;
display: flex;
align-items: center;
}
.nav-inner {
max-width: 1100px;
width: 100%;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
}
.nav-brand {
display: flex;
align-items: center;
gap: 0.6rem;
font-weight: 700;
font-size: 1.15rem;
color: var(--text);
text-decoration: none;
}
.nav-brand i { color: var(--red); font-size: 1.3rem; }
.nav-links { display: flex; align-items: center; gap: 0.25rem; }
.nav-links a {
padding: 0.4rem 0.8rem;
border-radius: var(--radius-xs);
color: var(--text-secondary);
font-size: 0.875rem;
font-weight: 500;
transition: all var(--transition);
text-decoration: none;
}
.nav-links a:hover { color: var(--text); background: var(--bg-card); }
.nav-links a.active { color: var(--red); }
.theme-toggle {
background: var(--bg-card);
border: 1px solid var(--border);
color: var(--text-secondary);
width: 36px; height: 36px;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 0.9rem;
transition: all var(--transition);
margin-left: 0.5rem;
}
.theme-toggle:hover { color: var(--text); border-color: var(--red); }
/* ===== Main ===== */
main {
flex: 1;
position: relative;
z-index: 1;
max-width: 1100px;
width: 100%;
margin: 0 auto;
padding: 2rem 1.5rem 4rem;
}
/* ===== Hero ===== */
.hero {
text-align: center;
padding: 2.5rem 1rem 2rem;
}
.hero h1 {
font-size: 2.5rem;
font-weight: 800;
letter-spacing: -0.02em;
margin-bottom: 0.5rem;
}
.hero h1 span { color: var(--red); }
.hero p { color: var(--text-secondary); font-size: 1.05rem; max-width: 500px; margin: 0 auto; }
/* ===== URL Input ===== */
.url-section {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.5rem;
margin-bottom: 1.5rem;
box-shadow: var(--shadow-sm);
transition: border-color var(--transition);
}
.url-section:focus-within { border-color: var(--border-focus); }
.url-row {
display: flex;
gap: 0.75rem;
align-items: stretch;
}
.url-input-wrap {
flex: 1;
position: relative;
}
.url-input-wrap i {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
color: var(--text-muted);
font-size: 0.9rem;
transition: color var(--transition);
}
.url-input-wrap:focus-within i { color: var(--red); }
.url-input {
width: 100%;
padding: 0.85rem 1rem 0.85rem 2.6rem;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
font-size: 0.95rem;
outline: none;
transition: all var(--transition);
}
.url-input::placeholder { color: var(--text-muted); }
.url-input:focus { border-color: var(--red); box-shadow: 0 0 0 3px var(--red-glow); }
/* ===== Buttons ===== */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.8rem 1.4rem;
border-radius: var(--radius-sm);
font-weight: 600;
font-size: 0.9rem;
border: none;
transition: all var(--transition);
white-space: nowrap;
}
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary { background: var(--red); color: #fff; }
.btn-primary:hover:not(:disabled) { background: var(--red-dark); transform: translateY(-1px); box-shadow: 0 4px 16px var(--red-glow); }
.btn-primary:active:not(:disabled) { transform: translateY(0); }
.btn-secondary { background: var(--bg-input); color: var(--text); border: 1px solid var(--border); }
.btn-secondary:hover:not(:disabled) { border-color: var(--text-muted); background: var(--bg-card-hover); }
.btn-success { background: var(--success); color: #fff; }
.btn-success:hover:not(:disabled) { filter: brightness(1.1); transform: translateY(-1px); }
.btn-ghost { background: transparent; color: var(--text-secondary); padding: 0.5rem; }
.btn-ghost:hover { color: var(--text); background: var(--bg-card); }
.btn-sm { padding: 0.45rem 0.8rem; font-size: 0.8rem; border-radius: var(--radius-xs); }
.btn-icon { width: 36px; height: 36px; padding: 0; border-radius: var(--radius-xs); }
/* ===== Video Info Card ===== */
.video-info {
display: none;
animation: fadeSlideUp 0.4s ease;
}
.video-info.visible { display: block; }
@keyframes fadeSlideUp {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
.info-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
margin-bottom: 1.5rem;
box-shadow: var(--shadow-sm);
}
.info-grid {
display: grid;
grid-template-columns: 320px 1fr;
}
.info-thumb {
position: relative;
aspect-ratio: 16/9;
background: #000;
overflow: hidden;
}
.info-thumb img {
width: 100%; height: 100%;
object-fit: cover;
}
.info-thumb .duration-badge {
position: absolute;
bottom: 8px; right: 8px;
background: rgba(0,0,0,0.85);
color: #fff;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 600;
font-family: var(--mono);
}
.info-details {
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.info-details h2 {
font-size: 1.2rem;
font-weight: 700;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.info-meta {
display: flex;
flex-wrap: wrap;
gap: 1rem;
font-size: 0.85rem;
color: var(--text-secondary);
}
.info-meta span { display: flex; align-items: center; gap: 0.35rem; }
.info-meta i { font-size: 0.8rem; color: var(--text-muted); }
.info-desc {
font-size: 0.85rem;
color: var(--text-secondary);
line-height: 1.6;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.info-desc.expanded { -webkit-line-clamp: unset; }
.info-desc-toggle {
font-size: 0.8rem;
color: var(--red);
background: none;
border: none;
cursor: pointer;
padding: 0;
font-weight: 500;
}
.info-tags {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: auto;
}
.info-tag {
background: var(--bg-input);
color: var(--text-secondary);
padding: 0.2rem 0.6rem;
border-radius: 20px;
font-size: 0.72rem;
border: 1px solid var(--border);
}
/* ===== Download Options ===== */
.options-section {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-bottom: 1.5rem;
}
.option-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.5rem;
box-shadow: var(--shadow-sm);
transition: all var(--transition);
}
.option-card:hover { border-color: var(--text-muted); }
.option-card h3 {
font-size: 1rem;
font-weight: 700;
margin-bottom: 0.35rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.option-card h3 i { color: var(--red); }
.option-card > p { font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 1rem; }
/* Resolution Grid */
.res-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(70px, 1fr));
gap: 0.4rem;
margin-bottom: 1rem;
}
.res-btn {
padding: 0.5rem;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius-xs);
color: var(--text-secondary);
font-size: 0.8rem;
font-weight: 600;
text-align: center;
transition: all var(--transition);
}
.res-btn:hover { border-color: var(--red); color: var(--text); }
.res-btn.selected {
background: var(--red);
border-color: var(--red);
color: #fff;
box-shadow: 0 2px 8px var(--red-glow);
}
/* Audio Quality */
.audio-select {
width: 100%;
padding: 0.7rem 1rem;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius-xs);
color: var(--text);
font-size: 0.9rem;
margin-bottom: 1rem;
outline: none;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238888a0' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
}
.audio-select:focus { border-color: var(--red); }
.audio-select option { background: var(--bg-card); color: var(--text); }
/* Format Table */
.format-table-wrap {
max-height: 260px;
overflow-y: auto;
margin-bottom: 1rem;
border: 1px solid var(--border);
border-radius: var(--radius-xs);
}
.format-table {
width: 100%;
border-collapse: collapse;
font-size: 0.82rem;
}
.format-table thead { position: sticky; top: 0; z-index: 1; }
.format-table th {
background: var(--bg-elevated);
color: var(--text-secondary);
font-weight: 600;
text-align: left;
padding: 0.6rem 0.8rem;
border-bottom: 1px solid var(--border);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.format-table td {
padding: 0.55rem 0.8rem;
border-bottom: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
color: var(--text-secondary);
}
.format-table tr:last-child td { border-bottom: none; }
.format-table tr:hover td { background: var(--bg-card-hover); }
.format-table .dl-btn {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
}
/* ===== Progress Bar ===== */
.progress-section {
display: none;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem 1.5rem;
margin-bottom: 1.5rem;
animation: fadeSlideUp 0.3s ease;
}
.progress-section.visible { display: block; }
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.progress-header span { font-size: 0.85rem; font-weight: 600; }
.progress-header .prog-stats {
font-size: 0.8rem;
color: var(--text-secondary);
font-family: var(--mono);
}
.progress-bar-track {
width: 100%;
height: 8px;
background: var(--bg-input);
border-radius: 4px;
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--red), #ff6b6b);
border-radius: 4px;
width: 0%;
transition: width 0.3s ease;
}
.progress-bar-fill.completed { background: linear-gradient(90deg, var(--success), #4ade80); }
.progress-bar-fill.error { background: var(--error); }
/* ===== Download History ===== */
.history-section {
margin-top: 1rem;
}
.history-section h3 {
font-size: 0.95rem;
font-weight: 700;
margin-bottom: 0.75rem;
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--text-secondary);
}
.history-section h3 i { color: var(--text-muted); }
.history-list { display: flex; flex-direction: column; gap: 0.4rem; }
.history-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.7rem 1rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 0.85rem;
transition: all var(--transition);
}
.history-item:hover { border-color: var(--text-muted); }
.history-item .h-name {
font-weight: 600;
color: var(--text);
max-width: 400px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.history-item .h-meta { font-size: 0.78rem; color: var(--text-muted); margin-top: 0.15rem; }
.history-empty {
color: var(--text-muted);
font-size: 0.85rem;
padding: 1.5rem;
text-align: center;
border: 1px dashed var(--border);
border-radius: var(--radius-sm);
}
/* ===== Toast ===== */
.toast-container {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 0.5rem;
pointer-events: none;
}
.toast {
padding: 0.85rem 1.2rem;
border-radius: var(--radius-sm);
background: var(--bg-card);
border: 1px solid var(--border);
box-shadow: var(--shadow);
font-size: 0.85rem;
display: flex;
align-items: center;
gap: 0.6rem;
pointer-events: auto;
animation: toastIn 0.35s ease;
max-width: 380px;
backdrop-filter: blur(12px);
}
.toast.toast-out { animation: toastOut 0.3s ease forwards; }
.toast.success { border-left: 3px solid var(--success); }
.toast.success i { color: var(--success); }
.toast.error { border-left: 3px solid var(--error); }
.toast.error i { color: var(--error); }
.toast.info { border-left: 3px solid var(--info); }
.toast.info i { color: var(--info); }
@keyframes toastIn { from { opacity: 0; transform: translateY(16px) scale(0.95); } to { opacity: 1; transform: translateY(0) scale(1); } }
@keyframes toastOut { to { opacity: 0; transform: translateY(8px) scale(0.95); } }
/* ===== Footer ===== */
.footer {
text-align: center;
padding: 1.5rem;
font-size: 0.8rem;
color: var(--text-muted);
border-top: 1px solid var(--border);
position: relative;
z-index: 1;
}
.footer a { color: var(--text-secondary); }
/* ===== Responsive ===== */
@media (max-width: 768px) {
.hero h1 { font-size: 1.8rem; }
.info-grid { grid-template-columns: 1fr; }
.info-thumb { max-height: 200px; }
.options-section { grid-template-columns: 1fr; }
.url-row { flex-direction: column; }
.btn { width: 100%; justify-content: center; }
main { padding: 1rem; }
.navbar { padding: 0 1rem; }
.nav-links a { padding: 0.35rem 0.5rem; font-size: 0.8rem; }
}
@media (max-width: 480px) {
.hero h1 { font-size: 1.5rem; }
.res-grid { grid-template-columns: repeat(4, 1fr); }
.info-details { padding: 1rem; }
.info-details h2 { font-size: 1rem; }
}
/* ===== Loading Spinner ===== */
.spinner {
width: 18px; height: 18px;
border: 2px solid var(--border);
border-top-color: var(--red);
border-radius: 50%;
animation: spin 0.6s linear infinite;
display: inline-block;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ===== Keyboard Hint ===== */
.kbd {
display: inline-block;
padding: 0.1rem 0.4rem;
font-size: 0.7rem;
font-family: var(--mono);
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text-muted);
}
/* ===== Subtitle Section ===== */
.subtitle-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border);
}
</style>
</head>
<body>
<div class="bg-effects"></div>
<!-- Navbar -->
<nav class="navbar">
<div class="nav-inner">
<a href="/" class="nav-brand">
<i class="fab fa-youtube"></i>
YTDL
</a>
<div class="nav-links">
<a href="/" class="active" id="navHome">Home</a>
<a href="/api-guide" id="navApi">API Guide</a>
<button class="theme-toggle" id="themeToggle" title="Toggle theme">
<i class="fas fa-moon" id="themeIcon"></i>
</button>
</div>
</div>
</nav>
<!-- Main Content -->
<main>
<!-- Hero -->
<section class="hero">
<h1>Download <span>YouTube</span> Videos</h1>
<p>Paste a URL, pick a format, and download. Fast, free, no ads.</p>
</section>
<!-- URL Input -->
<section class="url-section">
<div class="url-row">
<div class="url-input-wrap">
<i class="fas fa-link"></i>
<input type="text" class="url-input" id="urlInput"
placeholder="https://www.youtube.com/watch?v=..."
autocomplete="off" spellcheck="false">
</div>
<button class="btn btn-primary" id="fetchBtn">
<i class="fas fa-search"></i> Fetch
</button>
</div>
<div style="margin-top: 0.5rem; font-size: 0.75rem; color: var(--text-muted);">
Press <span class="kbd">Ctrl</span> + <span class="kbd">Enter</span> to fetch
</div>
</section>
<!-- Video Info -->
<section class="video-info" id="videoInfo">
<div class="info-card">
<div class="info-grid">
<div class="info-thumb">
<img id="thumb" src="" alt="Thumbnail">
<div class="duration-badge" id="durationBadge">0:00</div>
</div>
<div class="info-details">
<h2 id="vTitle">Video Title</h2>
<div class="info-meta">
<span><i class="fas fa-user"></i> <span id="vAuthor">Author</span></span>
<span><i class="fas fa-eye"></i> <span id="vViews">0</span> views</span>
<span><i class="fas fa-calendar"></i> <span id="vDate">--</span></span>
</div>
<p class="info-desc" id="vDesc">Description</p>
<button class="info-desc-toggle" id="descToggle" style="display:none;">Show more</button>
<div class="info-tags" id="vTags"></div>
</div>
</div>
</div>
<!-- Progress -->
<div class="progress-section" id="progressSection">
<div class="progress-header">
<span id="progLabel">Downloading...</span>
<span class="prog-stats" id="progStats">0%</span>
</div>
<div class="progress-bar-track">
<div class="progress-bar-fill" id="progBar"></div>
</div>
</div>
<!-- Download Options -->
<div class="options-section">
<!-- Video Card -->
<div class="option-card">
<h3><i class="fas fa-film"></i> Video</h3>
<p>Choose a resolution and download. Progressive formats include audio.</p>
<div class="res-grid" id="resGrid"></div>
<div class="format-table-wrap" id="videoTableWrap" style="display:none;">
<table class="format-table">
<thead>
<tr><th>Format</th><th>Size</th><th></th></tr>
</thead>
<tbody id="videoTableBody"></tbody>
</table>
</div>
<button class="btn btn-primary" id="dlVideoBtn" disabled>
<i class="fas fa-download"></i> Download Video
</button>
<div class="subtitle-row">
<i class="fas fa-closed-captioning" style="color: var(--text-muted);"></i>
<button class="btn btn-secondary btn-sm" id="dlSubsBtn">
<i class="fas fa-download"></i> Subtitles
</button>
</div>
</div>
<!-- Audio Card -->
<div class="option-card">
<h3><i class="fas fa-headphones"></i> Audio</h3>
<p>Extract audio as MP3. Higher quality means larger file.</p>
<select class="audio-select" id="audioQuality">
<option value="9">Best (320 kbps)</option>
<option value="5" selected>Good (192 kbps)</option>
<option value="2">Medium (128 kbps)</option>
<option value="0">Small (64 kbps)</option>
</select>
<div class="format-table-wrap" id="audioTableWrap" style="display:none;">
<table class="format-table">
<thead>
<tr><th>Format</th><th>Bitrate</th><th></th></tr>
</thead>
<tbody id="audioTableBody"></tbody>
</table>
</div>
<button class="btn btn-success" id="dlAudioBtn" disabled>
<i class="fas fa-download"></i> Download Audio
</button>
</div>
</div>
</section>
<!-- Download History -->
<section class="history-section" id="historySection">
<h3><i class="fas fa-clock-rotate-left"></i> History</h3>
<div class="history-list" id="historyList"></div>
</section>
</main>
<!-- Footer -->
<footer class="footer">
Powered by <a href="https://github.com/yt-dlp/yt-dlp" target="_blank">yt-dlp</a> &middot;
<a href="/api-guide">API Guide</a>
</footer>
<!-- Toast Container -->
<div class="toast-container" id="toastContainer"></div>
<!-- Gradio Client -->
<script type="importmap">
{
"imports": {
"@gradio/client": "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js"
}
}
</script>
<script type="module">
import { Client } from "@gradio/client";
// ===== State =====
const state = {
client: null,
video: null, // current video info
streams: null, // current streams data
selectedRes: null,
selectedFormatId: null,
currentTaskId: null,
progressTimer: null,
history: JSON.parse(localStorage.getItem('ytdl_history') || '[]'),
theme: localStorage.getItem('ytdl_theme') || 'dark',
};
// ===== DOM =====
const $ = (s) => document.querySelector(s);
const $$ = (s) => document.querySelectorAll(s);
// ===== Theme =====
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
const icon = $('#themeIcon');
icon.className = theme === 'dark' ? 'fas fa-moon' : 'fas fa-sun';
localStorage.setItem('ytdl_theme', theme);
state.theme = theme;
}
applyTheme(state.theme);
$('#themeToggle').addEventListener('click', () => {
applyTheme(state.theme === 'dark' ? 'light' : 'dark');
});
// ===== Toast =====
function toast(message, type = 'info') {
const container = $('#toastContainer');
const el = document.createElement('div');
el.className = `toast ${type}`;
const icons = { success: 'fa-check-circle', error: 'fa-times-circle', info: 'fa-info-circle' };
el.innerHTML = `<i class="fas ${icons[type] || icons.info}"></i><span>${message}</span>`;
container.appendChild(el);
setTimeout(() => {
el.classList.add('toast-out');
setTimeout(() => el.remove(), 300);
}, 4000);
}
// ===== Formatters =====
function fmtViews(n) {
if (!n) return '0';
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.toLocaleString();
}
function fmtSize(mb) {
if (mb == null) return '--';
if (mb >= 1024) return (mb / 1024).toFixed(1) + ' GB';
return mb.toFixed(1) + ' MB';
}
// ===== Init Client =====
async function initClient() {
try {
state.client = await Client.connect(window.location.origin);
} catch (err) {
console.error('Gradio connect failed:', err);
toast('Failed to connect to server. Please refresh.', 'error');
}
}
// ===== Fetch Video =====
async function fetchVideo() {
const url = $('#urlInput').value.trim();
if (!url) { toast('Please enter a YouTube URL', 'error'); return; }
if (!/youtube\.com|youtu\.be/.test(url)) { toast('Invalid YouTube URL', 'error'); return; }
const btn = $('#fetchBtn');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Fetching...';
try {
// Fetch info
const infoRes = await state.client.predict('/get_video_info', { url });
const infoData = infoRes.data;
if (!infoData.success) {
toast(infoData.error || 'Failed to fetch video', 'error');
return;
}
const info = infoData.info;
state.video = { url, info };
// Update UI
$('#thumb').src = info.thumbnail || '';
$('#vTitle').textContent = info.title || 'Unknown';
$('#vAuthor').textContent = info.author || 'Unknown';
$('#vViews').textContent = fmtViews(info.views);
$('#vDate').textContent = info.upload_date || '--';
$('#durationBadge').textContent = info.duration_string || '0:00';
const desc = $('#vDesc');
desc.textContent = info.description || 'No description.';
const isLong = (info.description || '').length > 200;
$('#descToggle').style.display = isLong ? 'inline' : 'none';
desc.classList.toggle('expanded', false);
$('#descToggle').textContent = 'Show more';
// Tags
const tags = $('#vTags');
tags.innerHTML = (info.keywords || []).slice(0, 8).map(t =>
`<span class="info-tag">${t}</span>`
).join('');
// Fetch streams
await fetchStreams(url);
// Show
$('#videoInfo').classList.add('visible');
toast('Video loaded!', 'success');
} catch (err) {
toast('Error: ' + err.message, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-search"></i> Fetch';
}
}
// ===== Fetch Streams =====
async function fetchStreams(url) {
try {
const res = await state.client.predict('/get_streams', { url });
const data = res.data;
if (!data.success) { toast(data.error || 'Failed to fetch streams', 'error'); return; }
state.streams = data;
// Build resolution grid from progressive + video-only
const allRes = [...(data.progressive || []), ...(data.video_only || [])];
const resSet = [...new Set(allRes.map(s => s.resolution).filter(Boolean))];
const grid = $('#resGrid');
grid.innerHTML = resSet.map(r => `
<button class="res-btn" data-res="${r}">${r}</button>
`).join('');
// Auto-select best
if (data.best_resolution) {
state.selectedRes = data.best_resolution;
const best = grid.querySelector(`[data-res="${data.best_resolution}"]`);
if (best) best.classList.add('selected');
// Also find the format_id for this resolution
pickFormatId(data.best_resolution);
}
// Resolution click handlers
grid.querySelectorAll('.res-btn').forEach(btn => {
btn.addEventListener('click', () => {
grid.querySelectorAll('.res-btn').forEach(b => b.classList.remove('selected'));
btn.classList.add('selected');
state.selectedRes = btn.dataset.res;
pickFormatId(btn.dataset.res);
$('#dlVideoBtn').disabled = false;
});
});
// Video formats table (video-only)
const vOnly = data.video_only || [];
const vBody = $('#videoTableBody');
if (vOnly.length > 0) {
$('#videoTableWrap').style.display = 'block';
vBody.innerHTML = vOnly.slice(0, 10).map(s => `
<tr>
<td>${s.label || s.resolution} ${s.fps ? `@ ${s.fps}fps` : ''}</td>
<td>${fmtSize(s.filesize_mb)}</td>
<td><button class="btn btn-secondary btn-sm dl-btn" data-fid="${s.format_id}"><i class="fas fa-download"></i></button></td>
</tr>
`).join('');
vBody.querySelectorAll('.dl-btn').forEach(b => {
b.addEventListener('click', () => startDownload(b.dataset.fid, false));
});
} else {
$('#videoTableWrap').style.display = 'none';
}
// Audio formats table
const aOnly = data.audio_only || [];
const aBody = $('#audioTableBody');
if (aOnly.length > 0) {
$('#audioTableWrap').style.display = 'block';
aBody.innerHTML = aOnly.slice(0, 8).map(s => `
<tr>
<td>${s.label || 'Audio'}</td>
<td>${s.abr || '--'}</td>
<td><button class="btn btn-secondary btn-sm dl-btn" data-fid="${s.format_id}"><i class="fas fa-download"></i></button></td>
</tr>
`).join('');
aBody.querySelectorAll('.dl-btn').forEach(b => {
b.addEventListener('click', () => startDownload(b.dataset.fid, true));
});
} else {
$('#audioTableWrap').style.display = 'none';
}
// Enable audio button
$('#dlAudioBtn').disabled = aOnly.length === 0;
} catch (err) {
toast('Error fetching streams: ' + err.message, 'error');
}
}
function pickFormatId(resolution) {
// Prefer progressive (has audio), then video-only
const s = state.streams;
if (!s) return;
const prog = (s.progressive || []).find(f => f.resolution === resolution);
if (prog) { state.selectedFormatId = prog.format_id; return; }
const vonly = (s.video_only || []).find(f => f.resolution === resolution);
if (vonly) { state.selectedFormatId = vonly.format_id; return; }
}
// ===== Download =====
async function startDownload(formatId, isAudio) {
if (!state.video) { toast('Fetch a video first', 'error'); return; }
const progSection = $('#progressSection');
const progBar = $('#progBar');
const progLabel = $('#progLabel');
const progStats = $('#progStats');
progBar.className = 'progress-bar-fill';
progBar.style.width = '0%';
progLabel.textContent = isAudio ? 'Extracting audio...' : 'Downloading video...';
progStats.textContent = 'Starting...';
progSection.classList.add('visible');
try {
const args = { url: state.video.url, format_id: formatId, is_audio: isAudio };
if (isAudio) args.audio_quality = $('#audioQuality').value;
const res = await state.client.predict('/download', args);
const data = res.data;
if (!data.success) {
toast(data.error || 'Download failed', 'error');
progBar.className = 'progress-bar-fill error';
progLabel.textContent = 'Failed';
progStats.textContent = data.error || 'Error';
return;
}
// Poll progress
const taskId = data.task_id;
state.currentTaskId = taskId;
clearInterval(state.progressTimer);
state.progressTimer = setInterval(async () => {
try {
const pRes = await state.client.predict('/get_progress', { task_id: taskId });
const p = pRes.data;
if (p.status === 'downloading') {
progBar.style.width = (p.percent || 0).toFixed(1) + '%';
const parts = [p.percent ? p.percent.toFixed(1) + '%' : ''];
if (p.speed) parts.push(p.speed);
if (p.eta) parts.push('ETA ' + p.eta);
progStats.textContent = parts.join(' · ') || 'Downloading...';
} else if (p.status === 'completed') {
clearInterval(state.progressTimer);
progBar.style.width = '100%';
progBar.className = 'progress-bar-fill completed';
progLabel.textContent = 'Complete!';
progStats.textContent = 'Done';
toast('Download complete!', 'success');
addHistory(p.filename || (isAudio ? 'audio.mp3' : 'video.mp4'), isAudio ? 'Audio' : 'Video', state.selectedRes || '--');
// Auto-hide progress after 3s
setTimeout(() => progSection.classList.remove('visible'), 3000);
// Trigger file download
if (p.filepath) {
const videoId = state.video.info.id;
const filename = p.filename;
window.location.href = `/serve/${videoId}/${encodeURIComponent(filename)}`;
}
} else if (p.status === 'error') {
clearInterval(state.progressTimer);
progBar.className = 'progress-bar-fill error';
progLabel.textContent = 'Error';
progStats.textContent = p.error || 'Download failed';
toast(p.error || 'Download failed', 'error');
}
} catch (e) {
// Ignore poll errors
}
}, 1500);
} catch (err) {
toast('Error: ' + err.message, 'error');
progSection.classList.remove('visible');
}
}
// ===== Subtitles =====
async function downloadSubs() {
if (!state.video) { toast('Fetch a video first', 'error'); return; }
toast('Downloading subtitles...', 'info');
try {
const res = await state.client.predict('/download_subtitles', { url: state.video.url, lang: 'en' });
const data = res.data;
if (data.success) {
toast('Subtitles downloaded!', 'success');
addHistory('subtitles.srt', 'Subtitles', 'SRT');
} else {
toast(data.error || 'No subtitles available', 'error');
}
} catch (err) {
toast('Error: ' + err.message, 'error');
}
}
// ===== History =====
function addHistory(filename, type, quality) {
state.history.unshift({
filename, type, quality,
time: new Date().toLocaleString(),
});
if (state.history.length > 30) state.history = state.history.slice(0, 30);
localStorage.setItem('ytdl_history', JSON.stringify(state.history));
renderHistory();
}
function renderHistory() {
const list = $('#historyList');
if (state.history.length === 0) {
list.innerHTML = '<div class="history-empty">No downloads yet</div>';
return;
}
list.innerHTML = state.history.map((h, i) => `
<div class="history-item">
<div>
<div class="h-name">${h.filename}</div>
<div class="h-meta">${h.type} &middot; ${h.quality} &middot; ${h.time}</div>
</div>
<button class="btn btn-ghost btn-icon" data-idx="${i}" title="Remove">
<i class="fas fa-xmark"></i>
</button>
</div>
`).join('');
list.querySelectorAll('[data-idx]').forEach(btn => {
btn.addEventListener('click', () => {
state.history.splice(parseInt(btn.dataset.idx), 1);
localStorage.setItem('ytdl_history', JSON.stringify(state.history));
renderHistory();
});
});
}
// ===== Description Toggle =====
$('#descToggle').addEventListener('click', () => {
const desc = $('#vDesc');
const expanded = desc.classList.toggle('expanded');
$('#descToggle').textContent = expanded ? 'Show less' : 'Show more';
});
// ===== Event Listeners =====
$('#fetchBtn').addEventListener('click', fetchVideo);
$('#urlInput').addEventListener('keypress', (e) => {
if (e.key === 'Enter') fetchVideo();
});
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 'Enter') { e.preventDefault(); fetchVideo(); }
});
$('#dlVideoBtn').addEventListener('click', () => {
if (state.selectedFormatId) startDownload(state.selectedFormatId, false);
else toast('Select a resolution first', 'error');
});
$('#dlAudioBtn').addEventListener('click', () => {
// For audio, we use a special approach - use any audio format_id or pass is_audio=true
const aOnly = state.streams?.audio_only || [];
const fid = aOnly[0]?.format_id || 'bestaudio';
startDownload(fid, true);
});
$('#dlSubsBtn').addEventListener('click', downloadSubs);
// ===== Init =====
await initClient();
renderHistory();
</script>
</body>
</html>