feat(downloads): add active download queue

This commit is contained in:
4gray committed 2026-08-01 16:29:49 +02:00
1 parent 8e1af010a9
commit eb55c17a05
5 files changed
+1372

No files matched your search

@@ -0,0 +1,16 @@
import type { DownloadItem } from '@iptvnator/services';
export type DownloadItemActionType =
| 'cancel'
| 'copy-url'
| 'pause'
| 'play'
| 'remove'
| 'resume'
| 'retry'
| 'reveal';
export interface DownloadItemAction {
readonly type: DownloadItemActionType;
readonly item: DownloadItem;
}
@@ -0,0 +1,342 @@
@for (section of sections(); track section.testId) {
<section
class="download-queue"
[attr.data-test-id]="section.testId"
[attr.aria-labelledby]="section.headingId"
>
<header class="download-queue__header">
<h2 [id]="section.headingId">
{{ section.headingKey | translate }}
</h2>
</header>
<div class="download-queue__list" role="list">
@for (row of section.items; track row.item.id) {
<article
class="download-queue__item"
[class.download-queue__item--pending]="
isPending(row.item.id)
"
[attr.data-test-id]="'download-queue-item-' + row.item.id"
role="listitem"
>
<button
type="button"
class="download-queue__artwork"
data-test-control="artwork"
[attr.aria-label]="
'DOWNLOADS.ARIA.OPEN'
| translate: { title: row.item.title }
"
[disabled]="isPending(row.item.id)"
(click)="requestOpen(row.item)"
>
@if (hasPoster(row.item)) {
<img
[src]="row.item.posterUrl ?? ''"
alt=""
loading="lazy"
decoding="async"
(error)="markPosterFailed(row.item)"
/>
} @else {
<span
class="download-queue__poster-placeholder"
role="img"
[attr.aria-label]="
'DOWNLOADS.ARIA.POSTER_UNAVAILABLE'
| translate: { title: row.item.title }
"
>
<mat-icon aria-hidden="true">
{{
row.item.contentType === 'episode'
? 'tv'
: 'movie'
}}
</mat-icon>
</span>
}
</button>
<div class="download-queue__content">
<div class="download-queue__title-line">
<button
type="button"
class="download-queue__title"
data-test-control="title"
[attr.aria-label]="
'DOWNLOADS.ARIA.OPEN'
| translate: { title: row.item.title }
"
[disabled]="isPending(row.item.id)"
(click)="requestOpen(row.item)"
>
{{ row.item.title }}
</button>
@if (row.episodeLabel.trim()) {
<span class="download-queue__episode">
{{ row.episodeLabel }}
</span>
}
</div>
<div class="download-queue__metadata">
<span
class="download-queue__status"
[class]="
'download-queue__status download-queue__status--' +
row.item.status
"
>
<mat-icon aria-hidden="true">
{{ statusIcon(row.item) }}
</mat-icon>
{{
'DOWNLOADS.STATUS.' +
row.item.status.toUpperCase()
| translate
}}
</span>
<span class="download-queue__bytes">
{{ formatBytes(row.item.bytesDownloaded) }}
@if (row.item.totalBytes !== undefined) {
/
{{ formatBytes(row.item.totalBytes) }}
}
</span>
@if (row.sourceName.trim()) {
<span class="download-queue__source">
{{ row.sourceName.trim() }}
</span>
}
@if (row.item.errorMessage?.trim(); as error) {
<span class="download-queue__error">
{{ error }}
</span>
}
</div>
@if (progressMode(row.item); as mode) {
<mat-progress-bar
class="download-queue__progress"
[mode]="mode"
[value]="progressValue(row.item)"
[attr.aria-label]="
'DOWNLOADS.ARIA.PROGRESS'
| translate: { title: row.item.title }
"
/>
}
</div>
<div class="download-queue__actions">
@switch (row.item.status) {
@case ('queued') {
<ng-container
[ngTemplateOutlet]="pauseAction"
[ngTemplateOutletContext]="{
$implicit: row.item,
}"
/>
<ng-container
[ngTemplateOutlet]="cancelAction"
[ngTemplateOutletContext]="{
$implicit: row.item,
}"
/>
}
@case ('downloading') {
<ng-container
[ngTemplateOutlet]="pauseAction"
[ngTemplateOutletContext]="{
$implicit: row.item,
}"
/>
<ng-container
[ngTemplateOutlet]="cancelAction"
[ngTemplateOutletContext]="{
$implicit: row.item,
}"
/>
}
@case ('paused') {
<button
type="button"
mat-icon-button
data-test-action="resume"
[disabled]="isPending(row.item.id)"
[attr.aria-label]="
'DOWNLOADS.ARIA.RESUME'
| translate
: { title: row.item.title }
"
[matTooltip]="
'DOWNLOADS.ARIA.RESUME'
| translate
: { title: row.item.title }
"
(click)="emitAction('resume', row.item)"
>
<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
[ngTemplateOutlet]="retryAction"
[ngTemplateOutletContext]="{
$implicit: row.item,
}"
/>
<ng-container
[ngTemplateOutlet]="removeAction"
[ngTemplateOutletContext]="{
$implicit: row.item,
}"
/>
}
@case ('canceled') {
<ng-container
[ngTemplateOutlet]="retryAction"
[ngTemplateOutletContext]="{
$implicit: row.item,
}"
/>
<ng-container
[ngTemplateOutlet]="removeAction"
[ngTemplateOutletContext]="{
$implicit: row.item,
}"
/>
}
}
<button
type="button"
mat-icon-button
data-test-action="more"
[matMenuTriggerFor]="itemMenu"
[disabled]="isPending(row.item.id)"
[attr.aria-label]="
'DOWNLOADS.ARIA.MORE_ACTIONS'
| translate: { title: row.item.title }
"
[matTooltip]="
'DOWNLOADS.ARIA.MORE_ACTIONS'
| translate: { title: row.item.title }
"
>
<mat-icon>more_vert</mat-icon>
</button>
<mat-menu #itemMenu="matMenu" xPosition="before">
<button
type="button"
mat-menu-item
data-test-action="copy-url"
[disabled]="isPending(row.item.id)"
[attr.aria-label]="
'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 }
}}
</span>
</button>
</mat-menu>
</div>
</article>
}
</div>
</section>
}
<ng-template #pauseAction let-item>
<button
type="button"
mat-icon-button
data-test-action="pause"
[disabled]="isPending(item.id)"
[attr.aria-label]="
'DOWNLOADS.ARIA.PAUSE' | translate: { title: item.title }
"
[matTooltip]="'DOWNLOADS.ARIA.PAUSE' | translate: { title: item.title }"
(click)="emitAction('pause', item)"
>
<mat-icon>pause</mat-icon>
</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
data-test-action="retry"
[disabled]="isPending(item.id)"
[attr.aria-label]="
'DOWNLOADS.ARIA.RETRY' | translate: { title: item.title }
"
[matTooltip]="'DOWNLOADS.ARIA.RETRY' | translate: { title: item.title }"
(click)="emitAction('retry', item)"
>
<mat-icon>refresh</mat-icon>
</button>
</ng-template>
@@ -0,0 +1,312 @@
:host {
display: block;
container-type: inline-size;
}
.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;
}
&__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;
}
}
&__list {
display: grid;
}
&__item {
display: grid;
grid-template-columns: 58px minmax(0, 1fr) auto;
align-items: center;
gap: 14px;
min-width: 0;
padding: 12px 14px;
& + & {
border-top: 1px solid
color-mix(
in srgb,
var(--app-separator, var(--mat-sys-outline-variant)) 64%,
transparent
);
}
&--pending {
opacity: 0.68;
}
}
&__artwork {
position: relative;
width: 58px;
height: 80px;
overflow: hidden;
padding: 0;
border: 0;
border-radius: 9px;
background: var(--mat-sys-surface-container-highest);
color: var(--mat-sys-on-surface-variant);
cursor: pointer;
transition:
transform 140ms ease,
box-shadow 140ms ease;
img,
.download-queue__poster-placeholder {
width: 100%;
height: 100%;
}
img {
display: block;
object-fit: cover;
}
&:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: var(--mat-sys-level1);
}
}
&__poster-placeholder {
display: grid;
place-items: center;
background: color-mix(
in srgb,
var(--mat-sys-primary-container) 44%,
var(--mat-sys-surface-container-highest)
);
color: var(--mat-sys-on-primary-container);
mat-icon {
width: 24px;
height: 24px;
font-size: 24px;
}
}
&__content {
display: grid;
gap: 8px;
min-width: 0;
}
&__title-line,
&__metadata,
&__actions {
display: flex;
align-items: center;
}
&__title-line {
gap: 8px;
min-width: 0;
}
&__title {
min-width: 0;
overflow: hidden;
padding: 2px 0;
border: 0;
background: transparent;
color: var(--mat-sys-on-surface);
font: var(--mat-sys-title-medium);
text-align: start;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
&:hover:not(:disabled) {
color: var(--mat-sys-primary);
}
}
&__episode {
flex: none;
padding: 2px 7px;
border-radius: 999px;
background: var(--mat-sys-primary-container);
color: var(--mat-sys-on-primary-container);
font: var(--mat-sys-label-small);
}
&__metadata {
flex-wrap: wrap;
gap: 6px 12px;
min-width: 0;
color: var(--mat-sys-on-surface-variant);
font: var(--mat-sys-body-small);
}
&__status {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 8px;
border-radius: 999px;
font: var(--mat-sys-label-medium);
mat-icon {
width: 16px;
height: 16px;
font-size: 16px;
}
&--queued {
background: var(--mat-sys-surface-container-highest);
color: var(--mat-sys-on-surface-variant);
}
&--downloading {
background: var(--mat-sys-primary-container);
color: var(--mat-sys-on-primary-container);
}
&--paused {
background: var(--mat-sys-tertiary-container);
color: var(--mat-sys-on-tertiary-container);
}
&--failed,
&--canceled {
background: var(--mat-sys-error-container);
color: var(--mat-sys-on-error-container);
}
}
&__bytes {
font-variant-numeric: tabular-nums;
}
&__source {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__error {
color: var(--mat-sys-error);
font-weight: 500;
}
&__progress {
width: min(420px, 100%);
border-radius: 999px;
}
&__actions {
justify-content: flex-end;
gap: 2px;
button {
flex: none;
transition:
background-color 140ms ease,
transform 140ms ease;
&:hover:not(:disabled) {
background: var(
--app-selection-surface,
var(--mat-sys-secondary-container)
);
transform: translateY(-1px);
}
}
}
button:focus-visible {
outline: 2px solid var(--mat-sys-primary);
outline-offset: 2px;
}
button:disabled {
cursor: default;
}
}
@container (max-width: 680px) {
.download-queue {
&__item {
grid-template-columns: 52px minmax(0, 1fr);
align-items: start;
gap: 10px 12px;
}
&__artwork {
width: 52px;
height: 74px;
}
&__actions {
grid-column: 2;
justify-content: flex-start;
margin-top: -4px;
}
&__progress {
width: 100%;
}
}
}
@container (max-width: 430px) {
.download-queue {
&__item {
padding-inline: 10px;
}
&__title-line {
align-items: flex-start;
flex-direction: column;
gap: 4px;
}
&__metadata {
align-items: flex-start;
flex-direction: column;
gap: 5px;
}
}
}
@media (prefers-reduced-motion: reduce) {
.download-queue {
&__artwork,
&__actions button {
transition: none;
&:hover:not(:disabled) {
transform: none;
}
}
}
}
@@ -0,0 +1,523 @@
import { OverlayContainer } from '@angular/cdk/overlay';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatProgressBarHarness } from '@angular/material/progress-bar/testing';
import { MatTooltipHarness } from '@angular/material/tooltip/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import type { DownloadItem } from '@iptvnator/services';
import type { DownloadItemAction } from './download-actions';
import type { DownloadListItemViewModel } from './download-manager.viewmodel';
import {
DownloadQueueComponent,
formatDownloadBytes,
} from './download-queue.component';
const TEST_TRANSLATIONS = {
DOWNLOADS: {
DOWNLOADING_NOW: 'Downloading now',
NEEDS_ATTENTION: 'Needs attention',
STATUS: {
QUEUED: 'Queued',
DOWNLOADING: 'Downloading',
PAUSED: 'Paused',
FAILED: 'Failed',
CANCELED: 'Canceled',
},
ARIA: {
OPEN: 'Open {{title}}',
POSTER_UNAVAILABLE: 'Artwork unavailable for {{title}}',
PROGRESS: 'Download progress for {{title}}',
PAUSE: 'Pause {{title}}',
CANCEL: 'Cancel {{title}}',
RESUME: 'Resume {{title}}',
REMOVE: 'Remove {{title}} from manager',
RETRY: 'Retry {{title}}',
MORE_ACTIONS: 'More actions for {{title}}',
COPY_URL: 'Copy URL for {{title}}',
},
},
};
function createItem(
id: number,
status: DownloadItem['status'],
overrides: Partial<DownloadItem> = {}
): DownloadItem {
return {
id,
playlistId: 'playlist-a',
xtreamId: id * 10,
contentType: 'vod',
title: `Download ${id}`,
url: `https://example.test/${id}`,
status,
createdAt: '2026-07-30T10:00:00Z',
...overrides,
};
}
function createRow(
id: number,
status: DownloadItem['status'],
overrides: Partial<DownloadListItemViewModel> = {}
): DownloadListItemViewModel {
return {
item: createItem(id, status),
episodeLabel: '',
seriesTitle: '',
sourceName: '',
...overrides,
};
}
describe('DownloadQueueComponent', () => {
let fixture: ComponentFixture<DownloadQueueComponent>;
let overlayContainer: OverlayContainer;
function render(
activeItems: readonly DownloadListItemViewModel[] = [],
attentionItems: readonly DownloadListItemViewModel[] = [],
pendingIds: ReadonlySet<number> = new Set()
): void {
fixture.componentRef.setInput('activeItems', activeItems);
fixture.componentRef.setInput('attentionItems', attentionItems);
fixture.componentRef.setInput('pendingIds', pendingIds);
fixture.detectChanges();
}
function row(id: number): HTMLElement {
const element = fixture.nativeElement.querySelector(
`[data-test-id="download-queue-item-${id}"]`
);
expect(element).not.toBeNull();
return element;
}
function actionButton(host: ParentNode, action: string): HTMLButtonElement {
const button = host.querySelector<HTMLButtonElement>(
`[data-test-action="${action}"]`
);
expect(button).not.toBeNull();
return button as HTMLButtonElement;
}
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
DownloadQueueComponent,
NoopAnimationsModule,
TranslateModule.forRoot(),
],
}).compileComponents();
const translate = TestBed.inject(TranslateService);
translate.setTranslation('en', TEST_TRANSLATIONS);
translate.use('en');
overlayContainer = TestBed.inject(OverlayContainer);
fixture = TestBed.createComponent(DownloadQueueComponent);
});
afterEach(() => {
overlayContainer.getContainerElement().replaceChildren();
});
it('omits empty sections and renders named sections with stable row IDs', () => {
const queued = createRow(1, 'queued');
const failed = createRow(2, 'failed');
render([queued], []);
const activeSection: HTMLElement = fixture.nativeElement.querySelector(
'[data-test-id="downloads-active-section"]'
);
expect(activeSection).not.toBeNull();
expect(activeSection.querySelector('h2')?.textContent?.trim()).toBe(
'Downloading now'
);
expect(activeSection.getAttribute('aria-labelledby')).toBeTruthy();
expect(
fixture.nativeElement.querySelector(
'[data-test-id="downloads-attention-section"]'
)
).toBeNull();
expect(row(1)).not.toBeNull();
render([], [failed]);
const attentionSection: HTMLElement =
fixture.nativeElement.querySelector(
'[data-test-id="downloads-attention-section"]'
);
expect(
fixture.nativeElement.querySelector(
'[data-test-id="downloads-active-section"]'
)
).toBeNull();
expect(attentionSection.querySelector('h2')?.textContent?.trim()).toBe(
'Needs attention'
);
expect(row(2)).not.toBeNull();
render();
expect(fixture.nativeElement.querySelector('section')).toBeNull();
});
it.each([
['queued', 'Queued', 'schedule'],
['downloading', 'Downloading', 'downloading'],
['paused', 'Paused', 'pause_circle'],
['failed', 'Failed', 'error'],
['canceled', 'Canceled', 'cancel'],
] as const)(
'renders translated %s status text with its semantic icon',
(status, text, icon) => {
const item = createRow(1, status);
render(
status === 'failed' || status === 'canceled' ? [] : [item],
status === 'failed' || status === 'canceled' ? [item] : []
);
const statusPill = row(1).querySelector<HTMLElement>(
'.download-queue__status'
);
expect(statusPill?.textContent).toContain(text);
expect(
statusPill?.querySelector('mat-icon')?.textContent?.trim()
).toBe(icon);
}
);
it.each([
['queued', ['pause', 'cancel']],
['downloading', ['pause', 'cancel']],
['paused', ['resume', 'cancel', 'remove']],
['failed', ['retry', 'remove']],
['canceled', ['retry', 'remove']],
] as const)(
'exposes the exact primary action contract for %s',
(status, expectedActions) => {
const viewModel = createRow(7, status);
const emitted: DownloadItemAction[] = [];
const opened = jest.fn();
fixture.componentInstance.itemAction.subscribe((action) =>
emitted.push(action)
);
fixture.componentInstance.openRequested.subscribe(opened);
render(
status === 'failed' || status === 'canceled' ? [] : [viewModel],
status === 'failed' || status === 'canceled' ? [viewModel] : []
);
const host = row(7);
const renderedActions = Array.from(
host.querySelectorAll<HTMLButtonElement>(
'[data-test-action]:not([data-test-action="more"])'
)
).map((button) => button.dataset['testAction']);
expect(renderedActions).toEqual(expectedActions);
for (const action of expectedActions) {
actionButton(host, action).click();
}
expect(emitted.map(({ type }) => type)).toEqual(expectedActions);
for (const event of emitted) {
expect(event.item).toBe(viewModel.item);
}
expect(opened).not.toHaveBeenCalled();
}
);
it('emits copy-url with the original item only through the Material overflow menu', async () => {
const viewModel = createRow(11, 'queued');
const emitted = jest.fn<void, [DownloadItemAction]>();
const opened = jest.fn();
fixture.componentInstance.itemAction.subscribe(emitted);
fixture.componentInstance.openRequested.subscribe(opened);
render([viewModel]);
expect(
row(11).querySelector('[data-test-action="copy-url"]')
).toBeNull();
actionButton(row(11), 'more').click();
fixture.detectChanges();
await fixture.whenStable();
const copyButton = actionButton(
overlayContainer.getContainerElement(),
'copy-url'
);
expect(copyButton.closest('.mat-mdc-menu-panel')).not.toBeNull();
expect(copyButton.textContent).toContain('Copy URL for Download 11');
copyButton.click();
expect(emitted).toHaveBeenCalledTimes(1);
expect(emitted.mock.calls[0][0]).toEqual({
type: 'copy-url',
item: viewModel.item,
});
expect(emitted.mock.calls[0][0].item).toBe(viewModel.item);
expect(opened).not.toHaveBeenCalled();
});
it('opens the original item from real artwork and title buttons only', () => {
const viewModel = createRow(5, 'downloading');
const opened: DownloadItem[] = [];
fixture.componentInstance.openRequested.subscribe((item) =>
opened.push(item)
);
render([viewModel]);
const artwork = row(5).querySelector<HTMLButtonElement>(
'[data-test-control="artwork"]'
);
const title = row(5).querySelector<HTMLButtonElement>(
'[data-test-control="title"]'
);
expect(artwork?.tagName).toBe('BUTTON');
expect(artwork?.type).toBe('button');
expect(title?.tagName).toBe('BUTTON');
expect(title?.type).toBe('button');
artwork?.click();
title?.click();
expect(opened).toEqual([viewModel.item, viewModel.item]);
expect(opened[0]).toBe(viewModel.item);
expect(opened[1]).toBe(viewModel.item);
});
it('disables navigation, primary actions, overflow, and an open menu command while pending', async () => {
const viewModel = createRow(17, 'paused');
const emitted = jest.fn();
const opened = jest.fn();
fixture.componentInstance.itemAction.subscribe(emitted);
fixture.componentInstance.openRequested.subscribe(opened);
render([viewModel]);
actionButton(row(17), 'more').click();
fixture.detectChanges();
await fixture.whenStable();
expect(
actionButton(overlayContainer.getContainerElement(), 'copy-url')
.disabled
).toBe(false);
fixture.componentRef.setInput('pendingIds', new Set([17]));
fixture.detectChanges();
const itemButtons = Array.from(
row(17).querySelectorAll<HTMLButtonElement>('button')
);
expect(itemButtons).toHaveLength(6);
expect(itemButtons.every(({ disabled }) => disabled)).toBe(true);
expect(
actionButton(overlayContainer.getContainerElement(), 'copy-url')
.disabled
).toBe(true);
fixture.componentInstance.emitAction('remove', viewModel.item);
fixture.componentInstance.requestOpen(viewModel.item);
expect(emitted).not.toHaveBeenCalled();
expect(opened).not.toHaveBeenCalled();
});
it('renders determinate and indeterminate progress, omits meaningless paused progress, and clamps values', async () => {
const known = createRow(1, 'downloading', {
item: createItem(1, 'downloading', {
bytesDownloaded: 250,
totalBytes: 200,
}),
});
const unknown = createRow(2, 'queued', {
item: createItem(2, 'queued', {
bytesDownloaded: 10,
totalBytes: undefined,
}),
});
const pausedUnknown = createRow(3, 'paused', {
item: createItem(3, 'paused', {
bytesDownloaded: 20,
totalBytes: 0,
}),
});
const pausedKnown = createRow(4, 'paused', {
item: createItem(4, 'paused', {
bytesDownloaded: -50,
totalBytes: 100,
}),
});
render([known, unknown, pausedUnknown, pausedKnown]);
const loader = TestbedHarnessEnvironment.loader(fixture);
const bars = await loader.getAllHarnesses(MatProgressBarHarness);
expect(bars).toHaveLength(3);
expect(await Promise.all(bars.map((bar) => bar.getMode()))).toEqual([
'determinate',
'indeterminate',
'determinate',
]);
expect(await bars[0].getValue()).toBe(100);
expect(await bars[2].getValue()).toBe(0);
expect(
fixture.componentInstance.progressValue(
createItem(5, 'queued', {
bytesDownloaded: Number.POSITIVE_INFINITY,
totalBytes: 100,
})
)
).toBe(0);
expect(row(3).querySelector('.download-queue__progress')).toBeNull();
expect(
Array.from(
fixture.nativeElement.querySelectorAll<HTMLElement>(
'.download-queue__progress'
)
).map((bar) => bar.getAttribute('aria-label'))
).toEqual([
'Download progress for Download 1',
'Download progress for Download 2',
'Download progress for Download 4',
]);
expect(fixture.nativeElement.querySelector('[aria-live]')).toBeNull();
});
it.each([
[undefined, '0 B'],
[0, '0 B'],
[-1, '0 B'],
[Number.NaN, '0 B'],
[Number.POSITIVE_INFINITY, '0 B'],
[0.5, '0.5 B'],
[1023, '1023 B'],
[1024, '1 KB'],
[1.5 * 1024 ** 2, '1.5 MB'],
[2 * 1024 ** 3, '2 GB'],
[3.25 * 1024 ** 4, '3.3 TB'],
[1024 ** 5, '1024 TB'],
])('formats byte metadata %p as %s', (bytes, expected) => {
expect(formatDownloadBytes(bytes)).toBe(expected);
});
it('renders downloaded/total bytes, episode/source/error metadata, and omits empty sources', () => {
const detailed = createRow(21, 'failed', {
item: createItem(21, 'failed', {
contentType: 'episode',
bytesDownloaded: 1.5 * 1024 ** 2,
totalBytes: 2 * 1024 ** 2,
errorMessage: 'Connection reset',
}),
episodeLabel: 'S02E04',
seriesTitle: 'Example series',
sourceName: 'Alpha Source',
});
const noSource = createRow(22, 'canceled', {
item: createItem(22, 'canceled', {
bytesDownloaded: undefined,
totalBytes: undefined,
}),
sourceName: ' ',
});
render([], [detailed, noSource]);
expect(
row(21)
.querySelector('.download-queue__bytes')
?.textContent?.replace(/\s+/g, ' ')
.trim()
).toBe('1.5 MB / 2 MB');
expect(
row(21)
.querySelector('.download-queue__episode')
?.textContent?.trim()
).toBe('S02E04');
expect(
row(21)
.querySelector('.download-queue__source')
?.textContent?.trim()
).toBe('Alpha Source');
expect(
row(21).querySelector('.download-queue__error')?.textContent?.trim()
).toBe('Connection reset');
expect(row(22).querySelector('.download-queue__source')).toBeNull();
expect(
row(22).querySelector('.download-queue__bytes')?.textContent
).toContain('0 B');
});
it('uses a lazy decorative image and swaps missing or broken posters for a semantic placeholder', () => {
const withPoster = createRow(31, 'queued', {
item: createItem(31, 'queued', {
title: 'Poster title',
posterUrl: 'https://example.test/poster.jpg',
}),
});
const missingPoster = createRow(32, 'queued', {
item: createItem(32, 'queued', {
title: 'Missing title',
posterUrl: ' ',
}),
});
render([withPoster, missingPoster]);
const image = row(31).querySelector<HTMLImageElement>('img');
expect(image?.getAttribute('loading')).toBe('lazy');
expect(image?.alt).toBe('');
expect(
row(32).querySelector('[role="img"]')?.getAttribute('aria-label')
).toBe('Artwork unavailable for Missing title');
image?.dispatchEvent(new Event('error'));
fixture.detectChanges();
expect(row(31).querySelector('img')).toBeNull();
expect(
row(31).querySelector('[role="img"]')?.getAttribute('aria-label')
).toBe('Artwork unavailable for Poster title');
fixture.componentInstance.markPosterFailed(withPoster.item);
expect(fixture.componentInstance.hasPoster(withPoster.item)).toBe(
false
);
});
it('gives every icon command an item-specific translated accessible name and tooltip', async () => {
const viewModel = createRow(41, 'queued', {
item: createItem(41, 'queued', { title: 'Named movie' }),
});
render([viewModel]);
expect(actionButton(row(41), 'pause').getAttribute('aria-label')).toBe(
'Pause Named movie'
);
expect(actionButton(row(41), 'cancel').getAttribute('aria-label')).toBe(
'Cancel Named movie'
);
expect(actionButton(row(41), 'more').getAttribute('aria-label')).toBe(
'More actions for Named movie'
);
const loader = TestbedHarnessEnvironment.loader(fixture);
const pauseTooltip = await loader.getHarness(
MatTooltipHarness.with({
selector: '[data-test-action="pause"]',
})
);
await pauseTooltip.show();
expect(await pauseTooltip.getTooltipText()).toBe('Pause Named movie');
await pauseTooltip.hide();
actionButton(row(41), 'more').click();
fixture.detectChanges();
await fixture.whenStable();
const copyButton = actionButton(
overlayContainer.getContainerElement(),
'copy-url'
);
expect(copyButton.type).toBe('button');
expect(copyButton.getAttribute('aria-label')).toBe(
'Copy URL for Named movie'
);
});
});
@@ -0,0 +1,179 @@
import { NgTemplateOutlet } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
signal,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import type { DownloadItem } from '@iptvnator/services';
import type {
DownloadItemAction,
DownloadItemActionType,
} from './download-actions';
import type { DownloadListItemViewModel } from './download-manager.viewmodel';
type DownloadProgressMode = 'determinate' | 'indeterminate';
interface DownloadQueueSection {
readonly headingId: string;
readonly headingKey: string;
readonly items: readonly DownloadListItemViewModel[];
readonly testId: string;
}
interface DownloadItemWithKnownTotal extends DownloadItem {
readonly totalBytes: number;
}
const STATUS_ICONS: Readonly<Record<DownloadItem['status'], string>> = {
queued: 'schedule',
downloading: 'downloading',
paused: 'pause_circle',
completed: 'check_circle',
failed: 'error',
canceled: 'cancel',
};
const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const;
export function formatDownloadBytes(bytes: number | undefined): string {
if (!Number.isFinite(bytes) || bytes === undefined || bytes <= 0) {
return '0 B';
}
const unitIndex = Math.max(
0,
Math.min(
Math.floor(Math.log(bytes) / Math.log(1024)),
BYTE_UNITS.length - 1
)
);
const value = bytes / 1024 ** unitIndex;
const rounded = Math.round(value * 10) / 10;
return `${rounded} ${BYTE_UNITS[unitIndex]}`;
}
@Component({
selector: 'app-download-queue',
imports: [
MatButtonModule,
MatIcon,
MatMenuModule,
MatProgressBarModule,
MatTooltip,
NgTemplateOutlet,
TranslatePipe,
],
templateUrl: './download-queue.component.html',
styleUrl: './download-queue.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DownloadQueueComponent {
readonly activeItems =
input.required<readonly DownloadListItemViewModel[]>();
readonly attentionItems =
input.required<readonly DownloadListItemViewModel[]>();
readonly pendingIds = input<ReadonlySet<number>>(new Set());
readonly itemAction = output<DownloadItemAction>();
readonly openRequested = output<DownloadItem>();
private readonly failedPosterIds = signal<ReadonlySet<number>>(new Set());
readonly sections = computed<readonly DownloadQueueSection[]>(() => {
const sections: DownloadQueueSection[] = [
{
headingId: 'downloads-active-heading',
headingKey: 'DOWNLOADS.DOWNLOADING_NOW',
items: this.activeItems(),
testId: 'downloads-active-section',
},
{
headingId: 'downloads-attention-heading',
headingKey: 'DOWNLOADS.NEEDS_ATTENTION',
items: this.attentionItems(),
testId: 'downloads-attention-section',
},
];
return sections.filter(({ items }) => items.length > 0);
});
emitAction(type: DownloadItemActionType, item: DownloadItem): void {
if (!this.isPending(item.id)) {
this.itemAction.emit({ type, item });
}
}
requestOpen(item: DownloadItem): void {
if (!this.isPending(item.id)) {
this.openRequested.emit(item);
}
}
isPending(id: number): boolean {
return this.pendingIds().has(id);
}
statusIcon(item: DownloadItem): string {
return STATUS_ICONS[item.status];
}
hasPoster(item: DownloadItem): boolean {
return (
(item.posterUrl?.trim().length ?? 0) > 0 &&
!this.failedPosterIds().has(item.id)
);
}
markPosterFailed(item: DownloadItem): void {
this.failedPosterIds.update((ids) => {
if (ids.has(item.id)) {
return ids;
}
const next = new Set(ids);
next.add(item.id);
return next;
});
}
hasKnownTotal(item: DownloadItem): item is DownloadItemWithKnownTotal {
return (
item.totalBytes !== undefined &&
Number.isFinite(item.totalBytes) &&
item.totalBytes > 0
);
}
progressMode(item: DownloadItem): DownloadProgressMode | null {
if (item.status === 'paused') {
return this.hasKnownTotal(item) ? 'determinate' : null;
}
if (item.status === 'queued' || item.status === 'downloading') {
return this.hasKnownTotal(item) ? 'determinate' : 'indeterminate';
}
return null;
}
progressValue(item: DownloadItem): number {
if (!this.hasKnownTotal(item)) {
return 0;
}
const downloaded =
item.bytesDownloaded !== undefined &&
Number.isFinite(item.bytesDownloaded)
? Math.max(0, item.bytesDownloaded)
: 0;
return Math.min(100, (downloaded / item.totalBytes) * 100);
}
formatBytes(bytes: number | undefined): string {
return formatDownloadBytes(bytes);
}
}