mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 17:36:15 -08:00
feat(downloads): open ready cards in offline details
This commit is contained in:
1 parent
e8e2a31312
commit
1ec0f444e4
6 files changed
+148
-142
No files matched your search
@@ -24,25 +24,10 @@
|
||||
class="download-library__artwork-button"
|
||||
data-test-id="download-library-series-open"
|
||||
[disabled]="isPending(entity.representative)"
|
||||
[class.download-library__artwork-button--disabled]="
|
||||
!canOpen(entity.representative)
|
||||
"
|
||||
[attr.aria-disabled]="
|
||||
!canOpen(entity.representative)
|
||||
? 'true'
|
||||
: null
|
||||
"
|
||||
[attr.aria-label]="
|
||||
'DOWNLOADS.OPEN_ARTWORK'
|
||||
| translate: { title: entity.title }
|
||||
"
|
||||
[matTooltip]="
|
||||
'DOWNLOADS.SOURCE_PLAYLIST_MISSING'
|
||||
| translate
|
||||
"
|
||||
[matTooltipDisabled]="
|
||||
canOpen(entity.representative)
|
||||
"
|
||||
(click)="openDetails(entity.representative)"
|
||||
>
|
||||
@if (artworkUrl(entity); as artwork) {
|
||||
@@ -86,26 +71,11 @@
|
||||
[disabled]="
|
||||
isPending(entity.representative)
|
||||
"
|
||||
[class.download-library__title-button--disabled]="
|
||||
!canOpen(entity.representative)
|
||||
"
|
||||
[attr.aria-disabled]="
|
||||
!canOpen(entity.representative)
|
||||
? 'true'
|
||||
: null
|
||||
"
|
||||
[attr.aria-label]="
|
||||
('DOWNLOADS.OPEN_DETAILS' | translate) +
|
||||
': ' +
|
||||
entity.title
|
||||
"
|
||||
[matTooltip]="
|
||||
'DOWNLOADS.SOURCE_PLAYLIST_MISSING'
|
||||
| translate
|
||||
"
|
||||
[matTooltipDisabled]="
|
||||
canOpen(entity.representative)
|
||||
"
|
||||
(click)="openDetails(entity.representative)"
|
||||
>
|
||||
{{ entity.title }}
|
||||
@@ -227,16 +197,6 @@
|
||||
type="button"
|
||||
class="download-library__artwork-button"
|
||||
[disabled]="isPending(entity.item)"
|
||||
[class.download-library__artwork-button--disabled]="
|
||||
entity.kind === 'movie' &&
|
||||
!canOpen(entity.item)
|
||||
"
|
||||
[attr.aria-disabled]="
|
||||
entity.kind === 'movie' &&
|
||||
!canOpen(entity.item)
|
||||
? 'true'
|
||||
: null
|
||||
"
|
||||
[attr.aria-label]="
|
||||
entity.kind === 'movie'
|
||||
? ('DOWNLOADS.OPEN_ARTWORK'
|
||||
@@ -246,14 +206,6 @@
|
||||
': ' +
|
||||
entity.item.title
|
||||
"
|
||||
[matTooltip]="
|
||||
'DOWNLOADS.SOURCE_PLAYLIST_MISSING'
|
||||
| translate
|
||||
"
|
||||
[matTooltipDisabled]="
|
||||
entity.kind !== 'movie' ||
|
||||
canOpen(entity.item)
|
||||
"
|
||||
(click)="
|
||||
entity.kind === 'movie'
|
||||
? openDetails(entity.item)
|
||||
@@ -306,16 +258,6 @@
|
||||
type="button"
|
||||
class="download-library__title-button"
|
||||
[disabled]="isPending(entity.item)"
|
||||
[class.download-library__title-button--disabled]="
|
||||
entity.kind === 'movie' &&
|
||||
!canOpen(entity.item)
|
||||
"
|
||||
[attr.aria-disabled]="
|
||||
entity.kind === 'movie' &&
|
||||
!canOpen(entity.item)
|
||||
? 'true'
|
||||
: null
|
||||
"
|
||||
[attr.aria-label]="
|
||||
((entity.kind === 'movie'
|
||||
? 'DOWNLOADS.OPEN_DETAILS'
|
||||
@@ -324,14 +266,6 @@
|
||||
': ' +
|
||||
entity.item.title
|
||||
"
|
||||
[matTooltip]="
|
||||
'DOWNLOADS.SOURCE_PLAYLIST_MISSING'
|
||||
| translate
|
||||
"
|
||||
[matTooltipDisabled]="
|
||||
entity.kind !== 'movie' ||
|
||||
canOpen(entity.item)
|
||||
"
|
||||
(click)="
|
||||
entity.kind === 'movie'
|
||||
? openDetails(entity.item)
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { FocusMonitor } from '@angular/cdk/a11y';
|
||||
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/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';
|
||||
@@ -33,8 +30,6 @@ const TRANSLATIONS = {
|
||||
SEASON: 'Season {{season}}',
|
||||
SEASON_RANGE: 'Seasons {{first}}–{{last}}',
|
||||
SERIES: 'Series',
|
||||
SOURCE_PLAYLIST_MISSING:
|
||||
'This download can no longer open because its source was removed.',
|
||||
},
|
||||
PORTALS: {
|
||||
MULTI_SOURCE: {
|
||||
@@ -154,10 +149,6 @@ describe('DownloadLibraryComponent', () => {
|
||||
fixture = TestBed.createComponent(DownloadLibraryComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.componentRef.setInput('entities', ENTITIES);
|
||||
fixture.componentRef.setInput(
|
||||
'availablePlaylistIds',
|
||||
new Set(['playlist-a'])
|
||||
);
|
||||
await fixture.whenStable();
|
||||
});
|
||||
|
||||
@@ -323,9 +314,7 @@ describe('DownloadLibraryComponent', () => {
|
||||
expect(actions).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps orphaned movie navigation focusable and explains why it cannot open', async () => {
|
||||
fixture.componentRef.setInput('availablePlaylistIds', new Set());
|
||||
await fixture.whenStable();
|
||||
it('opens orphaned movie details without source-dependent disabled semantics', async () => {
|
||||
const card = byTestId('download-library-movie-9');
|
||||
const artwork = button(card, 'Open details: Moonrise artwork');
|
||||
const title = button(card, 'Open details: Moonrise');
|
||||
@@ -334,27 +323,12 @@ describe('DownloadLibraryComponent', () => {
|
||||
|
||||
expect(artwork.disabled).toBe(false);
|
||||
expect(title.disabled).toBe(false);
|
||||
expect(artwork.getAttribute('aria-disabled')).toBe('true');
|
||||
expect(title.getAttribute('aria-disabled')).toBe('true');
|
||||
|
||||
const loader = TestbedHarnessEnvironment.loader(fixture);
|
||||
const tooltip = await loader.getHarness(
|
||||
MatTooltipHarness.with({
|
||||
selector:
|
||||
'.download-library__artwork-button[aria-label="Open details: Moonrise artwork"]',
|
||||
})
|
||||
);
|
||||
TestBed.inject(FocusMonitor).focusVia(artwork, 'keyboard');
|
||||
await fixture.whenStable();
|
||||
expect(await tooltip.isOpen()).toBe(true);
|
||||
expect(await tooltip.getTooltipText()).toBe(
|
||||
'This download can no longer open because its source was removed.'
|
||||
);
|
||||
await tooltip.hide();
|
||||
expect(artwork.getAttribute('aria-disabled')).toBeNull();
|
||||
expect(title.getAttribute('aria-disabled')).toBeNull();
|
||||
|
||||
await click(artwork);
|
||||
await click(title);
|
||||
expect(opened).toEqual([]);
|
||||
expect(opened).toEqual([MOVIE, MOVIE]);
|
||||
});
|
||||
|
||||
it('keeps explicit movie toolbar and menu commands local', async () => {
|
||||
@@ -430,13 +404,19 @@ describe('DownloadLibraryComponent', () => {
|
||||
await fixture.whenStable();
|
||||
expect(opened).toEqual([]);
|
||||
|
||||
await click(
|
||||
button(
|
||||
byTestId('download-library-movie-9'),
|
||||
'Open details: Moonrise artwork'
|
||||
)
|
||||
);
|
||||
expect(opened).toEqual([MOVIE]);
|
||||
|
||||
await click(episodes);
|
||||
expect(episodeGroups).toEqual([SERIES]);
|
||||
});
|
||||
|
||||
it('disables orphaned series navigation and explains why while keeping episodes local', async () => {
|
||||
fixture.componentRef.setInput('availablePlaylistIds', new Set());
|
||||
await fixture.whenStable();
|
||||
it('opens orphaned series navigation while keeping episodes local', async () => {
|
||||
const card = byTestId('download-library-series-playlist-a-77');
|
||||
const artwork = button(card, 'Open details: Northwind artwork');
|
||||
const title = button(card, 'Open details: Northwind');
|
||||
@@ -446,29 +426,16 @@ describe('DownloadLibraryComponent', () => {
|
||||
|
||||
expect(artwork.disabled).toBe(false);
|
||||
expect(title.disabled).toBe(false);
|
||||
expect(artwork.getAttribute('aria-disabled')).toBe('true');
|
||||
expect(title.getAttribute('aria-disabled')).toBe('true');
|
||||
expect(artwork.getAttribute('aria-disabled')).toBeNull();
|
||||
expect(title.getAttribute('aria-disabled')).toBeNull();
|
||||
expect(episodes.disabled).toBe(false);
|
||||
|
||||
const loader = TestbedHarnessEnvironment.loader(fixture);
|
||||
const tooltip = await loader.getHarness(
|
||||
MatTooltipHarness.with({
|
||||
selector:
|
||||
'[data-test-id="download-library-series-open"].download-library__artwork-button',
|
||||
})
|
||||
);
|
||||
TestBed.inject(FocusMonitor).focusVia(artwork, 'keyboard');
|
||||
await fixture.whenStable();
|
||||
artwork.focus();
|
||||
expect(document.activeElement).toBe(artwork);
|
||||
expect(await tooltip.isOpen()).toBe(true);
|
||||
expect(await tooltip.getTooltipText()).toBe(
|
||||
'This download can no longer open because its source was removed.'
|
||||
);
|
||||
await tooltip.hide();
|
||||
|
||||
await click(artwork);
|
||||
await click(title);
|
||||
expect(opened).toEqual([]);
|
||||
expect(opened).toEqual([SERIES.representative, SERIES.representative]);
|
||||
});
|
||||
|
||||
it('opens the downloaded episode group from its count control', async () => {
|
||||
|
||||
@@ -38,7 +38,6 @@ import { DownloadSourceMenuHeaderComponent } from './download-source-menu-header
|
||||
})
|
||||
export class DownloadLibraryComponent {
|
||||
readonly entities = input.required<readonly DownloadLibraryEntity[]>();
|
||||
readonly availablePlaylistIds = input.required<ReadonlySet<string>>();
|
||||
readonly pendingIds = input<ReadonlySet<number>>(new Set());
|
||||
readonly itemAction = output<DownloadItemAction>();
|
||||
readonly openRequested = output<DownloadItem>();
|
||||
@@ -83,10 +82,6 @@ export class DownloadLibraryComponent {
|
||||
return this.pendingIds().has(item.id);
|
||||
}
|
||||
|
||||
protected canOpen(item: DownloadItem): boolean {
|
||||
return this.availablePlaylistIds().has(item.playlistId);
|
||||
}
|
||||
|
||||
protected emitAction(
|
||||
type: DownloadItemActionType,
|
||||
item: DownloadItem
|
||||
@@ -97,7 +92,7 @@ export class DownloadLibraryComponent {
|
||||
}
|
||||
|
||||
protected openDetails(item: DownloadItem): void {
|
||||
if (!this.isPending(item) && this.canOpen(item)) {
|
||||
if (!this.isPending(item)) {
|
||||
this.openRequested.emit(item);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,10 +232,9 @@
|
||||
/>
|
||||
<app-download-library
|
||||
[entities]="model().library"
|
||||
[availablePlaylistIds]="availablePlaylistIds()"
|
||||
[pendingIds]="pendingIds()"
|
||||
(itemAction)="runAction($event)"
|
||||
(openRequested)="openInLibrary($event)"
|
||||
(openRequested)="openOfflineDetail($event)"
|
||||
(episodesOpened)="openDownloadedSeries($event)"
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -5,7 +5,12 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
|
||||
import {
|
||||
ActivatedRoute,
|
||||
convertToParamMap,
|
||||
type ParamMap,
|
||||
Router,
|
||||
} from '@angular/router';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
type DownloadItem,
|
||||
@@ -18,7 +23,7 @@ import {
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import type { Playlist } from '@iptvnator/shared/interfaces';
|
||||
import { DialogService } from '@iptvnator/ui/components';
|
||||
import { BehaviorSubject, Subject } from 'rxjs';
|
||||
import { BehaviorSubject, type Observable, Subject } from 'rxjs';
|
||||
import { DownloadLibraryNavigationService } from './download-library-navigation.service';
|
||||
import { DownloadManagerActionsService } from './download-manager-actions.service';
|
||||
import type { DownloadSeriesCardViewModel } from './download-manager.viewmodel';
|
||||
@@ -44,6 +49,7 @@ interface ExpectedDownloadsComponent {
|
||||
readonly pendingIds: () => ReadonlySet<number>;
|
||||
clearFinished(): void;
|
||||
openDownloadedSeries(group: DownloadSeriesCardViewModel): void;
|
||||
openOfflineDetail(item: DownloadItem): void;
|
||||
openInLibrary(item: DownloadItem): Promise<void>;
|
||||
runAction(action: {
|
||||
readonly type: string;
|
||||
@@ -90,6 +96,16 @@ describe('DownloadsComponent', () => {
|
||||
let downloads: ReturnType<typeof signal<DownloadItem[]>>;
|
||||
let routeParams: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
|
||||
let queryParams: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
|
||||
let activatedRoute: {
|
||||
readonly paramMap: Observable<ParamMap>;
|
||||
readonly queryParamMap: Observable<ParamMap>;
|
||||
readonly snapshot: {
|
||||
readonly params: Record<string, string>;
|
||||
readonly paramMap: ReturnType<typeof convertToParamMap>;
|
||||
readonly queryParamMap: ReturnType<typeof convertToParamMap>;
|
||||
};
|
||||
};
|
||||
let router: { url: string; navigate: jest.Mock };
|
||||
let playlistItems: BehaviorSubject<Playlist[]>;
|
||||
let collectionContext: PortalCollectionContextService;
|
||||
let dialogConfigs: ConfirmConfig[];
|
||||
@@ -131,6 +147,19 @@ describe('DownloadsComponent', () => {
|
||||
downloads = signal<DownloadItem[]>([]);
|
||||
routeParams = new BehaviorSubject(convertToParamMap({}));
|
||||
queryParams = new BehaviorSubject(convertToParamMap({}));
|
||||
activatedRoute = {
|
||||
paramMap: routeParams.asObservable(),
|
||||
queryParamMap: queryParams.asObservable(),
|
||||
snapshot: {
|
||||
params: {},
|
||||
paramMap: convertToParamMap({}),
|
||||
queryParamMap: convertToParamMap({}),
|
||||
},
|
||||
};
|
||||
router = {
|
||||
url: '/workspace/downloads?q=signal',
|
||||
navigate: jest.fn(async () => true),
|
||||
};
|
||||
playlistItems = new BehaviorSubject([
|
||||
playlist('playlist-a', 'Alpha source'),
|
||||
playlist('playlist-b', 'Beta source'),
|
||||
@@ -204,19 +233,11 @@ describe('DownloadsComponent', () => {
|
||||
providers: [
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
paramMap: routeParams.asObservable(),
|
||||
queryParamMap: queryParams.asObservable(),
|
||||
snapshot: {
|
||||
params: {},
|
||||
paramMap: convertToParamMap({}),
|
||||
queryParamMap: convertToParamMap({}),
|
||||
},
|
||||
},
|
||||
useValue: activatedRoute,
|
||||
},
|
||||
{
|
||||
provide: Router,
|
||||
useValue: { navigate: jest.fn(async () => true) },
|
||||
useValue: router,
|
||||
},
|
||||
{
|
||||
provide: DownloadsService,
|
||||
@@ -718,10 +739,11 @@ describe('DownloadsComponent', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('opens completed movie artwork in details while explicit Play uses the file', async () => {
|
||||
const item = download(14, {
|
||||
title: 'Completed movie',
|
||||
filePath: '/downloads/completed-movie.mp4',
|
||||
it('opens removed-source movie artwork in its offline detail while explicit Play stays local', async () => {
|
||||
const item = download(17, {
|
||||
playlistId: 'removed-playlist',
|
||||
title: 'Signal',
|
||||
filePath: '/downloads/signal.mp4',
|
||||
});
|
||||
downloads.set([item]);
|
||||
await fixture.whenStable();
|
||||
@@ -734,12 +756,17 @@ describe('DownloadsComponent', () => {
|
||||
artworkButton.click();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(navigation.open).toHaveBeenCalledWith(item);
|
||||
expect(router.navigate).toHaveBeenCalledWith(['17'], {
|
||||
relativeTo: activatedRoute,
|
||||
state: { returnUrl: '/workspace/downloads?q=signal' },
|
||||
});
|
||||
expect(navigation.canOpen).not.toHaveBeenCalled();
|
||||
expect(navigation.open).not.toHaveBeenCalled();
|
||||
expect(downloadsService.playDownload).not.toHaveBeenCalled();
|
||||
|
||||
jest.clearAllMocks();
|
||||
const playButton = fixture.nativeElement.querySelector(
|
||||
'.download-library__actions button[aria-label="Play: Completed movie"]'
|
||||
'.download-library__actions button[aria-label="Play: Signal"]'
|
||||
) as HTMLButtonElement;
|
||||
expect(playButton).toBeTruthy();
|
||||
|
||||
@@ -749,9 +776,83 @@ describe('DownloadsComponent', () => {
|
||||
expect(downloadsService.playDownload).toHaveBeenCalledWith(
|
||||
item.filePath
|
||||
);
|
||||
expect(router.navigate).not.toHaveBeenCalled();
|
||||
expect(navigation.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens a grouped-series representative relative to the Xtream downloads route', async () => {
|
||||
const older = download(18, {
|
||||
contentType: 'episode',
|
||||
createdAt: '2026-07-18T12:00:00Z',
|
||||
episodeNumber: 1,
|
||||
seasonNumber: 1,
|
||||
seriesXtreamId: 77,
|
||||
title: 'Northwind - S01E01 - Arrival',
|
||||
});
|
||||
const representative = download(19, {
|
||||
contentType: 'episode',
|
||||
createdAt: '2026-07-19T12:00:00Z',
|
||||
episodeNumber: 2,
|
||||
seasonNumber: 1,
|
||||
seriesXtreamId: 77,
|
||||
title: 'Northwind - S01E02 - Signal',
|
||||
});
|
||||
routeParams.next(convertToParamMap({ id: 'playlist-a' }));
|
||||
router.url = '/workspace/xtream/playlist-a/downloads?q=northwind';
|
||||
downloads.set([older, representative]);
|
||||
await fixture.whenStable();
|
||||
|
||||
const seriesArtwork = fixture.nativeElement.querySelector(
|
||||
'[data-test-id="download-library-series-open"].download-library__artwork-button'
|
||||
) as HTMLButtonElement;
|
||||
expect(seriesArtwork).toBeTruthy();
|
||||
|
||||
seriesArtwork.click();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith(['19'], {
|
||||
relativeTo: activatedRoute,
|
||||
state: {
|
||||
returnUrl: '/workspace/xtream/playlist-a/downloads?q=northwind',
|
||||
},
|
||||
});
|
||||
expect(navigation.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps offline detail navigation relative to the Stalker downloads route', () => {
|
||||
routeParams.next(convertToParamMap({ id: 'playlist-a' }));
|
||||
router.url = '/workspace/stalker/playlist-a/downloads?q=signal';
|
||||
|
||||
component.openOfflineDetail(download(22));
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith(['22'], {
|
||||
relativeTo: activatedRoute,
|
||||
state: {
|
||||
returnUrl: '/workspace/stalker/playlist-a/downloads?q=signal',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks only the exact pending download from opening offline detail', async () => {
|
||||
const operation = deferred<{ success: boolean }>();
|
||||
const pending = download(20, { status: 'downloading' });
|
||||
const ready = download(21);
|
||||
downloadsService.pauseDownload.mockReturnValueOnce(operation.promise);
|
||||
|
||||
const action = component.runAction({ type: 'pause', item: pending });
|
||||
component.openOfflineDetail(pending);
|
||||
component.openOfflineDetail(ready);
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledTimes(1);
|
||||
expect(router.navigate).toHaveBeenCalledWith(['21'], {
|
||||
relativeTo: activatedRoute,
|
||||
state: { returnUrl: '/workspace/downloads?q=signal' },
|
||||
});
|
||||
|
||||
operation.resolve({ success: true });
|
||||
await action;
|
||||
});
|
||||
|
||||
it('does not navigate an item whose source playlist is missing', async () => {
|
||||
const item = download(13, { playlistId: 'removed-playlist' });
|
||||
navigation.canOpen.mockReturnValueOnce(false);
|
||||
|
||||
@@ -226,6 +226,16 @@ export class DownloadsComponent {
|
||||
this.destroyRef.onDestroy(cleanup);
|
||||
}
|
||||
|
||||
openOfflineDetail(item: DownloadItem): void {
|
||||
if (this.pendingIds().has(item.id)) {
|
||||
return;
|
||||
}
|
||||
void this.router.navigate([String(item.id)], {
|
||||
relativeTo: this.route,
|
||||
state: { returnUrl: this.router.url },
|
||||
});
|
||||
}
|
||||
|
||||
async openInLibrary(item: DownloadItem): Promise<void> {
|
||||
if (this.pendingIds().has(item.id)) {
|
||||
return;
|
||||
|
||||
Reference in new issue
Block a user