mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
feat(downloads): add active download queue
This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user