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()) {
@@ -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) {