metube/design/metube-redesign.html

1472 lines
54 KiB
HTML

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MeTube — 下载管理</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg: oklch(98% 0.005 250);
--surface: oklch(100% 0 0);
--fg: oklch(22% 0.02 240);
--muted: oklch(50% 0.018 240);
--border: oklch(90% 0.008 240);
--accent: oklch(58% 0.16 145);
--accent-strong: oklch(48% 0.14 145);
--accent-tint: oklch(95% 0.03 145);
--success: oklch(58% 0.16 145);
--warn: oklch(70% 0.15 75);
--danger: oklch(55% 0.19 25);
--danger-tint: oklch(95% 0.025 25);
--font-display: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', system-ui, sans-serif;
--font-body: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, Menlo, monospace;
--radius: 8px;
--sidebar-w: 236px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
font-family: var(--font-body);
background: var(--bg);
color: var(--fg);
font-size: 14px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
}
button { font-family: inherit; cursor: pointer; }
input, select { font-family: inherit; }
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 4px;
}
.num { font-family: var(--font-mono); font-variant-numeric: tabular-nums; letter-spacing: 0; }
/* ============ App frame ============ */
.app {
display: grid;
grid-template-columns: var(--sidebar-w) 1fr;
height: 100vh;
overflow: hidden;
}
/* ============ Sidebar ============ */
.sidebar {
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow-y: auto;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
padding: 16px 18px 14px;
border-bottom: 1px solid var(--border);
}
.brand-mark {
width: 28px; height: 28px;
border-radius: 7px;
background: var(--fg);
color: var(--surface);
display: grid; place-items: center;
flex: none;
}
.brand-name {
font-family: var(--font-display);
font-size: 15px;
font-weight: 600;
letter-spacing: -0.01em;
}
.brand-ver {
font-family: var(--font-mono);
font-size: 11px;
color: var(--muted);
margin-left: auto;
}
.nav-section { padding: 14px 10px 4px; }
.nav-label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
color: var(--muted);
padding: 0 8px 6px;
}
.nav-item {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
padding: 7px 8px;
border: none;
background: none;
border-radius: 6px;
font-size: 13.5px;
color: var(--fg);
text-align: left;
position: relative;
transition: background 0.12s ease;
}
.nav-item:hover { background: var(--bg); }
.nav-item.active { background: var(--accent-tint); font-weight: 550; }
.nav-item.active::before {
content: "";
position: absolute;
left: -10px;
top: 6px; bottom: 6px;
width: 3px;
border-radius: 0 2px 2px 0;
background: var(--accent);
}
.nav-item svg { flex: none; color: var(--muted); }
.nav-item.active svg { color: var(--accent-strong); }
.nav-count {
margin-left: auto;
font-family: var(--font-mono);
font-size: 11.5px;
color: var(--muted);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 20px;
padding: 0 7px;
line-height: 18px;
}
.nav-item.active .nav-count {
background: var(--surface);
border-color: oklch(85% 0.05 145);
color: var(--accent-strong);
}
.nav-item .dot {
width: 7px; height: 7px;
border-radius: 50%;
background: var(--accent);
flex: none;
animation: pulse 1.6s ease-in-out infinite;
}
@keyframes pulse { 50% { opacity: 0.35; } }
.sidebar-foot {
margin-top: auto;
border-top: 1px solid var(--border);
padding: 10px;
display: flex;
gap: 6px;
}
.ghost-btn {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 7px 10px;
border: 1px solid var(--border);
background: var(--surface);
border-radius: 6px;
font-size: 12.5px;
color: var(--fg);
transition: background 0.12s ease, border-color 0.12s ease;
}
.ghost-btn:hover { background: var(--bg); border-color: oklch(82% 0.01 240); }
.ghost-btn svg { color: var(--muted); }
/* ============ Main ============ */
.main {
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
/* Command bar */
.commandbar {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 14px 22px 12px;
}
.cb-row {
display: flex;
gap: 10px;
align-items: stretch;
}
.url-wrap {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
padding: 0 12px;
transition: border-color 0.12s ease, background 0.12s ease;
}
.url-wrap:focus-within {
border-color: var(--accent);
background: var(--surface);
}
.url-wrap svg { color: var(--muted); flex: none; }
.url-input {
flex: 1;
border: none;
background: none;
height: 40px;
font-size: 14px;
color: var(--fg);
outline: none;
font-family: var(--font-mono);
}
.url-input::placeholder { color: var(--muted); font-family: var(--font-body); }
.cb-chips {
display: flex;
gap: 6px;
margin-top: 10px;
align-items: center;
flex-wrap: wrap;
}
.chip {
border: 1px solid var(--border);
background: var(--surface);
border-radius: 20px;
padding: 4px 12px;
font-size: 12.5px;
color: var(--fg);
transition: background 0.12s ease, border-color 0.12s ease;
}
.chip:hover { background: var(--bg); }
.chip.on {
background: var(--fg);
border-color: var(--fg);
color: var(--surface);
font-weight: 550;
}
.chip-sep {
width: 1px;
height: 18px;
background: var(--border);
margin: 0 4px;
}
.cb-label { font-size: 12px; color: var(--muted); margin-right: 2px; }
.folder-pick {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid var(--border);
background: var(--surface);
border-radius: 20px;
padding: 4px 12px;
font-size: 12.5px;
color: var(--fg);
}
.folder-pick:hover { background: var(--bg); }
.folder-pick svg { color: var(--muted); }
.advanced-link {
margin-left: auto;
border: none;
background: none;
font-size: 12.5px;
color: var(--muted);
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 6px;
border-radius: 6px;
}
.advanced-link:hover { background: var(--bg); color: var(--fg); }
.primary-btn {
display: inline-flex;
align-items: center;
gap: 7px;
background: var(--accent);
color: oklch(99% 0.01 145);
border: none;
border-radius: var(--radius);
padding: 0 18px;
height: 40px;
font-size: 14px;
font-weight: 590;
letter-spacing: 0.02em;
transition: background 0.12s ease, transform 0.06s ease;
white-space: nowrap;
}
.primary-btn:hover { background: var(--accent-strong); }
.primary-btn:active { transform: translateY(1px); }
/* Stats strip */
.stats {
display: flex;
gap: 0;
background: var(--surface);
border-bottom: 1px solid var(--border);
}
.stat {
padding: 8px 22px;
border-right: 1px solid var(--border);
display: flex;
align-items: baseline;
gap: 7px;
font-size: 12px;
color: var(--muted);
}
.stat strong {
font-family: var(--font-mono);
font-size: 13px;
font-weight: 500;
color: var(--fg);
}
.stat .live { color: var(--accent-strong); font-weight: 500; }
/* Scrollable content */
.content {
flex: 1;
overflow-y: auto;
padding: 18px 22px 90px;
}
/* Active downloads */
.active-panel { margin-bottom: 22px; }
.section-head {
display: flex;
align-items: baseline;
gap: 10px;
margin-bottom: 10px;
}
.section-title {
font-family: var(--font-display);
font-size: 15px;
font-weight: 600;
letter-spacing: -0.01em;
line-height: 1.35;
}
.section-meta { font-size: 12px; color: var(--muted); font-family: var(--font-mono); }
.active-list {
display: grid;
gap: 8px;
}
.active-row {
display: grid;
grid-template-columns: 120px 1fr auto;
gap: 14px;
align-items: center;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 14px 10px 10px;
}
.thumb {
border-radius: 5px;
overflow: hidden;
background: oklch(94% 0.01 240);
position: relative;
flex: none;
}
.thumb img { display: block; width: 100%; height: 100%; object-fit: cover; }
.thumb .dur {
position: absolute;
right: 4px; bottom: 4px;
background: oklch(20% 0.02 240 / 0.82);
color: oklch(96% 0.01 240);
font-family: var(--font-mono);
font-size: 10.5px;
padding: 1px 5px;
border-radius: 3px;
}
.active-title {
font-size: 13.5px;
font-weight: 550;
line-height: 1.4;
margin-bottom: 6px;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.progress-track {
height: 5px;
border-radius: 3px;
background: oklch(93% 0.008 240);
overflow: hidden;
margin-bottom: 6px;
}
.progress-fill {
height: 100%;
border-radius: 3px;
background: var(--accent);
transition: width 0.6s ease;
}
.active-stats {
display: flex;
gap: 14px;
font-size: 12px;
color: var(--muted);
}
.active-stats .num { color: var(--fg); }
.icon-btn {
width: 32px; height: 32px;
display: grid; place-items: center;
border: 1px solid transparent;
background: none;
border-radius: 6px;
color: var(--muted);
transition: background 0.12s ease, color 0.12s ease;
}
.icon-btn:hover { background: var(--danger-tint); color: var(--danger); }
.icon-btn.neutral:hover { background: var(--bg); color: var(--fg); }
/* List toolbar */
.list-toolbar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.filter-wrap {
display: flex;
align-items: center;
gap: 7px;
border: 1px solid var(--border);
background: var(--surface);
border-radius: 6px;
padding: 0 10px;
height: 34px;
width: 240px;
transition: border-color 0.12s ease;
}
.filter-wrap:focus-within { border-color: var(--accent); }
.filter-wrap svg { color: var(--muted); flex: none; }
.filter-wrap input {
border: none; background: none; outline: none;
font-size: 13px; color: var(--fg);
width: 100%;
}
.filter-wrap input::placeholder { color: var(--muted); }
.tool-select {
height: 34px;
border: 1px solid var(--border);
background: var(--surface);
border-radius: 6px;
font-size: 12.5px;
color: var(--fg);
padding: 0 8px;
}
.view-toggle {
display: flex;
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
background: var(--surface);
}
.view-toggle button {
width: 34px; height: 32px;
border: none;
background: none;
display: grid; place-items: center;
color: var(--muted);
transition: background 0.12s ease, color 0.12s ease;
}
.view-toggle button + button { border-left: 1px solid var(--border); }
.view-toggle button.on { background: var(--fg); color: var(--surface); }
.view-toggle button:not(.on):hover { background: var(--bg); color: var(--fg); }
.result-count {
margin-left: auto;
font-size: 12px;
color: var(--muted);
font-family: var(--font-mono);
}
/* Queue list rows */
.queue-list {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.q-row {
display: grid;
grid-template-columns: 30px 88px 1fr auto auto;
gap: 12px;
align-items: center;
padding: 8px 14px 8px 8px;
border-bottom: 1px solid var(--border);
transition: background 0.1s ease;
}
.q-row:last-child { border-bottom: none; }
.q-row:hover { background: var(--bg); }
.q-row.selected { background: var(--accent-tint); }
.q-check {
width: 17px; height: 17px;
accent-color: var(--accent);
cursor: pointer;
margin-left: 6px;
}
.q-title {
font-size: 13.5px;
font-weight: 500;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.q-meta {
display: flex;
gap: 8px;
align-items: center;
margin-top: 3px;
font-size: 11.5px;
color: var(--muted);
}
.badge {
font-size: 10.5px;
font-weight: 550;
padding: 1px 7px;
border-radius: 20px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--muted);
letter-spacing: 0.02em;
}
.badge.folder { color: var(--fg); background: var(--surface); }
.badge.fmt { font-family: var(--font-mono); font-weight: 500; }
.q-side {
text-align: right;
font-size: 11.5px;
color: var(--muted);
white-space: nowrap;
}
.q-side .num { color: var(--fg); font-size: 12px; }
.q-actions { display: flex; gap: 2px; opacity: 0; transition: opacity 0.12s ease; }
.q-row:hover .q-actions, .q-row.selected .q-actions { opacity: 1; }
/* Completed grid */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(218px, 1fr));
gap: 14px;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
transition: border-color 0.12s ease, box-shadow 0.12s ease;
position: relative;
}
.card:hover {
border-color: oklch(80% 0.012 240);
box-shadow: 0 4px 14px oklch(30% 0.02 240 / 0.08);
}
.card .thumb { border-radius: 0; aspect-ratio: 16/9; }
.card-body { padding: 10px 12px 12px; }
.card-title {
font-size: 13px;
font-weight: 550;
line-height: 1.45;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
min-height: 38px;
}
.card-meta {
display: flex;
align-items: center;
gap: 7px;
margin-top: 8px;
font-size: 11.5px;
color: var(--muted);
}
.card-meta .num { color: var(--fg); }
.card-actions {
margin-left: auto;
display: flex;
gap: 2px;
}
.card .icon-btn { width: 28px; height: 28px; }
.card-check {
position: absolute;
top: 8px; left: 8px;
z-index: 2;
width: 17px; height: 17px;
accent-color: var(--accent);
cursor: pointer;
opacity: 0;
transition: opacity 0.12s ease;
}
.card:hover .card-check, .card-check:checked { opacity: 1; }
.card.selected { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
/* Empty state */
.empty {
text-align: center;
padding: 56px 20px;
border: 1px dashed var(--border);
border-radius: var(--radius);
background: var(--surface);
}
.empty svg { color: var(--muted); margin-bottom: 12px; }
.empty-title { font-size: 14px; font-weight: 600; margin-bottom: 4px; }
.empty-sub { font-size: 12.5px; color: var(--muted); }
/* Batch bar */
.batchbar {
position: absolute;
left: 50%;
bottom: 18px;
transform: translate(-50%, 76px);
display: flex;
align-items: center;
gap: 6px;
background: var(--fg);
color: oklch(96% 0.008 240);
border-radius: 10px;
padding: 8px 8px 8px 16px;
box-shadow: 0 10px 30px oklch(20% 0.02 240 / 0.3);
transition: transform 0.2s cubic-bezier(0.2, 0.9, 0.3, 1.2);
z-index: 30;
}
.batchbar.show { transform: translate(-50%, 0); }
.batch-count {
font-size: 13px;
font-weight: 550;
margin-right: 8px;
white-space: nowrap;
}
.batch-btn {
display: inline-flex;
align-items: center;
gap: 6px;
border: none;
background: oklch(100% 0 0 / 0.1);
color: oklch(96% 0.008 240);
border-radius: 6px;
padding: 7px 12px;
font-size: 12.5px;
font-weight: 500;
transition: background 0.12s ease;
white-space: nowrap;
}
.batch-btn:hover { background: oklch(100% 0 0 / 0.2); }
.batch-btn.danger:hover { background: var(--danger); }
/* Slide-over */
.scrim {
position: fixed;
inset: 0;
background: oklch(25% 0.02 240 / 0.35);
opacity: 0;
pointer-events: none;
transition: opacity 0.18s ease;
z-index: 40;
}
.scrim.show { opacity: 1; pointer-events: auto; }
.slideover {
position: fixed;
top: 0; right: 0; bottom: 0;
width: 340px;
background: var(--surface);
border-left: 1px solid var(--border);
box-shadow: -12px 0 40px oklch(30% 0.02 240 / 0.15);
transform: translateX(100%);
transition: transform 0.22s ease;
z-index: 50;
display: flex;
flex-direction: column;
}
.slideover.show { transform: translateX(0); }
.so-head {
display: flex;
align-items: center;
padding: 16px 18px;
border-bottom: 1px solid var(--border);
}
.so-title { font-size: 15px; font-weight: 600; letter-spacing: -0.01em; }
.so-body { padding: 18px; overflow-y: auto; flex: 1; }
.so-group { margin-bottom: 24px; }
.so-label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
color: var(--muted);
margin-bottom: 10px;
}
.so-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 9px 0;
font-size: 13.5px;
}
.so-row .sub { font-size: 11.5px; color: var(--muted); margin-top: 2px; }
.switch {
width: 36px; height: 21px;
border-radius: 20px;
border: none;
background: oklch(85% 0.01 240);
position: relative;
transition: background 0.15s ease;
flex: none;
}
.switch::after {
content: "";
position: absolute;
top: 2.5px; left: 3px;
width: 16px; height: 16px;
border-radius: 50%;
background: var(--surface);
box-shadow: 0 1px 3px oklch(20% 0.02 240 / 0.3);
transition: transform 0.15s ease;
}
.switch.on { background: var(--accent); }
.switch.on::after { transform: translateX(14px); }
.so-input {
width: 72px;
height: 30px;
border: 1px solid var(--border);
border-radius: 6px;
padding: 0 8px;
font-size: 13px;
font-family: var(--font-mono);
color: var(--fg);
text-align: right;
}
.so-btn {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--border);
background: var(--surface);
border-radius: 6px;
font-size: 13px;
color: var(--fg);
text-align: left;
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
transition: background 0.12s ease;
}
.so-btn:hover { background: var(--bg); }
.so-btn svg { color: var(--muted); }
.cookie-saved {
display: flex;
align-items: center;
gap: 7px;
font-size: 12.5px;
padding: 7px 10px;
background: var(--accent-tint);
border-radius: 6px;
color: var(--accent-strong);
margin-bottom: 8px;
font-family: var(--font-mono);
}
/* Toast */
.toasts {
position: fixed;
right: 20px; bottom: 20px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 60;
}
.toast {
display: flex;
align-items: center;
gap: 10px;
background: var(--surface);
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
border-radius: 8px;
padding: 11px 14px;
font-size: 13px;
box-shadow: 0 8px 24px oklch(30% 0.02 240 / 0.14);
min-width: 260px;
animation: toast-in 0.25s ease;
}
.toast.err { border-left-color: var(--danger); }
.toast svg { flex: none; }
.toast .t-act {
margin-left: auto;
border: none;
background: none;
color: var(--accent-strong);
font-size: 12.5px;
font-weight: 550;
padding: 2px 4px;
border-radius: 4px;
white-space: nowrap;
}
.toast .t-act:hover { background: var(--accent-tint); }
@keyframes toast-in {
from { opacity: 0; transform: translateY(8px); }
}
/* Responsive */
@media (max-width: 900px) {
.app { grid-template-columns: 1fr; }
.sidebar {
position: fixed;
z-index: 45;
width: 260px;
height: 100%;
transform: translateX(-100%);
transition: transform 0.2s ease;
box-shadow: 12px 0 40px oklch(30% 0.02 240 / 0.15);
}
.sidebar.open { transform: translateX(0); }
.menu-btn { display: grid !important; }
.cb-row { flex-wrap: wrap; }
.url-wrap { width: 100%; flex: none; }
.active-row { grid-template-columns: 96px 1fr; }
.active-row .row-actions { grid-column: 2; justify-self: end; }
.q-row { grid-template-columns: 30px 64px 1fr auto; }
.q-side { display: none; }
.stats { flex-wrap: wrap; }
.filter-wrap { width: 100%; }
.list-toolbar { flex-wrap: wrap; }
.result-count { margin-left: 0; width: 100%; }
}
.menu-btn {
display: none;
place-items: center;
width: 40px; height: 40px;
border: 1px solid var(--border);
background: var(--surface);
border-radius: var(--radius);
color: var(--fg);
flex: none;
}
</style>
</head>
<body>
<div class="app" data-od-id="app">
<!-- ============ Sidebar ============ -->
<aside class="sidebar" id="sidebar" data-od-id="sidebar">
<div class="brand">
<span class="brand-mark">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polygon points="6 3 20 12 6 21 6 3" fill="currentColor" stroke="none"/></svg>
</span>
<span class="brand-name">MeTube</span>
<span class="brand-ver num">v1.13.2</span>
</div>
<div class="nav-section" data-od-id="nav-status">
<div class="nav-label">状态</div>
<button class="nav-item active" data-status="downloading">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
正在下载
<span class="nav-count num">2</span>
</button>
<button class="nav-item" data-status="queued">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15.5 14"/></svg>
排队中
<span class="nav-count num">230</span>
</button>
<button class="nav-item" data-status="completed">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
已完成
<span class="nav-count num">303</span>
</button>
</div>
<div class="nav-section" data-od-id="nav-folders">
<div class="nav-label">文件夹</div>
<button class="nav-item active" data-folder="all">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
全部
<span class="nav-count num">535</span>
</button>
<button class="nav-item" data-folder="movies">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
movies
<span class="nav-count num">90</span>
</button>
<button class="nav-item" data-folder="shows">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
shows
<span class="nav-count num">86</span>
</button>
<button class="nav-item" data-folder="music">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
music
<span class="nav-count num">54</span>
</button>
<button class="nav-item" data-folder="uncategorized">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
未分类
<span class="nav-count num">305</span>
</button>
</div>
<div class="sidebar-foot">
<button class="ghost-btn" id="openAdvanced">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
高级选项
</button>
<button class="ghost-btn" id="activityBtn" title="活动日志">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
日志
</button>
</div>
</aside>
<!-- ============ Main ============ -->
<main class="main">
<!-- Command bar -->
<div class="commandbar" data-od-id="command-bar">
<div class="cb-row">
<button class="menu-btn" id="menuBtn" aria-label="打开导航">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<div class="url-wrap">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
<input class="url-input" id="urlInput" type="url" placeholder="粘贴视频或播放列表 URL…" aria-label="视频 URL">
</div>
<button class="primary-btn" id="addBtn" data-od-id="add-button">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
添加到队列
</button>
</div>
<div class="cb-chips">
<span class="cb-label">清晰度</span>
<button class="chip on" data-quality="best">最佳</button>
<button class="chip" data-quality="1080p">1080p</button>
<button class="chip" data-quality="720p">720p</button>
<button class="chip" data-quality="audio">仅音频</button>
<span class="chip-sep"></span>
<span class="cb-label">格式</span>
<button class="chip on" data-format="mp4">MP4</button>
<button class="chip" data-format="mkv">MKV</button>
<button class="chip" data-format="mp3">MP3</button>
<span class="chip-sep"></span>
<button class="folder-pick" id="folderPick">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
<span id="folderPickLabel">movies</span>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<button class="advanced-link" id="openAdvanced2">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/></svg>
高级选项
</button>
</div>
</div>
<!-- Stats strip -->
<div class="stats" data-od-id="stats-strip">
<div class="stat"><span class="live">● 下载中</span><strong id="statActive">2</strong></div>
<div class="stat">总速度 <strong id="statSpeed">12.4 MB/s</strong></div>
<div class="stat">队列 <strong>230</strong></div>
<div class="stat">已入库存储 <strong>412.6 GB</strong></div>
</div>
<!-- Content -->
<div class="content" id="content">
<!-- Active downloads -->
<section class="active-panel" data-od-id="active-panel">
<div class="section-head">
<h2 class="section-title">正在下载</h2>
<span class="section-meta" id="activeMeta">12.4 MB/s · 预计 0:43 完成首个任务</span>
</div>
<div class="active-list" id="activeList"></div>
</section>
<!-- Library / queue -->
<section data-od-id="list-section">
<div class="section-head">
<h2 class="section-title" id="listTitle">排队中</h2>
<span class="section-meta" id="listSubtitle">movies · 90 项</span>
</div>
<div class="list-toolbar" data-od-id="list-toolbar">
<div class="filter-wrap">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input id="filterInput" type="search" placeholder="筛选标题…" aria-label="筛选标题">
</div>
<select class="tool-select" id="sortSelect" aria-label="排序方式">
<option value="date">按添加时间</option>
<option value="title">按标题</option>
<option value="size">按大小</option>
</select>
<div class="view-toggle" id="viewToggle" data-od-id="view-toggle">
<button data-view="list" class="on" aria-label="列表视图">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
</button>
<button data-view="grid" aria-label="网格视图">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
</button>
</div>
<button class="ghost-btn" id="selectAllBtn">全选</button>
<span class="result-count" id="resultCount">显示 90 / 90</span>
</div>
<div id="listContainer"></div>
</section>
</div>
<!-- Batch bar -->
<div class="batchbar" id="batchbar" data-od-id="batch-bar">
<span class="batch-count" id="batchCount">已选 0 项</span>
<button class="batch-btn" data-batch="start">
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="6 3 20 12 6 21 6 3"/></svg>
开始
</button>
<button class="batch-btn" data-batch="move">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
移动到…
</button>
<button class="batch-btn danger" data-batch="delete">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
删除
</button>
<button class="batch-btn" data-batch="clear">取消选择</button>
</div>
</main>
</div>
<!-- Slide-over -->
<div class="scrim" id="scrim"></div>
<aside class="slideover" id="slideover" data-od-id="advanced-panel" aria-label="高级选项">
<div class="so-head">
<span class="so-title">高级选项</span>
<button class="icon-btn neutral" id="closeAdvanced" style="margin-left:auto" aria-label="关闭">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="so-body">
<div class="so-group">
<div class="so-label">下载行为</div>
<div class="so-row">
<div>自动开始<div class="sub">添加后立即开始下载</div></div>
<button class="switch on" role="switch" aria-checked="true" aria-label="自动开始"></button>
</div>
<div class="so-row">
<div>严格播放列表<div class="sub">仅下载完全匹配的播放列表项</div></div>
<button class="switch" role="switch" aria-checked="false" aria-label="严格播放列表"></button>
</div>
<div class="so-row">
<div>播放列表上限<div class="sub">单次最多展开的条目数</div></div>
<input class="so-input num" type="number" value="10" min="1" max="500">
</div>
<div class="so-row">
<div>文件名前缀<div class="sub">可选,如频道名</div></div>
<input class="so-input" style="width:120px;text-align:left;font-family:var(--font-body)" type="text" placeholder="无前缀">
</div>
</div>
<div class="so-group">
<div class="so-label">批量操作</div>
<button class="so-btn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
导入 URL 列表
</button>
<button class="so-btn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
导出全部 URL
</button>
<button class="so-btn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
复制全部 URL
</button>
</div>
<div class="so-group">
<div class="so-label">Cookies</div>
<div class="cookie-saved">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
bilibili.com · 已保存
</div>
<button class="so-btn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1"/></svg>
粘贴新的 Cookie…
</button>
</div>
</div>
</aside>
<!-- Toasts -->
<div class="toasts" id="toasts" aria-live="polite"></div>
<script>
/* ================= Sample data ================= */
const THUMBS = [
'assets/thumb-city.jpg', 'assets/thumb-coding.jpg', 'assets/thumb-cooking.jpg',
'assets/thumb-desk.jpg', 'assets/thumb-mountains.jpg', 'assets/thumb-ocean.jpg',
'assets/thumb-travel.jpg', 'assets/thumb-waveform.jpg'
];
const ACTIVE = [
{ id: 'a1', title: '【4K】东京城市漫游 — 雨后的新宿与涩谷街头实录', thumb: THUMBS[0], dur: '24:16', pct: 62, speed: '9.3 MB/s', eta: '0:43', size: '1.8 GB' },
{ id: 'a2', title: '深水地平线:北大西洋冬季航行的真实海况记录', thumb: THUMBS[5], dur: '41:02', pct: 18, speed: '3.1 MB/s', eta: '4:12', size: '3.4 GB' }
];
const QUEUED = [
{ id: 'q1', title: '银翼杀手 2049 — 摄影与灯光美学全解析', folder: 'movies', quality: '1080p', fmt: 'MP4', size: '2.4 GB', added: '2 天前', thumb: THUMBS[0] },
{ id: 'q2', title: '阿尔卑斯山 solo 徒步 7 日:装备清单与路线复盘', folder: 'movies', quality: '最佳', fmt: 'MKV', size: '5.1 GB', added: '2 天前', thumb: THUMBS[4] },
{ id: 'q3', title: '东京胶囊旅馆体验 — 一个人的关西旅行 EP03', folder: 'shows', quality: '1080p', fmt: 'MP4', size: '1.2 GB', added: '2 天前', thumb: THUMBS[6] },
{ id: 'q4', title: 'Rust 异步运行时原理:从 Future 到 Executor', folder: 'shows', quality: '720p', fmt: 'MP4', size: '860 MB', added: '3 天前', thumb: THUMBS[1] },
{ id: 'q5', title: '家庭厨房:意式手工意面从零开始(附配方)', folder: 'shows', quality: '1080p', fmt: 'MP4', size: '1.5 GB', added: '3 天前', thumb: THUMBS[2] },
{ id: 'q6', title: 'Lo-fi Hip Hop Radio — 专注学习 3 小时合辑', folder: 'music', quality: '仅音频', fmt: 'MP3', size: '168 MB', added: '3 天前', thumb: THUMBS[7] },
{ id: 'q7', title: '桌面搭建 2025:极简工作台与理线方案', folder: 'shows', quality: '最佳', fmt: 'MP4', size: '2.0 GB', added: '4 天前', thumb: THUMBS[3] },
{ id: 'q8', title: '冰岛环岛自驾:一号公路全段路况与营地指南', folder: 'movies', quality: '1080p', fmt: 'MKV', size: '4.6 GB', added: '4 天前', thumb: THUMBS[5] },
{ id: 'q9', title: 'City Pop 名盘 20 选 — 昭和都市之夜歌单', folder: 'music', quality: '仅音频', fmt: 'M4A', size: '142 MB', added: '5 天前', thumb: THUMBS[7] }
];
const COMPLETED = [
{ id: 'c1', title: '挪威峡湾邮轮记:盖朗厄尔到弗洛姆', folder: 'movies', quality: '1080p', fmt: 'MP4', size: '3.2 GB', added: '今天 09:12', thumb: THUMBS[5], dur: '38:44' },
{ id: 'c2', title: 'Vue 3 源码共读:响应式系统完整实现', folder: 'shows', quality: '720p', fmt: 'MP4', size: '1.1 GB', added: '昨天 22:03', thumb: THUMBS[1], dur: '52:10' },
{ id: 'c3', title: '深夜食堂复刻:玉子烧与味噌汤的家常做法', folder: 'shows', quality: '1080p', fmt: 'MP4', size: '980 MB', added: '昨天 20:47', thumb: THUMBS[2], dur: '18:26' },
{ id: 'c4', title: '摩洛哥撒哈拉沙漠团:三天两夜全记录', folder: 'movies', quality: '最佳', fmt: 'MKV', size: '4.8 GB', added: '2 天前', thumb: THUMBS[6], dur: '45:31' },
{ id: 'c5', title: 'Efficient Desk Setup — Cable Management Guide', folder: 'shows', quality: '1080p', fmt: 'MP4', size: '1.4 GB', added: '3 天前', thumb: THUMBS[3], dur: '12:08' },
{ id: 'c6', title: 'Ambient Focus Mix — Rainy Night Coding', folder: 'music', quality: '仅音频', fmt: 'MP3', size: '204 MB', added: '3 天前', thumb: THUMBS[7], dur: '1:02:44' },
{ id: 'c7', title: '尼泊尔 ABC 大本营徒步:高反预防与季节选择', folder: 'movies', quality: '1080p', fmt: 'MP4', size: '3.9 GB', added: '4 天前', thumb: THUMBS[4], dur: '33:17' },
{ id: 'c8', title: '纽约曼哈顿天际线延时摄影合集(4K HDR)', folder: 'movies', quality: '最佳', fmt: 'MKV', size: '6.2 GB', added: '5 天前', thumb: THUMBS[0], dur: '26:55' }
];
const FOLDER_NAMES = { all: '全部', movies: 'movies', shows: 'shows', music: 'music', uncategorized: '未分类' };
const STATUS_NAMES = { downloading: '正在下载', queued: '排队中', completed: '已完成' };
/* ================= State ================= */
const saved = JSON.parse(localStorage.getItem('metube-ui') || '{}');
const state = {
status: saved.status || 'queued',
folder: saved.folder || 'all',
view: saved.view || 'list',
filter: '',
sort: 'date',
selected: new Set()
};
function persist() {
localStorage.setItem('metube-ui', JSON.stringify({ status: state.status, folder: state.folder, view: state.view }));
}
/* ================= Toasts ================= */
const toastsEl = document.getElementById('toasts');
function toast(msg, opts = {}) {
const el = document.createElement('div');
el.className = 'toast' + (opts.error ? ' err' : '');
const icon = opts.error
? '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--danger)" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>'
: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--accent-strong)" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
el.innerHTML = icon + '<span>' + msg + '</span>' + (opts.action ? '<button class="t-act">' + opts.action + '</button>' : '');
toastsEl.appendChild(el);
setTimeout(() => { el.style.opacity = '0'; el.style.transition = 'opacity .3s'; setTimeout(() => el.remove(), 320); }, opts.error ? 6000 : 3600);
}
/* ================= Active downloads ================= */
const activeList = document.getElementById('activeList');
function renderActive() {
activeList.innerHTML = ACTIVE.map(d => `
<div class="active-row" data-od-id="active-row-${d.id}">
<div class="thumb" style="aspect-ratio:16/9"><img src="${d.thumb}" alt="${d.title}" loading="lazy"><span class="dur num">${d.dur}</span></div>
<div>
<div class="active-title">${d.title}</div>
<div class="progress-track"><div class="progress-fill" data-id="${d.id}" style="width:${d.pct}%"></div></div>
<div class="active-stats">
<span><span class="num" data-pct="${d.id}">${d.pct}</span>% · ${d.size}</span>
<span class="num">${d.speed}</span>
<span>ETA <span class="num">${d.eta}</span></span>
</div>
</div>
<div class="row-actions">
<button class="icon-btn" data-cancel="${d.id}" title="取消下载" aria-label="取消下载">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
</div>`).join('');
}
renderActive();
/* Simulate live progress */
setInterval(() => {
ACTIVE.forEach(d => {
if (d.pct < 99) d.pct = Math.min(99, d.pct + (Math.random() * 1.4));
const fill = document.querySelector(`.progress-fill[data-id="${d.id}"]`);
const pct = document.querySelector(`[data-pct="${d.id}"]`);
if (fill) fill.style.width = d.pct + '%';
if (pct) pct.textContent = Math.floor(d.pct);
});
}, 1800);
activeList.addEventListener('click', e => {
const btn = e.target.closest('[data-cancel]');
if (!btn) return;
const item = ACTIVE.find(d => d.id === btn.dataset.cancel);
toast(`已取消「${item.title.slice(0, 22)}…」`, { error: true, action: '撤销' });
});
/* ================= List rendering ================= */
const listContainer = document.getElementById('listContainer');
const listTitle = document.getElementById('listTitle');
const listSubtitle = document.getElementById('listSubtitle');
const resultCount = document.getElementById('resultCount');
function currentItems() {
let items = state.status === 'completed' ? COMPLETED.slice()
: state.status === 'queued' ? QUEUED.slice() : [];
if (state.folder !== 'all') items = items.filter(i => i.folder === state.folder);
if (state.filter) items = items.filter(i => i.title.toLowerCase().includes(state.filter.toLowerCase()));
if (state.sort === 'title') items.sort((a, b) => a.title.localeCompare(b.title, 'zh'));
if (state.sort === 'size') items.sort((a, b) => parseFloat(b.size) - parseFloat(a.size));
return items;
}
function esc(s) { return s; }
function renderList() {
const items = currentItems();
const folderPart = state.folder === 'all' ? '全部文件夹' : FOLDER_NAMES[state.folder];
listTitle.textContent = STATUS_NAMES[state.status];
listSubtitle.textContent = folderPart + ' · ' + items.length + ' 项';
resultCount.textContent = `显示 ${items.length} / ${items.length}`;
if (state.status === 'downloading') {
listContainer.innerHTML = `
<div class="empty" data-od-id="empty-active">
<svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
<div class="empty-title">下载任务固定显示在上方</div>
<div class="empty-sub">进行中的 2 个任务已固定在顶部面板,这里不再重复列出</div>
</div>`;
return;
}
if (items.length === 0) {
listContainer.innerHTML = `
<div class="empty">
<svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<div class="empty-title">没有匹配的条目</div>
<div class="empty-sub">试试清除筛选条件,或切换到其他文件夹</div>
</div>`;
return;
}
if (state.view === 'grid' && state.status === 'completed') {
listContainer.innerHTML = `<div class="grid" data-od-id="completed-grid">` + items.map(i => `
<div class="card ${state.selected.has(i.id) ? 'selected' : ''}" data-od-id="card-${i.id}">
<input type="checkbox" class="card-check" data-select="${i.id}" ${state.selected.has(i.id) ? 'checked' : ''} aria-label="选择 ${i.title}">
<div class="thumb"><img src="${i.thumb}" alt="${i.title}" loading="lazy">${i.dur ? `<span class="dur num">${i.dur}</span>` : ''}</div>
<div class="card-body">
<div class="card-title">${i.title}</div>
<div class="card-meta">
<span class="badge folder">${i.folder}</span>
<span class="num">${i.size}</span>
<div class="card-actions">
<button class="icon-btn neutral" title="在文件夹中显示" aria-label="在文件夹中显示">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
</button>
<button class="icon-btn" title="删除" aria-label="删除" data-del="${i.id}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
</div>
</div>
</div>
</div>`).join('') + '</div>';
return;
}
listContainer.innerHTML = `<div class="queue-list" data-od-id="queue-list">` + items.map(i => `
<div class="q-row ${state.selected.has(i.id) ? 'selected' : ''}" data-od-id="row-${i.id}">
<input type="checkbox" class="q-check" data-select="${i.id}" ${state.selected.has(i.id) ? 'checked' : ''} aria-label="选择 ${i.title}">
<div class="thumb" style="aspect-ratio:16/9"><img src="${i.thumb}" alt="${i.title}" loading="lazy"></div>
<div>
<div class="q-title">${i.title}</div>
<div class="q-meta">
<span class="badge folder">${i.folder}</span>
<span class="badge fmt">${i.quality} · ${i.fmt}</span>
<span>${i.added} 添加</span>
</div>
</div>
<div class="q-side"><span class="num">${i.size}</span></div>
<div class="q-actions">
${state.status === 'queued' ? `<button class="icon-btn neutral" title="立即开始" aria-label="立即开始" data-start="${i.id}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="6 3 20 12 6 21 6 3"/></svg>
</button>` : ''}
<button class="icon-btn" title="删除" aria-label="删除" data-del="${i.id}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
</div>
</div>`).join('') + '</div>';
}
/* ================= Batch bar ================= */
const batchbar = document.getElementById('batchbar');
const batchCount = document.getElementById('batchCount');
function updateBatchbar() {
const n = state.selected.size;
batchbar.classList.toggle('show', n > 0);
batchCount.textContent = `已选 ${n}`;
}
listContainer.addEventListener('change', e => {
const cb = e.target.closest('[data-select]');
if (!cb) return;
cb.checked ? state.selected.add(cb.dataset.select) : state.selected.delete(cb.dataset.select);
const row = cb.closest('.q-row, .card');
if (row) row.classList.toggle('selected', cb.checked);
updateBatchbar();
});
listContainer.addEventListener('click', e => {
const start = e.target.closest('[data-start]');
const del = e.target.closest('[data-del]');
if (start) toast('已开始下载该任务');
if (del) toast('已删除 1 项', { error: true, action: '撤销' });
});
batchbar.addEventListener('click', e => {
const btn = e.target.closest('[data-batch]');
if (!btn) return;
const n = state.selected.size;
const act = btn.dataset.batch;
if (act === 'clear') {
state.selected.clear();
renderList();
} else if (act === 'start') {
toast(`已开始 ${n} 个任务`);
state.selected.clear(); renderList();
} else if (act === 'delete') {
toast(`已删除 ${n}`, { error: true, action: '撤销' });
state.selected.clear(); renderList();
} else if (act === 'move') {
toast(`已将 ${n} 项移动到 movies`);
state.selected.clear(); renderList();
}
updateBatchbar();
});
document.getElementById('selectAllBtn').addEventListener('click', () => {
const items = currentItems();
const allSelected = items.every(i => state.selected.has(i.id));
state.selected.clear();
if (!allSelected) items.forEach(i => state.selected.add(i.id));
renderList();
updateBatchbar();
});
/* ================= Navigation ================= */
function bindNav(selector, key) {
document.querySelectorAll(selector).forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll(selector).forEach(b => b.classList.remove('active'));
btn.classList.add('active');
state[key] = btn.dataset.status || btn.dataset.folder;
state.selected.clear();
persist();
renderList();
updateBatchbar();
document.getElementById('sidebar').classList.remove('open');
});
});
}
bindNav('[data-status]', 'status');
bindNav('[data-folder]', 'folder');
/* Restore persisted nav state */
document.querySelectorAll('[data-status]').forEach(b => b.classList.toggle('active', b.dataset.status === state.status));
document.querySelectorAll('[data-folder]').forEach(b => b.classList.toggle('active', b.dataset.folder === state.folder));
/* Filter & sort */
document.getElementById('filterInput').addEventListener('input', e => {
state.filter = e.target.value.trim();
renderList();
});
document.getElementById('sortSelect').addEventListener('change', e => {
state.sort = e.target.value;
renderList();
});
/* View toggle */
document.getElementById('viewToggle').addEventListener('click', e => {
const btn = e.target.closest('[data-view]');
if (!btn) return;
document.querySelectorAll('#viewToggle button').forEach(b => b.classList.remove('on'));
btn.classList.add('on');
state.view = btn.dataset.view;
persist();
renderList();
});
document.querySelectorAll('#viewToggle button').forEach(b => b.classList.toggle('on', b.dataset.view === state.view));
/* Quality / format chips */
document.querySelectorAll('.cb-chips').forEach(group => {
group.addEventListener('click', e => {
const chip = e.target.closest('.chip');
if (!chip) return;
const attr = chip.dataset.quality ? 'quality' : 'format';
document.querySelectorAll(`.chip[data-${attr}]`).forEach(c => c.classList.remove('on'));
chip.classList.add('on');
});
});
/* Folder picker (cycles as a demo) */
const folderPick = document.getElementById('folderPick');
const folderPickLabel = document.getElementById('folderPickLabel');
const pickCycle = ['movies', 'shows', 'music', '未分类'];
folderPick.addEventListener('click', () => {
const i = pickCycle.indexOf(folderPickLabel.textContent);
folderPickLabel.textContent = pickCycle[(i + 1) % pickCycle.length];
toast('下载目录已切换为 ' + folderPickLabel.textContent);
});
/* Add */
document.getElementById('addBtn').addEventListener('click', () => {
const input = document.getElementById('urlInput');
const url = input.value.trim();
if (!url) {
toast('请先粘贴一个视频 URL', { error: true });
input.focus();
return;
}
if (!/^https?:\/\//.test(url)) {
toast('URL 格式不正确,请检查后重试', { error: true });
input.focus();
return;
}
input.value = '';
toast('已解析并加入队列', { action: '查看' });
});
document.getElementById('urlInput').addEventListener('keydown', e => {
if (e.key === 'Enter') document.getElementById('addBtn').click();
});
/* Slide-over */
const scrim = document.getElementById('scrim');
const slideover = document.getElementById('slideover');
function openSlideover() { scrim.classList.add('show'); slideover.classList.add('show'); }
function closeSlideover() { scrim.classList.remove('show'); slideover.classList.remove('show'); }
document.getElementById('openAdvanced').addEventListener('click', openSlideover);
document.getElementById('openAdvanced2').addEventListener('click', openSlideover);
document.getElementById('closeAdvanced').addEventListener('click', closeSlideover);
scrim.addEventListener('click', closeSlideover);
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeSlideover(); });
document.querySelectorAll('.switch').forEach(sw => {
sw.addEventListener('click', () => {
sw.classList.toggle('on');
sw.setAttribute('aria-checked', sw.classList.contains('on'));
});
});
document.getElementById('activityBtn').addEventListener('click', () => {
toast('最近事件:precheck 完成 · 3 个任务待开始');
});
/* Mobile menu */
document.getElementById('menuBtn').addEventListener('click', () => {
document.getElementById('sidebar').classList.toggle('open');
});
/* Initial render */
renderList();
updateBatchbar();
</script>
</body>
</html>