feat: 全新界面改版,新增缩略图、侧边栏导航与 Toast 反馈

This commit is contained in:
tigerenwork 2026-08-11 01:46:10 +08:00
parent 1c9cfd84bf
commit 33424409d5
53 changed files with 5632 additions and 2373 deletions

335
UI_IMPLEMENTATION_PLAN.md Normal file
View File

@ -0,0 +1,335 @@
# MeTube UI Redesign — Implementation Plan
Date: 2026-08-11
Status: Plan (ready to implement)
Design source: [`design/metube-redesign.html`](design/metube-redesign.html) (this plan supersedes §6 of [`UI_REDESIGN.md`](UI_REDESIGN.md))
> **Implementation log (2026-08-11):** Phases 15 are implemented and
> verified. Deltas from this plan: the completed **grid** uses the accepted
> fallback (§Phase 3 item 4) — plain CSS grid with `loading="lazy"` images
> rather than chunked row virtualization; the pre-existing broken `test`/
> karma setup and the tslint/codelyzer/protractor scaffolding were removed in
> Phase 5; the UI was verified headless (desktop + 375px mobile) against a
> live backend with seeded queue/completed data, including the `/thumbnail`
> endpoint.
## 0. What the mockup improves over the original proposal
`metube-redesign.html` implements the spirit of `UI_REDESIGN.md` but resolves
several open questions and simplifies the riskiest parts:
1. **Folder grouping moved from in-list headers to a sidebar nav.** The doc's
open question ("per-folder navigation") is answered: folders are a
first-class sidebar section with counts, and the list itself is flat. This
also removes the doc's biggest technical risk — flattening collapsible
group headers into a `cdk-virtual-scroll-viewport` — entirely.
2. **A stats strip** (active count, total speed, queue count, library size)
replaces the navbar counters.
3. **Active downloads become a pinned thumbnail panel** with progress, speed,
ETA and cancel, instead of the top tab of a table.
4. **Completed items get a media-library grid** with a list/grid toggle,
duration badges and hover actions.
5. **A floating batch bar** (Start / Move / Delete / clear) replaces the
always-visible disabled toolbar buttons.
6. **Errors and feedback move to toasts** with optional action buttons.
7. **Empty states** for every list, and a single-row command bar (URL input +
quality/format chips + folder picker) instead of the full form.
8. **Mobile**: the sidebar becomes a drawer and everything reflows to
single-column — no horizontal table scroll.
## 1. Constraints & assumptions
- **Stay on the current stack**: Angular 19 + Bootstrap 5.3 + ng-bootstrap +
ng-select + FontAwesome + PWA. No framework rewrite (same rationale as the
design doc).
- **Labels are Chinese** per the mockup (`lang="zh-CN"`). The current app is
English; the mockup is the source of truth, and this matches the fork's
usage. All strings are inline template text so switching language later is a
mechanical pass.
- **No new features beyond the design** — no playlist monitor, no new download
options. The one additive backend change is the thumbnail endpoint (§5).
- **Mockup data vs real data deltas** (implement from real data, not the
mockup):
- `MKV` format chip: the backend's `Formats` list has no `mkv`; chips are
derived from the real list (`any`, `mp4`, `m4a`, `mp3`, `opus`, `wav`,
`flac`, `thumbnail`).
- Queued rows show size in the mockup, but size is unknown until download
starts; render `—` until `size` is available.
- "Reveal in folder" has no backend endpoint; map it to opening the download
URL (skip the icon until a backend reveal exists).
- Folder nav counts are computed from real maps, not the mockup's numbers.
- **Theme**: keep the existing light/dark/auto Bootstrap mechanism; the
mockup's palette (green accent, oklch) is implemented as CSS custom
properties with dark-mode overrides (§3).
- **Fonts**: the mockup loads JetBrains Mono from Google Fonts. For a
self-hosted LAN app, do **not** depend on an external font CDN; use the
system mono fallback stack from the mockup and optionally bundle
`@fontsource/jetbrains-mono` in Phase 5.
## 2. Target component structure
Break the 744-line `app.component.ts` / 595-line template monolith into:
```
AppComponent app shell: sidebar + command bar + stats +
content + toasts + slide-over; owns nav state,
metrics, versions, theme
├── SidebarComponent brand/version, status nav (counts), folder
│ nav (counts), footer (advanced, activity,
│ theme toggle)
├── CommandBarComponent URL input + Add, quality/format chips,
│ folder picker (ng-select), advanced link
├── StatsStripComponent active count · total speed · queue · storage
├── ActiveDownloadsComponent pinned panel: rows with thumb, progress,
│ speed/ETA, cancel
├── DownloadListComponent reusable queued/completed list: filter, sort,
│ ├── DownloadRowComponent queue-style row (checkbox, thumb, meta, size,
│ │ hover actions)
│ └── DownloadCardComponent completed grid card (checkbox, thumb,
│ duration, title, folder, size, actions)
├── AdvancedOptionsComponent slide-over: download behavior, batch ops,
│ cookies
├── ActivityDrawerComponent recent events (Phase 5)
└── ToastsComponent + service replaces alert()
```
Shared services:
- `preferences.service.ts` — thin wrapper over `ngx-cookie-service` for
`metube_quality`, `metube_format`, `metube_auto_start`, `metube_sort_order`,
`metube_active_tab`, `metube_theme`, plus new `metube_status`,
`metube_folder`, `metube_view` keys.
- `toast.service.ts``show(message, {error?, actionLabel?, onAction?})`,
auto-dismiss, rendered by `ToastsComponent`.
- `downloads.service.ts` — kept **as-is** except: add optional
`duration?: number` / `thumbnail?: string` to the `Download` interface
(backend §5) and add `moveById(ids, folder)` convenience that calls the
existing `POST /set_folder` with an id list.
The triplicated table markup (queue/pending/done) collapses into two
`DownloadListComponent` instances.
## 3. Design tokens & styling
New `ui/src/styles/_tokens.sass` (imported from `styles.sass`), defining the
mockup's oklch palette as CSS custom properties, with dark overrides under
`[data-bs-theme="dark"]`:
| Token | Light | Dark |
|---|---|---|
| `--bg` | `oklch(98% 0.005 250)` | `oklch(16% 0.012 250)` |
| `--surface` | `oklch(100% 0 0)` | `oklch(20% 0.014 250)` |
| `--fg` | `oklch(22% 0.02 240)` | `oklch(92% 0.01 240)` |
| `--muted` | `oklch(50% 0.018 240)` | `oklch(68% 0.012 240)` |
| `--border` | `oklch(90% 0.008 240)` | `oklch(30% 0.014 250)` |
| `--accent` | `oklch(58% 0.16 145)` | `oklch(66% 0.15 145)` |
| `--accent-strong` | `oklch(48% 0.14 145)` | `oklch(74% 0.13 145)` |
| `--accent-tint` | `oklch(95% 0.03 145)` | `oklch(26% 0.05 145)` |
| `--warn` / `--danger` | `oklch(70% 0.15 75)` / `oklch(55% 0.19 25)` | same, lightened |
Plus `--font-mono` (JetBrains Mono → system fallback), `--radius: 8px`,
`--sidebar-w: 236px`. Global styles: `body` background/color/font, `.num`
tabular-nums utility, focus-visible accent outline.
Keep Bootstrap loaded (the app shell, ng-select, modals still use it), but the
redesigned surfaces use the tokens via component Sass. The legacy navbar /
`styles.sass` overrides are deleted once the shell lands.
## 4. Phases
Each phase ends with `cd ui && npm run build` green and the manual checklist
in §7 passing for the touched areas. Phases 13 deliver the bulk of the UX
gain; Phase 4 is separable if backend changes need to wait.
### Phase 1 — Foundations & component split (no visual change)
Goal: establish structure and services before touching the design, so
regression risk is isolated.
1. `npm install @angular/cdk@^19` (needed for virtualization in Phase 3).
2. Add `_tokens.sass`; wire into `styles.sass` (no visual change yet).
3. Add `preferences.service.ts` (migrate existing cookie reads/writes) and
`toast.service.ts` + `ToastsComponent`.
4. Extract `CommandBarComponent`, `AdvancedOptionsComponent`,
`ActiveDownloadsComponent`, `DownloadListComponent` + row/card components
from the monolith, keeping the current tables and behavior identical.
5. Register everything in `app.module.ts`; delete now-unused `@ViewChild`
native-element manipulation and triplicated markup as extraction proceeds.
Acceptance: UI looks/behaves exactly as before; `npm run build` passes.
### Phase 2 — App shell & navigation
Goal: the mockup's frame — sidebar, stats strip, command bar, slide-over,
pinned active panel — over the still-table-based lists.
1. **Sidebar**: brand + version (from existing `/version` fetch), status nav
(正在下载 / 排队中 / 已完成 with live count badges), folder nav (全部 +
each distinct `folder` value with counts, 未分类 for `''`), footer with
高级选项, 日志, and a theme toggle (preserves existing light/dark/auto).
Replace the Bootstrap tabs; persist `metube_status` / `metube_folder`.
2. **Stats strip**: active count, total speed (`totalSpeed | speed`), queue
count, library size (sum of `size` over `done`, `FileSizePipe`).
3. **Command bar**: URL input + 添加到队列, quality chips and format chips
derived from the real `Formats`/qualities (render as chips; if more than
~4, a "more" popover — keep current `setQualities()` behavior), folder
picker (ng-select with tag creation), 高级选项 link.
4. **AdvancedOptionsComponent slide-over** with scrim, Escape/click-close:
auto-start, strict playlist, playlist limit, name prefix, import/export/
copy URLs, cookies (existing `POST /cookie`; show saved cookie domain from
configuration if available). Wire batch import modal into the slide-over.
5. **ActiveDownloadsComponent** pinned above the list: rows with 16:9
thumbnail placeholder, title, progress bar, pct/size/speed/ETA, cancel.
Until Phase 4, thumbnails show the placeholder block.
6. **Events**: keep the existing inline events box under the new shell (or
move to a toasts-driven minimal version); full drawer is Phase 5.
Acceptance: all add/start/cancel/delete/folder/cookie/import-export flows work
from the new shell; tab cookie `metube_active_tab` still respected (maps
queue→downloading, pending→queued, done→completed).
### Phase 3 — List redesign
Goal: replace tables with the mockup's lists — filter, sort, multi-select,
batch bar, grid/list toggle, empty states, virtualization.
1. **DownloadListComponent** for queued and completed:
- Toolbar: filter input (title substring), sort select (date / title /
size) + direction toggle (preserve existing asc/desc cookie), view toggle
(list/grid; grid shown only for completed), 全选, result count.
- Flat items only — folder filtering comes from the sidebar nav, so no
group-header flattening is needed.
- Selection state: a `Set<string>` of ids owned by the list component,
cleared on nav change. Batch actions emit id lists up to `AppComponent`,
which calls `startById` / `delById` / `setFolder` directly (removes the
current `dl.checked` mutation and `MasterCheckbox` machinery).
- Batch bar: floating bottom bar (mockup styling) — queued: 开始 / 移动到…
/ 删除 / 取消选择; completed: 移动到… / 删除 / 取消选择 (start N/A).
Completed toolbar keeps 清除已完成 / 清除失败 / 重试失败 / 下载所选 as
ghost buttons.
- Empty states per mockup (正在下载 fixed-on-top hint; 没有匹配的条目 for
filter misses; plus a true empty-state for an empty queue/library).
- Skeleton rows while `downloads.loading` (first connect replaces
"Connecting to server...").
2. **DownloadRowComponent** (queued): checkbox, 88px thumb, title, folder +
quality·format badges, added time, size (`—` when unknown), hover actions:
start (pending only), cancel/delete, external link, folder edit (ng-select
with tag creation → `POST /set_folder`).
3. **DownloadCardComponent** (completed): hover checkbox, 16:9 thumb with
duration badge, 2-line title, folder badge, size, actions: download file,
external link, folder edit, delete, retry (error items). Grid uses
`repeat(auto-fill, minmax(218px, 1fr))` per mockup.
4. **Virtualization** (queued list + completed grid):
- Queued: `cdk-virtual-scroll-viewport` with fixed row height over a flat
array.
- Completed grid: chunk items into fixed-height rows of N columns (N from a
`ResizeObserver` on the container) and virtualize rows. Fallback if
chunking proves fiddly: non-virtualized grid with `loading="lazy"` images
(303 cards is acceptable; re-evaluate if scrolling degrades).
- `trackBy` on item id.
Acceptance: smooth filter/sort/scroll at 230 queued + 303 completed; batch
select/start/cancel/move/delete correct; folder nav filters; selection resets
on nav change; all previous row actions (folder edit, retry, download file)
still work.
### Phase 4 — Thumbnails (backend additive)
Goal: thumbnail-first rows/cards without hot-linking remote images.
Backend (`app/ytdl.py`, `app/main.py` — no queue/precheck/persistence changes):
1. `DownloadInfo` gains `self.thumbnail` and `self.duration` extracted from the
yt-dlp `entry` (`entry.get('thumbnail') or thumbnails[-1]`, `entry.get(
'duration')`) — these already persist through the existing shelve files, so
completed items survive restarts.
2. New route `GET /thumbnail?id=<download_id>`:
- Look up the id across queue/pending/done shelves, take the stored
`thumbnail` URL (never an arbitrary client-supplied URL).
- Fetch with `aiohttp.ClientSession`, cache bytes under
`STATE_DIR/thumbnails/<id>.<ext>` with long-lived `Cache-Control`.
- 404/placeholder SVG when no thumbnail exists (audio-only downloads).
3. Optionally, trim the socket payload: exclude the huge `entry` dict from
serialized `DownloadInfo` now that only `thumbnail`/`duration` are needed
client-side (keeps the existing `all` event contract otherwise intact).
Frontend:
1. `Download` interface gains `duration?: number`, `thumbnail?: string`.
2. `thumbnailUrl(id)` helper → `/thumbnail?id=…`; `<img loading="lazy">` in
rows, cards and the active panel; placeholder block while missing/loading.
3. Duration formatting pipe (`mm:ss` / `h:mm:ss`) for the badge.
Acceptance: thumbnails render for queued/active/completed after a cold reload;
no third-party image requests leave the host; audio items show placeholder.
### Phase 5 — Polish & cleanup
1. Dark theme pass over every surface (verify both palettes at 375/768/1280px).
2. Mobile: sidebar drawer with hamburger (mockup behavior), command bar
wraps, batch bar safe-area spacing, slide-over becomes a bottom sheet at
narrow widths.
3. Activity drawer (`ActivityDrawerComponent`) for recent events with clear
button, fed by existing `/events` + `eventReceived`.
4. Optionally bundle JetBrains Mono via `@fontsource/jetbrains-mono`.
5. Dead tooling cleanup: remove `tslint.json`/codelyzer/`ng lint` script and
the protractor e2e scaffolding, per the doc's §6.4 phase 5.
6. Update `app.component.spec.ts` (currently asserts on the removed template)
or delete it.
Acceptance: full manual QA (§7); PWA build works; no console errors.
## 5. Backend change summary (Phase 4 only)
| File | Change |
|---|---|
| `app/ytdl.py` | `DownloadInfo.__init__`: set `thumbnail`, `duration` from `entry` |
| `app/main.py` | add `GET /thumbnail?id=` route + cache dir; optional serialization trim |
Everything else in the backend (queue, precheck, conflict resolution,
persistence, socket events, cookie handling) is untouched.
## 6. Out of scope
- Framework/stack change; backend queue/persistence rework.
- Playlist monitor (separate planned effort).
- Reveal-in-folder OS action (no backend support).
- New download options; localization framework.
- Publishing: when the redesign is merged, publish via `./publish.sh` per
`AGENTS.md` (bumps the version in `DEPLOY.md` in the same commit).
## 7. Manual test checklist
Run through after Phase 3 (core) and again after Phase 5 (full):
- Add single URL and playlist URL; Enter key adds; invalid/empty URL toasts.
- Quality/format chips persist across reload; folder picker persists; tag
creation in picker works.
- Cancel active download; cancel/delete queued; delete completed; retry
failed; clear completed/failed; download selected files.
- Batch: select 1 and many; floating bar actions on queued vs completed;
move-to-folder batch moves files on disk (`POST /set_folder`).
- Import URLs (with cancel), export, copy; cookie paste flow.
- Filter by title; sort by date/title/size both directions; view toggle;
select-all respects filter; counts in sidebar/stat strip match maps.
- 230+ queued / 300+ completed: scrolling stays smooth, selection is
responsive, thumbnails lazy-load, no layout jump while filtering.
- Theme light/dark/auto persists and all surfaces are legible; PWA offline
shell loads.
- Mobile 375px and 768px: drawer navigation, no horizontal scroll, batch bar
reachable, slide-over usable.
## 8. Effort estimate
| Phase | Estimate |
|---|---|
| 1. Foundations & split | 0.51 day |
| 2. App shell & navigation | 1 day |
| 3. List redesign | 11.5 days |
| 4. Thumbnails (front + back) | 0.5 day |
| 5. Polish & cleanup | 0.5 day |
| **Total** | **~3.54.5 days** |
Phases 13 are the bulk of the UX gain; the mockup's folder sidebar removes
the doc's main virtualization risk, so the list phase is more contained than
originally estimated.

323
UI_REDESIGN.md Normal file
View File

