From 52a618fd0f4f7cf0c529cf932ab323b5e75973ed Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 16 Feb 2026 22:15:20 +0100 Subject: [PATCH] feat(stalker): display all type items when no category is selected --- .../stalker-main-container.component.html | 6 +- .../stalker-main-container.component.ts | 25 ++- .../stalker-shell/stalker-shell.component.ts | 8 +- apps/web/src/app/stalker/stalker.routes.ts | 22 +++ .../features/with-stalker-content.feature.ts | 69 ++++++-- .../category-content-view.component.ts | 151 +++++++++++++----- 6 files changed, 223 insertions(+), 58 deletions(-) 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>(new Map()); + private readonly stalkerPositions = signal< + Map + >(new Map()); /** Stalker series positions (key: seriesId, value: episode positions) */ - private readonly stalkerSeriesPositions = signal>(new Map()); + private readonly stalkerSeriesPositions = signal< + Map + >(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; + 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; + 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; + 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 + ).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 { - const positions = await this.dataSource.getAllPlaybackPositions(playlistId); + const positions = + await this.dataSource.getAllPlaybackPositions(playlistId); const positionsMap = new Map(); const seriesMap = new Map(); @@ -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 { - const position = await this.dataSource.getPlaybackPosition(playlistId, vodId, 'vod'); + private async loadSingleVodPosition( + playlistId: string, + vodId: number + ): Promise { + 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).removeFromFavorites( - favoriteId, - onDone - ); + ( + this.store as InstanceType + ).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; + 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,