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
This commit is contained in:
4gray committed 2026-04-18 10:16:09 +02:00
1 parent 562bfd009c
commit 5e41e3d1ce
19 files changed
+1671 -181

No files matched your search

+4 -4
View File
@@ -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
),
},
{
@@ -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: `
<app-unified-collection-page
[mode]="mode()"
[defaultScope]="defaultScope()"
>
<ng-template unifiedCollectionDetail let-item let-close="close">
@if (item.sourceType === 'xtream') {
<app-xtream-collection-detail [item]="item" />
} @else if (item.sourceType === 'stalker') {
<app-stalker-collection-detail
[item]="item"
(closeRequested)="close()"
/>
}
</ng-template>
</app-unified-collection-page>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class GlobalCollectionRouteComponent {
readonly mode = input<'favorites' | 'recent'>('favorites');
readonly defaultScope = input<CollectionScope | undefined>(undefined);
}
@@ -1,10 +1,19 @@
@if (isLoading()) {
<ng-content select="ng-template[unifiedCollectionDetail]"></ng-content>
@if (detailContext(); as detailContext) {
<ng-container
[ngTemplateOutlet]="detailTemplate()?.templateRef ?? null"
[ngTemplateOutletContext]="detailContext"
/>
} @else if (isLoading()) {
<div class="collection-header">
<span class="collection-title">{{ title() | translate }}</span>
<div class="collection-header-toggles">
<span class="toggle-placeholder"></span>
@if (showScopeToggle()) {
<span class="toggle-placeholder toggle-placeholder--scope"></span>
<span
class="toggle-placeholder toggle-placeholder--scope"
></span>
}
</div>
</div>
@@ -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
"
>
<mat-icon>delete_sweep</mat-icon>
</button>
@@ -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)"
/>
}
@@ -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<UnifiedCollectionItem>();
readonly removeItem = output<UnifiedCollectionItem>();
}
@Component({
template: `
<app-unified-collection-page
[mode]="mode"
[portalType]="portalType"
[defaultScope]="defaultScope"
>
<ng-template unifiedCollectionDetail let-item>
<div class="detail-probe">{{ item.name }}</div>
</ng-template>
</app-unified-collection-page>
`,
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<UnifiedCollectionPageComponent>;
let route: ActivatedRoute & {
@@ -69,10 +95,14 @@ describe('UnifiedCollectionPageComponent', () => {
parent: null;
};
paramMap: ReturnType<
BehaviorSubject<ReturnType<typeof convertToParamMap>>['asObservable']
BehaviorSubject<
ReturnType<typeof convertToParamMap>
>['asObservable']
>;
queryParamMap: ReturnType<
BehaviorSubject<ReturnType<typeof convertToParamMap>>['asObservable']
BehaviorSubject<
ReturnType<typeof convertToParamMap>
>['asObservable']
>;
pathFromRoot: ActivatedRoute[];
};
@@ -80,7 +110,9 @@ describe('UnifiedCollectionPageComponent', () => {
let routeQueryParamMap$: BehaviorSubject<
ReturnType<typeof convertToParamMap>
>;
let workspaceParamMap$: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
let workspaceParamMap$: BehaviorSubject<
ReturnType<typeof convertToParamMap>
>;
const playlistsLoaded = signal(false);
const playlists = signal<PlaylistMeta[]>([]);
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<string, unknown>
@@ -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),
}),
},
},
}
);
});
});
@@ -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<string>();
readonly playlistIdInput = input<string | undefined>(undefined, {
@@ -61,11 +77,16 @@ export class UnifiedCollectionPageComponent {
readonly defaultScope = input<CollectionScope>();
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<CollectionScope | null>(
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<CollectionViewState | null>(
getCollectionViewState(window.history.state)
);
readonly isLoading = signal(true);
readonly allItems = signal<UnifiedCollectionItem[]>([]);
readonly selectedContentType = signal<CollectionContentType>('live');
readonly selectedContentType = signal<CollectionContentType>(
this.historyCollectionViewState()?.selectedContentType ?? 'live'
);
readonly selectedDetailItem = signal<UnifiedCollectionItem | null>(null);
readonly pendingAutoOpenLiveItem = signal(
getOpenLiveCollectionItemState(window.history.state)
);
readonly detailContext = computed<UnifiedCollectionDetailContext | null>(
() => {
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<void> {
if (this.mode() === 'favorites') {
await this.favoritesData.removeFavorite(item);
@@ -235,9 +366,7 @@ export class UnifiedCollectionPageComponent {
}
async onReorder(items: UnifiedCollectionItem[]): Promise<void> {
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<string, unknown> {
return state && typeof state === 'object'
? { ...(state as Record<string, unknown>) }
: {};
}
}
@@ -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<UnifiedCollectionItem[]>();
@@ -35,8 +27,7 @@ export class UnifiedGridTabComponent {
readonly searchTerm = input('');
readonly removeItem = output<UnifiedCollectionItem>();
private readonly router = inject(Router);
readonly itemSelected = output<UnifiedCollectionItem>();
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 {
@@ -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'],
@@ -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<string, unknown>)['playlistId']
);
const itemId = toPathSegment((candidate as Record<string, unknown>)['itemId']);
const itemId = toPathSegment(
(candidate as Record<string, unknown>)['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<string, unknown> = {
@@ -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<string, unknown> | null {
function toStateRecord(state: unknown): Record<string, unknown> | null {
return state && typeof state === 'object'
? (state as Record<string, unknown>)
: null;
+1
View File
@@ -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';
@@ -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) {
<app-stalker-inline-detail
[categoryId]="inlineDetail().categoryId"
[seriesItem]="inlineDetail().seriesItem"
[isSeries]="inlineDetail().isSeries"
[vodDetailsItem]="inlineDetail().vodDetailsItem"
[isFavorite]="isSelectedVodFavorite()"
(backClicked)="closeRequested.emit()"
(playClicked)="onVodPlay($event)"
(favoriteToggled)="onVodFavoriteToggled($event)"
/>
} @else {
<app-content-hero [isLoading]="true" />
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
`
:host {
display: block;
width: 100%;
height: 100%;
min-height: 0;
}
`,
],
})
export class StalkerCollectionDetailComponent {
readonly item = input<UnifiedCollectionItem | null>(null);
readonly closeRequested = output<void>();
private readonly playlistsService = inject(PlaylistsService);
private readonly stalkerStore = inject(StalkerStore);
private readonly originalState = this.captureStoreState();
private readonly favoritesRefresh = createRefreshTrigger();
readonly itemDetails = signal<StalkerSelectedVodItem | null>(null);
readonly vodDetailsItem = signal<VodDetailsItem | null>(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<void> {
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<Playlist | null> {
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);
}
}
@@ -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: `
<app-unified-collection-page
[mode]="mode()"
[portalType]="portalType()"
[playlistId]="playlistId()"
/>
>
<ng-template unifiedCollectionDetail let-item let-close="close">
<app-stalker-collection-detail
[item]="item"
(closeRequested)="close()"
/>
</ng-template>
</app-unified-collection-page>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
+1
View File
@@ -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';
@@ -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<void> {
const positions = await this.playbackPositions.getSeriesPlaybackPositions(
playlistId,
seriesXtreamId
);
const positions =
await this.playbackPositions.getSeriesPlaybackPositions(
playlistId,
seriesXtreamId
);
const positionsMap = new Map<number, PlaybackPositionData>();
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);
@@ -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;
}
@@ -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()) {
<ng-container
*ngComponentOutlet="
detailComponent();
injector: detailInjector()
"
/>
} @else {
<app-content-hero [isLoading]="true" />
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
`
:host {
display: block;
width: 100%;
height: 100%;
min-height: 0;
}
`,
],
})
export class XtreamCollectionDetailComponent {
readonly item = input<UnifiedCollectionItem | null>(null);
private readonly parentInjector = inject(Injector);
private readonly playlistsService = inject(PlaylistsService);
private readonly xtreamStore = inject(XtreamStore);
private readonly originalState = this.captureStoreState();
readonly detailComponent = signal<Type<unknown> | null>(null);
readonly detailInjector = signal<Injector | null>(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<void> {
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<Playlist | null> {
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();
}
}
@@ -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: `
<app-xtream-cached-offline-notice />
@@ -21,7 +27,11 @@ import { XtreamCachedOfflineNoticeComponent } from './xtream-cached-offline-noti
[mode]="mode()"
[portalType]="portalType()"
[playlistId]="playlistId()"
/>
>
<ng-template unifiedCollectionDetail let-item>
<app-xtream-collection-detail [item]="item" />
</ng-template>
</app-unified-collection-page>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
@@ -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
// ============================================================================
@@ -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');
});
});
@@ -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<void> {
@@ -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) {