From 5e41e3d1ce5a904b9c0fed1dbac3df2d687293cd Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 18 Apr 2026 10:16:09 +0200 Subject: [PATCH] refactor(favs-recent): adapt UI and routing behavior - Introduced StalkerCollectionDetailComponent to handle detailed views for Stalker collections. - Added XtreamCollectionDetailComponent for managing Xtream collection details. - Updated routing components to integrate new detail components for both Stalker and Xtream. - Enhanced global favorites and recently viewed functionalities to support new detail navigation for Stalker and Xtream items. - Improved styling and structure for better responsiveness and user experience in detail views. Entire-Checkpoint: c6e522b4276c --- apps/web/src/app/app.routes.ts | 8 +- .../app/global-collection-route.component.ts | 40 +++ .../unified-collection-page.component.html | 25 +- .../unified-collection-page.component.spec.ts | 226 +++++++++++- .../unified-collection-page.component.ts | 321 +++++++++++++++++- .../unified-grid-tab.component.ts | 30 +- .../workspace-portal-navigation.spec.ts | 117 ++++--- .../navigation/workspace-portal-navigation.ts | 261 +++++++++++++- libs/portal/stalker/feature/src/index.ts | 1 + .../stalker-collection-detail.component.ts | 285 ++++++++++++++++ .../lib/stalker-collection-route.component.ts | 21 +- libs/portal/xtream/feature/src/index.ts | 1 + .../serial-details.component.ts | 40 ++- .../vod-details-route.component.scss | 32 +- .../lib/xtream-collection-detail.component.ts | 243 +++++++++++++ .../lib/xtream-collection-route.component.ts | 14 +- .../content-hero/content-hero.component.scss | 3 +- .../src/lib/dashboard-data.service.spec.ts | 110 +++++- .../src/lib/dashboard-data.service.ts | 74 +++- 19 files changed, 1671 insertions(+), 181 deletions(-) create mode 100644 apps/web/src/app/global-collection-route.component.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts create mode 100644 libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.ts diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts index 3bf8568d8..d4f7d4afd 100644 --- a/apps/web/src/app/app.routes.ts +++ b/apps/web/src/app/app.routes.ts @@ -98,8 +98,8 @@ export const routes: Routes = [ defaultScope: 'all', }, loadComponent: () => - import('@iptvnator/portal/shared/ui').then( - (c) => c.UnifiedCollectionPageComponent + import('./global-collection-route.component').then( + (c) => c.GlobalCollectionRouteComponent ), }, { @@ -109,8 +109,8 @@ export const routes: Routes = [ defaultScope: 'all', }, loadComponent: () => - import('@iptvnator/portal/shared/ui').then( - (c) => c.UnifiedCollectionPageComponent + import('./global-collection-route.component').then( + (c) => c.GlobalCollectionRouteComponent ), }, { diff --git a/apps/web/src/app/global-collection-route.component.ts b/apps/web/src/app/global-collection-route.component.ts new file mode 100644 index 000000000..254ec3609 --- /dev/null +++ b/apps/web/src/app/global-collection-route.component.ts @@ -0,0 +1,40 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { + UnifiedCollectionDetailDirective, + UnifiedCollectionPageComponent, +} from '@iptvnator/portal/shared/ui'; +import { CollectionScope } from '@iptvnator/portal/shared/util'; +import { StalkerCollectionDetailComponent } from '@iptvnator/portal/stalker/feature'; +import { XtreamCollectionDetailComponent } from '@iptvnator/portal/xtream/feature'; + +@Component({ + selector: 'app-global-collection-route', + imports: [ + StalkerCollectionDetailComponent, + UnifiedCollectionDetailDirective, + UnifiedCollectionPageComponent, + XtreamCollectionDetailComponent, + ], + template: ` + + + @if (item.sourceType === 'xtream') { + + } @else if (item.sourceType === 'stalker') { + + } + + + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class GlobalCollectionRouteComponent { + readonly mode = input<'favorites' | 'recent'>('favorites'); + readonly defaultScope = input(undefined); +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html index 8905643ae..adbd4f578 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html @@ -1,10 +1,19 @@ -@if (isLoading()) { + + +@if (detailContext(); as detailContext) { + +} @else if (isLoading()) {
{{ title() | translate }}
@if (showScopeToggle()) { - + }
@@ -102,8 +111,14 @@ type="button" mat-icon-button (click)="clearAllRecent()" - [matTooltip]="'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_SECTION' | translate" - [attr.aria-label]="'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_SECTION_ARIA' | translate" + [matTooltip]=" + 'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_SECTION' + | translate + " + [attr.aria-label]=" + 'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_SECTION_ARIA' + | translate + " > delete_sweep @@ -133,6 +148,7 @@ [mode]="mode()" [contentType]="'movie'" [searchTerm]="workspaceSearchTerm()" + (itemSelected)="onGridItemSelected($event)" (removeItem)="onRemoveItem($event)" /> } @@ -144,6 +160,7 @@ [mode]="mode()" [contentType]="'series'" [searchTerm]="workspaceSearchTerm()" + (itemSelected)="onGridItemSelected($event)" (removeItem)="onRemoveItem($event)" /> } diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts index 5ea29f57e..589d2819b 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts @@ -4,25 +4,26 @@ import { input, output, signal, + ViewChild, } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { convertToParamMap, ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'; import { Store } from '@ngrx/store'; import { TranslateModule } from '@ngx-translate/core'; import { + COLLECTION_VIEW_STATE_KEY, CollectionScope, + OPEN_COLLECTION_DETAIL_STATE_KEY, ScopeToggleService, UnifiedCollectionItem, UnifiedFavoritesDataService, UnifiedRecentDataService, } from '@iptvnator/portal/shared/util'; -import { - selectAllPlaylistsMeta, - selectPlaylistsLoadingFlag, -} from 'm3u-state'; +import { selectAllPlaylistsMeta, selectPlaylistsLoadingFlag } from 'm3u-state'; import { BehaviorSubject } from 'rxjs'; import { PlaylistMeta } from 'shared-interfaces'; import { UnifiedCollectionPageComponent } from './unified-collection-page.component'; +import { UnifiedCollectionDetailDirective } from './unified-collection-detail.directive'; import { UnifiedGridTabComponent } from './unified-grid-tab.component'; import { UnifiedLiveTabComponent } from './unified-live-tab.component'; @@ -54,9 +55,34 @@ class StubUnifiedGridTabComponent { readonly contentType = input<'movie' | 'series'>('movie'); readonly searchTerm = input(''); + readonly itemSelected = output(); readonly removeItem = output(); } +@Component({ + template: ` + + +
{{ item.name }}
+
+
+ `, + imports: [UnifiedCollectionDetailDirective, UnifiedCollectionPageComponent], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class HostUnifiedCollectionPageComponent { + mode: 'favorites' | 'recent' = 'favorites'; + portalType?: string; + defaultScope: CollectionScope | undefined = 'all'; + + @ViewChild(UnifiedCollectionPageComponent) + pageComponent?: UnifiedCollectionPageComponent; +} + describe('UnifiedCollectionPageComponent', () => { let fixture: ComponentFixture; let route: ActivatedRoute & { @@ -69,10 +95,14 @@ describe('UnifiedCollectionPageComponent', () => { parent: null; }; paramMap: ReturnType< - BehaviorSubject>['asObservable'] + BehaviorSubject< + ReturnType + >['asObservable'] >; queryParamMap: ReturnType< - BehaviorSubject>['asObservable'] + BehaviorSubject< + ReturnType + >['asObservable'] >; pathFromRoot: ActivatedRoute[]; }; @@ -80,7 +110,9 @@ describe('UnifiedCollectionPageComponent', () => { let routeQueryParamMap$: BehaviorSubject< ReturnType >; - let workspaceParamMap$: BehaviorSubject>; + let workspaceParamMap$: BehaviorSubject< + ReturnType + >; const playlistsLoaded = signal(false); const playlists = signal([]); const favoritesData = { @@ -93,6 +125,10 @@ describe('UnifiedCollectionPageComponent', () => { removeRecentItem: jest.fn(), clearRecentItems: jest.fn(), }; + const router = { + navigate: jest.fn().mockResolvedValue(true), + url: '/workspace/global-favorites', + }; function toParamMapRecord( values: Record @@ -174,6 +210,7 @@ describe('UnifiedCollectionPageComponent', () => { await TestBed.configureTestingModule({ imports: [ + HostUnifiedCollectionPageComponent, UnifiedCollectionPageComponent, TranslateModule.forRoot(), ], @@ -182,6 +219,10 @@ describe('UnifiedCollectionPageComponent', () => { provide: ActivatedRoute, useValue: route, }, + { + provide: Router, + useValue: router, + }, { provide: Store, useValue: { @@ -215,10 +256,7 @@ describe('UnifiedCollectionPageComponent', () => { }) .overrideComponent(UnifiedCollectionPageComponent, { remove: { - imports: [ - UnifiedGridTabComponent, - UnifiedLiveTabComponent, - ], + imports: [UnifiedGridTabComponent, UnifiedLiveTabComponent], }, add: { imports: [ @@ -232,6 +270,7 @@ describe('UnifiedCollectionPageComponent', () => { fixture = TestBed.createComponent(UnifiedCollectionPageComponent); fixture.componentRef.setInput('mode', 'favorites'); fixture.componentRef.setInput('defaultScope', 'all'); + window.history.replaceState({}, document.title); }); it('reloads favorites after playlist hydration completes', async () => { @@ -374,4 +413,167 @@ describe('UnifiedCollectionPageComponent', () => { 'm3u' ); }); + + it('opens inline detail from history state when a detail template is projected', async () => { + const item: UnifiedCollectionItem = { + uid: 'xtream::xtream-1::77', + name: 'Inline Movie', + contentType: 'movie', + sourceType: 'xtream', + playlistId: 'xtream-1', + playlistName: 'Xtream One', + xtreamId: 77, + categoryId: 12, + }; + window.history.replaceState( + { + [OPEN_COLLECTION_DETAIL_STATE_KEY]: { + item, + }, + }, + document.title + ); + + const hostFixture = TestBed.createComponent( + HostUnifiedCollectionPageComponent + ); + + hostFixture.detectChanges(); + await hostFixture.whenStable(); + hostFixture.detectChanges(); + + expect( + hostFixture.nativeElement.querySelector('.detail-probe') + ?.textContent + ).toContain('Inline Movie'); + expect( + hostFixture.componentInstance.pageComponent?.selectedContentType() + ).toBe('movie'); + }); + + it('restores collection scope and selected content type from history state', async () => { + setRouteParams({ id: 'playlist-1' }); + playlistsLoaded.set(true); + window.history.replaceState( + { + [COLLECTION_VIEW_STATE_KEY]: { + selectedContentType: 'movie', + scope: 'all', + }, + }, + document.title + ); + + const restoredFixture = TestBed.createComponent( + UnifiedCollectionPageComponent + ); + restoredFixture.componentRef.setInput('mode', 'recent'); + restoredFixture.componentRef.setInput('portalType', 'stalker'); + restoredFixture.componentRef.setInput('defaultScope', 'playlist'); + + restoredFixture.detectChanges(); + await restoredFixture.whenStable(); + + expect(restoredFixture.componentInstance.scope()).toBe('all'); + expect(restoredFixture.componentInstance.selectedContentType()).toBe( + 'movie' + ); + expect(recentData.getRecentItems).toHaveBeenLastCalledWith( + 'all', + 'playlist-1', + 'stalker' + ); + }); + + it('closes inline detail when popstate removes the detail state', async () => { + const item: UnifiedCollectionItem = { + uid: 'stalker::stalker-1::series-9', + name: 'Inline Series', + contentType: 'series', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Stalker One', + stalkerId: 'series-9', + categoryId: 'series', + }; + const hostFixture = TestBed.createComponent( + HostUnifiedCollectionPageComponent + ); + + hostFixture.detectChanges(); + await hostFixture.whenStable(); + hostFixture.componentInstance.pageComponent?.onGridItemSelected(item); + hostFixture.detectChanges(); + + expect( + hostFixture.nativeElement.querySelector('.detail-probe') + ?.textContent + ).toContain('Inline Series'); + + window.history.replaceState({}, document.title); + window.dispatchEvent(new PopStateEvent('popstate')); + await hostFixture.whenStable(); + hostFixture.detectChanges(); + + expect( + hostFixture.nativeElement.querySelector('.detail-probe') + ).toBeNull(); + }); + + it('routes cross-provider collection detail opens through the global collection route', async () => { + const item: UnifiedCollectionItem = { + uid: 'xtream::xtream-1::77', + name: 'Cross Provider Movie', + contentType: 'movie', + sourceType: 'xtream', + playlistId: 'xtream-1', + playlistName: 'Xtream One', + xtreamId: 77, + categoryId: 12, + }; + const hostFixture = TestBed.createComponent( + HostUnifiedCollectionPageComponent + ); + setRouteParams({ id: 'host-playlist' }); + router.url = '/workspace/stalker/host/recent'; + hostFixture.componentInstance.mode = 'recent'; + hostFixture.componentInstance.portalType = 'stalker'; + + hostFixture.detectChanges(); + await hostFixture.whenStable(); + hostFixture.componentInstance.pageComponent?.onScopeChange('all'); + hostFixture.componentInstance.pageComponent?.onContentTypeChange( + 'movie' + ); + + hostFixture.componentInstance.pageComponent?.onGridItemSelected(item); + + expect(window.history.state).toEqual( + expect.objectContaining({ + [COLLECTION_VIEW_STATE_KEY]: { + selectedContentType: 'movie', + scope: 'all', + }, + }) + ); + expect(router.navigate).toHaveBeenCalledWith( + ['/workspace', 'global-recent'], + { + state: { + [OPEN_COLLECTION_DETAIL_STATE_KEY]: { + item: expect.objectContaining({ + uid: item.uid, + name: item.name, + contentType: item.contentType, + sourceType: item.sourceType, + playlistId: item.playlistId, + playlistName: item.playlistName, + xtreamId: item.xtreamId, + categoryId: String(item.categoryId), + }), + }, + }, + } + ); + }); }); diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts index a249ab2c3..63b674001 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts @@ -1,7 +1,11 @@ +import { NgTemplateOutlet } from '@angular/common'; import { + AfterContentInit, ChangeDetectionStrategy, Component, computed, + contentChild, + DestroyRef, effect, inject, linkedSignal, @@ -13,29 +17,40 @@ import { MatButtonToggleModule } from '@angular/material/button-toggle'; import { MatIconButton } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatTooltip } from '@angular/material/tooltip'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { Store } from '@ngrx/store'; import { TranslatePipe } from '@ngx-translate/core'; import { + buildGlobalCollectionDetailNavigationTarget, + buildCollectionViewState, + buildOpenCollectionDetailItemState, clearNavigationStateKeys, + COLLECTION_VIEW_STATE_KEY, CollectionContentType, CollectionScope, + CollectionViewState, + getOpenCollectionDetailItemState, + getCollectionViewState, getOpenLiveCollectionItemState, + getUnifiedCollectionNavigation, isWorkspaceLayoutRoute, + OPEN_COLLECTION_DETAIL_STATE_KEY, OPEN_LIVE_COLLECTION_ITEM_STATE_KEY, queryParamSignal, routeParamSignal, ScopeToggleService, + STALKER_RETURN_TO_STATE_KEY, UnifiedCollectionItem, UnifiedFavoritesDataService, UnifiedRecentDataService, } from '@iptvnator/portal/shared/util'; -import { - selectAllPlaylistsMeta, - selectPlaylistsLoadingFlag, -} from 'm3u-state'; +import { selectAllPlaylistsMeta, selectPlaylistsLoadingFlag } from 'm3u-state'; import { UnifiedLiveTabComponent } from './unified-live-tab.component'; import { UnifiedGridTabComponent } from './unified-grid-tab.component'; +import { + UnifiedCollectionDetailContext, + UnifiedCollectionDetailDirective, +} from './unified-collection-detail.directive'; @Component({ selector: 'app-unified-collection-page', @@ -43,6 +58,7 @@ import { UnifiedGridTabComponent } from './unified-grid-tab.component'; styleUrl: './unified-collection-page.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, imports: [ + NgTemplateOutlet, MatButtonToggleModule, MatIconButton, MatIconModule, @@ -52,7 +68,7 @@ import { UnifiedGridTabComponent } from './unified-grid-tab.component'; UnifiedLiveTabComponent, ], }) -export class UnifiedCollectionPageComponent { +export class UnifiedCollectionPageComponent implements AfterContentInit { readonly mode = input<'favorites' | 'recent'>('favorites'); readonly portalType = input(); readonly playlistIdInput = input(undefined, { @@ -61,11 +77,16 @@ export class UnifiedCollectionPageComponent { readonly defaultScope = input(); private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); private readonly store = inject(Store); + private readonly destroyRef = inject(DestroyRef); private readonly scopeService = inject(ScopeToggleService); private readonly favoritesData = inject(UnifiedFavoritesDataService); private readonly recentData = inject(UnifiedRecentDataService); - private readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta); + readonly detailTemplate = contentChild(UnifiedCollectionDetailDirective); + private readonly playlists = this.store.selectSignal( + selectAllPlaylistsMeta + ); private readonly playlistsLoaded = this.store.selectSignal( selectPlaylistsLoadingFlag ); @@ -73,8 +94,7 @@ export class UnifiedCollectionPageComponent { private readonly queryScope = queryParamSignal( this.route, 'scope', - (value) => - value === 'all' || value === 'playlist' ? value : null + (value) => (value === 'all' || value === 'playlist' ? value : null) ); private readonly routeSearchTerm = queryParamSignal( this.route, @@ -92,13 +112,34 @@ export class UnifiedCollectionPageComponent { readonly workspaceSearchTerm = computed(() => this.isWorkspaceLayout ? this.routeSearchTerm() : '' ); + private readonly historyCollectionViewState = + signal( + getCollectionViewState(window.history.state) + ); readonly isLoading = signal(true); readonly allItems = signal([]); - readonly selectedContentType = signal('live'); + readonly selectedContentType = signal( + this.historyCollectionViewState()?.selectedContentType ?? 'live' + ); + readonly selectedDetailItem = signal(null); readonly pendingAutoOpenLiveItem = signal( getOpenLiveCollectionItemState(window.history.state) ); + readonly detailContext = computed( + () => { + const item = this.selectedDetailItem(); + if (!item) { + return null; + } + + return { + $implicit: item, + item, + close: this.requestCloseDetail, + }; + } + ); readonly skeletonRows = Array.from({ length: 12 }, (_, i) => i); readonly skeletonCards = Array.from({ length: 8 }, (_, i) => i); @@ -117,6 +158,11 @@ export class UnifiedCollectionPageComponent { return queryScope; } + const historyScope = this.historyCollectionViewState()?.scope; + if (historyScope) { + return historyScope; + } + const defaultScope = this.defaultScope(); if (defaultScope) { return defaultScope; @@ -174,7 +220,11 @@ export class UnifiedCollectionPageComponent { .map((playlist) => [ playlist._id, - playlist.serverUrl ? 'xtream' : playlist.macAddress ? 'stalker' : 'm3u', + playlist.serverUrl + ? 'xtream' + : playlist.macAddress + ? 'stalker' + : 'm3u', JSON.stringify(playlist.favorites ?? []), ].join('::') ) @@ -201,6 +251,53 @@ export class UnifiedCollectionPageComponent { }); }); }); + private readonly persistCollectionViewState = effect(() => { + const selectedContentType = this.selectedContentType(); + const scope = this.showScopeToggle() ? this.scope() : undefined; + + untracked(() => { + this.syncCollectionViewStateToHistory({ + selectedContentType, + scope, + }); + }); + }); + private readonly crossProviderDetailRedirect = effect(() => { + const item = this.selectedDetailItem(); + if (!item || this.canRenderInlineDetailOnCurrentRoute(item)) { + return; + } + + const navigation = this.getGlobalCollectionDetailNavigation(item); + if (!navigation) { + return; + } + + untracked(() => { + this.selectedDetailItem.set(null); + void this.router.navigate(navigation.link, { + state: navigation.state, + }); + }); + }); + + constructor() { + if (typeof window !== 'undefined') { + const onPopState = () => { + this.syncCollectionViewStateFromHistory(); + this.syncDetailFromHistoryState(); + }; + window.addEventListener('popstate', onPopState); + this.destroyRef.onDestroy(() => { + window.removeEventListener('popstate', onPopState); + }); + } + } + + ngAfterContentInit(): void { + this.syncCollectionViewStateFromHistory(); + this.syncDetailFromHistoryState(); + } onScopeChange(value: CollectionScope): void { if (!this.showScopeToggle()) { @@ -215,6 +312,40 @@ export class UnifiedCollectionPageComponent { this.selectedContentType.set(value); } + onGridItemSelected(item: UnifiedCollectionItem): void { + this.syncCurrentCollectionViewState(); + + if (this.canOpenInlineDetail(item)) { + this.pushInlineDetailState(item); + this.openInlineDetail(item); + return; + } + + const globalDetailNavigation = + this.getGlobalCollectionDetailNavigation(item); + if (globalDetailNavigation) { + void this.router.navigate(globalDetailNavigation.link, { + state: globalDetailNavigation.state, + }); + return; + } + + const navigation = getUnifiedCollectionNavigation(item); + if (!navigation) { + return; + } + + const state = + item.sourceType === 'stalker' && item.contentType !== 'live' + ? { + ...(navigation.state ?? {}), + [STALKER_RETURN_TO_STATE_KEY]: this.router.url, + } + : navigation.state; + + void this.router.navigate(navigation.link, { state }); + } + async onRemoveItem(item: UnifiedCollectionItem): Promise { if (this.mode() === 'favorites') { await this.favoritesData.removeFavorite(item); @@ -235,9 +366,7 @@ export class UnifiedCollectionPageComponent { } async onReorder(items: UnifiedCollectionItem[]): Promise { - const nonLive = this.allItems().filter( - (i) => i.contentType !== 'live' - ); + const nonLive = this.allItems().filter((i) => i.contentType !== 'live'); this.allItems.set([...items, ...nonLive]); await this.favoritesData.reorder(items, { scope: this.effectiveScope(), @@ -252,7 +381,10 @@ export class UnifiedCollectionPageComponent { } this.allItems.update((items) => { - const nextItems = [item, ...items.filter((candidate) => candidate.uid !== item.uid)]; + const nextItems = [ + item, + ...items.filter((candidate) => candidate.uid !== item.uid), + ]; return nextItems.sort( (a, b) => new Date(b.viewedAt ?? 0).getTime() - @@ -309,6 +441,10 @@ export class UnifiedCollectionPageComponent { } private autoSelectContentType(): void { + if (this.selectedDetailItem()) { + return; + } + const types = this.availableTypes(); if (types.length > 0 && !types.includes(this.selectedContentType())) { this.selectedContentType.set(types[0]); @@ -319,4 +455,159 @@ export class UnifiedCollectionPageComponent { this.pendingAutoOpenLiveItem.set(null); clearNavigationStateKeys([OPEN_LIVE_COLLECTION_ITEM_STATE_KEY]); } + + private readonly requestCloseDetail = (): void => { + if (getOpenCollectionDetailItemState(window.history.state)) { + window.history.back(); + return; + } + + this.clearInlineDetail(); + }; + + private canOpenInlineDetail(item: UnifiedCollectionItem): boolean { + return ( + Boolean(this.detailTemplate()) && + item.contentType !== 'live' && + (item.sourceType === 'xtream' || item.sourceType === 'stalker') && + this.canRenderInlineDetailOnCurrentRoute(item) + ); + } + + private canRenderInlineDetailOnCurrentRoute( + item: UnifiedCollectionItem + ): boolean { + const url = this.router.url; + + if (url.includes('/workspace/xtreams/')) { + return item.sourceType === 'xtream'; + } + + if (url.includes('/workspace/stalker/')) { + return item.sourceType === 'stalker'; + } + + if ( + url.includes('/workspace/global-favorites') || + url.includes('/workspace/global-recent') + ) { + return ( + item.sourceType === 'xtream' || item.sourceType === 'stalker' + ); + } + + const portalType = this.portalType(); + return !portalType || portalType === item.sourceType; + } + + private getGlobalCollectionDetailNavigation(item: UnifiedCollectionItem) { + if ( + item.contentType === 'live' || + (item.sourceType !== 'xtream' && item.sourceType !== 'stalker') + ) { + return null; + } + + return buildGlobalCollectionDetailNavigationTarget(this.mode(), item); + } + + private openInlineDetail(item: UnifiedCollectionItem): void { + this.selectedContentType.set(item.contentType); + this.selectedDetailItem.set(item); + } + + private clearInlineDetail(): void { + this.selectedDetailItem.set(null); + this.autoSelectContentType(); + clearNavigationStateKeys([OPEN_COLLECTION_DETAIL_STATE_KEY]); + } + + private pushInlineDetailState(item: UnifiedCollectionItem): void { + const currentState = window.history.state ?? {}; + window.history.pushState( + { + ...currentState, + [OPEN_COLLECTION_DETAIL_STATE_KEY]: + buildOpenCollectionDetailItemState(item), + }, + document.title + ); + } + + private syncCollectionViewStateFromHistory(): void { + const collectionViewState = getCollectionViewState( + window.history.state + ); + this.historyCollectionViewState.set(collectionViewState); + + if (collectionViewState?.selectedContentType) { + this.selectedContentType.set( + collectionViewState.selectedContentType + ); + } + } + + private syncCurrentCollectionViewState(): void { + this.syncCollectionViewStateToHistory({ + selectedContentType: this.selectedContentType(), + scope: this.showScopeToggle() ? this.scope() : undefined, + }); + } + + private syncCollectionViewStateToHistory(state: CollectionViewState): void { + const nextCollectionViewState = buildCollectionViewState(state); + const currentCollectionViewState = this.historyCollectionViewState(); + + if ( + this.isSameCollectionViewState( + currentCollectionViewState, + nextCollectionViewState + ) + ) { + return; + } + + const currentState = this.toHistoryStateRecord(window.history.state); + const nextState = { ...currentState }; + + if (nextCollectionViewState) { + nextState[COLLECTION_VIEW_STATE_KEY] = nextCollectionViewState; + } else { + delete nextState[COLLECTION_VIEW_STATE_KEY]; + } + + window.history.replaceState(nextState, document.title); + this.historyCollectionViewState.set(nextCollectionViewState); + } + + private syncDetailFromHistoryState(): void { + const detailItem = getOpenCollectionDetailItemState( + window.history.state + )?.item; + + if (detailItem && this.canOpenInlineDetail(detailItem)) { + this.openInlineDetail(detailItem); + return; + } + + if (this.selectedDetailItem()) { + this.clearInlineDetail(); + } + } + + private isSameCollectionViewState( + left: CollectionViewState | null, + right: CollectionViewState | null + ): boolean { + return ( + left?.selectedContentType === right?.selectedContentType && + left?.scope === right?.scope + ); + } + + private toHistoryStateRecord(state: unknown): Record { + return state && typeof state === 'object' + ? { ...(state as Record) } + : {}; + } } diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-grid-tab.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-grid-tab.component.ts index 7983e11f5..7a73b229e 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-grid-tab.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-grid-tab.component.ts @@ -2,17 +2,13 @@ import { ChangeDetectionStrategy, Component, computed, - inject, input, output, } from '@angular/core'; import { MatIconModule } from '@angular/material/icon'; -import { Router } from '@angular/router'; import { TranslatePipe } from '@ngx-translate/core'; import { CollectionContentType, - getUnifiedCollectionNavigation, - STALKER_RETURN_TO_STATE_KEY, UnifiedCollectionItem, } from '@iptvnator/portal/shared/util'; import { ContentCardComponent } from '../content-card/content-card.component'; @@ -22,11 +18,7 @@ import { ContentCardComponent } from '../content-card/content-card.component'; templateUrl: './unified-grid-tab.component.html', styleUrl: './unified-grid-tab.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ - ContentCardComponent, - MatIconModule, - TranslatePipe, - ], + imports: [ContentCardComponent, MatIconModule, TranslatePipe], }) export class UnifiedGridTabComponent { readonly items = input.required(); @@ -35,8 +27,7 @@ export class UnifiedGridTabComponent { readonly searchTerm = input(''); readonly removeItem = output(); - - private readonly router = inject(Router); + readonly itemSelected = output(); readonly filteredItems = computed(() => { const term = this.searchTerm().trim().toLowerCase(); @@ -47,22 +38,7 @@ export class UnifiedGridTabComponent { }); onCardClick(item: UnifiedCollectionItem): void { - const navigation = getUnifiedCollectionNavigation(item); - if (!navigation) { - return; - } - - const state = - item.sourceType === 'stalker' && item.contentType !== 'live' - ? { - ...(navigation.state ?? {}), - [STALKER_RETURN_TO_STATE_KEY]: this.router.url, - } - : navigation.state; - - void this.router.navigate(navigation.link, { - state, - }); + this.itemSelected.emit(item); } onRemove(item: UnifiedCollectionItem): void { diff --git a/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.spec.ts b/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.spec.ts index b2673ce0e..6a0c5a936 100644 --- a/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.spec.ts +++ b/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.spec.ts @@ -1,12 +1,17 @@ import { + buildCollectionViewState, buildStalkerStateItem, buildXtreamItemLink, + COLLECTION_VIEW_STATE_KEY, getGlobalFavoriteNavigation, + getCollectionViewState, + getOpenCollectionDetailItemState, getOpenLiveCollectionItemState, getOpenStalkerItemState, getRecentItemNavigation, getStalkerReturnToState, getUnifiedCollectionNavigation, + OPEN_COLLECTION_DETAIL_STATE_KEY, matchesOpenLiveCollectionItem, OPEN_LIVE_COLLECTION_ITEM_STATE_KEY, OPEN_STALKER_ITEM_STATE_KEY, @@ -33,14 +38,30 @@ describe('workspace-portal-navigation', () => { categoryId: 42, itemId: 99, }) - ).toEqual([ - '/workspace', - 'xtreams', - 'xtream-1', - 'vod', - '42', - '99', - ]); + ).toEqual(['/workspace', 'xtreams', 'xtream-1', 'vod', '42', '99']); + }); + + it('normalizes collection view state for history persistence', () => { + expect( + buildCollectionViewState({ + selectedContentType: 'movie', + scope: 'all', + }) + ).toEqual({ + selectedContentType: 'movie', + scope: 'all', + }); + expect( + getCollectionViewState({ + [COLLECTION_VIEW_STATE_KEY]: { + selectedContentType: 'series', + scope: 'playlist', + }, + }) + ).toEqual({ + selectedContentType: 'series', + scope: 'playlist', + }); }); it('routes live M3U favorites to the playlist favorites collection with auto-open state', () => { @@ -69,57 +90,57 @@ describe('workspace-portal-navigation', () => { }, }, }); - expect( - getOpenLiveCollectionItemState(navigation.state) - ).toEqual(navigation.state?.[OPEN_LIVE_COLLECTION_ITEM_STATE_KEY]); + expect(getOpenLiveCollectionItemState(navigation.state)).toEqual( + navigation.state?.[OPEN_LIVE_COLLECTION_ITEM_STATE_KEY] + ); }); - it('routes Stalker movie recents into the source category route with detail state', () => { - const navigation = - getGlobalFavoriteNavigation({ - id: 'movie-7', - title: 'Movie Seven', - type: 'movie', - playlist_id: 'stalker-1', - category_id: '17', - xtream_id: 'movie-7', - source: 'stalker', - poster_url: 'https://example.com/poster.png', - added_at: '2026-03-01T00:00:00.000Z', - }); + it('routes Stalker movie favorites into the global favorites page with inline detail state', () => { + const navigation = getGlobalFavoriteNavigation({ + id: 'movie-7', + title: 'Movie Seven', + type: 'movie', + playlist_id: 'stalker-1', + category_id: '17', + xtream_id: 'movie-7', + source: 'stalker', + poster_url: 'https://example.com/poster.png', + added_at: '2026-03-01T00:00:00.000Z', + }); expect(navigation).toEqual({ - link: ['/workspace', 'stalker', 'stalker-1', 'vod', '17'], + link: ['/workspace', 'global-favorites'], state: { - openStalkerItem: expect.objectContaining({ - category_id: '17', - id: 'movie-7', - title: 'Movie Seven', - }), - stalkerReturnTo: '/workspace/dashboard', + openCollectionDetailItem: { + item: expect.objectContaining({ + uid: 'stalker::stalker-1::movie-7', + name: 'Movie Seven', + contentType: 'movie', + sourceType: 'stalker', + playlistId: 'stalker-1', + categoryId: '17', + stalkerId: 'movie-7', + }), + }, }, }); - expect(getOpenStalkerItemState(navigation.state)).toEqual( - navigation.state?.[OPEN_STALKER_ITEM_STATE_KEY] - ); - expect(getStalkerReturnToState(navigation.state)).toBe( - navigation.state?.[STALKER_RETURN_TO_STATE_KEY] + expect(getOpenCollectionDetailItemState(navigation.state)).toEqual( + navigation.state?.[OPEN_COLLECTION_DETAIL_STATE_KEY] ); }); it('routes Stalker live recents to the collection route with auto-open state', () => { - const navigation = - getRecentItemNavigation({ - id: 'stalker-live-1', - title: 'Live One', - type: 'live', - playlist_id: 'stalker-1', - category_id: 'itv', - xtream_id: 'stalker-live-1', - source: 'stalker', - poster_url: 'https://example.com/poster.png', - viewed_at: '2026-03-01T00:00:00.000Z', - }); + const navigation = getRecentItemNavigation({ + id: 'stalker-live-1', + title: 'Live One', + type: 'live', + playlist_id: 'stalker-1', + category_id: 'itv', + xtream_id: 'stalker-live-1', + source: 'stalker', + poster_url: 'https://example.com/poster.png', + viewed_at: '2026-03-01T00:00:00.000Z', + }); expect(navigation).toEqual({ link: ['/workspace', 'stalker', 'stalker-1', 'recent'], diff --git a/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts b/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts index 931068191..5b7174a91 100644 --- a/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts +++ b/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts @@ -1,13 +1,17 @@ import { + extractStalkerItemId, PortalActivityType, PortalFavoriteItem, PortalRecentItem, StalkerPortalItem, } from 'shared-interfaces'; import { + buildCollectionUid, + CollectionContentType, CollectionSourceType, UnifiedCollectionItem, } from '../collection/unified-collection-item.interface'; +import { CollectionScope } from '../collection/scope-toggle.service'; export interface WorkspaceNavigationTarget { link: string[]; @@ -15,8 +19,10 @@ export interface WorkspaceNavigationTarget { } export const OPEN_LIVE_COLLECTION_ITEM_STATE_KEY = 'openLiveCollectionItem'; +export const OPEN_COLLECTION_DETAIL_STATE_KEY = 'openCollectionDetailItem'; export const OPEN_STALKER_ITEM_STATE_KEY = 'openStalkerItem'; export const STALKER_RETURN_TO_STATE_KEY = 'stalkerReturnTo'; +export const COLLECTION_VIEW_STATE_KEY = 'collectionViewState'; export interface OpenLiveCollectionItemState { contentType: 'live'; @@ -27,6 +33,15 @@ export interface OpenLiveCollectionItemState { imageUrl?: string | null; } +export interface OpenCollectionDetailItemState { + item: UnifiedCollectionItem; +} + +export interface CollectionViewState { + selectedContentType?: CollectionContentType; + scope?: CollectionScope; +} + export function getRecentItemNavigation( item: PortalRecentItem ): WorkspaceNavigationTarget { @@ -41,6 +56,14 @@ export function getRecentItemNavigation( }); } + const collectionItem = buildDashboardCollectionDetailItem(item); + if (collectionItem) { + return buildGlobalCollectionDetailNavigationTarget( + 'recent', + collectionItem + ); + } + if (item.source === 'stalker') { return buildStalkerDetailNavigationTarget({ playlistId: item.playlist_id, @@ -75,6 +98,14 @@ export function getGlobalFavoriteNavigation( }); } + const collectionItem = buildDashboardCollectionDetailItem(item); + if (collectionItem) { + return buildGlobalCollectionDetailNavigationTarget( + 'favorites', + collectionItem + ); + } + if (item.source === 'stalker') { return buildStalkerDetailNavigationTarget({ playlistId: item.playlist_id, @@ -134,15 +165,85 @@ export function buildLiveCollectionNavigationTarget(params: { imageUrl?: string | null; }): WorkspaceNavigationTarget { return { - link: buildCollectionRoute(params.sourceType, params.playlistId, params.mode), + link: buildCollectionRoute( + params.sourceType, + params.playlistId, + params.mode + ), state: { - [OPEN_LIVE_COLLECTION_ITEM_STATE_KEY]: buildOpenLiveCollectionItemState( - params - ), + [OPEN_LIVE_COLLECTION_ITEM_STATE_KEY]: + buildOpenLiveCollectionItemState(params), }, }; } +export function buildGlobalCollectionDetailNavigationTarget( + mode: 'favorites' | 'recent', + item: UnifiedCollectionItem +): WorkspaceNavigationTarget { + return { + link: [ + '/workspace', + mode === 'favorites' ? 'global-favorites' : 'global-recent', + ], + state: { + [OPEN_COLLECTION_DETAIL_STATE_KEY]: + buildOpenCollectionDetailItemState(item), + }, + }; +} + +export function buildCollectionViewState(params: { + selectedContentType?: CollectionContentType | null; + scope?: CollectionScope | null; +}): CollectionViewState | null { + const selectedContentType = normalizeCollectionContentType( + params.selectedContentType + ); + const scope = normalizeCollectionScope(params.scope); + + if (!selectedContentType && !scope) { + return null; + } + + return { + ...(selectedContentType ? { selectedContentType } : {}), + ...(scope ? { scope } : {}), + }; +} + +export function getCollectionViewState( + state: unknown +): CollectionViewState | null { + const record = toStateRecord(state); + const candidate = toStateRecord(record?.[COLLECTION_VIEW_STATE_KEY]); + + return buildCollectionViewState({ + selectedContentType: candidate?.[ + 'selectedContentType' + ] as CollectionContentType | null, + scope: candidate?.['scope'] as CollectionScope | null, + }); +} + +export function buildOpenCollectionDetailItemState( + item: UnifiedCollectionItem +): OpenCollectionDetailItemState { + return { + item: normalizeCollectionDetailItem(item), + }; +} + +export function getOpenCollectionDetailItemState( + state: unknown +): OpenCollectionDetailItemState | null { + const record = toStateRecord(state); + const candidate = toStateRecord(record?.[OPEN_COLLECTION_DETAIL_STATE_KEY]); + const item = normalizeCollectionDetailItem(candidate?.['item']); + + return item ? { item } : null; +} + export function buildOpenLiveCollectionItemState(params: { sourceType: CollectionSourceType; playlistId: string; @@ -175,7 +276,9 @@ export function getOpenLiveCollectionItemState( const playlistId = toPathSegment( (candidate as Record)['playlistId'] ); - const itemId = toPathSegment((candidate as Record)['itemId']); + const itemId = toPathSegment( + (candidate as Record)['itemId'] + ); if ( !playlistId || @@ -272,10 +375,7 @@ export function getUnifiedCollectionNavigation( item: buildStalkerStateItem( item.stalkerItem as StalkerPortalItem | undefined, { - id: - item.stalkerId ?? - getLastSegment(item.uid, '::') ?? - '', + id: item.stalkerId ?? getLastSegment(item.uid, '::') ?? '', title: item.name, type: item.contentType, category_id: item.categoryId, @@ -300,7 +400,13 @@ export function buildStalkerDetailNavigationTarget(params: { params.categoryId ?? toStalkerCategoryId(params.type) ); const link = normalizedCategoryId - ? ['/workspace', 'stalker', params.playlistId, section, normalizedCategoryId] + ? [ + '/workspace', + 'stalker', + params.playlistId, + section, + normalizedCategoryId, + ] : ['/workspace', 'stalker', params.playlistId, section]; const state: Record = { @@ -394,7 +500,7 @@ export function matchesOpenLiveCollectionItem( const normalizedTargetTitle = target.title?.trim().toLowerCase(); return Boolean( normalizedTargetTitle && - item.name.trim().toLowerCase() === normalizedTargetTitle + item.name.trim().toLowerCase() === normalizedTargetTitle ); } @@ -418,6 +524,72 @@ function normalizeReturnToState( return null; } +function normalizeCollectionContentType( + value: CollectionContentType | null | undefined +): CollectionContentType | null { + return value === 'live' || value === 'movie' || value === 'series' + ? value + : null; +} + +function normalizeCollectionScope( + value: CollectionScope | null | undefined +): CollectionScope | null { + return value === 'playlist' || value === 'all' ? value : null; +} + +function buildDashboardCollectionDetailItem( + item: PortalFavoriteItem | PortalRecentItem +): UnifiedCollectionItem | null { + if ( + item.type === 'live' || + (item.source !== 'xtream' && item.source !== 'stalker') + ) { + return null; + } + + if (item.source === 'xtream') { + return normalizeCollectionDetailItem({ + uid: buildCollectionUid('xtream', item.playlist_id, item.xtream_id), + name: item.title, + contentType: item.type, + sourceType: 'xtream', + playlistId: item.playlist_id, + playlistName: item.playlist_name ?? '', + posterUrl: item.poster_url ?? null, + xtreamId: toOptionalNumber(item.xtream_id), + contentId: toOptionalNumber(item.id), + categoryId: item.category_id, + addedAt: 'added_at' in item ? item.added_at : undefined, + viewedAt: 'viewed_at' in item ? item.viewed_at : undefined, + }); + } + + const stalkerItem = buildStalkerStateItem(item.stalker_item, { + id: item.id, + title: item.title, + type: item.type, + category_id: item.category_id, + poster_url: item.poster_url, + }); + const stalkerId = extractStalkerItemId(stalkerItem, item.playlist_id); + + return normalizeCollectionDetailItem({ + uid: buildCollectionUid('stalker', item.playlist_id, stalkerId), + name: item.title, + contentType: item.type, + sourceType: 'stalker', + playlistId: item.playlist_id, + playlistName: item.playlist_name ?? '', + posterUrl: item.poster_url ?? null, + stalkerId, + categoryId: item.category_id, + stalkerItem, + addedAt: 'added_at' in item ? item.added_at : undefined, + viewedAt: 'viewed_at' in item ? item.viewed_at : undefined, + }); +} + export function buildStalkerStateItem( rawItem: StalkerPortalItem | undefined, fallback: { @@ -473,6 +645,69 @@ function toOptionalPathSegment(value: unknown): string | undefined { return normalized ? normalized : undefined; } +function toOptionalNumber(value: unknown): number | undefined { + const rawValue = String(value ?? '').trim(); + if (!rawValue) { + return undefined; + } + + const normalized = Number(rawValue); + return Number.isFinite(normalized) ? normalized : undefined; +} + +function normalizeCollectionDetailItem( + candidate: unknown +): UnifiedCollectionItem | null { + const record = toStateRecord(candidate); + if (!record) { + return null; + } + + const contentType = toPathSegment(record['contentType']); + const sourceType = toPathSegment( + record['sourceType'] + ) as CollectionSourceType; + const uid = toPathSegment(record['uid']); + const name = toPathSegment(record['name']); + const playlistId = toPathSegment(record['playlistId']); + + if ( + !uid || + !name || + !playlistId || + (contentType !== 'movie' && contentType !== 'series') || + (sourceType !== 'xtream' && sourceType !== 'stalker') + ) { + return null; + } + + return { + uid, + name, + contentType, + sourceType, + playlistId, + playlistName: toPathSegment(record['playlistName']), + posterUrl: + toOptionalPathSegment(record['posterUrl']) ?? + toOptionalPathSegment(record['logo']) ?? + null, + logo: toOptionalPathSegment(record['logo']) ?? null, + xtreamId: toOptionalNumber(record['xtreamId']), + contentId: toOptionalNumber(record['contentId']), + stalkerId: toOptionalPathSegment(record['stalkerId']), + stalkerCmd: toOptionalPathSegment(record['stalkerCmd']), + stalkerPortalUrl: toOptionalPathSegment(record['stalkerPortalUrl']), + stalkerMacAddress: toOptionalPathSegment(record['stalkerMacAddress']), + categoryId: toOptionalPathSegment(record['categoryId']), + rating: toOptionalPathSegment(record['rating']), + addedAt: toOptionalPathSegment(record['addedAt']), + viewedAt: toOptionalPathSegment(record['viewedAt']), + position: toOptionalNumber(record['position']), + stalkerItem: toStateRecord(record['stalkerItem']) ?? undefined, + }; +} + function buildCollectionRoute( sourceType: CollectionSourceType, playlistId: string, @@ -489,9 +724,7 @@ function buildCollectionRoute( return ['/workspace', 'playlists', playlistId, mode]; } -function toStateRecord( - state: unknown -): Record | null { +function toStateRecord(state: unknown): Record | null { return state && typeof state === 'object' ? (state as Record) : null; diff --git a/libs/portal/stalker/feature/src/index.ts b/libs/portal/stalker/feature/src/index.ts index 644b6b87c..5aae4db16 100644 --- a/libs/portal/stalker/feature/src/index.ts +++ b/libs/portal/stalker/feature/src/index.ts @@ -1,4 +1,5 @@ export * from './lib/stalker-inline-detail/stalker-inline-detail.component'; +export * from './lib/stalker-collection-detail.component'; export * from './lib/stalker-collection-channels-list/stalker-collection-channels-list.component'; export * from './lib/stalker-catalog-detail/stalker-catalog-detail.component'; export * from './lib/stalker-favorites-button/stalker-favorites-button.component'; diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts new file mode 100644 index 000000000..646778b68 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts @@ -0,0 +1,285 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + input, + output, + signal, + untracked, +} from '@angular/core'; +import { ContentHeroComponent } from 'components'; +import { + buildStalkerStateItem, + toStalkerCategoryId, + UnifiedCollectionItem, +} from '@iptvnator/portal/shared/util'; +import { + buildStalkerSelectedVodItem, + clearStalkerDetailViewState, + createPortalFavoritesResource, + createRefreshTrigger, + createStalkerDetailViewState, + createStalkerInlineDetailState, + isSelectedStalkerVodFavorite, + isStalkerSeriesFlag, + StalkerSelectedVodItem, + StalkerStore, + toggleStalkerVodFavorite, +} from '@iptvnator/portal/stalker/data-access'; +import { PlaylistsService } from 'services'; +import { Playlist, StalkerPortalItem, VodDetailsItem } from 'shared-interfaces'; +import { firstValueFrom } from 'rxjs'; +import { StalkerInlineDetailComponent } from './stalker-inline-detail/stalker-inline-detail.component'; + +interface StalkerCollectionStateSnapshot { + currentPlaylist: Playlist | undefined; + selectedContentType: 'vod' | 'itv' | 'series'; + selectedCategoryId: string | null | undefined; + selectedItem: unknown; +} + +@Component({ + selector: 'app-stalker-collection-detail', + imports: [ContentHeroComponent, StalkerInlineDetailComponent], + template: ` + @if (inlineDetail().categoryId) { + + } @else { + + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [ + ` + :host { + display: block; + width: 100%; + height: 100%; + min-height: 0; + } + `, + ], +}) +export class StalkerCollectionDetailComponent { + readonly item = input(null); + readonly closeRequested = output(); + + private readonly playlistsService = inject(PlaylistsService); + private readonly stalkerStore = inject(StalkerStore); + private readonly originalState = this.captureStoreState(); + private readonly favoritesRefresh = createRefreshTrigger(); + + readonly itemDetails = signal(null); + readonly vodDetailsItem = signal(null); + readonly isSelectedVodFavorite = signal(false); + readonly inlineDetail = computed(() => + createStalkerInlineDetailState( + this.itemDetails(), + this.vodDetailsItem(), + this.item()?.contentType === 'series' ? 'series' : 'vod' + ) + ); + + readonly portalFavorites = createPortalFavoritesResource( + this.playlistsService, + () => this.stalkerStore.currentPlaylist()?._id, + () => this.favoritesRefresh.refreshVersion() + ); + + private initRequestId = 0; + + constructor() { + effect(() => { + this.portalFavorites.value(); + this.syncSelectedVodFavorite(); + }); + + effect(() => { + const item = this.item(); + untracked(() => { + void this.prepareDetail(item); + }); + }); + } + + ngOnDestroy(): void { + void this.stalkerStore.setCurrentPlaylist( + this.originalState.currentPlaylist + ); + this.stalkerStore.setSelectedContentType( + this.originalState.selectedContentType + ); + this.stalkerStore.setSelectedCategory( + this.originalState.selectedCategoryId + ); + this.stalkerStore.setSelectedItem( + this.originalState.selectedItem as never + ); + } + + onVodPlay(item: VodDetailsItem): void { + if (item.type === 'stalker') { + this.stalkerStore.createLinkToPlayVod( + item.cmd, + item.data.info?.name, + item.data.info?.movie_image + ); + } + } + + onVodFavoriteToggled(event: { + item: VodDetailsItem; + isFavorite: boolean; + }): void { + toggleStalkerVodFavorite(event, { + addToFavorites: (item, onDone) => + this.stalkerStore.addToFavorites( + item as StalkerPortalItem, + onDone + ), + removeFromFavorites: (favoriteId, onDone) => + this.stalkerStore.removeFromFavorites(favoriteId, onDone), + onComplete: () => { + this.favoritesRefresh.refresh(); + this.syncSelectedVodFavorite(); + }, + }); + } + + private async prepareDetail( + item: UnifiedCollectionItem | null + ): Promise { + const requestId = ++this.initRequestId; + + if (!item) { + this.clearLocalDetailState(); + return; + } + + const playlist = await this.loadPlaylist(item.playlistId); + if (requestId !== this.initRequestId) { + return; + } + + if (!playlist?.macAddress || !playlist.portalUrl) { + this.clearLocalDetailState(); + return; + } + + await this.stalkerStore.setCurrentPlaylist(playlist); + if (requestId !== this.initRequestId) { + return; + } + + const stalkerItem = this.resolveStalkerItem(item); + const hasEmbeddedSeries = Array.isArray( + (stalkerItem as { series?: unknown[] }).series + ) + ? (stalkerItem as { series?: unknown[] }).series!.length > 0 + : false; + const needsSeriesFetch = + item.contentType === 'movie' && + !hasEmbeddedSeries && + isStalkerSeriesFlag( + (stalkerItem as { is_series?: unknown }).is_series + ); + const itemDetails = buildStalkerSelectedVodItem( + stalkerItem as never, + needsSeriesFetch + ); + + this.stalkerStore.setSelectedContentType( + item.contentType === 'series' ? 'series' : 'vod' + ); + this.stalkerStore.setSelectedCategory( + item.categoryId ?? toStalkerCategoryId(item.contentType) + ); + this.stalkerStore.setSelectedItem(itemDetails); + this.itemDetails.set(itemDetails); + + if ( + item.contentType === 'movie' && + !hasEmbeddedSeries && + !needsSeriesFetch + ) { + const detailViewState = createStalkerDetailViewState( + itemDetails, + playlist._id + ); + this.itemDetails.set(detailViewState.itemDetails); + this.vodDetailsItem.set(detailViewState.vodDetailsItem); + this.syncSelectedVodFavorite(); + return; + } + + const cleared = clearStalkerDetailViewState(); + this.vodDetailsItem.set(cleared.vodDetailsItem); + this.isSelectedVodFavorite.set(false); + } + + private captureStoreState(): StalkerCollectionStateSnapshot { + return { + currentPlaylist: + (this.stalkerStore.currentPlaylist() as Playlist | undefined) ?? + undefined, + selectedContentType: this.stalkerStore.selectedContentType(), + selectedCategoryId: this.stalkerStore.selectedCategoryId(), + selectedItem: this.stalkerStore.selectedItem(), + }; + } + + private async loadPlaylist(playlistId: string): Promise { + try { + return ( + (await firstValueFrom( + this.playlistsService.getPlaylistById(playlistId) + )) ?? null + ); + } catch { + return null; + } + } + + private resolveStalkerItem(item: UnifiedCollectionItem): StalkerPortalItem { + return buildStalkerStateItem( + item.stalkerItem as StalkerPortalItem | undefined, + { + id: + item.stalkerId ?? + item.uid.split('::')[item.uid.split('::').length - 1] ?? + item.uid, + title: item.name, + type: item.contentType, + category_id: item.categoryId, + poster_url: item.posterUrl ?? item.logo ?? undefined, + } + ) as StalkerPortalItem; + } + + private syncSelectedVodFavorite(): void { + this.isSelectedVodFavorite.set( + isSelectedStalkerVodFavorite( + this.vodDetailsItem(), + this.portalFavorites.value() ?? [] + ) + ); + } + + private clearLocalDetailState(): void { + const cleared = clearStalkerDetailViewState(); + this.itemDetails.set(cleared.itemDetails); + this.vodDetailsItem.set(cleared.vodDetailsItem); + this.isSelectedVodFavorite.set(false); + } +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-route.component.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-route.component.ts index c8fab48e0..c31ad764f 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-collection-route.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-collection-route.component.ts @@ -5,18 +5,33 @@ import { input, } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; -import { UnifiedCollectionPageComponent } from '@iptvnator/portal/shared/ui'; +import { + UnifiedCollectionDetailDirective, + UnifiedCollectionPageComponent, +} from '@iptvnator/portal/shared/ui'; import { routeParamSignal } from '@iptvnator/portal/shared/util'; +import { StalkerCollectionDetailComponent } from './stalker-collection-detail.component'; @Component({ selector: 'app-stalker-collection-route', - imports: [UnifiedCollectionPageComponent], + imports: [ + StalkerCollectionDetailComponent, + UnifiedCollectionDetailDirective, + UnifiedCollectionPageComponent, + ], template: ` + > + + + + `, changeDetection: ChangeDetectionStrategy.OnPush, }) diff --git a/libs/portal/xtream/feature/src/index.ts b/libs/portal/xtream/feature/src/index.ts index a207275a1..27233563f 100644 --- a/libs/portal/xtream/feature/src/index.ts +++ b/libs/portal/xtream/feature/src/index.ts @@ -8,6 +8,7 @@ export * from './lib/portal-channels-list/portal-channels-list.component'; export * from './lib/recently-added/recently-added.component'; export * from './lib/search-results/search-results.component'; export * from './lib/serial-details/serial-details.component'; +export * from './lib/xtream-collection-detail.component'; export * from './lib/vod-details/vod-details-route.component'; export * from './lib/xtream-content-gate.component'; export * from './lib/xtream-catalog-facade.service'; diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts index ad6013557..8815b5b08 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts @@ -1,5 +1,12 @@ import { Location, SlicePipe } from '@angular/common'; -import { Component, effect, inject, OnDestroy, OnInit, signal } from '@angular/core'; +import { + Component, + effect, + inject, + OnDestroy, + OnInit, + signal, +} from '@angular/core'; import { MatIcon } from '@angular/material/icon'; import { MatSnackBar } from '@angular/material/snack-bar'; import { ActivatedRoute } from '@angular/router'; @@ -32,7 +39,19 @@ type XtreamSerieDetailsView = XtreamSerieDetails & { @Component({ selector: 'app-serial-details', templateUrl: './serial-details.component.html', - styleUrls: ['../../../../../../ui/components/src/lib/styles/detail-view.scss'], + styleUrls: [ + '../../../../../../ui/components/src/lib/styles/detail-view.scss', + ], + styles: [ + ` + :host { + display: block; + width: 100%; + height: 100%; + min-height: 0; + } + `, + ], imports: [ ContentHeroComponent, MatIcon, @@ -198,7 +217,9 @@ export class SerialDetailsComponent implements OnInit, OnDestroy { episodeNumber: Number(episode.episode_num), }; - const position = this.episodePlaybackPositions().get(Number(episode.id)); + const position = this.episodePlaybackPositions().get( + Number(episode.id) + ); const playback: ResolvedPortalPlayback = { streamUrl, @@ -308,10 +329,11 @@ export class SerialDetailsComponent implements OnInit, OnDestroy { playlistId: string, seriesXtreamId: number ): Promise { - const positions = await this.playbackPositions.getSeriesPlaybackPositions( - playlistId, - seriesXtreamId - ); + const positions = + await this.playbackPositions.getSeriesPlaybackPositions( + playlistId, + seriesXtreamId + ); const positionsMap = new Map(); positions.forEach((position) => { positionsMap.set(position.contentXtreamId, position); @@ -319,7 +341,9 @@ export class SerialDetailsComponent implements OnInit, OnDestroy { this.episodePlaybackPositions.set(positionsMap); } - private updateEpisodePlaybackPosition(position: PlaybackPositionData): void { + private updateEpisodePlaybackPosition( + position: PlaybackPositionData + ): void { const updated = new Map(this.episodePlaybackPositions()); updated.set(position.contentXtreamId, position); this.episodePlaybackPositions.set(updated); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.scss b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.scss index fa7d6038b..c35099222 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.scss +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.scss @@ -1,13 +1,17 @@ :host { + display: block; + width: 100%; + height: 100%; + min-height: 0; + ::ng-deep .details__tag--status { color: var(--text-primary); border-color: color-mix(in srgb, var(--accent-color) 28%, transparent); - background: - linear-gradient( - 135deg, - color-mix(in srgb, var(--accent-color) 12%, transparent) 0%, - var(--tag-bg) 100% - ); + background: linear-gradient( + 135deg, + color-mix(in srgb, var(--accent-color) 12%, transparent) 0%, + var(--tag-bg) 100% + ); box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08); } @@ -17,12 +21,11 @@ padding: 14px 16px; border: 1px solid color-mix(in srgb, var(--tag-border) 85%, transparent); border-radius: 14px; - background: - linear-gradient( - 180deg, - color-mix(in srgb, var(--surface-bg) 86%, transparent) 0%, - color-mix(in srgb, var(--tag-bg) 86%, transparent) 100% - ); + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--surface-bg) 86%, transparent) 0%, + color-mix(in srgb, var(--tag-bg) 86%, transparent) 100% + ); color: var(--text-secondary); line-height: 1.5; backdrop-filter: blur(14px); @@ -32,8 +35,9 @@ min-width: min(220px, 100%); .value--mono { - font-family: ui-monospace, 'SFMono-Regular', 'SF Mono', Menlo, - Consolas, monospace; + font-family: + ui-monospace, 'SFMono-Regular', 'SF Mono', Menlo, Consolas, + monospace; font-size: 0.8125rem; letter-spacing: 0.03em; } diff --git a/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.ts b/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.ts new file mode 100644 index 000000000..4ebd3f839 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.ts @@ -0,0 +1,243 @@ +import { NgComponentOutlet } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + Injector, + Type, + effect, + inject, + input, + signal, + untracked, +} from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { ContentHeroComponent } from 'components'; +import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util'; +import { + XtreamPlaylistData, + XtreamStore, +} from '@iptvnator/portal/xtream/data-access'; +import { PlaylistsService } from 'services'; +import { Playlist } from 'shared-interfaces'; +import { firstValueFrom } from 'rxjs'; +import { SerialDetailsComponent } from './serial-details/serial-details.component'; +import { VodDetailsRouteComponent } from './vod-details/vod-details-route.component'; + +interface XtreamCollectionStateSnapshot { + playlistId: string | null; + currentPlaylist: XtreamPlaylistData | null; + selectedContentType: 'live' | 'vod' | 'series'; + selectedCategoryId: number | null; + selectedItem: unknown; + isLoadingDetails: boolean; + detailsError: string | null; +} + +@Component({ + selector: 'app-xtream-collection-detail', + imports: [ContentHeroComponent, NgComponentOutlet], + template: ` + @if (detailComponent() && detailInjector()) { + + } @else { + + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [ + ` + :host { + display: block; + width: 100%; + height: 100%; + min-height: 0; + } + `, + ], +}) +export class XtreamCollectionDetailComponent { + readonly item = input(null); + + private readonly parentInjector = inject(Injector); + private readonly playlistsService = inject(PlaylistsService); + private readonly xtreamStore = inject(XtreamStore); + private readonly originalState = this.captureStoreState(); + readonly detailComponent = signal | null>(null); + readonly detailInjector = signal(null); + + private initRequestId = 0; + + constructor() { + effect(() => { + const item = this.item(); + untracked(() => { + void this.prepareDetail(item); + }); + }); + } + + ngOnDestroy(): void { + this.restoreStoreState(); + } + + private async prepareDetail( + item: UnifiedCollectionItem | null + ): Promise { + const requestId = ++this.initRequestId; + + if (!item) { + this.detailComponent.set(null); + this.detailInjector.set(null); + return; + } + + const xtreamId = this.resolveXtreamId(item); + if (!xtreamId) { + this.detailComponent.set(null); + this.detailInjector.set(null); + return; + } + + const playlist = await this.loadPlaylist(item.playlistId); + if (requestId !== this.initRequestId) { + return; + } + + if (!playlist?.serverUrl || !playlist.username || !playlist.password) { + this.detailComponent.set(null); + this.detailInjector.set(null); + return; + } + + const xtreamPlaylist = this.toXtreamPlaylist(playlist); + this.xtreamStore.setPlaylistId(xtreamPlaylist.id); + this.xtreamStore.setCurrentPlaylist(xtreamPlaylist); + this.xtreamStore.setSelectedContentType( + item.contentType === 'movie' ? 'vod' : 'series' + ); + this.xtreamStore.setSelectedCategory( + this.toCategoryId(item.categoryId) + ); + this.xtreamStore.setSelectedItem(null); + this.xtreamStore.setIsLoadingDetails(false); + this.xtreamStore.setDetailsError(null); + + this.detailComponent.set( + item.contentType === 'movie' + ? VodDetailsRouteComponent + : SerialDetailsComponent + ); + this.detailInjector.set( + Injector.create({ + providers: [ + { + provide: ActivatedRoute, + useValue: { + snapshot: { + params: + item.contentType === 'movie' + ? { + categoryId: this.toPathSegment( + item.categoryId + ), + vodId: xtreamId, + } + : { + categoryId: this.toPathSegment( + item.categoryId + ), + serialId: xtreamId, + }, + }, + }, + }, + ], + parent: this.parentInjector, + }) + ); + } + + private captureStoreState(): XtreamCollectionStateSnapshot { + return { + playlistId: this.xtreamStore.playlistId(), + currentPlaylist: this.xtreamStore.currentPlaylist(), + selectedContentType: this.xtreamStore.selectedContentType(), + selectedCategoryId: this.xtreamStore.selectedCategoryId(), + selectedItem: this.xtreamStore.selectedItem(), + isLoadingDetails: this.xtreamStore.isLoadingDetails(), + detailsError: this.xtreamStore.detailsError(), + }; + } + + private restoreStoreState(): void { + this.xtreamStore.setPlaylistId(this.originalState.playlistId ?? ''); + this.xtreamStore.setCurrentPlaylist(this.originalState.currentPlaylist); + this.xtreamStore.setSelectedContentType( + this.originalState.selectedContentType + ); + this.xtreamStore.setSelectedCategory( + this.originalState.selectedCategoryId + ); + this.xtreamStore.setSelectedItem( + this.originalState.selectedItem as never + ); + this.xtreamStore.setIsLoadingDetails( + this.originalState.isLoadingDetails + ); + this.xtreamStore.setDetailsError(this.originalState.detailsError); + } + + private async loadPlaylist(playlistId: string): Promise { + try { + return ( + (await firstValueFrom( + this.playlistsService.getPlaylistById(playlistId) + )) ?? null + ); + } catch { + return null; + } + } + + private toXtreamPlaylist(playlist: Playlist): XtreamPlaylistData { + return { + id: playlist._id, + name: playlist.title || '', + title: playlist.title || '', + serverUrl: playlist.serverUrl || '', + username: playlist.username || '', + password: playlist.password || '', + type: 'xtream', + userAgent: playlist.userAgent, + referrer: playlist.referrer, + origin: playlist.origin, + }; + } + + private resolveXtreamId(item: UnifiedCollectionItem): string | null { + const directId = Number(item.xtreamId); + if (Number.isFinite(directId) && directId > 0) { + return String(directId); + } + + const uidSegments = item.uid.split('::'); + const fallbackId = Number(uidSegments[uidSegments.length - 1]); + return Number.isFinite(fallbackId) && fallbackId > 0 + ? String(fallbackId) + : null; + } + + private toCategoryId(value: unknown): number | null { + const categoryId = Number(String(value ?? '').trim()); + return Number.isFinite(categoryId) ? categoryId : null; + } + + private toPathSegment(value: unknown): string { + return String(value ?? '').trim(); + } +} diff --git a/libs/portal/xtream/feature/src/lib/xtream-collection-route.component.ts b/libs/portal/xtream/feature/src/lib/xtream-collection-route.component.ts index 312d559f0..b32934bf3 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-collection-route.component.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-collection-route.component.ts @@ -5,15 +5,21 @@ import { input, } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; -import { UnifiedCollectionPageComponent } from '@iptvnator/portal/shared/ui'; +import { + UnifiedCollectionDetailDirective, + UnifiedCollectionPageComponent, +} from '@iptvnator/portal/shared/ui'; import { routeParamSignal } from '@iptvnator/portal/shared/util'; import { XtreamCachedOfflineNoticeComponent } from './xtream-cached-offline-notice.component'; +import { XtreamCollectionDetailComponent } from './xtream-collection-detail.component'; @Component({ selector: 'app-xtream-collection-route', imports: [ + UnifiedCollectionDetailDirective, UnifiedCollectionPageComponent, XtreamCachedOfflineNoticeComponent, + XtreamCollectionDetailComponent, ], template: ` @@ -21,7 +27,11 @@ import { XtreamCachedOfflineNoticeComponent } from './xtream-cached-offline-noti [mode]="mode()" [portalType]="portalType()" [playlistId]="playlistId()" - /> + > + + + + `, changeDetection: ChangeDetectionStrategy.OnPush, }) diff --git a/libs/ui/components/src/lib/content-hero/content-hero.component.scss b/libs/ui/components/src/lib/content-hero/content-hero.component.scss index 20ec01b63..ecfa08daa 100644 --- a/libs/ui/components/src/lib/content-hero/content-hero.component.scss +++ b/libs/ui/components/src/lib/content-hero/content-hero.component.scss @@ -61,6 +61,8 @@ top: 16px; left: 16px; z-index: 10; + app-region: no-drag; + -webkit-app-region: no-drag; display: flex; align-items: center; justify-content: center; @@ -481,7 +483,6 @@ } } - // ============================================================================ // Mobile Responsive - Option A: Keep poster grid layout // ============================================================================ diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts index 5845814dd..010da56fd 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts @@ -2,10 +2,7 @@ import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; -import { - selectAllPlaylistsMeta, - selectPlaylistsLoadingFlag, -} from 'm3u-state'; +import { selectAllPlaylistsMeta, selectPlaylistsLoadingFlag } from 'm3u-state'; import { of } from 'rxjs'; import { DatabaseService, PlaylistsService } from 'services'; import { Playlist, PlaylistMeta } from 'shared-interfaces'; @@ -105,14 +102,12 @@ describe('DashboardDataService', () => { const playlistsServiceMock = { getPlaylistById: jest.fn().mockReturnValue(of(playlistMock)), setFavorites: jest.fn().mockReturnValue(of(undefined)), - removeFromM3uRecentlyViewed: jest - .fn() - .mockReturnValue( - of({ - ...playlistMock, - recentlyViewed: [], - }) - ), + removeFromM3uRecentlyViewed: jest.fn().mockReturnValue( + of({ + ...playlistMock, + recentlyViewed: [], + }) + ), }; beforeEach(() => { @@ -241,10 +236,14 @@ describe('DashboardDataService', () => { ]) ); expect( - service.globalFavoriteItems().filter((item) => item.type === 'movie') + service + .globalFavoriteItems() + .filter((item) => item.type === 'movie') ).toHaveLength(1); expect( - service.globalFavoriteItems().filter((item) => item.type === 'series') + service + .globalFavoriteItems() + .filter((item) => item.type === 'series') ).toHaveLength(1); }); @@ -273,6 +272,45 @@ describe('DashboardDataService', () => { }); }); + it('routes Xtream non-live favorites through the global favorites page with inline detail state', async () => { + dbServiceMock.getAllGlobalFavorites.mockResolvedValue([ + { + id: 51, + category_id: 12, + title: 'Action Movie', + rating: '8.0', + added_at: '2026-02-02T10:00:00.000Z', + poster_url: 'https://example.com/movie.png', + xtream_id: 5001, + type: 'movie', + playlist_id: 'xtream-1', + playlist_name: 'Xtream Playlist', + }, + ]); + + await service.reloadGlobalFavorites(); + const movieItem = service + .globalFavoriteItems() + .find((item) => item.type === 'movie'); + + expect(movieItem).toBeDefined(); + expect(service.getGlobalFavoriteLink(movieItem!)).toEqual([ + '/workspace', + 'global-favorites', + ]); + expect(service.getGlobalFavoriteNavigationState(movieItem!)).toEqual({ + openCollectionDetailItem: { + item: expect.objectContaining({ + uid: 'xtream::xtream-1::5001', + name: 'Action Movie', + contentType: 'movie', + sourceType: 'xtream', + playlistId: 'xtream-1', + }), + }, + }); + }); + it('removes M3U favorites via PlaylistsService', async () => { await service.reloadGlobalFavorites(); const m3uItem = service @@ -388,6 +426,45 @@ describe('DashboardDataService', () => { }); }); + it('routes Xtream non-live recents through the global recent page with inline detail state', async () => { + dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([ + { + id: 91, + category_id: 18, + title: 'Recent Movie', + rating: '7.8', + viewed_at: '2026-02-04T10:00:00.000Z', + poster_url: 'https://example.com/recent-movie.png', + xtream_id: 7001, + type: 'movie', + playlist_id: 'xtream-1', + playlist_name: 'Xtream Playlist', + }, + ]); + + await service.reloadGlobalRecentItems(); + const movieItem = service + .globalRecentItems() + .find((item) => item.source === 'xtream'); + + expect(movieItem).toBeDefined(); + expect(service.getRecentItemLink(movieItem!)).toEqual([ + '/workspace', + 'global-recent', + ]); + expect(service.getRecentItemNavigationState(movieItem!)).toEqual({ + openCollectionDetailItem: { + item: expect.objectContaining({ + uid: 'xtream::xtream-1::7001', + name: 'Recent Movie', + contentType: 'movie', + sourceType: 'xtream', + playlistId: 'xtream-1', + }), + }, + }); + }); + it('removes M3U recently viewed via PlaylistsService', async () => { const m3uItem = service .globalRecentItems() @@ -399,9 +476,6 @@ describe('DashboardDataService', () => { expect( playlistsServiceMock.removeFromM3uRecentlyViewed - ).toHaveBeenCalledWith( - 'm3u-1', - 'https://example.com/stream-1.m3u8' - ); + ).toHaveBeenCalledWith('m3u-1', 'https://example.com/stream-1.m3u8'); }); }); diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts index 9c6856f7a..91eed7f18 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts @@ -42,6 +42,9 @@ import { toTimestamp, } from './dashboard-mappers'; import { + buildStalkerDetailNavigationTarget, + buildStalkerStateItem, + buildXtreamNavigationTarget, getGlobalFavoriteNavigation, getRecentItemNavigation, WorkspaceNavigationTarget, @@ -80,12 +83,15 @@ export class DashboardDataService { private readonly globalFavoritesLoadingState = signal(true); private readonly globalFavoritesLoadedState = signal(false); readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta); - readonly playlistsLoaded = this.store.selectSignal(selectPlaylistsLoadingFlag); + readonly playlistsLoaded = this.store.selectSignal( + selectPlaylistsLoadingFlag + ); readonly globalRecentLoading = this.globalRecentLoadingState.asReadonly(); readonly globalRecentLoaded = this.globalRecentLoadedState.asReadonly(); readonly globalFavoritesLoading = this.globalFavoritesLoadingState.asReadonly(); - readonly globalFavoritesLoaded = this.globalFavoritesLoadedState.asReadonly(); + readonly globalFavoritesLoaded = + this.globalFavoritesLoadedState.asReadonly(); private readonly playlistFavoritesReloadKey = computed(() => { if (!this.playlistsLoaded()) { @@ -96,7 +102,11 @@ export class DashboardDataService { .map((playlist) => [ playlist._id, - playlist.serverUrl ? 'xtream' : playlist.macAddress ? 'stalker' : 'm3u', + playlist.serverUrl + ? 'xtream' + : playlist.macAddress + ? 'stalker' + : 'm3u', JSON.stringify(playlist.favorites ?? []), ].join('::') ) @@ -281,7 +291,8 @@ export class DashboardDataService { Array.isArray(playlist.favorites) && playlist.favorites.some( (favorite): favorite is string => - typeof favorite === 'string' && favorite.trim().length > 0 + typeof favorite === 'string' && + favorite.trim().length > 0 ) ); @@ -478,9 +489,7 @@ export class DashboardDataService { return this.getActivityItemTypeLabel(item); } - getRecentlyAddedItemTypeLabel( - item: DashboardRecentlyAddedItem - ): string { + getRecentlyAddedItemTypeLabel(item: DashboardRecentlyAddedItem): string { return this.getActivityItemTypeLabel(item); } @@ -502,13 +511,57 @@ export class DashboardDataService { } getRecentlyAddedLink(item: DashboardRecentlyAddedItem): string[] { - return getGlobalFavoriteNavigation(item).link; + if (item.source === 'stalker' && item.type !== 'live') { + return buildStalkerDetailNavigationTarget({ + playlistId: item.playlist_id, + type: item.type, + categoryId: item.category_id, + item: buildStalkerStateItem(item.stalker_item, { + id: item.id, + title: item.title, + type: item.type, + category_id: item.category_id, + poster_url: item.poster_url, + }), + }).link; + } + + return buildXtreamNavigationTarget({ + playlistId: item.playlist_id, + type: item.type, + categoryId: item.category_id, + itemId: item.xtream_id, + title: item.title, + imageUrl: item.poster_url, + }).link; } getRecentlyAddedNavigationState( item: DashboardRecentlyAddedItem ): WorkspaceNavigationTarget['state'] { - return getGlobalFavoriteNavigation(item).state; + if (item.source === 'stalker' && item.type !== 'live') { + return buildStalkerDetailNavigationTarget({ + playlistId: item.playlist_id, + type: item.type, + categoryId: item.category_id, + item: buildStalkerStateItem(item.stalker_item, { + id: item.id, + title: item.title, + type: item.type, + category_id: item.category_id, + poster_url: item.poster_url, + }), + }).state; + } + + return buildXtreamNavigationTarget({ + playlistId: item.playlist_id, + type: item.type, + categoryId: item.category_id, + itemId: item.xtream_id, + title: item.title, + imageUrl: item.poster_url, + }).state; } async removeGlobalFavorite(item: DashboardFavoriteItem): Promise { @@ -619,8 +672,7 @@ export class DashboardDataService { const channelId = String(channel.id ?? '').trim(); const channelUrl = String(channel.url ?? '').trim(); const matchedFavoriteId = favorites.find( - (favorite) => - favorite === channelId || favorite === channelUrl + (favorite) => favorite === channelId || favorite === channelUrl ); if (!matchedFavoriteId) {