@ -0,0 +1,323 @@
# MeTube UI Redesign — Design Doc
Date: 2026-08-09
Status: Proposal (not yet implemented)
## 1. Background
MeTube (this fork) is a self-hosted web GUI for yt-dlp: Python 3.13 backend
(`aiohttp` + `python-socketio`, ~2,000 LOC) and an Angular 19 frontend
(Bootstrap 5.3 + ng-bootstrap, ~2,400 LOC, PWA).
The frontend is a single-screen app built around one monolithic component:
- `ui/src/app/app.component.ts` — 744 LOC
- `ui/src/app/app.component.html` — 595 LOC, with the same table markup
triplicated across three tabs
- `ui/src/app/app.component.sass` — 341 LOC
- Supporting: `downloads.service.ts` (socket/HTTP state), 2 checkbox
components, 4 pipes, theme/format helpers
The UI is stock Bootstrap: three tabs (Downloading / Pending / Completed),
each a 7-column `<table>`; an inline add-form; a collapsible "Advanced
Options" card; `alert()` for errors; cookie-persisted preferences.
## 2. Problems with the current UI
1. **Table-based lists**: 7 text columns, horizontal `overflow-auto` scroll on
mobile, no thumbnails — recognizing "which video is this" from text is slow.
2. **No design language**: default Bootstrap chrome, no empty states, no
skeleton loaders, "Connecting to server..." as the loading experience.
3. **Errors via `alert()`**: blocking, non-actionable, disappears.
4. **Monolithic component**: imperative `@ViewChild(...).nativeElement.disabled`
manipulation (7+ instances), triplicated template markup, dead tooling
(tslint/codelyzer/protractor). Every change is slower and riskier than it
should be.
5. **List tooling doesn't match real data volumes**: the primary user runs
~230 queued / ~300 completed items. Plain scrolling tables are unusable at
that scale — no filter, no sort, weak batch selection, no virtualization.
## 3. Decision: redesign in place, do NOT rewrite the stack
Evaluated and rejected: rewriting the frontend in Next.js (or Vite + React),
and rewriting the backend in Node.
Rationale:
- ~90% of the user-visible benefit comes from the **design**, not the
framework. Cards, thumbnails, filter/sort, batch actions, virtualization
are all achievable in the existing Angular + Bootstrap stack (Angular CDK
has virtual scrolling; Bootstrap renders cards fine).
- The rewrite's only unique benefit is code health / developer velocity,
which doesn't pay off for what is largely a one-off redesign.
- Regression risk: cookie-persisted prefs, folder tag-creation, socket
string-parsing quirks, PWA/service-worker setup would all need to be
rediscovered and re-tested.
- Next.js specifically is the wrong shape for this app: a LAN SPA with no
SEO/SSR needs. If a rewrite ever happens, Vite + React is the right-sized
tool — but it is not worth doing now.
- The backend must stay Python regardless: yt-dlp is Python-native, and the
subtle, battle-tested logic (precheck worker, filename-conflict resolution,
multiprocess lifecycle, playlist expansion, cookie wiring) lives in
`app/ytdl.py`. A Node port is 1.53 weeks of risk for zero user-visible gain.
If the frontend keeps needing substantial work after this redesign and Angular
becomes a friction point, revisit a rewrite then — with a proven design to
port, it becomes a mechanical ~2-day job.
## 4. Goals / Non-goals
Goals:
- Modern card-based, thumbnail-first UI for all three lists.
- Handle 200500-item lists comfortably: filter, sort, folder grouping,
multi-select batch actions, virtualized rendering.
- Keep "now downloading" always visible.
- Replace `alert()` with toasts; add empty states and skeleton loading.
- Mobile-friendly (cards stack; no horizontal table scroll).
- Refactor the monolith into components as part of the work (only as much as
the redesign requires — no gratuitous re-architecture).
Non-goals:
- No framework/stack change (stays Angular 19 + Bootstrap 5.3).
- No backend queue/precheck/persistence changes, except the additive
thumbnail endpoint (§6.3).
- No new features beyond what the design requires (no new download options,
no playlist monitor — that's a separate planned effort).
## 5. Proposed design
### 5.1 Layout (desktop)
```
┌──────────────────────────────────────────────────────────────────────────┐
│ ▶ MeTube ⬇ 2 active · 12.4 MB/s 🌙 ⚙ │
├──────────────────────────────────────────────────────────────────────────┤
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ 🔗 Paste video / playlist URL… [Add] │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ [Best][1080p][720p] [MP4▾][MP3][M4A] 📁 movies ⋯ Advanced │
├──────────────────────────────────────────────────────────────────────────┤
│ ── NOW DOWNLOADING (2) ───────────────────────────── 12.4 MB/s total ── │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ [thumb] Title ███████████████░░░░░ 62% 12.4MB/s ETA 0:43 ✕ │ │
│ │ [thumb] Title ████░░░░░░░░░░░░░░░ 18% 3.1MB/s ETA 4:12 ✕ │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
├──────────────────────────────────────────────────────────────────────────┤
│ [ Queued 230 ] [ Completed 303 ] 📁 All folders ▾ ▦ | ☰ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ ☑ Select 🔍 Filter titles… Sort: Date added ▾ Group: 📁 │ │
│ ├───────────────────────────────────────────────────────────────────┤ │
│ │ ▸ 📁 shows (86) ☐ all │ │
│ │ ▸ 📁 music (54) ☐ all │ │
│ │ ▾ 📁 movies (90) ☐ all │ │
│ │ ☐ [thumb] Movie title one 720p · queued 2d ago ✕ │ │
│ │ ☑ [thumb] Movie title two 720p · queued 2d ago ✕ │ │
│ │ ☐ [thumb] Movie title three 720p · queued 3d ago ✕ │ │
│ │ …(virtualized scroll)… │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ 2 selected ── [ ▶ Start ] [ ✕ Cancel ] [ 📁 Move to… ] │
└──────────────────────────────────────────────────────────────────────────┘
```
### 5.2 Advanced options slide-over
Replaces the inline collapsible card. Rarely-changed controls move here;
cookie-persisted defaults keep the main bar to one row.
```
┌────────────────────────────┐
│ Advanced options ✕ │
│ │
│ Auto-start [ toggle ]│
│ Playlist strict [ toggle ]│
│ Playlist limit [ 10 ] │
│ │
│ ── Batch ────────────── │
│ [Import URLs] [Export] │
│ [Copy all URLs] │
│ │
│ ── Cookies ──────────── │
│ domain.com ✓ saved │
│ [Paste cookie…] │
└────────────────────────────┘
```
### 5.3 Mobile
Cards stack vertically; the active panel, tabs, filter bar, and list reflow to
single-column. No horizontal scrolling anywhere. The ⋮ menu and slide-over
become bottom sheets.
```
┌─────────────────────────┐
│ ▶ MeTube 🌙 ⚙ │
│ ⬇2 · 12MB/s │
├─────────────────────────┤
│ ┌─────────────────────┐ │
│ │🔗 Paste URL… [Add]│ │
│ └─────────────────────┘ │
│ [Best][1080p][720p] │
│ 📁 movies ⋯ More │
│ │
│ NOW DOWNLOADING (2) │
│ ┌─────────────────────┐ │
│ │[thumb] │ │
│ │ Title goes here │ │
│ │ ████████░░░ 62% │ │
│ │ 12MB/s · ETA 0:43 ✕ │ │
│ └─────────────────────┘ │
│ │
│ [Queued 230][Done 303] │
│ ┌────────┐ ┌────────┐ │
│ │ thumb │ │ thumb │ │
│ │ title… │ │ title… │ │
│ │ 384MB ⋮│ │ 1.2GB ⋮│ │
│ └────────┘ └────────┘ │
└─────────────────────────┘
```
### 5.4 Key design decisions and rationale
1. **Tabs for the archives, pinned panel for active downloads.**
Stacked sections (all three lists on one scrolling page) were considered
and rejected: at 230/303 items the lists never fit on a screen, so the
"pipeline at a glance" argument fails. Tabs hide state, so each tab carries
a count badge. The active-downloads panel stays pinned above the tabs
because its size is bounded by the concurrency limit (~3) — it costs
nothing and keeps live progress always visible.
2. **Tables → thumbnail-first cards/rows.**
Recognition-by-image is far faster than recognition-by-text; it also
eliminates the 7-column mobile squeeze. Completed items get a grid/list
toggle (▦|☰) — the completed tab is a media library (browse, play, move,
delete), not an audit log.
3. **Command bar instead of a form.**
Usage is 95% two actions: paste URL, watch progress. Quality/format become
one-tap chips with cookie-persisted defaults; folder picker stays; prefix
and everything else moves to the slide-over.
4. **List tooling matched to volume** (the main volume-driven change):
- Per-tab filter box (title substring), sort (date added, title, size,
folder), and folder grouping.
- Folder groups are collapsible with per-group select-all.
- Multi-select with a batch action bar (start / cancel / delete / move to
folder). With 230 queued items the real operations are plural.
- Virtualized scrolling (Angular CDK `cdk-virtual-scroll-viewport`) — render
~30 rows instead of 533 DOM subtrees. Concrete perf win over the current
tables.
5. **Toasts replace `alert()`** — non-blocking, can carry actions (e.g.
"Duplicate skipped — view item"). The recent-events panel becomes a
collapsible activity drawer (diagnostic info: available, not permanently
occupying screen space).
6. **Theme**: keep the existing dark/light/auto Bootstrap `data-bs-theme`
mechanism; refine palette to one accent color. Header gets slim stat chips
(active count, total speed) replacing the current navbar counters.
### 5.5 Open question: per-folder navigation
If queue work is frequently per-folder ("start all the music ones"), folder
groups may deserve first-class status — e.g. a qBittorrent-style left sidebar
of folders with counts instead of group headers inside the list. Decide based
on actual usage before implementation; default is the group-header design
above (simpler, keeps single-column layout).
## 6. Implementation plan
### 6.1 Component refactor (Angular, in place)
Break the monolith into:
- `CommandBarComponent` — URL input, quality/format chips, folder picker,
slide-over trigger. Owns the add-form state and cookie-persisted defaults.
- `AdvancedOptionsComponent` — slide-over (auto-start, playlist options,
import/export/copy, cookie paste).
- `ActiveDownloadsComponent` — pinned now-downloading panel (rows + total
speed).
- `DownloadListComponent` — reusable list for Queued and Completed tabs:
filter, sort, folder grouping, multi-select, batch action bar,
`cdk-virtual-scroll-viewport`. Grid/list toggle applies to Completed.
- `DownloadRowComponent` — thumbnail, title, progress/status line, folder
badge, actions (cancel / start / move / delete / reveal).
- `ToastsComponent` / toast service — replaces `alert()`.
- Keep: `downloads.service.ts` (socket + HTTP state) largely as-is;
`downloads.pipe.ts` (eta/speed/fileSize); theme helpers. The triplicated
tab markup collapses into two instances of `DownloadListComponent`.
### 6.2 Frontend behaviors to preserve exactly
- Cookie-persisted prefs: quality, format, theme, active tab, sort, grouping.
- Folder picker with tag-creation (currently ng-select) and per-row folder
editing (calls `POST /set_folder`; moves completed files on disk).
- Socket.IO event handling: events arrive as JSON *strings* and are parsed
client-side; full state re-sync on connect via the `all` event.
- PWA/service-worker setup (`custom-service-worker.js`, `ngsw-config.json`).
- Import/Export/Copy URLs batch modal.
- Existing HTTP API usage: `POST /add`, `POST /delete`, `POST /start`,
`POST /set_folder`, `POST /cookie`, `GET /history`, `GET|POST /events`,
`GET /version`.
### 6.3 Backend addition: thumbnail endpoint (additive only)
Cards need thumbnails. yt-dlp metadata already contains `thumbnail` URLs, but
hot-linking remote thumbnails from a LAN app leaks requests to third-party
hosts and breaks for expired URLs. Add one endpoint:
- `GET /thumbnail?id=<download_id>` — backend fetches/caches the thumbnail
(cache under `STATE_DIR/thumbnails/`, keyed by download id), returns the
image with long-lived cache headers. Fall back to a placeholder icon when
no thumbnail exists (e.g. audio-only downloads).
No other backend changes. Queue, precheck, conflict resolution, persistence,
socket events are untouched.
### 6.4 Phasing
1. **Phase 1 — component split** (no visual change): extract components from
the monolith, keep the tables. Establishes the structure the redesign
builds on; low regression risk, easily reviewable.
2. **Phase 2 — list redesign**: `DownloadListComponent` with cards, filter,
sort, folder groups, multi-select, virtualization; pinned active panel;
tabs with count badges.
3. **Phase 3 — command bar + slide-over**: new add flow, toasts, activity
drawer, empty states, skeletons.
4. **Phase 4 — thumbnails**: backend endpoint + wire into rows/grid.
5. **Phase 5 — polish**: palette/accent, mobile bottom sheets, cleanup of dead
tooling (tslint/codelyzer/protractor configs) if desired.
Phases 13 deliver the bulk of the UX gain; Phase 4 is the biggest visual
upgrade but is separable if backend changes need to wait.
### 6.5 Effort estimate
| Phase | Estimate |
|---|---|
| 1. Component split | 0.51 day |
| 2. List redesign | 11.5 days |
| 3. Command bar + toasts | 0.51 day |
| 4. Thumbnails (front + back) | 0.5 day |
| 5. Polish | 0.5 day |
| **Total** | **~34 days** |
(For reference, the rejected full rewrite in React was estimated at 35 days
for the frontend alone, plus regression risk on working behaviors.)
## 7. Risks
- **Virtual scroll + folder groups**: `cdk-virtual-scroll-viewport` works best
with flat lists; collapsible groups need flattening logic in the component.
Mitigation: flatten grouped data into a single array with group-header rows
(standard pattern).
- **Thumbnail fetching load**: 300 completed items loading thumbnails at once.
Mitigation: backend cache + lazy loading (`loading="lazy"` / viewport-based).
- **Bootstrap theming limits**: the refined palette may fight Bootstrap
defaults. Mitigation: CSS custom properties on top of `data-bs-theme`,
not a Bootstrap rebuild.
- **Regression in preserved behaviors** (§6.2): mitigate with manual test
checklist per phase; the project has no real frontend test suite, and
adding one is out of scope.

View File

@ -15,6 +15,8 @@ import json
import pathlib
import re
import base64
import hashlib
import aiohttp
from urllib.parse import urlparse
from watchfiles import DefaultFilter, Change, awatch
@ -332,6 +334,71 @@ async def set_cookie(request):
'msg': f'Failed to save cookie: {str(e)}'
}))
PLACEHOLDER_THUMB = '''<svg xmlns="http://www.w3.org/2000/svg" width="320" height="180" viewBox="0 0 320 180">
<rect width="320" height="180" fill="#e8eaed"/>
<g fill="none" stroke="#9aa0a6" stroke-width="6" stroke-linecap="round" stroke-linejoin="round">
<rect x="125" y="60" width="70" height="60" rx="8"/>
<polyline points="150 75 165 90 150 105"/>
</g>
</svg>'''
def sniff_image_type(data: bytes) -> str:
if data[:3] == b'\xff\xd8\xff':
return 'image/jpeg'
if data[:8] == b'\x89PNG\r\n\x1a\n':
return 'image/png'
if data[:4] == b'GIF8':
return 'image/gif'
if data[:4] == b'RIFF' and data[8:12] == b'WEBP':
return 'image/webp'
if data[:12] == b'\x00\x00\x00\x1cftyp':
return 'image/avif'
return 'application/octet-stream'
@routes.get(config.URL_PREFIX + 'thumbnail')
async def thumbnail(request):
"""Serve a cached copy of a download's remote thumbnail.
The URL is taken from stored yt-dlp metadata for the given download key
clients can never point this endpoint at arbitrary URLs. Missing/invalid
thumbnails fall back to a placeholder SVG.
"""
key = request.query.get('id')
if not key:
raise web.HTTPBadRequest()
info = dqueue.find_info(key)
if info is None or not info.thumbnail:
return web.Response(text=PLACEHOLDER_THUMB, content_type='image/svg+xml', headers={'Cache-Control': 'public, max-age=3600'})
thumb_dir = os.path.join(config.STATE_DIR, 'thumbnails')
os.makedirs(thumb_dir, exist_ok=True)
thumb_path = os.path.join(thumb_dir, hashlib.sha1(key.encode('utf-8')).hexdigest() + '.img')
if not os.path.exists(thumb_path):
try:
timeout = aiohttp.ClientTimeout(total=15)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(info.thumbnail, headers={'User-Agent': 'Mozilla/5.0 MeTube'}) as resp:
if resp.status != 200:
return web.Response(text=PLACEHOLDER_THUMB, content_type='image/svg+xml', headers={'Cache-Control': 'public, max-age=3600'})
data = await resp.read()
with open(thumb_path, 'wb') as f:
f.write(data)
except Exception as exc: # noqa: BLE001 - fall back to placeholder on any fetch error
log.warning(f"Failed to fetch thumbnail for {key}: {exc}")
return web.Response(text=PLACEHOLDER_THUMB, content_type='image/svg+xml', headers={'Cache-Control': 'public, max-age=3600'})
with open(thumb_path, 'rb') as f:
data = f.read()
return web.Response(
body=data,
content_type=sniff_image_type(data[:16]),
headers={'Cache-Control': 'public, max-age=31536000, immutable'}
)
@routes.post(config.URL_PREFIX + 'delete')
async def delete(request):
post = await request.json()

View File

@ -40,6 +40,7 @@ class DownloadQueueNotifier:
class DownloadInfo:
def __init__(self, id, title, url, quality, format, folder, custom_name_prefix, error, entry, playlist_item_limit):
entry = entry or {}
self.id = id if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{id}'
self.title = title if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{title}'
self.url = url
@ -54,6 +55,11 @@ class DownloadInfo:
self.error = error
self.entry = entry
self.playlist_item_limit = playlist_item_limit
# Thumbnail + duration for the redesigned thumbnail-first UI.
# Prefer the largest thumbnail entry, falling back to 'thumbnail'.
thumbnails = entry.get('thumbnails') or []
self.thumbnail = entry.get('thumbnail') or (thumbnails[-1].get('url') if thumbnails else None)
self.duration = entry.get('duration')
# Extract website domain from URL
parsed_url = urlparse(url)
self.website = parsed_url.netloc
@ -380,6 +386,16 @@ class DownloadQueue:
self.done.load()
def find_info(self, key):
"""Look up a DownloadInfo across pending/queue/done by URL key."""
for store in (self.pending, self.queue, self.done):
if key in store.dict:
return store.dict[key].info
for k, v in store.saved_items():
if k == key:
return v
return None
async def __import_queue(self):
for k, v in self.queue.saved_items():
await self.__add_download(v, True)

1471
design/metube-redesign.html Normal file

File diff suppressed because it is too large Load Diff

View File

@ -87,50 +87,6 @@
"options": {
"buildTarget": "metube:build"
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "src/test.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "tsconfig.spec.json",
"karmaConfig": "karma.conf.js",
"assets": [
"src/favicon.ico",
"src/assets",
"src/manifest.webmanifest",
"src/custom-service-worker.js"
],
"styles": [
"src/styles.sass"
],
"scripts": []
}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": [
"tsconfig.app.json",
"tsconfig.spec.json",
"e2e/tsconfig.json"
],
"exclude": [
"**/node_modules/**"
]
}
},
"e2e": {
"builder": "@angular-devkit/build-angular:protractor",
"options": {
"protractorConfig": "e2e/protractor.conf.js",
"devServerTarget": "metube:serve"
},
"configurations": {
"production": {
"devServerTarget": "metube:serve:production"
}
}
}
}
}

View File

@ -1,36 +0,0 @@
// @ts-check
// Protractor configuration file, see link for more information
// https://github.com/angular/protractor/blob/master/lib/config.ts
const { SpecReporter, StacktraceOption } = require('jasmine-spec-reporter');
/**
* @type { import("protractor").Config }
*/
exports.config = {
allScriptsTimeout: 11000,
specs: [
'./src/**/*.e2e-spec.ts'
],
capabilities: {
browserName: 'chrome'
},
directConnect: true,
baseUrl: 'http://localhost:4200/',
framework: 'jasmine',
jasmineNodeOpts: {
showColors: true,
defaultTimeoutInterval: 30000,
print: function() {}
},
onPrepare() {
require('ts-node').register({
project: require('path').join(__dirname, './tsconfig.json')
});
jasmine.getEnv().addReporter(new SpecReporter({
spec: {
displayStacktrace: StacktraceOption.PRETTY
}
}));
}
};

View File

@ -1,23 +0,0 @@
import { AppPage } from './app.po';
import { browser, logging } from 'protractor';
describe('workspace-project App', () => {
let page: AppPage;
beforeEach(() => {
page = new AppPage();
});
it('should display welcome message', () => {
page.navigateTo();
expect(page.getTitleText()).toEqual('metube app is running!');
});
afterEach(async () => {
// Assert that there are no errors emitted from the browser
const logs = await browser.manage().logs().get(logging.Type.BROWSER);
expect(logs).not.toContain(jasmine.objectContaining({
level: logging.Level.SEVERE,
} as logging.Entry));
});
});

View File

@ -1,11 +0,0 @@
import { browser, by, element } from 'protractor';
export class AppPage {
navigateTo(): Promise<unknown> {
return browser.get(browser.baseUrl) as Promise<unknown>;
}
getTitleText(): Promise<string> {
return element(by.css('app-root .content span')).getText() as Promise<string>;
}
}

View File

@ -1,14 +0,0 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "../out-tsc/e2e",
"module": "commonjs",
"target": "es2018",
"types": [
"jasmine",
"jasminewd2",
"node"
]
}
}

View File

@ -1,32 +0,0 @@
// Karma configuration file, see link for more information
// https://karma-runner.github.io/1.0/config/configuration-file.html
module.exports = function (config) {
config.set({
basePath: '',
frameworks: ['jasmine', '@angular-devkit/build-angular'],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'),
require('karma-coverage-istanbul-reporter'),
require('@angular-devkit/build-angular/plugins/karma')
],
client: {
clearContext: false // leave Jasmine Spec Runner output visible in browser
},
coverageIstanbulReporter: {
dir: require('path').join(__dirname, './coverage/metube'),
reports: ['html', 'lcovonly', 'text-summary'],
fixWebpackSourcePaths: true
},
reporters: ['progress', 'kjhtml'],
port: 9876,
colors: true,
logLevel: config.LOG_INFO,
autoWatch: true,
browsers: ['Chrome'],
singleRun: false,
restartOnFileChange: true
});
};

538
ui/package-lock.json generated
View File

