feat(downloads): align manager cards, queue rows, and headings with the rail design language (#1448)

This commit is contained in:
4gray authored and GitHub committed 2026-08-15 13:59:28 +02:00
1 parent 20857e34c2
commit 00000de798
16 files changed
+619 -617

No files matched your search

@@ -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.
@@ -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.
+6 -4
View File
@@ -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
+19 -8
View File
@@ -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();
+17 -6
View File
@@ -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
@@ -6,81 +6,208 @@
>
<header class="download-library__header">
<h2 id="downloads-library-heading">
{{ 'DOWNLOADS.READY_TO_WATCH' | translate }}
<span>{{ 'DOWNLOADS.READY_TO_WATCH' | translate }}</span>
<span class="app-count-badge" aria-hidden="true">
{{ entities().length }}
</span>
</h2>
</header>
<div class="download-library__grid">
@for (entity of entities(); track entity.key) {
@if (entity.kind === 'series') {
<article
class="download-library__card"
[attr.data-test-id]="libraryTestId(entity)"
>
<div
class="download-library__artwork download-library__artwork-hint"
<article
class="download-library__card"
[attr.data-test-id]="libraryTestId(entity)"
>
<div class="download-library__artwork">
<button
type="button"
class="download-library__artwork-button"
[attr.data-test-id]="
entity.kind === 'series'
? 'download-library-series-open'
: null
"
[disabled]="isPending(representativeOf(entity))"
[attr.aria-label]="
'DOWNLOADS.OPEN_ARTWORK'
| translate: { title: entityTitle(entity) }
"
(click)="openDetails(representativeOf(entity))"
>
<button
type="button"
class="download-library__artwork-button"
data-test-id="download-library-series-open"
[disabled]="isPending(entity.representative)"
[attr.aria-label]="
'DOWNLOADS.OPEN_ARTWORK'
| translate: { title: entity.title }
"
(click)="openDetails(entity.representative)"
>
@if (artworkUrl(entity); as artwork) {
<img
[src]="artwork"
alt=""
loading="lazy"
decoding="async"
(error)="
markArtworkFailed(entity, $event)
"
/>
} @else {
<span
class="download-library__placeholder"
role="img"
[attr.aria-label]="
('DOWNLOADS.ARTWORK_UNAVAILABLE'
| translate) +
': ' +
entity.title
"
>
<mat-icon aria-hidden="true"
>tv</mat-icon
>
</span>
}
</button>
</div>
<div class="download-library__body">
<span class="download-library__type">
{{ 'DOWNLOADS.SERIES' | translate }}
</span>
<span class="download-library__title-hint">
<button
type="button"
class="download-library__title-button"
data-test-id="download-library-series-open"
[disabled]="
isPending(entity.representative)
"
@if (artworkUrl(entity); as artwork) {
<img
[src]="artwork"
alt=""
loading="lazy"
decoding="async"
(error)="markArtworkFailed(entity, $event)"
/>
} @else {
<span
class="download-library__placeholder"
role="img"
[attr.aria-label]="
('DOWNLOADS.OPEN_DETAILS' | translate) +
('DOWNLOADS.ARTWORK_UNAVAILABLE'
| translate) +
': ' +
entityTitle(entity)
"
>
<mat-icon aria-hidden="true">
{{ placeholderIcon(entity) }}
</mat-icon>
</span>
}
</button>
<span class="download-library__type">
{{ typeKey(entity) | translate }}
</span>
<button
mat-icon-button
type="button"
class="download-library__more"
[matMenuTriggerFor]="itemMenu"
[disabled]="moreDisabled(entity)"
[attr.aria-label]="
('DOWNLOADS.MORE_ACTIONS' | translate) +
': ' +
entityTitle(entity)
"
[matTooltip]="'DOWNLOADS.MORE_ACTIONS' | translate"
>
<mat-icon aria-hidden="true">more_vert</mat-icon>
</button>
<mat-menu #itemMenu="matMenu" xPosition="before">
<app-download-source-menu-header
[sourceName]="entity.sourceName"
/>
@if (entity.kind === 'series') {
<button
mat-menu-item
type="button"
[attr.aria-label]="
('DOWNLOADS.OPEN_EPISODES'
| translate) +
': ' +
entity.title
"
(click)="openDetails(entity.representative)"
(click)="openEpisodes(entity)"
>
{{ entity.title }}
<mat-icon aria-hidden="true">
video_library
</mat-icon>
<span>
{{
'DOWNLOADS.OPEN_EPISODES'
| translate
}}
</span>
</button>
</span>
} @else {
<button
mat-menu-item
type="button"
[disabled]="isPending(entity.item)"
[attr.aria-label]="
('DOWNLOADS.PLAY' | translate) +
': ' +
entity.item.title
"
(click)="emitAction('play', entity.item)"
>
<mat-icon aria-hidden="true">
play_arrow
</mat-icon>
<span>
{{ 'DOWNLOADS.PLAY' | translate }}
</span>
</button>
<button
mat-menu-item
type="button"
[disabled]="isPending(entity.item)"
[attr.aria-label]="
('DOWNLOADS.REVEAL' | translate) +
': ' +
entity.item.title
"
(click)="emitAction('reveal', entity.item)"
>
<mat-icon aria-hidden="true">
folder_open
</mat-icon>
<span>
{{ 'DOWNLOADS.REVEAL' | translate }}
</span>
</button>
<button
mat-menu-item
type="button"
[disabled]="isPending(entity.item)"
[attr.aria-label]="
('DOWNLOADS.COPY_URL' | translate) +
': ' +
entity.item.title
"
(click)="
emitAction('copy-url', entity.item)
"
>
<mat-icon aria-hidden="true">
content_copy
</mat-icon>
<span>
{{ 'DOWNLOADS.COPY_URL' | translate }}
</span>
</button>
<button
mat-menu-item
type="button"
[disabled]="isPending(entity.item)"
[attr.aria-label]="
('DOWNLOADS.REMOVE_FROM_MANAGER'
| translate) +
': ' +
entity.item.title
"
(click)="emitAction('remove', entity.item)"
>
<mat-icon aria-hidden="true">
remove_circle_outline
</mat-icon>
<span>
{{
'DOWNLOADS.REMOVE_FROM_MANAGER'
| translate
}}
</span>
</button>
}
</mat-menu>
</div>
<div class="download-library__body">
<button
type="button"
class="download-library__title-button"
[attr.data-test-id]="
entity.kind === 'series'
? 'download-library-series-open'
: null
"
[disabled]="isPending(representativeOf(entity))"
[attr.aria-label]="
('DOWNLOADS.OPEN_DETAILS' | translate) +
': ' +
entityTitle(entity)
"
(click)="openDetails(representativeOf(entity))"
>
{{ entityTitle(entity) }}
</button>
@if (entity.kind === 'series') {
<div class="download-library__facts">
<button
type="button"
@@ -132,272 +259,14 @@
</span>
}
</div>
<div class="download-library__metadata">
<span>{{
formatBytes(entity.trackedBytes)
}}</span>
</div>
<div
class="download-library__actions"
[attr.aria-label]="entity.title"
>
<button
mat-icon-button
type="button"
[matMenuTriggerFor]="seriesMenu"
[attr.aria-label]="
('DOWNLOADS.MORE_ACTIONS' | translate) +
': ' +
entity.title
"
[matTooltip]="
'DOWNLOADS.MORE_ACTIONS' | translate
"
>
<mat-icon aria-hidden="true">
more_vert
</mat-icon>
</button>
<mat-menu #seriesMenu="matMenu">
<app-download-source-menu-header
[sourceName]="entity.sourceName"
/>
<button
mat-menu-item
type="button"
[attr.aria-label]="
('DOWNLOADS.OPEN_EPISODES'
| translate) +
': ' +
entity.title
"
(click)="openEpisodes(entity)"
>
<mat-icon aria-hidden="true">
video_library
</mat-icon>
<span>
{{
'DOWNLOADS.OPEN_EPISODES'
| translate
}}
</span>
</button>
</mat-menu>
</div>
</div>
</article>
} @else {
<article
class="download-library__card"
[attr.data-test-id]="libraryTestId(entity)"
>
<div class="download-library__artwork">
<button
type="button"
class="download-library__artwork-button"
[disabled]="isPending(entity.item)"
[attr.aria-label]="
entity.kind === 'movie'
? ('DOWNLOADS.OPEN_ARTWORK'
| translate
: { title: entity.item.title })
: ('DOWNLOADS.PLAY' | translate) +
': ' +
entity.item.title
"
(click)="
entity.kind === 'movie'
? openDetails(entity.item)
: emitAction('play', entity.item)
"
>
@if (artworkUrl(entity); as artwork) {
<img
[src]="artwork"
alt=""
loading="lazy"
decoding="async"
(error)="
markArtworkFailed(entity, $event)
"
/>
} @else {
<span
class="download-library__placeholder"
role="img"
[attr.aria-label]="
('DOWNLOADS.ARTWORK_UNAVAILABLE'
| translate) +
': ' +
entity.item.title
"
>
<mat-icon aria-hidden="true">
{{
entity.kind === 'movie'
? 'movie'
: 'live_tv'
}}
</mat-icon>
</span>
}
</button>
</div>
<div class="download-library__body">
<span class="download-library__type">
{{
(entity.kind === 'movie'
? 'DOWNLOADS.MOVIE'
: 'DOWNLOADS.EPISODE'
) | translate
}}
}
@if (entity.kind === 'episode' && entity.episodeLabel) {
<span class="download-library__episode-label">
{{ entity.episodeLabel }}
</span>
<button
type="button"
class="download-library__title-button"
[disabled]="isPending(entity.item)"
[attr.aria-label]="
((entity.kind === 'movie'
? 'DOWNLOADS.OPEN_DETAILS'
: 'DOWNLOADS.PLAY'
) | translate) +
': ' +
entity.item.title
"
(click)="
entity.kind === 'movie'
? openDetails(entity.item)
: emitAction('play', entity.item)
"
>
{{ entity.item.title }}
</button>
@if (
entity.kind === 'episode' && entity.episodeLabel
) {
<span class="download-library__episode-label">
{{ entity.episodeLabel }}
</span>
}
<div class="download-library__metadata">
<span>{{
formatBytes(entity.trackedBytes)
}}</span>
</div>
<div
class="download-library__actions"
[attr.aria-label]="entity.item.title"
>
<button
mat-icon-button
type="button"
[disabled]="isPending(entity.item)"
[attr.aria-label]="
('DOWNLOADS.PLAY' | translate) +
': ' +
entity.item.title
"
[matTooltip]="'DOWNLOADS.PLAY' | translate"
(click)="emitAction('play', entity.item)"
>
<mat-icon aria-hidden="true"
>play_arrow</mat-icon
>
</button>
<button
mat-icon-button
type="button"
[disabled]="isPending(entity.item)"
[attr.aria-label]="
('DOWNLOADS.REVEAL' | translate) +
': ' +
entity.item.title
"
[matTooltip]="
'DOWNLOADS.REVEAL' | translate
"
(click)="emitAction('reveal', entity.item)"
>
<mat-icon aria-hidden="true"
>folder_open</mat-icon
>
</button>
<button
mat-icon-button
type="button"
[matMenuTriggerFor]="itemMenu"
[disabled]="isPending(entity.item)"
[attr.aria-label]="
('DOWNLOADS.MORE_ACTIONS' | translate) +
': ' +
entity.item.title
"
[matTooltip]="
'DOWNLOADS.MORE_ACTIONS' | translate
"
>
<mat-icon aria-hidden="true"
>more_vert</mat-icon
>
</button>
<mat-menu #itemMenu="matMenu">
<app-download-source-menu-header
[sourceName]="entity.sourceName"
/>
<button
mat-menu-item
type="button"
[disabled]="isPending(entity.item)"
[attr.aria-label]="
('DOWNLOADS.COPY_URL' | translate) +
': ' +
entity.item.title
"
(click)="
emitAction('copy-url', entity.item)
"
>
<mat-icon aria-hidden="true">
content_copy
</mat-icon>
<span>
{{
'DOWNLOADS.COPY_URL' | translate
}}
</span>
</button>
<button
mat-menu-item
type="button"
[disabled]="isPending(entity.item)"
[attr.aria-label]="
('DOWNLOADS.REMOVE_FROM_MANAGER'
| translate) +
': ' +
entity.item.title
"
(click)="
emitAction('remove', entity.item)
"
>
<mat-icon aria-hidden="true">
remove_circle_outline
</mat-icon>
<span>
{{
'DOWNLOADS.REMOVE_FROM_MANAGER'
| translate
}}
</span>
</button>
</mat-menu>
</div>
</div>
</article>
}
}
</div>
</article>
}
</div>
</section>
@@ -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;
}
@@ -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<HTMLElement>(
'.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 () => {
@@ -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]}`;
}
}
@@ -4,11 +4,14 @@
[attr.data-test-id]="section.testId"
[attr.aria-labelledby]="section.headingId"
>
<header class="download-queue__header">
<h2 [id]="section.headingId">
<h2 class="download-queue__heading" [id]="section.headingId">
<span class="download-queue__heading-label">
{{ section.headingKey | translate }}
</h2>
</header>
</span>
<span class="app-count-badge" aria-hidden="true">
{{ section.items.length }}
</span>
</h2>
<div class="download-queue__list" role="list">
@for (row of section.items; track row.item.id) {
@@ -158,12 +161,6 @@
$implicit: row.item,
}"
/>
<ng-container
[ngTemplateOutlet]="cancelAction"
[ngTemplateOutletContext]="{
$implicit: row.item,
}"
/>
}
@case ('downloading') {
<ng-container
@@ -172,17 +169,12 @@
$implicit: row.item,
}"
/>
<ng-container
[ngTemplateOutlet]="cancelAction"
[ngTemplateOutletContext]="{
$implicit: row.item,
}"
/>
}
@case ('paused') {
<button
type="button"
mat-icon-button
class="download-queue__primary"
data-test-action="resume"
[disabled]="isPending(row.item.id)"
[attr.aria-label]="
@@ -199,18 +191,6 @@
>
<mat-icon>play_arrow</mat-icon>
</button>
<ng-container
[ngTemplateOutlet]="cancelAction"
[ngTemplateOutletContext]="{
$implicit: row.item,
}"
/>
<ng-container
[ngTemplateOutlet]="removeAction"
[ngTemplateOutletContext]="{
$implicit: row.item,
}"
/>
}
@case ('failed') {
<ng-container
@@ -219,12 +199,6 @@
$implicit: row.item,
}"
/>
<ng-container
[ngTemplateOutlet]="removeAction"
[ngTemplateOutletContext]="{
$implicit: row.item,
}"
/>
}
@case ('canceled') {
<ng-container
@@ -233,12 +207,6 @@
$implicit: row.item,
}"
/>
<ng-container
[ngTemplateOutlet]="removeAction"
[ngTemplateOutletContext]="{
$implicit: row.item,
}"
/>
}
}
}
@@ -264,6 +232,25 @@
<app-download-source-menu-header
[sourceName]="row.sourceName"
/>
@if (canCancel(row)) {
<button
type="button"
mat-menu-item
data-test-action="cancel"
[disabled]="isPending(row.item.id)"
[attr.aria-label]="
'DOWNLOADS.ARIA.CANCEL'
| translate
: { title: row.item.title }
"
(click)="emitAction('cancel', row.item)"
>
<mat-icon>close</mat-icon>
<span>
{{ 'DOWNLOADS.CANCEL' | translate }}
</span>
</button>
}
<button
type="button"
mat-menu-item
@@ -273,22 +260,14 @@
'DOWNLOADS.ARIA.COPY_URL'
| translate: { title: row.item.title }
"
[matTooltip]="
'DOWNLOADS.ARIA.COPY_URL'
| translate: { title: row.item.title }
"
(click)="emitAction('copy-url', row.item)"
>
<mat-icon>content_copy</mat-icon>
<span>
{{
'DOWNLOADS.ARIA.COPY_URL'
| translate
: { title: row.item.title }
}}
{{ 'DOWNLOADS.COPY_URL' | translate }}
</span>
</button>
@if (isMissingFile(row)) {
@if (canRemove(row)) {
<button
type="button"
mat-menu-item
@@ -304,9 +283,8 @@
<mat-icon>delete_outline</mat-icon>
<span>
{{
'DOWNLOADS.ARIA.REMOVE'
'DOWNLOADS.REMOVE_FROM_MANAGER'
| translate
: { title: row.item.title }
}}
</span>
</button>
@@ -323,6 +301,7 @@
<button
type="button"
mat-icon-button
class="download-queue__primary"
data-test-action="pause"
[disabled]="isPending(item.id)"
[attr.aria-label]="
@@ -335,46 +314,11 @@
</button>
</ng-template>
<ng-template #cancelAction let-item>
<button
type="button"
mat-icon-button
data-test-action="cancel"
[disabled]="isPending(item.id)"
[attr.aria-label]="
'DOWNLOADS.ARIA.CANCEL' | translate: { title: item.title }
"
[matTooltip]="
'DOWNLOADS.ARIA.CANCEL' | translate: { title: item.title }
"
(click)="emitAction('cancel', item)"
>
<mat-icon>close</mat-icon>
</button>
</ng-template>
<ng-template #removeAction let-item>
<button
type="button"
mat-icon-button
data-test-action="remove"
[disabled]="isPending(item.id)"
[attr.aria-label]="
'DOWNLOADS.ARIA.REMOVE' | translate: { title: item.title }
"
[matTooltip]="
'DOWNLOADS.ARIA.REMOVE' | translate: { title: item.title }
"
(click)="emitAction('remove', item)"
>
<mat-icon>delete_outline</mat-icon>
</button>
</ng-template>
<ng-template #retryAction let-item>
<button
type="button"
mat-icon-button
class="download-queue__primary"
data-test-action="retry"
[disabled]="isPending(item.id)"
[attr.aria-label]="
@@ -4,43 +4,37 @@
}
.download-queue {
overflow: hidden;
border: 1px solid
color-mix(
in srgb,
var(--app-separator, var(--mat-sys-outline-variant)) 78%,
transparent
);
border-radius: 14px;
background: var(--mat-sys-surface-container-low);
& + & {
margin-top: 16px;
margin-top: 20px;
}
&__header {
padding: 11px 16px;
border-bottom: 1px solid
color-mix(
in srgb,
var(--app-separator, var(--mat-sys-outline-variant)) 72%,
transparent
);
background: var(
--app-widget-header-bg,
var(--mat-sys-surface-container)
);
h2 {
margin: 0;
color: var(--mat-sys-on-surface);
font: var(--mat-sys-title-small);
letter-spacing: 0.01em;
}
// Section heading mirrors the dashboard rail title contract
// (rail__title + .app-count-badge) so same-level sections read alike
// across the app.
&__heading {
display: inline-flex;
align-items: baseline;
gap: 8px;
margin: 0 0 10px;
padding: 0 2px;
color: var(--app-heading-color, var(--mat-sys-on-surface));
font-size: 1.125rem;
font-weight: 600;
letter-spacing: -0.012em;
line-height: 1.2;
}
&__list {
display: grid;
overflow: hidden;
border: 1px solid
color-mix(
in srgb,
var(--app-separator, var(--mat-sys-outline-variant)) 78%,
transparent
);
border-radius: 14px;
background: var(--mat-sys-surface-container-low);
}
&__item {
@@ -247,7 +241,7 @@
&__actions {
justify-content: flex-end;
gap: 2px;
gap: 4px;
button {
flex: none;
@@ -263,6 +257,22 @@
transform: translateY(-1px);
}
}
// The one action that moves the download forward
// (pause/resume/retry). Destructive commands live in the
// overflow menu.
.download-queue__primary {
background: var(--mat-sys-primary-container);
color: var(--mat-sys-on-primary-container);
&:hover:not(:disabled) {
background: color-mix(
in srgb,
var(--mat-sys-primary) 24%,
var(--mat-sys-primary-container)
);
}
}
}
&__recover {
@@ -17,6 +17,9 @@ const TEST_TRANSLATIONS = {
DOWNLOADS: {
DOWNLOADING_NOW: 'Downloading now',
NEEDS_ATTENTION: 'Needs attention',
CANCEL: 'Cancel',
COPY_URL: 'Copy download URL',
REMOVE_FROM_MANAGER: 'Remove from manager',
STATUS: {
QUEUED: 'Queued',
DOWNLOADING: 'Downloading',
@@ -141,9 +144,14 @@ describe('DownloadQueueComponent', () => {
'[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<HTMLButtonElement>('[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');
});
});
@@ -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'
@@ -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 () => {
@@ -43,7 +43,8 @@ describe('DialogService', () => {
title: 'Remove',
message: 'Confirm removal?',
}),
width: '300px',
width: '420px',
maxWidth: 'calc(100vw - 32px)',
})
);
});
@@ -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()