diff --git a/libs/portal/downloads/feature/src/lib/download-actions.ts b/libs/portal/downloads/feature/src/lib/download-actions.ts index 5cd0ffa2a..48a38569a 100644 --- a/libs/portal/downloads/feature/src/lib/download-actions.ts +++ b/libs/portal/downloads/feature/src/lib/download-actions.ts @@ -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; diff --git a/libs/portal/downloads/feature/src/lib/download-library-navigation.service.spec.ts b/libs/portal/downloads/feature/src/lib/download-library-navigation.service.spec.ts index fbbdb15c5..597fb17e3 100644 --- a/libs/portal/downloads/feature/src/lib/download-library-navigation.service.spec.ts +++ b/libs/portal/downloads/feature/src/lib/download-library-navigation.service.spec.ts @@ -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) diff --git a/libs/portal/downloads/feature/src/lib/download-library-navigation.service.ts b/libs/portal/downloads/feature/src/lib/download-library-navigation.service.ts index 3fe076fbf..715dce1c1 100644 --- a/libs/portal/downloads/feature/src/lib/download-library-navigation.service.ts +++ b/libs/portal/downloads/feature/src/lib/download-library-navigation.service.ts @@ -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 { + const target = await this.resolveProviderTarget(item); + return target + ? this.navigateResolvedTarget(target) + : Promise.resolve(false); + } + + async resolveProviderTarget( + item: DownloadItem + ): Promise { 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 { + 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 - ): Array { + 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 { + ): Promise { 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 { + ): Promise { 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; } } diff --git a/libs/portal/downloads/feature/src/lib/download-manager-actions.service.ts b/libs/portal/downloads/feature/src/lib/download-manager-actions.service.ts index 0d7d38133..20f988ac4 100644 --- a/libs/portal/downloads/feature/src/lib/download-manager-actions.service.ts +++ b/libs/portal/downloads/feature/src/lib/download-manager-actions.service.ts @@ -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>(new Set()); readonly isClearing = signal(false); - async run(action: DownloadItemAction): Promise { + async run(action: DownloadItemAction): Promise { 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 { + ): Promise { 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 { - await this.withPending( + private async copyUrl(item: DownloadItem): Promise { + return this.withPending( item.id, async () => { try { @@ -158,10 +160,11 @@ export class DownloadManagerActionsService { itemId: number, operation: () => Promise, onFailure: (error?: string) => void | Promise = (error) => - this.showActionError(error) - ): Promise { + this.showActionError(error), + failureResult: (error?: string) => DownloadActionResult = () => 'failed' + ): Promise { 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); diff --git a/libs/portal/downloads/feature/src/lib/downloads.component.spec.ts b/libs/portal/downloads/feature/src/lib/downloads.component.spec.ts index 7d44cf1b1..b9431f5a9 100644 --- a/libs/portal/downloads/feature/src/lib/downloads.component.spec.ts +++ b/libs/portal/downloads/feature/src/lib/downloads.component.spec.ts @@ -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; + }): Promise | 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', diff --git a/libs/portal/downloads/feature/src/lib/downloads.component.ts b/libs/portal/downloads/feature/src/lib/downloads.component.ts index e823f1bbd..72536cf80 100644 --- a/libs/portal/downloads/feature/src/lib/downloads.component.ts +++ b/libs/portal/downloads/feature/src/lib/downloads.component.ts @@ -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 { - await this.actions.run(action); + async runAction(action: DownloadItemAction): Promise { + return this.actions.run(action); } clearFinished(): void { diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.html b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.html index 276fd697f..102e1eb21 100644 --- a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.html +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.html @@ -4,6 +4,7 @@ [description]="description()" [posterUrl]="posterUrl()" [backdropUrl]="backdropUrl()" + [backLabel]="'DOWNLOADS.OFFLINE_DETAIL.BACK' | translate" (backClicked)="goBack()" > @@ -43,10 +44,7 @@
{{ 'XTREAM.ACTORS' | translate }}
- @for ( - person of cast(); - track person.tmdbPersonId ?? person.name - ) { + @for (person of cast(); track personTrackKey(person)) { @for ( person of creators(); - track person.tmdbPersonId ?? person.name + track personTrackKey(person) ) { - @if (!canOpenInPortal()) { + @if (providerUnavailable()) { @for ( season of offlineDetail.seasons; - track seasonTestId(season) + track seasonTestId(season); + let seasonIndex = $index ) { + +
+ } @else if (showNotFound()) {

{{ 'DOWNLOADS.OFFLINE_DETAIL.NOT_FOUND_BODY' | translate }} diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.scss b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.scss index e8660562a..92b782d49 100644 --- a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.scss +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.scss @@ -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 { diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.spec.ts b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.spec.ts index c2b2452e8..a794c5cb3 100644 --- a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.spec.ts +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.spec.ts @@ -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>>; - readonly run: jest.Mock, [unknown]>; + readonly run: jest.Mock, [unknown]>; readonly showActionError: jest.Mock; } @@ -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; let routeParams: BehaviorSubject>; - let playlistItems: BehaviorSubject; - let playlistsObservable: Observable; let downloads: DownloadsFake; let actions: ActionsFake; let navigation: { - canOpen: jest.Mock]>; - open: jest.Mock, [DownloadItem]>; + resolveProviderTarget: jest.Mock< + Promise, + [DownloadItem] + >; + navigateResolvedTarget: jest.Mock< + Promise, + [WorkspaceNavigationTarget] + >; }; let metadata: { resolve: jest.Mock, [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([]), isLoadingDownloads: signal(false), @@ -148,14 +155,12 @@ describe('DownloadOfflineDetailComponent', () => { }; actions = { pendingIds: signal>(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(); + const operation = deferred(); 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(); + 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(); + 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(); + 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(); 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(); + 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(); + const secondResolution = deferred(); + 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(); + 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(); + 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(); + 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(); + const second = deferred(); + 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, diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.ts b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.ts index ed09b5b3f..12c3a314b 100644 --- a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.ts +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.ts @@ -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 { - 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 { - 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 { + await this.fileCoordinator.retry(this.routeContext()); } } diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.presentation.spec.ts b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.presentation.spec.ts new file mode 100644 index 000000000..697622078 --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.presentation.spec.ts @@ -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 + ); + }); +}); diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.presentation.ts b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.presentation.ts index 23d25f8c6..de6946bec 100644 --- a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.presentation.ts +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.presentation.ts @@ -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(); + const seenNames = new Set(); + 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( diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-file-coordinator.service.ts b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-file-coordinator.service.ts new file mode 100644 index 000000000..a82dd8eb6 --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-file-coordinator.service.ts @@ -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( + undefined + ); + private readonly ownedRouteGeneration = signal( + undefined + ); + private readonly redirectState = signal( + 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 { + if (!this.isRedirectFailed(route)) return; + await this.redirect(route); + } + + async runFileAction( + type: 'play' | 'reveal', + item: OfflineDetailItem, + currentRoute: () => OfflineDetailRouteContext + ): Promise { + 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 { + 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', + }); + } +} diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-metadata-resolution.service.ts b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-metadata-resolution.service.ts index fb71565a9..77b5b6bc1 100644 --- a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-metadata-resolution.service.ts +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-metadata-resolution.service.ts @@ -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 + >(); readonly resolution = this.state.asReadonly(); connect( - detail: Signal, - identity: Signal + key: Signal, + 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 { + 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); + } } diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-provider-coordinator.service.ts b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-provider-coordinator.service.ts new file mode 100644 index 000000000..0419c0a97 --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-provider-coordinator.service.ts @@ -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({ + 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 { + 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'; + } +} diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-route-context.ts b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-route-context.ts new file mode 100644 index 000000000..9a34db8ea --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-route-context.ts @@ -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 { + return toSignal( + route.paramMap.pipe( + map((params) => parseOfflineDownloadId(params.get('downloadId'))), + distinctUntilChanged(), + scan( + (previous, downloadId) => ({ + downloadId, + generation: previous.generation + 1, + }), + { generation: 0 } + ) + ), + { + initialValue: { + downloadId: parseOfflineDownloadId( + route.snapshot.paramMap.get('downloadId') + ), + generation: 0, + }, + } + ); +} diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-route-navigation.service.ts b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-route-navigation.service.ts index 0fc9c040a..59b056010 100644 --- a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-route-navigation.service.ts +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-route-navigation.service.ts @@ -17,17 +17,22 @@ export class DownloadOfflineRouteNavigationService { void this.toManager(false); } - async toManager(replaceUrl: boolean): Promise { - const returnUrl = this.returnUrl(); - if (returnUrl) { - await this.router.navigateByUrl(returnUrl, { replaceUrl }); - return; + async toManager(replaceUrl: boolean): Promise { + 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 { diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-season-selection.service.ts b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-season-selection.service.ts new file mode 100644 index 000000000..6a14c38b9 --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-season-selection.service.ts @@ -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(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('[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; + } +} diff --git a/libs/ui/components/src/lib/content-hero/content-hero.component.html b/libs/ui/components/src/lib/content-hero/content-hero.component.html index 66950ef10..3503d772c 100644 --- a/libs/ui/components/src/lib/content-hero/content-hero.component.html +++ b/libs/ui/components/src/lib/content-hero/content-hero.component.html @@ -1,8 +1,13 @@

- @if (errorMessage()) {
diff --git a/libs/ui/components/src/lib/content-hero/content-hero.component.spec.ts b/libs/ui/components/src/lib/content-hero/content-hero.component.spec.ts index eee853896..1beb6a5ab 100644 --- a/libs/ui/components/src/lib/content-hero/content-hero.component.spec.ts +++ b/libs/ui/components/src/lib/content-hero/content-hero.component.spec.ts @@ -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(); + }); }); diff --git a/libs/ui/components/src/lib/content-hero/content-hero.component.ts b/libs/ui/components/src/lib/content-hero/content-hero.component.ts index 8a18585b8..e8bd1e76e 100644 --- a/libs/ui/components/src/lib/content-hero/content-hero.component.ts +++ b/libs/ui/components/src/lib/content-hero/content-hero.component.ts @@ -37,17 +37,23 @@ export class ContentHeroComponent { readonly backdropUrl = input(); readonly isLoading = input(false); readonly errorMessage = input(); + readonly backLabel = input(); readonly backClicked = output(); readonly posterError = signal(false); - readonly descriptionEl = viewChild>('descriptionEl'); + readonly descriptionEl = + viewChild>('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); } } diff --git a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.html b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.html index 4a0c1f51d..c92425d62 100644 --- a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.html +++ b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.html @@ -8,6 +8,7 @@ [backdropUrl]="backdropUrl()" [isLoading]="isLoading()" [errorMessage]="errorMessage()" + [backLabel]="backLabel()" (backClicked)="backClicked.emit()" > diff --git a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.spec.ts b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.spec.ts index 8c2bb2d74..12860bca2 100644 --- a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.spec.ts +++ b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.spec.ts @@ -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', () => { diff --git a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts index 1113b1cfe..492c5de20 100644 --- a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts +++ b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts @@ -50,6 +50,7 @@ export class PortalDetailShellComponent { readonly backdropUrl = input(); readonly isLoading = input(false); readonly errorMessage = input(); + readonly backLabel = input(); /** True while inline playback is active — flips the layout to watch state. */ readonly playbackActive = input(false);