@ -9,6 +9,7 @@
"version": "0.0.0",
"dependencies": {
"@angular/animations": "^19.2.14",
"@angular/cdk": "^19.2.19",
"@angular/common": "^19.2.14",
"@angular/compiler": "^19.2.14",
"@angular/core": "^19.2.14",
@ -37,9 +38,7 @@
"@angular/cli": "^19.2.14",
"@angular/compiler-cli": "^19.2.14",
"@types/node": "^22.15.29",
"codelyzer": "^6.0.2",
"ts-node": "~10.9.1",
"tslint": "~6.1.3",
"typescript": "~5.8.3"
}
},
@ -417,6 +416,21 @@
}
}
},
"node_modules/@angular/cdk": {
"version": "19.2.19",
"resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-19.2.19.tgz",
"integrity": "sha512-PCpJagurPBqciqcq4Z8+3OtKLb7rSl4w/qBJoIMua8CgnrjvA1i+SWawhdtfI1zlY8FSwhzLwXV0CmWWfFzQPg==",
"license": "MIT",
"dependencies": {
"parse5": "^7.1.2",
"tslib": "^2.3.0"
},
"peerDependencies": {
"@angular/common": "^19.0.0 || ^20.0.0",
"@angular/core": "^19.0.0 || ^20.0.0",
"rxjs": "^6.5.3 || ^7.4.0"
}
},
"node_modules/@angular/cli": {
"version": "19.2.17",
"resolved": "https://registry.npmjs.org/@angular/cli/-/cli-19.2.17.tgz",
@ -5883,16 +5897,6 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/app-root-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/app-root-path/-/app-root-path-3.1.0.tgz",
"integrity": "sha512-biN3PwB2gUtjaYy/isrU3aNWI5w+fAfvHkSvCKeQGxhmYpwKFUxudR3Yya+KqVRHBmEDYh+/lTozYCFbmzX4nA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/arg": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
@ -5907,17 +5911,6 @@
"dev": true,
"license": "Python-2.0"
},
"node_modules/aria-query": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-3.0.0.tgz",
"integrity": "sha512-majUxHgLehQTeSA+hClx+DY09OVUqG3GtezWkF1krgLGNdlDu9l9V8DaqNMWbq4Eddc8wsyDA0hpDUtnYxQEXw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"ast-types-flow": "0.0.7",
"commander": "^2.11.0"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@ -5925,13 +5918,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/ast-types-flow": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz",
"integrity": "sha512-eBvWn1lvIApYMhzQMsu9ciLfkBY499mFZlNqG+/9WR7PVlroQw0vG30cOQQbaKz3sCEc44TAOu2ykzqXSNnwag==",
"dev": true,
"license": "ISC"
},
"node_modules/autoprefixer": {
"version": "10.4.20",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz",
@ -5970,16 +5956,6 @@
"postcss": "^8.1.0"
}
},
"node_modules/axobject-query": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.0.2.tgz",
"integrity": "sha512-MCeek8ZH7hKyO1rWUbKNQBbl4l2eY0ntk7OGi+q0RlafrCnfPxC06WZA+uebCfmYp4mNU9jRBP1AhGyf8+W3ww==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"ast-types-flow": "0.0.7"
}
},
"node_modules/babel-loader": {
"version": "9.2.1",
"resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-9.2.1.tgz",
@ -6337,16 +6313,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/builtin-modules": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-1.1.1.tgz",
"integrity": "sha512-wxXCdllwGhI2kCC0MnvTGYTMvnVZTvqgypkiTI8Pa5tcz2i6VqsqwYGgqwXji+4RgCzms6EajE4IxiUH6HH8nQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/bundle-name": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
@ -6736,93 +6702,6 @@
"node": ">=0.10.0"
}
},
"node_modules/codelyzer": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/codelyzer/-/codelyzer-6.0.2.tgz",
"integrity": "sha512-v3+E0Ucu2xWJMOJ2fA/q9pDT/hlxHftHGPUay1/1cTgyPV5JTHFdO9hqo837Sx2s9vKBMTt5gO+lhF95PO6J+g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@angular/compiler": "9.0.0",
"@angular/core": "9.0.0",
"app-root-path": "^3.0.0",
"aria-query": "^3.0.0",
"axobject-query": "2.0.2",
"css-selector-tokenizer": "^0.7.1",
"cssauron": "^1.4.0",
"damerau-levenshtein": "^1.0.4",
"rxjs": "^6.5.3",
"semver-dsl": "^1.0.1",
"source-map": "^0.5.7",
"sprintf-js": "^1.1.2",
"tslib": "^1.10.0",
"zone.js": "~0.10.3"
},
"peerDependencies": {
"@angular/compiler": ">=2.3.1 <13.0.0 || ^12.0.0-next || ^12.1.0-next || ^12.2.0-next",
"@angular/core": ">=2.3.1 <13.0.0 || ^12.0.0-next || ^12.1.0-next || ^12.2.0-next",
"tslint": "^5.0.0 || ^6.0.0"
}
},
"node_modules/codelyzer/node_modules/@angular/compiler": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-9.0.0.tgz",
"integrity": "sha512-ctjwuntPfZZT2mNj2NDIVu51t9cvbhl/16epc5xEwyzyDt76pX9UgwvY+MbXrf/C/FWwdtmNtfP698BKI+9leQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"tslib": "^1.10.0"
}
},
"node_modules/codelyzer/node_modules/@angular/core": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/@angular/core/-/core-9.0.0.tgz",
"integrity": "sha512-6Pxgsrf0qF9iFFqmIcWmjJGkkCaCm6V5QNnxMy2KloO3SDq6QuMVRbN9RtC8Urmo25LP+eZ6ZgYqFYpdD8Hd9w==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"rxjs": "^6.5.3",
"tslib": "^1.10.0",
"zone.js": "~0.10.2"
}
},
"node_modules/codelyzer/node_modules/rxjs": {
"version": "6.6.7",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz",
"integrity": "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^1.9.0"
},
"engines": {
"npm": ">=2.0.0"
}
},
"node_modules/codelyzer/node_modules/source-map": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
"integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/codelyzer/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"dev": true,
"license": "0BSD"
},
"node_modules/codelyzer/node_modules/zone.js": {
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.10.3.tgz",
"integrity": "sha512-LXVLVEq0NNOqK/fLJo3d0kfzd4sxwn2/h67/02pjCjfKDxgx1i9QqpvtHD8CrBnSSwMw5+dy11O7FRX5mkO7Cg==",
"dev": true,
"license": "MIT"
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -6921,13 +6800,6 @@
"node": ">= 0.6"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true,
"license": "MIT"
},
"node_modules/connect-history-api-fallback": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz",
@ -7191,17 +7063,6 @@
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css-selector-tokenizer": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/css-selector-tokenizer/-/css-selector-tokenizer-0.7.3.tgz",
"integrity": "sha512-jWQv3oCEL5kMErj4wRnK/OPoBi0D+P1FR2cDCKYPaMeD2eW3/mttav8HT4hT1CKopiJI/psEULjkClhvJo4Lvg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
"fastparse": "^1.1.2"
}
},
"node_modules/css-what": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
@ -7215,16 +7076,6 @@
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/cssauron": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/cssauron/-/cssauron-1.4.0.tgz",
"integrity": "sha512-Ht70DcFBh+/ekjVrYS2PlDMdSQEl3OFNmjK6lcn49HptBgilXf/Zwg4uFh9Xn0pX3Q8YOkSjIFOfK2osvdqpBw==",
"dev": true,
"license": "MIT",
"dependencies": {
"through": "X.X.X"
}
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@ -7238,13 +7089,6 @@
"node": ">=4"
}
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
"integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==",
"dev": true,
"license": "BSD-2-Clause"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@ -7793,16 +7637,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/escape-string-regexp": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
"integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/eslint-scope": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
@ -7817,20 +7651,6 @@
"node": ">=8.0.0"
}
},
"node_modules/esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
"dev": true,
"license": "BSD-2-Clause",
"bin": {
"esparse": "bin/esparse.js",
"esvalidate": "bin/esvalidate.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/esrecurse": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
@ -8034,13 +7854,6 @@
],
"license": "BSD-3-Clause"
},
"node_modules/fastparse": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/fastparse/-/fastparse-1.1.2.tgz",
"integrity": "sha512-483XLLxTVIwWK3QTrMGRqUfUpoOs/0hbQrl2oz4J0pAcm3A3bu84wxTFqGqkJzewCLdME38xJLJAxBABfQT8sQ==",
"dev": true,
"license": "MIT"
},
"node_modules/fastq": {
"version": "1.19.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
@ -8258,13 +8071,6 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"dev": true,
"license": "ISC"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -8832,18 +8638,6 @@
"node": ">=0.8.19"
}
},
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
"dev": true,
"license": "ISC",
"dependencies": {
"once": "^1.3.0",
"wrappy": "1"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@ -9892,16 +9686,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/minipass": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
@ -10546,16 +10330,6 @@
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"dev": true,
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/onetime": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz",
@ -10861,7 +10635,6 @@
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
@ -10902,7 +10675,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
@ -10931,16 +10703,6 @@
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@ -11860,26 +11622,6 @@
"node": ">=10"
}
},
"node_modules/semver-dsl": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/semver-dsl/-/semver-dsl-1.0.1.tgz",
"integrity": "sha512-e8BOaTo007E3dMuQQTnPdalbKTABKNS7UxoBIDnwOqRa+QwMrCPjynB8zAlPF6xlqUfdLPPLIJ13hJNmhtq8Ng==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^5.3.0"
}
},
"node_modules/semver-dsl/node_modules/semver": {
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver"
}
},
"node_modules/send": {
"version": "0.19.0",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
@ -12570,13 +12312,6 @@
"wbuf": "^1.7.3"
}
},
"node_modules/sprintf-js": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/ssri": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz",
@ -12933,13 +12668,6 @@
"tslib": "^2"
}
},
"node_modules/through": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==",
"dev": true,
"license": "MIT"
},
"node_modules/thunky": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz",
@ -13076,233 +12804,6 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tslint": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/tslint/-/tslint-6.1.3.tgz",
"integrity": "sha512-IbR4nkT96EQOvKE2PW/djGz8iGNeJ4rF2mBfiYaR/nvUWYKJhLwimoJKgjIFEIDibBtOevj7BqCRL4oHeWWUCg==",
"deprecated": "TSLint has been deprecated in favor of ESLint. Please see https://github.com/palantir/tslint/issues/4534 for more information.",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@babel/code-frame": "^7.0.0",
"builtin-modules": "^1.1.1",
"chalk": "^2.3.0",
"commander": "^2.12.1",
"diff": "^4.0.1",
"glob": "^7.1.1",
"js-yaml": "^3.13.1",
"minimatch": "^3.0.4",
"mkdirp": "^0.5.3",
"resolve": "^1.3.2",
"semver": "^5.3.0",
"tslib": "^1.13.0",
"tsutils": "^2.29.0"
},
"bin": {
"tslint": "bin/tslint"
},
"engines": {
"node": ">=4.8.0"
},
"peerDependencies": {
"typescript": ">=2.3.0-dev || >=2.4.0-dev || >=2.5.0-dev || >=2.6.0-dev || >=2.7.0-dev || >=2.8.0-dev || >=2.9.0-dev || >=3.0.0-dev || >= 3.1.0-dev || >= 3.2.0-dev || >= 4.0.0-dev"
}
},
"node_modules/tslint/node_modules/ansi-styles": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
"integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^1.9.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/tslint/node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/tslint/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/tslint/node_modules/chalk": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
"integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^3.2.1",
"escape-string-regexp": "^1.0.5",
"supports-color": "^5.3.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/tslint/node_modules/color-convert": {
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
"integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "1.1.3"
}
},
"node_modules/tslint/node_modules/color-name": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
"integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
"dev": true,
"license": "MIT"
},
"node_modules/tslint/node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"dev": true,
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/tslint/node_modules/has-flag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
"integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/tslint/node_modules/js-yaml": {
"version": "3.14.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
"integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/tslint/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/tslint/node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"minimist": "^1.2.6"
},
"bin": {
"mkdirp": "bin/cmd.js"
}
},
"node_modules/tslint/node_modules/semver": {
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver"
}
},
"node_modules/tslint/node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/tslint/node_modules/supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^3.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/tslint/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"dev": true,
"license": "0BSD"
},
"node_modules/tsutils": {
"version": "2.29.0",
"resolved": "https://registry.npmjs.org/tsutils/-/tsutils-2.29.0.tgz",
"integrity": "sha512-g5JVHCIJwzfISaXpXE1qvNalca5Jwob6FjI4AoPlqMusJ6ftFE7IkkFoMhVLRgK+4Kx3gkzb8UZK5t5yTTvEmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"tslib": "^1.8.1"
},
"peerDependencies": {
"typescript": ">=2.1.0 || >=2.1.0-dev || >=2.2.0-dev || >=2.3.0-dev || >=2.4.0-dev || >=2.5.0-dev || >=2.6.0-dev || >=2.7.0-dev || >=2.8.0-dev || >=2.9.0-dev || >= 3.0.0-dev || >= 3.1.0-dev"
}
},
"node_modules/tsutils/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"dev": true,
"license": "0BSD"
},
"node_modules/tuf-js": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-3.1.0.tgz",
@ -14470,13 +13971,6 @@
"node": ">=8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"dev": true,
"license": "ISC"
},
"node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",

View File

@ -4,14 +4,12 @@
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"test": "ng test",
"lint": "ng lint",
"e2e": "ng e2e"
"build": "ng build"
},
"private": true,
"dependencies": {
"@angular/animations": "^19.2.14",
"@angular/cdk": "^19.2.19",
"@angular/common": "^19.2.14",
"@angular/compiler": "^19.2.14",
"@angular/core": "^19.2.14",
@ -40,9 +38,7 @@
"@angular/cli": "^19.2.14",
"@angular/compiler-cli": "^19.2.14",
"@types/node": "^22.15.29",
"codelyzer": "^6.0.2",
"ts-node": "~10.9.1",
"tslint": "~6.1.3",
"typescript": "~5.8.3"
}
}

View File

@ -0,0 +1,60 @@
<section class="active-panel">
<div class="section-head">
<h2 class="section-title">正在下载</h2>
<span class="section-meta num" *ngIf="etaHint">{{ totalSpeed | speed }} · {{ etaHint }}</span>
</div>
<ng-container *ngIf="!loading; else skeletonTpl">
<div class="active-list" *ngIf="items.length; else emptyTpl">
<div class="active-row" *ngFor="let item of items; trackBy: trackByKey">
<div class="thumb">
<img *ngIf="item.value.thumbnail" [src]="thumbUrl(item)" [alt]="item.value.title" loading="lazy">
<span class="thumb-ph" *ngIf="!item.value.thumbnail">
<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="2" y="4" width="20" height="16" rx="2"/><polyline points="10 9 15 12 10 15"/></svg>
</span>
<span class="dur num" *ngIf="item.value.duration">{{ item.value.duration | duration }}</span>
</div>
<div class="active-main">
<div class="active-title">{{ item.value.title }}</div>
<div class="progress-track">
<div class="progress-fill" [class.indeterminate]="isPreparing(item)" [style.width.%]="isPreparing(item) ? 100 : (item.value.percent || 0)"></div>
</div>
<div class="active-stats">
<span>
<span class="num">{{ isPreparing(item) ? '—' : ((item.value.percent || 0) | number:'1.0-0') }}%</span>
<span> · {{ item.value.size ? (item.value.size | fileSize) : '—' }}</span>
</span>
<span class="num">{{ item.value.speed | speed }}</span>
<span>ETA <span class="num">{{ item.value.eta | eta }}</span></span>
</div>
</div>
<div class="row-actions">
<button class="icon-btn" (click)="cancel.emit(item.key)" 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>
</div>
<ng-template #emptyTpl>
<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"><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">添加 URL 后,下载进度会固定显示在这里</div>
</div>
</ng-template>
</ng-container>
<ng-template #skeletonTpl>
<div class="active-list">
<div class="active-row skeleton" *ngFor="let _ of [0, 1]">
<div class="thumb shimmer"></div>
<div class="active-main">
<div class="shimmer line w60"></div>
<div class="shimmer line w90"></div>
<div class="shimmer line w40"></div>
</div>
</div>
</div>
</ng-template>
</section>

View File

@ -0,0 +1,184 @@
:host
display: block
.section-head
display: flex
align-items: baseline
gap: 10px
margin-bottom: 8px
.section-title
font-family: var(--font-display)
font-size: 15px
font-weight: 600
letter-spacing: -0.01em
line-height: 1.35
margin: 0
.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: var(--placeholder)
position: relative
flex: none
aspect-ratio: 16 / 9
display: grid
place-items: center
img
display: block
width: 100%
height: 100%
object-fit: cover
.thumb-ph
color: var(--muted)
.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-main
min-width: 0
.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
[data-bs-theme="dark"] .progress-track
background: oklch(28% 0.014 250)
.progress-fill
height: 100%
border-radius: 3px
background: var(--accent)
transition: width 0.6s ease
&.indeterminate
background: linear-gradient(90deg, var(--accent-tint), var(--accent), var(--accent-tint))
background-size: 200% 100%
animation: slide 1.2s linear infinite
@keyframes slide
from
background-position: 200% 0
to
background-position: -200% 0
.active-stats
display: flex
gap: 14px
font-size: 12px
color: var(--muted)
flex-wrap: wrap
.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
&:hover
background: var(--danger-tint)
color: var(--danger)
.empty
text-align: center
padding: 14px 20px
border: 1px dashed var(--border)
border-radius: var(--radius)
background: var(--surface)
svg
color: var(--muted)
margin-bottom: 4px
.empty-title
font-size: 14px
font-weight: 600
margin-bottom: 4px
.empty-sub
font-size: 12.5px
color: var(--muted)
.skeleton
.shimmer
background: linear-gradient(90deg, var(--placeholder) 25%, oklch(90% 0.008 240) 50%, var(--placeholder) 75%)
background-size: 200% 100%
animation: shimmer 1.4s ease infinite
.line
height: 10px
border-radius: 5px
margin-bottom: 6px
&.w60
width: 60%
&.w90
width: 90%
&.w40
width: 40%
@keyframes shimmer
from
background-position: 200% 0
to
background-position: -200% 0
@media (max-width: 900px)
.active-row
grid-template-columns: 96px 1fr
.row-actions
grid-column: 2
justify-self: end

View File

@ -0,0 +1,45 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { Download } from './downloads.service';
import { DownloadsService } from './downloads.service';
export interface KeyedDownload {
key: string;
value: Download;
}
@Component({
selector: 'app-active-downloads',
standalone: false,
templateUrl: './active-downloads.component.html',
styleUrls: ['./active-downloads.component.sass']
})
export class ActiveDownloadsComponent {
@Input() items: KeyedDownload[] = [];
@Input() totalSpeed = 0;
@Input() loading = false;
@Output() cancel = new EventEmitter<string>();
constructor(private downloads: DownloadsService) {}
trackByKey(_: number, item: KeyedDownload): string {
return item.key;
}
thumbUrl(item: KeyedDownload): string {
return this.downloads.thumbnailUrl(item.key);
}
get etaHint(): string {
const etas = this.items.map(i => i.value.eta).filter((e): e is number => e !== null && e !== undefined && e > 0);
if (!etas.length) return '';
const min = Math.min(...etas);
const h = Math.floor(min / 3600);
const m = Math.floor((min % 3600) / 60);
const s = Math.round(min % 60);
return h > 0 ? `预计 ${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')} 完成首个任务` : `预计 ${m}:${String(s).padStart(2, '0')} 完成首个任务`;
}
isPreparing(item: KeyedDownload): boolean {
return item.value.status === 'preparing';
}
}

View File

