mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(downloads): align manager cards, queue rows, and headings with the rail design language (#1448)
This commit is contained in:
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.
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user