From 00000de7980618a1d2acde52092c7d11e95db96b Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 15 Aug 2026 13:59:28 +0200 Subject: [PATCH] feat(downloads): align manager cards, queue rows, and headings with the rail design language (#1448) --- .changes/components-confirm-dialog-width.md | 7 + .changes/downloads-library-cards-cleanup.md | 10 + CLAUDE.md | 10 +- .../electron-backend-e2e/src/downloads.e2e.ts | 27 +- docs/architecture/download-manager.md | 23 +- .../src/lib/download-library.component.html | 527 +++++++----------- .../src/lib/download-library.component.scss | 163 +++--- .../lib/download-library.component.spec.ts | 74 ++- .../src/lib/download-library.component.ts | 53 +- .../src/lib/download-queue.component.html | 120 ++-- .../src/lib/download-queue.component.scss | 72 +-- .../src/lib/download-queue.component.spec.ts | 105 ++-- .../src/lib/download-queue.component.ts | 22 + .../src/lib/downloads.component.spec.ts | 15 +- .../lib/confirm-dialog/dialog.service.spec.ts | 3 +- .../src/lib/confirm-dialog/dialog.service.ts | 5 +- 16 files changed, 619 insertions(+), 617 deletions(-) create mode 100644 .changes/components-confirm-dialog-width.md create mode 100644 .changes/downloads-library-cards-cleanup.md diff --git a/.changes/components-confirm-dialog-width.md b/.changes/components-confirm-dialog-width.md new file mode 100644 index 000000000..3ed0d545a --- /dev/null +++ b/.changes/components-confirm-dialog-width.md @@ -0,0 +1,7 @@ +--- +type: fix +area: components +--- + +Confirmation dialogs are wider by default, so their action buttons sit side by +side instead of wrapping into a vertical stack with longer translated labels. diff --git a/.changes/downloads-library-cards-cleanup.md b/.changes/downloads-library-cards-cleanup.md new file mode 100644 index 000000000..9f14fb53c --- /dev/null +++ b/.changes/downloads-library-cards-cleanup.md @@ -0,0 +1,10 @@ +--- +type: feature +area: downloads +--- + +The download manager got a visual cleanup: "Ready to watch" cards are compact +poster cards — file actions live in the poster's ⋮ menu, and clicking any card +(episodes included) opens its offline detail. Queue rows show one primary +action (pause/resume/retry) with cancel and remove in the menu, and all +sections share one heading style with item counts. diff --git a/CLAUDE.md b/CLAUDE.md index 8dbcf27f7..d3c44cd5e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1199,10 +1199,12 @@ engine` (restart required) or active rows plus completed available/unknown rows are skipped; failed, canceled, completed-missing, and unambiguous row-less episodes remain eligible. -- Ready movie and grouped-series cards open a focused local detail. Movies play - the finalized local file; series list only locally available episode rows and - every episode action targets its own downloaded file. Focused routes disable - route search and use `contextPanel: 'none'`. +- Ready cards (movies, grouped series, and standalone episodes) open a focused + local detail; local file actions (Play, Show in folder, Copy URL, Remove) + live in the poster's overflow menu. Movies play the finalized local file; + series list only locally available episode rows and every episode action + targets its own downloaded file. Focused routes disable route search and use + `contextPanel: 'none'`. - Downloads capture a versioned metadata snapshot from the rendered Xtream or Stalker movie/episode detail at start time, including already-merged TMDB fields. Legacy, sparse, stale, or wrong-language snapshots are safely diff --git a/apps/electron-backend-e2e/src/downloads.e2e.ts b/apps/electron-backend-e2e/src/downloads.e2e.ts index 42ae1148b..75b91056c 100644 --- a/apps/electron-backend-e2e/src/downloads.e2e.ts +++ b/apps/electron-backend-e2e/src/downloads.e2e.ts @@ -291,15 +291,11 @@ test.describe('Electron Downloads', () => { 'e2e download payload' ); - // Completed items expose play / reveal / remove actions. + // Completed items keep their file actions behind the poster's + // overflow menu; the card itself stays free of toolbar chrome. await expect( - card.getByRole('button', { name: 'Play: E2E Movie' }).last() - ).toBeVisible(); - await expect( - card.getByRole('button', { - name: 'Show in Folder: E2E Movie', - }) - ).toBeVisible(); + card.getByRole('button', { name: 'Play: E2E Movie' }) + ).toHaveCount(0); await expect( card.getByText('Offline', { exact: true }) ).toHaveCount(0); @@ -318,6 +314,16 @@ test.describe('Electron Downloads', () => { sourceHeader.getByText('Source', { exact: true }) ).toBeVisible(); await expect(sourceHeader).toContainText('Download Portal'); + await expect( + app.mainWindow.getByRole('menuitem', { + name: 'Play: E2E Movie', + }) + ).toBeVisible(); + await expect( + app.mainWindow.getByRole('menuitem', { + name: 'Show in Folder: E2E Movie', + }) + ).toBeVisible(); await app.mainWindow.keyboard.press('Escape'); // Removing the finalized file must move the persisted completed @@ -1022,6 +1028,11 @@ test.describe('Electron Downloads', () => { await expect(removeItem).toBeVisible(); await removeItem .getByRole('button', { + name: 'More actions for Remove Retained Partial', + }) + .click(); + await app.mainWindow + .getByRole('menuitem', { name: 'Remove Remove Retained Partial from manager', }) .click(); diff --git a/docs/architecture/download-manager.md b/docs/architecture/download-manager.md index 74e38c1cf..7b61c7085 100644 --- a/docs/architecture/download-manager.md +++ b/docs/architecture/download-manager.md @@ -150,8 +150,18 @@ variants, contextual buttons, and theme-aware styling. `DownloadItemAction` values and do not inject the download service, router, dialogs, or snackbars. The fixed header and filter row sit above one vertical scroll owner. The - queue uses compact progress rows with status text and icons; completed movies - and grouped series reuse the portal's canonical content grid. Both completed + queue and library sections share one heading treatment (the dashboard-rail + title style with an `.app-count-badge` item count) so same-level sections + read alike; the queue's bordered panel wraps only the row list. Queue rows + expose exactly one visible primary action — pause, resume, or retry — while + cancel, copy-URL, and remove live in the row's overflow menu, so two + adjacent destructive icons never compete. Completed movies + and grouped series reuse the portal's canonical content grid as compact + poster cards borrowed from the dashboard-rail card language: a type badge + and an always-visible ⋮ trigger sit on the artwork, the title and series + facts sit below it, and Play / Show in folder / Copy URL / Remove live in + that overflow menu. File size is not repeated on the card; it belongs to the + focused offline detail. Both completed cards and their loading skeleton consume the global `--cover-grid-min-width` / `--cover-gap` tokens, so the Small, Medium, and Large cover preference behaves like it does elsewhere in the app. All @@ -170,10 +180,11 @@ variants, contextual buttons, and theme-aware styling. playlist scope when the page is opened under a source route. VOD and episode detail views continue to render a paused download as an active Resume button (`DownloadsService.isPaused()` / `resumeDownloadByContent()`). Artwork and - titles on completed movie and grouped-series cards open the focused offline - detail for that download; the explicit card Play action still starts the - local file. A legacy standalone episode without a usable series id stays - directly playable because there is no reliable series detail to build. + titles on every completed card — movie, grouped series, and standalone + episode alike — open the focused offline detail for that download; the + explicit Play command in the poster's overflow menu still starts the local + file directly. A legacy standalone episode without a usable series id + resolves to a single-episode offline detail built from its own row. Missing completed rows show `File missing` under Needs attention with `Download again`; Play and Show in folder are withheld. A file-action race that returns `File not found` refreshes the authoritative list and returns diff --git a/libs/portal/downloads/feature/src/lib/download-library.component.html b/libs/portal/downloads/feature/src/lib/download-library.component.html index 59171fd69..aab3ba201 100644 --- a/libs/portal/downloads/feature/src/lib/download-library.component.html +++ b/libs/portal/downloads/feature/src/lib/download-library.component.html @@ -6,81 +6,208 @@ >

- {{ 'DOWNLOADS.READY_TO_WATCH' | translate }} + {{ 'DOWNLOADS.READY_TO_WATCH' | translate }} +

@for (entity of entities(); track entity.key) { - @if (entity.kind === 'series') { -
-
+
+ -
- -
- - {{ 'DOWNLOADS.SERIES' | translate }} - - - + + + {{ typeKey(entity) | translate }} + + + + + + @if (entity.kind === 'series') { + - + } @else { + + + + + } + +
+ +
+ + @if (entity.kind === 'series') {
- -
- - - - - -
-
-
- } @else { -
-
- -
- -
- - {{ - (entity.kind === 'movie' - ? 'DOWNLOADS.MOVIE' - : 'DOWNLOADS.EPISODE' - ) | translate - }} + } + @if (entity.kind === 'episode' && entity.episodeLabel) { + + {{ entity.episodeLabel }} - - @if ( - entity.kind === 'episode' && entity.episodeLabel - ) { - - {{ entity.episodeLabel }} - - } - - -
- - - - - - - - -
-
-
- } + } +
+ } diff --git a/libs/portal/downloads/feature/src/lib/download-library.component.scss b/libs/portal/downloads/feature/src/lib/download-library.component.scss index d1820c097..015d10b6f 100644 --- a/libs/portal/downloads/feature/src/lib/download-library.component.scss +++ b/libs/portal/downloads/feature/src/lib/download-library.component.scss @@ -6,16 +6,21 @@ .download-library { display: grid; - gap: 14px; + gap: 12px; } .download-library__header { h2 { + display: inline-flex; + align-items: baseline; + gap: 8px; margin: 0; + padding: 0 2px; color: var(--app-heading-color); - font: var(--mat-sys-title-medium); - font-weight: 680; - letter-spacing: -0.015em; + font-size: 1.125rem; + font-weight: 600; + letter-spacing: -0.012em; + line-height: 1.2; } } @@ -26,28 +31,31 @@ ); } +// Gallery card, borrowed from the dashboard-rail card language: bordered +// poster, text on the page surface below, actions behind the poster overflow +// trigger. All row/toolbar chrome lives in the focused offline detail. .download-library__card { min-width: 0; - overflow: hidden; - border: 1px solid var(--app-separator); - border-radius: 16px; - background: var(--app-widget-bg); color: var(--app-body-color); - transition: - border-color 160ms ease, - transform 160ms ease, - box-shadow 160ms ease; + transition: transform 160ms ease; &:hover, &:focus-within { - border-color: color-mix( - in srgb, - var(--mat-sys-primary) 38%, - var(--app-separator) - ); - box-shadow: 0 12px 28px - color-mix(in srgb, var(--mat-sys-shadow) 14%, transparent); transform: translateY(-2px); + + .download-library__artwork { + border-color: color-mix( + in srgb, + var(--mat-sys-primary) 38%, + var(--app-separator) + ); + box-shadow: 0 12px 28px + color-mix(in srgb, var(--mat-sys-shadow) 14%, transparent); + } + + .download-library__title-button:not(:disabled) { + color: var(--mat-sys-primary); + } } } @@ -55,7 +63,12 @@ position: relative; aspect-ratio: 2 / 3; overflow: hidden; + border: 1px solid var(--app-separator); + border-radius: 14px; background: var(--app-widget-header-bg); + transition: + border-color 160ms ease, + box-shadow 160ms ease; } .download-library__artwork-button, @@ -68,11 +81,6 @@ cursor: pointer; } -.download-library__title-hint { - display: block; - min-width: 0; -} - .download-library__artwork-button { display: block; width: 100%; @@ -119,26 +127,65 @@ } } +.download-library__type { + position: absolute; + top: 8px; + left: 8px; + padding: 3px 7px; + border-radius: 5px; + background: color-mix(in srgb, var(--mat-sys-scrim, #000) 74%, transparent); + color: #e8ecf4; + font-size: 0.62rem; + font-weight: 700; + letter-spacing: 0.07em; + line-height: 1.2; + text-transform: uppercase; + pointer-events: none; +} + +// Same contract as the dashboard rail action trigger: small, always +// visible, sitting on the poster's top-right corner. +.download-library__more { + position: absolute; + top: 4px; + right: 4px; + width: 32px; + height: 32px; + padding: 4px; + color: #fff; + --mdc-icon-button-state-layer-size: 32px; + + &::before { + position: absolute; + inset: 3px; + border-radius: 50%; + background: color-mix( + in srgb, + var(--mat-sys-scrim, #000) 55%, + transparent + ); + content: ''; + } + + mat-icon { + position: relative; + width: 20px; + height: 20px; + font-size: 20px; + line-height: 20px; + } +} + .download-library__body { display: grid; grid-template-columns: minmax(0, 1fr); - gap: 7px; - padding: 12px; -} - -.download-library__type { - color: var(--app-eyebrow-color); - font-size: 0.68rem; - font-weight: 700; - letter-spacing: 0.08em; - line-height: 1.2; - text-transform: uppercase; + gap: 5px; + padding: 9px 4px 0; } .download-library__title-button { display: -webkit-box; width: 100%; - min-height: 2.6em; padding: 0; overflow: hidden; background: transparent; @@ -147,38 +194,21 @@ font-weight: 650; line-height: 1.3; text-align: left; + transition: color 140ms ease; -webkit-box-orient: vertical; -webkit-line-clamp: 2; } -.download-library__facts, -.download-library__metadata { +.download-library__facts { display: flex; min-width: 0; align-items: center; gap: 5px; color: var(--app-muted-color); - font-size: 0.72rem; + font-size: 0.74rem; line-height: 1.3; } -.download-library__metadata { - font-family: var(--mat-sys-body-small-font); - - span:first-child { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - span:last-child { - font-family: - 'JetBrains Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', - monospace; - font-variant-numeric: tabular-nums; - } -} - .download-library__episodes-button { display: inline-flex; min-height: 44px; @@ -205,20 +235,6 @@ font-weight: 700; } -.download-library__actions { - display: flex; - min-height: 40px; - align-items: center; - justify-content: flex-end; - margin: 2px -6px -6px; - border-top: 1px solid var(--app-separator); - padding-top: 6px; - - button { - color: var(--app-body-color); - } -} - button:focus-visible { border-radius: 6px; outline: 2px solid var(--mat-sys-primary); @@ -230,14 +246,9 @@ button:disabled { opacity: 0.48; } -@media (max-width: 620px) { - .download-library__body { - padding: 10px; - } -} - @media (prefers-reduced-motion: reduce) { .download-library__card, + .download-library__artwork, .download-library__artwork-button img { transition: none; } diff --git a/libs/portal/downloads/feature/src/lib/download-library.component.spec.ts b/libs/portal/downloads/feature/src/lib/download-library.component.spec.ts index 61855ad94..01f9fbd2f 100644 --- a/libs/portal/downloads/feature/src/lib/download-library.component.spec.ts +++ b/libs/portal/downloads/feature/src/lib/download-library.component.spec.ts @@ -214,7 +214,7 @@ describe('DownloadLibraryComponent', () => { expect(fallbackEpisodeCard.textContent).toContain('Episode'); }); - it('keeps ready cards clean while preserving tracked size', () => { + it('keeps ready cards free of size, source, and toolbar chrome', () => { const section = byTestId('downloads-library-section'); const movieCard = byTestId('download-library-movie-9'); const seriesCard = byTestId('download-library-series-playlist-a-77'); @@ -224,10 +224,30 @@ describe('DownloadLibraryComponent', () => { expect(movieCard.textContent).not.toContain('Cinema'); expect(seriesCard.textContent).not.toContain('Living room'); expect(episodeCard.textContent).not.toContain('Archive'); - expect(movieCard.textContent).toContain('2.4 MB'); + expect(movieCard.textContent).not.toContain('2.4 MB'); + expect( + movieCard.querySelector('.download-library__actions') + ).toBeNull(); + expect( + Array.from(movieCard.querySelectorAll('button')).find( + (candidate) => + candidate.getAttribute('aria-label') === 'Play: Moonrise' + ) + ).toBeUndefined(); }); - it('puts the movie source ahead of its overflow commands', async () => { + it('renders the section heading with a presentational count badge', () => { + const heading = fixture.nativeElement.querySelector( + '#downloads-library-heading' + ) as HTMLElement; + const badge = heading.querySelector('.app-count-badge'); + + expect(heading.textContent).toContain('Ready to watch'); + expect(badge?.textContent?.trim()).toBe('3'); + expect(badge?.getAttribute('aria-hidden')).toBe('true'); + }); + + it('puts the movie source ahead of its ordered overflow commands', async () => { const card = byTestId('download-library-movie-9'); await click(button(card, 'More actions: Moonrise')); @@ -235,6 +255,8 @@ describe('DownloadLibraryComponent', () => { const header = document.querySelector( '.download-source-menu-header' ); + const play = button(document, 'Play: Moonrise'); + const reveal = button(document, 'Show in folder: Moonrise'); const copy = button(document, 'Copy download URL: Moonrise'); const remove = button(document, 'Remove from manager: Moonrise'); expect(header?.textContent?.replace(/\s+/g, ' ').trim()).toBe( @@ -242,9 +264,17 @@ describe('DownloadLibraryComponent', () => { ); expect( header && - header.compareDocumentPosition(copy) & + header.compareDocumentPosition(play) & Node.DOCUMENT_POSITION_FOLLOWING ).toBeTruthy(); + expect( + play.compareDocumentPosition(reveal) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + expect( + reveal.compareDocumentPosition(copy) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); expect( copy.compareDocumentPosition(remove) & Node.DOCUMENT_POSITION_FOLLOWING @@ -331,16 +361,17 @@ describe('DownloadLibraryComponent', () => { expect(opened).toEqual([MOVIE, MOVIE]); }); - it('keeps explicit movie toolbar and menu commands local', async () => { + it('keeps every movie file command local behind the overflow menu', async () => { const card = byTestId('download-library-movie-9'); const actions: unknown[] = []; - const toolbar = card.querySelector( - '.download-library__actions' - ) as HTMLElement; component.itemAction.subscribe((action) => actions.push(action)); - await click(button(toolbar, 'Play: Moonrise')); - await click(button(card, 'Show in folder: Moonrise')); + await clickMenuAction(card, 'More actions: Moonrise', 'Play: Moonrise'); + await clickMenuAction( + card, + 'More actions: Moonrise', + 'Show in folder: Moonrise' + ); await clickMenuAction( card, 'More actions: Moonrise', @@ -452,7 +483,7 @@ describe('DownloadLibraryComponent', () => { expect(opened).toEqual([SERIES]); }); - it('keeps an invalid-series episode local without series navigation', async () => { + it('opens a fallback-episode detail from artwork and title, playing only via the menu', async () => { const card = byTestId('download-library-episode-21'); const itemActions: unknown[] = []; const opened: DownloadItem[] = []; @@ -469,16 +500,23 @@ describe('DownloadLibraryComponent', () => { '.download-library__title-button' ) as HTMLButtonElement; - expect(artwork.getAttribute('aria-label')).toBe('Play: Legacy episode'); - expect(title.getAttribute('aria-label')).toBe('Play: Legacy episode'); + expect(artwork.getAttribute('aria-label')).toBe( + 'Open details: Legacy episode artwork' + ); + expect(title.getAttribute('aria-label')).toBe( + 'Open details: Legacy episode' + ); await click(artwork); await click(title); + expect(opened).toEqual([FALLBACK_EPISODE, FALLBACK_EPISODE]); + expect(itemActions).toEqual([]); - expect(itemActions).toEqual([ - { type: 'play', item: FALLBACK_EPISODE }, - { type: 'play', item: FALLBACK_EPISODE }, - ]); - expect(opened).toEqual([]); + await clickMenuAction( + card, + 'More actions: Legacy episode', + 'Play: Legacy episode' + ); + expect(itemActions).toEqual([{ type: 'play', item: FALLBACK_EPISODE }]); }); it('disables every local command while the concrete item is pending', async () => { diff --git a/libs/portal/downloads/feature/src/lib/download-library.component.ts b/libs/portal/downloads/feature/src/lib/download-library.component.ts index 7a175a4cf..fa1ae0c97 100644 --- a/libs/portal/downloads/feature/src/lib/download-library.component.ts +++ b/libs/portal/downloads/feature/src/lib/download-library.component.ts @@ -55,6 +55,42 @@ export class DownloadLibraryComponent { } } + protected representativeOf(entity: DownloadLibraryEntity): DownloadItem { + return entity.kind === 'series' ? entity.representative : entity.item; + } + + protected entityTitle(entity: DownloadLibraryEntity): string { + return entity.kind === 'series' ? entity.title : entity.item.title; + } + + protected typeKey(entity: DownloadLibraryEntity): string { + switch (entity.kind) { + case 'movie': + return 'DOWNLOADS.MOVIE'; + case 'episode': + return 'DOWNLOADS.EPISODE'; + case 'series': + return 'DOWNLOADS.SERIES'; + } + } + + protected placeholderIcon(entity: DownloadLibraryEntity): string { + switch (entity.kind) { + case 'movie': + return 'movie'; + case 'episode': + return 'live_tv'; + case 'series': + return 'tv'; + } + } + + // A pending series representative blocks navigation but must not lock + // the overflow menu: "Open downloaded episodes" is a local dialog. + protected moreDisabled(entity: DownloadLibraryEntity): boolean { + return entity.kind !== 'series' && this.isPending(entity.item); + } + protected artworkUrl(entity: DownloadLibraryEntity): string | undefined { const raw = entity.kind === 'series' ? entity.posterUrl : entity.item.posterUrl; @@ -106,21 +142,4 @@ export class DownloadLibraryComponent { ? 'DOWNLOADS.EPISODE_COUNT_ONE' : 'DOWNLOADS.EPISODE_COUNT_OTHER'; } - - protected formatBytes(bytes: number): string { - if (!Number.isFinite(bytes) || bytes <= 0) { - return '0 B'; - } - const units = ['B', 'KB', 'MB', 'GB', 'TB']; - const exponent = Math.min( - Math.floor(Math.log(bytes) / Math.log(1024)), - units.length - 1 - ); - const value = bytes / 1024 ** exponent; - const formatted = - value >= 10 || Number.isInteger(value) - ? value.toFixed(0) - : value.toFixed(1); - return `${formatted} ${units[exponent]}`; - } } diff --git a/libs/portal/downloads/feature/src/lib/download-queue.component.html b/libs/portal/downloads/feature/src/lib/download-queue.component.html index b1d41b122..4e28d176b 100644 --- a/libs/portal/downloads/feature/src/lib/download-queue.component.html +++ b/libs/portal/downloads/feature/src/lib/download-queue.component.html @@ -4,11 +4,14 @@ [attr.data-test-id]="section.testId" [attr.aria-labelledby]="section.headingId" > -
-

+

+ {{ section.headingKey | translate }} -

-
+ + +
@for (row of section.items; track row.item.id) { @@ -158,12 +161,6 @@ $implicit: row.item, }" /> - } @case ('downloading') { - } @case ('paused') { + } - @if (isMissingFile(row)) { + @if (canRemove(row)) { @@ -323,6 +301,7 @@ - - - - - -