@ -0,0 +1,61 @@
<div class="scrim" [class.show]="open" (click)="close.emit()"></div>
<aside class="slideover" [class.show]="open" aria-label="高级选项">
<div class="so-head">
<span class="so-title">高级选项</span>
<button class="icon-btn neutral" (click)="close.emit()" 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" [class.on]="autoStart" role="switch" [attr.aria-checked]="autoStart" (click)="autoStartChange.emit(!autoStart)"></button>
</div>
<div class="so-row">
<div>严格播放列表<div class="sub">仅下载完全匹配的播放列表项</div></div>
<button class="switch" [class.on]="playlistStrictMode" role="switch" [attr.aria-checked]="playlistStrictMode" (click)="strictChange.emit(!playlistStrictMode)"></button>
</div>
<div class="so-row">
<div>播放列表上限<div class="sub">单次最多展开的条目数(0 = 不限制)</div></div>
<input class="so-input num" type="number" min="0" [ngModel]="playlistItemLimit" (ngModelChange)="limitChange.emit($event)">
</div>
<div class="so-row">
<div>文件名前缀<div class="sub">可选,如频道名</div></div>
<input class="so-input text" type="text" placeholder="无前缀" [ngModel]="customNamePrefix" (ngModelChange)="prefixChange.emit($event)">
</div>
</div>
<div class="so-group">
<div class="so-label">批量操作</div>
<button class="so-btn" (click)="import.emit()">
<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" (click)="export.emit()">
<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" (click)="copy.emit()">
<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>
<p class="so-hint">某些站点需要登录 Cookie 才能下载</p>
<input class="so-input wide" type="text" placeholder="域名或 URL,如 bilibili.com" [(ngModel)]="cookieDomain" aria-label="Cookie 域名">
<textarea class="so-textarea" rows="4" placeholder="粘贴 Cookie 字符串(key1=value1; key2=value2)" [(ngModel)]="cookieText" aria-label="Cookie 内容"></textarea>
<button class="so-btn primary" (click)="saveCookie()" [disabled]="!cookieText.trim() || !cookieDomain.trim()">保存 Cookie</button>
</div>
<div class="so-group" *ngIf="metubeVersion || ytDlpVersion || ytDlpOptionsUpdateTime">
<div class="so-label">诊断</div>
<div class="so-row flat"><div>MeTube</div><span class="num">{{ metubeVersion || '—' }}</span></div>
<div class="so-row flat"><div>yt-dlp</div><span class="num">{{ ytDlpVersion || '—' }}</span></div>
<div class="so-row flat" *ngIf="ytDlpOptionsUpdateTime"><div>yt-dlp-options</div><span class="num">{{ ytDlpOptionsUpdateTime }}</span></div>
</div>
</div>
</aside>

View File

@ -0,0 +1,202 @@
:host
display: block
.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
&.show
opacity: 1
pointer-events: auto
.slideover
position: fixed
top: 0
right: 0
bottom: 0
width: 340px
max-width: 100vw
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
&.show
transform: translateX(0)
.so-head
display: flex
align-items: center
padding: 16px 18px
border-bottom: 1px solid var(--border)
flex: none
.so-title
font-size: 15px
font-weight: 600
letter-spacing: -0.01em
color: var(--fg)
.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-hint
font-size: 12px
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
color: var(--fg)
.sub
font-size: 11.5px
color: var(--muted)
margin-top: 2px
&.flat
padding: 6px 0
font-size: 12.5px
.num
font-size: 12px
color: var(--fg)
.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
&::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
&.on
background: var(--accent)
&::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
background: var(--surface)
&.text
width: 140px
text-align: left
font-family: var(--font-body)
&.wide
width: 100%
text-align: left
font-family: var(--font-body)
margin-bottom: 8px
.so-textarea
width: 100%
border: 1px solid var(--border)
border-radius: 6px
padding: 8px 10px
font-size: 12px
font-family: var(--font-mono)
color: var(--fg)
background: var(--surface)
resize: vertical
margin-bottom: 8px
.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
&:hover:not(:disabled)
background: var(--bg)
&:disabled
opacity: 0.5
cursor: default
svg
color: var(--muted)
flex: none
&.primary
background: var(--accent-tint)
border-color: oklch(85% 0.05 145)
color: var(--accent-strong)
font-weight: 550
&:hover:not(:disabled)
background: var(--accent-tint)
.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
&.neutral:hover
background: var(--bg)
color: var(--fg)

View File

@ -0,0 +1,39 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
@Component({
selector: 'app-advanced-options',
standalone: false,
templateUrl: './advanced-options.component.html',
styleUrls: ['./advanced-options.component.sass']
})
export class AdvancedOptionsComponent {
@Input() open = false;
@Input() autoStart = true;
@Input() playlistStrictMode = false;
@Input() playlistItemLimit: number | null = null;
@Input() customNamePrefix = '';
@Input() metubeVersion: string | null = null;
@Input() ytDlpVersion: string | null = null;
@Input() ytDlpOptionsUpdateTime: string | null = null;
@Output() close = new EventEmitter<void>();
@Output() autoStartChange = new EventEmitter<boolean>();
@Output() strictChange = new EventEmitter<boolean>();
@Output() limitChange = new EventEmitter<number>();
@Output() prefixChange = new EventEmitter<string>();
@Output() import = new EventEmitter<void>();
@Output() export = new EventEmitter<void>();
@Output() copy = new EventEmitter<void>();
@Output() cookie = new EventEmitter<{ domain: string; cookie: string }>();
cookieDomain = '';
cookieText = '';
saveCookie(): void {
const domain = this.cookieDomain.trim();
const cookie = this.cookieText.trim();
if (!cookie || !domain) return;
this.cookie.emit({ domain, cookie });
this.cookieText = '';
}
}

View File

