mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 17:36:15 -08:00
fix(downloads): harden offline detail interactions
This commit is contained in:
1 parent
a741415a6a
commit
211ef784fc
24 files changed
+1417
-255
No files matched your search
@@ -11,6 +11,9 @@ export type DownloadItemActionType =
|
||||
| 'retry'
|
||||
| 'reveal';
|
||||
|
||||
export type DownloadActionResult =
|
||||
'success' | 'file-missing' | 'failed' | 'ignored';
|
||||
|
||||
export interface DownloadItemAction {
|
||||
readonly type: DownloadItemActionType;
|
||||
readonly item: DownloadItem;
|
||||
|
||||
@@ -165,7 +165,11 @@ describe('DownloadLibraryNavigationService', () => {
|
||||
|
||||
await expect(navigation.open(download())).resolves.toBe(true);
|
||||
|
||||
expect(db.getContentByXtreamId).toHaveBeenCalledWith(41, PLAYLIST_ID);
|
||||
expect(db.getContentByXtreamId).toHaveBeenCalledWith(
|
||||
41,
|
||||
PLAYLIST_ID,
|
||||
'movie'
|
||||
);
|
||||
expect(router.navigate).toHaveBeenCalledWith([
|
||||
'/workspace',
|
||||
'xtreams',
|
||||
@@ -199,6 +203,10 @@ describe('DownloadLibraryNavigationService', () => {
|
||||
async (source) => {
|
||||
if (source === 'stalker') {
|
||||
playlists.getPlaylistById.mockReturnValue(of(STALKER_PLAYLIST));
|
||||
} else {
|
||||
db.getContentByXtreamId.mockResolvedValue({
|
||||
category_id: 7,
|
||||
} as never);
|
||||
}
|
||||
router.navigate.mockRejectedValue(new Error('navigation rejected'));
|
||||
|
||||
@@ -228,7 +236,11 @@ describe('DownloadLibraryNavigationService', () => {
|
||||
|
||||
await expect(navigation.open(item)).resolves.toBe(true);
|
||||
|
||||
expect(db.getContentByXtreamId).toHaveBeenCalledWith(93, PLAYLIST_ID);
|
||||
expect(db.getContentByXtreamId).toHaveBeenCalledWith(
|
||||
93,
|
||||
PLAYLIST_ID,
|
||||
'series'
|
||||
);
|
||||
expect(router.navigate).toHaveBeenCalledWith([
|
||||
'/workspace',
|
||||
'xtreams',
|
||||
@@ -243,21 +255,58 @@ describe('DownloadLibraryNavigationService', () => {
|
||||
['missing', {}],
|
||||
['null', { category_id: null }],
|
||||
])(
|
||||
'falls back to the Xtream collection route for a %s category',
|
||||
'does not navigate an Xtream item with a %s category',
|
||||
async (_label, content) => {
|
||||
db.getContentByXtreamId.mockResolvedValue(content as never);
|
||||
|
||||
await expect(navigation.open(download())).resolves.toBe(true);
|
||||
await expect(navigation.open(download())).resolves.toBe(false);
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith([
|
||||
'/workspace',
|
||||
'xtreams',
|
||||
PLAYLIST_ID,
|
||||
'vod',
|
||||
]);
|
||||
expect(router.navigate).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('prefers the persisted provider category and skips the database lookup', async () => {
|
||||
const item = download({
|
||||
metadataSnapshot: {
|
||||
version: 1,
|
||||
language: 'en',
|
||||
mediaKind: 'movie',
|
||||
title: 'Downloaded title',
|
||||
providerCategoryId: '18',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(navigation.open(item)).resolves.toBe(true);
|
||||
|
||||
expect(db.getContentByXtreamId).not.toHaveBeenCalled();
|
||||
expect(router.navigate).toHaveBeenCalledWith([
|
||||
'/workspace',
|
||||
'xtreams',
|
||||
PLAYLIST_ID,
|
||||
'vod',
|
||||
'18',
|
||||
'41',
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves a concrete target before navigating that exact cached target', async () => {
|
||||
db.getContentByXtreamId.mockResolvedValue({ category_id: 7 } as never);
|
||||
|
||||
const target = await navigation.resolveProviderTarget(download());
|
||||
|
||||
expect(target).toEqual({
|
||||
link: ['/workspace', 'xtreams', PLAYLIST_ID, 'vod', '7', '41'],
|
||||
});
|
||||
expect(router.navigate).not.toHaveBeenCalled();
|
||||
if (!target) throw new Error('Expected a resolved provider target');
|
||||
|
||||
await expect(navigation.navigateResolvedTarget(target)).resolves.toBe(
|
||||
true
|
||||
);
|
||||
expect(router.navigate).toHaveBeenCalledWith(target.link);
|
||||
expect(db.getContentByXtreamId).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns false when the source playlist is missing', async () => {
|
||||
playlists.getPlaylistById.mockReturnValue(
|
||||
of(undefined as unknown as Playlist)
|
||||
|
||||
@@ -6,7 +6,10 @@ import {
|
||||
type DownloadItem,
|
||||
PlaylistsService,
|
||||
} from '@iptvnator/services';
|
||||
import { buildStalkerDetailNavigationTarget } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
buildStalkerDetailNavigationTarget,
|
||||
type WorkspaceNavigationTarget,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import type { StalkerPortalItem } from '@iptvnator/shared/interfaces';
|
||||
|
||||
type PortalSource = 'xtream' | 'stalker';
|
||||
@@ -33,20 +36,43 @@ export class DownloadLibraryNavigationService {
|
||||
}
|
||||
|
||||
async open(item: DownloadItem): Promise<boolean> {
|
||||
const target = await this.resolveProviderTarget(item);
|
||||
return target
|
||||
? this.navigateResolvedTarget(target)
|
||||
: Promise.resolve(false);
|
||||
}
|
||||
|
||||
async resolveProviderTarget(
|
||||
item: DownloadItem
|
||||
): Promise<WorkspaceNavigationTarget | null> {
|
||||
const targetId = this.targetId(item);
|
||||
if (targetId === null) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const source = await this.resolveSourceType(item.playlistId);
|
||||
if (source === null) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
return source === 'xtream'
|
||||
? await this.openXtreamItem(item, targetId)
|
||||
: await this.openStalkerItem(item, targetId);
|
||||
? await this.resolveXtreamTarget(item, targetId)
|
||||
: await this.resolveStalkerTarget(item, targetId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async navigateResolvedTarget(
|
||||
target: WorkspaceNavigationTarget
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
return target.state
|
||||
? await this.router.navigate(target.link, {
|
||||
state: target.state,
|
||||
})
|
||||
: await this.router.navigate(target.link);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -64,8 +90,8 @@ export class DownloadLibraryNavigationService {
|
||||
private route(
|
||||
source: PortalSource,
|
||||
playlistId: string,
|
||||
segments: Array<string | number>
|
||||
): Array<string | number> {
|
||||
segments: string[]
|
||||
): string[] {
|
||||
return [
|
||||
'/workspace',
|
||||
source === 'stalker' ? 'stalker' : 'xtreams',
|
||||
@@ -93,24 +119,31 @@ export class DownloadLibraryNavigationService {
|
||||
}
|
||||
}
|
||||
|
||||
private async openXtreamItem(
|
||||
private async resolveXtreamTarget(
|
||||
item: DownloadItem,
|
||||
targetId: number
|
||||
): Promise<boolean> {
|
||||
): Promise<WorkspaceNavigationTarget | null> {
|
||||
const contentType = item.contentType === 'episode' ? 'series' : 'vod';
|
||||
const content = await this.db.getContentByXtreamId(
|
||||
targetId,
|
||||
item.playlistId
|
||||
);
|
||||
const categoryId = content?.category_id;
|
||||
const segments =
|
||||
categoryId === null || categoryId === undefined
|
||||
? [contentType]
|
||||
: [contentType, String(categoryId), String(targetId)];
|
||||
const persistedCategory =
|
||||
item.metadataSnapshot?.providerCategoryId?.trim();
|
||||
const content = persistedCategory
|
||||
? null
|
||||
: await this.db.getContentByXtreamId(
|
||||
targetId,
|
||||
item.playlistId,
|
||||
item.contentType === 'episode' ? 'series' : 'movie'
|
||||
);
|
||||
const categoryId =
|
||||
persistedCategory || String(content?.category_id ?? '').trim();
|
||||
if (!categoryId) return null;
|
||||
|
||||
return this.router.navigate(
|
||||
this.route('xtream', item.playlistId, segments)
|
||||
);
|
||||
return {
|
||||
link: this.route('xtream', item.playlistId, [
|
||||
contentType,
|
||||
categoryId,
|
||||
String(targetId),
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
private normalizePortalItemId(value: unknown): string {
|
||||
@@ -205,10 +238,10 @@ export class DownloadLibraryNavigationService {
|
||||
};
|
||||
}
|
||||
|
||||
private async openStalkerItem(
|
||||
private async resolveStalkerTarget(
|
||||
item: DownloadItem,
|
||||
targetId: number
|
||||
): Promise<boolean> {
|
||||
): Promise<WorkspaceNavigationTarget> {
|
||||
const fallback = item.contentType === 'episode' ? 'series' : 'vod';
|
||||
const openStalkerItem = await this.stalkerOpenState(
|
||||
item,
|
||||
@@ -231,6 +264,6 @@ export class DownloadLibraryNavigationService {
|
||||
item: openStalkerItem,
|
||||
});
|
||||
|
||||
return this.router.navigate(target.link, { state: target.state });
|
||||
return target;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,10 @@ import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { type DownloadItem, DownloadsService } from '@iptvnator/services';
|
||||
import { DialogService } from '@iptvnator/ui/components';
|
||||
import type { DownloadItemAction } from './download-actions';
|
||||
import type {
|
||||
DownloadActionResult,
|
||||
DownloadItemAction,
|
||||
} from './download-actions';
|
||||
|
||||
interface DownloadOperationResult {
|
||||
readonly success: boolean;
|
||||
@@ -20,22 +23,20 @@ export class DownloadManagerActionsService {
|
||||
readonly pendingIds = signal<ReadonlySet<number>>(new Set());
|
||||
readonly isClearing = signal(false);
|
||||
|
||||
async run(action: DownloadItemAction): Promise<void> {
|
||||
async run(action: DownloadItemAction): Promise<DownloadActionResult> {
|
||||
const { item, type } = action;
|
||||
if (this.pendingIds().has(item.id)) {
|
||||
return;
|
||||
return 'ignored';
|
||||
}
|
||||
if (type === 'remove') {
|
||||
this.confirmRemove(item);
|
||||
return;
|
||||
return 'ignored';
|
||||
}
|
||||
if (type === 'copy-url') {
|
||||
await this.copyUrl(item);
|
||||
return;
|
||||
return this.copyUrl(item);
|
||||
}
|
||||
if (type === 'play' || type === 'reveal') {
|
||||
await this.runFileAction(type, item);
|
||||
return;
|
||||
return this.runFileAction(type, item);
|
||||
}
|
||||
const operations = {
|
||||
cancel: () => this.downloads.cancelDownload(item.id),
|
||||
@@ -44,7 +45,7 @@ export class DownloadManagerActionsService {
|
||||
resume: () => this.downloads.resumeDownload(item.id),
|
||||
retry: () => this.downloads.retryDownload(item.id),
|
||||
};
|
||||
await this.withPending(item.id, operations[type]);
|
||||
return this.withPending(item.id, operations[type]);
|
||||
}
|
||||
|
||||
clearFinished(scopePlaylistId?: string): void {
|
||||
@@ -117,13 +118,13 @@ export class DownloadManagerActionsService {
|
||||
private async runFileAction(
|
||||
type: 'play' | 'reveal',
|
||||
item: DownloadItem
|
||||
): Promise<void> {
|
||||
): Promise<DownloadActionResult> {
|
||||
const filePath = item.filePath;
|
||||
if (!filePath) {
|
||||
this.showMessage('DOWNLOADS.FILE_NOT_FOUND', 3000);
|
||||
return;
|
||||
return 'file-missing';
|
||||
}
|
||||
await this.withPending(
|
||||
return this.withPending(
|
||||
item.id,
|
||||
() =>
|
||||
type === 'play'
|
||||
@@ -134,12 +135,13 @@ export class DownloadManagerActionsService {
|
||||
await this.downloads.loadDownloads();
|
||||
}
|
||||
this.showFileActionError(error);
|
||||
}
|
||||
},
|
||||
(error) => (error === 'File not found' ? 'file-missing' : 'failed')
|
||||
);
|
||||
}
|
||||
|
||||
private async copyUrl(item: DownloadItem): Promise<void> {
|
||||
await this.withPending(
|
||||
private async copyUrl(item: DownloadItem): Promise<DownloadActionResult> {
|
||||
return this.withPending(
|
||||
item.id,
|
||||
async () => {
|
||||
try {
|
||||
@@ -158,10 +160,11 @@ export class DownloadManagerActionsService {
|
||||
itemId: number,
|
||||
operation: () => Promise<DownloadOperationResult>,
|
||||
onFailure: (error?: string) => void | Promise<void> = (error) =>
|
||||
this.showActionError(error)
|
||||
): Promise<void> {
|
||||
this.showActionError(error),
|
||||
failureResult: (error?: string) => DownloadActionResult = () => 'failed'
|
||||
): Promise<DownloadActionResult> {
|
||||
if (this.pendingIds().has(itemId)) {
|
||||
return;
|
||||
return 'ignored';
|
||||
}
|
||||
this.pendingIds.update((ids) => new Set(ids).add(itemId));
|
||||
try {
|
||||
@@ -177,8 +180,16 @@ export class DownloadManagerActionsService {
|
||||
error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
if (failed) {
|
||||
await onFailure(failure);
|
||||
try {
|
||||
await onFailure(failure);
|
||||
} catch (error) {
|
||||
this.showActionError(
|
||||
error instanceof Error ? error.message : String(error)
|
||||
);
|
||||
}
|
||||
return failureResult(failure);
|
||||
}
|
||||
return 'success';
|
||||
} finally {
|
||||
this.pendingIds.update((ids) => {
|
||||
const next = new Set(ids);
|
||||
|
||||
@@ -25,6 +25,7 @@ import type { Playlist } from '@iptvnator/shared/interfaces';
|
||||
import { DialogService } from '@iptvnator/ui/components';
|
||||
import { BehaviorSubject, type Observable, Subject } from 'rxjs';
|
||||
import { DownloadLibraryNavigationService } from './download-library-navigation.service';
|
||||
import type { DownloadActionResult } from './download-actions';
|
||||
import { DownloadManagerActionsService } from './download-manager-actions.service';
|
||||
import type { DownloadSeriesCardViewModel } from './download-manager.viewmodel';
|
||||
import { DownloadsComponent } from './downloads.component';
|
||||
@@ -54,7 +55,7 @@ interface ExpectedDownloadsComponent {
|
||||
runAction(action: {
|
||||
readonly type: string;
|
||||
readonly item: DownloadItem;
|
||||
}): Promise<void> | void;
|
||||
}): Promise<DownloadActionResult> | void;
|
||||
setFilter(filter: string): void;
|
||||
}
|
||||
|
||||
@@ -553,7 +554,7 @@ describe('DownloadsComponent', () => {
|
||||
|
||||
expect(component.pendingIds().has(8)).toBe(true);
|
||||
operation.resolve({ success: true });
|
||||
await action;
|
||||
await expect(action).resolves.toBe('success');
|
||||
expect(component.pendingIds().has(8)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -566,8 +567,9 @@ describe('DownloadsComponent', () => {
|
||||
const second = component.runAction({ type: 'pause', item });
|
||||
|
||||
expect(downloadsService.pauseDownload).toHaveBeenCalledTimes(1);
|
||||
await expect(second).resolves.toBe('ignored');
|
||||
operation.resolve({ success: true });
|
||||
await Promise.all([first, second]);
|
||||
await expect(first).resolves.toBe('success');
|
||||
});
|
||||
|
||||
it('reports operation failures through the existing snackbar path', async () => {
|
||||
@@ -576,10 +578,12 @@ describe('DownloadsComponent', () => {
|
||||
error: 'network offline',
|
||||
});
|
||||
|
||||
await component.runAction({
|
||||
type: 'retry',
|
||||
item: download(10, { status: 'failed' }),
|
||||
});
|
||||
await expect(
|
||||
component.runAction({
|
||||
type: 'retry',
|
||||
item: download(10, { status: 'failed' }),
|
||||
})
|
||||
).resolves.toBe('failed');
|
||||
|
||||
expect(snackBar.open).toHaveBeenCalledWith(
|
||||
'Action failed: network offline',
|
||||
@@ -659,7 +663,7 @@ describe('DownloadsComponent', () => {
|
||||
expect(snackBar.open).not.toHaveBeenCalled();
|
||||
|
||||
reload.resolve();
|
||||
await action;
|
||||
await expect(action).resolves.toBe('file-missing');
|
||||
|
||||
expect(component.pendingIds().has(17)).toBe(false);
|
||||
expect(snackBar.open).toHaveBeenCalledWith(
|
||||
@@ -669,6 +673,22 @@ describe('DownloadsComponent', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('reports a missing file path as a typed file-missing result', async () => {
|
||||
await expect(
|
||||
component.runAction({
|
||||
type: 'reveal',
|
||||
item: download(18, { filePath: undefined }),
|
||||
})
|
||||
).resolves.toBe('file-missing');
|
||||
|
||||
expect(downloadsService.revealFile).not.toHaveBeenCalled();
|
||||
expect(snackBar.open).toHaveBeenCalledWith(
|
||||
'File not found',
|
||||
undefined,
|
||||
expect.objectContaining({ duration: 3000 })
|
||||
);
|
||||
});
|
||||
|
||||
it('routes dialog episode actions through the shared dispatcher and cleans up', async () => {
|
||||
const item = download(11, {
|
||||
contentType: 'episode',
|
||||
|
||||
@@ -28,7 +28,10 @@ import {
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import type { Playlist, XtreamCategory } from '@iptvnator/shared/interfaces';
|
||||
import { map, startWith, take } from 'rxjs';
|
||||
import type { DownloadItemAction } from './download-actions';
|
||||
import type {
|
||||
DownloadActionResult,
|
||||
DownloadItemAction,
|
||||
} from './download-actions';
|
||||
import { DownloadLibraryNavigationService } from './download-library-navigation.service';
|
||||
import { DownloadLibraryComponent } from './download-library.component';
|
||||
import { DownloadManagerActionsService } from './download-manager-actions.service';
|
||||
@@ -192,8 +195,8 @@ export class DownloadsComponent {
|
||||
return this.downloadsService.formatBytes(bytes);
|
||||
}
|
||||
|
||||
async runAction(action: DownloadItemAction): Promise<void> {
|
||||
await this.actions.run(action);
|
||||
async runAction(action: DownloadItemAction): Promise<DownloadActionResult> {
|
||||
return this.actions.run(action);
|
||||
}
|
||||
|
||||
clearFinished(): void {
|
||||
|
||||
+87
-18
@@ -4,6 +4,7 @@
|
||||
[description]="description()"
|
||||
[posterUrl]="posterUrl()"
|
||||
[backdropUrl]="backdropUrl()"
|
||||
[backLabel]="'DOWNLOADS.OFFLINE_DETAIL.BACK' | translate"
|
||||
(backClicked)="goBack()"
|
||||
>
|
||||
<ng-template appDetailTags>
|
||||
@@ -43,10 +44,7 @@
|
||||
<div class="details__meta-item offline-detail__people-group">
|
||||
<span class="label">{{ 'XTREAM.ACTORS' | translate }}</span>
|
||||
<div class="details__cast-strip">
|
||||
@for (
|
||||
person of cast();
|
||||
track person.tmdbPersonId ?? person.name
|
||||
) {
|
||||
@for (person of cast(); track personTrackKey(person)) {
|
||||
<span
|
||||
class="details__cast-chip offline-detail__person"
|
||||
[title]="
|
||||
@@ -86,7 +84,7 @@
|
||||
<div class="details__cast-strip">
|
||||
@for (
|
||||
person of creators();
|
||||
track person.tmdbPersonId ?? person.name
|
||||
track personTrackKey(person)
|
||||
) {
|
||||
<span
|
||||
class="details__cast-chip offline-detail__person"
|
||||
@@ -144,22 +142,31 @@
|
||||
|
||||
<span
|
||||
class="offline-detail__portal-action"
|
||||
[attr.tabindex]="providerUnavailable() ? 0 : null"
|
||||
[attr.aria-describedby]="
|
||||
providerUnavailable()
|
||||
? 'offline-view-in-portal-unavailable'
|
||||
: null
|
||||
"
|
||||
[matTooltip]="
|
||||
canOpenInPortal()
|
||||
? ''
|
||||
: ('DOWNLOADS.OFFLINE_DETAIL.VIEW_IN_PORTAL_UNAVAILABLE'
|
||||
providerUnavailable()
|
||||
? ('DOWNLOADS.OFFLINE_DETAIL.VIEW_IN_PORTAL_UNAVAILABLE'
|
||||
| translate)
|
||||
: ''
|
||||
"
|
||||
>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
data-testid="view-in-portal"
|
||||
[disabled]="!canOpenInPortal()"
|
||||
[disabled]="!canOpenInPortal() || providerPending()"
|
||||
[attr.aria-busy]="
|
||||
providerLoading() || providerPending() ? true : null
|
||||
"
|
||||
[attr.aria-describedby]="
|
||||
canOpenInPortal()
|
||||
? null
|
||||
: 'offline-view-in-portal-unavailable'
|
||||
providerUnavailable()
|
||||
? 'offline-view-in-portal-unavailable'
|
||||
: null
|
||||
"
|
||||
(click)="viewInPortal()"
|
||||
>
|
||||
@@ -167,7 +174,7 @@
|
||||
{{ 'DOWNLOADS.OFFLINE_DETAIL.VIEW_IN_PORTAL' | translate }}
|
||||
</button>
|
||||
</span>
|
||||
@if (!canOpenInPortal()) {
|
||||
@if (providerUnavailable()) {
|
||||
<span
|
||||
id="offline-view-in-portal-unavailable"
|
||||
class="offline-detail__visually-hidden"
|
||||
@@ -222,7 +229,8 @@
|
||||
<div class="offline-detail__season-tabs" role="tablist">
|
||||
@for (
|
||||
season of offlineDetail.seasons;
|
||||
track seasonTestId(season)
|
||||
track seasonTestId(season);
|
||||
let seasonIndex = $index
|
||||
) {
|
||||
<button
|
||||
type="button"
|
||||
@@ -232,11 +240,14 @@
|
||||
isSelectedSeason(season)
|
||||
"
|
||||
[attr.aria-selected]="isSelectedSeason(season)"
|
||||
[attr.tabindex]="seasonTabIndex(season)"
|
||||
[id]="seasonTabId(season)"
|
||||
[attr.aria-controls]="
|
||||
seasonTestId(season) + '-panel'
|
||||
"
|
||||
[attr.data-testid]="seasonTestId(season)"
|
||||
(click)="selectSeason(season)"
|
||||
(keydown)="onSeasonKeydown($event, seasonIndex)"
|
||||
>
|
||||
<span>
|
||||
@if (season.seasonNumberState === 'known') {
|
||||
@@ -267,6 +278,7 @@
|
||||
role="tabpanel"
|
||||
class="offline-detail__episode-list"
|
||||
[id]="seasonTestId(season) + '-panel'"
|
||||
[attr.aria-labelledby]="seasonTabId(season)"
|
||||
>
|
||||
@for (
|
||||
episode of season.episodes;
|
||||
@@ -330,6 +342,20 @@
|
||||
'episode-play-' + episode.item.id
|
||||
"
|
||||
[disabled]="isPending(episode.item)"
|
||||
[attr.aria-label]="
|
||||
('DOWNLOADS.OFFLINE_DETAIL.PLAY'
|
||||
| translate) +
|
||||
': ' +
|
||||
episodeCoordinate(
|
||||
episode.seasonNumber,
|
||||
episode.episodeNumber
|
||||
) +
|
||||
' ' +
|
||||
episodeTitle(
|
||||
episode.item,
|
||||
episode.episodeMetadata?.title
|
||||
)
|
||||
"
|
||||
(click)="
|
||||
runFileAction('play', episode.item)
|
||||
"
|
||||
@@ -369,7 +395,26 @@
|
||||
<button
|
||||
mat-menu-item
|
||||
type="button"
|
||||
[attr.data-testid]="
|
||||
'episode-reveal-' +
|
||||
episode.item.id
|
||||
"
|
||||
[disabled]="isPending(episode.item)"
|
||||
[attr.aria-label]="
|
||||
('DOWNLOADS.REVEAL'
|
||||
| translate) +
|
||||
': ' +
|
||||
episodeCoordinate(
|
||||
episode.seasonNumber,
|
||||
episode.episodeNumber
|
||||
) +
|
||||
' ' +
|
||||
episodeTitle(
|
||||
episode.item,
|
||||
episode.episodeMetadata
|
||||
?.title
|
||||
)
|
||||
"
|
||||
(click)="
|
||||
runFileAction(
|
||||
'reveal',
|
||||
@@ -396,14 +441,38 @@
|
||||
} @else {
|
||||
<app-portal-detail-shell
|
||||
[isLoading]="showLoading()"
|
||||
[backLabel]="'DOWNLOADS.OFFLINE_DETAIL.BACK' | translate"
|
||||
[errorMessage]="
|
||||
showNotFound()
|
||||
? ('DOWNLOADS.OFFLINE_DETAIL.NOT_FOUND_TITLE' | translate)
|
||||
: undefined
|
||||
redirectFailed()
|
||||
? ('DOWNLOADS.FILE_NOT_FOUND' | translate)
|
||||
: showNotFound()
|
||||
? ('DOWNLOADS.OFFLINE_DETAIL.NOT_FOUND_TITLE' | translate)
|
||||
: undefined
|
||||
"
|
||||
(backClicked)="goBack()"
|
||||
>
|
||||
@if (showNotFound()) {
|
||||
@if (redirectFailed()) {
|
||||
<div hero-error-actions class="offline-detail__not-found-actions">
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
data-testid="redirect-back"
|
||||
(click)="goBack()"
|
||||
>
|
||||
<mat-icon aria-hidden="true">arrow_back</mat-icon>
|
||||
{{ 'DOWNLOADS.OFFLINE_DETAIL.BACK' | translate }}
|
||||
</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
type="button"
|
||||
data-testid="redirect-retry"
|
||||
(click)="retryRedirect()"
|
||||
>
|
||||
<mat-icon aria-hidden="true">refresh</mat-icon>
|
||||
{{ 'DOWNLOADS.RETRY' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
} @else if (showNotFound()) {
|
||||
<div hero-error-actions class="offline-detail__not-found-actions">
|
||||
<p>
|
||||
{{ 'DOWNLOADS.OFFLINE_DETAIL.NOT_FOUND_BODY' | translate }}
|
||||
|
||||
+18
-2
@@ -87,8 +87,24 @@ button,
|
||||
--app-selection-border,
|
||||
var(--mat-sys-primary, var(--text-primary))
|
||||
);
|
||||
background: var(--mat-sys-secondary-container, var(--tag-bg));
|
||||
color: var(--mat-sys-on-secondary-container, var(--text-primary));
|
||||
background: var(
|
||||
--app-selection-surface,
|
||||
var(--mat-sys-secondary-container, var(--tag-bg))
|
||||
);
|
||||
color: var(
|
||||
--app-selection-color,
|
||||
var(--mat-sys-on-secondary-container, var(--text-primary))
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px var(--app-selection-border, var(--mat-sys-primary)),
|
||||
0 12px 20px -18px var(--app-selection-glow, var(--mat-sys-primary));
|
||||
|
||||
&:hover {
|
||||
background: var(
|
||||
--app-selection-surface-strong,
|
||||
var(--mat-sys-secondary-container, var(--tag-bg))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
|
||||
+419
-38
@@ -7,18 +7,20 @@ import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
type DownloadItem,
|
||||
DownloadsService,
|
||||
PlaylistsService,
|
||||
SettingsStore,
|
||||
} from '@iptvnator/services';
|
||||
import type {
|
||||
DownloadMetadataSnapshot,
|
||||
Playlist,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { BehaviorSubject, type Observable, throwError } from 'rxjs';
|
||||
import type { DownloadMetadataSnapshot } from '@iptvnator/shared/interfaces';
|
||||
import type { WorkspaceNavigationTarget } from '@iptvnator/portal/shared/util';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import type { DownloadActionResult } from '../download-actions';
|
||||
import { DownloadLibraryNavigationService } from '../download-library-navigation.service';
|
||||
import { DownloadManagerActionsService } from '../download-manager-actions.service';
|
||||
import { DownloadOfflineDetailComponent } from './download-offline-detail.component';
|
||||
import { DownloadOfflineFileCoordinatorService } from './download-offline-file-coordinator.service';
|
||||
import { DownloadOfflineMetadataResolutionService } from './download-offline-metadata-resolution.service';
|
||||
import { DownloadOfflineMetadataService } from './download-offline-metadata.service';
|
||||
import { DownloadOfflineProviderCoordinatorService } from './download-offline-provider-coordinator.service';
|
||||
import { DownloadOfflineSeasonSelectionService } from './download-offline-season-selection.service';
|
||||
import { DownloadOfflineRouteNavigationService } from './download-offline-route-navigation.service';
|
||||
|
||||
const TRANSLATIONS = {
|
||||
@@ -38,6 +40,9 @@ const TRANSLATIONS = {
|
||||
NOT_FOUND_BODY: 'This download is no longer in the manager.',
|
||||
BACK: 'Back to Downloads',
|
||||
},
|
||||
FILE_ACTION_ERROR: 'The file action could not be completed.',
|
||||
FILE_NOT_FOUND: 'This downloaded file is no longer available on disk.',
|
||||
RETRY: 'Retry',
|
||||
},
|
||||
XTREAM: {
|
||||
ACTORS: 'Cast',
|
||||
@@ -62,7 +67,7 @@ interface DownloadsFake {
|
||||
|
||||
interface ActionsFake {
|
||||
readonly pendingIds: ReturnType<typeof signal<ReadonlySet<number>>>;
|
||||
readonly run: jest.Mock<Promise<void>, [unknown]>;
|
||||
readonly run: jest.Mock<Promise<DownloadActionResult>, [unknown]>;
|
||||
readonly showActionError: jest.Mock<void, []>;
|
||||
}
|
||||
|
||||
@@ -107,20 +112,24 @@ function download(
|
||||
};
|
||||
}
|
||||
|
||||
function playlist(id: string): Playlist {
|
||||
return { _id: id, title: id } as Playlist;
|
||||
}
|
||||
const PROVIDER_TARGET: WorkspaceNavigationTarget = {
|
||||
link: ['/workspace', 'xtreams', 'playlist-a', 'vod', '7', '1017'],
|
||||
};
|
||||
|
||||
describe('DownloadOfflineDetailComponent', () => {
|
||||
let fixture: ComponentFixture<DownloadOfflineDetailComponent>;
|
||||
let routeParams: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
|
||||
let playlistItems: BehaviorSubject<Playlist[]>;
|
||||
let playlistsObservable: Observable<Playlist[]>;
|
||||
let downloads: DownloadsFake;
|
||||
let actions: ActionsFake;
|
||||
let navigation: {
|
||||
canOpen: jest.Mock<boolean, [DownloadItem, ReadonlySet<string>]>;
|
||||
open: jest.Mock<Promise<boolean>, [DownloadItem]>;
|
||||
resolveProviderTarget: jest.Mock<
|
||||
Promise<WorkspaceNavigationTarget | null>,
|
||||
[DownloadItem]
|
||||
>;
|
||||
navigateResolvedTarget: jest.Mock<
|
||||
Promise<boolean>,
|
||||
[WorkspaceNavigationTarget]
|
||||
>;
|
||||
};
|
||||
let metadata: {
|
||||
resolve: jest.Mock<Promise<DownloadMetadataSnapshot>, [unknown]>;
|
||||
@@ -137,8 +146,6 @@ describe('DownloadOfflineDetailComponent', () => {
|
||||
routeParams = new BehaviorSubject(
|
||||
convertToParamMap({ downloadId: '17' })
|
||||
);
|
||||
playlistItems = new BehaviorSubject([playlist('playlist-a')]);
|
||||
playlistsObservable = playlistItems.asObservable();
|
||||
downloads = {
|
||||
downloads: signal<DownloadItem[]>([]),
|
||||
isLoadingDownloads: signal(false),
|
||||
@@ -148,14 +155,12 @@ describe('DownloadOfflineDetailComponent', () => {
|
||||
};
|
||||
actions = {
|
||||
pendingIds: signal<ReadonlySet<number>>(new Set()),
|
||||
run: jest.fn().mockResolvedValue(undefined),
|
||||
run: jest.fn().mockResolvedValue('success'),
|
||||
showActionError: jest.fn(),
|
||||
};
|
||||
navigation = {
|
||||
canOpen: jest.fn((item, availableIds) =>
|
||||
availableIds.has(item.playlistId)
|
||||
),
|
||||
open: jest.fn().mockResolvedValue(true),
|
||||
resolveProviderTarget: jest.fn().mockResolvedValue(PROVIDER_TARGET),
|
||||
navigateResolvedTarget: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
metadata = {
|
||||
resolve: jest.fn(async (detail: unknown) => {
|
||||
@@ -206,10 +211,8 @@ describe('DownloadOfflineDetailComponent', () => {
|
||||
useValue: downloads,
|
||||
},
|
||||
{
|
||||
provide: PlaylistsService,
|
||||
useValue: {
|
||||
getAllPlaylists: () => playlistsObservable,
|
||||
},
|
||||
provide: SettingsStore,
|
||||
useValue: { language: signal('en') },
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -228,8 +231,11 @@ describe('DownloadOfflineDetailComponent', () => {
|
||||
provide: DownloadOfflineMetadataService,
|
||||
useValue: metadata,
|
||||
},
|
||||
DownloadOfflineFileCoordinatorService,
|
||||
DownloadOfflineMetadataResolutionService,
|
||||
DownloadOfflineProviderCoordinatorService,
|
||||
DownloadOfflineRouteNavigationService,
|
||||
DownloadOfflineSeasonSelectionService,
|
||||
],
|
||||
},
|
||||
})
|
||||
@@ -316,6 +322,11 @@ describe('DownloadOfflineDetailComponent', () => {
|
||||
expect(button('view-in-portal').textContent).toContain(
|
||||
'View in portal'
|
||||
);
|
||||
expect(
|
||||
(fixture.nativeElement as HTMLElement)
|
||||
.querySelector('.offline-detail__portal-action')
|
||||
?.hasAttribute('tabindex')
|
||||
).toBe(false);
|
||||
expect(button('movie-overflow')).toBeTruthy();
|
||||
expect(text()).not.toContain('Play from source');
|
||||
});
|
||||
@@ -389,6 +400,12 @@ describe('DownloadOfflineDetailComponent', () => {
|
||||
) as HTMLButtonElement[];
|
||||
expect(seasons).toHaveLength(2);
|
||||
expect(seasons[0].getAttribute('aria-selected')).toBe('true');
|
||||
expect(seasons[0].tabIndex).toBe(0);
|
||||
expect(seasons[1].tabIndex).toBe(-1);
|
||||
expect(seasons[0].id).toBe('offline-season-1-tab');
|
||||
expect(seasons[0].getAttribute('aria-controls')).toBe(
|
||||
'offline-season-1-panel'
|
||||
);
|
||||
expect(seasons[0].textContent).toContain('2 downloaded episodes');
|
||||
const firstSeasonEpisodes = Array.from(
|
||||
(fixture.nativeElement as HTMLElement).querySelectorAll(
|
||||
@@ -404,7 +421,15 @@ describe('DownloadOfflineDetailComponent', () => {
|
||||
'Available offline'
|
||||
);
|
||||
expect(button('episode-play-19')).toBeTruthy();
|
||||
expect(button('episode-play-19').getAttribute('aria-label')).toBe(
|
||||
'Play offline: S01E01 Arrival'
|
||||
);
|
||||
expect(button('episode-overflow-19')).toBeTruthy();
|
||||
button('episode-overflow-19').click();
|
||||
await fixture.whenStable();
|
||||
expect(button('episode-reveal-19').getAttribute('aria-label')).toBe(
|
||||
'Show in folder: S01E01 Arrival'
|
||||
);
|
||||
|
||||
seasons[1].click();
|
||||
await fixture.whenStable();
|
||||
@@ -412,6 +437,109 @@ describe('DownloadOfflineDetailComponent', () => {
|
||||
expect(seasons[1].getAttribute('aria-selected')).toBe('true');
|
||||
expect(text()).toContain('S02E01');
|
||||
expect(text()).not.toContain('Missing');
|
||||
const panel = (fixture.nativeElement as HTMLElement).querySelector(
|
||||
'[role="tabpanel"]'
|
||||
);
|
||||
expect(panel?.getAttribute('aria-labelledby')).toBe(
|
||||
'offline-season-2-tab'
|
||||
);
|
||||
});
|
||||
|
||||
it('moves season selection and focus with tablist keyboard controls', async () => {
|
||||
const episode = (id: number, seasonNumber: number) =>
|
||||
download(id, {
|
||||
contentType: 'episode',
|
||||
episodeNumber: 1,
|
||||
seasonNumber,
|
||||
seriesXtreamId: 77,
|
||||
title: `Northwind season ${seasonNumber}`,
|
||||
});
|
||||
await render([episode(17, 1), episode(18, 2)]);
|
||||
const tabs = () =>
|
||||
Array.from(
|
||||
(fixture.nativeElement as HTMLElement).querySelectorAll(
|
||||
'[role="tab"]'
|
||||
)
|
||||
) as HTMLButtonElement[];
|
||||
|
||||
for (const [key, expected] of [
|
||||
['ArrowRight', 1],
|
||||
['ArrowLeft', 0],
|
||||
['ArrowDown', 1],
|
||||
['ArrowUp', 0],
|
||||
['End', 1],
|
||||
['Home', 0],
|
||||
] as const) {
|
||||
const active = tabs().find(
|
||||
(tab) => tab.getAttribute('aria-selected') === 'true'
|
||||
);
|
||||
if (!active) throw new Error('Expected one selected season tab');
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
active.dispatchEvent(event);
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(tabs()[expected].getAttribute('aria-selected')).toBe('true');
|
||||
expect(tabs()[expected].tabIndex).toBe(0);
|
||||
expect(document.activeElement).toBe(tabs()[expected]);
|
||||
}
|
||||
});
|
||||
|
||||
it('persists the first-season fallback when a selected season disappears and later returns', async () => {
|
||||
const episode = (id: number, seasonNumber: number) =>
|
||||
download(id, {
|
||||
contentType: 'episode',
|
||||
episodeNumber: 1,
|
||||
seasonNumber,
|
||||
seriesXtreamId: 77,
|
||||
title: `Northwind season ${seasonNumber}`,
|
||||
});
|
||||
const first = episode(17, 1);
|
||||
const second = episode(18, 2);
|
||||
await render([first, second]);
|
||||
button('offline-season-2').click();
|
||||
await fixture.whenStable();
|
||||
|
||||
downloads.downloads.set([first]);
|
||||
await fixture.whenStable();
|
||||
expect(button('offline-season-1').getAttribute('aria-selected')).toBe(
|
||||
'true'
|
||||
);
|
||||
|
||||
downloads.downloads.set([first, second]);
|
||||
await fixture.whenStable();
|
||||
expect(button('offline-season-1').getAttribute('aria-selected')).toBe(
|
||||
'true'
|
||||
);
|
||||
expect(button('offline-season-2').getAttribute('aria-selected')).toBe(
|
||||
'false'
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes season selection when the route is reused within one series', async () => {
|
||||
const episode = (id: number, seasonNumber: number) =>
|
||||
download(id, {
|
||||
contentType: 'episode',
|
||||
episodeNumber: 1,
|
||||
seasonNumber,
|
||||
seriesXtreamId: 77,
|
||||
title: `Northwind season ${seasonNumber}`,
|
||||
});
|
||||
await render([episode(17, 1), episode(18, 2)]);
|
||||
button('offline-season-2').click();
|
||||
await fixture.whenStable();
|
||||
|
||||
routeParams.next(convertToParamMap({ downloadId: '18' }));
|
||||
router.url = '/workspace/downloads/18';
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(button('offline-season-1').getAttribute('aria-selected')).toBe(
|
||||
'true'
|
||||
);
|
||||
});
|
||||
|
||||
it('shows a focused not-found state when the row is absent after the initial load', async () => {
|
||||
@@ -427,10 +555,11 @@ describe('DownloadOfflineDetailComponent', () => {
|
||||
});
|
||||
|
||||
it('keeps View in portal visible but disabled and described when its source playlist is missing', async () => {
|
||||
playlistItems.next([]);
|
||||
navigation.canOpen.mockReturnValue(false);
|
||||
navigation.resolveProviderTarget.mockResolvedValueOnce(null);
|
||||
|
||||
await render([download(17, { title: 'Orphaned movie' })]);
|
||||
await Promise.resolve();
|
||||
fixture.detectChanges();
|
||||
|
||||
const viewButton = button('view-in-portal');
|
||||
expect(viewButton.disabled).toBe(true);
|
||||
@@ -438,18 +567,27 @@ describe('DownloadOfflineDetailComponent', () => {
|
||||
'offline-view-in-portal-unavailable'
|
||||
);
|
||||
expect(text()).toContain('The source is unavailable');
|
||||
expect(navigation.canOpen).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 17 }),
|
||||
new Set()
|
||||
const wrapper = (fixture.nativeElement as HTMLElement).querySelector(
|
||||
'.offline-detail__portal-action'
|
||||
) as HTMLElement;
|
||||
expect(wrapper.getAttribute('tabindex')).toBe('0');
|
||||
expect(wrapper.getAttribute('aria-describedby')?.split(' ')).toContain(
|
||||
'offline-view-in-portal-unavailable'
|
||||
);
|
||||
expect(wrapper.querySelector('button:not([disabled])')).toBeNull();
|
||||
expect(navigation.resolveProviderTarget).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 17 })
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps local playback available when loading source playlists fails', async () => {
|
||||
playlistsObservable = throwError(
|
||||
() => new Error('playlist storage unavailable')
|
||||
it('keeps local playback available when provider resolution fails', async () => {
|
||||
navigation.resolveProviderTarget.mockRejectedValueOnce(
|
||||
new Error('playlist storage unavailable')
|
||||
);
|
||||
|
||||
await render([download(17, { title: 'Offline survivor' })]);
|
||||
await Promise.resolve();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(text()).toContain('Offline survivor');
|
||||
expect(button('offline-play').disabled).toBe(false);
|
||||
@@ -478,7 +616,7 @@ describe('DownloadOfflineDetailComponent', () => {
|
||||
it.each(['play', 'reveal'] as const)(
|
||||
'returns to the manager when a local %s race reloads the current file as missing',
|
||||
async (actionType) => {
|
||||
const operation = deferred<void>();
|
||||
const operation = deferred<DownloadActionResult>();
|
||||
actions.run.mockReturnValueOnce(operation.promise);
|
||||
await render([download(17), download(18, { title: 'Other' })]);
|
||||
|
||||
@@ -498,7 +636,8 @@ describe('DownloadOfflineDetailComponent', () => {
|
||||
download(17, { fileAvailability: 'missing' }),
|
||||
download(18, { title: 'Other' }),
|
||||
]);
|
||||
operation.resolve(undefined);
|
||||
expect(router.navigate).not.toHaveBeenCalled();
|
||||
operation.resolve('file-missing');
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(actions.run).toHaveBeenCalledWith(
|
||||
@@ -512,9 +651,107 @@ describe('DownloadOfflineDetailComponent', () => {
|
||||
queryParamsHandling: 'preserve',
|
||||
replaceUrl: true,
|
||||
});
|
||||
downloads.downloads.set([
|
||||
download(17, { fileAvailability: 'missing' }),
|
||||
]);
|
||||
await fixture.whenStable();
|
||||
expect(router.navigate).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
it('does not redirect when a failed file action coincides with a missing-row emission', async () => {
|
||||
const operation = deferred<DownloadActionResult>();
|
||||
actions.run.mockReturnValueOnce(operation.promise);
|
||||
await render([download(17)]);
|
||||
|
||||
button('offline-play').click();
|
||||
downloads.downloads.set([
|
||||
download(17, { fileAvailability: 'missing' }),
|
||||
]);
|
||||
await fixture.whenStable();
|
||||
expect(router.navigate).not.toHaveBeenCalled();
|
||||
|
||||
operation.resolve('failed');
|
||||
await fixture.whenStable();
|
||||
expect(router.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not let an older episode action redirect a reused route in the same series', async () => {
|
||||
const operation = deferred<DownloadActionResult>();
|
||||
actions.run.mockReturnValueOnce(operation.promise);
|
||||
const episode = (id: number, episodeNumber: number) =>
|
||||
download(id, {
|
||||
contentType: 'episode',
|
||||
episodeNumber,
|
||||
seasonNumber: 1,
|
||||
seriesXtreamId: 77,
|
||||
title: `Northwind - S01E0${episodeNumber}`,
|
||||
});
|
||||
await render([episode(17, 1), episode(18, 2)]);
|
||||
|
||||
button('episode-play-17').click();
|
||||
routeParams.next(convertToParamMap({ downloadId: '18' }));
|
||||
router.url = '/workspace/downloads/18';
|
||||
await fixture.whenStable();
|
||||
operation.resolve('file-missing');
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(text()).toContain('Northwind');
|
||||
expect(router.navigate).not.toHaveBeenCalled();
|
||||
expect(router.navigateByUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders a retryable missing-file error when the redirect returns false', async () => {
|
||||
router.navigate
|
||||
.mockResolvedValueOnce(false)
|
||||
.mockResolvedValueOnce(true);
|
||||
|
||||
await render([download(17, { fileAvailability: 'missing' })]);
|
||||
await Promise.resolve();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(text()).toContain(
|
||||
'This downloaded file is no longer available on disk.'
|
||||
);
|
||||
expect(button('redirect-back').textContent).toContain(
|
||||
'Back to Downloads'
|
||||
);
|
||||
button('redirect-retry').click();
|
||||
await fixture.whenStable();
|
||||
expect(router.navigate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('renders the retryable missing-file error when redirect navigation rejects', async () => {
|
||||
router.navigate.mockRejectedValueOnce(new Error('router unavailable'));
|
||||
|
||||
await render([download(17, { fileAvailability: 'missing' })]);
|
||||
await Promise.resolve();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(text()).toContain(
|
||||
'This downloaded file is no longer available on disk.'
|
||||
);
|
||||
expect(button('redirect-retry').textContent).toContain('Retry');
|
||||
});
|
||||
|
||||
it('ignores an older redirect failure after route reuse', async () => {
|
||||
const redirect = deferred<boolean>();
|
||||
router.navigate.mockReturnValueOnce(redirect.promise);
|
||||
await render([download(17, { fileAvailability: 'missing' })]);
|
||||
|
||||
routeParams.next(convertToParamMap({ downloadId: '18' }));
|
||||
router.url = '/workspace/downloads/18';
|
||||
downloads.downloads.set([download(18, { title: 'Current route' })]);
|
||||
await fixture.whenStable();
|
||||
redirect.resolve(false);
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(text()).toContain('Current route');
|
||||
expect(text()).not.toContain(
|
||||
'This downloaded file is no longer available on disk.'
|
||||
);
|
||||
});
|
||||
|
||||
it('reacts to route reuse and displays the new row before metadata resolution completes', async () => {
|
||||
const secondResolution = deferred<DownloadMetadataSnapshot>();
|
||||
metadata.resolve
|
||||
@@ -557,20 +794,164 @@ describe('DownloadOfflineDetailComponent', () => {
|
||||
expect(text()).not.toContain('Stale first');
|
||||
});
|
||||
|
||||
it('does not restart metadata resolution for unrelated download emissions', async () => {
|
||||
const resolution = deferred<DownloadMetadataSnapshot>();
|
||||
const current = download(17, { title: 'Stable local title' });
|
||||
const unrelated = download(18, { title: 'Other download' });
|
||||
metadata.resolve.mockReturnValueOnce(resolution.promise);
|
||||
await render([current, unrelated]);
|
||||
|
||||
downloads.downloads.set([
|
||||
{ ...current, bytesDownloaded: 512 },
|
||||
{ ...unrelated, bytesDownloaded: 256 },
|
||||
]);
|
||||
await fixture.whenStable();
|
||||
downloads.downloads.set([
|
||||
{ ...current, bytesDownloaded: 768 },
|
||||
{ ...unrelated, bytesDownloaded: 512 },
|
||||
]);
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(metadata.resolve).toHaveBeenCalledTimes(1);
|
||||
resolution.resolve(snapshot('movie', 'Stable resolved title'));
|
||||
await fixture.whenStable();
|
||||
});
|
||||
|
||||
it('restarts metadata resolution when the current persisted snapshot changes', async () => {
|
||||
const firstResolution = deferred<DownloadMetadataSnapshot>();
|
||||
const secondResolution = deferred<DownloadMetadataSnapshot>();
|
||||
const current = download(17, { title: 'Local title' });
|
||||
metadata.resolve
|
||||
.mockReturnValueOnce(firstResolution.promise)
|
||||
.mockReturnValueOnce(secondResolution.promise);
|
||||
await render([current]);
|
||||
|
||||
downloads.downloads.set([
|
||||
{
|
||||
...current,
|
||||
metadataSnapshot: snapshot('movie', 'Persisted replacement'),
|
||||
},
|
||||
]);
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(metadata.resolve).toHaveBeenCalledTimes(2);
|
||||
secondResolution.resolve(snapshot('movie', 'Fresh replacement'));
|
||||
await fixture.whenStable();
|
||||
firstResolution.resolve(snapshot('movie', 'Stale result'));
|
||||
await fixture.whenStable();
|
||||
expect(text()).toContain('Fresh replacement');
|
||||
expect(text()).not.toContain('Stale result');
|
||||
});
|
||||
|
||||
it('opens the current provider target and reports a navigation failure without blocking local playback', async () => {
|
||||
navigation.open.mockResolvedValueOnce(false);
|
||||
navigation.navigateResolvedTarget.mockResolvedValueOnce(false);
|
||||
await render([download(17)]);
|
||||
|
||||
button('view-in-portal').click();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(navigation.open).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 17 })
|
||||
expect(navigation.navigateResolvedTarget).toHaveBeenCalledWith(
|
||||
PROVIDER_TARGET
|
||||
);
|
||||
expect(actions.showActionError).toHaveBeenCalledTimes(1);
|
||||
expect(button('offline-play').disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the provider action disabled while its exact target is resolving', async () => {
|
||||
const resolution = deferred<WorkspaceNavigationTarget | null>();
|
||||
navigation.resolveProviderTarget.mockReturnValueOnce(
|
||||
resolution.promise
|
||||
);
|
||||
await render([download(17)]);
|
||||
|
||||
expect(button('view-in-portal').disabled).toBe(true);
|
||||
expect(button('view-in-portal').getAttribute('aria-busy')).toBe('true');
|
||||
expect(text()).not.toContain('The source is unavailable');
|
||||
|
||||
resolution.resolve(PROVIDER_TARGET);
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
expect(button('view-in-portal').disabled).toBe(false);
|
||||
expect(button('view-in-portal').getAttribute('aria-busy')).toBeNull();
|
||||
});
|
||||
|
||||
it('uses one cached provider target and ignores duplicate open requests', async () => {
|
||||
const opening = deferred<boolean>();
|
||||
navigation.navigateResolvedTarget.mockReturnValueOnce(opening.promise);
|
||||
await render([download(17)]);
|
||||
|
||||
const first = fixture.componentInstance.viewInPortal();
|
||||
const duplicate = fixture.componentInstance.viewInPortal();
|
||||
|
||||
expect(navigation.resolveProviderTarget).toHaveBeenCalledTimes(1);
|
||||
expect(navigation.navigateResolvedTarget).toHaveBeenCalledTimes(1);
|
||||
await duplicate;
|
||||
opening.resolve(true);
|
||||
await first;
|
||||
});
|
||||
|
||||
it('ignores a failed provider navigation after the route is reused while opening', async () => {
|
||||
const opening = deferred<boolean>();
|
||||
navigation.navigateResolvedTarget.mockReturnValueOnce(opening.promise);
|
||||
await render([download(17), download(18, { title: 'Current route' })]);
|
||||
|
||||
const oldRouteAction = fixture.componentInstance.viewInPortal();
|
||||
routeParams.next(convertToParamMap({ downloadId: '18' }));
|
||||
router.url = '/workspace/downloads/18';
|
||||
await fixture.whenStable();
|
||||
opening.resolve(false);
|
||||
await oldRouteAction;
|
||||
|
||||
expect(text()).toContain('Current route');
|
||||
expect(actions.showActionError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not enable or navigate a stale provider target after route reuse', async () => {
|
||||
const first = deferred<WorkspaceNavigationTarget | null>();
|
||||
const second = deferred<WorkspaceNavigationTarget | null>();
|
||||
navigation.resolveProviderTarget
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockReturnValueOnce(second.promise);
|
||||
await render([download(17), download(18, { title: 'Current route' })]);
|
||||
|
||||
routeParams.next(convertToParamMap({ downloadId: '18' }));
|
||||
router.url = '/workspace/downloads/18';
|
||||
await fixture.whenStable();
|
||||
first.resolve(PROVIDER_TARGET);
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(button('view-in-portal').disabled).toBe(true);
|
||||
button('view-in-portal').click();
|
||||
expect(navigation.navigateResolvedTarget).not.toHaveBeenCalled();
|
||||
second.resolve(null);
|
||||
await fixture.whenStable();
|
||||
});
|
||||
|
||||
it.each(['false', 'reject'] as const)(
|
||||
'clears provider pending state when navigation returns %s',
|
||||
async (outcome) => {
|
||||
if (outcome === 'false') {
|
||||
navigation.navigateResolvedTarget.mockResolvedValueOnce(false);
|
||||
} else {
|
||||
navigation.navigateResolvedTarget.mockRejectedValueOnce(
|
||||
new Error('router rejected')
|
||||
);
|
||||
}
|
||||
await render([download(17)]);
|
||||
|
||||
button('view-in-portal').click();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(button('view-in-portal').disabled).toBe(false);
|
||||
expect(
|
||||
button('view-in-portal').getAttribute('aria-busy')
|
||||
).toBeNull();
|
||||
expect(actions.showActionError).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
it('uses browser Back only for a validated manager return URL', async () => {
|
||||
historyState = {
|
||||
navigationId: 9,
|
||||
|
||||
+86
-102
@@ -2,30 +2,21 @@ import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
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 { ActivatedRoute } from '@angular/router';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import {
|
||||
type DownloadItem,
|
||||
DownloadsService,
|
||||
PlaylistsService,
|
||||
} from '@iptvnator/services';
|
||||
import type { Playlist } from '@iptvnator/shared/interfaces';
|
||||
import { DownloadsService, SettingsStore } from '@iptvnator/services';
|
||||
import {
|
||||
DetailActionsTemplateDirective,
|
||||
DetailMetaTemplateDirective,
|
||||
DetailTagsTemplateDirective,
|
||||
PortalDetailShellComponent,
|
||||
} from '@iptvnator/ui/components';
|
||||
import { catchError, map, of, startWith } from 'rxjs';
|
||||
import { DownloadLibraryNavigationService } from '../download-library-navigation.service';
|
||||
import { DownloadManagerActionsService } from '../download-manager-actions.service';
|
||||
import {
|
||||
@@ -34,23 +25,24 @@ import {
|
||||
} from './download-offline-detail.viewmodel';
|
||||
import {
|
||||
boundedOfflinePeople,
|
||||
offlineDetailIdentity,
|
||||
offlineDetailRepresentative,
|
||||
offlineDurationLabel,
|
||||
offlineEpisodeCoordinate,
|
||||
offlineEpisodeCount,
|
||||
offlineEpisodeTitle,
|
||||
offlineFileByteCount,
|
||||
offlineHasLocalFile,
|
||||
offlineMetadataResolutionKey,
|
||||
offlinePersonTrackKey,
|
||||
offlinePositiveFinite,
|
||||
offlineSeasonKey,
|
||||
offlineSeasonTestId,
|
||||
parseOfflineDownloadId,
|
||||
type OfflineDetailItem,
|
||||
type OfflineSelectedSeason,
|
||||
} from './download-offline-detail.presentation';
|
||||
import { DownloadOfflineMetadataResolutionService } from './download-offline-metadata-resolution.service';
|
||||
import { DownloadOfflineRouteNavigationService } from './download-offline-route-navigation.service';
|
||||
import { DownloadOfflineFileCoordinatorService } from './download-offline-file-coordinator.service';
|
||||
import { DownloadOfflineProviderCoordinatorService } from './download-offline-provider-coordinator.service';
|
||||
import { createDownloadOfflineRouteContext } from './download-offline-route-context';
|
||||
import { DownloadOfflineSeasonSelectionService } from './download-offline-season-selection.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-download-offline-detail',
|
||||
@@ -74,38 +66,37 @@ import { DownloadOfflineRouteNavigationService } from './download-offline-route-
|
||||
providers: [
|
||||
DownloadLibraryNavigationService,
|
||||
DownloadManagerActionsService,
|
||||
DownloadOfflineFileCoordinatorService,
|
||||
DownloadOfflineMetadataResolutionService,
|
||||
DownloadOfflineProviderCoordinatorService,
|
||||
DownloadOfflineRouteNavigationService,
|
||||
DownloadOfflineSeasonSelectionService,
|
||||
],
|
||||
})
|
||||
export class DownloadOfflineDetailComponent {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly downloadsService = inject(DownloadsService);
|
||||
private readonly playlistsService = inject(PlaylistsService);
|
||||
private readonly settings = inject(SettingsStore);
|
||||
private readonly actions = inject(DownloadManagerActionsService);
|
||||
private readonly navigation = inject(DownloadLibraryNavigationService);
|
||||
private readonly metadataResolution = inject(
|
||||
DownloadOfflineMetadataResolutionService
|
||||
);
|
||||
private readonly routeNavigation = inject(
|
||||
DownloadOfflineRouteNavigationService
|
||||
);
|
||||
private readonly selectedSeasonState = signal<
|
||||
OfflineSelectedSeason | undefined
|
||||
>(undefined);
|
||||
private redirectedUnavailableId?: number;
|
||||
private readonly fileCoordinator = inject(
|
||||
DownloadOfflineFileCoordinatorService
|
||||
);
|
||||
private readonly providerCoordinator = inject(
|
||||
DownloadOfflineProviderCoordinatorService
|
||||
);
|
||||
private readonly seasonSelection = inject(
|
||||
DownloadOfflineSeasonSelectionService
|
||||
);
|
||||
|
||||
readonly pendingIds = this.actions.pendingIds;
|
||||
readonly downloadId = toSignal(
|
||||
this.route.paramMap.pipe(
|
||||
map((params) => parseOfflineDownloadId(params.get('downloadId')))
|
||||
),
|
||||
{
|
||||
initialValue: parseOfflineDownloadId(
|
||||
this.route.snapshot.paramMap.get('downloadId')
|
||||
),
|
||||
}
|
||||
);
|
||||
readonly routeContext = createDownloadOfflineRouteContext(this.route);
|
||||
readonly downloadId = computed(() => this.routeContext().downloadId);
|
||||
readonly detail = computed(() => {
|
||||
const downloadId = this.downloadId();
|
||||
return downloadId === undefined
|
||||
@@ -115,8 +106,12 @@ export class DownloadOfflineDetailComponent {
|
||||
downloads: this.downloadsService.downloads(),
|
||||
});
|
||||
});
|
||||
readonly currentIdentity = computed(() =>
|
||||
offlineDetailIdentity(this.detail())
|
||||
readonly metadataResolutionKey = computed(() =>
|
||||
offlineMetadataResolutionKey(
|
||||
this.detail(),
|
||||
this.downloadId(),
|
||||
this.settings.language()
|
||||
)
|
||||
);
|
||||
readonly currentItem = computed(() =>
|
||||
offlineDetailRepresentative(this.detail())
|
||||
@@ -130,7 +125,7 @@ export class DownloadOfflineDetailComponent {
|
||||
const detail = this.detail();
|
||||
if (!detail) return undefined;
|
||||
const resolved = this.metadataResolution.resolution();
|
||||
return resolved.identity === this.currentIdentity()
|
||||
return resolved.key === this.metadataResolutionKey()
|
||||
? (resolved.snapshot ?? detail.snapshot)
|
||||
: detail.snapshot;
|
||||
});
|
||||
@@ -154,27 +149,19 @@ export class DownloadOfflineDetailComponent {
|
||||
readonly creators = computed(() =>
|
||||
boundedOfflinePeople(this.metadata()?.creators)
|
||||
);
|
||||
readonly playlists = toSignal(
|
||||
this.playlistsService.getAllPlaylists().pipe(
|
||||
startWith(null),
|
||||
catchError(() => of([]))
|
||||
),
|
||||
{ initialValue: null as Playlist[] | null }
|
||||
readonly providerState = this.providerCoordinator.state;
|
||||
readonly canOpenInPortal = computed(
|
||||
() => this.providerState().status === 'available'
|
||||
);
|
||||
readonly availablePlaylistIds = computed(
|
||||
() => new Set((this.playlists() ?? []).map(({ _id }) => _id))
|
||||
readonly providerLoading = computed(
|
||||
() => this.providerState().status === 'loading'
|
||||
);
|
||||
readonly providerPending = computed(
|
||||
() => this.providerState().status === 'opening'
|
||||
);
|
||||
readonly providerUnavailable = computed(
|
||||
() => this.providerState().status === 'unavailable'
|
||||
);
|
||||
readonly canOpenInPortal = computed(() => {
|
||||
const item = this.currentItem();
|
||||
return (
|
||||
item !== undefined &&
|
||||
this.playlists() !== null &&
|
||||
this.navigation.canOpen(
|
||||
item as DownloadItem,
|
||||
this.availablePlaylistIds()
|
||||
)
|
||||
);
|
||||
});
|
||||
readonly showLoading = computed(
|
||||
() =>
|
||||
!this.detail() &&
|
||||
@@ -187,19 +174,17 @@ export class DownloadOfflineDetailComponent {
|
||||
!this.showLoading() &&
|
||||
(!this.selectedRow() || this.selectedRow()?.status !== 'completed')
|
||||
);
|
||||
readonly redirectFailed = computed(() =>
|
||||
this.fileCoordinator.isRedirectFailed(this.routeContext())
|
||||
);
|
||||
readonly seasons = computed(() => {
|
||||
const detail = this.detail();
|
||||
return detail?.kind === 'series' ? detail.seasons : [];
|
||||
});
|
||||
readonly selectedSeason = computed(() => {
|
||||
const seasons = this.seasons();
|
||||
const selection = this.selectedSeasonState();
|
||||
const identity = this.currentIdentity();
|
||||
if (!selection || selection.identity !== identity) return seasons[0];
|
||||
return (
|
||||
seasons.find(
|
||||
(season) => offlineSeasonKey(season) === selection.key
|
||||
) ?? seasons[0]
|
||||
return this.seasonSelection.selected(
|
||||
this.routeContext(),
|
||||
this.seasons()
|
||||
);
|
||||
});
|
||||
readonly count = computed(() => offlineEpisodeCount(this.selectedSeason()));
|
||||
@@ -216,27 +201,50 @@ export class DownloadOfflineDetailComponent {
|
||||
readonly episodeCoordinate = offlineEpisodeCoordinate;
|
||||
readonly episodeTitle = offlineEpisodeTitle;
|
||||
readonly positiveFinite = offlinePositiveFinite;
|
||||
readonly personTrackKey = offlinePersonTrackKey;
|
||||
|
||||
constructor() {
|
||||
void this.downloadsService.loadDownloads();
|
||||
this.metadataResolution.connect(this.detail, this.currentIdentity);
|
||||
this.redirectUnavailableRowsReactively();
|
||||
this.metadataResolution.connect(
|
||||
this.metadataResolutionKey,
|
||||
this.detail
|
||||
);
|
||||
this.providerCoordinator.connect(this.routeContext, this.currentItem);
|
||||
this.seasonSelection.connect(this.routeContext, this.seasons);
|
||||
this.fileCoordinator.connect({
|
||||
detail: this.detail,
|
||||
hasLoaded: this.downloadsService.hasLoadedDownloads,
|
||||
isLoading: this.downloadsService.isLoadingDownloads,
|
||||
route: this.routeContext,
|
||||
selectedRow: this.selectedRow,
|
||||
});
|
||||
}
|
||||
|
||||
selectSeason(season: DownloadOfflineSeason): void {
|
||||
const identity = this.currentIdentity();
|
||||
if (identity) {
|
||||
this.selectedSeasonState.set({
|
||||
identity,
|
||||
key: offlineSeasonKey(season),
|
||||
});
|
||||
}
|
||||
this.seasonSelection.select(this.routeContext(), season);
|
||||
}
|
||||
|
||||
isSelectedSeason(season: DownloadOfflineSeason): boolean {
|
||||
return this.selectedSeason() === season;
|
||||
}
|
||||
|
||||
seasonTabId(season: DownloadOfflineSeason): string {
|
||||
return this.seasonSelection.tabId(season);
|
||||
}
|
||||
|
||||
seasonTabIndex(season: DownloadOfflineSeason): number {
|
||||
return this.isSelectedSeason(season) ? 0 : -1;
|
||||
}
|
||||
|
||||
onSeasonKeydown(event: KeyboardEvent, index: number): void {
|
||||
this.seasonSelection.handleKeydown(
|
||||
event,
|
||||
index,
|
||||
this.routeContext(),
|
||||
this.seasons()
|
||||
);
|
||||
}
|
||||
|
||||
isPending(item: OfflineDetailItem): boolean {
|
||||
return this.pendingIds().has(item.id);
|
||||
}
|
||||
@@ -252,21 +260,15 @@ export class DownloadOfflineDetailComponent {
|
||||
type: 'play' | 'reveal',
|
||||
item: OfflineDetailItem
|
||||
): Promise<void> {
|
||||
const identity = this.currentIdentity();
|
||||
await this.actions.run({ type, item: item as DownloadItem });
|
||||
if (identity !== this.currentIdentity()) return;
|
||||
const live = this.downloadsService
|
||||
.downloads()
|
||||
.find(({ id }) => id === item.id);
|
||||
if (!offlineHasLocalFile(live)) {
|
||||
await this.routeNavigation.toManager(true);
|
||||
}
|
||||
await this.fileCoordinator.runFileAction(type, item, this.routeContext);
|
||||
}
|
||||
|
||||
async viewInPortal(): Promise<void> {
|
||||
const item = this.currentItem();
|
||||
if (!item || !this.canOpenInPortal()) return;
|
||||
if (!(await this.navigation.open(item as DownloadItem))) {
|
||||
const result = await this.providerCoordinator.open(
|
||||
this.routeContext(),
|
||||
this.currentItem()
|
||||
);
|
||||
if (result === 'failed') {
|
||||
this.actions.showActionError();
|
||||
}
|
||||
}
|
||||
@@ -275,25 +277,7 @@ export class DownloadOfflineDetailComponent {
|
||||
this.routeNavigation.back();
|
||||
}
|
||||
|
||||
private redirectUnavailableRowsReactively(): void {
|
||||
effect(() => {
|
||||
const id = this.downloadId();
|
||||
const row = this.selectedRow();
|
||||
const loaded = this.downloadsService.hasLoadedDownloads();
|
||||
const loading = this.downloadsService.isLoadingDownloads();
|
||||
if (
|
||||
id === undefined ||
|
||||
!loaded ||
|
||||
loading ||
|
||||
!row ||
|
||||
row.status !== 'completed' ||
|
||||
this.detail() ||
|
||||
this.redirectedUnavailableId === id
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.redirectedUnavailableId = id;
|
||||
void this.routeNavigation.toManager(true);
|
||||
});
|
||||
async retryRedirect(): Promise<void> {
|
||||
await this.fileCoordinator.retry(this.routeContext());
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
boundedOfflinePeople,
|
||||
offlinePersonTrackKey,
|
||||
} from './download-offline-detail.presentation';
|
||||
|
||||
describe('offline detail people presentation', () => {
|
||||
it('deduplicates people and supplies unique stable composite track keys', () => {
|
||||
const people = boundedOfflinePeople([
|
||||
{ tmdbPersonId: 7, name: 'Ada Actor', role: 'Lead' },
|
||||
{ tmdbPersonId: 7, name: 'Ada Actor', role: 'Duplicate' },
|
||||
{ tmdbPersonId: 8, name: 'Ada Actor', role: 'Lead' },
|
||||
{ name: 'Ada Actor', role: 'Lead' },
|
||||
{ name: 'Casey Guest', role: 'Pilot' },
|
||||
{ name: 'Casey Guest', role: 'Pilot' },
|
||||
{ name: 'Casey Guest', role: 'Captain' },
|
||||
]);
|
||||
|
||||
expect(people).toEqual([
|
||||
{ tmdbPersonId: 7, name: 'Ada Actor', role: 'Lead' },
|
||||
{ name: 'Casey Guest', role: 'Pilot' },
|
||||
{ name: 'Casey Guest', role: 'Captain' },
|
||||
]);
|
||||
expect(new Set(people.map(offlinePersonTrackKey)).size).toBe(
|
||||
people.length
|
||||
);
|
||||
});
|
||||
});
|
||||
+60
-4
@@ -1,5 +1,8 @@
|
||||
import type { DownloadItem } from '@iptvnator/services';
|
||||
import type { DownloadMetadataPerson } from '@iptvnator/shared/interfaces';
|
||||
import type {
|
||||
DownloadMetadataPerson,
|
||||
DownloadMetadataSnapshot,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type {
|
||||
DeepReadonly,
|
||||
DownloadOfflineDetail,
|
||||
@@ -40,6 +43,30 @@ export function offlineDetailIdentity(
|
||||
: `movie:${item.playlistId}:${item.id}`;
|
||||
}
|
||||
|
||||
export function offlineMetadataResolutionKey(
|
||||
detail: DownloadOfflineDetail | undefined,
|
||||
downloadId: number | undefined,
|
||||
language: string
|
||||
): string | undefined {
|
||||
const item = offlineDetailRepresentative(detail);
|
||||
if (!detail || !item || downloadId === undefined) return undefined;
|
||||
const snapshot = detail.snapshot as DownloadMetadataSnapshot | undefined;
|
||||
return JSON.stringify([
|
||||
detail.kind,
|
||||
downloadId,
|
||||
item.id,
|
||||
item.playlistId,
|
||||
item.xtreamId,
|
||||
item.seriesXtreamId,
|
||||
item.seasonNumber,
|
||||
item.episodeNumber,
|
||||
item.title,
|
||||
item.posterUrl,
|
||||
language.trim() || 'en',
|
||||
snapshot,
|
||||
]);
|
||||
}
|
||||
|
||||
export function offlineSeasonKey(season: DownloadOfflineSeason): string {
|
||||
return season.seasonNumberState === 'known'
|
||||
? `season-${season.seasonNumber}`
|
||||
@@ -96,9 +123,38 @@ export function offlinePositiveFinite(value: number): boolean {
|
||||
export function boundedOfflinePeople(
|
||||
values: readonly DownloadMetadataPerson[] | undefined
|
||||
): readonly DownloadMetadataPerson[] {
|
||||
return (values ?? [])
|
||||
.filter(({ name }) => name.trim().length > 0)
|
||||
.slice(0, 12);
|
||||
const people: DownloadMetadataPerson[] = [];
|
||||
const seenProviderIds = new Set<number>();
|
||||
const seenNames = new Set<string>();
|
||||
for (const person of values ?? []) {
|
||||
if (!person.name.trim()) continue;
|
||||
const nameKey = offlinePersonNameKey(person);
|
||||
const duplicate =
|
||||
seenNames.has(nameKey) ||
|
||||
(person.tmdbPersonId !== undefined &&
|
||||
seenProviderIds.has(person.tmdbPersonId));
|
||||
seenNames.add(nameKey);
|
||||
if (person.tmdbPersonId !== undefined) {
|
||||
seenProviderIds.add(person.tmdbPersonId);
|
||||
}
|
||||
if (duplicate) continue;
|
||||
people.push(person);
|
||||
if (people.length === 12) break;
|
||||
}
|
||||
return people;
|
||||
}
|
||||
|
||||
export function offlinePersonTrackKey(person: DownloadMetadataPerson): string {
|
||||
return [person.tmdbPersonId ?? 'local', offlinePersonNameKey(person)].join(
|
||||
':'
|
||||
);
|
||||
}
|
||||
|
||||
function offlinePersonNameKey(person: DownloadMetadataPerson): string {
|
||||
return [
|
||||
person.name.trim().toLowerCase(),
|
||||
person.role?.trim().toLowerCase() ?? '',
|
||||
].join(':');
|
||||
}
|
||||
|
||||
export function offlineHasLocalFile(
|
||||
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
import { effect, inject, Injectable, Injector, signal } from '@angular/core';
|
||||
import type { DownloadItem } from '@iptvnator/services';
|
||||
import type { DownloadOfflineDetail } from './download-offline-detail.viewmodel';
|
||||
import type { OfflineDetailItem } from './download-offline-detail.presentation';
|
||||
import { DownloadManagerActionsService } from '../download-manager-actions.service';
|
||||
import { DownloadOfflineRouteNavigationService } from './download-offline-route-navigation.service';
|
||||
|
||||
export interface OfflineDetailRouteContext {
|
||||
readonly downloadId?: number;
|
||||
readonly generation: number;
|
||||
}
|
||||
|
||||
interface ActiveFileAction {
|
||||
readonly actionGeneration: number;
|
||||
readonly routeGeneration: number;
|
||||
}
|
||||
|
||||
interface RedirectState {
|
||||
readonly routeGeneration: number;
|
||||
readonly status: 'pending' | 'failed' | 'succeeded';
|
||||
}
|
||||
|
||||
interface RedirectInputs {
|
||||
readonly detail: () => DownloadOfflineDetail | undefined;
|
||||
readonly hasLoaded: () => boolean;
|
||||
readonly isLoading: () => boolean;
|
||||
readonly route: () => OfflineDetailRouteContext;
|
||||
readonly selectedRow: () => DownloadItem | undefined;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DownloadOfflineFileCoordinatorService {
|
||||
private readonly actions = inject(DownloadManagerActionsService);
|
||||
private readonly navigation = inject(DownloadOfflineRouteNavigationService);
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly activeAction = signal<ActiveFileAction | undefined>(
|
||||
undefined
|
||||
);
|
||||
private readonly ownedRouteGeneration = signal<number | undefined>(
|
||||
undefined
|
||||
);
|
||||
private readonly redirectState = signal<RedirectState | undefined>(
|
||||
undefined
|
||||
);
|
||||
private actionGeneration = 0;
|
||||
|
||||
connect(inputs: RedirectInputs): void {
|
||||
effect(
|
||||
() => {
|
||||
const route = inputs.route();
|
||||
const row = inputs.selectedRow();
|
||||
const detail = inputs.detail();
|
||||
const activeRouteGeneration =
|
||||
this.activeAction()?.routeGeneration;
|
||||
if (
|
||||
detail &&
|
||||
activeRouteGeneration !== route.generation &&
|
||||
this.ownedRouteGeneration() === route.generation
|
||||
) {
|
||||
this.ownedRouteGeneration.set(undefined);
|
||||
}
|
||||
const unavailable =
|
||||
route.downloadId !== undefined &&
|
||||
inputs.hasLoaded() &&
|
||||
!inputs.isLoading() &&
|
||||
row?.status === 'completed' &&
|
||||
!detail;
|
||||
if (
|
||||
!unavailable ||
|
||||
activeRouteGeneration === route.generation ||
|
||||
this.ownedRouteGeneration() === route.generation ||
|
||||
this.redirectState()?.routeGeneration === route.generation
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void this.redirect(route);
|
||||
},
|
||||
{ injector: this.injector }
|
||||
);
|
||||
}
|
||||
|
||||
isRedirectFailed(route: OfflineDetailRouteContext): boolean {
|
||||
const state = this.redirectState();
|
||||
return (
|
||||
state?.routeGeneration === route.generation &&
|
||||
state.status === 'failed'
|
||||
);
|
||||
}
|
||||
|
||||
async retry(route: OfflineDetailRouteContext): Promise<void> {
|
||||
if (!this.isRedirectFailed(route)) return;
|
||||
await this.redirect(route);
|
||||
}
|
||||
|
||||
async runFileAction(
|
||||
type: 'play' | 'reveal',
|
||||
item: OfflineDetailItem,
|
||||
currentRoute: () => OfflineDetailRouteContext
|
||||
): Promise<void> {
|
||||
const route = currentRoute();
|
||||
if (
|
||||
this.activeAction()?.routeGeneration === route.generation ||
|
||||
route.downloadId === undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const active = {
|
||||
actionGeneration: ++this.actionGeneration,
|
||||
routeGeneration: route.generation,
|
||||
};
|
||||
this.activeAction.set(active);
|
||||
this.ownedRouteGeneration.set(route.generation);
|
||||
const result = await this.actions.run({
|
||||
type,
|
||||
item: item as DownloadItem,
|
||||
});
|
||||
const stillOwnsAction = this.activeAction() === active;
|
||||
if (stillOwnsAction) this.activeAction.set(undefined);
|
||||
if (
|
||||
!stillOwnsAction ||
|
||||
currentRoute().generation !== route.generation ||
|
||||
result !== 'file-missing'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await this.redirect(route);
|
||||
}
|
||||
|
||||
private async redirect(route: OfflineDetailRouteContext): Promise<void> {
|
||||
const state = this.redirectState();
|
||||
if (
|
||||
state?.routeGeneration === route.generation &&
|
||||
state.status !== 'failed'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const pending: RedirectState = {
|
||||
routeGeneration: route.generation,
|
||||
status: 'pending',
|
||||
};
|
||||
this.redirectState.set(pending);
|
||||
const succeeded = await this.navigation.toManager(true);
|
||||
if (this.redirectState() !== pending) return;
|
||||
this.redirectState.set({
|
||||
routeGeneration: route.generation,
|
||||
status: succeeded ? 'succeeded' : 'failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
+32
-12
@@ -5,6 +5,7 @@ import {
|
||||
Injector,
|
||||
signal,
|
||||
type Signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import type { DownloadMetadataSnapshot } from '@iptvnator/shared/interfaces';
|
||||
import type { DownloadOfflineDetail } from './download-offline-detail.viewmodel';
|
||||
@@ -12,7 +13,7 @@ import { DownloadOfflineMetadataService } from './download-offline-metadata.serv
|
||||
|
||||
export interface OfflineMetadataResolution {
|
||||
readonly generation: number;
|
||||
readonly identity?: string;
|
||||
readonly key?: string;
|
||||
readonly snapshot?: DownloadMetadataSnapshot;
|
||||
}
|
||||
|
||||
@@ -24,37 +25,56 @@ export class DownloadOfflineMetadataResolutionService {
|
||||
generation: 0,
|
||||
});
|
||||
private generation = 0;
|
||||
private readonly inFlight = new Map<
|
||||
string,
|
||||
Promise<DownloadMetadataSnapshot>
|
||||
>();
|
||||
|
||||
readonly resolution = this.state.asReadonly();
|
||||
|
||||
connect(
|
||||
detail: Signal<DownloadOfflineDetail | undefined>,
|
||||
identity: Signal<string | undefined>
|
||||
key: Signal<string | undefined>,
|
||||
currentDetail: () => DownloadOfflineDetail | undefined
|
||||
): void {
|
||||
effect(
|
||||
() => {
|
||||
const currentDetail = detail();
|
||||
const currentIdentity = identity();
|
||||
const currentKey = key();
|
||||
const generation = ++this.generation;
|
||||
this.state.set({ generation, identity: currentIdentity });
|
||||
if (!currentDetail || !currentIdentity) return;
|
||||
void this.metadata
|
||||
.resolve(currentDetail)
|
||||
this.state.set({ generation, key: currentKey });
|
||||
const detail = untracked(currentDetail);
|
||||
if (!detail || !currentKey) return;
|
||||
void this.resolveOnce(currentKey, detail)
|
||||
.then((snapshot) => {
|
||||
this.clearRequest(currentKey);
|
||||
if (
|
||||
generation === this.generation &&
|
||||
currentIdentity === identity()
|
||||
currentKey === key()
|
||||
) {
|
||||
this.state.set({
|
||||
generation,
|
||||
identity: currentIdentity,
|
||||
key: currentKey,
|
||||
snapshot,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
.catch(() => this.clearRequest(currentKey));
|
||||
},
|
||||
{ injector: this.injector }
|
||||
);
|
||||
}
|
||||
|
||||
private resolveOnce(
|
||||
key: string,
|
||||
detail: DownloadOfflineDetail
|
||||
): Promise<DownloadMetadataSnapshot> {
|
||||
const active = this.inFlight.get(key);
|
||||
if (active) return active;
|
||||
const request = this.metadata.resolve(detail);
|
||||
this.inFlight.set(key, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
private clearRequest(key: string): void {
|
||||
this.inFlight.delete(key);
|
||||
}
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
import { effect, inject, Injectable, Injector, signal } from '@angular/core';
|
||||
import type { DownloadItem } from '@iptvnator/services';
|
||||
import type { WorkspaceNavigationTarget } from '@iptvnator/portal/shared/util';
|
||||
import { DownloadLibraryNavigationService } from '../download-library-navigation.service';
|
||||
import type { OfflineDetailRouteContext } from './download-offline-file-coordinator.service';
|
||||
import type { OfflineDetailItem } from './download-offline-detail.presentation';
|
||||
|
||||
type ProviderTargetStatus = 'loading' | 'available' | 'unavailable' | 'opening';
|
||||
|
||||
interface ProviderTargetState {
|
||||
readonly itemId?: number;
|
||||
readonly routeGeneration?: number;
|
||||
readonly status: ProviderTargetStatus;
|
||||
readonly target?: WorkspaceNavigationTarget;
|
||||
}
|
||||
|
||||
export type ProviderOpenResult = 'success' | 'failed' | 'ignored';
|
||||
|
||||
function targetKey(
|
||||
route: OfflineDetailRouteContext,
|
||||
item: OfflineDetailItem | undefined
|
||||
): string | undefined {
|
||||
if (!item || route.downloadId === undefined) return undefined;
|
||||
return JSON.stringify([
|
||||
route.generation,
|
||||
route.downloadId,
|
||||
item.id,
|
||||
item.playlistId,
|
||||
item.contentType,
|
||||
item.xtreamId,
|
||||
item.seriesXtreamId,
|
||||
item.title,
|
||||
item.posterUrl,
|
||||
item.metadataSnapshot?.providerCategoryId,
|
||||
]);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DownloadOfflineProviderCoordinatorService {
|
||||
private readonly navigation = inject(DownloadLibraryNavigationService);
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly targetState = signal<ProviderTargetState>({
|
||||
status: 'loading',
|
||||
});
|
||||
private generation = 0;
|
||||
private currentKey?: string;
|
||||
|
||||
readonly state = this.targetState.asReadonly();
|
||||
|
||||
connect(
|
||||
route: () => OfflineDetailRouteContext,
|
||||
item: () => OfflineDetailItem | undefined
|
||||
): void {
|
||||
effect(
|
||||
() => {
|
||||
const currentRoute = route();
|
||||
const currentItem = item();
|
||||
const key = targetKey(currentRoute, currentItem);
|
||||
if (key === this.currentKey) return;
|
||||
this.currentKey = key;
|
||||
const generation = ++this.generation;
|
||||
if (!currentItem || !key) {
|
||||
this.targetState.set({ status: 'loading' });
|
||||
return;
|
||||
}
|
||||
const base = {
|
||||
itemId: currentItem.id,
|
||||
routeGeneration: currentRoute.generation,
|
||||
};
|
||||
this.targetState.set({ ...base, status: 'loading' });
|
||||
void this.navigation
|
||||
.resolveProviderTarget(currentItem as DownloadItem)
|
||||
.then((target) => {
|
||||
if (
|
||||
generation !== this.generation ||
|
||||
key !== this.currentKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.targetState.set(
|
||||
target
|
||||
? { ...base, status: 'available', target }
|
||||
: { ...base, status: 'unavailable' }
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
if (
|
||||
generation === this.generation &&
|
||||
key === this.currentKey
|
||||
) {
|
||||
this.targetState.set({
|
||||
...base,
|
||||
status: 'unavailable',
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
{ injector: this.injector }
|
||||
);
|
||||
}
|
||||
|
||||
async open(
|
||||
route: OfflineDetailRouteContext,
|
||||
item: OfflineDetailItem | undefined
|
||||
): Promise<ProviderOpenResult> {
|
||||
const state = this.targetState();
|
||||
const key = targetKey(route, item);
|
||||
if (
|
||||
!key ||
|
||||
key !== this.currentKey ||
|
||||
state.status !== 'available' ||
|
||||
!state.target
|
||||
) {
|
||||
return 'ignored';
|
||||
}
|
||||
const opening = { ...state, status: 'opening' as const };
|
||||
this.targetState.set(opening);
|
||||
let succeeded = false;
|
||||
try {
|
||||
succeeded = await this.navigation.navigateResolvedTarget(
|
||||
state.target
|
||||
);
|
||||
} catch {
|
||||
succeeded = false;
|
||||
}
|
||||
if (this.targetState() !== opening || key !== this.currentKey) {
|
||||
return 'ignored';
|
||||
}
|
||||
this.targetState.set(state);
|
||||
return succeeded ? 'success' : 'failed';
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import type { Signal } from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import type { ActivatedRoute } from '@angular/router';
|
||||
import { distinctUntilChanged, map, scan } from 'rxjs';
|
||||
import type { OfflineDetailRouteContext } from './download-offline-file-coordinator.service';
|
||||
import { parseOfflineDownloadId } from './download-offline-detail.presentation';
|
||||
|
||||
export function createDownloadOfflineRouteContext(
|
||||
route: ActivatedRoute
|
||||
): Signal<OfflineDetailRouteContext> {
|
||||
return toSignal(
|
||||
route.paramMap.pipe(
|
||||
map((params) => parseOfflineDownloadId(params.get('downloadId'))),
|
||||
distinctUntilChanged(),
|
||||
scan<number | undefined, OfflineDetailRouteContext>(
|
||||
(previous, downloadId) => ({
|
||||
downloadId,
|
||||
generation: previous.generation + 1,
|
||||
}),
|
||||
{ generation: 0 }
|
||||
)
|
||||
),
|
||||
{
|
||||
initialValue: {
|
||||
downloadId: parseOfflineDownloadId(
|
||||
route.snapshot.paramMap.get('downloadId')
|
||||
),
|
||||
generation: 0,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
+15
-10
@@ -17,17 +17,22 @@ export class DownloadOfflineRouteNavigationService {
|
||||
void this.toManager(false);
|
||||
}
|
||||
|
||||
async toManager(replaceUrl: boolean): Promise<void> {
|
||||
const returnUrl = this.returnUrl();
|
||||
if (returnUrl) {
|
||||
await this.router.navigateByUrl(returnUrl, { replaceUrl });
|
||||
return;
|
||||
async toManager(replaceUrl: boolean): Promise<boolean> {
|
||||
try {
|
||||
const returnUrl = this.returnUrl();
|
||||
if (returnUrl) {
|
||||
return await this.router.navigateByUrl(returnUrl, {
|
||||
replaceUrl,
|
||||
});
|
||||
}
|
||||
return await this.router.navigate(['..'], {
|
||||
relativeTo: this.route,
|
||||
queryParamsHandling: 'preserve',
|
||||
...(replaceUrl ? { replaceUrl: true } : {}),
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
await this.router.navigate(['..'], {
|
||||
relativeTo: this.route,
|
||||
queryParamsHandling: 'preserve',
|
||||
...(replaceUrl ? { replaceUrl: true } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
private returnUrl(): string | undefined {
|
||||
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
import { effect, inject, Injectable, Injector, signal } from '@angular/core';
|
||||
import type { DownloadOfflineSeason } from './download-offline-detail.viewmodel';
|
||||
import { offlineSeasonKey } from './download-offline-detail.presentation';
|
||||
import type { OfflineDetailRouteContext } from './download-offline-file-coordinator.service';
|
||||
|
||||
interface SeasonSelection {
|
||||
readonly key: string;
|
||||
readonly routeGeneration: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DownloadOfflineSeasonSelectionService {
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly selection = signal<SeasonSelection | undefined>(undefined);
|
||||
|
||||
connect(
|
||||
route: () => OfflineDetailRouteContext,
|
||||
seasons: () => readonly DownloadOfflineSeason[]
|
||||
): void {
|
||||
effect(
|
||||
() => {
|
||||
const currentRoute = route();
|
||||
const available = seasons();
|
||||
const current = this.selection();
|
||||
const currentAvailable = available.some(
|
||||
(season) => offlineSeasonKey(season) === current?.key
|
||||
);
|
||||
if (available.length === 0) {
|
||||
if (current) this.selection.set(undefined);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
current?.routeGeneration === currentRoute.generation &&
|
||||
currentAvailable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.selection.set({
|
||||
key: offlineSeasonKey(available[0]),
|
||||
routeGeneration: currentRoute.generation,
|
||||
});
|
||||
},
|
||||
{ injector: this.injector }
|
||||
);
|
||||
}
|
||||
|
||||
selected(
|
||||
route: OfflineDetailRouteContext,
|
||||
seasons: readonly DownloadOfflineSeason[]
|
||||
): DownloadOfflineSeason | undefined {
|
||||
const current = this.selection();
|
||||
if (current?.routeGeneration !== route.generation) return seasons[0];
|
||||
return (
|
||||
seasons.find(
|
||||
(season) => offlineSeasonKey(season) === current.key
|
||||
) ?? seasons[0]
|
||||
);
|
||||
}
|
||||
|
||||
select(
|
||||
route: OfflineDetailRouteContext,
|
||||
season: DownloadOfflineSeason
|
||||
): void {
|
||||
this.selection.set({
|
||||
key: offlineSeasonKey(season),
|
||||
routeGeneration: route.generation,
|
||||
});
|
||||
}
|
||||
|
||||
tabId(season: DownloadOfflineSeason): string {
|
||||
return `offline-${offlineSeasonKey(season)}-tab`;
|
||||
}
|
||||
|
||||
handleKeydown(
|
||||
event: KeyboardEvent,
|
||||
index: number,
|
||||
route: OfflineDetailRouteContext,
|
||||
seasons: readonly DownloadOfflineSeason[]
|
||||
): void {
|
||||
if (seasons.length === 0) return;
|
||||
const last = seasons.length - 1;
|
||||
const target = this.keyboardTarget(event.key, index, last);
|
||||
if (target === undefined) return;
|
||||
event.preventDefault();
|
||||
this.select(route, seasons[target]);
|
||||
const tabs = (
|
||||
event.currentTarget as HTMLElement | null
|
||||
)?.parentElement?.querySelectorAll<HTMLButtonElement>('[role="tab"]');
|
||||
tabs?.[target]?.focus();
|
||||
}
|
||||
|
||||
private keyboardTarget(
|
||||
key: string,
|
||||
index: number,
|
||||
last: number
|
||||
): number | undefined {
|
||||
if (key === 'Home') return 0;
|
||||
if (key === 'End') return last;
|
||||
if (key === 'ArrowRight' || key === 'ArrowDown') {
|
||||
return index === last ? 0 : index + 1;
|
||||
}
|
||||
if (key === 'ArrowLeft' || key === 'ArrowUp') {
|
||||
return index === 0 ? last : index - 1;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
<!-- Hero Section with Backdrop -->
|
||||
<section class="hero">
|
||||
<!-- Back Button -->
|
||||
<button class="hero__back-button" (click)="onBack()">
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
<button
|
||||
class="hero__back-button"
|
||||
type="button"
|
||||
[attr.aria-label]="backLabel() || ('BACK' | translate)"
|
||||
(click)="onBack()"
|
||||
>
|
||||
<mat-icon aria-hidden="true">arrow_back</mat-icon>
|
||||
</button>
|
||||
@if (errorMessage()) {
|
||||
<div class="hero__content hero__error">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { ContentHeroComponent } from './content-hero.component';
|
||||
|
||||
describe('ContentHeroComponent', () => {
|
||||
@@ -20,6 +20,9 @@ describe('ContentHeroComponent', () => {
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ContentHeroComponent);
|
||||
const translate = TestBed.inject(TranslateService);
|
||||
translate.setTranslation('en', { BACK: 'Go back' });
|
||||
translate.use('en');
|
||||
});
|
||||
|
||||
it('renders description content when ResizeObserver is unavailable', () => {
|
||||
@@ -38,4 +41,47 @@ describe('ContentHeroComponent', () => {
|
||||
expect(host.textContent).toContain('Fallback Title');
|
||||
expect(host.textContent).toContain('Plain description');
|
||||
});
|
||||
|
||||
it('renders an accessible button with the translated fallback back label', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
const back = (fixture.nativeElement as HTMLElement).querySelector(
|
||||
'.hero__back-button'
|
||||
) as HTMLButtonElement;
|
||||
expect(back.type).toBe('button');
|
||||
expect(back.getAttribute('aria-label')).toBe('Go back');
|
||||
});
|
||||
|
||||
it('uses an explicit back label when supplied', () => {
|
||||
fixture.componentRef.setInput('backLabel', 'Back to downloads');
|
||||
fixture.detectChanges();
|
||||
|
||||
const back = (fixture.nativeElement as HTMLElement).querySelector(
|
||||
'.hero__back-button'
|
||||
) as HTMLButtonElement;
|
||||
expect(back.getAttribute('aria-label')).toBe('Back to downloads');
|
||||
});
|
||||
|
||||
it('resets a poster failure when the poster URL changes', () => {
|
||||
fixture.componentRef.setInput('posterUrl', 'broken.jpg');
|
||||
fixture.detectChanges();
|
||||
const first = (fixture.nativeElement as HTMLElement).querySelector(
|
||||
'img[src="broken.jpg"]'
|
||||
) as HTMLImageElement;
|
||||
first.dispatchEvent(new Event('error'));
|
||||
fixture.detectChanges();
|
||||
expect(
|
||||
(fixture.nativeElement as HTMLElement).querySelector(
|
||||
'img[src="broken.jpg"]'
|
||||
)
|
||||
).toBeNull();
|
||||
|
||||
fixture.componentRef.setInput('posterUrl', 'replacement.jpg');
|
||||
fixture.detectChanges();
|
||||
expect(
|
||||
(fixture.nativeElement as HTMLElement).querySelector(
|
||||
'img[src="replacement.jpg"]'
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -37,17 +37,23 @@ export class ContentHeroComponent {
|
||||
readonly backdropUrl = input<string>();
|
||||
readonly isLoading = input(false);
|
||||
readonly errorMessage = input<string>();
|
||||
readonly backLabel = input<string>();
|
||||
|
||||
readonly backClicked = output<void>();
|
||||
readonly posterError = signal(false);
|
||||
|
||||
readonly descriptionEl = viewChild<ElementRef<HTMLElement>>('descriptionEl');
|
||||
readonly descriptionEl =
|
||||
viewChild<ElementRef<HTMLElement>>('descriptionEl');
|
||||
readonly isDescriptionExpanded = signal(false);
|
||||
readonly hasDescriptionOverflow = signal(false);
|
||||
|
||||
private resizeObserver?: ResizeObserver;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
this.posterUrl();
|
||||
untracked(() => this.posterError.set(false));
|
||||
});
|
||||
effect(() => {
|
||||
// Re-measure whenever description content or the element changes.
|
||||
this.description();
|
||||
@@ -120,7 +126,9 @@ export class ContentHeroComponent {
|
||||
this.measureOverflow(el);
|
||||
return;
|
||||
}
|
||||
this.resizeObserver = new ResizeObserver(() => this.measureOverflow(el));
|
||||
this.resizeObserver = new ResizeObserver(() =>
|
||||
this.measureOverflow(el)
|
||||
);
|
||||
this.resizeObserver.observe(el);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
[backdropUrl]="backdropUrl()"
|
||||
[isLoading]="isLoading()"
|
||||
[errorMessage]="errorMessage()"
|
||||
[backLabel]="backLabel()"
|
||||
(backClicked)="backClicked.emit()"
|
||||
>
|
||||
<ng-container hero-tags>
|
||||
|
||||
@@ -21,6 +21,7 @@ import { PortalDetailShellComponent } from './portal-detail-shell.component';
|
||||
[title]="'Show Title'"
|
||||
[description]="'Show description'"
|
||||
[posterUrl]="'poster.jpg'"
|
||||
[backLabel]="'Return to downloads'"
|
||||
[playbackActive]="playbackActive()"
|
||||
(closePlayerRequested)="closeRequests = closeRequests + 1"
|
||||
>
|
||||
@@ -72,6 +73,9 @@ describe('PortalDetailShellComponent', () => {
|
||||
expect(query('.details__meta .details__meta-item')).toBeTruthy();
|
||||
expect(query('.action-buttons .play-btn')).toBeTruthy();
|
||||
expect(query('app-content-about')).toBeNull();
|
||||
expect(query('.hero__back-button')?.getAttribute('aria-label')).toBe(
|
||||
'Return to downloads'
|
||||
);
|
||||
});
|
||||
|
||||
it('collapses hero and shows About with re-stamped templates in watch state', () => {
|
||||
|
||||
@@ -50,6 +50,7 @@ export class PortalDetailShellComponent {
|
||||
readonly backdropUrl = input<string>();
|
||||
readonly isLoading = input(false);
|
||||
readonly errorMessage = input<string>();
|
||||
readonly backLabel = input<string>();
|
||||
/** True while inline playback is active — flips the layout to watch state. */
|
||||
readonly playbackActive = input(false);
|
||||
|
||||
|
||||
Reference in new issue
Block a user