feat(stalker): display all type items when no category is selected

This commit is contained in:
4gray committed 2026-02-16 22:15:20 +01:00
1 parent 53a23cd03a
commit 52a618fd0f
6 files changed
+223 -58

No files matched your search

@@ -48,7 +48,11 @@
{{ getContentLabel() }}
@if (!contentItems() && !isContentLoading()) {
@if (
!contentItems() &&
!isContentLoading() &&
!stalkerStore.selectedCategoryId()
) {
<h2>
{{ 'PORTALS.SELECT_CATEGORY' | translate }}
</h2>
@@ -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<string, string> = {
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;
@@ -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,
});
@@ -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: () =>
@@ -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),
});
}
@@ -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<Map<string, PlaybackPositionData>>(new Map());
private readonly stalkerPositions = signal<
Map<string, PlaybackPositionData>
>(new Map());
/** Stalker series positions (key: seriesId, value: episode positions) */
private readonly stalkerSeriesPositions = signal<Map<number, PlaybackPositionData[]>>(new Map());
private readonly stalkerSeriesPositions = signal<
Map<number, PlaybackPositionData[]>
>(new Map());
/** Track which playlist we've loaded positions for */
private loadedPositionsForPlaylistId: string | null = null;
@@ -77,11 +89,16 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
// Effect to load Stalker positions when playlist becomes available
if (this.isStalker) {
effect(() => {
const stalkerStore = this.store as InstanceType<typeof StalkerStore>;
const stalkerStore = this.store as InstanceType<
typeof StalkerStore
>;
const playlist = stalkerStore.currentPlaylist();
const playlistId = playlist?._id;
if (playlistId && playlistId !== this.loadedPositionsForPlaylistId) {
if (
playlistId &&
playlistId !== this.loadedPositionsForPlaylistId
) {
this.loadedPositionsForPlaylistId = playlistId;
this.loadStalkerPositions(playlistId);
}
@@ -89,7 +106,9 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
// Effect to load position for selected VOD item (ensures fresh data after playback)
effect(() => {
const stalkerStore = this.store as InstanceType<typeof StalkerStore>;
const stalkerStore = this.store as InstanceType<
typeof StalkerStore
>;
const item = this.selectedItem();
const playlist = stalkerStore.currentPlaylist();
@@ -100,16 +119,22 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
// Listen for real-time playback position updates from video player
if (window.electron?.onPlaybackPositionUpdate) {
this.unsubscribePositionUpdates = window.electron.onPlaybackPositionUpdate(
(data: PlaybackPositionData) => {
if (data.contentType === 'vod' && data.contentXtreamId) {
const key = `vod_${data.contentXtreamId}`;
const updated = new Map(this.stalkerPositions());
updated.set(key, data);
this.stalkerPositions.set(updated);
this.unsubscribePositionUpdates =
window.electron.onPlaybackPositionUpdate(
(data: PlaybackPositionData) => {
if (
data.contentType === 'vod' &&
data.contentXtreamId
) {
const key = `vod_${data.contentXtreamId}`;
const updated = new Map(
this.stalkerPositions()
);
updated.set(key, data);
this.stalkerPositions.set(updated);
}
}
}
);
);
}
}
}
@@ -129,10 +154,15 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
// Check if this item has series/episode progress (watched episodes)
// This works even when is_series flag isn't set on the item
const hasSeriesProgress = seriesPositions.has(id) && seriesPositions.get(id)!.length > 0;
const hasSeriesProgress =
seriesPositions.has(id) &&
seriesPositions.get(id)!.length > 0;
// For series content type, or items with is_series flag
const isSeries = contentType === 'series' || item.is_series === '1' || item.is_series === 1;
const isSeries =
contentType === 'series' ||
item.is_series === '1' ||
item.is_series === 1;
if (hasSeriesProgress) {
return { ...item, hasSeriesProgress: true };
@@ -157,7 +187,9 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
return items.map((item: any) => {
const isSeries = this.contentType() === 'series';
const id = Number(item.xtream_id || item.series_id || item.stream_id);
const id = Number(
item.xtream_id || item.series_id || item.stream_id
);
if (isSeries) {
return {
@@ -210,7 +242,9 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
this.playlistService,
() => {
if (!this.isStalker) return undefined;
const stalkerStore = this.store as InstanceType<typeof StalkerStore>;
const stalkerStore = this.store as InstanceType<
typeof StalkerStore
>;
return stalkerStore.currentPlaylist()?._id;
},
() => this.favoritesRefresh.refreshVersion()
@@ -245,8 +279,22 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
if (categoryId) {
this.store.setSelectedCategory(categoryId);
} else {
// No categoryId in route means "All Items"
this.store.setSelectedCategory(null);
// Stalker uses "*" as the "All" category id
if (this.isStalker) {
(
this.store as InstanceType<typeof StalkerStore>
).setSelectedCategory('*');
} else {
this.store.setSelectedCategory(null);
}
}
if (this.isStalker) {
this.logger.warn('Stalker category init from route', {
routeCategoryId: categoryId ?? null,
selectedCategoryId: (this.store as any).selectedCategoryId?.(),
selectedContentType: this.contentType(),
});
}
}
@@ -254,7 +302,8 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
* Load all playback positions for a Stalker portal
*/
private async loadStalkerPositions(playlistId: string): Promise<void> {
const positions = await this.dataSource.getAllPlaybackPositions(playlistId);
const positions =
await this.dataSource.getAllPlaybackPositions(playlistId);
const positionsMap = new Map<string, PlaybackPositionData>();
const seriesMap = new Map<number, PlaybackPositionData[]>();
@@ -278,8 +327,15 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
/**
* Load playback position for a single VOD item
*/
private async loadSingleVodPosition(playlistId: string, vodId: number): Promise<void> {
const position = await this.dataSource.getPlaybackPosition(playlistId, vodId, 'vod');
private async loadSingleVodPosition(
playlistId: string,
vodId: number
): Promise<void> {
const position = await this.dataSource.getPlaybackPosition(
playlistId,
vodId,
'vod'
);
if (position) {
const key = `vod_${vodId}`;
@@ -292,10 +348,13 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
/**
* Calculate progress percentage from position data
*/
private calculateProgress(position: PlaybackPositionData | undefined): number {
private calculateProgress(
position: PlaybackPositionData | undefined
): number {
if (!position || !position.durationSeconds) return 0;
const percent = (position.positionSeconds / position.durationSeconds) * 100;
const percent =
(position.positionSeconds / position.durationSeconds) * 100;
// If watched > 10s but percent < 1, return 1 to show visual progress
if (position.positionSeconds > 10 && percent < 1) {
@@ -359,10 +418,9 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
removeFromFavorites(favoriteId: string, onDone?: () => void) {
this.logger.debug('Remove from favorites', favoriteId);
if (this.isStalker) {
(this.store as InstanceType<typeof StalkerStore>).removeFromFavorites(
favoriteId,
onDone
);
(
this.store as InstanceType<typeof StalkerStore>
).removeFromFavorites(favoriteId, onDone);
return;
}
this.store.removeFromFavorites(favoriteId);
@@ -381,9 +439,14 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
}
/** Handle resume from vod-details component */
onVodResume(event: { item: VodDetailsItem; positionSeconds: number }): void {
onVodResume(event: {
item: VodDetailsItem;
positionSeconds: number;
}): void {
if (event.item.type === 'stalker') {
const stalkerStore = this.store as InstanceType<typeof StalkerStore>;
const stalkerStore = this.store as InstanceType<
typeof StalkerStore
>;
stalkerStore.createLinkToPlayVod(
event.item.cmd,
event.item.data.info?.name,
@@ -435,7 +498,8 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
cmdToUse.includes('/media/') &&
!cmdToUse.includes('/media/file_')
) {
const fileId = await stalkerStore.fetchMovieFileId(normalizedItemId);
const fileId =
await stalkerStore.fetchMovieFileId(normalizedItemId);
if (fileId) {
cmdToUse = `/media/file_${fileId}.mpg`;
}
@@ -448,8 +512,7 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
);
if (!url) return;
const numericId =
normalizeStalkerEntityIdAsNumber(itemData?.id) ?? 0;
const numericId = normalizeStalkerEntityIdAsNumber(itemData?.id) ?? 0;
await this.downloadsService.startDownload({
playlistId: playlist._id,