Spaces:
Runtime error
Runtime error
| <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> · | |
| <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} · ${h.quality} · ${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> |