mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
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:
1 parent
562bfd009c
commit
5e41e3d1ce
19 files changed
+1671
-181
No files matched your search
@@ -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);
|
||||
}
|
||||
+21
-4
@@ -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)"
|
||||
/>
|
||||
}
|
||||
|
||||
+214
-12
@@ -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),
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
+306
-15
@@ -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>) }
|
||||
: {};
|
||||
}
|
||||
}
|
||||
+3
-27
@@ -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,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,
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user