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 pathlib
import re import re
import base64 import base64
import hashlib
import aiohttp
from urllib.parse import urlparse from urllib.parse import urlparse
from watchfiles import DefaultFilter, Change, awatch from watchfiles import DefaultFilter, Change, awatch
@ -332,6 +334,71 @@ async def set_cookie(request):
'msg': f'Failed to save cookie: {str(e)}' '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') @routes.post(config.URL_PREFIX + 'delete')
async def delete(request): async def delete(request):
post = await request.json() post = await request.json()

View File

@ -40,6 +40,7 @@ class DownloadQueueNotifier:
class DownloadInfo: class DownloadInfo:
def __init__(self, id, title, url, quality, format, folder, custom_name_prefix, error, entry, playlist_item_limit): 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.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.title = title if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{title}'
self.url = url self.url = url
@ -54,6 +55,11 @@ class DownloadInfo:
self.error = error self.error = error
self.entry = entry self.entry = entry
self.playlist_item_limit = playlist_item_limit 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 # Extract website domain from URL
parsed_url = urlparse(url) parsed_url = urlparse(url)
self.website = parsed_url.netloc self.website = parsed_url.netloc
@ -380,6 +386,16 @@ class DownloadQueue:
self.done.load() 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): async def __import_queue(self):
for k, v in self.queue.saved_items(): for k, v in self.queue.saved_items():
await self.__add_download(v, True) 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": { "options": {
"buildTarget": "metube:build" "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", "version": "0.0.0",
"dependencies": { "dependencies": {
"@angular/animations": "^19.2.14", "@angular/animations": "^19.2.14",
"@angular/cdk": "^19.2.19",
"@angular/common": "^19.2.14", "@angular/common": "^19.2.14",
"@angular/compiler": "^19.2.14", "@angular/compiler": "^19.2.14",
"@angular/core": "^19.2.14", "@angular/core": "^19.2.14",
@ -37,9 +38,7 @@
"@angular/cli": "^19.2.14", "@angular/cli": "^19.2.14",
"@angular/compiler-cli": "^19.2.14", "@angular/compiler-cli": "^19.2.14",
"@types/node": "^22.15.29", "@types/node": "^22.15.29",
"codelyzer": "^6.0.2",
"ts-node": "~10.9.1", "ts-node": "~10.9.1",
"tslint": "~6.1.3",
"typescript": "~5.8.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": { "node_modules/@angular/cli": {
"version": "19.2.17", "version": "19.2.17",
"resolved": "https://registry.npmjs.org/@angular/cli/-/cli-19.2.17.tgz", "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-19.2.17.tgz",
@ -5883,16 +5897,6 @@
"url": "https://github.com/sponsors/jonschlinkert" "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": { "node_modules/arg": {
"version": "4.1.3", "version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
@ -5907,17 +5911,6 @@
"dev": true, "dev": true,
"license": "Python-2.0" "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": { "node_modules/array-flatten": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@ -5925,13 +5918,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/autoprefixer": {
"version": "10.4.20", "version": "10.4.20",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz",
@ -5970,16 +5956,6 @@
"postcss": "^8.1.0" "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": { "node_modules/babel-loader": {
"version": "9.2.1", "version": "9.2.1",
"resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-9.2.1.tgz", "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-9.2.1.tgz",
@ -6337,16 +6313,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/bundle-name": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
@ -6736,93 +6702,6 @@
"node": ">=0.10.0" "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": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -6921,13 +6800,6 @@
"node": ">= 0.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": { "node_modules/connect-history-api-fallback": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz", "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" "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": { "node_modules/css-what": {
"version": "6.2.2", "version": "6.2.2",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
@ -7215,16 +7076,6 @@
"url": "https://github.com/sponsors/fb55" "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": { "node_modules/cssesc": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@ -7238,13 +7089,6 @@
"node": ">=4" "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": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@ -7793,16 +7637,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/eslint-scope": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
@ -7817,20 +7651,6 @@
"node": ">=8.0.0" "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": { "node_modules/esrecurse": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
@ -8034,13 +7854,6 @@
], ],
"license": "BSD-3-Clause" "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": { "node_modules/fastq": {
"version": "1.19.1", "version": "1.19.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", "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": "^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": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -8832,18 +8638,6 @@
"node": ">=0.8.19" "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": { "node_modules/inherits": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@ -9892,16 +9686,6 @@
"url": "https://github.com/sponsors/isaacs" "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": { "node_modules/minipass": {
"version": "7.1.2", "version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
@ -10546,16 +10330,6 @@
"node": ">= 0.8" "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": { "node_modules/onetime": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz",
@ -10861,7 +10635,6 @@
"version": "7.3.0", "version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"entities": "^6.0.0" "entities": "^6.0.0"
@ -10902,7 +10675,6 @@
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"engines": { "engines": {
"node": ">=0.12" "node": ">=0.12"
@ -10931,16 +10703,6 @@
"node": "^12.20.0 || ^14.13.1 || >=16.0.0" "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": { "node_modules/path-key": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@ -11860,26 +11622,6 @@
"node": ">=10" "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": { "node_modules/send": {
"version": "0.19.0", "version": "0.19.0",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
@ -12570,13 +12312,6 @@
"wbuf": "^1.7.3" "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": { "node_modules/ssri": {
"version": "12.0.0", "version": "12.0.0",
"resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz", "resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz",
@ -12933,13 +12668,6 @@
"tslib": "^2" "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": { "node_modules/thunky": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz",
@ -13076,233 +12804,6 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD" "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": { "node_modules/tuf-js": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-3.1.0.tgz", "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-3.1.0.tgz",
@ -14470,13 +13971,6 @@
"node": ">=8" "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": { "node_modules/ws": {
"version": "8.17.1", "version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",

View File

@ -4,14 +4,12 @@
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"start": "ng serve", "start": "ng serve",
"build": "ng build", "build": "ng build"
"test": "ng test",
"lint": "ng lint",
"e2e": "ng e2e"
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/animations": "^19.2.14", "@angular/animations": "^19.2.14",
"@angular/cdk": "^19.2.19",
"@angular/common": "^19.2.14", "@angular/common": "^19.2.14",
"@angular/compiler": "^19.2.14", "@angular/compiler": "^19.2.14",
"@angular/core": "^19.2.14", "@angular/core": "^19.2.14",
@ -40,9 +38,7 @@
"@angular/cli": "^19.2.14", "@angular/cli": "^19.2.14",
"@angular/compiler-cli": "^19.2.14", "@angular/compiler-cli": "^19.2.14",
"@types/node": "^22.15.29", "@types/node": "^22.15.29",
"codelyzer": "^6.0.2",
"ts-node": "~10.9.1", "ts-node": "~10.9.1",
"tslint": "~6.1.3",
"typescript": "~5.8.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="app">
<div class="container-fluid"> <app-sidebar
<a class="navbar-brand d-flex align-items-center" href="#"> [status]="status"
<img src="assets/icons/android-chrome-192x192.png" alt="MeTube Logo" height="32" class="me-2"> [selectedFolder]="selectedFolder"
MeTube [statusCounts]="statusCounts"
</a> [folders]="folderNavs"
<div class="download-metrics"> [allCount]="allCount"
<div class="metric" *ngIf="activeDownloads > 0"> [metubeVersion]="metubeVersion"
<fa-icon [icon]="faDownload" class="text-primary"></fa-icon> [themes]="themes"
<span>{{activeDownloads}} downloading</span> [activeTheme]="activeTheme"
</div> [open]="sidebarOpen"
<div class="metric" *ngIf="queuedDownloads > 0"> (statusChange)="onStatusChange($event)"
<fa-icon [icon]="faClock" class="text-warning"></fa-icon> (folderChange)="onFolderChange($event)"
<span>{{queuedDownloads}} queued</span> (openAdvanced)="toggleAdvanced(true)"
</div> (openActivity)="activityOpen = !activityOpen"
<div class="metric" *ngIf="completedDownloads > 0"> (themeChanged)="themeChanged($event)"
<fa-icon [icon]="faCheck" class="text-success"></fa-icon> (close)="sidebarOpen = false">
<span>{{completedDownloads}} completed</span> </app-sidebar>
</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>
<main role="main" class="container container-xl"> <main class="main">
<!-- Events Display Area --> <app-command-bar
<div *ngIf="events.length > 0" class="events-container mb-3"> [url]="addUrl"
<div class="events-header"> (urlChange)="addUrl = $event"
<div class="d-flex align-items-center justify-content-between"> [quality]="quality"
<h6 class="mb-0"> [format]="format"
<fa-icon [icon]="faClock" class="me-2"></fa-icon> [folder]="folder"
Recent Events [qualities]="qualities"
</h6> [formats]="formats"
<button type="button" [customDirs$]="customDirs$"
class="btn btn-sm btn-outline-secondary" [allowCustomDir]="allowCustomDir.bind(this)"
(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()"
[disabled]="addInProgress || downloads.loading" [disabled]="addInProgress || downloads.loading"
ngbTooltip="Automatically start downloads when added"> (add)="addDownload($event)"
<option [ngValue]="true">Yes</option> (qualityChange)="setQuality($event)"
<option [ngValue]="false">No</option> (formatChange)="setFormat($event)"
</select> (folderChange)="setFolder($event)"
</div> (openAdvanced)="toggleAdvanced(true)"
</div> (menu)="sidebarOpen = true">
<div class="col-md-6"> </app-command-bar>
<div class="input-group">
<span class="input-group-text">Download Folder</span> <app-stats-strip
<ng-select [items]="customDirs$ | async" [active]="activeDownloads"
placeholder="Default" [speed]="totalSpeed"
[addTag]="allowCustomDir.bind(this)" [queued]="queuedDownloads"
addTagText="Create directory" [storage]="librarySize">
bindLabel="folder" </app-stats-strip>
[(ngModel)]="folder"
[disabled]="addInProgress || downloads.loading" <div class="content">
[virtualScroll]="true" <app-active-downloads
[clearable]="true" [items]="downloadingRows"
[totalSpeed]="totalSpeed"
[loading]="downloads.loading" [loading]="downloads.loading"
[searchable]="true" (cancel)="cancelDownload($event)">
[closeOnSelect]="true" </app-active-downloads>
ngbTooltip="Choose where to save downloads. Type to create a new folder.">
</ng-select> <app-download-list
</div> [status]="status"
</div> [rows]="filteredRows"
<div class="col-md-6"> [folderName]="folderName"
<div class="input-group"> [filterText]="filter"
<span class="input-group-text">Custom Name Prefix</span> (filterChange)="filter = $event"
<input type="text" [sort]="sort"
class="form-control" (sortChange)="sort = $event"
placeholder="Default" [sortAscending]="sortAscending"
name="customNamePrefix" (sortAscendingChange)="setSortDirection($event)"
[(ngModel)]="customNamePrefix" [view]="view"
[disabled]="addInProgress || downloads.loading" (viewChange)="setView($event)"
ngbTooltip="Add a prefix to downloaded filenames"> [loading]="downloads.loading"
</div> [customDirsFor]="customDirsForDownload.bind(this)"
</div> [allowCustomDir]="allowCustomDir.bind(this)"
<div class="col-md-6"> [downloadUrlFor]="buildDownloadLink.bind(this)"
<div class="input-group"> (start)="startItems($event)"
<span class="input-group-text">Items Limit</span> (del)="delItems($event, status === 'completed' ? 'done' : 'queue')"
<input type="number" (move)="moveItems($event.ids, $event.folder)"
min="0" (retry)="retryDownload($event.key, $event.dl)"
class="form-control" (folderEdit)="folderChanged($event.key, $event.folder)"
placeholder="Default" (clearCompleted)="clearCompletedDownloads()"
name="playlistItemLimit" (clearFailed)="clearFailedDownloads()"
(keydown)="isNumber($event)" (retryFailed)="retryFailedDownloads()"
[(ngModel)]="playlistItemLimit" (downloadSelected)="downloadSelectedFiles($event)">
[disabled]="addInProgress || downloads.loading" </app-download-list>
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>
</div> </div>
</main>
</div> </div>
<!-- Advanced Actions --> <!-- Advanced options slide-over -->
<div class="row"> <app-advanced-options
<div class="col-12"> [open]="isAdvancedOpen"
<hr class="my-3"> [autoStart]="autoStart"
<div class="row g-2"> (autoStartChange)="setAutoStart($event)"
<div class="col-md-4"> [playlistStrictMode]="playlistStrictMode"
<button type="button" (strictChange)="playlistStrictMode = $event"
class="btn btn-secondary w-100" [playlistItemLimit]="playlistItemLimit"
(click)="openBatchImportModal()"> (limitChange)="playlistItemLimit = $event"
<fa-icon [icon]="faFileImport" class="me-2"></fa-icon> [customNamePrefix]="customNamePrefix"
Import URLs (prefixChange)="customNamePrefix = $event"
</button> [metubeVersion]="metubeVersion"
</div> [ytDlpVersion]="ytDlpVersion"
<div class="col-md-4"> [ytDlpOptionsUpdateTime]="ytDlpOptionsUpdateTime"
<button type="button" (close)="toggleAdvanced(false)"
class="btn btn-secondary w-100" (import)="openBatchImportModal()"
(click)="exportBatchUrls('all')"> (export)="exportBatchUrls('all')"
<fa-icon [icon]="faFileExport" class="me-2"></fa-icon> (copy)="copyBatchUrls('all')"
Export URLs (cookie)="saveCookie($event.domain, $event.cookie)">
</button> </app-advanced-options>
</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>
<!-- 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 fade" tabindex="-1" role="dialog" [ngClass]="{'show': batchImportModalOpen}" [ngStyle]="{'display': batchImportModalOpen ? 'block' : 'none'}">
<div class="modal-dialog" role="document"> <div class="modal-dialog" role="document">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title">Batch Import URLs</h5> <h5 class="modal-title">批量导入 URL</h5>
<button type="button" class="btn-close" aria-label="Close" (click)="closeBatchImportModal()"></button> <button type="button" class="btn-close" aria-label="关闭" (click)="closeBatchImportModal()"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<textarea [(ngModel)]="batchImportText" class="form-control" rows="6" <textarea [(ngModel)]="batchImportText" class="form-control" rows="6" placeholder="每行粘贴一个视频 URL"></textarea>
placeholder="Paste one video URL per line"></textarea>
<div class="mt-2"> <div class="mt-2">
<small *ngIf="batchImportStatus">{{ batchImportStatus }}</small> <small *ngIf="batchImportStatus" class="batch-status">{{ batchImportStatus }}</small>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-danger me-auto" *ngIf="importInProgress" (click)="cancelBatchImport()"> <button type="button" class="btn btn-danger me-auto" *ngIf="importInProgress" (click)="cancelBatchImport()">取消导入</button>
Cancel Import <button type="button" class="btn btn-secondary" (click)="closeBatchImportModal()">关闭</button>
</button> <button type="button" class="btn btn-primary" (click)="startBatchImport()" [disabled]="importInProgress">导入 URL</button>
<button type="button" class="btn btn-secondary" (click)="closeBatchImportModal()">Close</button> </div>
<button type="button" class="btn btn-primary" (click)="startBatchImport()" [disabled]="importInProgress"> </div>
Import URLs </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> </button>
</div> </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> </div>
<button type="button" class="ghost-btn activity-clear" (click)="clearEvents()">清空日志</button>
</div> </div>
</div> </aside>
<app-toasts></app-toasts>
<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>

View File

@ -1,341 +1,149 @@
.button-toggle-theme:focus, .button-toggle-theme:active :host
box-shadow: none display: block
outline: 0px height: 100vh
.add-url-box .app
max-width: 960px display: grid
margin: 4rem auto grid-template-columns: var(--sidebar-w) 1fr
height: 100vh
overflow: hidden
.add-url-component .main
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
display: flex display: flex
padding-left: 5px flex-direction: column
padding-right: 5px overflow: hidden
position: relative
min-width: 0
.sortable-header .content
cursor: pointer flex: 1
user-select: none min-height: 0
overflow: hidden
padding: 18px 22px 20px
display: flex
flex-direction: column
gap: 22px
.metube-tabs // Activity drawer
margin-top: 3.5rem .scrim
font-size: 1.1rem position: fixed
inset: 0
background: oklch(25% 0.02 240 / 0.35)
z-index: 40
.nav-link .activity
cursor: pointer 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 &.show
text-decoration: none transform: translateX(0)
th .activity-head
border-top: 0 display: flex
border-bottom-width: 3px !important align-items: center
vertical-align: middle !important padding: 16px 18px
white-space: nowrap border-bottom: 1px solid var(--border)
flex: none
td .activity-title
vertical-align: middle font-size: 15px
.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)
font-weight: 600 font-weight: 600
color: var(--bs-secondary-color) color: var(--fg)
padding-top: 0.4rem
padding-bottom: 0.4rem
.folder-group-chevron .icon-btn
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
margin-left: auto margin-left: auto
.batch-panel .activity-body
margin-top: 15px flex: 1
border: 1px solid #ccc overflow-y: auto
border-radius: 8px padding: 14px 18px
padding: 15px
background-color: #fff
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1)
.batch-panel-header .activity-empty
border-bottom: 1px solid #eee text-align: center
padding-bottom: 8px color: var(--muted)
margin-bottom: 15px font-size: 13px
h4 padding: 40px 0
font-size: 1.5rem
margin: 0
.batch-panel-body .activity-item
textarea.form-control padding: 10px 0
resize: vertical 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 .batch-status
font-size: 0.9rem font-size: 0.9rem
color: #555 color: var(--muted)
.d-flex.my-3
margin-top: 1rem
margin-bottom: 1rem
.modal.fade.show .modal.fade.show
background-color: rgba(0, 0, 0, 0.5) background-color: rgba(0, 0, 0, 0.5)
.modal-header @media (max-width: 900px)
border-bottom: 1px solid #eee .app
grid-template-columns: 1fr
.modal-body .content
textarea.form-control padding: 14px 14px 16px
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

View File

@ -1,31 +1,24 @@
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { TestBed } from '@angular/core/testing'; 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 { AppComponent } from './app.component';
import { DownloadsService } from './downloads.service';
import { MeTubeSocket } from './metube-socket';
describe('AppComponent', () => { describe('AppComponent', () => {
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
declarations: [ declarations: [AppComponent],
AppComponent imports: [FormsModule],
], schemas: [NO_ERRORS_SCHEMA],
providers: [MeTubeSocket, CookieService, DownloadsService, provideHttpClient()]
}).compileComponents(); }).compileComponents();
}); });
it('should create the app', () => { it('should create the app', () => {
const fixture = TestBed.createComponent(AppComponent); const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance; expect(fixture.componentInstance).toBeTruthy();
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!');
}); });
}); });

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 { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { CookieService } from 'ngx-cookie-service'; import { CookieService } from 'ngx-cookie-service';
import { ScrollingModule } from '@angular/cdk/scrolling';
import { AppComponent } from './app.component'; import { AppComponent } from './app.component';
import { EtaPipe, SpeedPipe, EncodeURIComponent, FileSizePipe } from './downloads.pipe'; import { EtaPipe, SpeedPipe, EncodeURIComponent, FileSizePipe, DurationPipe } from './downloads.pipe';
import { MasterCheckboxComponent, SlaveCheckboxComponent } from './master-checkbox.component';
import { MeTubeSocket } from './metube-socket'; import { MeTubeSocket } from './metube-socket';
import { NgSelectModule } from '@ng-select/ng-select'; import { NgSelectModule } from '@ng-select/ng-select';
import { ServiceWorkerModule } from '@angular/service-worker'; 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: [ @NgModule({ declarations: [
AppComponent, AppComponent,
EtaPipe, EtaPipe,
SpeedPipe, SpeedPipe,
FileSizePipe, FileSizePipe,
DurationPipe,
EncodeURIComponent, EncodeURIComponent,
MasterCheckboxComponent, SidebarComponent,
SlaveCheckboxComponent CommandBarComponent,
StatsStripComponent,
ActiveDownloadsComponent,
DownloadListComponent,
DownloadRowComponent,
DownloadCardComponent,
AdvancedOptionsComponent,
ToastsComponent
], ],
bootstrap: [AppComponent], imports: [BrowserModule, bootstrap: [AppComponent], imports: [BrowserModule,
FormsModule, FormsModule,
NgbModule, NgbModule,
FontAwesomeModule, FontAwesomeModule,
NgSelectModule, NgSelectModule,
ScrollingModule,
ServiceWorkerModule.register('custom-service-worker.js', { ServiceWorkerModule.register('custom-service-worker.js', {
enabled: !isDevMode(), enabled: !isDevMode(),
// Register the ServiceWorker as soon as the application is stable // 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]}`; 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; speed: number;
eta: number; eta: number;
filename: string; filename: string;
size?: number;
duration?: number;
thumbnail?: string;
timestamp?: number; timestamp?: number;
website?: string; website?: string;
file_exists?: boolean; file_exists?: boolean;
@ -166,6 +169,23 @@ export class DownloadsService {
this[where].forEach((dl: Download) => { if (filter(dl)) ids.push(dl.url) }); this[where].forEach((dl: Download) => { if (filter(dl)) ids.push(dl.url) });
return this.delById(where, ids); 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> { public addDownloadByUrl(url: string): Promise<any> {
const defaultQuality = 'best'; const defaultQuality = 'best';
const defaultFormat = 'mp4'; 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> <!doctype html>
<html lang="en"> <html lang="zh-CN">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>MeTube</title> <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 body
background-color: var(--bs-dark) !important 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"] & button
background-color: var(--bs-dark-bg-subtle) !important 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"
]
}
}