fix(downloads): harden offline detail interactions

This commit is contained in:
4gray committed 2026-08-01 16:29:50 +02:00
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 {
@@ -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 }}
@@ -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 {
@@ -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,
@@ -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());
}
}
@@ -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
);
});
});
@@ -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(
@@ -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',
});
}
}
@@ -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);
}
}
@@ -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';
}
}
@@ -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,
},
}
);
}
@@ -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 {
@@ -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);