@ -1,595 +1,149 @@
<nav class="navbar navbar-expand-md navbar-dark">
<div class="container-fluid">
<a class="navbar-brand d-flex align-items-center" href="#">
<img src="assets/icons/android-chrome-192x192.png" alt="MeTube Logo" height="32" class="me-2">
MeTube
</a>
<div class="download-metrics">
<div class="metric" *ngIf="activeDownloads > 0">
<fa-icon [icon]="faDownload" class="text-primary"></fa-icon>
<span>{{activeDownloads}} downloading</span>
</div>
<div class="metric" *ngIf="queuedDownloads > 0">
<fa-icon [icon]="faClock" class="text-warning"></fa-icon>
<span>{{queuedDownloads}} queued</span>
</div>
<div class="metric" *ngIf="completedDownloads > 0">
<fa-icon [icon]="faCheck" class="text-success"></fa-icon>
<span>{{completedDownloads}} completed</span>
</div>
<div class="metric" *ngIf="failedDownloads > 0">
<fa-icon [icon]="faTimesCircle" class="text-danger"></fa-icon>
<span>{{failedDownloads}} failed</span>
</div>
<div class="metric" *ngIf="(totalSpeed | speed) !== ''">
<fa-icon [icon]="faTachometerAlt" class="text-info"></fa-icon>
<span>{{totalSpeed | speed }}</span>
</div>
</div>
<!--
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarsDefault" aria-controls="navbarsDefault" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarsDefault">
<ul class="navbar-nav mr-auto">
<li class="nav-item active">
<a class="nav-link" href="#">Home <span class="sr-only">(current)</span></a>
</li>
</ul>
</div>
-->
<div class="navbar-nav ms-auto">
<div class="nav-item dropdown">
<button class="btn btn-link nav-link py-2 px-0 px-sm-2 dropdown-toggle d-flex align-items-center"
id="theme-select"
type="button"
aria-expanded="false"
data-bs-toggle="dropdown"
data-bs-display="static">
<fa-icon [icon]="activeTheme.icon"></fa-icon>
</button>
<ul class="dropdown-menu dropdown-menu-end position-absolute" aria-labelledby="theme-select">
<li *ngFor="let theme of themes">
<button type="button" class="dropdown-item d-flex align-items-center" [ngClass]="{'active' : activeTheme == theme}" (click)="themeChanged(theme)">
<span class="me-2 opacity-50">
<fa-icon [icon]="theme.icon"></fa-icon>
</span>
{{ theme.displayName }}
<span class="ms-auto" [ngClass]="{'d-none' : activeTheme != theme}">
<fa-icon [icon]="faCheck"></fa-icon>
</span>
</button>
</li>
</ul>
</div>
</div>
</div>
</nav>
<div class="app">
<app-sidebar
[status]="status"
[selectedFolder]="selectedFolder"
[statusCounts]="statusCounts"
[folders]="folderNavs"
[allCount]="allCount"
[metubeVersion]="metubeVersion"
[themes]="themes"
[activeTheme]="activeTheme"
[open]="sidebarOpen"
(statusChange)="onStatusChange($event)"
(folderChange)="onFolderChange($event)"
(openAdvanced)="toggleAdvanced(true)"
(openActivity)="activityOpen = !activityOpen"
(themeChanged)="themeChanged($event)"
(close)="sidebarOpen = false">
</app-sidebar>
<main role="main" class="container container-xl">
<!-- Events Display Area -->
<div *ngIf="events.length > 0" class="events-container mb-3">
<div class="events-header">
<div class="d-flex align-items-center justify-content-between">
<h6 class="mb-0">
<fa-icon [icon]="faClock" class="me-2"></fa-icon>
Recent Events
</h6>
<button type="button"
class="btn btn-sm btn-outline-secondary"
(click)="clearEvents()"
title="Clear all events">
<fa-icon [icon]="faTrashAlt" class="me-1"></fa-icon>
Clear
</button>
</div>
</div>
<div class="events-list">
<div *ngFor="let event of events" class="event-item" [ngClass]="'event-' + event.type">
<div class="event-icon">
<fa-icon [icon]="faTimesCircle"></fa-icon>
</div>
<div class="event-content">
<div class="event-message">{{ event.message }}</div>
<div class="event-url" *ngIf="event.url" [title]="event.url">
{{ event.url }}
</div>
</div>
<div class="event-time">
{{ getRelativeTime(event.timestamp) }}
</div>
</div>
</div>
</div>
<form #f="ngForm">
<div class="container add-url-box">
<!-- Main URL Input with Download Button -->
<div class="row mb-4">
<div class="col">
<div class="input-group input-group-lg shadow-sm">
<input type="text"
autocomplete="off"
spellcheck="false"
class="form-control form-control-lg"
placeholder="Enter video or playlist URL"
name="addUrl"
[(ngModel)]="addUrl"
[disabled]="addInProgress || downloads.loading">
<button class="btn btn-primary btn-lg px-4"
type="submit"
(click)="addDownload()"
[disabled]="addInProgress || downloads.loading">
<span class="spinner-border spinner-border-sm" role="status" id="add-spinner" *ngIf="addInProgress"></span>
{{ addInProgress ? "Adding..." : "Download" }}
</button>
</div>
</div>
</div>
<!-- Options Row -->
<div class="row mb-3 g-3">
<div class="col-md-4">
<div class="input-group">
<span class="input-group-text">Quality</span>
<select class="form-select"
name="quality"
[(ngModel)]="quality"
(change)="qualityChanged()"
[disabled]="addInProgress || downloads.loading">
<option *ngFor="let q of qualities" [ngValue]="q.id">{{ q.text }}</option>
</select>
</div>
</div>
<div class="col-md-4">
<div class="input-group">
<span class="input-group-text">Format</span>
<select class="form-select"
name="format"
[(ngModel)]="format"
(change)="formatChanged()"
[disabled]="addInProgress || downloads.loading">
<option *ngFor="let f of formats" [ngValue]="f.id">{{ f.text }}</option>
</select>
</div>
</div>
<div class="col-md-4">
<button type="button"
class="btn btn-outline-secondary w-100 h-100"
(click)="toggleAdvanced()">
Advanced Options
</button>
</div>
</div>
<!-- Advanced Options Panel -->
<div class="row">
<div class="col-12">
<div class="collapse show" id="advancedOptions" [ngbCollapse]="!isAdvancedOpen">
<div class="card card-body">
<!-- Advanced Settings -->
<div class="row g-3 mb-2">
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text">Auto Start</span>
<select class="form-select"
name="autoStart"
[(ngModel)]="autoStart"
(change)="autoStartChanged()"
<main class="main">
<app-command-bar
[url]="addUrl"
(urlChange)="addUrl = $event"
[quality]="quality"
[format]="format"
[folder]="folder"
[qualities]="qualities"
[formats]="formats"
[customDirs$]="customDirs$"
[allowCustomDir]="allowCustomDir.bind(this)"
[disabled]="addInProgress || downloads.loading"
ngbTooltip="Automatically start downloads when added">
<option [ngValue]="true">Yes</option>
<option [ngValue]="false">No</option>
</select>
</div>
</div>
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text">Download Folder</span>
<ng-select [items]="customDirs$ | async"
placeholder="Default"
[addTag]="allowCustomDir.bind(this)"
addTagText="Create directory"
bindLabel="folder"
[(ngModel)]="folder"
[disabled]="addInProgress || downloads.loading"
[virtualScroll]="true"
[clearable]="true"
(add)="addDownload($event)"
(qualityChange)="setQuality($event)"
(formatChange)="setFormat($event)"
(folderChange)="setFolder($event)"
(openAdvanced)="toggleAdvanced(true)"
(menu)="sidebarOpen = true">
</app-command-bar>
<app-stats-strip
[active]="activeDownloads"
[speed]="totalSpeed"
[queued]="queuedDownloads"
[storage]="librarySize">
</app-stats-strip>
<div class="content">
<app-active-downloads
[items]="downloadingRows"
[totalSpeed]="totalSpeed"
[loading]="downloads.loading"
[searchable]="true"
[closeOnSelect]="true"
ngbTooltip="Choose where to save downloads. Type to create a new folder.">
</ng-select>
</div>
</div>
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text">Custom Name Prefix</span>
<input type="text"
class="form-control"
placeholder="Default"
name="customNamePrefix"
[(ngModel)]="customNamePrefix"
[disabled]="addInProgress || downloads.loading"
ngbTooltip="Add a prefix to downloaded filenames">
</div>
</div>
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text">Items Limit</span>
<input type="number"
min="0"
class="form-control"
placeholder="Default"
name="playlistItemLimit"
(keydown)="isNumber($event)"
[(ngModel)]="playlistItemLimit"
[disabled]="addInProgress || downloads.loading"
ngbTooltip="Maximum number of items to download from a playlist (0 = no limit)">
</div>
</div>
<div class="col-12">
<div class="form-check form-switch">
<input class="form-check-input"
type="checkbox"
role="switch"
name="playlistStrictMode"
[(ngModel)]="playlistStrictMode"
[disabled]="addInProgress || downloads.loading"
ngbTooltip="Only download playlists when URL explicitly points to a playlist">
<label class="form-check-label">Strict Playlist Mode</label>
</div>
(cancel)="cancelDownload($event)">
</app-active-downloads>
<app-download-list
[status]="status"
[rows]="filteredRows"
[folderName]="folderName"
[filterText]="filter"
(filterChange)="filter = $event"
[sort]="sort"
(sortChange)="sort = $event"
[sortAscending]="sortAscending"
(sortAscendingChange)="setSortDirection($event)"
[view]="view"
(viewChange)="setView($event)"
[loading]="downloads.loading"
[customDirsFor]="customDirsForDownload.bind(this)"
[allowCustomDir]="allowCustomDir.bind(this)"
[downloadUrlFor]="buildDownloadLink.bind(this)"
(start)="startItems($event)"
(del)="delItems($event, status === 'completed' ? 'done' : 'queue')"
(move)="moveItems($event.ids, $event.folder)"
(retry)="retryDownload($event.key, $event.dl)"
(folderEdit)="folderChanged($event.key, $event.folder)"
(clearCompleted)="clearCompletedDownloads()"
(clearFailed)="clearFailedDownloads()"
(retryFailed)="retryFailedDownloads()"
(downloadSelected)="downloadSelectedFiles($event)">
</app-download-list>
</div>
</main>
</div>
<!-- Advanced Actions -->
<div class="row">
<div class="col-12">
<hr class="my-3">
<div class="row g-2">
<div class="col-md-4">
<button type="button"
class="btn btn-secondary w-100"
(click)="openBatchImportModal()">
<fa-icon [icon]="faFileImport" class="me-2"></fa-icon>
Import URLs
</button>
</div>
<div class="col-md-4">
<button type="button"
class="btn btn-secondary w-100"
(click)="exportBatchUrls('all')">
<fa-icon [icon]="faFileExport" class="me-2"></fa-icon>
Export URLs
</button>
</div>
<div class="col-md-4">
<button type="button"
class="btn btn-secondary w-100"
(click)="copyBatchUrls('all')">
<fa-icon [icon]="faCopy" class="me-2"></fa-icon>
Copy URLs
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</form>
<!-- Advanced options slide-over -->
<app-advanced-options
[open]="isAdvancedOpen"
[autoStart]="autoStart"
(autoStartChange)="setAutoStart($event)"
[playlistStrictMode]="playlistStrictMode"
(strictChange)="playlistStrictMode = $event"
[playlistItemLimit]="playlistItemLimit"
(limitChange)="playlistItemLimit = $event"
[customNamePrefix]="customNamePrefix"
(prefixChange)="customNamePrefix = $event"
[metubeVersion]="metubeVersion"
[ytDlpVersion]="ytDlpVersion"
[ytDlpOptionsUpdateTime]="ytDlpOptionsUpdateTime"
(close)="toggleAdvanced(false)"
(import)="openBatchImportModal()"
(export)="exportBatchUrls('all')"
(copy)="copyBatchUrls('all')"
(cookie)="saveCookie($event.domain, $event.cookie)">
</app-advanced-options>
<!-- Batch Import Modal -->
<!-- Batch import modal -->
<div class="modal fade" tabindex="-1" role="dialog" [ngClass]="{'show': batchImportModalOpen}" [ngStyle]="{'display': batchImportModalOpen ? 'block' : 'none'}">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Batch Import URLs</h5>
<button type="button" class="btn-close" aria-label="Close" (click)="closeBatchImportModal()"></button>
<h5 class="modal-title">批量导入 URL</h5>
<button type="button" class="btn-close" aria-label="关闭" (click)="closeBatchImportModal()"></button>
</div>
<div class="modal-body">
<textarea [(ngModel)]="batchImportText" class="form-control" rows="6"
placeholder="Paste one video URL per line"></textarea>
<textarea [(ngModel)]="batchImportText" class="form-control" rows="6" placeholder="每行粘贴一个视频 URL"></textarea>
<div class="mt-2">
<small *ngIf="batchImportStatus">{{ batchImportStatus }}</small>
<small *ngIf="batchImportStatus" class="batch-status">{{ batchImportStatus }}</small>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-danger me-auto" *ngIf="importInProgress" (click)="cancelBatchImport()">
Cancel Import
</button>
<button type="button" class="btn btn-secondary" (click)="closeBatchImportModal()">Close</button>
<button type="button" class="btn btn-primary" (click)="startBatchImport()" [disabled]="importInProgress">
Import URLs
<button type="button" class="btn btn-danger me-auto" *ngIf="importInProgress" (click)="cancelBatchImport()">取消导入</button>
<button type="button" class="btn btn-secondary" (click)="closeBatchImportModal()">关闭</button>
<button type="button" class="btn btn-primary" (click)="startBatchImport()" [disabled]="importInProgress">导入 URL</button>
</div>
</div>
</div>
</div>
<!-- Activity drawer -->
<div class="scrim" *ngIf="activityOpen" (click)="activityOpen = false"></div>
<aside class="activity" [class.show]="activityOpen" aria-label="活动日志">
<div class="activity-head">
<span class="activity-title">活动日志</span>
<button type="button" class="icon-btn neutral" (click)="activityOpen = false" 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="activity-body">
<div *ngIf="events.length === 0" class="activity-empty">暂无事件</div>
<div *ngFor="let event of events" class="activity-item">
<div class="activity-msg">{{ event.message }}</div>
<div class="activity-url" *ngIf="event.url" [title]="event.url">{{ event.url }}</div>
<div class="activity-time num">{{ getRelativeTime(event.timestamp) }}</div>
</div>
<button type="button" class="ghost-btn activity-clear" (click)="clearEvents()">清空日志</button>
</div>
</div>
</aside>
<div *ngIf="downloads.loading" class="alert alert-info" role="alert">
Connecting to server...
</div>
<ul class="nav nav-tabs metube-tabs">
<li class="nav-item">
<a class="nav-link" role="button" [class.active]="activeTab === 'queue'" (click)="setActiveTab('queue')">
Downloading <span class="badge text-bg-secondary">{{ tabDownloads('queue').size }}</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" role="button" [class.active]="activeTab === 'pending'" (click)="setActiveTab('pending')">
Pending <span class="badge text-bg-secondary">{{ tabDownloads('pending').size }}</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" role="button" [class.active]="activeTab === 'done'" (click)="setActiveTab('done')">
Completed <span class="badge text-bg-secondary">{{ downloads.done.size }}</span>
</a>
</li>
</ul>
<div [class.d-none]="activeTab !== 'queue'">
<div class="px-2 py-3 border-bottom d-flex align-items-center flex-wrap">
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #queueDelSelected (click)="delSelectedDownloads('queue')"><fa-icon [icon]="faTrashAlt"></fa-icon>&nbsp; Cancel selected</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #queueDownloadSelected (click)="startSelectedDownloads('queue')"><fa-icon [icon]="faDownload"></fa-icon>&nbsp; Download selected</button>
<div class="form-check form-switch d-flex align-items-center mb-0 ps-0">
<input class="form-check-input ms-0 me-2" type="checkbox" role="switch" id="queueGroupByFolder" [(ngModel)]="groupByFolder" (change)="groupByFolderChanged()" ngbTooltip="Group downloads by folder">
<label class="form-check-label mb-0" for="queueGroupByFolder"><fa-icon [icon]="faFolderOpen"></fa-icon>&nbsp; Group by folder</label>
</div>
</div>
<div class="overflow-auto">
<table class="table">
<thead>
<tr>
<th scope="col" style="width: 1rem;">
<app-master-checkbox #queueMasterCheckbox [id]="'queue'" [list]="tabDownloads('queue')" (changed)="queueSelectionChanged($event)"></app-master-checkbox>
</th>
<th scope="col">Video</th>
<th scope="col" class="sortable-header" (click)="toggleSortOrder()" ngbTooltip="Sort by creation time">
Created
<fa-icon [icon]="sortAscending ? faSortUp : faSortDown"></fa-icon>
</th>
<th scope="col">Folder</th>
<th scope="col" style="width: 8rem;">Speed</th>
<th scope="col" style="width: 7rem;">ETA</th>
<th scope="col" style="width: 6rem;"></th>
</tr>
</thead>
<tbody>
<ng-container *ngFor="let row of displayRows('queue'); trackBy: identifyDisplayRow">
<tr *ngIf="row.type === 'header'" class="folder-group-header" role="button" (click)="toggleFolderCollapse(row.rawFolder)">
<td colspan="7">
<fa-icon [icon]="row.collapsed ? faChevronRight : faChevronDown" class="folder-group-chevron"></fa-icon>&nbsp;
<fa-icon [icon]="faFolderOpen"></fa-icon>&nbsp; {{ row.folder }}
<span class="folder-group-count">{{ row.count }}</span>
</td>
</tr>
<tr *ngIf="row.type === 'item'" [class.disabled]='row.value.deleting'>
<td>
<app-slave-checkbox [id]="row.key" [master]="queueMasterCheckbox" [checkable]="row.value"></app-slave-checkbox>
</td>
<td title="{{ row.value.filename }}">
<div class="d-flex flex-column flex-sm-row align-items-center row-gap-2 column-gap-3">
<div>
<span class="website-tag" *ngIf="row.value.website" [title]="row.value.website">{{ getSimplifiedDomain(row.value.website) }}</span>
{{ row.value.title }}
</div>
<ngb-progressbar height="1.5rem" [showValue]="row.value.status != 'preparing'" [striped]="row.value.status == 'preparing'" [animated]="row.value.status == 'preparing'" type="success" [value]="row.value.status == 'preparing' ? 100 : row.value.percent | number:'1.0-0'" class="download-progressbar"></ngb-progressbar>
</div>
</td>
<td>{{ formatTimestamp(row.value.timestamp) }}</td>
<td>{{ row.value.folder || 'Default' }}</td>
<td>{{ row.value.speed | speed }}</td>
<td>{{ row.value.eta | eta }}</td>
<td>
<div class="d-flex">
<button *ngIf="row.value.status === 'pending'" type="button" class="btn btn-link" (click)="downloadItemByKey(row.key)"><fa-icon [icon]="faDownload"></fa-icon></button>
<button type="button" class="btn btn-link" (click)="delDownload('queue', row.key)"><fa-icon [icon]="faTrashAlt"></fa-icon></button>
<a href="{{row.value.url}}" target="_blank" class="btn btn-link"><fa-icon [icon]="faExternalLinkAlt"></fa-icon></a>
</div>
</td>
</tr>
</ng-container>
</tbody>
</table>
</div>
</div>
<div [class.d-none]="activeTab !== 'pending'">
<div class="px-2 py-3 border-bottom d-flex align-items-center flex-wrap">
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #pendingDelSelected (click)="delSelectedDownloads('pending')"><fa-icon [icon]="faTrashAlt"></fa-icon>&nbsp; Cancel selected</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #pendingDownloadSelected (click)="startSelectedDownloads('pending')"><fa-icon [icon]="faDownload"></fa-icon>&nbsp; Download selected</button>
<div class="form-check form-switch d-flex align-items-center mb-0 ps-0">
<input class="form-check-input ms-0 me-2" type="checkbox" role="switch" id="pendingGroupByFolder" [(ngModel)]="groupByFolder" (change)="groupByFolderChanged()" ngbTooltip="Group downloads by folder">
<label class="form-check-label mb-0" for="pendingGroupByFolder"><fa-icon [icon]="faFolderOpen"></fa-icon>&nbsp; Group by folder</label>
</div>
</div>
<div class="overflow-auto">
<table class="table">
<thead>
<tr>
<th scope="col" style="width: 1rem;">
<app-master-checkbox #pendingMasterCheckbox [id]="'pending'" [list]="tabDownloads('pending')" (changed)="pendingSelectionChanged($event)"></app-master-checkbox>
</th>
<th scope="col">Video</th>
<th scope="col" class="sortable-header" (click)="toggleSortOrder()" ngbTooltip="Sort by creation time">
Created
<fa-icon [icon]="sortAscending ? faSortUp : faSortDown"></fa-icon>
</th>
<th scope="col">Folder</th>
<th scope="col" style="width: 8rem;">Speed</th>
<th scope="col" style="width: 7rem;">ETA</th>
<th scope="col" style="width: 6rem;"></th>
</tr>
</thead>
<tbody>
<ng-container *ngFor="let row of displayRows('pending'); trackBy: identifyDisplayRow">
<tr *ngIf="row.type === 'header'" class="folder-group-header" role="button" (click)="toggleFolderCollapse(row.rawFolder)">
<td colspan="7">
<fa-icon [icon]="row.collapsed ? faChevronRight : faChevronDown" class="folder-group-chevron"></fa-icon>&nbsp;
<fa-icon [icon]="faFolderOpen"></fa-icon>&nbsp; {{ row.folder }}
<span class="folder-group-count">{{ row.count }}</span>
</td>
</tr>
<tr *ngIf="row.type === 'item'" [class.disabled]='row.value.deleting'>
<td>
<app-slave-checkbox [id]="row.key" [master]="pendingMasterCheckbox" [checkable]="row.value"></app-slave-checkbox>
</td>
<td title="{{ row.value.filename }}">
<span class="website-tag" *ngIf="row.value.website" [title]="row.value.website">{{ getSimplifiedDomain(row.value.website) }}</span>
{{ row.value.title }}
</td>
<td>{{ formatTimestamp(row.value.timestamp) }}</td>
<td class="folder-edit-cell">
<ng-select [items]="customDirsForDownload(row.value)"
placeholder="Default"
[addTag]="allowCustomDir.bind(this)"
addTagText="Create directory"
[ngModel]="row.value.folder"
(change)="folderChanged(row.key, $event)"
[clearable]="true"
[searchable]="true"
[closeOnSelect]="true"
appendTo="body"
ngbTooltip="Change download folder">
</ng-select>
</td>
<td>{{ row.value.speed | speed }}</td>
<td>{{ row.value.eta | eta }}</td>
<td>
<div class="d-flex">
<button *ngIf="row.value.status === 'pending'" type="button" class="btn btn-link" (click)="downloadItemByKey(row.key)"><fa-icon [icon]="faDownload"></fa-icon></button>
<button type="button" class="btn btn-link" (click)="delDownload('queue', row.key)"><fa-icon [icon]="faTrashAlt"></fa-icon></button>
<a href="{{row.value.url}}" target="_blank" class="btn btn-link"><fa-icon [icon]="faExternalLinkAlt"></fa-icon></a>
</div>
</td>
</tr>
</ng-container>
</tbody>
</table>
</div>
</div>
<div [class.d-none]="activeTab !== 'done'">
<div class="px-2 py-3 border-bottom d-flex align-items-center flex-wrap">
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneDelSelected (click)="delSelectedDownloads('done')"><fa-icon [icon]="faTrashAlt"></fa-icon>&nbsp; Clear selected</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneClearCompleted (click)="clearCompletedDownloads()"><fa-icon [icon]="faCheckCircle"></fa-icon>&nbsp; Clear completed</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneClearFailed (click)="clearFailedDownloads()"><fa-icon [icon]="faTimesCircle"></fa-icon>&nbsp; Clear failed</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneRetryFailed (click)="retryFailedDownloads()"><fa-icon [icon]="faRedoAlt"></fa-icon>&nbsp; Retry failed</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneDownloadSelected (click)="downloadSelectedFiles()"><fa-icon [icon]="faDownload"></fa-icon>&nbsp; Download Selected</button>
<div class="form-check form-switch d-flex align-items-center mb-0 ps-0">
<input class="form-check-input ms-0 me-2" type="checkbox" role="switch" id="doneGroupByFolder" [(ngModel)]="groupByFolder" (change)="groupByFolderChanged()" ngbTooltip="Group downloads by folder">
<label class="form-check-label mb-0" for="doneGroupByFolder"><fa-icon [icon]="faFolderOpen"></fa-icon>&nbsp; Group by folder</label>
</div>
</div>
<div class="overflow-auto">
<table class="table">
<thead>
<tr>
<th scope="col" style="width: 1rem;">
<app-master-checkbox #doneMasterCheckbox [id]="'done'" [list]="downloads.done" (changed)="doneSelectionChanged($event)"></app-master-checkbox>
</th>
<th scope="col">Video</th>
<th scope="col" class="sortable-header" (click)="toggleSortOrder()" ngbTooltip="Sort by creation time">
Created
<fa-icon [icon]="sortAscending ? faSortUp : faSortDown"></fa-icon>
</th>
<th scope="col">Folder</th>
<th scope="col">File Size</th>
<th scope="col" style="width: 8rem;"></th>
</tr>
</thead>
<tbody>
<ng-container *ngFor="let row of displayRows('done'); trackBy: identifyDisplayRow">
<tr *ngIf="row.type === 'header'" class="folder-group-header" role="button" (click)="toggleFolderCollapse(row.rawFolder)">
<td colspan="6">
<fa-icon [icon]="row.collapsed ? faChevronRight : faChevronDown" class="folder-group-chevron"></fa-icon>&nbsp;
<fa-icon [icon]="faFolderOpen"></fa-icon>&nbsp; {{ row.folder }}
<span class="folder-group-count">{{ row.count }}</span>
</td>
</tr>
<tr *ngIf="row.type === 'item'" [class.disabled]='row.value.deleting'>
<td>
<app-slave-checkbox [id]="row.key" [master]="doneMasterCheckbox" [checkable]="row.value"></app-slave-checkbox>
</td>
<td>
<div style="display: inline-block; width: 1.5rem;">
<fa-icon *ngIf="row.value.status == 'finished'" [icon]="faCheckCircle" class="text-success"></fa-icon>
<fa-icon *ngIf="row.value.status == 'error'" [icon]="faTimesCircle" class="text-danger"></fa-icon>
</div>
<span class="website-tag" *ngIf="row.value.website" [title]="row.value.website">{{ getSimplifiedDomain(row.value.website) }}</span>
<span class="file-exists-indicator" *ngIf="row.value.filename" [ngClass]="{'file-exists': row.value.file_exists, 'file-missing': row.value.file_exists === false}" [title]="row.value.file_exists ? 'File exists' : 'File not found'">
<fa-icon *ngIf="row.value.file_exists" [icon]="faCheckCircle" class="text-success"></fa-icon>
<fa-icon *ngIf="row.value.file_exists === false" [icon]="faTimesCircle" class="text-warning"></fa-icon>
</span>
<span ngbTooltip="{{row.value.msg}} | {{row.value.error}}">
<a *ngIf="!!row.value.filename && row.value.file_exists !== false; else noDownloadLink" href="{{buildDownloadLink(row.value)}}" target="_blank">{{ row.value.title }}</a>
</span>
<ng-template #noDownloadLink>
{{row.value.title}}
<span *ngIf="row.value.msg"><br>{{row.value.msg}}</span>
<span *ngIf="row.value.error"><br>Error: {{row.value.error}}</span>
</ng-template>
</td>
<td>{{ formatTimestamp(row.value.timestamp) }}</td>
<td class="folder-edit-cell">
<ng-select [items]="customDirsForDownload(row.value)"
placeholder="Default"
[addTag]="allowCustomDir.bind(this)"
addTagText="Create directory"
[ngModel]="row.value.folder"
(change)="folderChanged(row.key, $event)"
[clearable]="true"
[searchable]="true"
[closeOnSelect]="true"
appendTo="body"
ngbTooltip="Move file to another folder">
</ng-select>
</td>
<td>
<span *ngIf="row.value.size">{{ row.value.size | fileSize }}</span>
</td>
<td>
<div class="d-flex">
<button *ngIf="row.value.status == 'error'" type="button" class="btn btn-link" (click)="retryDownload(row.key, row.value)"><fa-icon [icon]="faRedoAlt"></fa-icon></button>
<a *ngIf="row.value.filename" href="{{buildDownloadLink(row.value)}}" download class="btn btn-link"><fa-icon [icon]="faDownload"></fa-icon></a>
<a href="{{row.value.url}}" target="_blank" class="btn btn-link"><fa-icon [icon]="faExternalLinkAlt"></fa-icon></a>
<button type="button" class="btn btn-link" (click)="delDownload('done', row.key)"><fa-icon [icon]="faTrashAlt"></fa-icon></button>
</div>
</td>
</tr>
</ng-container>
</tbody>
</table>
</div>
</div>
</main><!-- /.container -->
<footer class="footer navbar-dark bg-dark py-3 mt-5">
<div class="container text-center">
<div class="footer-content" *ngIf="ytDlpVersion && metubeVersion">
<div class="version-item">
<span class="version-label">yt-dlp</span>
<span class="version-value">{{ytDlpVersion}}</span>
</div>
<div class="version-separator"></div>
<div class="version-item">
<span class="version-label">MeTube</span>
<span class="version-value">{{metubeVersion}}</span>
</div>
<div class="version-separator"></div>
<div class="version-item" *ngIf="ytDlpOptionsUpdateTime">
<span class="version-label">yt-dlp-options</span>
<span class="version-value">{{ytDlpOptionsUpdateTime}}</span>
</div>
<div class="version-separator" *ngIf="ytDlpOptionsUpdateTime"></div>
<a href="https://github.com/alexta69/metube" target="_blank" class="github-link">
<fa-icon [icon]="faGithub"></fa-icon>
<span>GitHub</span>
</a>
</div>
</div>
</footer>
<app-toasts></app-toasts>

View File

@ -1,341 +1,149 @@
.button-toggle-theme:focus, .button-toggle-theme:active
box-shadow: none
outline: 0px
:host
display: block
height: 100vh
.add-url-box
max-width: 960px
margin: 4rem auto
.app
display: grid
grid-template-columns: var(--sidebar-w) 1fr
height: 100vh
overflow: hidden
.add-url-component
margin: 0.5rem auto
.add-url-group
width: 100%
button.add-url
width: 100%
.folder-dropdown-menu
width: 500px
max-width: calc(100vw - 3rem)
.folder-dropdown-menu .input-group
.main
display: flex
padding-left: 5px
padding-right: 5px
flex-direction: column
overflow: hidden
position: relative
min-width: 0
.sortable-header
cursor: pointer
user-select: none
.content
flex: 1
min-height: 0
overflow: hidden
padding: 18px 22px 20px
display: flex
flex-direction: column
gap: 22px
.metube-tabs
margin-top: 3.5rem
font-size: 1.1rem
// Activity drawer
.scrim
position: fixed
inset: 0
background: oklch(25% 0.02 240 / 0.35)
z-index: 40
.nav-link
cursor: pointer
.activity
position: fixed
top: 0
right: 0
bottom: 0
width: 340px
max-width: 100vw
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
button:hover
text-decoration: none
&.show
transform: translateX(0)
th
border-top: 0
border-bottom-width: 3px !important
vertical-align: middle !important
white-space: nowrap
.activity-head
display: flex
align-items: center
padding: 16px 18px
border-bottom: 1px solid var(--border)
flex: none
td
vertical-align: middle
.disabled
opacity: 0.5
pointer-events: none
.folder-edit-cell
min-width: 12rem
.folder-group-header
cursor: pointer
user-select: none
td
background: var(--bs-secondary-bg)
.activity-title
font-size: 15px
font-weight: 600
color: var(--bs-secondary-color)
padding-top: 0.4rem
padding-bottom: 0.4rem
color: var(--fg)
.folder-group-chevron
width: 0.75rem
opacity: 0.7
.folder-group-count
display: inline-block
margin-left: 0.5rem
padding: 0.1rem 0.5rem
border-radius: 1rem
background: var(--bs-tertiary-bg)
font-size: 0.75rem
font-weight: 500
.form-switch
input
margin-top: 5px
.download-progressbar
width: 12rem
.icon-btn
margin-left: auto
.batch-panel
margin-top: 15px
border: 1px solid #ccc
border-radius: 8px
padding: 15px
background-color: #fff
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1)
.activity-body
flex: 1
overflow-y: auto
padding: 14px 18px
.batch-panel-header
border-bottom: 1px solid #eee
padding-bottom: 8px
margin-bottom: 15px
h4
font-size: 1.5rem
margin: 0
.activity-empty
text-align: center
color: var(--muted)
font-size: 13px
padding: 40px 0
.batch-panel-body
textarea.form-control
resize: vertical
.activity-item
padding: 10px 0
border-bottom: 1px solid var(--border)
.activity-msg
font-size: 13px
color: var(--fg)
margin-bottom: 3px
.activity-url
font-size: 11.5px
color: var(--muted)
white-space: nowrap
overflow: hidden
text-overflow: ellipsis
font-family: var(--font-mono)
.activity-time
font-size: 11px
color: var(--muted)
margin-top: 4px
.activity-clear
margin-top: 14px
width: 100%
justify-content: center
.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
&.neutral:hover
background: var(--bg)
color: var(--fg)
.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
&:hover
background: var(--bg)
border-color: oklch(82% 0.01 240)
.batch-status
font-size: 0.9rem
color: #555
.d-flex.my-3
margin-top: 1rem
margin-bottom: 1rem
color: var(--muted)
.modal.fade.show
background-color: rgba(0, 0, 0, 0.5)
.modal-header
border-bottom: 1px solid #eee
@media (max-width: 900px)
.app
grid-template-columns: 1fr
.modal-body
textarea.form-control
resize: vertical
.add-url
display: inline-flex
align-items: center
justify-content: center
.spinner-border
margin-right: 0.5rem
::ng-deep .ng-select
flex: 1
.ng-select-container
min-height: 38px
.ng-value
white-space: nowrap
overflow: visible
.ng-dropdown-panel
.ng-dropdown-panel-items
max-height: 300px
.ng-option
white-space: nowrap
overflow: visible
text-overflow: ellipsis
:host
display: flex
flex-direction: column
min-height: 100vh
main
flex-grow: 1
.footer
width: 100%
padding: 10px 0
background: linear-gradient(to bottom, rgba(0,0,0,0.2), rgba(0,0,0,0.1))
.footer-content
display: flex
justify-content: center
align-items: center
gap: 20px
color: #fff
font-size: 0.9rem
.version-item
display: flex
align-items: center
gap: 8px
.version-label
font-size: 0.75rem
text-transform: uppercase
letter-spacing: 0.5px
opacity: 0.7
.version-value
font-family: monospace
font-size: 0.85rem
padding: 2px 6px
background: rgba(255,255,255,0.1)
border-radius: 4px
.version-separator
width: 1px
height: 16px
background: rgba(255,255,255,0.2)
margin: 0 4px
.github-link
display: flex
align-items: center
gap: 6px
color: #fff
text-decoration: none
font-size: 0.85rem
padding: 2px 8px
border-radius: 4px
transition: background-color 0.2s ease
&:hover
background: rgba(255,255,255,0.1)
color: #fff
text-decoration: none
i
font-size: 1rem
.download-metrics
display: flex
align-items: center
gap: 16px
margin-left: 24px
.metric
display: flex
align-items: center
gap: 6px
font-size: 0.9rem
color: #adb5bd
fa-icon
font-size: 1rem
span
white-space: nowrap
.website-tag
display: inline-block
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%)
color: white
padding: 2px 8px
border-radius: 4px
font-size: 0.7rem
font-weight: 600
text-transform: uppercase
letter-spacing: 0.5px
margin-right: 8px
white-space: nowrap
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1)
transition: all 0.2s ease
&:hover
transform: translateY(-1px)
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.15)
.file-exists-indicator
display: inline-block
margin-right: 6px
font-size: 0.9rem
fa-icon
vertical-align: middle
// Events display area
.events-container
background: var(--bs-body-bg)
border: 1px solid var(--bs-border-color)
border-radius: 0.5rem
overflow: hidden
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1)
margin-top: 1rem
.events-header
padding: 0.75rem 1rem
background: var(--bs-secondary-bg)
border-bottom: 1px solid var(--bs-border-color)
h6
color: var(--bs-secondary-color)
font-weight: 600
display: flex
align-items: center
fa-icon
opacity: 0.7
.events-list
max-height: 300px
overflow-y: auto
.event-item
display: flex
align-items: flex-start
padding: 0.875rem 1rem
border-bottom: 1px solid var(--bs-border-color)
transition: background-color 0.2s
&:last-child
border-bottom: none
&:hover
background: var(--bs-tertiary-bg)
.event-icon
flex-shrink: 0
width: 32px
height: 32px
display: flex
align-items: center
justify-content: center
border-radius: 50%
margin-right: 0.75rem
fa-icon
font-size: 1.1rem
&.event-duplicate_skipped
.event-icon
background: rgba(255, 193, 7, 0.15)
color: #ffc107
.event-content
flex: 1
min-width: 0
.event-message
font-size: 0.9375rem
color: var(--bs-body-color)
margin-bottom: 0.25rem
font-weight: 500
.event-url
font-size: 0.8125rem
color: var(--bs-secondary-color)
white-space: nowrap
overflow: hidden
text-overflow: ellipsis
font-family: 'Courier New', monospace
.event-time
flex-shrink: 0
font-size: 0.75rem
color: var(--bs-secondary-color)
margin-left: 0.75rem
whitespace: nowrap
.content
padding: 14px 14px 16px

