refactor: streamline navigation and layout handling across components

- Updated StalkerShellComponent to utilize utility functions for workspace layout detection and portal section resolution.
- Refactored StalkerVod utilities to include inline detail state normalization and added tests for new functionality.
- Modified workspace context panels to import shared components for better organization.
- Enhanced DownloadsComponent to utilize a collection context for managing categories and downloads.
- Updated FavoritesComponent to leverage a portal collection shell layout and improved category management.
- Refactored RecentlyViewedComponent to use a shared collection shell layout and optimized item filtering.
- Improved SearchResultsComponent to utilize query parameter signals for search term handling.
- Updated XtreamMainContainer and XtreamShell components to streamline layout detection and section management.
This commit is contained in:
4gray committed 2026-03-06 23:20:57 +01:00
1 parent ea55c485a7
commit 7bb61ab64c
47 files changed
+1487 -718

No files matched your search

@@ -1,4 +1,4 @@
@use '../../../nav-list.scss';
@use '../../../../nav-list.scss';
:host {
display: block;
@@ -5,7 +5,8 @@ import { MatTooltip } from '@angular/material/tooltip';
import { ActivatedRoute } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { PlaylistSwitcherComponent, ResizableDirective } from 'components';
import { CategoryViewComponent } from '../../../xtream-electron/category-view/category-view.component';
import { isWorkspaceLayoutRoute } from '../../navigation/portal-route.utils';
import { CategoryViewComponent } from '../category-view/category-view.component';
import { ContentCardComponent } from '../content-card/content-card.component';
@Component({
@@ -13,7 +14,7 @@ import { ContentCardComponent } from '../content-card/content-card.component';
templateUrl: './favorites-layout.component.html',
styleUrls: [
'./favorites-layout.component.scss',
'../../../xtream-electron/sidebar.scss',
'../../styles/portal-sidebar.scss',
],
imports: [
CategoryViewComponent,
@@ -27,9 +28,9 @@ import { ContentCardComponent } from '../content-card/content-card.component';
],
})
export class FavoritesLayoutComponent {
private readonly route = inject(ActivatedRoute);
readonly isWorkspaceLayout =
this.route.snapshot.data['layout'] === 'workspace';
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(
inject(ActivatedRoute)
);
readonly categories = input<any[]>([]);
readonly favorites = input<any[]>([]);
@@ -4,7 +4,7 @@ import { MatIcon } from '@angular/material/icon';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { PlaylistErrorViewComponent } from '../../../xtream-electron/playlist-error-view/playlist-error-view.component';
import { PlaylistErrorViewComponent } from '../playlist-error-view/playlist-error-view.component';
import { ProgressCapsuleComponent } from '../../../xtream-electron/shared/progress-capsule/progress-capsule.component';
import { WatchedBadgeComponent } from '../../../xtream-electron/shared/watched-badge/watched-badge.component';
@@ -1,2 +1,6 @@
export * from './content-card/content-card.component';
export * from './category-view/category-view.component';
export * from './header/header.component';
export * from './playlist-error-view/playlist-error-view.component';
export * from './portal-collection-shell/portal-collection-shell.component';
export * from './stalker-inline-detail/stalker-inline-detail.component';
@@ -0,0 +1,23 @@
@if (showDetails()) {
<ng-content select="[details]" />
} @else {
<app-favorites-layout
[categories]="categories()"
[favorites]="items()"
[playlistSubtitle]="playlistSubtitle()"
[playlistTitle]="playlistTitle()"
[selectedCategoryId]="selectedCategoryId()"
[titleTranslationString]="
layout().titleTranslationKey || 'CHANNELS.FAVORITES'
"
[removeTooltip]="layout().removeTooltip || ''"
[emptyIcon]="layout().emptyIcon || 'favorite_border'"
[showHeaderAction]="layout().showHeaderAction || false"
[headerActionIcon]="layout().headerActionIcon || 'delete_sweep'"
[headerActionTooltip]="layout().headerActionTooltip || ''"
(categoryClicked)="onCategoryClicked($event)"
(removeFavorite)="removeItem.emit($event)"
(headerActionClicked)="headerActionClicked.emit()"
(openItem)="openItem.emit($event)"
/>
}
@@ -0,0 +1,50 @@
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
import { XtreamCategory } from 'shared-interfaces';
import { FavoritesLayoutComponent } from '../favorites-layout/favorites-layout.component';
export interface PortalCollectionShellLayout {
titleTranslationKey?: string;
removeTooltip?: string;
emptyIcon?: string;
showHeaderAction?: boolean;
headerActionIcon?: string;
headerActionTooltip?: string;
}
@Component({
selector: 'app-portal-collection-shell',
imports: [FavoritesLayoutComponent],
templateUrl: './portal-collection-shell.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
`
:host {
display: block;
height: 100%;
width: 100%;
}
`,
],
})
export class PortalCollectionShellComponent {
readonly categories = input<XtreamCategory[]>([]);
readonly items = input<any[]>([]);
readonly playlistSubtitle = input<string>('');
readonly playlistTitle = input<string>('Playlist');
readonly selectedCategoryId = input<string>('all');
readonly layout = input<PortalCollectionShellLayout>({});
readonly showDetails = input<boolean>(false);
readonly categoryClicked = output<string>();
readonly removeItem = output<any>();
readonly openItem = output<any>();
readonly headerActionClicked = output<void>();
onCategoryClicked(event: { category_id?: string }) {
if (!event?.category_id) {
return;
}
this.categoryClicked.emit(event.category_id);
}
}
@@ -2,83 +2,85 @@
<!-- Inline details slot (for stalker) - shown above everything when active -->
<ng-content select="[details]" />
<div class="search-content">
<div class="header sticky-header">
<div class="header-top">
<div class="header-left">
<h2>{{ title() | translate }}</h2>
@if (!showDetails()) {
<div class="search-content">
<div class="header sticky-header">
<div class="header-top">
<div class="header-left">
<h2>{{ title() | translate }}</h2>
<!-- Filters slot -->
<div class="type-filters">
<ng-content select="[filters]" />
<!-- Filters slot -->
<div class="type-filters">
<ng-content select="[filters]" />
</div>
</div>
<div class="header-right">
@if (showResultsCount() && resultsCount() > 0) {
<div class="results-count">
{{
'PORTALS.SEARCH_VIEW.RESULTS_FOUND'
| translate: { count: resultsCount() }
}}
</div>
}
@if (showCloseButton()) {
<button mat-icon-button (click)="onCloseClick()">
<mat-icon>close</mat-icon>
</button>
}
<!-- Extra header actions slot -->
<ng-content select="[header-actions]" />
</div>
</div>
<div class="header-right">
@if (showResultsCount() && resultsCount() > 0) {
<div class="results-count">
{{
'PORTALS.SEARCH_VIEW.RESULTS_FOUND'
| translate: { count: resultsCount() }
}}
</div>
}
@if (showCloseButton()) {
<button mat-icon-button (click)="onCloseClick()">
<mat-icon>close</mat-icon>
</button>
}
<!-- Extra header actions slot -->
<ng-content select="[header-actions]" />
</div>
@if (showSearchInput()) {
<div class="search-input-container">
<app-search-form
[searchTerm]="searchTerm()"
(searchTermChange)="onSearchTermChange($event)"
/>
</div>
}
<!-- Subheader slot (info notes, secondary controls) -->
<ng-content select="[subheader]" />
</div>
@if (showSearchInput()) {
<div class="search-input-container">
<app-search-form
[searchTerm]="searchTerm()"
(searchTermChange)="onSearchTermChange($event)"
/>
</div>
}
<!-- Subheader slot (info notes, secondary controls) -->
<ng-content select="[subheader]" />
</div>
<div class="results-container">
@if (isLoading()) {
<div class="loading-state">
<mat-spinner diameter="48" />
</div>
} @else if (showResults) {
<!-- Results slot -->
<ng-content select="[results]" />
} @else if (showNoResults) {
<div class="no-items">
<mat-icon>search_off</mat-icon>
<div class="no-items-content">
<h3>{{ 'PORTALS.EMPTY_LIST_VIEW.TITLE' | translate }}</h3>
<p>{{ 'PORTALS.EMPTY_LIST_VIEW.NO_SEARCH_RESULTS' | translate }}</p>
<p class="search-term-note">
{{
'PORTALS.SEARCH_VIEW.NO_RESULTS_FOR'
| translate: { term: searchTerm() }
}}
</p>
<div class="results-container">
@if (isLoading()) {
<div class="loading-state">
<mat-spinner diameter="48" />
</div>
</div>
} @else if (showInitialState) {
<div class="no-items initial-state">
<mat-icon>search</mat-icon>
<div class="no-items-content">
<h3>{{ 'PORTALS.SEARCH_VIEW.INITIAL_TITLE' | translate }}</h3>
<p>{{ 'PORTALS.SEARCH_VIEW.INITIAL_DESCRIPTION' | translate }}</p>
} @else if (showResults) {
<!-- Results slot -->
<ng-content select="[results]" />
} @else if (showNoResults) {
<div class="no-items">
<mat-icon>search_off</mat-icon>
<div class="no-items-content">
<h3>{{ 'PORTALS.EMPTY_LIST_VIEW.TITLE' | translate }}</h3>
<p>{{ 'PORTALS.EMPTY_LIST_VIEW.NO_SEARCH_RESULTS' | translate }}</p>
<p class="search-term-note">
{{
'PORTALS.SEARCH_VIEW.NO_RESULTS_FOR'
| translate: { term: searchTerm() }
}}
</p>
</div>
</div>
</div>
}
<!-- Custom empty state slot (optional override) -->
<ng-content select="[empty]" />
} @else if (showInitialState) {
<div class="no-items initial-state">
<mat-icon>search</mat-icon>
<div class="no-items-content">
<h3>{{ 'PORTALS.SEARCH_VIEW.INITIAL_TITLE' | translate }}</h3>
<p>{{ 'PORTALS.SEARCH_VIEW.INITIAL_DESCRIPTION' | translate }}</p>
</div>
</div>
}
<!-- Custom empty state slot (optional override) -->
<ng-content select="[empty]" />
</div>
</div>
</div>
}
</div>
@@ -50,6 +50,9 @@ export class SearchLayoutComponent {
/** Whether to show the inline search input */
readonly showSearchInput = input<boolean>(true);
/** Whether inline details are active and should replace the search body */
readonly showDetails = input<boolean>(false);
/** Minimum characters required for search */
readonly minSearchLength = input<number>(3);
@@ -0,0 +1,23 @@
@if (categoryId() === 'vod') {
@if ((seriesItem()?.series?.length ?? 0) > 0) {
<!-- VOD item with embedded series (vclub) -->
<app-stalker-series-view
[vodWithSeries]="seriesItem()"
(backClicked)="onBackClicked()"
/>
} @else if (isSeries()) {
<!-- VOD item that is actually a series (Ministra plugin is_series=1) -->
<app-stalker-series-view (backClicked)="onBackClicked()" />
} @else if (vodDetailsItem()) {
<!-- Regular VOD item -->
<app-vod-details
[item]="vodDetailsItem()"
[isFavorite]="isFavorite()"
(playClicked)="onPlayClicked($event)"
(favoriteToggled)="onFavoriteToggled($event)"
(backClicked)="onBackClicked()"
/>
}
} @else if (categoryId() === 'series') {
<app-stalker-series-view (backClicked)="onBackClicked()" />
}
@@ -0,0 +1,47 @@
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
import { VodDetailsItem } from 'shared-interfaces';
import { VodDetailsComponent } from '../../../xtream-electron/vod-details/vod-details.component';
import { StalkerSelectedVodItem } from '../../../stalker/models/stalker-favorite-item.interface';
import { StalkerSeriesViewComponent } from '../../../stalker/stalker-series-view/stalker-series-view.component';
@Component({
selector: 'app-stalker-inline-detail',
imports: [StalkerSeriesViewComponent, VodDetailsComponent],
templateUrl: './stalker-inline-detail.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
`
:host {
display: block;
height: 100%;
width: 100%;
}
`,
],
})
export class StalkerInlineDetailComponent {
readonly categoryId = input<'vod' | 'series' | null>(null);
readonly seriesItem = input<StalkerSelectedVodItem | null>(null);
readonly isSeries = input<boolean>(false);
readonly vodDetailsItem = input<VodDetailsItem | null>(null);
readonly isFavorite = input<boolean>(false);
readonly backClicked = output<void>();
readonly playClicked = output<VodDetailsItem>();
readonly favoriteToggled = output<{
item: VodDetailsItem;
isFavorite: boolean;
}>();
onBackClicked() {
this.backClicked.emit();
}
onPlayClicked(item: VodDetailsItem) {
this.playClicked.emit(item);
}
onFavoriteToggled(event: { item: VodDetailsItem; isFavorite: boolean }) {
this.favoriteToggled.emit(event);
}
}
@@ -8,7 +8,7 @@ import {
RouterLink,
RouterLinkActive,
} from '@angular/router';
import { PortalRailLink } from './portal-rail-links';
import { PortalRailLink, PortalRailSection } from './portal-rail-links';
@Component({
selector: 'app-portal-rail-links',
@@ -20,7 +20,9 @@ export class PortalRailLinksComponent {
private readonly router = inject(Router);
readonly links = input<PortalRailLink[]>([]);
readonly selectedSection = input<string | null | undefined>(null);
readonly selectedSection = input<
PortalRailSection | string | null | undefined
>(null);
readonly activeClass = input<'active' | 'is-active'>('active');
readonly variant = input<'list' | 'rail'>('list');
@@ -1,11 +1,24 @@
export type PortalProvider = 'xtreams' | 'stalker' | 'playlists';
export type PortalRailSection =
| 'all'
| 'downloads'
| 'favorites'
| 'groups'
| 'itv'
| 'library'
| 'live'
| 'recent'
| 'recently-added'
| 'search'
| 'series'
| 'vod';
export interface PortalRailLink {
icon: string;
tooltip: string;
path: (string | number)[];
exact?: boolean;
section?: string;
section?: PortalRailSection;
}
interface BuildPortalRailLinksOptions {
@@ -0,0 +1,121 @@
import {
createEnvironmentInjector,
EnvironmentInjector,
runInInjectionContext,
} from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap } from '@angular/router';
import { BehaviorSubject } from 'rxjs';
import {
extractPortalSection,
isWorkspaceLayoutRoute,
queryParamSignal,
resolveCurrentPortalSection,
} from './portal-route.utils';
describe('portal-route.utils', () => {
it('detects workspace layout from route data', () => {
const route = {
snapshot: {
data: {
layout: 'workspace',
},
},
} as unknown as ActivatedRoute;
expect(isWorkspaceLayoutRoute(route)).toBe(true);
});
it('extracts Xtream sections from workspace and local URLs', () => {
expect(extractPortalSection('/xtreams/123/search', 'xtreams')).toBe(
'search'
);
expect(
extractPortalSection('/workspace/xtreams/123/recent', 'xtreams')
).toBe('recent');
});
it('extracts Stalker sections from workspace and local URLs', () => {
expect(extractPortalSection('/stalker/321/series', 'stalker')).toBe(
'series'
);
expect(
extractPortalSection('/workspace/stalker/321/favorites', 'stalker')
).toBe('favorites');
});
it('prefers the child snapshot section and falls back to URL parsing', () => {
const routeWithChild = {
firstChild: {
snapshot: {
url: [{ path: 'vod' }],
},
},
} as unknown as ActivatedRoute;
const routeWithoutChild = {
firstChild: null,
} as unknown as ActivatedRoute;
expect(
resolveCurrentPortalSection(
routeWithChild,
'/workspace/stalker/123/search',
'stalker'
)
).toBe('vod');
expect(
resolveCurrentPortalSection(
routeWithoutChild,
'/workspace/stalker/123/search',
'stalker'
)
).toBe('search');
});
it('creates normalized query-param signals', () => {
TestBed.configureTestingModule({});
const parentInjector = TestBed.inject(EnvironmentInjector);
const childInjector = createEnvironmentInjector([], parentInjector);
const queryParams$ = new BehaviorSubject(
convertToParamMap({
q: ' Matrix ',
refresh: '42',
})
);
const route = {
snapshot: {
data: {},
queryParamMap: convertToParamMap({
q: ' Matrix ',
refresh: '42',
}),
},
queryParamMap: queryParams$.asObservable(),
} as unknown as ActivatedRoute;
const searchTerm = runInInjectionContext(childInjector, () =>
queryParamSignal(route, 'q', (value) =>
(value ?? '').trim().toLowerCase()
)
);
const refreshToken = runInInjectionContext(childInjector, () =>
queryParamSignal(route, 'refresh', (value) => value ?? '')
);
expect(searchTerm()).toBe('matrix');
expect(refreshToken()).toBe('42');
queryParams$.next(
convertToParamMap({
q: 'Alien',
refresh: '84',
})
);
expect(searchTerm()).toBe('alien');
expect(refreshToken()).toBe('84');
childInjector.destroy();
});
});
@@ -0,0 +1,57 @@
import { Signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute } from '@angular/router';
import { map } from 'rxjs';
import {
PortalProvider,
PortalRailSection,
} from './portal-rail-links';
type QueryParamNormalizer<T> = (value: string | null) => T;
export function isWorkspaceLayoutRoute(route: ActivatedRoute): boolean {
return route.snapshot.data['layout'] === 'workspace';
}
export function queryParamSignal<T = string>(
route: ActivatedRoute,
key: string,
normalizer?: QueryParamNormalizer<T>
): Signal<T> {
const normalize =
normalizer ??
(((
value: string | null
) => (value ?? '') as T) as QueryParamNormalizer<T>);
return toSignal(
route.queryParamMap.pipe(map((params) => normalize(params.get(key)))),
{
initialValue: normalize(route.snapshot.queryParamMap.get(key)),
}
);
}
export function extractPortalSection(
url: string,
provider: PortalProvider
): PortalRailSection | null {
const match = url.match(
new RegExp(`^/(?:workspace/)?${provider}/[^/?]+/([^/?]+)`)
);
return (match?.[1] as PortalRailSection | undefined) ?? null;
}
export function resolveCurrentPortalSection(
route: ActivatedRoute,
routerUrl: string,
provider: PortalProvider
): PortalRailSection | null {
const sectionFromSnapshot =
route.firstChild?.snapshot?.url?.[0]?.path ?? null;
return (
(sectionFromSnapshot as PortalRailSection | null) ??
extractPortalSection(routerUrl, provider)
);
}
@@ -0,0 +1,125 @@
import {
createEnvironmentInjector,
EnvironmentInjector,
runInInjectionContext,
signal,
} from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { FavoritesContextService } from '../../workspace/favorites-context.service';
import { createPortalCollectionContext } from './portal-collection-context';
describe('portal-collection-context', () => {
it('syncs categories into the favorites context', () => {
TestBed.configureTestingModule({
providers: [FavoritesContextService],
});
const parentInjector = TestBed.inject(EnvironmentInjector);
const childInjector = createEnvironmentInjector([], parentInjector);
const ctx = TestBed.inject(FavoritesContextService);
const categories = signal([
{
id: 1,
category_id: 'all',
category_name: 'All',
count: 2,
parent_id: 0,
},
]);
const bridge = runInInjectionContext(childInjector, () =>
createPortalCollectionContext({
ctx,
categories,
})
);
bridge.setCategoryId('all');
expect(ctx.categories()).toEqual(categories());
expect(ctx.selectedCategoryId()).toBe('all');
childInjector.destroy();
});
it('does not mutate the shared context when disabled', () => {
TestBed.configureTestingModule({
providers: [FavoritesContextService],
});
const parentInjector = TestBed.inject(EnvironmentInjector);
const childInjector = createEnvironmentInjector([], parentInjector);
const ctx = TestBed.inject(FavoritesContextService);
const categories = signal([
{
id: 1,
category_id: 'movie',
category_name: 'Movies',
count: 1,
parent_id: 0,
},
]);
ctx.setCategories([
{
id: 99,
category_id: 'all',
category_name: 'Existing',
count: 5,
parent_id: 0,
},
]);
ctx.setCategoryId('series');
const bridge = runInInjectionContext(childInjector, () =>
createPortalCollectionContext({
ctx,
categories,
enabled: () => false,
})
);
bridge.setCategoryId('movie');
expect(ctx.categories()[0]?.category_name).toBe('Existing');
expect(ctx.selectedCategoryId()).toBe('series');
childInjector.destroy();
expect(ctx.categories()[0]?.category_name).toBe('Existing');
expect(ctx.selectedCategoryId()).toBe('series');
});
it('resets managed context state on destroy', () => {
TestBed.configureTestingModule({
providers: [FavoritesContextService],
});
const parentInjector = TestBed.inject(EnvironmentInjector);
const childInjector = createEnvironmentInjector([], parentInjector);
const ctx = TestBed.inject(FavoritesContextService);
const categories = signal([
{
id: 1,
category_id: 'all',
category_name: 'All',
count: 3,
parent_id: 0,
},
]);
runInInjectionContext(childInjector, () =>
createPortalCollectionContext({
ctx,
categories,
})
);
ctx.setCategoryId('movie');
childInjector.destroy();
expect(ctx.categories()).toEqual([]);
expect(ctx.selectedCategoryId()).toBe('all');
});
});
@@ -0,0 +1,46 @@
import { DestroyRef, Signal, effect, inject } from '@angular/core';
import { XtreamCategory } from 'shared-interfaces';
import { FavoritesContextService } from '../../workspace/favorites-context.service';
export interface PortalCollectionContextBridge {
readonly selectedCategoryId: Signal<string>;
setCategoryId(id: string): void;
}
interface CreatePortalCollectionContextOptions {
ctx: FavoritesContextService;
categories: () => XtreamCategory[];
enabled?: () => boolean;
}
export function createPortalCollectionContext(
options: CreatePortalCollectionContextOptions
): PortalCollectionContextBridge {
const destroyRef = inject(DestroyRef);
const isEnabled = options.enabled ?? (() => true);
let managesContext = false;
effect(() => {
if (!isEnabled()) {
return;
}
managesContext = true;
options.ctx.setCategories(options.categories());
});
destroyRef.onDestroy(() => {
if (managesContext && isEnabled()) {
options.ctx.reset();
}
});
return {
selectedCategoryId: options.ctx.selectedCategoryId,
setCategoryId(id: string): void {
if (isEnabled()) {
options.ctx.setCategoryId(id);
}
},
};
}
@@ -0,0 +1,138 @@
import {
buildStandardCollectionCategories,
filterCollectionBucket,
} from './portal-collection-items';
describe('portal-collection-items', () => {
it('builds 4-category collections when live content is included', () => {
const categories = buildStandardCollectionCategories({
labels: {
all: 'All',
movie: 'Movies',
live: 'Live TV',
series: 'Series',
},
counts: {
all: 10,
movie: 4,
live: 3,
series: 3,
},
includeLive: true,
});
expect(categories.map((category) => category.category_id)).toEqual([
'all',
'movie',
'live',
'series',
]);
expect(categories[2]?.count).toBe(3);
});
it('supports alternate live category ids for stalker collections', () => {
const categories = buildStandardCollectionCategories({
labels: {
all: 'All',
movie: 'Movies',
live: 'Live TV',
series: 'Series',
},
counts: {
all: 3,
movie: 1,
live: 1,
series: 1,
},
includeLive: true,
liveCategoryId: 'itv',
});
expect(categories[2]?.category_id).toBe('itv');
});
it('builds 3-category collections when live content is excluded', () => {
const categories = buildStandardCollectionCategories({
labels: {
all: 'All',
movie: 'Movies',
live: 'Live TV',
series: 'Series',
},
counts: {
all: 5,
movie: 2,
series: 3,
},
includeLive: false,
});
expect(categories.map((category) => category.category_id)).toEqual([
'all',
'movie',
'series',
]);
expect(categories[2]?.id).toBe(3);
});
it('filters items by selected category', () => {
const items = [
{ title: 'Movie 1' },
{ title: 'Series 1' },
{ title: 'Live 1' },
];
const filtered = filterCollectionBucket({
selectedCategoryId: 'series',
allItems: items,
buckets: {
movie: [items[0]],
live: [items[2]],
series: [items[1]],
},
searchTerm: '',
textOf: (item) => item.title,
});
expect(filtered).toEqual([items[1]]);
});
it('filters alternate live buckets without changing stalker category ids', () => {
const items = [
{ name: 'Movie' },
{ name: 'Live' },
];
const filtered = filterCollectionBucket({
selectedCategoryId: 'itv',
allItems: items,
buckets: {
movie: [items[0]],
live: [items[1]],
},
liveCategoryId: 'itv',
textOf: (item) => item.name,
});
expect(filtered).toEqual([items[1]]);
});
it('filters items by search term using the provided text selector', () => {
const items = [
{ name: 'Alien', o_name: 'Extended Cut' },
{ name: 'Matrix', o_name: 'Neo' },
];
const filtered = filterCollectionBucket({
selectedCategoryId: 'all',
allItems: items,
buckets: {
movie: items,
},
searchTerm: 'neo',
textOf: (item) => `${item.name} ${item.o_name}`,
});
expect(filtered).toEqual([items[1]]);
});
});
@@ -0,0 +1,96 @@
import { XtreamCategory } from 'shared-interfaces';
type StandardCollectionBucket = 'all' | 'movie' | 'live' | 'series';
interface BuildStandardCollectionCategoriesOptions {
labels: Record<StandardCollectionBucket, string>;
counts: Partial<Record<StandardCollectionBucket, number>>;
includeLive?: boolean;
liveCategoryId?: 'itv' | 'live';
}
interface FilterCollectionBucketOptions<T> {
selectedCategoryId: string | null | undefined;
allItems: readonly T[] | null | undefined;
buckets: Partial<Record<'movie' | 'live' | 'series', readonly T[] | null | undefined>>;
searchTerm?: string | null | undefined;
liveCategoryId?: string;
textOf: (item: T) => string;
}
export function buildStandardCollectionCategories(
options: BuildStandardCollectionCategoriesOptions
): XtreamCategory[] {
const {
labels,
counts,
includeLive = false,
liveCategoryId = 'live',
} = options;
const categories: XtreamCategory[] = [
{
id: 1,
category_id: 'all',
category_name: labels.all,
count: counts.all ?? 0,
parent_id: 0,
},
{
id: 2,
category_id: 'movie',
category_name: labels.movie,
count: counts.movie ?? 0,
parent_id: 0,
},
];
if (includeLive) {
categories.push({
id: 3,
category_id: liveCategoryId,
category_name: labels.live,
count: counts.live ?? 0,
parent_id: 0,
});
}
categories.push({
id: includeLive ? 4 : 3,
category_id: 'series',
category_name: labels.series,
count: counts.series ?? 0,
parent_id: 0,
});
return categories;
}
export function filterCollectionBucket<T>(
options: FilterCollectionBucketOptions<T>
): T[] {
const {
selectedCategoryId,
allItems,
buckets,
searchTerm,
liveCategoryId = 'live',
textOf,
} = options;
const baseItems =
selectedCategoryId === 'movie'
? buckets.movie ?? []
: selectedCategoryId === liveCategoryId
? buckets.live ?? []
: selectedCategoryId === 'series'
? buckets.series ?? []
: allItems ?? [];
const normalizedTerm = (searchTerm ?? '').trim().toLowerCase();
if (!normalizedTerm) {
return [...baseItems];
}
return baseItems.filter((item) =>
String(textOf(item)).toLowerCase().includes(normalizedTerm)
);
}
@@ -1,48 +1,25 @@
@if (itemDetails) {
@switch (itemDetails.category_id) {
@case ('vod') {
@if (itemDetails.series?.length > 0) {
<!-- VOD item with embedded series (vclub) -->
<app-stalker-series-view
[vodWithSeries]="itemDetails"
(backClicked)="itemDetails = null; vodDetailsItem = null"
/>
} @else if (itemDetails.is_series === true) {
<!-- VOD item that is actually a series (Ministra plugin is_series=1) -->
<app-stalker-series-view
(backClicked)="itemDetails = null; vodDetailsItem = null"
/>
} @else if (vodDetailsItem) {
<!-- Regular VOD item -->
<app-vod-details
[item]="vodDetailsItem"
[isFavorite]="isSelectedVodFavorite()"
(playClicked)="onVodPlay($event)"
(favoriteToggled)="onVodFavoriteToggled($event)"
(backClicked)="onVodBack()"
/>
}
}
@case ('series') {
<app-stalker-series-view (backClicked)="itemDetails = null" />
}
}
} @else {
<app-favorites-layout
[categories]="categories() ?? []"
[favorites]="itemsToShow() ?? []"
[playlistSubtitle]="'Stalker Portal'"
[playlistTitle]="currentPlaylist()?.title || 'Portal'"
[selectedCategoryId]="selectedCategoryId()"
[titleTranslationString]="'PORTALS.SIDEBAR.RECENT'"
[removeTooltip]="'Remove from recent'"
[emptyIcon]="'history'"
[showHeaderAction]="!isWorkspaceLayout"
[headerActionIcon]="'delete_sweep'"
[headerActionTooltip]="'Clear recently viewed'"
(categoryClicked)="setCategoryId($event.category_id)"
(removeFavorite)="removeFromRecentlyViewed($event)"
(headerActionClicked)="clearAllRecentlyViewed()"
(openItem)="openItem($event)"
<app-portal-collection-shell
[categories]="categories() ?? []"
[items]="itemsToShow() ?? []"
[playlistSubtitle]="playlistSubtitle"
[playlistTitle]="playlistTitle()"
[selectedCategoryId]="selectedCategoryId()"
[layout]="layout"
[showDetails]="showDetails()"
(categoryClicked)="setCategoryId($event)"
(removeItem)="removeFromRecentlyViewed($event)"
(headerActionClicked)="clearAllRecentlyViewed()"
(openItem)="openItem($event)"
>
<app-stalker-inline-detail
details
[categoryId]="inlineDetail().categoryId"
[seriesItem]="inlineDetail().seriesItem"
[isSeries]="inlineDetail().isSeries"
[vodDetailsItem]="inlineDetail().vodDetailsItem"
[isFavorite]="isSelectedVodFavorite()"
(playClicked)="onVodPlay($event)"
(favoriteToggled)="onVodFavoriteToggled($event)"
(backClicked)="onVodBack()"
/>
}
</app-portal-collection-shell>
@@ -3,23 +3,32 @@ import {
computed,
effect,
inject,
OnDestroy,
signal,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { map } from 'rxjs';
import { PlaylistsService } from 'services';
import { VodDetailsItem } from 'shared-interfaces';
import { FavoritesLayoutComponent } from '../../shared/components/favorites-layout/favorites-layout.component';
import {
PortalCollectionShellComponent,
PortalCollectionShellLayout,
} from '../../shared/components/portal-collection-shell/portal-collection-shell.component';
import { StalkerInlineDetailComponent } from '../../shared/components/stalker-inline-detail/stalker-inline-detail.component';
import {
isWorkspaceLayoutRoute,
queryParamSignal,
} from '../../shared/navigation/portal-route.utils';
import { createPortalCollectionContext } from '../../shared/utils/portal-collection-context';
import {
buildStandardCollectionCategories,
filterCollectionBucket,
} from '../../shared/utils/portal-collection-items';
import { createLogger } from '../../shared/utils/logger';
import { FavoritesContextService } from '../../workspace/favorites-context.service';
import { VodDetailsComponent } from '../../xtream-electron/vod-details/vod-details.component';
import { StalkerSelectedVodItem, StalkerVodSource } from '../models';
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
import {
clearStalkerDetailViewState,
createStalkerInlineDetailState,
createPortalCollectionResource,
createPortalFavoritesResource,
createRefreshTrigger,
@@ -30,14 +39,21 @@ import {
} from '../stalker-vod.utils';
import { StalkerStore } from '../stalker.store';
const STALKER_RECENT_LAYOUT: Omit<
PortalCollectionShellLayout,
'showHeaderAction'
> = {
titleTranslationKey: 'PORTALS.SIDEBAR.RECENT',
removeTooltip: 'Remove from recent',
emptyIcon: 'history',
headerActionIcon: 'delete_sweep',
headerActionTooltip: 'Clear recently viewed',
};
@Component({
selector: 'app-recently-viewed',
templateUrl: './recently-viewed.component.html',
imports: [
FavoritesLayoutComponent,
StalkerSeriesViewComponent,
VodDetailsComponent,
],
imports: [PortalCollectionShellComponent, StalkerInlineDetailComponent],
styles: [
`
:host {
@@ -53,7 +69,7 @@ import { StalkerStore } from '../stalker.store';
`,
],
})
export class RecentlyViewedComponent implements OnDestroy {
export class RecentlyViewedComponent {
private static isCategoryType(
value: string
): value is 'vod' | 'series' | 'itv' {
@@ -69,11 +85,15 @@ export class RecentlyViewedComponent implements OnDestroy {
private readonly logger = createLogger('StalkerRecentlyViewed');
private readonly favoritesCtx = inject(FavoritesContextService);
itemDetails: (StalkerVodSource & { category_id?: string }) | null = null;
itemDetails: StalkerSelectedVodItem | null = null;
vodDetailsItem: VodDetailsItem | null = null;
readonly isSelectedVodFavorite = signal<boolean>(false);
readonly currentPlaylist = this.stalkerStore.currentPlaylist;
readonly playlistSubtitle = 'Stalker Portal';
readonly playlistTitle = computed(
() => this.currentPlaylist()?.title || 'Portal'
);
readonly allFavorites = createPortalCollectionResource(
this.playlistService,
@@ -88,81 +108,58 @@ export class RecentlyViewedComponent implements OnDestroy {
() => this.favoritesRefresh.refreshVersion()
);
readonly categories = computed(() => [
{
id: 1,
category_id: 'all',
category_name: this.translate.instant('PORTALS.ALL_CATEGORIES'),
count: this.allFavorites.value()?.length ?? 0,
parent_id: 0,
},
{
id: 2,
category_id: 'movie',
category_name: this.translate.instant('PORTALS.SIDEBAR.MOVIES'),
count: this.movies()?.length ?? 0,
parent_id: 0,
},
{
id: 3,
category_id: 'itv',
category_name: this.translate.instant('PORTALS.SIDEBAR.LIVE_TV'),
count: this.live()?.length ?? 0,
parent_id: 0,
},
{
id: 4,
category_id: 'series',
category_name: this.translate.instant('PORTALS.SIDEBAR.SERIES'),
count: this.series()?.length ?? 0,
parent_id: 0,
},
]);
readonly categories = computed(() =>
buildStandardCollectionCategories({
labels: {
all: this.translate.instant('PORTALS.ALL_CATEGORIES'),
movie: this.translate.instant('PORTALS.SIDEBAR.MOVIES'),
live: this.translate.instant('PORTALS.SIDEBAR.LIVE_TV'),
series: this.translate.instant('PORTALS.SIDEBAR.SERIES'),
},
counts: {
all: this.allFavorites.value()?.length ?? 0,
movie: this.movies()?.length ?? 0,
live: this.live()?.length ?? 0,
series: this.series()?.length ?? 0,
},
includeLive: true,
liveCategoryId: 'itv',
})
);
readonly collectionContext = createPortalCollectionContext({
ctx: this.favoritesCtx,
categories: this.categories,
});
readonly itemsToShow = computed(() => {
const term = this.searchTerm();
const filterByTerm = (
items: (StalkerVodSource & { category_id?: string })[] | undefined
) => {
if (!items) return [];
if (!term) return items;
return items.filter((item) =>
`${item?.name ?? ''} ${item?.o_name ?? ''}`
.toLowerCase()
.includes(term)
);
};
switch (this.selectedCategoryId()) {
case 'all':
return filterByTerm(this.allFavorites.value());
case 'movie':
return filterByTerm(this.movies());
case 'itv':
return filterByTerm(this.live());
case 'series':
return filterByTerm(this.series());
default:
return [];
}
return filterCollectionBucket({
selectedCategoryId: this.selectedCategoryId(),
allItems: this.allFavorites.value(),
buckets: {
movie: this.movies(),
live: this.live(),
series: this.series(),
},
searchTerm: this.searchTerm(),
liveCategoryId: 'itv',
textOf: (item: any) => `${item?.name ?? ''} ${item?.o_name ?? ''}`,
});
});
/** Synced with workspace context service so panel clicks are reactive */
readonly selectedCategoryId = this.favoritesCtx.selectedCategoryId;
readonly isWorkspaceLayout =
this.route.snapshot.data['layout'] === 'workspace';
readonly searchTerm = toSignal(
this.route.queryParamMap.pipe(
map((params) => (params.get('q') ?? '').trim().toLowerCase())
),
{ initialValue: '' }
readonly selectedCategoryId = this.collectionContext.selectedCategoryId;
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route);
readonly layout: PortalCollectionShellLayout = {
...STALKER_RECENT_LAYOUT,
showHeaderAction: !this.isWorkspaceLayout,
};
readonly searchTerm = queryParamSignal(this.route, 'q', (value) =>
(value ?? '').trim().toLowerCase()
);
readonly refreshToken = toSignal(
this.route.queryParamMap.pipe(
map((params) => params.get('refresh') ?? '')
),
{ initialValue: '' }
readonly refreshToken = queryParamSignal(
this.route,
'refresh',
(value) => value ?? ''
);
readonly series = computed(() =>
@@ -183,11 +180,6 @@ export class RecentlyViewedComponent implements OnDestroy {
this.syncSelectedVodFavorite();
});
// Keep workspace context panel in sync with categories
effect(() => {
this.favoritesCtx.setCategories(this.categories());
});
effect(() => {
const refresh = this.refreshToken();
if (!refresh) {
@@ -245,7 +237,7 @@ export class RecentlyViewedComponent implements OnDestroy {
}
setCategoryId(categoryId: string) {
this.favoritesCtx.setCategoryId(categoryId);
this.collectionContext.setCategoryId(categoryId);
}
openItem(item: StalkerVodSource & { category_id: string }) {
@@ -279,8 +271,8 @@ export class RecentlyViewedComponent implements OnDestroy {
break;
}
case 'series':
this.itemDetails = item;
this.stalkerStore.setSelectedItem(item);
this.itemDetails = this.normalizeRecentItem(item);
this.stalkerStore.setSelectedItem(this.itemDetails);
break;
default:
break;
@@ -362,7 +354,14 @@ export class RecentlyViewedComponent implements OnDestroy {
);
}
ngOnDestroy(): void {
this.favoritesCtx.reset();
inlineDetail() {
return createStalkerInlineDetailState(
this.itemDetails,
this.vodDetailsItem
);
}
showDetails() {
return this.inlineDetail().categoryId !== null;
}
}
@@ -1,41 +1,24 @@
@if (itemDetails) {
@switch (itemDetails.category_id) {
@case ('vod') {
@if (itemDetails.details?.series?.length > 0) {
<!-- VOD item with embedded series (vclub) -->
<app-stalker-series-view
[vodWithSeries]="itemDetails.details"
(backClicked)="onSeriesBack()"
/>
} @else if (itemDetails.details?.is_series === true) {
<!-- VOD item that is actually a series (Ministra plugin is_series=1) -->
<app-stalker-series-view
(backClicked)="onSeriesBack()"
/>
} @else if (vodDetailsItem) {
<!-- Regular VOD item -->
<app-vod-details
[item]="vodDetailsItem"
[isFavorite]="isSelectedVodFavorite()"
(playClicked)="onVodPlay($event)"
(favoriteToggled)="onVodFavoriteToggled($event)"
(backClicked)="onVodBack()"
/>
}
}
@case ('series') {
<app-stalker-series-view (backClicked)="onSeriesBack()" />
}
}
} @else {
<app-favorites-layout
[categories]="categories() ?? []"
[favorites]="favoritesToShow() ?? []"
[playlistSubtitle]="'Stalker Portal'"
[playlistTitle]="currentPlaylist()?.title || 'Portal'"
[selectedCategoryId]="selectedCategoryId()"
(categoryClicked)="setCategoryId($event.category_id)"
(removeFavorite)="removeFromFavorites($event)"
(openItem)="openItem($event)"
<app-portal-collection-shell
[categories]="categories() ?? []"
[items]="favoritesToShow() ?? []"
[playlistSubtitle]="playlistSubtitle"
[playlistTitle]="playlistTitle()"
[selectedCategoryId]="selectedCategoryId()"
[layout]="layout"
[showDetails]="showDetails()"
(categoryClicked)="setCategoryId($event)"
(removeItem)="removeFromFavorites($event)"
(openItem)="openItem($event)"
>
<app-stalker-inline-detail
details
[categoryId]="inlineDetail().categoryId"
[seriesItem]="inlineDetail().seriesItem"
[isSeries]="inlineDetail().isSeries"
[vodDetailsItem]="inlineDetail().vodDetailsItem"
[isFavorite]="isSelectedVodFavorite()"
(playClicked)="onVodPlay($event)"
(favoriteToggled)="onVodFavoriteToggled($event)"
(backClicked)="onSeriesBack()"
/>
}
</app-portal-collection-shell>
@@ -3,23 +3,29 @@ import {
computed,
effect,
inject,
OnDestroy,
signal,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { map } from 'rxjs';
import { PlaylistsService } from 'services';
import { VodDetailsItem } from 'shared-interfaces';
import { FavoritesLayoutComponent } from '../../shared/components/favorites-layout/favorites-layout.component';
import {
PortalCollectionShellComponent,
PortalCollectionShellLayout,
} from '../../shared/components/portal-collection-shell/portal-collection-shell.component';
import { StalkerInlineDetailComponent } from '../../shared/components/stalker-inline-detail/stalker-inline-detail.component';
import { queryParamSignal } from '../../shared/navigation/portal-route.utils';
import { createPortalCollectionContext } from '../../shared/utils/portal-collection-context';
import {
buildStandardCollectionCategories,
filterCollectionBucket,
} from '../../shared/utils/portal-collection-items';
import { createLogger } from '../../shared/utils/logger';
import { FavoritesContextService } from '../../workspace/favorites-context.service';
import { VodDetailsComponent } from '../../xtream-electron/vod-details/vod-details.component';
import { StalkerFavoriteItem } from '../models';
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
import {
clearStalkerDetailViewState,
createStalkerInlineDetailState,
createPortalFavoritesResource,
createRefreshTrigger,
createStalkerDetailViewState,
@@ -31,14 +37,12 @@ import {
} from '../stalker-vod.utils';
import { StalkerStore } from '../stalker.store';
const STALKER_FAVORITES_LAYOUT: PortalCollectionShellLayout = {};
@Component({
selector: 'app-stalker-favorites',
templateUrl: './stalker-favorites.component.html',
imports: [
FavoritesLayoutComponent,
StalkerSeriesViewComponent,
VodDetailsComponent,
],
imports: [PortalCollectionShellComponent, StalkerInlineDetailComponent],
styles: [
`
:host {
@@ -54,7 +58,7 @@ import { StalkerStore } from '../stalker.store';
`,
],
})
export class StalkerFavoritesComponent implements OnDestroy {
export class StalkerFavoritesComponent {
private static isCategoryType(
value: unknown
): value is 'vod' | 'series' | 'itv' {
@@ -80,6 +84,11 @@ export class StalkerFavoritesComponent implements OnDestroy {
readonly isSelectedVodFavorite = signal<boolean>(false);
readonly currentPlaylist = this.stalkerStore.currentPlaylist;
readonly layout = STALKER_FAVORITES_LAYOUT;
readonly playlistSubtitle = 'Stalker Portal';
readonly playlistTitle = computed(
() => this.currentPlaylist()?.title || 'Portal'
);
readonly allFavorites = createPortalFavoritesResource(
this.playlistService,
@@ -87,71 +96,48 @@ export class StalkerFavoritesComponent implements OnDestroy {
() => this.favoritesRefresh.refreshVersion()
);
readonly categories = computed(() => [
{
id: 1,
category_id: 'all',
category_name: this.translate.instant('PORTALS.ALL_CATEGORIES'),
count: this.allFavorites.value()?.length ?? 0,
parent_id: 0,
},
{
id: 2,
category_id: 'movie',
category_name: this.translate.instant('PORTALS.SIDEBAR.MOVIES'),
count: this.movies()?.length ?? 0,
parent_id: 0,
},
{
id: 3,
category_id: 'itv',
category_name: this.translate.instant('PORTALS.SIDEBAR.LIVE_TV'),
count: this.live()?.length ?? 0,
parent_id: 0,
},
{
id: 4,
category_id: 'series',
category_name: this.translate.instant('PORTALS.SIDEBAR.SERIES'),
count: this.series()?.length ?? 0,
parent_id: 0,
},
]);
readonly categories = computed(() =>
buildStandardCollectionCategories({
labels: {
all: this.translate.instant('PORTALS.ALL_CATEGORIES'),
movie: this.translate.instant('PORTALS.SIDEBAR.MOVIES'),
live: this.translate.instant('PORTALS.SIDEBAR.LIVE_TV'),
series: this.translate.instant('PORTALS.SIDEBAR.SERIES'),
},
counts: {
all: this.allFavorites.value()?.length ?? 0,
movie: this.movies()?.length ?? 0,
live: this.live()?.length ?? 0,
series: this.series()?.length ?? 0,
},
includeLive: true,
liveCategoryId: 'itv',
})
);
readonly collectionContext = createPortalCollectionContext({
ctx: this.favoritesCtx,
categories: this.categories,
});
readonly favoritesToShow = computed(() => {
const term = this.searchTerm();
const filterByTerm = (items: StalkerFavoriteItem[] | undefined) => {
if (!items) return [];
if (!term) return items;
return items.filter((item) =>
`${item?.name ?? ''} ${item?.o_name ?? ''}`
.toLowerCase()
.includes(term)
);
};
switch (this.selectedCategoryId()) {
case 'all':
return filterByTerm(this.allFavorites.value());
case 'movie':
return filterByTerm(this.movies());
case 'itv':
return filterByTerm(this.live());
case 'series':
return filterByTerm(this.series());
default:
return [];
}
return filterCollectionBucket({
selectedCategoryId: this.selectedCategoryId(),
allItems: this.allFavorites.value(),
buckets: {
movie: this.movies(),
live: this.live(),
series: this.series(),
},
searchTerm: this.searchTerm(),
liveCategoryId: 'itv',
textOf: (item: any) => `${item?.name ?? ''} ${item?.o_name ?? ''}`,
});
});
/** Synced with workspace context service so panel clicks are reactive */
readonly selectedCategoryId = this.favoritesCtx.selectedCategoryId;
readonly searchTerm = toSignal(
this.route.queryParamMap.pipe(
map((params) => (params.get('q') ?? '').trim().toLowerCase())
),
{ initialValue: '' }
readonly selectedCategoryId = this.collectionContext.selectedCategoryId;
readonly searchTerm = queryParamSignal(this.route, 'q', (value) =>
(value ?? '').trim().toLowerCase()
);
readonly series = computed(() =>
@@ -172,11 +158,6 @@ export class StalkerFavoritesComponent implements OnDestroy {
this.syncSelectedVodFavorite();
});
// Keep workspace context panel in sync with categories
effect(() => {
this.favoritesCtx.setCategories(this.categories());
});
effect(() => {
const playlist = this.currentPlaylist();
if (!playlist?._id) {
@@ -227,7 +208,7 @@ export class StalkerFavoritesComponent implements OnDestroy {
}
setCategoryId(categoryId: string) {
this.favoritesCtx.setCategoryId(categoryId);
this.collectionContext.setCategoryId(categoryId);
}
openItem(item: StalkerFavoriteItem) {
@@ -331,6 +312,17 @@ export class StalkerFavoritesComponent implements OnDestroy {
);
}
inlineDetail() {
return createStalkerInlineDetailState(
this.itemDetails,
this.vodDetailsItem
);
}
showDetails() {
return this.inlineDetail().categoryId !== null;
}
async createLinkToPlayVodItv(
cmd?: string,
title?: string,
@@ -343,8 +335,4 @@ export class StalkerFavoritesComponent implements OnDestroy {
});
await this.stalkerStore.createLinkToPlayVod(cmd, title, thumbnail);
}
ngOnDestroy(): void {
this.favoritesCtx.reset();
}
}
@@ -30,12 +30,13 @@ import { EpgItem, EpgProgram, PlaylistMeta } from 'shared-interfaces';
import { EpgViewComponent, WebPlayerViewComponent } from 'shared-portals';
import { SettingsStore } from '../../services/settings-store.service';
import { PlayerService } from '../../services/player.service';
import { isWorkspaceLayoutRoute } from '../../shared/navigation/portal-route.utils';
import {
getAdjacentChannelItem,
getChannelItemByNumber,
} from '../../shared/services/remote-channel-navigation.util';
import { CategoryViewComponent } from '../../xtream-electron/category-view/category-view.component';
import { PlaylistErrorViewComponent } from '../../xtream-electron/playlist-error-view/playlist-error-view.component';
import { CategoryViewComponent } from '../../shared/components/category-view/category-view.component';
import { PlaylistErrorViewComponent } from '../../shared/components/playlist-error-view/playlist-error-view.component';
import { StalkerStore } from '../stalker.store';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { createLogger } from '../../shared/utils/logger';
@@ -53,7 +54,7 @@ import {
templateUrl: './stalker-live-stream-layout.component.html',
styleUrls: [
'./stalker-live-stream-layout.component.scss',
'../../xtream-electron/sidebar.scss',
'../../shared/styles/portal-sidebar.scss',
],
imports: [
CategoryViewComponent,
@@ -95,8 +96,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
readonly isCategoryFailed = this.stalkerStore.isCategoryResourceFailed;
readonly selectedCategoryTitle = this.stalkerStore.getSelectedCategoryName;
readonly currentPlaylist = this.stalkerStore.currentPlaylist;
readonly isWorkspaceLayout =
this.route.snapshot.data['layout'] === 'workspace';
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route);
/** Channels */
readonly itvChannels = this.stalkerStore.itvChannels;
@@ -5,8 +5,9 @@ import { MatPaginatorModule } from '@angular/material/paginator';
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { PlaylistSwitcherComponent, ResizableDirective } from 'components';
import { CategoryViewComponent } from '../xtream-electron/category-view/category-view.component';
import { PlaylistErrorViewComponent } from '../xtream-electron/playlist-error-view/playlist-error-view.component';
import { CategoryViewComponent } from '../shared/components/category-view/category-view.component';
import { PlaylistErrorViewComponent } from '../shared/components/playlist-error-view/playlist-error-view.component';
import { isWorkspaceLayoutRoute } from '../shared/navigation/portal-route.utils';
import { StalkerStore } from './stalker.store';
@Component({
@@ -15,7 +16,7 @@ import { StalkerStore } from './stalker.store';
styleUrls: [
'./stalker-main-container.component.scss',
'../xtream-electron/xtream-main-container.component.scss',
'../xtream-electron/sidebar.scss',
'../shared/styles/portal-sidebar.scss',
],
imports: [
CategoryViewComponent,
@@ -34,8 +35,7 @@ export class StalkerMainContainerComponent {
private readonly router = inject(Router);
readonly stalkerStore = inject(StalkerStore);
private readonly translateService = inject(TranslateService);
readonly isWorkspaceLayout =
this.route.snapshot.data['layout'] === 'workspace';
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route);
currentLayout:
| 'category_content'
@@ -1,67 +1,50 @@
@if (showingDetails) {
<!-- Inline details view -->
<div class="details-view">
@switch (selectedFilterType()) {
@case ('vod') {
@if (itemDetails.series?.length > 0) {
<!-- VOD item with embedded series (vclub or Ministra) -->
<app-stalker-series-view
[vodWithSeries]="itemDetails"
(backClicked)="onVodBack()"
/>
} @else if (itemDetails.is_series === true) {
<!-- VOD item that is actually a series (Ministra plugin is_series=1) -->
<app-stalker-series-view (backClicked)="onVodBack()" />
} @else if (vodDetailsItem) {
<!-- Regular VOD item -->
<app-vod-details
[item]="vodDetailsItem"
[isFavorite]="isSelectedVodFavorite()"
(playClicked)="onVodPlay($event)"
(favoriteToggled)="onVodFavoriteToggled($event)"
(backClicked)="onVodBack()"
/>
}
}
@case ('series') {
<app-stalker-series-view (backClicked)="itemDetails = null" />
}
}
</div>
} @else {
<app-search-layout
[searchTerm]="searchTerm()"
[resultsCount]="resultsCount"
[isLoading]="searchResultsResource.isLoading()"
[showResultsCount]="true"
[showSearchInput]="!isWorkspaceLayout"
(searchTermChange)="updateSearchTerm($event)"
>
<!-- Filters (single selection mode for stalker) -->
<ng-container filters>
@for (filter of filterConfig; track filter.key) {
<mat-checkbox
[ngModel]="filters()[filter.key]"
(ngModelChange)="updateFilter(filter.key, $event)"
>
{{ filter.translationKey | translate }}
</mat-checkbox>
}
</ng-container>
<app-search-layout
[searchTerm]="searchTerm()"
[resultsCount]="resultsCount"
[isLoading]="searchResultsResource.isLoading()"
[showResultsCount]="true"
[showSearchInput]="!isWorkspaceLayout"
[showDetails]="showingDetails"
(searchTermChange)="updateSearchTerm($event)"
>
@if (showingDetails) {
<app-stalker-inline-detail
details
[categoryId]="inlineDetail().categoryId"
[seriesItem]="inlineDetail().seriesItem"
[isSeries]="inlineDetail().isSeries"
[vodDetailsItem]="inlineDetail().vodDetailsItem"
[isFavorite]="isSelectedVodFavorite()"
(playClicked)="onVodPlay($event)"
(favoriteToggled)="onVodFavoriteToggled($event)"
(backClicked)="onVodBack()"
/>
}
<!-- Results -->
<ng-container results>
<div class="results-grid">
@for (item of searchResultsResource.value(); track item.id) {
<app-content-card
[title]="item.o_name || item.name"
[posterUrl]="item.screenshot_uri"
[type]="selectedFilterType() === 'vod' ? 'movie' : 'series'"
[showPlaceholder]="true"
(cardClick)="selectItem(item)"
/>
}
</div>
</ng-container>
</app-search-layout>
}
<!-- Filters (single selection mode for stalker) -->
<ng-container filters>
@for (filter of filterConfig; track filter.key) {
<mat-checkbox
[ngModel]="filters()[filter.key]"
(ngModelChange)="updateFilter(filter.key, $event)"
>
{{ filter.translationKey | translate }}
</mat-checkbox>
}
</ng-container>
<!-- Results -->
<ng-container results>
<div class="results-grid">
@for (item of searchResultsResource.value(); track item.id) {
<app-content-card
[title]="item.o_name || item.name"
[posterUrl]="item.screenshot_uri"
[type]="selectedFilterType() === 'vod' ? 'movie' : 'series'"
[showPlaceholder]="true"
(cardClick)="selectItem(item)"
/>
}
</div>
</ng-container>
</app-search-layout>
@@ -1,5 +1,4 @@
import { Component, effect, inject, resource, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { ActivatedRoute } from '@angular/router';
@@ -15,10 +14,13 @@ import {
} from 'shared-interfaces';
import { ContentCardComponent } from '../../shared/components/content-card/content-card.component';
import { SearchLayoutComponent } from '../../shared/components/search-layout/search-layout.component';
import { VodDetailsComponent } from '../../xtream-electron/vod-details/vod-details.component';
import { StalkerInlineDetailComponent } from '../../shared/components/stalker-inline-detail/stalker-inline-detail.component';
import { StalkerContentTypes } from '../stalker-content-types';
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
import { StalkerStore } from '../stalker.store';
import {
isWorkspaceLayoutRoute,
queryParamSignal,
} from '../../shared/navigation/portal-route.utils';
import { createLogger } from '../../shared/utils/logger';
import {
StalkerSelectedVodItem,
@@ -27,6 +29,7 @@ import {
import {
buildStalkerSelectedVodItem,
clearStalkerDetailViewState,
createStalkerInlineDetailState,
createPortalFavoritesResource,
createRefreshTrigger,
createStalkerDetailViewState,
@@ -48,9 +51,8 @@ interface StalkerFilter {
FormsModule,
MatCheckboxModule,
SearchLayoutComponent,
StalkerSeriesViewComponent,
StalkerInlineDetailComponent,
TranslatePipe,
VodDetailsComponent,
],
templateUrl: './stalker-search.component.html',
styleUrl: './stalker-search.component.scss',
@@ -68,8 +70,7 @@ export class StalkerSearchComponent {
series: false,
vod: true,
});
readonly isWorkspaceLayout =
this.activatedRoute.snapshot.data['layout'] === 'workspace';
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.activatedRoute);
readonly filterConfig: StalkerFilter[] = [
{
@@ -85,6 +86,11 @@ export class StalkerSearchComponent {
];
readonly searchTerm = signal('');
readonly routeSearchTerm = queryParamSignal(
this.activatedRoute,
'q',
(value) => (value ?? '').trim()
);
private readonly currentPlaylist = this.store.selectSignal(
selectPlaylistById(this.activatedRoute?.parent?.snapshot.params.id)
@@ -162,14 +168,12 @@ export class StalkerSearchComponent {
readonly isSelectedVodFavorite = signal<boolean>(false);
constructor() {
this.activatedRoute.queryParamMap
.pipe(takeUntilDestroyed())
.subscribe((queryParams) => {
const routeTerm = queryParams.get('q')?.trim() ?? '';
if (routeTerm !== this.searchTerm()) {
this.searchTerm.set(routeTerm);
}
});
effect(() => {
const routeTerm = this.routeSearchTerm();
if (routeTerm !== this.searchTerm()) {
this.searchTerm.set(routeTerm);
}
});
effect(() => {
// Re-evaluate favorite state whenever favorites resource changes.
@@ -180,7 +184,7 @@ export class StalkerSearchComponent {
/** Check if showing item details */
get showingDetails(): boolean {
return this.itemDetails !== null;
return this.inlineDetail().categoryId !== null;
}
/** Get results count for layout */
@@ -296,6 +300,14 @@ export class StalkerSearchComponent {
);
}
inlineDetail() {
return createStalkerInlineDetailState(
this.itemDetails,
this.vodDetailsItem,
this.selectedFilterType() === 'series' ? 'series' : 'vod'
);
}
private processItemUrls(
item: StalkerVodSource,
portalUrl: string
@@ -9,6 +9,11 @@ import {
import { Store } from '@ngrx/store';
import { PlaylistActions, selectPlaylistById } from 'm3u-state';
import { map, switchMap } from 'rxjs';
import { PortalRailSection } from '../../shared/navigation/portal-rail-links';
import {
isWorkspaceLayoutRoute,
resolveCurrentPortalSection,
} from '../../shared/navigation/portal-route.utils';
import { createLogger } from '../../shared/utils/logger';
import { NavigationComponent } from '../../xtream-electron/navigation/navigation.component';
import { XtreamStore } from '../../xtream-electron/stores/xtream.store';
@@ -27,8 +32,7 @@ export class StalkerShellComponent implements OnDestroy {
private readonly router = inject(Router);
readonly stalkerStore = inject(StalkerStore);
private readonly store = inject(Store);
readonly isWorkspaceLayout =
this.route.snapshot.data['layout'] === 'workspace';
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route);
/** Current playlist derived from route params */
readonly currentPlaylist = toSignal(
@@ -37,7 +41,7 @@ export class StalkerShellComponent implements OnDestroy {
switchMap((id) => this.store.select(selectPlaylistById(id)))
)
);
private currentSection: string | null = null;
private currentSection: PortalRailSection | null = null;
constructor() {
// Subscribe to route params to handle switching between playlists
@@ -84,10 +88,11 @@ export class StalkerShellComponent implements OnDestroy {
}
private syncSectionFromRoute(): void {
const sectionFromSnapshot =
this.route.firstChild?.snapshot?.url?.[0]?.path ?? null;
const sectionFromUrl = this.getSectionFromUrl(this.router.url);
const section = sectionFromSnapshot ?? sectionFromUrl;
const section = resolveCurrentPortalSection(
this.route,
this.router.url,
'stalker'
);
if (!section || section === this.currentSection) {
return;
@@ -102,11 +107,4 @@ export class StalkerShellComponent implements OnDestroy {
this.stalkerStore.setSelectedContentType(null as any);
}
private getSectionFromUrl(url: string): string | null {
const match = url.match(
/^\/(?:workspace\/)?stalker\/[^\/\?]+\/([^\/\?]+)/
);
return match?.[1] ?? null;
}
}
@@ -1,6 +1,8 @@
import { VodDetailsItem } from 'shared-interfaces';
import {
createStalkerInlineDetailState,
createStalkerDetailViewState,
normalizeStalkerFavoriteItem,
toggleStalkerVodFavorite,
} from './stalker-vod.utils';
@@ -106,4 +108,61 @@ describe('stalker-vod.utils regressions', () => {
expect(removeFromFavorites).toHaveBeenCalledTimes(1);
expect(onComplete).toHaveBeenCalledTimes(1);
});
it('normalizes nested favorite details for the shared inline detail shell', () => {
const detailState = createStalkerInlineDetailState(
normalizeStalkerFavoriteItem({
id: '7',
category_id: 'vod',
name: 'Nested Movie',
details: {
id: '7',
cmd: '/media/file_7.mpg',
series: [11, 12],
info: {
name: 'Nested Movie',
movie_image: '',
description: '',
actors: '',
director: '',
releasedate: '',
genre: '',
rating_imdb: '',
rating_kinopoisk: '',
},
},
} as any),
null
);
expect(detailState.categoryId).toBe('vod');
expect(detailState.seriesItem?.series).toEqual([11, 12]);
expect(detailState.isSeries).toBe(false);
expect(detailState.vodDetailsItem).toBeNull();
});
it('allows search to force series mode when results omit category metadata', () => {
const detailState = createStalkerInlineDetailState(
{
id: '9',
cmd: '/media/file_9.mpg',
info: {
name: 'Series Result',
movie_image: '',
description: '',
actors: '',
director: '',
releasedate: '',
genre: '',
rating_imdb: '',
rating_kinopoisk: '',
},
},
null,
'series'
);
expect(detailState.categoryId).toBe('series');
expect(detailState.seriesItem?.id).toBe('9');
});
});
@@ -23,6 +23,13 @@ export interface StalkerDetailViewState {
vodDetailsItem: VodDetailsItem | null;
}
export interface StalkerInlineDetailState {
categoryId: 'vod' | 'series' | null;
seriesItem: StalkerSelectedVodItem | null;
isSeries: boolean;
vodDetailsItem: VodDetailsItem | null;
}
export interface StalkerFavoriteToggleEvent {
item: VodDetailsItem;
isFavorite: boolean;
@@ -222,6 +229,44 @@ export function normalizeStalkerVodDetailsItem(
};
}
export function createStalkerInlineDetailState(
itemDetails: StalkerSelectedVodItem | NormalizedStalkerFavoriteItem | null,
vodDetailsItem: VodDetailsItem | null,
categoryOverride?: 'vod' | 'series' | null
): StalkerInlineDetailState {
if (!itemDetails) {
return {
categoryId: null,
seriesItem: null,
isSeries: false,
vodDetailsItem: null,
};
}
const seriesItem =
'details' in itemDetails ? itemDetails.details : itemDetails;
const rawCategoryId =
categoryOverride ??
(typeof itemDetails.category_id === 'string'
? itemDetails.category_id
: typeof seriesItem.category_id === 'string'
? seriesItem.category_id
: undefined);
const categoryId =
rawCategoryId === 'vod' || rawCategoryId === 'movie'
? 'vod'
: rawCategoryId === 'series'
? 'series'
: null;
return {
categoryId,
seriesItem,
isSeries: isStalkerSeriesFlag(seriesItem?.is_series),
vodDetailsItem,
};
}
export function createStalkerDetailViewState(
normalizedItem: StalkerSelectedVodItem,
playlistId: string
@@ -1,7 +1,7 @@
import { Component, computed, inject, input } from '@angular/core';
import { Router } from '@angular/router';
import { PlaylistErrorViewComponent } from '../xtream-electron/playlist-error-view/playlist-error-view.component';
import { CategoryViewComponent } from '../xtream-electron/category-view/category-view.component';
import { CategoryViewComponent } from '../shared/components/category-view/category-view.component';
import { PlaylistErrorViewComponent } from '../shared/components/playlist-error-view/playlist-error-view.component';
import { XtreamStore } from '../xtream-electron/stores/xtream.store';
import { StalkerStore } from '../stalker/stalker.store';
@@ -1,5 +1,5 @@
import { Component, computed, inject } from '@angular/core';
import { CategoryViewComponent } from '../xtream-electron/category-view/category-view.component';
import { CategoryViewComponent } from '../shared/components/category-view/category-view.component';
import { FavoritesContextService } from './favorites-context.service';
/**
@@ -20,6 +20,7 @@ import {
} from 'shared-interfaces';
import { DownloadsService } from '../../services/downloads.service';
import { GridListComponent } from '../../shared/components/grid-list/grid-list.component';
import { PlaylistErrorViewComponent } from '../../shared/components/playlist-error-view/playlist-error-view.component';
import { createLogger } from '../../shared/utils/logger';
import { StalkerVodSource } from '../../stalker/models';
import { StalkerSeriesViewComponent } from '../../stalker/stalker-series-view/stalker-series-view.component';
@@ -34,7 +35,6 @@ import {
} from '../../stalker/stalker-vod.utils';
import { StalkerStore } from '../../stalker/stalker.store';
import { XTREAM_DATA_SOURCE } from '../data-sources';
import { PlaylistErrorViewComponent } from '../playlist-error-view/playlist-error-view.component';
import { XtreamStore } from '../stores/xtream.store';
import { VodDetailsComponent } from '../vod-details/vod-details.component';
@@ -4,7 +4,6 @@ import {
computed,
effect,
inject,
OnDestroy,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
@@ -16,20 +15,37 @@ import { TranslatePipe } from '@ngx-translate/core';
import { ResizableDirective } from 'components';
import { firstValueFrom, map } from 'rxjs';
import { DatabaseService, PlaylistsService } from 'services';
import { XtreamCategory } from 'shared-interfaces';
import {
DownloadItem,
DownloadsService,
} from '../../services/downloads.service';
import { CategoryViewComponent } from '../../shared/components/category-view/category-view.component';
import {
isWorkspaceLayoutRoute,
queryParamSignal,
} from '../../shared/navigation/portal-route.utils';
import { createPortalCollectionContext } from '../../shared/utils/portal-collection-context';
import {
buildStandardCollectionCategories,
filterCollectionBucket,
} from '../../shared/utils/portal-collection-items';
import { FavoritesContextService } from '../../workspace/favorites-context.service';
import { CategoryViewComponent } from '../category-view/category-view.component';
type PortalSource = 'xtream' | 'stalker';
const DOWNLOAD_COLLECTION_LABELS = {
all: 'All',
movie: 'Movies',
live: 'Live TV',
series: 'Series',
};
@Component({
selector: 'app-downloads',
templateUrl: './downloads.component.html',
styleUrls: ['./downloads.component.scss', '../sidebar.scss'],
styleUrls: [
'./downloads.component.scss',
'../../shared/styles/portal-sidebar.scss',
],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CategoryViewComponent,
@@ -41,31 +57,26 @@ type PortalSource = 'xtream' | 'stalker';
TranslatePipe,
],
})
export class DownloadsComponent implements OnDestroy {
export class DownloadsComponent {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly dbService = inject(DatabaseService);
private readonly playlistsService = inject(PlaylistsService);
private readonly favoritesCtx = inject(FavoritesContextService);
readonly downloadsService = inject(DownloadsService);
readonly isWorkspaceLayout =
this.route.snapshot.data['layout'] === 'workspace';
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route);
readonly downloads = this.downloadsService.downloads;
readonly downloadFolder = this.downloadsService.downloadFolder;
readonly isAvailable = this.downloadsService.isAvailable;
readonly hasDownloads = this.downloadsService.hasDownloads;
readonly activeCount = this.downloadsService.activeCount;
readonly selectedCategoryId = this.favoritesCtx.selectedCategoryId;
readonly playlistId = toSignal(
this.route.paramMap.pipe(map((params) => params.get('id') ?? '')),
{ initialValue: this.route.snapshot.params['id'] ?? '' }
);
readonly searchTerm = toSignal(
this.route.queryParamMap.pipe(
map((params) => (params.get('q') ?? '').trim().toLowerCase())
),
{ initialValue: '' }
readonly searchTerm = queryParamSignal(this.route, 'q', (value) =>
(value ?? '').trim().toLowerCase()
);
readonly scopedDownloads = computed(() => {
@@ -76,7 +87,7 @@ export class DownloadsComponent implements OnDestroy {
: downloads;
});
readonly categories = computed<XtreamCategory[]>(() => {
readonly categories = computed(() => {
const downloads = this.scopedDownloads();
const moviesCount = downloads.filter(
(item) => item.contentType === 'vod'
@@ -85,55 +96,38 @@ export class DownloadsComponent implements OnDestroy {
(item) => item.contentType === 'episode'
).length;
return [
{
id: 1,
category_id: 'all',
category_name: 'All',
count: downloads.length,
parent_id: 0,
return buildStandardCollectionCategories({
labels: DOWNLOAD_COLLECTION_LABELS,
counts: {
all: downloads.length,
movie: moviesCount,
series: seriesCount,
},
{
id: 2,
category_id: 'movie',
category_name: 'Movies',
count: moviesCount,
parent_id: 0,
},
{
id: 3,
category_id: 'series',
category_name: 'Series',
count: seriesCount,
parent_id: 0,
},
];
});
});
readonly collectionContext = createPortalCollectionContext({
ctx: this.favoritesCtx,
categories: this.categories,
});
readonly selectedCategoryId = this.collectionContext.selectedCategoryId;
/** Filter downloads for current playlist and sort by newest first */
readonly filteredDownloads = computed(() => {
const selectedCategoryId = this.selectedCategoryId();
const term = this.searchTerm();
const downloads = this.scopedDownloads();
const filteredByCategory = downloads.filter((item) => {
if (selectedCategoryId === 'movie') {
return item.contentType === 'vod';
}
if (selectedCategoryId === 'series') {
return item.contentType === 'episode';
}
return true;
const filteredByTerm = filterCollectionBucket({
selectedCategoryId: this.selectedCategoryId(),
allItems: downloads,
buckets: {
movie: downloads.filter((item) => item.contentType === 'vod'),
series: downloads.filter(
(item) => item.contentType === 'episode'
),
},
searchTerm: term,
textOf: (item) => `${item.title ?? ''} ${item.errorMessage ?? ''}`,
});
const filteredByTerm = term
? filteredByCategory.filter((item) =>
`${item.title ?? ''} ${item.errorMessage ?? ''}`
.toLowerCase()
.includes(term)
)
: filteredByCategory;
// Sort by createdAt descending (newest first)
return [...filteredByTerm].sort((a, b) => {
const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0;
@@ -145,21 +139,13 @@ export class DownloadsComponent implements OnDestroy {
constructor() {
effect(() => {
const playlistId = this.playlistId();
this.favoritesCtx.setCategoryId('all');
this.collectionContext.setCategoryId('all');
void this.downloadsService.loadDownloads(playlistId || undefined);
});
effect(() => {
this.favoritesCtx.setCategories(this.categories());
});
}
setCategoryId(categoryId: string) {
this.favoritesCtx.setCategoryId(categoryId);
}
ngOnDestroy(): void {
this.favoritesCtx.reset();
this.collectionContext.setCategoryId(categoryId);
}
getProgress(item: DownloadItem): number {
@@ -1,10 +1,11 @@
<app-favorites-layout
<app-portal-collection-shell
[categories]="categories()"
[favorites]="filteredFavoritesToShow()"
[playlistSubtitle]="'Xtream Code'"
[playlistTitle]="currentPlaylist()?.name || currentPlaylist()?.title || 'Playlist'"
[items]="filteredFavoritesToShow()"
[playlistSubtitle]="playlistSubtitle"
[playlistTitle]="playlistTitle()"
[selectedCategoryId]="selectedCategoryId()"
(categoryClicked)="setCategoryId($event.category_id)"
(removeFavorite)="removeFromFavorites($event)"
[layout]="layout"
(categoryClicked)="setCategoryId($event)"
(removeItem)="removeFromFavorites($event)"
(openItem)="openItem($event)"
/>
@@ -1,31 +1,48 @@
import {
Component,
OnDestroy,
OnInit,
computed,
inject,
signal,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatCardModule } from '@angular/material/card';
import { ActivatedRoute, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { selectActivePlaylist } from 'm3u-state';
import { BehaviorSubject, map, switchMap } from 'rxjs';
import { XtreamCategory } from 'shared-interfaces';
import { FavoritesLayoutComponent } from '../../shared/components/favorites-layout/favorites-layout.component';
import { BehaviorSubject, switchMap } from 'rxjs';
import { FavoritesContextService } from '../../workspace/favorites-context.service';
import {
PortalCollectionShellComponent,
PortalCollectionShellLayout,
} from '../../shared/components/portal-collection-shell/portal-collection-shell.component';
import { queryParamSignal } from '../../shared/navigation/portal-route.utils';
import { createPortalCollectionContext } from '../../shared/utils/portal-collection-context';
import {
buildStandardCollectionCategories,
filterCollectionBucket,
} from '../../shared/utils/portal-collection-items';
import { FavoriteItem } from '../services/favorite-item.interface';
import { FavoritesService } from '../services/favorites.service';
import { XtreamStore } from '../stores/xtream.store';
const XTREAM_FAVORITES_LAYOUT: PortalCollectionShellLayout = {};
const XTREAM_COLLECTION_LABELS = {
all: 'All',
movie: 'Movies',
live: 'Live TV',
series: 'Series',
};
@Component({
selector: 'app-favorites',
imports: [FavoritesLayoutComponent, MatCardModule],
imports: [PortalCollectionShellComponent, MatCardModule],
templateUrl: './favorites.component.html',
styleUrls: ['./favorites.component.scss', '../sidebar.scss'],
styleUrls: [
'./favorites.component.scss',
'../../shared/styles/portal-sidebar.scss',
],
})
export class FavoritesComponent implements OnInit, OnDestroy {
export class FavoritesComponent implements OnInit {
private favoritesService = inject(FavoritesService);
private route = inject(ActivatedRoute);
private router = inject(Router);
@@ -33,47 +50,56 @@ export class FavoritesComponent implements OnInit, OnDestroy {
private xtreamStore = inject(XtreamStore);
private readonly favoritesCtx = inject(FavoritesContextService);
readonly categories = signal<XtreamCategory[]>([]);
readonly currentPlaylist = this.xtreamStore.currentPlaylist;
readonly layout = XTREAM_FAVORITES_LAYOUT;
readonly playlistSubtitle = 'Xtream Code';
readonly playlistTitle = computed(
() =>
this.currentPlaylist()?.name ||
this.currentPlaylist()?.title ||
'Playlist'
);
readonly allItems = signal<FavoriteItem[]>([]);
readonly series = signal<FavoriteItem[]>([]);
readonly movies = signal<FavoriteItem[]>([]);
readonly live = signal<FavoriteItem[]>([]);
readonly searchTerm = toSignal(
this.route.queryParamMap.pipe(
map((params) => (params.get('q') ?? '').trim().toLowerCase())
),
{ initialValue: '' }
readonly searchTerm = queryParamSignal(this.route, 'q', (value) =>
(value ?? '').trim().toLowerCase()
);
readonly categories = computed(() =>
buildStandardCollectionCategories({
labels: XTREAM_COLLECTION_LABELS,
counts: {
all: this.allItems().length,
movie: this.movies().length,
live: this.live().length,
series: this.series().length,
},
includeLive: true,
})
);
readonly collectionContext = createPortalCollectionContext({
ctx: this.favoritesCtx,
categories: this.categories,
});
/** Synced with the workspace context service so panel clicks are reactive */
readonly selectedCategoryId = this.favoritesCtx.selectedCategoryId;
readonly selectedCategoryId = this.collectionContext.selectedCategoryId;
/** Items filtered by the active category — fully reactive, no imperative calls */
readonly filteredFavoritesToShow = computed(() => {
const term = this.searchTerm();
const categoryId = this.selectedCategoryId();
let items: FavoriteItem[];
switch (categoryId) {
case 'movie':
items = this.movies();
break;
case 'live':
items = this.live();
break;
case 'series':
items = this.series();
break;
default:
items = this.allItems();
}
if (!term) return items;
return items.filter((item) =>
`${item?.title ?? ''}`.toLowerCase().includes(term)
);
return filterCollectionBucket({
selectedCategoryId: this.selectedCategoryId(),
allItems: this.allItems(),
buckets: {
movie: this.movies(),
live: this.live(),
series: this.series(),
},
searchTerm: this.searchTerm(),
textOf: (item) => `${item?.title ?? ''}`,
});
});
private favoritesRefresh$ = new BehaviorSubject<void>(undefined);
@@ -92,47 +118,11 @@ export class FavoritesComponent implements OnInit, OnDestroy {
);
this.live.set(items.filter((item) => item.type === 'live'));
this.series.set(items.filter((item) => item.type === 'series'));
this.initCategories();
});
}
initCategories() {
const updated: XtreamCategory[] = [
{
id: 1,
category_id: 'all',
category_name: 'All',
count: this.allItems().length,
parent_id: 0,
},
{
id: 2,
category_id: 'movie',
category_name: 'Movies',
count: this.movies().length,
parent_id: 0,
},
{
id: 3,
category_id: 'live',
category_name: 'Live TV',
count: this.live().length,
parent_id: 0,
},
{
id: 4,
category_id: 'series',
category_name: 'Series',
count: this.series().length,
parent_id: 0,
},
];
this.categories.set(updated);
this.favoritesCtx.setCategories(updated);
}
setCategoryId(categoryId: string) {
this.favoritesCtx.setCategoryId(categoryId);
this.collectionContext.setCategoryId(categoryId);
}
async removeFromFavorites(item: any) {
@@ -180,8 +170,4 @@ export class FavoritesComponent implements OnInit, OnDestroy {
);
}
}
ngOnDestroy(): void {
this.favoritesCtx.reset();
}
}
@@ -27,6 +27,8 @@ import { PlaylistSwitcherComponent, ResizableDirective } from 'components';
import { XtreamCategory } from 'shared-interfaces';
import { EpgViewComponent, WebPlayerViewComponent } from 'shared-portals';
import { SettingsStore } from '../../services/settings-store.service';
import { CategoryViewComponent } from '../../shared/components/category-view/category-view.component';
import { isWorkspaceLayoutRoute } from '../../shared/navigation/portal-route.utils';
import {
getAdjacentChannelItem,
getChannelItemByNumber,
@@ -35,9 +37,6 @@ import {
CategoryManagementDialogComponent,
CategoryManagementDialogData,
} from '../category-management-dialog/category-management-dialog.component';
import {
CategoryViewComponent,
} from '../category-view/category-view.component';
import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component';
import { FavoritesService } from '../services/favorites.service';
import { XtreamStore } from '../stores/xtream.store';
@@ -48,7 +47,10 @@ const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode';
@Component({
selector: 'app-live-stream-layout',
templateUrl: './live-stream-layout.component.html',
styleUrls: ['./live-stream-layout.component.scss', '../sidebar.scss'],
styleUrls: [
'./live-stream-layout.component.scss',
'../../shared/styles/portal-sidebar.scss',
],
imports: [
CategoryViewComponent,
EpgViewComponent,
@@ -81,8 +83,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
readonly currentPlaylist = this.xtreamStore.currentPlaylist;
readonly epgItems = this.xtreamStore.epgItems;
readonly selectedCategoryId = this.xtreamStore.selectedCategoryId;
readonly isWorkspaceLayout =
this.route.snapshot.data['layout'] === 'workspace';
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route);
readonly liveChannelSortMode = signal<LiveChannelSortMode>('server');
private readonly pendingAutoOpenLiveItemId = signal<number | null>(null);
readonly liveChannelSortLabel = computed(() => {
@@ -119,20 +119,15 @@
}
</div>
} @else {
<app-favorites-layout
<app-portal-collection-shell
[categories]="categories()"
[favorites]="nonGlobalItemsToShow()"
[playlistSubtitle]="'Xtream Code'"
[playlistTitle]="currentPlaylist()?.name || currentPlaylist()?.title || 'Playlist'"
[items]="nonGlobalItemsToShow()"
[playlistSubtitle]="playlistSubtitle"
[playlistTitle]="playlistTitle()"
[selectedCategoryId]="selectedCategoryId()"
[titleTranslationString]="'PORTALS.SIDEBAR.RECENT'"
[removeTooltip]="'Remove from history'"
[emptyIcon]="'history'"
[showHeaderAction]="!isWorkspaceLayout"
[headerActionIcon]="'delete_sweep'"
[headerActionTooltip]="'Clear history'"
(categoryClicked)="setCategoryId($event.category_id)"
(removeFavorite)="onRemoveItem($event)"
[layout]="nonGlobalLayout"
(categoryClicked)="setCategoryId($event)"
(removeItem)="onRemoveItem($event)"
(headerActionClicked)="clearHistory()"
(openItem)="openItem($event)"
/>
@@ -2,14 +2,11 @@ import { KeyValuePipe } from '@angular/common';
import {
Component,
computed,
effect,
inject,
OnDestroy,
Optional,
signal,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
import { MatButton, MatIconButton } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
@@ -17,33 +14,60 @@ import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { ActivatedRoute, Router } from '@angular/router';
import groupBy from 'lodash/groupBy';
import { firstValueFrom, map } from 'rxjs';
import { firstValueFrom } from 'rxjs';
import { DatabaseService, PlaylistsService } from 'services';
import { XtreamCategory } from 'shared-interfaces';
import { ContentCardComponent } from '../../shared/components/content-card/content-card.component';
import { FavoritesLayoutComponent } from '../../shared/components/favorites-layout/favorites-layout.component';
import {
PortalCollectionShellComponent,
PortalCollectionShellLayout,
} from '../../shared/components/portal-collection-shell/portal-collection-shell.component';
import {
isWorkspaceLayoutRoute,
queryParamSignal,
} from '../../shared/navigation/portal-route.utils';
import { createPortalCollectionContext } from '../../shared/utils/portal-collection-context';
import {
buildStandardCollectionCategories,
filterCollectionBucket,
} from '../../shared/utils/portal-collection-items';
import { createLogger } from '../../shared/utils/logger';
import { StalkerStore } from '../../stalker/stalker.store';
import { FavoritesContextService } from '../../workspace/favorites-context.service';
import { XtreamStore } from '../stores/xtream.store';
const XTREAM_RECENT_LAYOUT: Omit<
PortalCollectionShellLayout,
'showHeaderAction'
> = {
titleTranslationKey: 'PORTALS.SIDEBAR.RECENT',
removeTooltip: 'Remove from history',
emptyIcon: 'history',
headerActionIcon: 'delete_sweep',
headerActionTooltip: 'Clear history',
};
const XTREAM_COLLECTION_LABELS = {
all: 'All',
movie: 'Movies',
live: 'Live TV',
series: 'Series',
};
@Component({
selector: 'app-recently-viewed',
imports: [
ContentCardComponent,
FavoritesLayoutComponent,
FormsModule,
KeyValuePipe,
MatButton,
MatCheckboxModule,
MatIcon,
MatIconButton,
PortalCollectionShellComponent,
],
providers: [],
templateUrl: './recently-viewed.component.html',
styleUrl: './recently-viewed.component.scss',
})
export class RecentlyViewedComponent implements OnDestroy {
export class RecentlyViewedComponent {
private static readonly GROUP_BY_STORAGE_KEY =
'global-recent-group-by-playlist';
private static readonly TYPE_FILTERS_STORAGE_KEY =
@@ -55,14 +79,17 @@ export class RecentlyViewedComponent implements OnDestroy {
private router = inject(Router);
private readonly dbService = inject(DatabaseService);
private readonly playlistsService = inject(PlaylistsService);
private readonly stalkerStore = inject(StalkerStore);
private dialogData = inject(MAT_DIALOG_DATA, { optional: true });
private readonly logger = createLogger('XtreamRecentlyViewed');
private readonly favoritesCtx = inject(FavoritesContextService);
readonly isGlobal = this.dialogData?.isGlobal ?? false;
readonly isWorkspaceLayout =
this.activatedRoute.snapshot.data['layout'] === 'workspace';
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.activatedRoute);
readonly nonGlobalLayout: PortalCollectionShellLayout = {
...XTREAM_RECENT_LAYOUT,
showHeaderAction: !this.isWorkspaceLayout,
};
readonly playlistSubtitle = 'Xtream Code';
private readonly _groupByPlaylist = (() => {
const saved = localStorage.getItem(
RecentlyViewedComponent.GROUP_BY_STORAGE_KEY
@@ -98,8 +125,7 @@ export class RecentlyViewedComponent implements OnDestroy {
? this.xtreamStore.globalRecentItems()
: this.xtreamStore.recentItems()
);
readonly selectedCategoryId = this.favoritesCtx.selectedCategoryId;
readonly categories = computed<XtreamCategory[]>(() => {
readonly categories = computed(() => {
const items = this.recentItems();
const movies = items.filter(
(item: any) => item?.type === 'movie'
@@ -109,44 +135,28 @@ export class RecentlyViewedComponent implements OnDestroy {
(item: any) => item?.type === 'series'
).length;
const cats: XtreamCategory[] = [
{
id: 1,
category_id: 'all',
category_name: 'All',
count: items.length,
parent_id: 0,
return buildStandardCollectionCategories({
labels: XTREAM_COLLECTION_LABELS,
counts: {
all: items.length,
movie: movies,
live,
series,
},
{
id: 2,
category_id: 'movie',
category_name: 'Movies',
count: movies,
parent_id: 0,
},
{
id: 3,
category_id: 'live',
category_name: 'Live TV',
count: live,
parent_id: 0,
},
{
id: 4,
category_id: 'series',
category_name: 'Series',
count: series,
parent_id: 0,
},
];
return cats;
includeLive: true,
});
});
readonly collectionContext = createPortalCollectionContext({
ctx: this.favoritesCtx,
categories: this.categories,
enabled: () => !this.isGlobal,
});
readonly selectedCategoryId = this.collectionContext.selectedCategoryId;
readonly recentSearchTerm = signal('');
readonly workspaceSearchTerm = toSignal(
this.activatedRoute.queryParamMap.pipe(
map((params) => (params.get('q') ?? '').trim().toLowerCase())
),
{ initialValue: '' }
readonly workspaceSearchTerm = queryParamSignal(
this.activatedRoute,
'q',
(value) => (value ?? '').trim().toLowerCase()
);
readonly filteredRecentItems = computed(() => {
const term =
@@ -180,20 +190,25 @@ export class RecentlyViewedComponent implements OnDestroy {
});
readonly nonGlobalItemsToShow = computed(() => {
const items = this.visibleRecentItems();
switch (this.selectedCategoryId()) {
case 'movie':
return items.filter((item: any) => item?.type === 'movie');
case 'live':
return items.filter((item: any) => item?.type === 'live');
case 'series':
return items.filter((item: any) => item?.type === 'series');
case 'all':
default:
return items;
}
return filterCollectionBucket({
selectedCategoryId: this.selectedCategoryId(),
allItems: items,
buckets: {
movie: items.filter((item: any) => item?.type === 'movie'),
live: items.filter((item: any) => item?.type === 'live'),
series: items.filter((item: any) => item?.type === 'series'),
},
textOf: (item: any) =>
`${item?.title ?? ''} ${item?.playlist_name ?? ''}`,
});
});
readonly currentPlaylist = this.xtreamStore.currentPlaylist;
readonly playlistTitle = computed(
() =>
this.currentPlaylist()?.name ||
this.currentPlaylist()?.title ||
'Playlist'
);
constructor(
@Optional() public dialogRef?: MatDialogRef<RecentlyViewedComponent>
@@ -205,13 +220,6 @@ export class RecentlyViewedComponent implements OnDestroy {
} else if (this.currentPlaylist()) {
this.xtreamStore.loadRecentItems(this.currentPlaylist);
}
// Keep workspace context panel in sync with current categories
effect(() => {
if (!this.isGlobal) {
this.favoritesCtx.setCategories(this.categories());
}
});
}
private async loadGlobalItems() {
@@ -342,10 +350,6 @@ export class RecentlyViewedComponent implements OnDestroy {
}
setCategoryId(categoryId: string): void {
this.favoritesCtx.setCategoryId(categoryId);
}
ngOnDestroy(): void {
this.favoritesCtx.reset();
this.collectionContext.setCategoryId(categoryId);
}
}
@@ -10,7 +10,6 @@ import {
signal,
viewChild,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
import { MatIconButton } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
@@ -23,10 +22,13 @@ import { MatIcon } from '@angular/material/icon';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import groupBy from 'lodash/groupBy';
import { map } from 'rxjs';
import { DatabaseService } from 'services';
import { ContentCardComponent } from '../../shared/components/content-card/content-card.component';
import { SearchLayoutComponent } from '../../shared/components/search-layout/search-layout.component';
import {
isWorkspaceLayoutRoute,
queryParamSignal,
} from '../../shared/navigation/portal-route.utils';
import { createLogger } from '../../shared/utils/logger';
import { XtreamContentItem } from '../data-sources/xtream-data-source.interface';
import { SearchFilters } from '../stores/features/with-search.feature';
@@ -62,13 +64,11 @@ export class SearchResultsComponent implements AfterViewInit {
readonly activatedRoute = inject(ActivatedRoute);
readonly databaseService = inject(DatabaseService);
private readonly logger = createLogger('XtreamSearchResults');
readonly isWorkspaceLayout =
this.activatedRoute.snapshot.data['layout'] === 'workspace';
readonly routeSearchTerm = toSignal(
this.activatedRoute.queryParamMap.pipe(
map((params) => (params.get('q') ?? '').trim())
),
{ initialValue: '' }
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.activatedRoute);
readonly routeSearchTerm = queryParamSignal(
this.activatedRoute,
'q',
(value) => (value ?? '').trim()
);
/** Search term from store */
@@ -11,11 +11,12 @@ import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { PlaylistSwitcherComponent, ResizableDirective } from 'components';
import { XtreamCategory } from 'shared-interfaces';
import { CategoryViewComponent } from '../shared/components/category-view/category-view.component';
import { isWorkspaceLayoutRoute } from '../shared/navigation/portal-route.utils';
import {
CategoryManagementDialogComponent,
CategoryManagementDialogData,
} from './category-management-dialog/category-management-dialog.component';
import { CategoryViewComponent } from './category-view/category-view.component';
import { XtreamStore } from './stores/xtream.store';
import { XtreamCategorySortMode } from './stores/features/with-selection.feature';
@@ -24,7 +25,10 @@ const XTREAM_CATEGORY_SORT_STORAGE_KEY = 'xtream-category-sort-mode';
@Component({
selector: 'app-xtream-main-container',
templateUrl: './xtream-main-container.component.html',
styleUrls: ['./xtream-main-container.component.scss', './sidebar.scss'],
styleUrls: [
'./xtream-main-container.component.scss',
'../shared/styles/portal-sidebar.scss',
],
imports: [
CategoryViewComponent,
FormsModule,
@@ -58,8 +62,7 @@ export class XtreamMainContainerComponent implements OnInit {
const type = this.xtreamStore.selectedContentType();
return hasCategorySelected && (type === 'vod' || type === 'series');
});
readonly isWorkspaceLayout =
this.route.snapshot.data['layout'] === 'workspace';
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route);
readonly contentSortLabel = computed(() => {
const mode = this.contentSortMode();
if (mode === 'date-asc') return 'Date Added (Oldest First)';
@@ -10,6 +10,11 @@ import { Store } from '@ngrx/store';
import { TranslateModule } from '@ngx-translate/core';
import { PlaylistActions } from 'm3u-state';
import { filter } from 'rxjs';
import {
isWorkspaceLayoutRoute,
resolveCurrentPortalSection,
} from '../../shared/navigation/portal-route.utils';
import { PortalRailSection } from '../../shared/navigation/portal-rail-links';
import { LoadingOverlayComponent } from '../loading-overlay/loading-overlay.component';
import { NavigationComponent } from '../navigation/navigation.component';
import { XtreamStore } from '../stores/xtream.store';
@@ -34,15 +39,14 @@ export class XtreamShellComponent {
readonly isImporting = this.xtreamStore.isImporting;
readonly itemsToImport = this.xtreamStore.itemsToImport;
readonly portalStatus = this.xtreamStore.portalStatus;
readonly isWorkspaceLayout =
this.route.snapshot.data['layout'] === 'workspace';
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route);
readonly showImportOverlay = computed(() => {
const section = this.currentSection();
return this.isImporting() && this.isContentSection(section);
});
private currentPlaylistId: string | null = null;
private readonly currentSection = signal<string | null>(null);
private readonly currentSection = signal<PortalRailSection | null>(null);
constructor() {
// Subscribe to route params to handle switching between playlists
@@ -108,10 +112,11 @@ export class XtreamShellComponent {
}
private syncSectionFromRoute(): void {
const sectionFromSnapshot =
this.route.firstChild?.snapshot?.url?.[0]?.path ?? null;
const sectionFromUrl = this.getSectionFromUrl(this.router.url);
const section = sectionFromSnapshot ?? sectionFromUrl;
const section = resolveCurrentPortalSection(
this.route,
this.router.url,
'xtreams'
);
if (!section || section === this.currentSection()) {
return;
@@ -127,14 +132,7 @@ export class XtreamShellComponent {
this.xtreamStore.setSelectedContentType(undefined);
}
private getSectionFromUrl(url: string): string | null {
const match = url.match(
/^\/(?:workspace\/)?xtreams\/[^\/\?]+\/([^\/\?]+)/
);
return match?.[1] ?? null;
}
private isContentSection(section: string | null): boolean {
private isContentSection(section: PortalRailSection | null): boolean {
return (
section === 'vod' ||
section === 'live' ||