From eb55c17a05e967f06ac63d315523f96e1bb59420 Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 30 Jul 2026 02:07:45 +0200 Subject: [PATCH] feat(downloads): add active download queue --- .../feature/src/lib/download-actions.ts | 16 + .../src/lib/download-queue.component.html | 342 ++++++++++++ .../src/lib/download-queue.component.scss | 312 +++++++++++ .../src/lib/download-queue.component.spec.ts | 523 ++++++++++++++++++ .../src/lib/download-queue.component.ts | 179 ++++++ 5 files changed, 1372 insertions(+) create mode 100644 libs/portal/downloads/feature/src/lib/download-actions.ts create mode 100644 libs/portal/downloads/feature/src/lib/download-queue.component.html create mode 100644 libs/portal/downloads/feature/src/lib/download-queue.component.scss create mode 100644 libs/portal/downloads/feature/src/lib/download-queue.component.spec.ts create mode 100644 libs/portal/downloads/feature/src/lib/download-queue.component.ts diff --git a/libs/portal/downloads/feature/src/lib/download-actions.ts b/libs/portal/downloads/feature/src/lib/download-actions.ts new file mode 100644 index 000000000..acb6f65e1 --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/download-actions.ts @@ -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; +} diff --git a/libs/portal/downloads/feature/src/lib/download-queue.component.html b/libs/portal/downloads/feature/src/lib/download-queue.component.html new file mode 100644 index 000000000..7f41fc1ec --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/download-queue.component.html @@ -0,0 +1,342 @@ +@for (section of sections(); track section.testId) { +
+
+

+ {{ section.headingKey | translate }} +

+
+ +
+ @for (row of section.items; track row.item.id) { +
+ + +
+
+ + @if (row.episodeLabel.trim()) { + + {{ row.episodeLabel }} + + } +
+ + + + @if (progressMode(row.item); as mode) { + + } +
+ +
+ @switch (row.item.status) { + @case ('queued') { + + + } + @case ('downloading') { + + + } + @case ('paused') { + + + + } + @case ('failed') { + + + } + @case ('canceled') { + + + } + } + + + + + +
+
+ } +
+
+} + + + + + + + + + + + + + + + + diff --git a/libs/portal/downloads/feature/src/lib/download-queue.component.scss b/libs/portal/downloads/feature/src/lib/download-queue.component.scss new file mode 100644 index 000000000..593edb54a --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/download-queue.component.scss @@ -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; + } + } + } +} diff --git a/libs/portal/downloads/feature/src/lib/download-queue.component.spec.ts b/libs/portal/downloads/feature/src/lib/download-queue.component.spec.ts new file mode 100644 index 000000000..63ec1a6f7 --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/download-queue.component.spec.ts @@ -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 { + 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 { + return { + item: createItem(id, status), + episodeLabel: '', + seriesTitle: '', + sourceName: '', + ...overrides, + }; +} + +describe('DownloadQueueComponent', () => { + let fixture: ComponentFixture; + let overlayContainer: OverlayContainer; + + function render( + activeItems: readonly DownloadListItemViewModel[] = [], + attentionItems: readonly DownloadListItemViewModel[] = [], + pendingIds: ReadonlySet = 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( + `[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( + '.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( + '[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(); + 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( + '[data-test-control="artwork"]' + ); + const title = row(5).querySelector( + '[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('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( + '.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('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' + ); + }); +}); diff --git a/libs/portal/downloads/feature/src/lib/download-queue.component.ts b/libs/portal/downloads/feature/src/lib/download-queue.component.ts new file mode 100644 index 000000000..f2d8f52ea --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/download-queue.component.ts @@ -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> = { + 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 attentionItems = + input.required(); + readonly pendingIds = input>(new Set()); + readonly itemAction = output(); + readonly openRequested = output(); + + private readonly failedPosterIds = signal>(new Set()); + + readonly sections = computed(() => { + 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); + } +}