View File

@ -1,31 +1,24 @@
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { FormsModule } from '@angular/forms';
import { provideHttpClient } from '@angular/common/http';
import { CookieService } from 'ngx-cookie-service';
import { AppComponent } from './app.component';
import { DownloadsService } from './downloads.service';
import { MeTubeSocket } from './metube-socket';
describe('AppComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [
AppComponent
],
declarations: [AppComponent],
imports: [FormsModule],
schemas: [NO_ERRORS_SCHEMA],
providers: [MeTubeSocket, CookieService, DownloadsService, provideHttpClient()]
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it(`should have as title 'metube'`, () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app.title).toEqual('metube');
});
it('should render title', () => {
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.content span').textContent).toContain('metube app is running!');
expect(fixture.componentInstance).toBeTruthy();
});
});

File diff suppressed because it is too large Load Diff

View File

@ -5,28 +5,46 @@ import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { CookieService } from 'ngx-cookie-service';
import { ScrollingModule } from '@angular/cdk/scrolling';
import { AppComponent } from './app.component';
import { EtaPipe, SpeedPipe, EncodeURIComponent, FileSizePipe } from './downloads.pipe';
import { MasterCheckboxComponent, SlaveCheckboxComponent } from './master-checkbox.component';
import { EtaPipe, SpeedPipe, EncodeURIComponent, FileSizePipe, DurationPipe } from './downloads.pipe';
import { MeTubeSocket } from './metube-socket';
import { NgSelectModule } from '@ng-select/ng-select';
import { ServiceWorkerModule } from '@angular/service-worker';
import { SidebarComponent } from './sidebar.component';
import { CommandBarComponent } from './command-bar.component';
import { StatsStripComponent } from './stats-strip.component';
import { ActiveDownloadsComponent } from './active-downloads.component';
import { DownloadListComponent } from './download-list.component';
import { DownloadRowComponent } from './download-row.component';
import { DownloadCardComponent } from './download-card.component';
import { AdvancedOptionsComponent } from './advanced-options.component';
import { ToastsComponent } from './toasts.component';
@NgModule({ declarations: [
AppComponent,
EtaPipe,
SpeedPipe,
FileSizePipe,
DurationPipe,
EncodeURIComponent,
MasterCheckboxComponent,
SlaveCheckboxComponent
SidebarComponent,
CommandBarComponent,
StatsStripComponent,
ActiveDownloadsComponent,
DownloadListComponent,
DownloadRowComponent,
DownloadCardComponent,
AdvancedOptionsComponent,
ToastsComponent
],
bootstrap: [AppComponent], imports: [BrowserModule,
FormsModule,
NgbModule,
FontAwesomeModule,
NgSelectModule,
ScrollingModule,
ServiceWorkerModule.register('custom-service-worker.js', {
enabled: !isDevMode(),
// Register the ServiceWorker as soon as the application is stable

View File

@ -0,0 +1,73 @@
<div class="commandbar">
<div class="cb-row">
<button class="menu-btn" (click)="menu.emit()" 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" type="url" placeholder="粘贴视频或播放列表 URL…" aria-label="视频 URL"
autocomplete="off" spellcheck="false"
[ngModel]="url" (ngModelChange)="urlChange.emit($event)"
(keydown.enter)="add.emit(url)" [disabled]="disabled">
</div>
<button class="primary-btn" (click)="add.emit(url)" [disabled]="disabled">
<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 *ngFor="let q of quickQualities" type="button" class="chip" [class.on]="quality === q.id" (click)="qualityChange.emit(q.id)">
{{ qualityLabel(q.id) }}
</button>
<div class="more-wrap" *ngIf="moreQualities.length">
<button type="button" class="chip chip-more" [class.on]="menuOpen === 'quality'" (click)="toggleMenu('quality')">
更多
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<div class="menu" *ngIf="menuOpen === 'quality'">
<button *ngFor="let q of moreQualities" type="button" class="menu-item" [class.on]="quality === q.id" (click)="pickQuality(q.id)">
{{ qualityLabel(q.id) }}
</button>
</div>
</div>
<span class="chip-sep"></span>
<span class="cb-label">格式</span>
<button *ngFor="let f of quickFormats" type="button" class="chip" [class.on]="format === f.id" (click)="formatChange.emit(f.id)">
{{ formatLabel(f.id) }}
</button>
<div class="more-wrap" *ngIf="moreFormats.length">
<button type="button" class="chip chip-more" [class.on]="menuOpen === 'format'" (click)="toggleMenu('format')">
更多
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<div class="menu" *ngIf="menuOpen === 'format'">
<button *ngFor="let f of moreFormats" type="button" class="menu-item" [class.on]="format === f.id" (click)="pickFormat(f.id)">
{{ formatLabel(f.id) }}
</button>
</div>
</div>
<span class="chip-sep"></span>
<ng-select class="folder-select"
[items]="customDirs$ | async"
placeholder="默认目录"
[addTag]="allowCustomDir"
addTagText="创建目录"
[clearable]="true"
[searchable]="true"
[closeOnSelect]="true"
[disabled]="disabled"
[(ngModel)]="folder"
(ngModelChange)="folderChange.emit($event)"
ngbTooltip="选择保存目录,可直接输入创建新目录">
</ng-select>
<button class="advanced-link" (click)="openAdvanced.emit()">
<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>

View File

@ -0,0 +1,193 @@
:host
display: block
flex: none
.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
&:focus-within
border-color: var(--accent)
background: var(--surface)
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)
&::placeholder
color: var(--muted)
font-family: var(--font-body)
.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
&:hover:not(:disabled)
background: var(--accent-strong)
&:active:not(:disabled)
transform: translateY(1px)
&:disabled
opacity: 0.55
cursor: default
.cb-chips
display: flex
gap: 6px
margin-top: 10px
align-items: center
flex-wrap: wrap
position: relative
.cb-label
font-size: 12px
color: var(--muted)
margin-right: 2px
.chip
display: inline-flex
align-items: center
gap: 4px
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
&:hover
background: var(--bg)
&.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
.more-wrap
position: relative
.menu
position: absolute
top: calc(100% + 6px)
left: 0
z-index: 40
min-width: 120px
background: var(--surface)
border: 1px solid var(--border)
border-radius: 8px
box-shadow: 0 8px 24px oklch(30% 0.02 240 / 0.14)
padding: 5px
display: flex
flex-direction: column
gap: 2px
.menu-item
display: flex
align-items: center
width: 100%
border: none
background: none
border-radius: 6px
padding: 7px 10px
font-size: 12.5px
color: var(--fg)
text-align: left
&:hover
background: var(--bg)
&.on
background: var(--accent-tint)
color: var(--accent-strong)
font-weight: 550
.folder-select
display: inline-block
min-width: 120px
max-width: 220px
.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
&:hover
background: var(--bg)
color: var(--fg)
.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
@media (max-width: 900px)
.menu-btn
display: grid !important
.cb-row
flex-wrap: wrap
.url-wrap
width: 100%
flex: none

View File

@ -0,0 +1,81 @@
import { Component, ElementRef, EventEmitter, HostListener, Input, Output } from '@angular/core';
import { Observable } from 'rxjs';
import { Format, Quality } from './formats';
import { formatLabel, qualityLabel } from './labels';
const QUICK_QUALITY_IDS = ['best', '1080', '720', 'audio'];
const QUICK_FORMAT_IDS = ['any', 'mp4', 'm4a', 'mp3'];
@Component({
selector: 'app-command-bar',
standalone: false,
templateUrl: './command-bar.component.html',
styleUrls: ['./command-bar.component.sass']
})
export class CommandBarComponent {
@Input() url = '';
@Input() quality = 'best';
@Input() format = 'any';
@Input() folder = '';
@Input() qualities: Quality[] = [];
@Input() formats: Format[] = [];
@Input() customDirs$: Observable<string[]>;
@Input() allowCustomDir: (tag: string) => string | boolean = () => false;
@Input() disabled = false;
@Output() urlChange = new EventEmitter<string>();
@Output() add = new EventEmitter<string>();
@Output() qualityChange = new EventEmitter<string>();
@Output() formatChange = new EventEmitter<string>();
@Output() folderChange = new EventEmitter<string>();
@Output() openAdvanced = new EventEmitter<void>();
@Output() menu = new EventEmitter<void>();
menuOpen: 'quality' | 'format' | null = null;
constructor(private el: ElementRef) {}
@HostListener('document:click', ['$event'])
onDocClick(event: MouseEvent): void {
if (!this.el.nativeElement.contains(event.target)) {
this.menuOpen = null;
}
}
get quickQualities(): Quality[] {
return this.qualities.filter(q => QUICK_QUALITY_IDS.includes(q.id));
}
get moreQualities(): Quality[] {
return this.qualities.filter(q => !QUICK_QUALITY_IDS.includes(q.id));
}
get quickFormats(): Format[] {
return this.formats.filter(f => QUICK_FORMAT_IDS.includes(f.id));
}
get moreFormats(): Format[] {
return this.formats.filter(f => !QUICK_FORMAT_IDS.includes(f.id));
}
qualityLabel = qualityLabel;
formatLabel = formatLabel;
folderLabel(): string {
return this.folder || '默认目录';
}
toggleMenu(which: 'quality' | 'format'): void {
this.menuOpen = this.menuOpen === which ? null : which;
}
pickQuality(id: string): void {
this.menuOpen = null;
this.qualityChange.emit(id);
}
pickFormat(id: string): void {
this.menuOpen = null;
this.formatChange.emit(id);
}
}

View File

@ -0,0 +1,49 @@
<div class="card" [class.selected]="selected">
<input type="checkbox" class="card-check" [checked]="selected" (change)="selectChange.emit($event.target.checked)" [attr.aria-label]="'选择 ' + row.value.title">
<div class="thumb">
<img *ngIf="row.value.thumbnail" [src]="thumbUrl()" [alt]="row.value.title" loading="lazy">
<span class="thumb-ph" *ngIf="!row.value.thumbnail">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><polyline points="10 9 15 12 10 15"/></svg>
</span>
<span class="dur num" *ngIf="row.value.duration">{{ row.value.duration | duration }}</span>
<span *ngIf="row.value.file_exists === false" class="file-warn" title="文件不存在">
<fa-icon [icon]="faTimesCircle"></fa-icon>
</span>
</div>
<div class="card-body">
<div class="card-title" [title]="row.value.title">{{ row.value.title }}</div>
<div class="card-meta">
<span class="badge folder">{{ folderLabel() }}</span>
<span class="badge fmt">{{ qualityLabel(row.value.quality) }} · {{ formatLabel(row.value.format) }}</span>
<span class="card-added" [title]="fullTime()">{{ relativeTime(row.value.timestamp) }}</span>
<span class="num card-size">{{ row.value.size ? (row.value.size | fileSize) : '—' }}</span>
</div>
<div class="card-actions">
<a *ngIf="downloadUrl" class="icon-btn neutral" [href]="downloadUrl" target="_blank" title="下载文件">
<fa-icon [icon]="faDownload"></fa-icon>
</a>
<button *ngIf="row.value.status === 'error'" type="button" class="icon-btn neutral" (click)="retry.emit({ key: row.key, dl: row.value })" title="重试">
<fa-icon [icon]="faRedoAlt"></fa-icon>
</button>
<a class="icon-btn neutral" [href]="row.value.url" target="_blank" title="打开原链接">
<fa-icon [icon]="faExternalLinkAlt"></fa-icon>
</a>
<button type="button" class="icon-btn" (click)="del.emit(row.key)" title="删除" aria-label="删除">
<fa-icon [icon]="faTrashAlt"></fa-icon>
</button>
<ng-select class="card-folder-select"
[items]="customDirs"
placeholder="默认"
[addTag]="allowCustomDir"
addTagText="创建目录"
[clearable]="true"
[searchable]="true"
[closeOnSelect]="true"
[ngModel]="row.value.folder"
(change)="folderChange.emit({ key: row.key, folder: $event || '' })"
appendTo="body"
ngbTooltip="移动文件到其他目录">
</ng-select>
</div>
</div>
</div>

View File

@ -0,0 +1,156 @@
:host
display: block
.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
height: 100%
&:hover
border-color: oklch(80% 0.012 240)
box-shadow: 0 4px 14px oklch(30% 0.02 240 / 0.08)
&.selected
border-color: var(--accent)
box-shadow: 0 0 0 1px var(--accent)
.thumb
border-radius: 0
aspect-ratio: 16 / 9
overflow: hidden
background: var(--placeholder)
position: relative
display: grid
place-items: center
img
display: block
width: 100%
height: 100%
object-fit: cover
.thumb-ph
color: var(--muted)
.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
.file-warn
position: absolute
left: 8px
top: 8px
color: var(--warn)
background: oklch(20% 0.02 240 / 0.55)
border-radius: 50%
width: 22px
height: 22px
display: grid
place-items: center
.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: 6px
margin-top: 8px
font-size: 11.5px
color: var(--muted)
flex-wrap: wrap
.num
color: var(--fg)
.card-size
margin-left: auto
.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
white-space: nowrap
&.folder
color: var(--fg)
background: var(--surface)
&.fmt
font-family: var(--font-mono)
font-weight: 500
.card-added
white-space: nowrap
.card-actions
display: flex
gap: 2px
margin-top: 8px
align-items: center
.icon-btn
width: 28px
height: 28px
display: grid
place-items: center
border: 1px solid transparent
background: none
border-radius: 6px
color: var(--muted)
text-decoration: none
transition: background 0.12s ease, color 0.12s ease
font-size: 12px
&:hover
background: var(--danger-tint)
color: var(--danger)
&.neutral:hover
background: var(--bg)
color: var(--fg)
.card-folder-select
width: 108px
margin-left: auto
.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

View File

@ -0,0 +1,49 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { faDownload, faExternalLinkAlt, faRedoAlt, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
import { faCheckCircle, faTimesCircle } from '@fortawesome/free-regular-svg-icons';
import { Download, DownloadsService } from './downloads.service';
import { KeyedDownload } from './active-downloads.component';
import { formatLabel, qualityLabel, relativeTime } from './labels';
@Component({
selector: 'app-download-card',
standalone: false,
templateUrl: './download-card.component.html',
styleUrls: ['./download-card.component.sass']
})
export class DownloadCardComponent {
@Input() row: KeyedDownload;
@Input() selected = false;
@Input() customDirs: string[] = [];
@Input() allowCustomDir: (tag: string) => string | boolean = () => false;
@Input() downloadUrl = '';
@Output() selectChange = new EventEmitter<boolean>();
@Output() del = new EventEmitter<string>();
@Output() retry = new EventEmitter<{ key: string; dl: Download }>();
@Output() folderChange = new EventEmitter<{ key: string; folder: string }>();
faDownload = faDownload;
faExternalLinkAlt = faExternalLinkAlt;
faRedoAlt = faRedoAlt;
faTrashAlt = faTrashAlt;
faCheckCircle = faCheckCircle;
faTimesCircle = faTimesCircle;
qualityLabel = qualityLabel;
formatLabel = formatLabel;
relativeTime = relativeTime;
constructor(private downloads: DownloadsService) {}
thumbUrl(): string {
return this.downloads.thumbnailUrl(this.row.key);
}
folderLabel(): string {
return this.row.value.folder || '默认';
}
fullTime(): string {
return new Date((this.row.value.timestamp || 0) / 1e6).toLocaleString();
}
}

View File

@ -0,0 +1,133 @@
<section class="list-section">
<div class="section-head">
<h2 class="section-title">{{ title }}</h2>
<span class="section-meta num" *ngIf="status !== 'downloading'">{{ subtitle }}</span>
</div>
<div class="list-toolbar" *ngIf="status !== 'downloading'">
<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 type="search" placeholder="筛选标题…" aria-label="筛选标题"
[ngModel]="filterText" (ngModelChange)="filterChange.emit($event)">
</div>
<select class="tool-select" aria-label="排序方式" [ngModel]="sort" (ngModelChange)="sortChange.emit($event)">
<option value="date">按添加时间</option>
<option value="title">按标题</option>
<option value="size">按大小</option>
</select>
<button class="sort-dir" (click)="sortAscendingChange.emit(!sortAscending)" [title]="sortAscending ? '升序,点击切换' : '降序,点击切换'">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline [attr.points]="sortAscending ? '6 15 12 9 18 15' : '6 9 12 15 18 9'"/></svg>
</button>
<div class="view-toggle" *ngIf="status === 'completed'" aria-label="视图切换">
<button type="button" [class.on]="view === 'list'" (click)="viewChange.emit('list')" title="列表视图" aria-label="列表视图">
<fa-icon [icon]="faList"></fa-icon>
</button>
<button type="button" [class.on]="view === 'grid'" (click)="viewChange.emit('grid')" title="网格视图" aria-label="网格视图">
<fa-icon [icon]="faThLarge"></fa-icon>
</button>
</div>
<button class="ghost-btn" (click)="toggleSelectAll()">全选</button>
<span class="result-count num">显示 {{ rows.length }} / {{ rows.length }}</span>
<ng-container *ngIf="status === 'completed'">
<button class="ghost-btn" (click)="clearCompleted.emit()">清除已完成</button>
<button class="ghost-btn" (click)="clearFailed.emit()">清除失败</button>
<button class="ghost-btn" (click)="retryFailed.emit()">重试失败</button>
<button class="ghost-btn" [disabled]="selected.size === 0" (click)="downloadSelected.emit(Array.from(selected))">下载所选</button>
</ng-container>
</div>
<ng-container *ngIf="!loading; else skeletonTpl">
<div class="empty" *ngIf="status === 'downloading'">
<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">进行中的任务会实时显示在「正在下载」面板</div>
</div>
<ng-container *ngIf="status !== 'downloading'">
<div class="list-frame" *ngIf="rows.length; else emptyTpl">
<cdk-virtual-scroll-viewport class="viewport" [itemSize]="ROW_H" *ngIf="status === 'queued' || view === 'list'">
<div class="row-slot" *cdkVirtualFor="let row of rows; trackBy: trackByKey">
<app-download-row
[row]="row"
[status]="status"
[selected]="selected.has(row.key)"
[customDirs]="customDirsFor(row.value)"
[allowCustomDir]="allowCustomDir"
[downloadUrl]="status === 'completed' ? downloadUrlFor(row.value) : ''"
(selectChange)="toggleSelect(row.key, $event)"
(start)="start.emit([$event])"
(del)="del.emit([$event])"
(retry)="retry.emit($event)"
(folderChange)="folderEdit.emit($event)">
</app-download-row>
</div>
</cdk-virtual-scroll-viewport>
<div class="grid-wrap" *ngIf="status === 'completed' && view === 'grid'">
<div class="grid">
<app-download-card
*ngFor="let row of rows; trackBy: trackByKey"
[row]="row"
[selected]="selected.has(row.key)"
[customDirs]="customDirsFor(row.value)"
[allowCustomDir]="allowCustomDir"
[downloadUrl]="downloadUrlFor(row.value)"
(selectChange)="toggleSelect(row.key, $event)"
(del)="del.emit([$event])"
(retry)="retry.emit($event)"
(folderChange)="folderEdit.emit($event)">
</app-download-card>
</div>
</div>
</div>
<ng-template #emptyTpl>
<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">{{ emptyTitle }}</div>
<div class="empty-sub">{{ emptySub }}</div>
</div>
</ng-template>
</ng-container>
</ng-container>
<ng-template #skeletonTpl>
<div class="list-frame">
<div class="sk-row" *ngFor="let _ of [0, 1, 2, 3, 4, 5]">
<div class="sk-check shimmer"></div>
<div class="sk-thumb shimmer"></div>
<div class="sk-lines">
<div class="shimmer line w60"></div>
<div class="shimmer line w35"></div>
</div>
<div class="sk-side shimmer"></div>
</div>
</div>
</ng-template>
<div class="batchbar" [class.show]="selected.size > 0">
<span class="batch-count">已选 {{ selected.size }} 项</span>
<button *ngIf="status === 'queued'" type="button" class="batch-btn" (click)="startSelected()">
<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>
<div class="move-wrap">
<button type="button" class="batch-btn" (click)="moveMenuOpen = !moveMenuOpen">
<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>
<div class="move-menu" *ngIf="moveMenuOpen">
<button *ngFor="let folder of folderOptions()" type="button" class="move-item" (click)="moveTo(folder)">{{ folder }}</button>
<button *ngIf="!folderOptions().length" type="button" class="move-item" disabled>暂无可移动的目录</button>
<div class="move-new">
<input type="text" placeholder="新建目录…" [(ngModel)]="newFolder" (keydown.enter)="confirmNewFolder()">
<button type="button" class="batch-btn small" (click)="confirmNewFolder()">创建</button>
</div>
</div>
</div>
<button type="button" class="batch-btn danger" (click)="delSelected()">
<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 type="button" class="batch-btn" (click)="clearSelection()">取消选择</button>
</div>
</section>

View File

@ -0,0 +1,361 @@
:host
display: flex
flex-direction: column
flex: 1
min-height: 160px
.list-section
display: flex
flex-direction: column
flex: 1
min-height: 0
.section-head
display: flex
align-items: baseline
gap: 10px
margin-bottom: 10px
flex: none
.section-title
font-family: var(--font-display)
font-size: 15px
font-weight: 600
letter-spacing: -0.01em
line-height: 1.35
margin: 0
.section-meta
font-size: 12px
color: var(--muted)
font-family: var(--font-mono)
.list-toolbar
display: flex
align-items: center
gap: 8px
margin-bottom: 12px
flex-wrap: wrap
flex: none
.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
&:focus-within
border-color: var(--accent)
svg
color: var(--muted)
flex: none
input
border: none
background: none
outline: none
font-size: 13px
color: var(--fg)
width: 100%
&::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
.sort-dir
width: 34px
height: 34px
display: grid
place-items: center
border: 1px solid var(--border)
background: var(--surface)
border-radius: 6px
color: var(--muted)
transition: background 0.12s ease, color 0.12s ease
&:hover
background: var(--bg)
color: var(--fg)
.view-toggle
display: flex
border: 1px solid var(--border)
border-radius: 6px
overflow: hidden
background: var(--surface)
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
& + button
border-left: 1px solid var(--border)
&.on
background: var(--fg)
color: var(--surface)
&:not(.on):hover
background: var(--bg)
color: var(--fg)
.ghost-btn
display: inline-flex
align-items: center
gap: 7px
padding: 6px 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
&:hover
background: var(--bg)
border-color: oklch(82% 0.01 240)
.result-count
margin-left: auto
font-size: 12px
color: var(--muted)
font-family: var(--font-mono)
.list-frame
flex: 1
min-height: 0
display: flex
flex-direction: column
background: var(--surface)
border: 1px solid var(--border)
border-radius: var(--radius)
overflow: hidden
.viewport
flex: 1
min-height: 0
.row-slot
height: 66px
overflow: hidden
.grid-wrap
flex: 1
min-height: 0
overflow-y: auto
padding: 14px
.grid
display: grid
grid-template-columns: repeat(auto-fill, minmax(218px, 1fr))
gap: 14px
.empty
text-align: center
padding: 56px 20px
border: 1px dashed var(--border)
border-radius: var(--radius)
background: var(--surface)
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)
// Skeleton
.sk-row
display: grid
grid-template-columns: 30px 88px 1fr 60px
gap: 12px
align-items: center
padding: 10px 14px 10px 14px
border-bottom: 1px solid var(--border)
.shimmer
background: linear-gradient(90deg, var(--placeholder) 25%, oklch(90% 0.008 240) 50%, var(--placeholder) 75%)
background-size: 200% 100%
animation: shimmer 1.4s ease infinite
[data-bs-theme="dark"] .shimmer
background: linear-gradient(90deg, var(--placeholder) 25%, oklch(26% 0.014 250) 50%, var(--placeholder) 75%)
background-size: 200% 100%
.sk-check
width: 17px
height: 17px
border-radius: 4px
.sk-thumb
width: 88px
aspect-ratio: 16 / 9
border-radius: 5px
.sk-lines
.line
height: 10px
border-radius: 5px
margin-bottom: 8px
&.w60
width: 60%
&.w35
width: 35%
.sk-side
width: 50px
height: 10px
border-radius: 5px
@keyframes shimmer
from
background-position: 200% 0
to
background-position: -200% 0
// Batch bar
.batchbar
position: fixed
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
pointer-events: none
&.show
transform: translate(-50%, 0)
pointer-events: auto
.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
&:hover
background: oklch(100% 0 0 / 0.2)
&.danger:hover
background: var(--danger)
&.small
padding: 6px 10px
font-size: 12px
.move-wrap
position: relative
.move-menu
position: absolute
bottom: calc(100% + 8px)
left: 0
z-index: 35
min-width: 190px
background: var(--surface)
border: 1px solid var(--border)
border-radius: 8px
box-shadow: 0 8px 24px oklch(30% 0.02 240 / 0.18)
padding: 6px
display: flex
flex-direction: column
gap: 2px
color: var(--fg)
.move-item
width: 100%
border: none
background: none
border-radius: 6px
padding: 7px 10px
font-size: 12.5px
color: var(--fg)
text-align: left
&:hover:not(:disabled)
background: var(--bg)
&:disabled
color: var(--muted)
.move-new
display: flex
gap: 6px
padding: 6px 2px 2px
border-top: 1px solid var(--border)
margin-top: 4px
input
flex: 1
min-width: 0
border: 1px solid var(--border)
background: var(--bg)
border-radius: 6px
padding: 5px 8px
font-size: 12.5px
color: var(--fg)
outline: none
&:focus
border-color: var(--accent)
@media (max-width: 900px)
.filter-wrap
width: 100%
.result-count
margin-left: 0
.grid
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr))

View File

@ -0,0 +1,132 @@
import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import { faList, faThLarge } from '@fortawesome/free-solid-svg-icons';
import { Download } from './downloads.service';
import { KeyedDownload } from './active-downloads.component';
export type ListStatus = 'downloading' | 'queued' | 'completed';
@Component({
selector: 'app-download-list',
standalone: false,
templateUrl: './download-list.component.html',
styleUrls: ['./download-list.component.sass']
})
export class DownloadListComponent implements OnChanges {
@Input() status: ListStatus = 'queued';
@Input() rows: KeyedDownload[] = [];
@Input() folderName = '全部';
@Input() filterText = '';
@Input() sort = 'date';
@Input() sortAscending = false;
@Input() view: 'list' | 'grid' = 'list';
@Input() loading = false;
@Input() customDirsFor: (dl: Download) => string[] = () => [];
@Input() allowCustomDir: (tag: string) => string | boolean = () => false;
@Input() downloadUrlFor: (dl: Download) => string = () => '';
@Output() filterChange = new EventEmitter<string>();
@Output() sortChange = new EventEmitter<string>();
@Output() sortAscendingChange = new EventEmitter<boolean>();
@Output() viewChange = new EventEmitter<'list' | 'grid'>();
@Output() start = new EventEmitter<string[]>();
@Output() del = new EventEmitter<string[]>();
@Output() move = new EventEmitter<{ ids: string[]; folder: string }>();
@Output() retry = new EventEmitter<{ key: string; dl: Download }>();
@Output() folderEdit = new EventEmitter<{ key: string; folder: string }>();
@Output() clearCompleted = new EventEmitter<void>();
@Output() clearFailed = new EventEmitter<void>();
@Output() retryFailed = new EventEmitter<void>();
@Output() downloadSelected = new EventEmitter<string[]>();
faList = faList;
faThLarge = faThLarge;
selected = new Set<string>();
moveMenuOpen = false;
newFolder = '';
readonly ROW_H = 66;
ngOnChanges(changes: SimpleChanges): void {
// Navigating to another status or folder resets the selection.
if (changes['status'] || changes['folderName']) {
this.clearSelection();
}
}
get title(): string {
return this.status === 'downloading' ? '正在下载' : this.status === 'queued' ? '排队中' : '已完成';
}
get subtitle(): string {
return `${this.folderName} · ${this.rows.length}`;
}
get emptyTitle(): string {
if (this.filterText) return '没有匹配的条目';
return this.status === 'queued' ? '队列为空' : '暂无已完成的下载';
}
get emptySub(): string {
if (this.filterText) return '试试清除筛选条件,或切换到其他文件夹';
return this.status === 'queued' ? '粘贴 URL 后即可加入下载队列' : '下载完成的任务会出现在这里';
}
trackByKey(_: number, row: KeyedDownload): string {
return row.key;
}
toggleSelect(key: string, checked: boolean): void {
if (checked) {
this.selected.add(key);
} else {
this.selected.delete(key);
}
this.moveMenuOpen = false;
}
allSelected(): boolean {
return this.rows.length > 0 && this.rows.every(r => this.selected.has(r.key));
}
toggleSelectAll(): void {
if (this.allSelected()) {
this.selected.clear();
} else {
this.rows.forEach(r => this.selected.add(r.key));
}
this.moveMenuOpen = false;
}
clearSelection(): void {
this.selected.clear();
this.moveMenuOpen = false;
}
startSelected(): void {
this.start.emit(Array.from(this.selected));
this.clearSelection();
}
delSelected(): void {
this.del.emit(Array.from(this.selected));
this.clearSelection();
}
moveTo(folder: string): void {
this.move.emit({ ids: Array.from(this.selected), folder });
this.clearSelection();
}
folderOptions(): string[] {
const set = new Set<string>();
this.rows.forEach(r => {
if (r.value.folder) set.add(r.value.folder);
});
return Array.from(set).sort((a, b) => a.localeCompare(b));
}
confirmNewFolder(): void {
const folder = this.newFolder.trim();
if (folder) this.moveTo(folder);
}
}

View File

@ -0,0 +1,55 @@
<div class="q-row" [class.selected]="selected">
<input type="checkbox" class="q-check" [checked]="selected" (change)="selectChange.emit($event.target.checked)" [attr.aria-label]="'选择 ' + row.value.title">
<div class="thumb">
<img *ngIf="row.value.thumbnail" [src]="thumbUrl()" [alt]="row.value.title" loading="lazy">
<span class="thumb-ph" *ngIf="!row.value.thumbnail">
<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="2" y="4" width="20" height="16" rx="2"/><polyline points="10 9 15 12 10 15"/></svg>
</span>
<span class="dur num" *ngIf="row.value.duration">{{ row.value.duration | duration }}</span>
</div>
<div class="q-main">
<div class="q-title" [title]="row.value.title">{{ row.value.title }}</div>
<div class="q-meta">
<span class="badge folder">{{ folderLabel() }}</span>
<span class="badge fmt">{{ qualityLabel(row.value.quality) }} · {{ formatLabel(row.value.format) }}</span>
<span class="q-added" [title]="fullTime()">{{ relativeTime(row.value.timestamp) }}</span>
<span *ngIf="status === 'completed' && row.value.file_exists === true" class="file-badge ok" title="文件存在">
<fa-icon [icon]="faCheckCircle"></fa-icon>
</span>
<span *ngIf="status === 'completed' && row.value.file_exists === false" class="file-badge missing" title="文件不存在">
<fa-icon [icon]="faTimesCircle"></fa-icon>
</span>
<ng-select class="row-folder-select"
[items]="customDirs"
placeholder="默认"
[addTag]="allowCustomDir"
addTagText="创建目录"
[clearable]="true"
[searchable]="true"
[closeOnSelect]="true"
[ngModel]="row.value.folder"
(change)="folderChange.emit({ key: row.key, folder: $event || '' })"
appendTo="body"
ngbTooltip="更改下载目录">
</ng-select>
</div>
</div>
<div class="q-side"><span class="num">{{ row.value.size ? (row.value.size | fileSize) : '—' }}</span></div>
<div class="q-actions">
<button *ngIf="status === 'queued'" type="button" class="icon-btn neutral" (click)="start.emit(row.key)" title="立即开始" aria-label="立即开始">
<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>
<a *ngIf="status === 'completed' && downloadUrl" class="icon-btn neutral" [href]="downloadUrl" target="_blank" title="下载文件">
<fa-icon [icon]="faDownload"></fa-icon>
</a>
<button *ngIf="status === 'completed' && row.value.status === 'error'" type="button" class="icon-btn neutral" (click)="retry.emit({ key: row.key, dl: row.value })" title="重试">
<fa-icon [icon]="faRedoAlt"></fa-icon>
</button>
<a class="icon-btn neutral" [href]="row.value.url" target="_blank" title="打开原链接">
<fa-icon [icon]="faExternalLinkAlt"></fa-icon>
</a>
<button type="button" class="icon-btn" (click)="del.emit(row.key)" title="删除" aria-label="删除">
<fa-icon [icon]="faTrashAlt"></fa-icon>
</button>
</div>
</div>

View File

@ -0,0 +1,156 @@
:host
display: block
height: 100%
.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)
height: 100%
box-sizing: border-box
transition: background 0.1s ease
&:hover
background: var(--bg)
&.selected
background: var(--accent-tint)
.q-check
width: 17px
height: 17px
accent-color: var(--accent)
cursor: pointer
margin-left: 6px
.thumb
border-radius: 5px
overflow: hidden
background: var(--placeholder)
position: relative
aspect-ratio: 16 / 9
display: grid
place-items: center
img
display: block
width: 100%
height: 100%
object-fit: cover
.thumb-ph
color: var(--muted)
.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
.q-main
min-width: 0
.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)
min-width: 0
.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
white-space: nowrap
&.folder
color: var(--fg)
background: var(--surface)
&.fmt
font-family: var(--font-mono)
font-weight: 500
.q-added
white-space: nowrap
.file-badge
display: inline-flex
color: var(--accent-strong)
&.missing
color: var(--warn)
.row-folder-select
width: 130px
flex: none
.q-side
text-align: right
font-size: 11.5px
color: var(--muted)
white-space: nowrap
.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
.icon-btn
width: 32px
height: 32px
display: grid
place-items: center
border: 1px solid transparent
background: none
border-radius: 6px
color: var(--muted)
text-decoration: none
transition: background 0.12s ease, color 0.12s ease
&:hover
background: var(--danger-tint)
color: var(--danger)
&.neutral:hover
background: var(--bg)
color: var(--fg)
@media (max-width: 900px)
.q-row
grid-template-columns: 30px 64px 1fr auto
.q-side
display: none

