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 @@
>
@for (entity of entities(); track entity.key) {
- @if (entity.kind === 'series') {
-
-
+
+
-
- @if (artworkUrl(entity); as artwork) {
-
- } @else {
-
- tv
-
- }
-
-
-
-
-
- {{ 'DOWNLOADS.SERIES' | translate }}
-
-
-
+ } @else {
+
+
+ {{ placeholderIcon(entity) }}
+
+
+ }
+
+
+
+ {{ typeKey(entity) | translate }}
+
+
+
+ more_vert
+
+
+
+ @if (entity.kind === 'series') {
+
- {{ entity.title }}
+
+ video_library
+
+
+ {{
+ 'DOWNLOADS.OPEN_EPISODES'
+ | translate
+ }}
+
-
+ } @else {
+
+
+ play_arrow
+
+
+ {{ 'DOWNLOADS.PLAY' | translate }}
+
+
+
+
+ folder_open
+
+
+ {{ 'DOWNLOADS.REVEAL' | translate }}
+
+
+
+
+ content_copy
+
+
+ {{ 'DOWNLOADS.COPY_URL' | translate }}
+
+
+
+
+ remove_circle_outline
+
+
+ {{
+ 'DOWNLOADS.REMOVE_FROM_MANAGER'
+ | translate
+ }}
+
+
+ }
+
+
+
+
+
+ {{ entityTitle(entity) }}
+
+ @if (entity.kind === 'series') {
}
-
- {{
- formatBytes(entity.trackedBytes)
- }}
-
-
-
-
- more_vert
-
-
-
-
-
-
- video_library
-
-
- {{
- 'DOWNLOADS.OPEN_EPISODES'
- | translate
- }}
-
-
-
-
-
-
- } @else {
-
-
-
- @if (artworkUrl(entity); as artwork) {
-
- } @else {
-
-
- {{
- entity.kind === 'movie'
- ? 'movie'
- : 'live_tv'
- }}
-
-
- }
-
-
-
-
-
- {{
- (entity.kind === 'movie'
- ? 'DOWNLOADS.MOVIE'
- : 'DOWNLOADS.EPISODE'
- ) | translate
- }}
+ }
+ @if (entity.kind === 'episode' && entity.episodeLabel) {
+
+ {{ entity.episodeLabel }}
-
- {{ entity.item.title }}
-
- @if (
- entity.kind === 'episode' && entity.episodeLabel
- ) {
-
- {{ entity.episodeLabel }}
-
- }
-
- {{
- formatBytes(entity.trackedBytes)
- }}
-
-
-
-
- play_arrow
-
-
- folder_open
-
-
- more_vert
-
-
-
-
-
- content_copy
-
-
- {{
- 'DOWNLOADS.COPY_URL' | translate
- }}
-
-
-
-
- remove_circle_outline
-
-
- {{
- 'DOWNLOADS.REMOVE_FROM_MANAGER'
- | translate
- }}
-
-
-
-
-
-
- }
+ }
+
+
}
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.items.length }}
+
+
@for (row of section.items; track row.item.id) {
@@ -158,12 +161,6 @@
$implicit: row.item,
}"
/>
-
}
@case ('downloading') {
-
}
@case ('paused') {
play_arrow
-
-
}
@case ('failed') {
-
}
@case ('canceled') {
-
}
}
}
@@ -264,6 +232,25 @@
+ @if (canCancel(row)) {
+
+ close
+
+ {{ 'DOWNLOADS.CANCEL' | translate }}
+
+
+ }
content_copy
- {{
- 'DOWNLOADS.ARIA.COPY_URL'
- | translate
- : { title: row.item.title }
- }}
+ {{ 'DOWNLOADS.COPY_URL' | translate }}
- @if (isMissingFile(row)) {
+ @if (canRemove(row)) {
delete_outline
{{
- 'DOWNLOADS.ARIA.REMOVE'
+ 'DOWNLOADS.REMOVE_FROM_MANAGER'
| translate
- : { title: row.item.title }
}}
@@ -323,6 +301,7 @@
-
-
- close
-
-
-
-
-
- delete_outline
-
-
-
{
'[data-test-id="downloads-active-section"]'
);
expect(activeSection).not.toBeNull();
- expect(activeSection.querySelector('h2')?.textContent?.trim()).toBe(
- 'Downloading now'
- );
+ expect(
+ activeSection
+ .querySelector('h2 .download-queue__heading-label')
+ ?.textContent?.trim()
+ ).toBe('Downloading now');
+ const activeBadge = activeSection.querySelector('h2 .app-count-badge');
+ expect(activeBadge?.textContent?.trim()).toBe('1');
+ expect(activeBadge?.getAttribute('aria-hidden')).toBe('true');
expect(activeSection.getAttribute('aria-labelledby')).toBeTruthy();
expect(
fixture.nativeElement.querySelector(
@@ -163,9 +171,11 @@ describe('DownloadQueueComponent', () => {
'[data-test-id="downloads-active-section"]'
)
).toBeNull();
- expect(attentionSection.querySelector('h2')?.textContent?.trim()).toBe(
- 'Needs attention'
- );
+ expect(
+ attentionSection
+ .querySelector('h2 .download-queue__heading-label')
+ ?.textContent?.trim()
+ ).toBe('Needs attention');
expect(row(2)).not.toBeNull();
render();
@@ -291,14 +301,14 @@ describe('DownloadQueueComponent', () => {
});
it.each([
- ['queued', ['pause', 'cancel']],
- ['downloading', ['pause', 'cancel']],
- ['paused', ['resume', 'cancel', 'remove']],
- ['failed', ['retry', 'remove']],
- ['canceled', ['retry', 'remove']],
+ ['queued', 'pause', ['cancel', 'copy-url']],
+ ['downloading', 'pause', ['cancel', 'copy-url']],
+ ['paused', 'resume', ['cancel', 'copy-url', 'remove']],
+ ['failed', 'retry', ['copy-url', 'remove']],
+ ['canceled', 'retry', ['copy-url', 'remove']],
] as const)(
- 'exposes the exact primary action contract for %s',
- (status, expectedActions) => {
+ 'exposes one visible primary action for %s and routes the rest through the menu',
+ async (status, primaryAction, menuActions) => {
const viewModel = createRow(7, status);
const emitted: DownloadItemAction[] = [];
const opened = jest.fn();
@@ -317,13 +327,32 @@ describe('DownloadQueueComponent', () => {
'[data-test-action]:not([data-test-action="more"])'
)
).map((button) => button.dataset['testAction']);
- expect(renderedActions).toEqual(expectedActions);
+ expect(renderedActions).toEqual([primaryAction]);
- for (const action of expectedActions) {
- actionButton(host, action).click();
+ actionButton(host, primaryAction).click();
+ expect(emitted.map(({ type }) => type)).toEqual([primaryAction]);
+
+ actionButton(host, 'more').click();
+ fixture.detectChanges();
+ await fixture.whenStable();
+
+ const menu = overlayContainer.getContainerElement();
+ const renderedMenuActions = Array.from(
+ menu.querySelectorAll('[data-test-action]')
+ ).map((button) => button.dataset['testAction']);
+ expect(renderedMenuActions).toEqual([...menuActions]);
+
+ const destructive = menuActions.find(
+ (action) => action !== 'copy-url'
+ );
+ if (destructive) {
+ actionButton(menu, destructive).click();
+ expect(emitted.map(({ type }) => type)).toEqual([
+ primaryAction,
+ destructive,
+ ]);
}
- expect(emitted.map(({ type }) => type)).toEqual(expectedActions);
for (const event of emitted) {
expect(event.item).toBe(viewModel.item);
}
@@ -351,7 +380,10 @@ describe('DownloadQueueComponent', () => {
'copy-url'
);
expect(copyButton.closest('.mat-mdc-menu-panel')).not.toBeNull();
- expect(copyButton.textContent).toContain('Copy URL for Download 11');
+ expect(copyButton.textContent).toContain('Copy download URL');
+ expect(copyButton.getAttribute('aria-label')).toBe(
+ 'Copy URL for Download 11'
+ );
copyButton.click();
expect(emitted).toHaveBeenCalledTimes(1);
@@ -417,7 +449,7 @@ describe('DownloadQueueComponent', () => {
expect(
row(17).querySelector('.download-queue__metadata')
).not.toBeNull();
- expect(itemButtons).toHaveLength(6);
+ expect(itemButtons).toHaveLength(4);
expect(itemButtons.every(({ disabled }) => disabled)).toBe(true);
expect(
actionButton(overlayContainer.getContainerElement(), 'copy-url')
@@ -606,10 +638,8 @@ describe('DownloadQueueComponent', () => {
const loader = TestbedHarnessEnvironment.loader(fixture);
const commands = [
[41, 'pause', 'Pause Named movie'],
- [41, 'cancel', 'Cancel Named movie'],
[41, 'more', 'More actions for Named movie'],
[42, 'resume', 'Resume Paused episode'],
- [42, 'remove', 'Remove Paused episode from manager'],
[43, 'retry', 'Retry Failed movie'],
] as const;
@@ -631,25 +661,28 @@ describe('DownloadQueueComponent', () => {
actionButton(row(41), 'more').click();
fixture.detectChanges();
await fixture.whenStable();
- const copyButton = actionButton(
- overlayContainer.getContainerElement(),
- 'copy-url'
+ const menu = overlayContainer.getContainerElement();
+ const cancelButton = actionButton(menu, 'cancel');
+ expect(cancelButton.getAttribute('aria-label')).toBe(
+ 'Cancel Named movie'
);
+ const copyButton = actionButton(menu, 'copy-url');
expect(copyButton.type).toBe('button');
expect(copyButton.getAttribute('aria-label')).toBe(
'Copy URL for Named movie'
);
- const documentLoader =
- TestbedHarnessEnvironment.documentRootLoader(fixture);
- const copyTooltip = await documentLoader.getHarness(
- MatTooltipHarness.with({
- selector: '[data-test-action="copy-url"]',
- })
- );
- await copyTooltip.show();
- expect(await copyTooltip.getTooltipText()).toBe(
- 'Copy URL for Named movie'
- );
- await copyTooltip.hide();
+ cancelButton.click();
+ fixture.detectChanges();
+ await fixture.whenStable();
+
+ actionButton(row(42), 'more').click();
+ fixture.detectChanges();
+ await fixture.whenStable();
+ expect(
+ actionButton(
+ overlayContainer.getContainerElement(),
+ 'remove'
+ ).getAttribute('aria-label')
+ ).toBe('Remove Paused episode from manager');
});
});
diff --git a/libs/portal/downloads/feature/src/lib/download-queue.component.ts b/libs/portal/downloads/feature/src/lib/download-queue.component.ts
index a029d7f9d..ec5c27ed0 100644
--- a/libs/portal/downloads/feature/src/lib/download-queue.component.ts
+++ b/libs/portal/downloads/feature/src/lib/download-queue.component.ts
@@ -127,6 +127,28 @@ export class DownloadQueueComponent {
return row.attentionReason === 'file-missing';
}
+ canCancel(row: DownloadListItemViewModel): boolean {
+ if (this.isMissingFile(row)) {
+ return false;
+ }
+ const status = row.item.status;
+ return (
+ status === 'queued' ||
+ status === 'downloading' ||
+ status === 'paused'
+ );
+ }
+
+ canRemove(row: DownloadListItemViewModel): boolean {
+ if (this.isMissingFile(row)) {
+ return true;
+ }
+ const status = row.item.status;
+ return (
+ status === 'paused' || status === 'failed' || status === 'canceled'
+ );
+ }
+
statusIcon(row: DownloadListItemViewModel): string {
return this.isMissingFile(row)
? 'file_off'
diff --git a/libs/portal/downloads/feature/src/lib/downloads.component.spec.ts b/libs/portal/downloads/feature/src/lib/downloads.component.spec.ts
index 6e15424fa..0df2fd033 100644
--- a/libs/portal/downloads/feature/src/lib/downloads.component.spec.ts
+++ b/libs/portal/downloads/feature/src/lib/downloads.component.spec.ts
@@ -828,8 +828,15 @@ describe('DownloadsComponent', () => {
expect(downloadsService.playDownload).not.toHaveBeenCalled();
jest.clearAllMocks();
- const playButton = fixture.nativeElement.querySelector(
- '.download-library__actions button[aria-label="Play: Signal"]'
+ const menuTrigger = fixture.nativeElement.querySelector(
+ '.download-library__more'
+ ) as HTMLButtonElement;
+ expect(menuTrigger).toBeTruthy();
+ menuTrigger.click();
+ await fixture.whenStable();
+
+ const playButton = document.querySelector(
+ 'button[aria-label="Play: Signal"]'
) as HTMLButtonElement;
expect(playButton).toBeTruthy();
@@ -841,6 +848,10 @@ describe('DownloadsComponent', () => {
);
expect(router.navigate).not.toHaveBeenCalled();
expect(navigation.open).not.toHaveBeenCalled();
+
+ document
+ .querySelectorAll('.cdk-overlay-container')
+ .forEach((element) => element.remove());
});
it('opens a grouped-series representative relative to the Xtream downloads route', async () => {
diff --git a/libs/ui/components/src/lib/confirm-dialog/dialog.service.spec.ts b/libs/ui/components/src/lib/confirm-dialog/dialog.service.spec.ts
index 637648ae8..8f5d656d9 100644
--- a/libs/ui/components/src/lib/confirm-dialog/dialog.service.spec.ts
+++ b/libs/ui/components/src/lib/confirm-dialog/dialog.service.spec.ts
@@ -43,7 +43,8 @@ describe('DialogService', () => {
title: 'Remove',
message: 'Confirm removal?',
}),
- width: '300px',
+ width: '420px',
+ maxWidth: 'calc(100vw - 32px)',
})
);
});
diff --git a/libs/ui/components/src/lib/confirm-dialog/dialog.service.ts b/libs/ui/components/src/lib/confirm-dialog/dialog.service.ts
index fdedd8362..b6cadcf5d 100644
--- a/libs/ui/components/src/lib/confirm-dialog/dialog.service.ts
+++ b/libs/ui/components/src/lib/confirm-dialog/dialog.service.ts
@@ -21,7 +21,10 @@ export class DialogService {
ConfirmDialogData
>(ConfirmDialogComponent, {
data,
- width: data.width ?? '300px',
+ // Wide enough for two side-by-side action buttons with translated
+ // labels; 300px forced them to wrap into a vertical stack.
+ width: data.width ?? '420px',
+ maxWidth: 'calc(100vw - 32px)',
});
dialogRef
.afterClosed()