mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 22:26:16 -08:00
feat(downloads): add ready-to-watch library
This commit is contained in:
1 parent
28770aad09
commit
8e1af010a9
8 files changed
+1485
No files matched your search
@@ -0,0 +1,294 @@
|
||||
<div class="download-library__grid">
|
||||
@for (entity of entities(); track entity.key) {
|
||||
@if (entity.kind === 'series') {
|
||||
<article
|
||||
class="download-library__card"
|
||||
[attr.data-test-id]="libraryTestId(entity)"
|
||||
>
|
||||
<div class="download-library__artwork">
|
||||
<button
|
||||
type="button"
|
||||
class="download-library__artwork-button"
|
||||
data-test-id="download-library-series-open"
|
||||
[attr.aria-label]="
|
||||
'DOWNLOADS.OPEN_ARTWORK'
|
||||
| translate: { title: entity.title }
|
||||
"
|
||||
(click)="openSeries(entity)"
|
||||
>
|
||||
@if (artworkUrl(entity); as artwork) {
|
||||
<img
|
||||
[src]="artwork"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
(error)="markArtworkFailed(entity, $event)"
|
||||
/>
|
||||
} @else {
|
||||
<span
|
||||
class="download-library__placeholder"
|
||||
role="img"
|
||||
[attr.aria-label]="
|
||||
('DOWNLOADS.ARTWORK_UNAVAILABLE'
|
||||
| translate) +
|
||||
': ' +
|
||||
entity.title
|
||||
"
|
||||
>
|
||||
<mat-icon aria-hidden="true">tv</mat-icon>
|
||||
</span>
|
||||
}
|
||||
</button>
|
||||
<span class="download-library__offline">
|
||||
<mat-icon aria-hidden="true">download_done</mat-icon>
|
||||
{{ 'DOWNLOADS.OFFLINE' | translate }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="download-library__body">
|
||||
<span class="download-library__type">
|
||||
{{ 'DOWNLOADS.SERIES' | translate }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="download-library__title-button"
|
||||
data-test-id="download-library-series-open"
|
||||
[attr.aria-label]="
|
||||
('DOWNLOADS.OPEN_DETAILS' | translate) +
|
||||
': ' +
|
||||
entity.title
|
||||
"
|
||||
(click)="openSeries(entity)"
|
||||
>
|
||||
{{ entity.title }}
|
||||
</button>
|
||||
<div class="download-library__facts">
|
||||
<button
|
||||
type="button"
|
||||
class="download-library__episodes-button"
|
||||
[attr.aria-label]="
|
||||
('DOWNLOADS.OPEN_EPISODES' | translate) +
|
||||
': ' +
|
||||
entity.title
|
||||
"
|
||||
(click)="openEpisodes(entity)"
|
||||
>
|
||||
{{
|
||||
'DOWNLOADS.EPISODE_COUNT'
|
||||
| translate
|
||||
: { count: entity.members.length }
|
||||
}}
|
||||
</button>
|
||||
@if (
|
||||
entity.firstSeason !== undefined &&
|
||||
entity.lastSeason !== undefined
|
||||
) {
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>
|
||||
@if (entity.firstSeason === entity.lastSeason) {
|
||||
{{
|
||||
'DOWNLOADS.SEASON'
|
||||
| translate
|
||||
: {
|
||||
season: entity.firstSeason,
|
||||
}
|
||||
}}
|
||||
} @else {
|
||||
{{
|
||||
'DOWNLOADS.SEASON_RANGE'
|
||||
| translate
|
||||
: {
|
||||
first: entity.firstSeason,
|
||||
last: entity.lastSeason,
|
||||
}
|
||||
}}
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="download-library__metadata">
|
||||
<span>{{ entity.sourceName }}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{{ formatBytes(entity.trackedBytes) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
} @else {
|
||||
<article
|
||||
class="download-library__card"
|
||||
[attr.data-test-id]="libraryTestId(entity)"
|
||||
>
|
||||
<div class="download-library__artwork">
|
||||
<button
|
||||
type="button"
|
||||
class="download-library__artwork-button"
|
||||
[disabled]="isPending(entity.item)"
|
||||
[attr.aria-label]="
|
||||
('DOWNLOADS.PLAY' | translate) +
|
||||
': ' +
|
||||
entity.item.title
|
||||
"
|
||||
(click)="emitAction('play', entity.item)"
|
||||
>
|
||||
@if (artworkUrl(entity); as artwork) {
|
||||
<img
|
||||
[src]="artwork"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
(error)="markArtworkFailed(entity, $event)"
|
||||
/>
|
||||
} @else {
|
||||
<span
|
||||
class="download-library__placeholder"
|
||||
role="img"
|
||||
[attr.aria-label]="
|
||||
('DOWNLOADS.ARTWORK_UNAVAILABLE'
|
||||
| translate) +
|
||||
': ' +
|
||||
entity.item.title
|
||||
"
|
||||
>
|
||||
<mat-icon aria-hidden="true">
|
||||
{{
|
||||
entity.kind === 'movie'
|
||||
? 'movie'
|
||||
: 'live_tv'
|
||||
}}
|
||||
</mat-icon>
|
||||
</span>
|
||||
}
|
||||
</button>
|
||||
<span class="download-library__offline">
|
||||
<mat-icon aria-hidden="true">download_done</mat-icon>
|
||||
{{ 'DOWNLOADS.OFFLINE' | translate }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="download-library__body">
|
||||
<span class="download-library__type">
|
||||
{{
|
||||
(entity.kind === 'movie'
|
||||
? 'DOWNLOADS.MOVIE'
|
||||
: 'DOWNLOADS.EPISODE'
|
||||
) | translate
|
||||
}}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="download-library__title-button"
|
||||
[disabled]="isPending(entity.item)"
|
||||
[attr.aria-label]="
|
||||
('DOWNLOADS.PLAY' | translate) +
|
||||
': ' +
|
||||
entity.item.title
|
||||
"
|
||||
(click)="emitAction('play', entity.item)"
|
||||
>
|
||||
{{ entity.item.title }}
|
||||
</button>
|
||||
@if (entity.kind === 'episode' && entity.episodeLabel) {
|
||||
<span class="download-library__episode-label">
|
||||
{{ entity.episodeLabel }}
|
||||
</span>
|
||||
}
|
||||
<div class="download-library__metadata">
|
||||
<span>{{ entity.sourceName }}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{{ formatBytes(entity.trackedBytes) }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="download-library__actions"
|
||||
[attr.aria-label]="entity.item.title"
|
||||
>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
[disabled]="isPending(entity.item)"
|
||||
[attr.aria-label]="
|
||||
('DOWNLOADS.PLAY' | translate) +
|
||||
': ' +
|
||||
entity.item.title
|
||||
"
|
||||
[matTooltip]="'DOWNLOADS.PLAY' | translate"
|
||||
(click)="emitAction('play', entity.item)"
|
||||
>
|
||||
<mat-icon aria-hidden="true">play_arrow</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
[disabled]="isPending(entity.item)"
|
||||
[attr.aria-label]="
|
||||
('DOWNLOADS.REVEAL' | translate) +
|
||||
': ' +
|
||||
entity.item.title
|
||||
"
|
||||
[matTooltip]="'DOWNLOADS.REVEAL' | translate"
|
||||
(click)="emitAction('reveal', entity.item)"
|
||||
>
|
||||
<mat-icon aria-hidden="true">folder_open</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
[matMenuTriggerFor]="itemMenu"
|
||||
[disabled]="isPending(entity.item)"
|
||||
[attr.aria-label]="
|
||||
('DOWNLOADS.MORE_ACTIONS' | translate) +
|
||||
': ' +
|
||||
entity.item.title
|
||||
"
|
||||
[matTooltip]="'DOWNLOADS.MORE_ACTIONS' | translate"
|
||||
>
|
||||
<mat-icon aria-hidden="true">more_vert</mat-icon>
|
||||
</button>
|
||||
<mat-menu #itemMenu="matMenu">
|
||||
<button
|
||||
mat-menu-item
|
||||
type="button"
|
||||
[disabled]="isPending(entity.item)"
|
||||
[attr.aria-label]="
|
||||
('DOWNLOADS.COPY_URL' | translate) +
|
||||
': ' +
|
||||
entity.item.title
|
||||
"
|
||||
(click)="emitAction('copy-url', entity.item)"
|
||||
>
|
||||
<mat-icon aria-hidden="true">
|
||||
content_copy
|
||||
</mat-icon>
|
||||
<span>
|
||||
{{ 'DOWNLOADS.COPY_URL' | translate }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
type="button"
|
||||
[disabled]="isPending(entity.item)"
|
||||
[attr.aria-label]="
|
||||
('DOWNLOADS.REMOVE_FROM_MANAGER'
|
||||
| translate) +
|
||||
': ' +
|
||||
entity.item.title
|
||||
"
|
||||
(click)="emitAction('remove', entity.item)"
|
||||
>
|
||||
<mat-icon aria-hidden="true">
|
||||
remove_circle_outline
|
||||
</mat-icon>
|
||||
<span>
|
||||
{{
|
||||
'DOWNLOADS.REMOVE_FROM_MANAGER'
|
||||
| translate
|
||||
}}
|
||||
</span>
|
||||
</button>
|
||||
</mat-menu>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,248 @@
|
||||
@use '../../../../shared/ui/src/lib/styles/content-grid' as grid;
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.download-library__grid {
|
||||
@include grid.content-grid(
|
||||
$min-width: min(100%, 168px),
|
||||
$gap: clamp(12px, 1.5vw, 20px)
|
||||
);
|
||||
}
|
||||
|
||||
.download-library__card {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--app-separator);
|
||||
border-radius: 16px;
|
||||
background: var(--app-widget-bg);
|
||||
color: var(--app-body-color);
|
||||
transition:
|
||||
border-color 160ms ease,
|
||||
transform 160ms ease,
|
||||
box-shadow 160ms ease;
|
||||
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-primary) 38%,
|
||||
var(--app-separator)
|
||||
);
|
||||
box-shadow: 0 12px 28px
|
||||
color-mix(in srgb, var(--mat-sys-shadow) 14%, transparent);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
.download-library__artwork {
|
||||
position: relative;
|
||||
aspect-ratio: 2 / 3;
|
||||
overflow: hidden;
|
||||
background: var(--app-widget-header-bg);
|
||||
}
|
||||
|
||||
.download-library__artwork-button,
|
||||
.download-library__title-button,
|
||||
.download-library__episodes-button {
|
||||
margin: 0;
|
||||
border: 0;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.download-library__artwork-button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
|
||||
img,
|
||||
.download-library__placeholder {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
img {
|
||||
object-fit: cover;
|
||||
transition: transform 180ms ease;
|
||||
}
|
||||
|
||||
&:hover img,
|
||||
&:focus-visible img {
|
||||
transform: scale(1.025);
|
||||
}
|
||||
}
|
||||
|
||||
.download-library__placeholder {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-primary) 13%,
|
||||
var(--app-widget-header-bg)
|
||||
),
|
||||
var(--app-widget-header-bg)
|
||||
);
|
||||
color: var(--app-muted-color);
|
||||
|
||||
mat-icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
font-size: 42px;
|
||||
}
|
||||
}
|
||||
|
||||
.download-library__offline {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px 8px;
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--mat-sys-on-primary) 18%, transparent);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--mat-sys-primary) 88%, transparent);
|
||||
color: var(--mat-sys-on-primary);
|
||||
font-size: 0.66rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.045em;
|
||||
line-height: 1;
|
||||
text-transform: uppercase;
|
||||
pointer-events: none;
|
||||
|
||||
mat-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.download-library__body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 7px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.download-library__type {
|
||||
color: var(--app-eyebrow-color);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
line-height: 1.2;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.download-library__title-button {
|
||||
display: -webkit-box;
|
||||
min-height: 2.6em;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
color: var(--app-heading-color);
|
||||
font-size: 0.93rem;
|
||||
font-weight: 650;
|
||||
line-height: 1.3;
|
||||
text-align: left;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.download-library__facts,
|
||||
.download-library__metadata {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: var(--app-muted-color);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.download-library__metadata {
|
||||
font-family: var(--mat-sys-body-small-font);
|
||||
|
||||
span:first-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.download-library__episodes-button {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: var(--mat-sys-primary);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.download-library__episode-label {
|
||||
width: fit-content;
|
||||
padding: 3px 6px;
|
||||
border-radius: 5px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-primary) 11%,
|
||||
var(--app-widget-header-bg)
|
||||
);
|
||||
color: var(--mat-sys-primary);
|
||||
font-family: var(--mat-sys-label-small-font);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.download-library__actions {
|
||||
display: flex;
|
||||
min-height: 40px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
margin: 2px -6px -6px;
|
||||
border-top: 1px solid var(--app-separator);
|
||||
padding-top: 6px;
|
||||
|
||||
button {
|
||||
color: var(--app-body-color);
|
||||
}
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
border-radius: 6px;
|
||||
outline: 2px solid var(--mat-sys-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.download-library__grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.download-library__body {
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.download-library__card,
|
||||
.download-library__artwork-button img {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.download-library__card:hover,
|
||||
.download-library__card:focus-within {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import type { DownloadItem } from '@iptvnator/services';
|
||||
import type {
|
||||
DownloadLibraryEntity,
|
||||
DownloadSeriesCardViewModel,
|
||||
} from './download-library.viewmodel';
|
||||
import { DownloadLibraryComponent } from './download-library.component';
|
||||
|
||||
const TRANSLATIONS = {
|
||||
DOWNLOADS: {
|
||||
ARTWORK_UNAVAILABLE: 'Artwork unavailable',
|
||||
COPY_URL: 'Copy download URL',
|
||||
EPISODE: 'Episode',
|
||||
EPISODE_COUNT: '{{count}} episodes',
|
||||
MORE_ACTIONS: 'More actions',
|
||||
MOVIE: 'Movie',
|
||||
OFFLINE: 'Offline',
|
||||
OPEN_ARTWORK: 'Open details: {{title}} artwork',
|
||||
OPEN_DETAILS: 'Open details',
|
||||
OPEN_EPISODES: 'Open downloaded episodes',
|
||||
PLAY: 'Play',
|
||||
REMOVE_FROM_MANAGER: 'Remove from manager',
|
||||
REVEAL: 'Show in folder',
|
||||
SEASON: 'Season {{season}}',
|
||||
SEASON_RANGE: 'Seasons {{first}}–{{last}}',
|
||||
SERIES: 'Series',
|
||||
},
|
||||
};
|
||||
|
||||
function item(overrides: Partial<DownloadItem>): DownloadItem {
|
||||
return {
|
||||
id: 1,
|
||||
playlistId: 'playlist-a',
|
||||
xtreamId: 101,
|
||||
contentType: 'vod',
|
||||
title: 'Download',
|
||||
url: 'https://media.example.test/download',
|
||||
status: 'completed',
|
||||
bytesDownloaded: 2_500_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const MOVIE = item({
|
||||
id: 9,
|
||||
title: 'Moonrise',
|
||||
posterUrl: 'https://media.example.test/moonrise.jpg',
|
||||
});
|
||||
const SERIES_MEMBERS = [
|
||||
item({
|
||||
id: 11,
|
||||
xtreamId: 201,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 77,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 2,
|
||||
title: 'Northwind - S01E02 - Harbour',
|
||||
}),
|
||||
item({
|
||||
id: 12,
|
||||
xtreamId: 202,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 77,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 5,
|
||||
title: 'Northwind - S01E05 - Crossing',
|
||||
}),
|
||||
item({
|
||||
id: 13,
|
||||
xtreamId: 203,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 77,
|
||||
seasonNumber: 2,
|
||||
episodeNumber: 1,
|
||||
title: 'Northwind - S02E01 - Return',
|
||||
}),
|
||||
] as const;
|
||||
const SERIES: DownloadSeriesCardViewModel = {
|
||||
kind: 'series',
|
||||
key: 'series:playlist-a:77',
|
||||
representative: SERIES_MEMBERS[2],
|
||||
members: SERIES_MEMBERS,
|
||||
seriesXtreamId: 77,
|
||||
title: 'Northwind',
|
||||
posterUrl: 'https://media.example.test/northwind.jpg',
|
||||
newestTimestamp: 3,
|
||||
sourceName: 'Living room',
|
||||
trackedBytes: 7_500_000,
|
||||
firstSeason: 1,
|
||||
lastSeason: 2,
|
||||
};
|
||||
const FALLBACK_EPISODE = item({
|
||||
id: 21,
|
||||
xtreamId: 301,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: undefined,
|
||||
seasonNumber: 2,
|
||||
episodeNumber: 4,
|
||||
title: 'Legacy episode',
|
||||
posterUrl: 'https://media.example.test/broken.jpg',
|
||||
});
|
||||
const ENTITIES: readonly DownloadLibraryEntity[] = [
|
||||
{
|
||||
kind: 'movie',
|
||||
key: 'movie:9',
|
||||
item: MOVIE,
|
||||
newestTimestamp: 4,
|
||||
sourceName: 'Cinema',
|
||||
trackedBytes: 2_500_000,
|
||||
},
|
||||
SERIES,
|
||||
{
|
||||
kind: 'episode',
|
||||
key: 'episode:21',
|
||||
item: FALLBACK_EPISODE,
|
||||
newestTimestamp: 2,
|
||||
sourceName: 'Archive',
|
||||
trackedBytes: 2_500_000,
|
||||
episodeLabel: 'S02E04',
|
||||
},
|
||||
];
|
||||
|
||||
describe('DownloadLibraryComponent', () => {
|
||||
let fixture: ComponentFixture<DownloadLibraryComponent>;
|
||||
let component: DownloadLibraryComponent;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
DownloadLibraryComponent,
|
||||
NoopAnimationsModule,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
TestBed.inject(TranslateService).setTranslation('en', TRANSLATIONS);
|
||||
TestBed.inject(TranslateService).use('en');
|
||||
fixture = TestBed.createComponent(DownloadLibraryComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.componentRef.setInput('entities', ENTITIES);
|
||||
await fixture.whenStable();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document
|
||||
.querySelectorAll('.cdk-overlay-container')
|
||||
.forEach((element) => element.remove());
|
||||
});
|
||||
|
||||
function byTestId(testId: string): HTMLElement {
|
||||
const element = fixture.nativeElement.querySelector(
|
||||
`[data-test-id="${testId}"]`
|
||||
) as HTMLElement | null;
|
||||
if (!element) {
|
||||
throw new Error(`Missing element: ${testId}`);
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
function button(container: ParentNode, label: string): HTMLButtonElement {
|
||||
const element = Array.from(container.querySelectorAll('button')).find(
|
||||
(candidate) => candidate.getAttribute('aria-label') === label
|
||||
);
|
||||
if (!element) {
|
||||
throw new Error(`Missing button: ${label}`);
|
||||
}
|
||||
return element as HTMLButtonElement;
|
||||
}
|
||||
|
||||
async function click(element: HTMLElement): Promise<void> {
|
||||
element.click();
|
||||
await fixture.whenStable();
|
||||
}
|
||||
|
||||
async function clickMenuAction(
|
||||
card: HTMLElement,
|
||||
triggerLabel: string,
|
||||
actionLabel: string
|
||||
): Promise<void> {
|
||||
await click(button(card, triggerLabel));
|
||||
await click(button(document, actionLabel));
|
||||
}
|
||||
|
||||
it('renders movie, grouped-series, and fallback-episode identities', () => {
|
||||
const movieCard = byTestId('download-library-movie-9');
|
||||
const seriesCard = byTestId('download-library-series-playlist-a-77');
|
||||
const fallbackEpisodeCard = byTestId('download-library-episode-21');
|
||||
|
||||
expect(movieCard.getAttribute('data-test-id')).toBe(
|
||||
'download-library-movie-9'
|
||||
);
|
||||
expect(movieCard.textContent).toContain('Moonrise');
|
||||
expect(seriesCard.textContent).toContain('3 episodes');
|
||||
expect(seriesCard.textContent).toContain('Seasons 1–2');
|
||||
expect(fallbackEpisodeCard.textContent).toContain('S02E04');
|
||||
expect(fallbackEpisodeCard.textContent).toContain('Episode');
|
||||
});
|
||||
|
||||
it('emits every concrete movie action with the movie item', async () => {
|
||||
const card = byTestId('download-library-movie-9');
|
||||
const actions: unknown[] = [];
|
||||
component.itemAction.subscribe((action) => actions.push(action));
|
||||
|
||||
await click(button(card, 'Play: Moonrise'));
|
||||
await click(button(card, 'Show in folder: Moonrise'));
|
||||
await clickMenuAction(
|
||||
card,
|
||||
'More actions: Moonrise',
|
||||
'Copy download URL: Moonrise'
|
||||
);
|
||||
await clickMenuAction(
|
||||
card,
|
||||
'More actions: Moonrise',
|
||||
'Remove from manager: Moonrise'
|
||||
);
|
||||
|
||||
expect(actions).toEqual([
|
||||
{ type: 'play', item: MOVIE },
|
||||
{ type: 'reveal', item: MOVIE },
|
||||
{ type: 'copy-url', item: MOVIE },
|
||||
{ type: 'remove', item: MOVIE },
|
||||
]);
|
||||
});
|
||||
|
||||
it('opens grouped-series details from both real navigation buttons', async () => {
|
||||
const card = byTestId('download-library-series-playlist-a-77');
|
||||
const opened: DownloadItem[] = [];
|
||||
component.seriesOpened.subscribe((selected) => opened.push(selected));
|
||||
|
||||
const artwork = button(card, 'Open details: Northwind artwork');
|
||||
const title = button(card, 'Open details: Northwind');
|
||||
expect(artwork.tagName).toBe('BUTTON');
|
||||
expect(title.tagName).toBe('BUTTON');
|
||||
|
||||
artwork.focus();
|
||||
expect(document.activeElement).toBe(artwork);
|
||||
await click(artwork);
|
||||
await click(title);
|
||||
|
||||
expect(opened).toEqual([SERIES.representative, SERIES.representative]);
|
||||
});
|
||||
|
||||
it('opens the downloaded episode group from its count control', async () => {
|
||||
const opened: DownloadSeriesCardViewModel[] = [];
|
||||
component.episodesOpened.subscribe((group) => opened.push(group));
|
||||
|
||||
await click(
|
||||
button(
|
||||
byTestId('download-library-series-playlist-a-77'),
|
||||
'Open downloaded episodes: Northwind'
|
||||
)
|
||||
);
|
||||
|
||||
expect(opened).toEqual([SERIES]);
|
||||
});
|
||||
|
||||
it('keeps an invalid-series episode local without series navigation', async () => {
|
||||
const card = byTestId('download-library-episode-21');
|
||||
const itemActions: unknown[] = [];
|
||||
const seriesOpened: DownloadItem[] = [];
|
||||
component.itemAction.subscribe((action) => itemActions.push(action));
|
||||
component.seriesOpened.subscribe((selected) =>
|
||||
seriesOpened.push(selected)
|
||||
);
|
||||
|
||||
expect(
|
||||
card.querySelector('[data-test-id="download-library-series-open"]')
|
||||
).toBeNull();
|
||||
await click(button(card, 'Play: Legacy episode'));
|
||||
await click(button(card, 'Show in folder: Legacy episode'));
|
||||
|
||||
expect(itemActions).toEqual([
|
||||
{ type: 'play', item: FALLBACK_EPISODE },
|
||||
{ type: 'reveal', item: FALLBACK_EPISODE },
|
||||
]);
|
||||
expect(seriesOpened).toEqual([]);
|
||||
});
|
||||
|
||||
it('disables every local command while the concrete item is pending', async () => {
|
||||
fixture.componentRef.setInput('pendingIds', new Set([9, 21]));
|
||||
await fixture.whenStable();
|
||||
|
||||
for (const testId of [
|
||||
'download-library-movie-9',
|
||||
'download-library-episode-21',
|
||||
]) {
|
||||
const card = byTestId(testId);
|
||||
expect(
|
||||
Array.from(card.querySelectorAll('button')).every(
|
||||
(candidate) => candidate.disabled
|
||||
)
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('replaces broken artwork with one semantic placeholder', async () => {
|
||||
const card = byTestId('download-library-episode-21');
|
||||
const image = card.querySelector('img') as HTMLImageElement;
|
||||
|
||||
image.dispatchEvent(new Event('error'));
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(card.querySelector('img')).toBeNull();
|
||||
const placeholders = card.querySelectorAll(
|
||||
'[role="img"][aria-label="Artwork unavailable: Legacy episode"]'
|
||||
);
|
||||
expect(placeholders).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import type { DownloadItem } from '@iptvnator/services';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import type {
|
||||
DownloadItemAction,
|
||||
DownloadItemActionType,
|
||||
} from './download-actions';
|
||||
import type {
|
||||
DownloadLibraryEntity,
|
||||
DownloadSeriesCardViewModel,
|
||||
} from './download-library.viewmodel';
|
||||
|
||||
@Component({
|
||||
selector: 'app-download-library',
|
||||
standalone: true,
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatMenuModule,
|
||||
MatTooltipModule,
|
||||
TranslatePipe,
|
||||
],
|
||||
templateUrl: './download-library.component.html',
|
||||
styleUrl: './download-library.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DownloadLibraryComponent {
|
||||
readonly entities = input.required<readonly DownloadLibraryEntity[]>();
|
||||
readonly pendingIds = input<ReadonlySet<number>>(new Set());
|
||||
readonly itemAction = output<DownloadItemAction>();
|
||||
readonly seriesOpened = output<DownloadItem>();
|
||||
readonly episodesOpened = output<DownloadSeriesCardViewModel>();
|
||||
readonly failedArtwork = signal<ReadonlySet<string>>(new Set());
|
||||
|
||||
protected libraryTestId(entity: DownloadLibraryEntity): string {
|
||||
switch (entity.kind) {
|
||||
case 'movie':
|
||||
return `download-library-movie-${entity.item.id}`;
|
||||
case 'episode':
|
||||
return `download-library-episode-${entity.item.id}`;
|
||||
case 'series':
|
||||
return `download-library-series-${entity.representative.playlistId}-${entity.seriesXtreamId}`;
|
||||
}
|
||||
}
|
||||
|
||||
protected artworkUrl(entity: DownloadLibraryEntity): string | undefined {
|
||||
const raw =
|
||||
entity.kind === 'series' ? entity.posterUrl : entity.item.posterUrl;
|
||||
return raw?.trim() && !this.failedArtwork().has(entity.key)
|
||||
? raw
|
||||
: undefined;
|
||||
}
|
||||
|
||||
protected markArtworkFailed(
|
||||
entity: DownloadLibraryEntity,
|
||||
event: Event
|
||||
): void {
|
||||
if (this.failedArtwork().has(entity.key)) {
|
||||
return;
|
||||
}
|
||||
this.failedArtwork.update((failed) => {
|
||||
const next = new Set(failed);
|
||||
next.add(entity.key);
|
||||
return next;
|
||||
});
|
||||
(event.target as HTMLImageElement | null)?.removeAttribute('src');
|
||||
}
|
||||
|
||||
protected isPending(item: DownloadItem): boolean {
|
||||
return this.pendingIds().has(item.id);
|
||||
}
|
||||
|
||||
protected emitAction(
|
||||
type: DownloadItemActionType,
|
||||
item: DownloadItem
|
||||
): void {
|
||||
if (!this.isPending(item)) {
|
||||
this.itemAction.emit({ type, item });
|
||||
}
|
||||
}
|
||||
|
||||
protected openSeries(group: DownloadSeriesCardViewModel): void {
|
||||
this.seriesOpened.emit(group.representative);
|
||||
}
|
||||
|
||||
protected openEpisodes(group: DownloadSeriesCardViewModel): void {
|
||||
this.episodesOpened.emit(group);
|
||||
}
|
||||
|
||||
protected formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) {
|
||||
return '0 B';
|
||||
}
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const exponent = Math.min(
|
||||
Math.floor(Math.log(bytes) / Math.log(1024)),
|
||||
units.length - 1
|
||||
);
|
||||
const value = bytes / 1024 ** exponent;
|
||||
const formatted =
|
||||
value >= 10 || Number.isInteger(value)
|
||||
? value.toFixed(0)
|
||||
: value.toFixed(1);
|
||||
return `${formatted} ${units[exponent]}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<header class="downloaded-series-dialog__header">
|
||||
<div>
|
||||
<span class="downloaded-series-dialog__eyebrow">
|
||||
{{ 'DOWNLOADS.DOWNLOADED_EPISODES' | translate }}
|
||||
</span>
|
||||
<h2 mat-dialog-title>{{ group.title }}</h2>
|
||||
<p>
|
||||
{{
|
||||
'DOWNLOADS.EPISODE_COUNT'
|
||||
| translate: { count: group.members.length }
|
||||
}}
|
||||
<span aria-hidden="true">·</span>
|
||||
{{ group.sourceName }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
mat-dialog-close
|
||||
[attr.aria-label]="'CLOSE' | translate"
|
||||
[matTooltip]="'CLOSE' | translate"
|
||||
>
|
||||
<mat-icon aria-hidden="true">close</mat-icon>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<mat-dialog-content>
|
||||
<ol class="downloaded-series-dialog__list">
|
||||
@for (member of group.members; track member.id) {
|
||||
<li
|
||||
class="downloaded-series-dialog__episode"
|
||||
[attr.data-test-id]="'downloaded-series-episode-' + member.id"
|
||||
>
|
||||
<div class="downloaded-series-dialog__identity">
|
||||
@if (episodeLabel(member); as label) {
|
||||
<span class="downloaded-series-dialog__number">
|
||||
{{ label }}
|
||||
</span>
|
||||
}
|
||||
<span class="downloaded-series-dialog__title">
|
||||
{{ member.title }}
|
||||
</span>
|
||||
<span class="downloaded-series-dialog__size">
|
||||
{{ formatBytes(member.bytesDownloaded) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="downloaded-series-dialog__actions">
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
[attr.aria-label]="
|
||||
('DOWNLOADS.PLAY' | translate) +
|
||||
': ' +
|
||||
episodeIdentity(member)
|
||||
"
|
||||
[matTooltip]="'DOWNLOADS.PLAY' | translate"
|
||||
(click)="emitAction('play', member)"
|
||||
>
|
||||
<mat-icon aria-hidden="true">play_arrow</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
[attr.aria-label]="
|
||||
('DOWNLOADS.REVEAL' | translate) +
|
||||
': ' +
|
||||
episodeIdentity(member)
|
||||
"
|
||||
[matTooltip]="'DOWNLOADS.REVEAL' | translate"
|
||||
(click)="emitAction('reveal', member)"
|
||||
>
|
||||
<mat-icon aria-hidden="true">folder_open</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
[attr.aria-label]="
|
||||
('DOWNLOADS.COPY_URL' | translate) +
|
||||
': ' +
|
||||
episodeIdentity(member)
|
||||
"
|
||||
[matTooltip]="'DOWNLOADS.COPY_URL' | translate"
|
||||
(click)="emitAction('copy-url', member)"
|
||||
>
|
||||
<mat-icon aria-hidden="true">content_copy</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
[attr.aria-label]="
|
||||
('DOWNLOADS.REMOVE_FROM_MANAGER' | translate) +
|
||||
': ' +
|
||||
episodeIdentity(member)
|
||||
"
|
||||
[matTooltip]="
|
||||
'DOWNLOADS.REMOVE_FROM_MANAGER' | translate
|
||||
"
|
||||
(click)="emitAction('remove', member)"
|
||||
>
|
||||
<mat-icon aria-hidden="true">
|
||||
remove_circle_outline
|
||||
</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
</mat-dialog-content>
|
||||
@@ -0,0 +1,155 @@
|
||||
:host {
|
||||
display: block;
|
||||
min-width: min(680px, calc(100vw - 32px));
|
||||
max-width: 760px;
|
||||
color: var(--app-body-color);
|
||||
}
|
||||
|
||||
.downloaded-series-dialog__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 22px 24px 14px;
|
||||
border-bottom: 1px solid var(--app-separator);
|
||||
background: var(--app-widget-header-bg);
|
||||
|
||||
h2[mat-dialog-title] {
|
||||
margin: 3px 0 0;
|
||||
padding: 0;
|
||||
color: var(--app-heading-color);
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 6px 0 0;
|
||||
color: var(--app-muted-color);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
}
|
||||
|
||||
.downloaded-series-dialog__eyebrow {
|
||||
color: var(--app-eyebrow-color);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
mat-dialog-content {
|
||||
max-height: min(66vh, 620px);
|
||||
padding: 10px 12px 18px;
|
||||
background: var(--app-widget-bg);
|
||||
}
|
||||
|
||||
.downloaded-series-dialog__list {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.downloaded-series-dialog__episode {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 64px;
|
||||
padding: 8px 10px 8px 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 10px;
|
||||
transition:
|
||||
border-color 140ms ease,
|
||||
background-color 140ms ease;
|
||||
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
border-color: var(--app-separator);
|
||||
background: var(--app-card-hover-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.downloaded-series-dialog__identity {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 4px 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.downloaded-series-dialog__number {
|
||||
grid-row: 1 / span 2;
|
||||
min-width: 54px;
|
||||
padding: 5px 7px;
|
||||
border-radius: 6px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-primary) 12%,
|
||||
var(--app-widget-header-bg)
|
||||
);
|
||||
color: var(--mat-sys-primary);
|
||||
font-family: var(--mat-sys-label-small-font);
|
||||
font-size: 0.73rem;
|
||||
font-weight: 750;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.downloaded-series-dialog__title {
|
||||
overflow: hidden;
|
||||
color: var(--app-heading-color);
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.downloaded-series-dialog__size {
|
||||
color: var(--app-muted-color);
|
||||
font-family: var(--mat-sys-label-small-font);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.downloaded-series-dialog__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
button {
|
||||
color: var(--app-body-color);
|
||||
}
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--mat-sys-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
:host {
|
||||
min-width: min(100%, calc(100vw - 16px));
|
||||
}
|
||||
|
||||
.downloaded-series-dialog__header {
|
||||
padding: 18px 16px 12px;
|
||||
}
|
||||
|
||||
.downloaded-series-dialog__episode {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.downloaded-series-dialog__actions {
|
||||
justify-content: flex-end;
|
||||
border-top: 1px solid var(--app-separator);
|
||||
padding-top: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.downloaded-series-dialog__episode {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import type { DownloadItem } from '@iptvnator/services';
|
||||
import type { DownloadSeriesCardViewModel } from './download-library.viewmodel';
|
||||
import { DownloadedSeriesDialogComponent } from './downloaded-series-dialog.component';
|
||||
|
||||
const TRANSLATIONS = {
|
||||
CLOSE: 'Close',
|
||||
DOWNLOADS: {
|
||||
COPY_URL: 'Copy download URL',
|
||||
DOWNLOADED_EPISODES: 'Downloaded episodes',
|
||||
EPISODE_COUNT: '{{count}} episodes',
|
||||
PLAY: 'Play',
|
||||
REMOVE_FROM_MANAGER: 'Remove from manager',
|
||||
REVEAL: 'Show in folder',
|
||||
},
|
||||
};
|
||||
|
||||
function episode(overrides: Partial<DownloadItem>): DownloadItem {
|
||||
return {
|
||||
id: 31,
|
||||
playlistId: 'playlist-series',
|
||||
xtreamId: 501,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 88,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
title: 'Signal House - S01E01 - Arrival',
|
||||
url: 'https://media.example.test/episode',
|
||||
status: 'completed',
|
||||
bytesDownloaded: 1_500_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const MEMBERS = [
|
||||
episode({ id: 31 }),
|
||||
episode({
|
||||
id: 32,
|
||||
xtreamId: 502,
|
||||
episodeNumber: 3,
|
||||
title: 'Signal House - S01E03 - Relay',
|
||||
}),
|
||||
episode({
|
||||
id: 33,
|
||||
xtreamId: 503,
|
||||
seasonNumber: 2,
|
||||
episodeNumber: 1,
|
||||
title: 'Signal House - S02E01 - Return',
|
||||
}),
|
||||
] as const;
|
||||
|
||||
const GROUP: DownloadSeriesCardViewModel = {
|
||||
kind: 'series',
|
||||
key: 'series:playlist-series:88',
|
||||
representative: MEMBERS[2],
|
||||
members: MEMBERS,
|
||||
seriesXtreamId: 88,
|
||||
title: 'Signal House',
|
||||
posterUrl: 'https://media.example.test/signal-house.jpg',
|
||||
newestTimestamp: 3,
|
||||
sourceName: 'Series source',
|
||||
trackedBytes: 4_500_000,
|
||||
firstSeason: 1,
|
||||
lastSeason: 2,
|
||||
};
|
||||
|
||||
describe('DownloadedSeriesDialogComponent', () => {
|
||||
let fixture: ComponentFixture<DownloadedSeriesDialogComponent>;
|
||||
let component: DownloadedSeriesDialogComponent;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
DownloadedSeriesDialogComponent,
|
||||
NoopAnimationsModule,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
providers: [{ provide: MAT_DIALOG_DATA, useValue: GROUP }],
|
||||
}).compileComponents();
|
||||
|
||||
TestBed.inject(TranslateService).setTranslation('en', TRANSLATIONS);
|
||||
TestBed.inject(TranslateService).use('en');
|
||||
fixture = TestBed.createComponent(DownloadedSeriesDialogComponent);
|
||||
component = fixture.componentInstance;
|
||||
await fixture.whenStable();
|
||||
});
|
||||
|
||||
function rows(): HTMLElement[] {
|
||||
return Array.from(
|
||||
fixture.nativeElement.querySelectorAll(
|
||||
'[data-test-id^="downloaded-series-episode-"]'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function button(row: ParentNode, label: string): HTMLButtonElement {
|
||||
const result = Array.from(row.querySelectorAll('button')).find(
|
||||
(candidate) => candidate.getAttribute('aria-label') === label
|
||||
);
|
||||
if (!result) {
|
||||
throw new Error(`Missing button: ${label}`);
|
||||
}
|
||||
return result as HTMLButtonElement;
|
||||
}
|
||||
|
||||
async function click(element: HTMLElement): Promise<void> {
|
||||
element.click();
|
||||
await fixture.whenStable();
|
||||
}
|
||||
|
||||
it('renders members in view-model order with their episode identities', () => {
|
||||
expect(rows().map((row) => row.getAttribute('data-test-id'))).toEqual([
|
||||
'downloaded-series-episode-31',
|
||||
'downloaded-series-episode-32',
|
||||
'downloaded-series-episode-33',
|
||||
]);
|
||||
expect(rows()[0].textContent).toContain('S01E01');
|
||||
expect(rows()[1].textContent).toContain('S01E03');
|
||||
expect(rows()[2].textContent).toContain('S02E01');
|
||||
});
|
||||
|
||||
it('includes the concrete episode identity in every action name', () => {
|
||||
rows().forEach((row, index) => {
|
||||
const identity = ['S01E01', 'S01E03', 'S02E01'][index];
|
||||
const labels = Array.from(
|
||||
row.querySelectorAll(
|
||||
'.downloaded-series-dialog__actions button'
|
||||
)
|
||||
).map((candidate) => candidate.getAttribute('aria-label'));
|
||||
|
||||
expect(labels).toHaveLength(4);
|
||||
expect(labels.every((label) => label?.includes(identity))).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('emits play, reveal, copy, and remove with the selected member', async () => {
|
||||
const selected = MEMBERS[1];
|
||||
const row = rows()[1];
|
||||
const actions: unknown[] = [];
|
||||
component.itemAction.subscribe((action) => actions.push(action));
|
||||
|
||||
await click(
|
||||
button(row, 'Play: S01E03 — Signal House - S01E03 - Relay')
|
||||
);
|
||||
await click(
|
||||
button(
|
||||
row,
|
||||
'Show in folder: S01E03 — Signal House - S01E03 - Relay'
|
||||
)
|
||||
);
|
||||
await click(
|
||||
button(
|
||||
row,
|
||||
'Copy download URL: S01E03 — Signal House - S01E03 - Relay'
|
||||
)
|
||||
);
|
||||
await click(
|
||||
button(
|
||||
row,
|
||||
'Remove from manager: S01E03 — Signal House - S01E03 - Relay'
|
||||
)
|
||||
);
|
||||
|
||||
expect(actions).toEqual([
|
||||
{ type: 'play', item: selected },
|
||||
{ type: 'reveal', item: selected },
|
||||
{ type: 'copy-url', item: selected },
|
||||
{ type: 'remove', item: selected },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import type { DownloadItem } from '@iptvnator/services';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import type {
|
||||
DownloadItemAction,
|
||||
DownloadItemActionType,
|
||||
} from './download-actions';
|
||||
import type { DownloadSeriesCardViewModel } from './download-library.viewmodel';
|
||||
|
||||
@Component({
|
||||
selector: 'app-downloaded-series-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatDialogModule,
|
||||
MatIconModule,
|
||||
MatTooltipModule,
|
||||
TranslatePipe,
|
||||
],
|
||||
templateUrl: './downloaded-series-dialog.component.html',
|
||||
styleUrl: './downloaded-series-dialog.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DownloadedSeriesDialogComponent {
|
||||
readonly group = inject<DownloadSeriesCardViewModel>(MAT_DIALOG_DATA);
|
||||
readonly itemAction = output<DownloadItemAction>();
|
||||
|
||||
protected emitAction(
|
||||
type: DownloadItemActionType,
|
||||
item: DownloadItem
|
||||
): void {
|
||||
this.itemAction.emit({ type, item });
|
||||
}
|
||||
|
||||
protected episodeLabel(item: DownloadItem): string {
|
||||
if (
|
||||
!Number.isSafeInteger(item.seasonNumber) ||
|
||||
(item.seasonNumber ?? -1) < 0 ||
|
||||
!Number.isSafeInteger(item.episodeNumber) ||
|
||||
(item.episodeNumber ?? -1) < 0
|
||||
) {
|
||||
return '';
|
||||
}
|
||||
return `S${String(item.seasonNumber).padStart(2, '0')}E${String(
|
||||
item.episodeNumber
|
||||
).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
protected episodeIdentity(item: DownloadItem): string {
|
||||
const label = this.episodeLabel(item);
|
||||
return label ? `${label} — ${item.title}` : item.title;
|
||||
}
|
||||
|
||||
protected formatBytes(bytes: number | undefined): string {
|
||||
if (!Number.isFinite(bytes) || (bytes ?? 0) <= 0) {
|
||||
return '0 B';
|
||||
}
|
||||
const value = bytes as number;
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const exponent = Math.min(
|
||||
Math.floor(Math.log(value) / Math.log(1024)),
|
||||
units.length - 1
|
||||
);
|
||||
const normalized = value / 1024 ** exponent;
|
||||
return `${normalized >= 10 ? normalized.toFixed(0) : normalized.toFixed(1)} ${units[exponent]}`;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user