diff --git a/libs/portal/downloads/feature/src/lib/download-queue.component.html b/libs/portal/downloads/feature/src/lib/download-queue.component.html index dcb495a05..5a7918b57 100644 --- a/libs/portal/downloads/feature/src/lib/download-queue.component.html +++ b/libs/portal/downloads/feature/src/lib/download-queue.component.html @@ -17,6 +17,7 @@ [class.download-queue__item--pending]=" isPending(row.item.id) " + [class.download-queue__item--missing]="isMissingFile(row)" [attr.aria-busy]="isPending(row.item.id) ? 'true' : null" [attr.data-test-id]="'download-queue-item-' + row.item.id" role="listitem" @@ -90,17 +91,13 @@ aria-atomic="true" [class]=" 'download-queue__status download-queue__status--' + - row.item.status + statusClass(row) " > - {{ - 'DOWNLOADS.STATUS.' + - row.item.status.toUpperCase() - | translate - }} + {{ statusKey(row) | translate }} {{ formatBytes(row.item.bytesDownloaded) }} @@ -109,11 +106,6 @@ {{ formatBytes(row.item.totalBytes) }} } - @if (row.sourceName.trim()) { - - {{ row.sourceName.trim() }} - - } @if (row.item.errorMessage?.trim(); as error) { {{ error }} @@ -135,95 +127,121 @@
- @switch (row.item.status) { - @case ('queued') { - - - } - @case ('downloading') { - - - } - @case ('paused') { - - - - } - @case ('failed') { - - - } - @case ('canceled') { - - + @if (isMissingFile(row)) { + + } @else { + @switch (row.item.status) { + @case ('queued') { + + + } + @case ('downloading') { + + + } + @case ('paused') { + + + + } + @case ('failed') { + + + } + @case ('canceled') { + + + } } } @@ -245,6 +263,9 @@ more_vert + + @if (isMissingFile(row)) { + + }
diff --git a/libs/portal/downloads/feature/src/lib/download-queue.component.scss b/libs/portal/downloads/feature/src/lib/download-queue.component.scss index 28cec0612..4e46bc5b7 100644 --- a/libs/portal/downloads/feature/src/lib/download-queue.component.scss +++ b/libs/portal/downloads/feature/src/lib/download-queue.component.scss @@ -68,6 +68,18 @@ opacity: 0.68; } } + + &--missing { + background: color-mix( + in srgb, + var(--mat-sys-tertiary-container) 18%, + var(--mat-sys-surface-container-low) + ); + + .download-queue__artwork { + filter: saturate(0.42); + } + } } &__artwork { @@ -206,6 +218,11 @@ background: var(--mat-sys-error-container); color: var(--mat-sys-on-error-container); } + + &--missing { + background: var(--mat-sys-tertiary-container); + color: var(--mat-sys-on-tertiary-container); + } } &__bytes { @@ -215,12 +232,6 @@ font-variant-numeric: tabular-nums; } - &__source { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - &__error { min-width: 0; max-width: 100%; @@ -254,6 +265,14 @@ } } + &__recover { + white-space: nowrap; + + mat-icon { + margin-inline-end: 6px; + } + } + button:focus-visible { outline: 2px solid var(--mat-sys-primary); outline-offset: 2px; 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 index f4b1e64b2..a91f76ab6 100644 --- a/libs/portal/downloads/feature/src/lib/download-queue.component.spec.ts +++ b/libs/portal/downloads/feature/src/lib/download-queue.component.spec.ts @@ -23,7 +23,9 @@ const TEST_TRANSLATIONS = { PAUSED: 'Paused', FAILED: 'Failed', CANCELED: 'Canceled', + FILE_MISSING: 'File missing', }, + DOWNLOAD_AGAIN: 'Download again', ARIA: { OPEN: 'Open {{title}}', POSTER_UNAVAILABLE: 'Artwork unavailable for {{title}}', @@ -35,6 +37,12 @@ const TEST_TRANSLATIONS = { RETRY: 'Retry {{title}}', MORE_ACTIONS: 'More actions for {{title}}', COPY_URL: 'Copy URL for {{title}}', + DOWNLOAD_AGAIN: 'Download {{title}} again', + }, + }, + PORTALS: { + MULTI_SOURCE: { + SOURCE: 'Source', }, }, }; @@ -63,6 +71,7 @@ function createRow( overrides: Partial = {} ): DownloadListItemViewModel { return { + attentionReason: 'transfer', item: createItem(id, status), episodeLabel: '', seriesTitle: '', @@ -192,6 +201,97 @@ describe('DownloadQueueComponent', () => { } ); + it('renders a missing completed file as recoverable attention', async () => { + const viewModel = createRow(8, 'completed', { + attentionReason: 'file-missing', + item: createItem(8, 'completed', { + bytesDownloaded: 2 * 1024 ** 2, + fileAvailability: 'missing', + filePath: '/downloads/missing.mp4', + }), + sourceName: 'Alpha Source', + }); + const emitted: DownloadItemAction[] = []; + fixture.componentInstance.itemAction.subscribe((action) => + emitted.push(action) + ); + render([], [viewModel]); + + const host = row(8); + const status = host.querySelector( + '.download-queue__status' + ); + const renderedActions = Array.from( + host.querySelectorAll( + '[data-test-action]:not([data-test-action="more"])' + ) + ).map((button) => button.dataset['testAction']); + expect(status?.textContent).toContain('File missing'); + expect(status?.querySelector('mat-icon')?.textContent?.trim()).toBe( + 'file_off' + ); + expect(renderedActions).toEqual(['redownload']); + expect(host.querySelector('[data-test-action="play"]')).toBeNull(); + expect(host.querySelector('[data-test-action="reveal"]')).toBeNull(); + expect(host.querySelector('.download-queue__source')).toBeNull(); + + actionButton(host, 'redownload').click(); + expect(emitted).toEqual([ + { type: 'redownload', item: viewModel.item }, + ]); + + actionButton(host, 'more').click(); + fixture.detectChanges(); + await fixture.whenStable(); + + const menu = overlayContainer.getContainerElement(); + const header = menu.querySelector( + '.download-source-menu-header' + ); + const copy = actionButton(menu, 'copy-url'); + const remove = actionButton(menu, 'remove'); + expect(header?.textContent?.replace(/\s+/g, ' ').trim()).toBe( + 'Source Alpha Source' + ); + expect(header?.querySelector('strong')?.textContent).toContain( + 'Alpha Source' + ); + expect( + header && + header.compareDocumentPosition(copy) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + expect( + copy.compareDocumentPosition(remove) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + }); + + it('disables Download again while pending and omits an empty source header', async () => { + const viewModel = createRow(9, 'completed', { + attentionReason: 'file-missing', + item: createItem(9, 'completed', { + fileAvailability: 'missing', + filePath: '/downloads/missing.mp4', + }), + sourceName: ' ', + }); + render([], [viewModel], new Set([9])); + + expect(actionButton(row(9), 'redownload').disabled).toBe(true); + fixture.componentRef.setInput('pendingIds', new Set()); + fixture.detectChanges(); + actionButton(row(9), 'more').click(); + fixture.detectChanges(); + await fixture.whenStable(); + + expect( + overlayContainer + .getContainerElement() + .querySelector('.download-source-menu-header') + ).toBeNull(); + }); + it.each([ ['queued', ['pause', 'cancel']], ['downloading', ['pause', 'cancel']], @@ -413,7 +513,7 @@ describe('DownloadQueueComponent', () => { expect(formatDownloadBytes(bytes)).toBe(expected); }); - it('renders downloaded/total bytes, episode/source/error metadata, and omits empty sources', () => { + it('renders downloaded/total bytes and episode/error metadata without a permanent source', () => { const longError = 'https://downloads.example.test/a/very/long/unbroken/path/that/must/remain/readable/error-code-connection-reset'; const detailed = createRow(21, 'failed', { @@ -447,11 +547,7 @@ describe('DownloadQueueComponent', () => { .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__source')).toBeNull(); const error = row(21).querySelector( '.download-queue__error' ); diff --git a/libs/portal/downloads/feature/src/lib/download-queue.component.ts b/libs/portal/downloads/feature/src/lib/download-queue.component.ts index f2d8f52ea..a029d7f9d 100644 --- a/libs/portal/downloads/feature/src/lib/download-queue.component.ts +++ b/libs/portal/downloads/feature/src/lib/download-queue.component.ts @@ -19,6 +19,7 @@ import type { DownloadItemActionType, } from './download-actions'; import type { DownloadListItemViewModel } from './download-manager.viewmodel'; +import { DownloadSourceMenuHeaderComponent } from './download-source-menu-header.component'; type DownloadProgressMode = 'determinate' | 'indeterminate'; @@ -71,6 +72,7 @@ export function formatDownloadBytes(bytes: number | undefined): string { MatTooltip, NgTemplateOutlet, TranslatePipe, + DownloadSourceMenuHeaderComponent, ], templateUrl: './download-queue.component.html', styleUrl: './download-queue.component.scss', @@ -121,8 +123,24 @@ export class DownloadQueueComponent { return this.pendingIds().has(id); } - statusIcon(item: DownloadItem): string { - return STATUS_ICONS[item.status]; + isMissingFile(row: DownloadListItemViewModel): boolean { + return row.attentionReason === 'file-missing'; + } + + statusIcon(row: DownloadListItemViewModel): string { + return this.isMissingFile(row) + ? 'file_off' + : STATUS_ICONS[row.item.status]; + } + + statusKey(row: DownloadListItemViewModel): string { + return this.isMissingFile(row) + ? 'DOWNLOADS.STATUS.FILE_MISSING' + : `DOWNLOADS.STATUS.${row.item.status.toUpperCase()}`; + } + + statusClass(row: DownloadListItemViewModel): string { + return this.isMissingFile(row) ? 'missing' : row.item.status; } hasPoster(item: DownloadItem): boolean { diff --git a/libs/portal/downloads/feature/src/lib/download-source-menu-header.component.ts b/libs/portal/downloads/feature/src/lib/download-source-menu-header.component.ts new file mode 100644 index 000000000..f06bd6124 --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/download-source-menu-header.component.ts @@ -0,0 +1,52 @@ +import { + ChangeDetectionStrategy, + Component, + input, +} from '@angular/core'; +import { MatTooltip } from '@angular/material/tooltip'; +import { TranslatePipe } from '@ngx-translate/core'; + +@Component({ + selector: 'app-download-source-menu-header', + imports: [MatTooltip, TranslatePipe], + template: ` + @if (sourceName().trim(); as source) { +
+ + {{ 'PORTALS.MULTI_SOURCE.SOURCE' | translate }} + + {{ source }} +
+ } + `, + styles: ` + :host { + display: block; + } + + .download-source-menu-header { + display: grid; + gap: 2px; + max-width: 280px; + padding: 10px 16px 8px; + color: var(--mat-sys-on-surface-variant); + + span { + font: var(--mat-sys-label-small); + letter-spacing: 0.03em; + } + + strong { + overflow: hidden; + color: var(--mat-sys-on-surface); + font: var(--mat-sys-label-large); + text-overflow: ellipsis; + white-space: nowrap; + } + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DownloadSourceMenuHeaderComponent { + readonly sourceName = input(''); +}