View File

@ -0,0 +1,51 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { faDownload, faExternalLinkAlt, faRedoAlt, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
import { faCheckCircle, faTimesCircle } from '@fortawesome/free-regular-svg-icons';
import { Download, DownloadsService } from './downloads.service';
import { KeyedDownload } from './active-downloads.component';
import { formatLabel, qualityLabel, relativeTime } from './labels';
@Component({
selector: 'app-download-row',
standalone: false,
templateUrl: './download-row.component.html',
styleUrls: ['./download-row.component.sass']
})
export class DownloadRowComponent {
@Input() row: KeyedDownload;
@Input() status: 'queued' | 'completed' = 'queued';
@Input() selected = false;
@Input() customDirs: string[] = [];
@Input() allowCustomDir: (tag: string) => string | boolean = () => false;
@Input() downloadUrl = '';
@Output() selectChange = new EventEmitter<boolean>();
@Output() start = new EventEmitter<string>();
@Output() del = new EventEmitter<string>();
@Output() retry = new EventEmitter<{ key: string; dl: Download }>();
@Output() folderChange = new EventEmitter<{ key: string; folder: string }>();
faDownload = faDownload;
faExternalLinkAlt = faExternalLinkAlt;
faRedoAlt = faRedoAlt;
faTrashAlt = faTrashAlt;
faCheckCircle = faCheckCircle;
faTimesCircle = faTimesCircle;
qualityLabel = qualityLabel;
formatLabel = formatLabel;
relativeTime = relativeTime;
constructor(private downloads: DownloadsService) {}
thumbUrl(): string {
return this.downloads.thumbnailUrl(this.row.key);
}
folderLabel(): string {
return this.row.value.folder || '默认';
}
fullTime(): string {
return new Date((this.row.value.timestamp || 0) / 1e6).toLocaleString();
}
}

View File

@ -91,3 +91,22 @@ export class FileSizePipe implements PipeTransform {
return `${unitValue.toFixed(2)} ${units[unitIndex]}`;
}
}
@Pipe({
name: 'duration',
standalone: false
})
export class DurationPipe implements PipeTransform {
transform(value: number, ...args: any[]): any {
if (value === null || value === undefined || isNaN(value) || value <= 0) {
return '';
}
const total = Math.round(value);
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const seconds = total % 60;
const mm = hours > 0 ? String(minutes).padStart(2, '0') : String(minutes);
const ss = String(seconds).padStart(2, '0');
return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`;
}
}

View File

@ -25,6 +25,9 @@ export interface Download {
speed: number;
eta: number;
filename: string;
size?: number;
duration?: number;
thumbnail?: string;
timestamp?: number;
website?: string;
file_exists?: boolean;
@ -166,6 +169,23 @@ export class DownloadsService {
this[where].forEach((dl: Download) => { if (filter(dl)) ids.push(dl.url) });
return this.delById(where, ids);
}
public moveById(ids: string[], folder: string) {
return this.http.post<Status>('set_folder', {ids: ids, folder: folder}).pipe(
catchError(this.handleHTTPError)
);
}
public setCookie(cookie: string, domain = '', url = '') {
return this.http.post<Status>('cookie', {url: url, domain: domain, cookie: cookie}).pipe(
catchError(this.handleHTTPError)
);
}
public thumbnailUrl(id: string): string {
return 'thumbnail?id=' + encodeURIComponent(id);
}
public addDownloadByUrl(url: string): Promise<any> {
const defaultQuality = 'best';
const defaultFormat = 'mp4';

45
ui/src/app/labels.ts Normal file
View File

@ -0,0 +1,45 @@
const QUALITY_LABELS: { [id: string]: string } = {
best: '最佳',
worst: '最差',
best_ios: 'iOS 兼容',
audio: '仅音频',
'2160': '2160p',
'1440': '1440p',
'1080': '1080p',
'720': '720p',
'480': '480p',
'360': '360p',
'240': '240p'
};
const FORMAT_LABELS: { [id: string]: string } = {
any: '自动',
mp4: 'MP4',
m4a: 'M4A',
mp3: 'MP3',
opus: 'OPUS',
wav: 'WAV',
flac: 'FLAC',
thumbnail: '封面'
};
export function qualityLabel(id: string): string {
if (QUALITY_LABELS[id]) return QUALITY_LABELS[id];
return /^\d+$/.test(id) ? `${id} kbps` : id;
}
export function formatLabel(id: string): string {
return FORMAT_LABELS[id] || id;
}
/** Relative time for a DownloadInfo timestamp (nanoseconds). */
export function relativeTime(timestampNs: number): string {
if (!timestampNs) return '';
const diffMs = Date.now() - timestampNs / 1e6;
const diff = Math.floor(diffMs / 1000);
if (diff < 60) return '刚刚';
if (diff < 3600) return `${Math.floor(diff / 60)} 分钟前`;
if (diff < 86400) return `${Math.floor(diff / 3600)} 小时前`;
if (diff < 86400 * 30) return `${Math.floor(diff / 86400)} 天前`;
return new Date(timestampNs / 1e6).toLocaleDateString();
}

View File

@ -1,55 +0,0 @@
import { Component, Input, ViewChild, ElementRef, Output, EventEmitter } from '@angular/core';
interface Checkable {
checked: boolean;
}
@Component({
selector: 'app-master-checkbox',
template: `
<div class="form-check">
<input type="checkbox" class="form-check-input" id="{{id}}-select-all" #masterCheckbox [(ngModel)]="selected" (change)="clicked()">
<label class="form-check-label" for="{{id}}-select-all"></label>
</div>
`,
standalone: false
})
export class MasterCheckboxComponent {
@Input() id: string;
@Input() list: Map<String, Checkable>;
@Output() changed = new EventEmitter<number>();
@ViewChild('masterCheckbox') masterCheckbox: ElementRef;
selected: boolean;
clicked() {
this.list.forEach(item => item.checked = this.selected);
this.selectionChanged();
}
selectionChanged() {
if (!this.masterCheckbox)
return;
let checked: number = 0;
this.list.forEach(item => { if(item.checked) checked++ });
this.selected = checked > 0 && checked == this.list.size;
this.masterCheckbox.nativeElement.indeterminate = checked > 0 && checked < this.list.size;
this.changed.emit(checked);
}
}
@Component({
selector: 'app-slave-checkbox',
template: `
<div class="form-check">
<input type="checkbox" class="form-check-input" id="{{master.id}}-{{id}}-select" [(ngModel)]="checkable.checked" (change)="master.selectionChanged()">
<label class="form-check-label" for="{{master.id}}-{{id}}-select"></label>
</div>
`,
standalone: false
})
export class SlaveCheckboxComponent {
@Input() id: string;
@Input() master: MasterCheckboxComponent;
@Input() checkable: Checkable;
}

View File

@ -0,0 +1,30 @@
import { Injectable } from '@angular/core';
import { CookieService } from 'ngx-cookie-service';
const EXPIRES_DAYS = 3650;
/**
* Thin wrapper over ngx-cookie-service for the app's persisted UI
* preferences (quality, format, theme, active tab/folder/view, sort...).
*/
@Injectable({ providedIn: 'root' })
export class PreferencesService {
constructor(private cookieService: CookieService) {}
get(name: string, fallback = ''): string {
return this.cookieService.check(name) ? this.cookieService.get(name) : fallback;
}
set(name: string, value: string): void {
this.cookieService.set(name, value, { expires: EXPIRES_DAYS });
}
getBool(name: string, fallback = false): boolean {
if (!this.cookieService.check(name)) return fallback;
return this.cookieService.get(name) === 'true';
}
setBool(name: string, value: boolean): void {
this.cookieService.set(name, value ? 'true' : 'false', { expires: EXPIRES_DAYS });
}
}

View File

@ -0,0 +1,66 @@
<aside class="sidebar" [class.open]="open">
<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">{{ metubeVersion || 'dev' }}</span>
</div>
<div class="nav-section">
<div class="nav-label">状态</div>
<button class="nav-item" [class.active]="status === 'downloading'" (click)="selectStatus('downloading')">
<fa-icon [icon]="faDownload" class="nav-icon"></fa-icon>
<span>正在下载</span>
<span class="dot" *ngIf="statusCounts && statusCounts.downloading > 0"></span>
<span class="nav-count num">{{ statusCounts ? statusCounts.downloading : 0 }}</span>
</button>
<button class="nav-item" [class.active]="status === 'queued'" (click)="selectStatus('queued')">
<fa-icon [icon]="faClock" class="nav-icon"></fa-icon>
<span>排队中</span>
<span class="nav-count num">{{ statusCounts ? statusCounts.queued : 0 }}</span>
</button>
<button class="nav-item" [class.active]="status === 'completed'" (click)="selectStatus('completed')">
<fa-icon [icon]="faCheckCircle" class="nav-icon"></fa-icon>
<span>已完成</span>
<span class="nav-count num">{{ statusCounts ? statusCounts.completed : 0 }}</span>
</button>
</div>
<div class="nav-section">
<div class="nav-label">文件夹</div>
<button class="nav-item" [class.active]="selectedFolder === 'all'" (click)="selectFolder('all')">
<fa-icon [icon]="faHome" class="nav-icon"></fa-icon>
<span>全部</span>
<span class="nav-count num">{{ allCount }}</span>
</button>
<button *ngFor="let folder of folders" class="nav-item" [class.active]="selectedFolder === folder.name" (click)="selectFolder(folder.name)">
<fa-icon [icon]="faFolderOpen" class="nav-icon"></fa-icon>
<span>{{ folderLabel(folder.name) }}</span>
<span class="nav-count num">{{ folder.count }}</span>
</button>
</div>
<div class="sidebar-foot">
<button class="ghost-btn" (click)="openAdvanced.emit()" 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"><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" (click)="openActivity.emit()" 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 ngbDropdown class="theme-dd" placement="top-start" container="body">
<button ngbDropdownToggle class="ghost-btn icon-only" title="主题">
<fa-icon *ngIf="activeTheme" [icon]="activeTheme.icon"></fa-icon>
</button>
<div ngbDropdownMenu class="dropdown-menu">
<button *ngFor="let theme of themes" ngbDropdownItem [class.active]="activeTheme === theme" (click)="themeChanged.emit(theme)">
<fa-icon [icon]="theme.icon"></fa-icon>
<span class="ms-2">{{ theme.displayName }}</span>
<fa-icon *ngIf="activeTheme === theme" [icon]="faCheck" class="ms-auto"></fa-icon>
</button>
</div>
</div>
</div>
</aside>

View File

@ -0,0 +1,165 @@
:host
display: contents
.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)
flex: none
.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
&:hover
background: var(--bg)
&.active
background: var(--accent-tint)
font-weight: 550
&::before
content: ""
position: absolute
left: -10px
top: 6px
bottom: 6px
width: 3px
border-radius: 0 2px 2px 0
background: var(--accent)
.nav-icon
flex: none
color: var(--muted)
font-size: 13px
&.active .nav-icon
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
&.active .nav-count
background: var(--surface)
border-color: oklch(85% 0.05 145)
color: var(--accent-strong)
.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
flex-wrap: wrap
.theme-dd
margin-left: auto
.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
&:hover
background: var(--bg)
border-color: oklch(82% 0.01 240)
svg, fa-icon
color: var(--muted)
&.icon-only
padding: 7px 9px
@media (max-width: 900px)
.sidebar
position: fixed
z-index: 45
width: 260px
height: 100%
top: 0
left: 0
transform: translateX(-100%)
transition: transform 0.2s ease
box-shadow: 12px 0 40px oklch(30% 0.02 240 / 0.15)
&.open
transform: translateX(0)

View File

@ -0,0 +1,55 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { faCheck, faClock, faDownload, faFolderOpen, faHome } from '@fortawesome/free-solid-svg-icons';
import { faCheckCircle } from '@fortawesome/free-regular-svg-icons';
import { Theme } from './theme';
export interface FolderNav {
name: string;
count: number;
}
@Component({
selector: 'app-sidebar',
standalone: false,
templateUrl: './sidebar.component.html',
styleUrls: ['./sidebar.component.sass']
})
export class SidebarComponent {
@Input() status: string;
@Input() selectedFolder: string;
@Input() statusCounts: { downloading: number; queued: number; completed: number };
@Input() folders: FolderNav[] = [];
@Input() allCount = 0;
@Input() metubeVersion: string | null = null;
@Input() themes: Theme[] = [];
@Input() activeTheme: Theme | null = null;
@Input() open = false;
@Output() statusChange = new EventEmitter<string>();
@Output() folderChange = new EventEmitter<string>();
@Output() openAdvanced = new EventEmitter<void>();
@Output() openActivity = new EventEmitter<void>();
@Output() themeChanged = new EventEmitter<Theme>();
@Output() close = new EventEmitter<void>();
faDownload = faDownload;
faClock = faClock;
faCheckCircle = faCheckCircle;
faFolderOpen = faFolderOpen;
faHome = faHome;
faCheck = faCheck;
folderLabel(name: string): string {
return name || '未分类';
}
selectStatus(status: string): void {
this.statusChange.emit(status);
this.close.emit();
}
selectFolder(folder: string): void {
this.folderChange.emit(folder);
this.close.emit();
}
}

View File

@ -0,0 +1,48 @@
import { Component, Input } from '@angular/core';
@Component({
selector: 'app-stats-strip',
standalone: false,
template: `
<div class="stats">
<div class="stat"><span class="live"> </span><strong class="num">{{ active }}</strong></div>
<div class="stat"> <strong class="num">{{ speed | speed }}</strong></div>
<div class="stat"> <strong class="num">{{ queued }}</strong></div>
<div class="stat"> <strong class="num">{{ storage | fileSize }}</strong></div>
</div>
`,
styles: [`
:host { display: block; flex: none; }
.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; }
@media (max-width: 900px) {
.stats { flex-wrap: wrap; }
}
`]
})
export class StatsStripComponent {
@Input() active = 0;
@Input() speed = 0;
@Input() queued = 0;
@Input() storage = 0;
}

View File

@ -0,0 +1,38 @@
import { Injectable } from '@angular/core';
export interface Toast {
id: number;
message: string;
error?: boolean;
actionLabel?: string;
onAction?: () => void;
}
/**
* Non-blocking toast notifications; replaces the previous alert() calls.
* ToastsComponent renders this service's list.
*/
@Injectable({ providedIn: 'root' })
export class ToastService {
toasts: Toast[] = [];
private nextId = 1;
private readonly okDuration = 3600;
private readonly errorDuration = 6000;
show(message: string, opts: { error?: boolean; actionLabel?: string; onAction?: () => void } = {}): number {
const toast: Toast = { id: this.nextId++, message, ...opts };
this.toasts = [...this.toasts, toast];
const ms = toast.error ? this.errorDuration : this.okDuration;
setTimeout(() => this.dismiss(toast.id), ms);
return toast.id;
}
dismiss(id: number): void {
this.toasts = this.toasts.filter(t => t.id !== id);
}
runAction(toast: Toast): void {
if (toast.onAction) toast.onAction();
this.dismiss(toast.id);
}
}

View File

@ -0,0 +1,87 @@
import { Component } from '@angular/core';
import { faCheckCircle, faTimesCircle } from '@fortawesome/free-regular-svg-icons';
import { Toast, ToastService } from './toast.service';
@Component({
selector: 'app-toasts',
standalone: false,
template: `
<div class="toasts" aria-live="polite">
<div *ngFor="let toast of toastService.toasts" class="toast" [class.err]="toast.error">
<fa-icon [icon]="toast.error ? faTimesCircle : faCheckCircle" class="t-icon"></fa-icon>
<span class="t-msg">{{ toast.message }}</span>
<button *ngIf="toast.actionLabel" type="button" class="t-act" (click)="toastService.runAction(toast)">
{{ toast.actionLabel }}
</button>
<button type="button" class="t-close" (click)="toastService.dismiss(toast.id)" aria-label="关闭">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" 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>
`,
styles: [`
:host { display: block; }
.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;
color: var(--fg);
box-shadow: 0 8px 24px oklch(30% 0.02 240 / 0.14);
min-width: 260px;
max-width: min(420px, calc(100vw - 40px));
animation: toast-in 0.25s ease;
}
.toast.err { border-left-color: var(--danger); }
.t-icon { flex: none; color: var(--accent-strong); }
.toast.err .t-icon { color: var(--danger); }
.t-msg { flex: 1; }
.t-act {
flex: none;
border: none;
background: none;
color: var(--accent-strong);
font-size: 12.5px;
font-weight: 550;
padding: 2px 4px;
border-radius: 4px;
white-space: nowrap;
}
.t-act:hover { background: var(--accent-tint); }
.t-close {
flex: none;
width: 20px;
height: 20px;
display: grid;
place-items: center;
border: none;
background: none;
color: var(--muted);
border-radius: 4px;
}
.t-close:hover { background: var(--bg); color: var(--fg); }
@keyframes toast-in {
from { opacity: 0; transform: translateY(8px); }
}
`]
})
export class ToastsComponent {
faCheckCircle = faCheckCircle;
faTimesCircle = faTimesCircle;
constructor(public toastService: ToastService) {}
}

View File

@ -1,5 +1,5 @@
<!doctype html>
<html lang="en">
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>MeTube</title>

View File

@ -1,7 +1,26 @@
/* You can add global styles to this file, and also import other style files */
@use 'styles/_tokens' as *
.navbar
background-color: var(--bs-dark) !important
body
font-family: var(--font-body)
background: var(--bg)
color: var(--fg)
font-size: 14px
line-height: 1.55
-webkit-font-smoothing: antialiased
[data-bs-theme="dark"] &
background-color: var(--bs-dark-bg-subtle) !important
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

View File

@ -0,0 +1,40 @@
// MeTube design tokens ported from design/metube-redesign.html
// Applied as CSS custom properties; dark variants key off Bootstrap's
// data-bs-theme attribute (set by theme.ts on <html>).
: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)
--placeholder: oklch(94% 0.01 240)
--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: ui-monospace, 'JetBrains Mono', 'IBM Plex Mono', Menlo, monospace
--radius: 8px
--sidebar-w: 236px
[data-bs-theme="dark"]
--bg: oklch(16% 0.012 250)
--surface: oklch(20% 0.014 250)
--fg: oklch(92% 0.01 240)
--muted: oklch(68% 0.012 240)
--border: oklch(30% 0.014 250)
--accent: oklch(66% 0.15 145)
--accent-strong: oklch(74% 0.13 145)
--accent-tint: oklch(26% 0.05 145)
--success: oklch(66% 0.15 145)
--warn: oklch(74% 0.14 75)
--danger: oklch(62% 0.17 25)
--danger-tint: oklch(24% 0.03 25)
--placeholder: oklch(26% 0.014 250)

View File

@ -1,14 +0,0 @@
// This file is required by karma.conf.js and loads recursively all the .spec and framework files
import 'zone.js/dist/zone-testing';
import { getTestBed } from '@angular/core/testing';
import {
BrowserDynamicTestingModule,
platformBrowserDynamicTesting
} from '@angular/platform-browser-dynamic/testing';
// First, initialize the Angular testing environment.
getTestBed().initTestEnvironment(
BrowserDynamicTestingModule,
platformBrowserDynamicTesting()
);

View File

@ -1,18 +0,0 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"jasmine"
]
},
"files": [
"src/test.ts",
"src/polyfills.ts"
],
"include": [
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}

View File

@ -1,152 +0,0 @@
{
"extends": "tslint:recommended",
"rulesDirectory": [
"codelyzer"
],
"rules": {
"align": {
"options": [
"parameters",
"statements"
]
},
"array-type": false,
"arrow-return-shorthand": true,
"curly": true,
"deprecation": {
"severity": "warning"
},
"eofline": true,
"import-blacklist": [
true,
"rxjs/Rx"
],
"import-spacing": true,
"indent": {
"options": [
"spaces"
]
},
"max-classes-per-file": false,
"max-line-length": [
true,
140
],
"member-ordering": [
true,
{
"order": [
"static-field",
"instance-field",
"static-method",
"instance-method"
]
}
],
"no-console": [
true,
"debug",
"info",
"time",
"timeEnd",
"trace"
],
"no-empty": false,
"no-inferrable-types": [
true,
"ignore-params"
],
"no-non-null-assertion": true,
"no-redundant-jsdoc": true,
"no-switch-case-fall-through": true,
"no-var-requires": false,
"object-literal-key-quotes": [
true,
"as-needed"
],
"quotemark": [
true,
"single"
],
"semicolon": {
"options": [
"always"
]
},
"space-before-function-paren": {
"options": {
"anonymous": "never",
"asyncArrow": "always",
"constructor": "never",
"method": "never",
"named": "never"
}
},
"typedef": [
true,
"call-signature"
],
"typedef-whitespace": {
"options": [
{
"call-signature": "nospace",
"index-signature": "nospace",
"parameter": "nospace",
"property-declaration": "nospace",
"variable-declaration": "nospace"
},
{
"call-signature": "onespace",
"index-signature": "onespace",
"parameter": "onespace",
"property-declaration": "onespace",
"variable-declaration": "onespace"
}
]
},
"variable-name": {
"options": [
"ban-keywords",
"check-format",
"allow-pascal-case"
]
},
"whitespace": {
"options": [
"check-branch",
"check-decl",
"check-operator",
"check-separator",
"check-type",
"check-typecast"
]
},
"component-class-suffix": true,
"contextual-lifecycle": true,
"directive-class-suffix": true,
"no-conflicting-lifecycle": true,
"no-host-metadata-property": true,
"no-input-rename": true,
"no-inputs-metadata-property": true,
"no-output-native": true,
"no-output-on-prefix": true,
"no-output-rename": true,
"no-outputs-metadata-property": true,
"template-banana-in-box": true,
"template-no-negated-async": true,
"use-lifecycle-interface": true,
"use-pipe-transform-interface": true,
"directive-selector": [
true,
"attribute",
"app",
"camelCase"
],
"component-selector": [
true,
"element",
"app",
"kebab-case"
]
}
}