feat(downloads): add ready-to-watch library

This commit is contained in:
4gray committed 2026-08-01 16:29:49 +02:00
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]}`;
}
}