diff --git a/apps/web/src/app/stalker/stalker-main-container.component.html b/apps/web/src/app/stalker/stalker-main-container.component.html
index 522c8811e..41755af3b 100644
--- a/apps/web/src/app/stalker/stalker-main-container.component.html
+++ b/apps/web/src/app/stalker/stalker-main-container.component.html
@@ -48,7 +48,11 @@
{{ getContentLabel() }}
- @if (!contentItems() && !isContentLoading()) {
+ @if (
+ !contentItems() &&
+ !isContentLoading() &&
+ !stalkerStore.selectedCategoryId()
+ ) {
{{ 'PORTALS.SELECT_CATEGORY' | translate }}
diff --git a/apps/web/src/app/stalker/stalker-main-container.component.ts b/apps/web/src/app/stalker/stalker-main-container.component.ts
index 0b264f153..d143caac9 100644
--- a/apps/web/src/app/stalker/stalker-main-container.component.ts
+++ b/apps/web/src/app/stalker/stalker-main-container.component.ts
@@ -87,11 +87,34 @@ export class StalkerMainContainerComponent {
}
getContentLabel(): string {
- const categoryName = this.stalkerStore.getSelectedCategoryName();
+ const selectedContentType = this.stalkerStore.selectedContentType();
+ const selectedCategoryId = this.stalkerStore.selectedCategoryId();
+ const typeLabelByContentType: Record = {
+ vod: this.translateService.instant('PORTALS.SIDEBAR.MOVIES'),
+ series: this.translateService.instant('PORTALS.SIDEBAR.SERIES'),
+ itv: this.translateService.instant('PORTALS.SIDEBAR.LIVE_TV'),
+ };
+ const selectedTypeLabel =
+ typeLabelByContentType[selectedContentType] ?? '';
+
+ const selectedCategoryName = this.stalkerStore.getSelectedCategoryName();
+ let categoryName = selectedCategoryName;
+
+ // For implicit "All" view, show a type-specific title so switching
+ // between VOD/Series updates header text immediately.
+ if (selectedCategoryId === '*') {
+ categoryName = `${selectedTypeLabel} - ${this.translateService.instant(
+ 'PORTALS.ALL_CATEGORIES'
+ )}`;
+ } else if (!categoryName) {
+ // Fallback while categories are loading or when no category is selected.
+ categoryName = selectedTypeLabel;
+ }
// Show page number when viewing category content (not detail view)
if (
!this.stalkerStore.selectedItem() &&
+ !this.isContentLoading() &&
this.stalkerStore.getTotalPages() > 1
) {
const currentPage = this.stalkerStore.page() + 1;
diff --git a/apps/web/src/app/stalker/stalker-shell/stalker-shell.component.ts b/apps/web/src/app/stalker/stalker-shell/stalker-shell.component.ts
index 7ced6961d..c81cb75f8 100644
--- a/apps/web/src/app/stalker/stalker-shell/stalker-shell.component.ts
+++ b/apps/web/src/app/stalker/stalker-shell/stalker-shell.component.ts
@@ -4,6 +4,7 @@ import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
import { Store } from '@ngrx/store';
import { PlaylistActions, selectPlaylistById } from 'm3u-state';
import { map, switchMap } from 'rxjs';
+import { createLogger } from '../../shared/utils/logger';
import { NavigationComponent } from '../../xtream-tauri/navigation/navigation.component';
import { NavigationItem } from '../../xtream-tauri/navigation/navigation.interface';
import { XtreamStore } from '../../xtream-tauri/stores/xtream.store';
@@ -17,6 +18,7 @@ import { StalkerStore } from '../stalker.store';
providers: [XtreamStore],
})
export class StalkerShellComponent implements OnDestroy {
+ private readonly logger = createLogger('StalkerShell');
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
readonly stalkerStore = inject(StalkerStore);
@@ -64,7 +66,9 @@ export class StalkerShellComponent implements OnDestroy {
const childRoute = this.route.snapshot.firstChild;
const path = childRoute?.url[0]?.path;
const validTypes = ['vod', 'series', 'itv'];
- const initialType = validTypes.includes(path) ? (path as any) : 'vod';
+ const initialType = validTypes.includes(path)
+ ? (path as any)
+ : 'vod';
this.stalkerStore.setSelectedContentType(initialType);
});
@@ -76,7 +80,7 @@ export class StalkerShellComponent implements OnDestroy {
setContentType(type: 'vod' | 'live' | 'series' | 'itv') {
if (type === 'live') type = 'itv';
this.stalkerStore.setSelectedContentType(type);
- this.stalkerStore.setSelectedCategory(null);
+ this.stalkerStore.setSelectedCategory(type === 'itv' ? null : '*');
this.router.navigate([type], {
relativeTo: this.route,
});
diff --git a/apps/web/src/app/stalker/stalker.routes.ts b/apps/web/src/app/stalker/stalker.routes.ts
index ea0bb9e63..fbdd2b375 100644
--- a/apps/web/src/app/stalker/stalker.routes.ts
+++ b/apps/web/src/app/stalker/stalker.routes.ts
@@ -20,6 +20,17 @@ export const stalkerRoutes: Route[] = [
(c) => c.StalkerMainContainerComponent
),
children: [
+ {
+ path: '',
+ data: {
+ api: 'stalker',
+ contentType: 'vod',
+ },
+ loadComponent: () =>
+ import(
+ '../xtream-tauri/category-content-view/category-content-view.component'
+ ).then((c) => c.CategoryContentViewComponent),
+ },
{
path: ':categoryId',
data: {
@@ -53,6 +64,17 @@ export const stalkerRoutes: Route[] = [
(c) => c.StalkerMainContainerComponent
),
children: [
+ {
+ path: '',
+ loadComponent: () =>
+ import(
+ '../xtream-tauri/category-content-view/category-content-view.component'
+ ).then((c) => c.CategoryContentViewComponent),
+ data: {
+ api: 'stalker',
+ contentType: 'series',
+ },
+ },
{
path: ':categoryId',
loadComponent: () =>
diff --git a/apps/web/src/app/stalker/stores/features/with-stalker-content.feature.ts b/apps/web/src/app/stalker/stores/features/with-stalker-content.feature.ts
index 63b5c626b..ed9b13c01 100644
--- a/apps/web/src/app/stalker/stores/features/with-stalker-content.feature.ts
+++ b/apps/web/src/app/stalker/stores/features/with-stalker-content.feature.ts
@@ -7,12 +7,14 @@ import {
withProps,
withState,
} from '@ngrx/signals';
+import { TranslateService } from '@ngx-translate/core';
import { DataService, StalkerSessionService } from 'services';
import {
Playlist,
STALKER_REQUEST,
StalkerPortalActions,
} from 'shared-interfaces';
+import { createLogger } from '../../../shared/utils/logger';
import {
StalkerCategoryItem,
StalkerContentItem,
@@ -20,7 +22,6 @@ import {
StalkerVodSource,
} from '../../models';
import { StalkerContentTypes } from '../../stalker-content-types';
-import { createLogger } from '../../../shared/utils/logger';
import { toStalkerContentItem, toStalkerItvChannel } from '../utils';
/**
@@ -52,7 +53,8 @@ export function withStalkerContent() {
(
store,
dataService = inject(DataService),
- stalkerSession = inject(StalkerSessionService)
+ stalkerSession = inject(StalkerSessionService),
+ translateService = inject(TranslateService)
) => ({
getCategoryResource: resource({
params: () => ({
@@ -83,7 +85,8 @@ export function withStalkerContent() {
break;
}
- const { portalUrl, macAddress } = params.currentPlaylist;
+ const { portalUrl, macAddress } =
+ params.currentPlaylist;
// Use makeAuthenticatedRequest for automatic retry on auth failure
const playlist = params.currentPlaylist as Playlist;
@@ -115,18 +118,37 @@ export function withStalkerContent() {
// Guard: ensure response has expected structure
if (!response?.js || !Array.isArray(response.js)) {
- logger.warn('Invalid categories response', response);
+ logger.warn(
+ 'Invalid categories response',
+ response
+ );
return [];
}
const categories = response.js
- .map((item): StalkerCategoryItem => ({
- category_name: item.title,
- category_id: String(item.id),
- }))
+ .map(
+ (item): StalkerCategoryItem => ({
+ category_name: item.title,
+ category_id: String(item.id),
+ })
+ )
.sort((a, b) =>
a.category_name.localeCompare(b.category_name)
);
+ if (
+ categories.length > 0 &&
+ !categories.some(
+ (category) =>
+ String(category.category_id) === '*'
+ )
+ ) {
+ categories.unshift({
+ category_name: translateService.instant(
+ 'PORTALS.ALL_CATEGORIES'
+ ),
+ category_id: '*',
+ });
+ }
patchState(store, {
[`${params.contentType}Categories`]: categories,
});
@@ -140,6 +162,21 @@ export function withStalkerContent() {
action: StalkerPortalActions.GetOrderedList,
search: (store as any).searchPhrase(),
pageIndex: (store as any).page() + 1,
+ availableCategoryCount: (() => {
+ const contentType = (
+ store as any
+ ).selectedContentType();
+ const categories =
+ contentType === 'vod'
+ ? store.vodCategories()
+ : contentType === 'series'
+ ? store.seriesCategories()
+ : store.itvCategories();
+ return categories.filter(
+ (category) =>
+ String(category.category_id) !== '*'
+ ).length;
+ })(),
}),
loader: async ({
params,
@@ -151,11 +188,22 @@ export function withStalkerContent() {
) {
return Promise.resolve(undefined);
}
+ if (
+ params.category === '*' &&
+ (params.contentType === 'vod' ||
+ params.contentType === 'series') &&
+ params.availableCategoryCount === 0
+ ) {
+ return Promise.resolve(undefined);
+ }
const currentPlaylist = (store as any).currentPlaylist;
// Guard: ensure currentPlaylist is available (may not be during deep link init)
- if (!currentPlaylist() || !currentPlaylist().portalUrl) {
+ if (
+ !currentPlaylist() ||
+ !currentPlaylist().portalUrl
+ ) {
return Promise.resolve(undefined);
}
// VOD uses 'genre' param, series uses 'category' param, itv uses both
@@ -242,7 +290,8 @@ export function withStalkerContent() {
const totalLoaded = store.itvChannels().length;
patchState(store, {
hasMoreChannels:
- totalLoaded < (response.js.total_items ?? 0),
+ totalLoaded <
+ (response.js.total_items ?? 0),
});
}
diff --git a/apps/web/src/app/xtream-tauri/category-content-view/category-content-view.component.ts b/apps/web/src/app/xtream-tauri/category-content-view/category-content-view.component.ts
index d82add385..5b496be4f 100644
--- a/apps/web/src/app/xtream-tauri/category-content-view/category-content-view.component.ts
+++ b/apps/web/src/app/xtream-tauri/category-content-view/category-content-view.component.ts
@@ -1,22 +1,27 @@
-import { Component, inject, OnInit, OnDestroy, computed, signal, effect } from '@angular/core';
+import {
+ Component,
+ computed,
+ effect,
+ inject,
+ OnDestroy,
+ OnInit,
+ signal,
+} from '@angular/core';
import { PageEvent } from '@angular/material/paginator';
import { ActivatedRoute, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslatePipe } from '@ngx-translate/core';
import { PlaylistsService } from 'services';
import {
- VodDetailsItem,
- StalkerVodDetails,
createStalkerVodItem,
PlaybackPositionData,
+ StalkerVodDetails,
+ VodDetailsItem,
} from 'shared-interfaces';
+import { DownloadsService } from '../../services/downloads.service';
import { GridListComponent } from '../../shared/components/grid-list/grid-list.component';
+import { createLogger } from '../../shared/utils/logger';
import { StalkerSeriesViewComponent } from '../../stalker/stalker-series-view/stalker-series-view.component';
-import { StalkerStore } from '../../stalker/stalker.store';
-import { VodDetailsComponent } from '../../xtream/vod-details/vod-details.component';
-import { XTREAM_DATA_SOURCE } from '../data-sources';
-import { PlaylistErrorViewComponent } from '../playlist-error-view/playlist-error-view.component';
-import { XtreamStore } from '../stores/xtream.store';
import {
buildStalkerSelectedVodItem,
createPortalFavoritesResource,
@@ -26,8 +31,11 @@ import {
normalizeStalkerEntityIdAsNumber,
toggleStalkerVodFavorite,
} from '../../stalker/stalker-vod.utils';
-import { createLogger } from '../../shared/utils/logger';
-import { DownloadsService } from '../../services/downloads.service';
+import { StalkerStore } from '../../stalker/stalker.store';
+import { VodDetailsComponent } from '../../xtream/vod-details/vod-details.component';
+import { XTREAM_DATA_SOURCE } from '../data-sources';
+import { PlaylistErrorViewComponent } from '../playlist-error-view/playlist-error-view.component';
+import { XtreamStore } from '../stores/xtream.store';
@Component({
selector: 'app-category-content-view',
@@ -67,9 +75,13 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
readonly paginatedContent = this.store.getPaginatedContent;
/** Stalker playback positions (key: `${contentType}_${id}`) */
- private readonly stalkerPositions = signal