feat: refactor Stalker layout to support workspace mode

- Updated the StalkerLiveStreamLayout component to conditionally render the sidebar based on the workspace layout.
- Enhanced the sidebar styling to use theme variables for consistency.
- Modified the StalkerMainContainer and StalkerShell components to accommodate workspace layout changes.
- Improved the search component to hide the search input in workspace mode.
- Adjusted the player feature to handle content types more effectively.
- Refactored selection feature to streamline category and item management.
- Ensured all components utilize the new layout structure and styling conventions.
This commit is contained in:
4gray committed 2026-02-24 19:14:24 +01:00
1 parent fd2f5972b0
commit 24581a2131
16 files changed
+624 -364

No files matched your search

@@ -37,12 +37,12 @@
[titleTranslationString]="'PORTALS.SIDEBAR.RECENT'"
[removeTooltip]="'Remove from recent'"
[emptyIcon]="'history'"
[showHeaderAction]="true"
[showHeaderAction]="!isWorkspaceLayout"
[headerActionIcon]="'delete_sweep'"
[headerActionTooltip]="'Clear all recently viewed'"
[headerActionTooltip]="'Clear recently viewed'"
(categoryClicked)="setCategoryId($event.category_id)"
(removeFavorite)="removeFromRecentlyViewed($event)"
(openItem)="openItem($event)"
(headerActionClicked)="clearAllRecentlyViewed()"
(openItem)="openItem($event)"
/>
}
@@ -1,29 +1,34 @@
import { Component, computed, effect, inject, signal } from '@angular/core';
import { Router } from '@angular/router';
import {
Component,
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 { VodDetailsItem } from 'shared-interfaces';
import { FavoritesLayoutComponent } from '../../shared/components/favorites-layout/favorites-layout.component';
import { VodDetailsComponent } from '../../xtream/vod-details/vod-details.component';
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
import { StalkerStore } from '../stalker.store';
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 {
StalkerSelectedVodItem,
StalkerVodSource,
} from '../models';
import {
clearStalkerDetailViewState,
createPortalCollectionResource,
createPortalFavoritesResource,
toggleStalkerVodFavorite,
clearStalkerDetailViewState,
createStalkerDetailViewState,
createRefreshTrigger,
normalizeStalkerVodDetailsItem,
createStalkerDetailViewState,
isSelectedStalkerVodFavorite,
normalizeStalkerVodDetailsItem,
toggleStalkerVodFavorite,
} from '../stalker-vod.utils';
import { StalkerStore } from '../stalker.store';
@Component({
selector: 'app-recently-viewed',
@@ -48,12 +53,13 @@ import {
`,
],
})
export class RecentlyViewedComponent {
export class RecentlyViewedComponent implements OnDestroy {
private static isCategoryType(
value: string
): value is 'vod' | 'series' | 'itv' {
return value === 'vod' || value === 'series' || value === 'itv';
}
private readonly route = inject(ActivatedRoute);
private readonly playlistService = inject(PlaylistsService);
private readonly collectionRefresh = createRefreshTrigger();
private readonly favoritesRefresh = createRefreshTrigger();
@@ -61,6 +67,7 @@ export class RecentlyViewedComponent {
private readonly router = inject(Router);
private readonly translate = inject(TranslateService);
private readonly logger = createLogger('StalkerRecentlyViewed');
private readonly favoritesCtx = inject(FavoritesContextService);
itemDetails: (StalkerVodSource & { category_id?: string }) | null = null;
vodDetailsItem: VodDetailsItem | null = null;
@@ -84,50 +91,79 @@ export class RecentlyViewedComponent {
readonly categories = computed(() => [
{
id: 1,
category_id: 'movie',
category_name:
this.translate.instant('PORTALS.SIDEBAR.MOVIES') +
' (' +
(this.movies()?.length ?? 0) +
')',
category_id: 'all',
category_name: this.translate.instant('PORTALS.ALL_CATEGORIES'),
count: this.allFavorites.value()?.length ?? 0,
parent_id: 0,
},
{
id: 2,
category_id: 'itv',
category_name:
this.translate.instant('PORTALS.SIDEBAR.LIVE_TV') +
' (' +
(this.live()?.length ?? 0) +
')',
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') +
' (' +
(this.series()?.length ?? 0) +
')',
category_name: this.translate.instant('PORTALS.SIDEBAR.SERIES'),
count: this.series()?.length ?? 0,
parent_id: 0,
},
]);
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 this.movies();
return filterByTerm(this.movies());
case 'itv':
return this.live();
return filterByTerm(this.live());
case 'series':
return this.series();
return filterByTerm(this.series());
default:
return [];
}
});
readonly selectedCategoryId = signal<string>('movie');
/** 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 refreshToken = toSignal(
this.route.queryParamMap.pipe(
map((params) => params.get('refresh') ?? '')
),
{ initialValue: '' }
);
readonly series = computed(() =>
this.allFavorites
@@ -147,14 +183,32 @@ export class RecentlyViewedComponent {
this.syncSelectedVodFavorite();
});
// Keep workspace context panel in sync with categories
effect(() => {
this.favoritesCtx.setCategories(this.categories());
});
effect(() => {
const refresh = this.refreshToken();
if (!refresh) {
return;
}
this.collectionRefresh.refresh();
});
effect(() => {
const playlist = this.currentPlaylist();
if (!playlist?._id) return;
const state = this.router.currentNavigation()?.extras?.state
?? window.history.state;
const state =
this.router.currentNavigation()?.extras?.state ??
window.history.state;
const item = state?.openRecentItem;
if (!item || !RecentlyViewedComponent.isCategoryType(item.category_id)) {
if (
!item ||
!RecentlyViewedComponent.isCategoryType(item.category_id)
) {
return;
}
@@ -191,7 +245,7 @@ export class RecentlyViewedComponent {
}
setCategoryId(categoryId: string) {
this.selectedCategoryId.set(categoryId);
this.favoritesCtx.setCategoryId(categoryId);
}
openItem(item: StalkerVodSource & { category_id: string }) {
@@ -202,9 +256,13 @@ export class RecentlyViewedComponent {
this.stalkerStore.setSelectedContentType(item.category_id);
switch (item.category_id) {
case 'itv':
this.createLinkToPlayVodItv(item.cmd, item.o_name || item.name, item.logo);
this.createLinkToPlayVodItv(
item.cmd,
item.o_name || item.name,
item.logo
);
break;
case 'vod':
case 'vod': {
// Normalize the item to ensure is_series flag is properly set
const normalizedItem = this.normalizeRecentItem(item);
const detailViewState = createStalkerDetailViewState(
@@ -219,6 +277,7 @@ export class RecentlyViewedComponent {
this.stalkerStore.setSelectedItem(detailViewState.itemDetails);
this.syncSelectedVodFavorite();
break;
}
case 'series':
this.itemDetails = item;
this.stalkerStore.setSelectedItem(item);
@@ -240,7 +299,10 @@ export class RecentlyViewedComponent {
}
/** Handle favorite toggle from vod-details component */
onVodFavoriteToggled(event: { item: VodDetailsItem; isFavorite: boolean }): void {
onVodFavoriteToggled(event: {
item: VodDetailsItem;
isFavorite: boolean;
}): void {
toggleStalkerVodFavorite(event, {
addToFavorites: (item, onDone) => this.addToFavorites(item, onDone),
removeFromFavorites: (favoriteId, onDone) =>
@@ -285,7 +347,9 @@ export class RecentlyViewedComponent {
* Normalize recent item to ensure is_series flag is properly set
* and all required fields are present for the series view
*/
private normalizeRecentItem(item: StalkerVodSource): StalkerSelectedVodItem {
private normalizeRecentItem(
item: StalkerVodSource
): StalkerSelectedVodItem {
return normalizeStalkerVodDetailsItem(item);
}
@@ -297,4 +361,8 @@ export class RecentlyViewedComponent {
)
);
}
ngOnDestroy(): void {
this.favoritesCtx.reset();
}
}
@@ -1,28 +1,35 @@
import { Component, computed, effect, inject, signal } from '@angular/core';
import {
Component,
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 { VodDetailsItem } from 'shared-interfaces';
import { FavoritesLayoutComponent } from '../../shared/components/favorites-layout/favorites-layout.component';
import { VodDetailsComponent } from '../../xtream/vod-details/vod-details.component';
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
import { StalkerStore } from '../stalker.store';
import { createLogger } from '../../shared/utils/logger';
import {
StalkerFavoriteItem,
} from '../models';
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,
createPortalFavoritesResource,
createRefreshTrigger,
createStalkerDetailViewState,
normalizeStalkerEntityId,
NormalizedStalkerFavoriteItem,
normalizeStalkerFavoriteItem,
isSelectedStalkerVodFavorite,
NormalizedStalkerFavoriteItem,
normalizeStalkerEntityId,
normalizeStalkerFavoriteItem,
toggleStalkerVodFavorite,
} from '../stalker-vod.utils';
import { StalkerStore } from '../stalker.store';
@Component({
selector: 'app-stalker-favorites',
@@ -47,12 +54,26 @@ import {
`,
],
})
export class StalkerFavoritesComponent {
export class StalkerFavoritesComponent implements OnDestroy {
private static isCategoryType(
value: unknown
): value is 'vod' | 'series' | 'itv' {
const normalized = String(value ?? '').toLowerCase();
return (
normalized === 'vod' ||
normalized === 'series' ||
normalized === 'itv'
);
}
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly playlistService = inject(PlaylistsService);
private readonly favoritesRefresh = createRefreshTrigger();
private readonly stalkerStore = inject(StalkerStore);
private readonly translate = inject(TranslateService);
private readonly logger = createLogger('StalkerFavorites');
private readonly favoritesCtx = inject(FavoritesContextService);
itemDetails: NormalizedStalkerFavoriteItem | null = null;
vodDetailsItem: VodDetailsItem | null = null;
@@ -69,50 +90,69 @@ export class StalkerFavoritesComponent {
readonly categories = computed(() => [
{
id: 1,
category_id: 'movie',
category_name:
this.translate.instant('PORTALS.SIDEBAR.MOVIES') +
' (' +
(this.movies()?.length ?? 0) +
')',
category_id: 'all',
category_name: this.translate.instant('PORTALS.ALL_CATEGORIES'),
count: this.allFavorites.value()?.length ?? 0,
parent_id: 0,
},
{
id: 2,
category_id: 'itv',
category_name:
this.translate.instant('PORTALS.SIDEBAR.LIVE_TV') +
' (' +
(this.live()?.length ?? 0) +
')',
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') +
' (' +
(this.series()?.length ?? 0) +
')',
category_name: this.translate.instant('PORTALS.SIDEBAR.SERIES'),
count: this.series()?.length ?? 0,
parent_id: 0,
},
]);
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 this.movies();
return filterByTerm(this.movies());
case 'itv':
return this.live();
return filterByTerm(this.live());
case 'series':
return this.series();
return filterByTerm(this.series());
default:
return [];
}
});
readonly selectedCategoryId = signal<string>('movie');
/** 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 series = computed(() =>
this.allFavorites
@@ -131,15 +171,49 @@ export class StalkerFavoritesComponent {
this.allFavorites.value();
this.syncSelectedVodFavorite();
});
// Keep workspace context panel in sync with categories
effect(() => {
this.favoritesCtx.setCategories(this.categories());
});
effect(() => {
const playlist = this.currentPlaylist();
if (!playlist?._id) {
return;
}
const state =
this.router.currentNavigation()?.extras?.state ??
window.history.state;
const item = state?.openFavoriteItem;
if (
!item ||
!StalkerFavoritesComponent.isCategoryType(item.category_id)
) {
return;
}
this.openItem(item);
try {
window.history.replaceState({}, document.title);
} catch {
// no-op
}
});
}
removeFromFavorites(item: Pick<StalkerFavoriteItem, 'id'>, onDone?: () => void) {
removeFromFavorites(
item: Pick<StalkerFavoriteItem, 'id'>,
onDone?: () => void
) {
this.stalkerStore.removeFromFavorites(
normalizeStalkerEntityId(item.id),
() => {
this.favoritesRefresh.refresh();
this.syncSelectedVodFavorite();
onDone?.();
this.favoritesRefresh.refresh();
this.syncSelectedVodFavorite();
onDone?.();
}
);
}
@@ -153,19 +227,29 @@ export class StalkerFavoritesComponent {
}
setCategoryId(categoryId: string) {
this.selectedCategoryId.set(categoryId);
this.favoritesCtx.setCategoryId(categoryId);
}
openItem(item: StalkerFavoriteItem) {
this.logger.debug('Open item', item);
const normalizedItem = normalizeStalkerFavoriteItem(item);
const normalizedCategory =
item.category_id === 'movie' ? 'vod' : item.category_id;
const itemToOpen = {
...item,
category_id: normalizedCategory,
} as StalkerFavoriteItem;
const normalizedItem = normalizeStalkerFavoriteItem(itemToOpen);
switch (item.category_id) {
switch (itemToOpen.category_id) {
case 'itv':
this.stalkerStore.setSelectedContentType('itv');
this.createLinkToPlayVodItv(item.cmd, item.o_name || item.name, item.logo);
this.createLinkToPlayVodItv(
itemToOpen.cmd,
itemToOpen.o_name || itemToOpen.name,
itemToOpen.logo
);
break;
case 'vod':
case 'vod': {
this.itemDetails = normalizedItem;
this.stalkerStore.setSelectedItem(normalizedItem.details);
this.stalkerStore.setSelectedContentType('vod');
@@ -181,6 +265,7 @@ export class StalkerFavoritesComponent {
this.vodDetailsItem = detailViewState.vodDetailsItem;
this.syncSelectedVodFavorite();
break;
}
case 'series':
this.itemDetails = normalizedItem;
this.stalkerStore.setSelectedItem(normalizedItem.details);
@@ -203,7 +288,10 @@ export class StalkerFavoritesComponent {
}
/** Handle favorite toggle from vod-details component */
onVodFavoriteToggled(event: { item: VodDetailsItem; isFavorite: boolean }): void {
onVodFavoriteToggled(event: {
item: VodDetailsItem;
isFavorite: boolean;
}): void {
toggleStalkerVodFavorite(event, {
addToFavorites: (item, onDone) => this.addToFavorites(item, onDone),
removeFromFavorites: (favoriteId, onDone) =>
@@ -255,4 +343,8 @@ export class StalkerFavoritesComponent {
});
await this.stalkerStore.createLinkToPlayVod(cmd, title, thumbnail);
}
ngOnDestroy(): void {
this.favoritesCtx.reset();
}
}
@@ -1,137 +1,141 @@
@let isEmbeddedPlayer =
player() === 'videojs' || player() === 'html5' || player() === 'artplayer';
<div
class="sidebar"
appResizable
[minWidth]="250"
[maxWidth]="600"
[defaultWidth]="400"
storageKey="sidebar-width"
>
<div class="sidebar-header">
@if (!isWorkspaceLayout || stalkerStore.selectedCategoryId()) {
<div
class="sidebar"
appResizable
[minWidth]="250"
[maxWidth]="600"
[defaultWidth]="400"
storageKey="sidebar-width"
>
@if (stalkerStore.selectedCategoryId() || !isWorkspaceLayout) {
<div class="sidebar-header">
@if (stalkerStore.selectedCategoryId()) {
<button mat-icon-button (click)="backToCategories()">
<mat-icon>chevron_left</mat-icon>
</button>
<h2>{{ selectedCategoryTitle() }}</h2>
} @else {
<app-playlist-switcher
class="switcher"
[currentTitle]="currentPlaylist()?.title || 'Portal'"
[subtitle]="'Stalker Portal'"
/>
}
</div>
}
@if (stalkerStore.selectedCategoryId()) {
<button mat-icon-button (click)="backToCategories()">
<mat-icon>chevron_left</mat-icon>
</button>
<h2>{{ selectedCategoryTitle() }}</h2>
<!-- <div class="search-panel">
<mat-form-field class="search-bar" subscriptSizing="dynamic">
<mat-icon matPrefix>search</mat-icon>
<input
matInput
[placeholder]="'PORTALS.SIDEBAR.SEARCH' | translate"
[ngModel]="searchString()"
(ngModelChange)="searchString.set($event)"
/>
@if (searchString()) {
<button matSuffix mat-icon-button (click)="searchString.set('')">
<mat-icon>close</mat-icon>
</button>
}
</mat-form-field>
</div> -->
<div #scrollContainer class="channels-list">
@for (item of itvChannels(); track item.id ?? $index) {
<div
class="channel-item"
[class.active]="stalkerStore.selectedItem()?.id === item.id"
(click)="playChannel(item)"
>
<img
class="stream-icon"
[src]="item.logo"
(error)="
$event.target.src =
'./assets/images/default-poster.png'
"
alt=""
/>
<div class="channel-info">
<span class="title">{{
item.o_name || item.name
}}</span>
@if (currentPrograms.get(item.id)) {
<div class="program-info">
<div class="current-program">
{{ currentPrograms.get(item.id) }}
</div>
@if (currentProgramsProgress.has(item.id)) {
<div class="progress-container">
<span>{{
programTimings.get(item.id)?.start
| date: 'HH:mm'
}}</span>
<div class="progress-bar">
<div
class="progress"
[style.width.%]="
currentProgramsProgress.get(item.id)
"
></div>
</div>
<span>{{
programTimings.get(item.id)?.end
| date: 'HH:mm'
}}</span>
</div>
}
</div>
}
</div>
<button
class="favorite-btn"
(click)="toggleFavorite(item); $event.stopPropagation()"
>
<mat-icon>{{
favorites.get(item.id) ? 'star' : 'star_outline'
}}</mat-icon>
</button>
</div>
}
@if (isLoadingMore()) {
<div class="loading-indicator">
<mat-spinner diameter="24" />
</div>
}
</div>
} @else {
<app-playlist-switcher
class="switcher"
[currentTitle]="currentPlaylist()?.title || 'Portal'"
[subtitle]="'Stalker Portal'"
/>
@if (isCategoryLoading()) {
<ngx-skeleton-loader
count="20"
[theme]="{
width: '95%',
height: '48px',
borderRadius: '40px',
}"
/>
} @else if (isCategoryFailed()) {
<app-playlist-error-view
[title]="'PORTALS.ERROR_VIEW.UNKNOWN_ERROR.TITLE' | translate"
[description]="
'PORTALS.ERROR_VIEW.UNKNOWN_ERROR.DESCRIPTION' | translate
"
/>
} @else {
<app-category-view
[items]="categories()"
[selectedCategoryId]="$any(stalkerStore.selectedCategoryId())"
(categoryClicked)="selectCategory($event)"
/>
}
}
</div>
@if (stalkerStore.selectedCategoryId()) {
<!-- <div class="search-panel">
<mat-form-field class="search-bar" subscriptSizing="dynamic">
<mat-icon matPrefix>search</mat-icon>
<input
matInput
[placeholder]="'PORTALS.SIDEBAR.SEARCH' | translate"
[ngModel]="searchString()"
(ngModelChange)="searchString.set($event)"
/>
@if (searchString()) {
<button matSuffix mat-icon-button (click)="searchString.set('')">
<mat-icon>close</mat-icon>
</button>
}
</mat-form-field>
</div> -->
<div #scrollContainer class="channels-list">
@for (item of itvChannels(); track item.id ?? $index) {
<div
class="channel-item"
[class.active]="stalkerStore.selectedItem()?.id === item.id"
(click)="playChannel(item)"
>
<img
class="stream-icon"
[src]="item.logo"
(error)="
$event.target.src =
'./assets/images/default-poster.png'
"
alt=""
/>
<div class="channel-info">
<span class="title">{{
item.o_name || item.name
}}</span>
@if (currentPrograms.get(item.id)) {
<div class="program-info">
<div class="current-program">
{{ currentPrograms.get(item.id) }}
</div>
@if (currentProgramsProgress.has(item.id)) {
<div class="progress-container">
<span>{{
programTimings.get(item.id)?.start
| date: 'HH:mm'
}}</span>
<div class="progress-bar">
<div
class="progress"
[style.width.%]="
currentProgramsProgress.get(item.id)
"
></div>
</div>
<span>{{
programTimings.get(item.id)?.end
| date: 'HH:mm'
}}</span>
</div>
}
</div>
}
</div>
<button
class="favorite-btn"
(click)="toggleFavorite(item); $event.stopPropagation()"
>
<mat-icon>{{
favorites.get(item.id) ? 'star' : 'star_outline'
}}</mat-icon>
</button>
</div>
}
@if (isLoadingMore()) {
<div class="loading-indicator">
<mat-spinner diameter="24" />
</div>
}
</div>
} @else {
@if (isCategoryLoading()) {
<ngx-skeleton-loader
count="20"
[theme]="{
width: '95%',
height: '48px',
borderRadius: '40px',
}"
/>
} @else if (isCategoryFailed()) {
<app-playlist-error-view
[title]="'PORTALS.ERROR_VIEW.UNKNOWN_ERROR.TITLE' | translate"
[description]="
'PORTALS.ERROR_VIEW.UNKNOWN_ERROR.DESCRIPTION' | translate
"
/>
} @else {
<app-category-view
[items]="categories()"
[selectedCategoryId]="$any(stalkerStore.selectedCategoryId())"
(categoryClicked)="selectCategory($event)"
/>
}
}
</div>
}
<div class="content-container">
@if (streamUrl && isEmbeddedPlayer) {
@@ -12,7 +12,7 @@
width: 400px;
min-width: 250px;
max-width: 600px;
border-right: 1px solid #111;
border-right: 1px solid var(--mat-sys-outline-variant);
display: flex;
flex-direction: column;
flex-shrink: 0;
@@ -110,18 +110,18 @@
overflow: hidden;
&:hover {
background-color: rgba(255, 255, 255, 0.1);
background-color: color-mix(in srgb, var(--mat-sys-on-surface) 8%, transparent);
.title {
color: #1db954;
color: var(--mat-sys-primary);
}
}
&.active {
background-color: rgba(29, 185, 84, 0.2);
background-color: color-mix(in srgb, var(--mat-sys-primary) 16%, transparent);
.title {
color: #1db954;
color: var(--mat-sys-primary);
}
}
}
@@ -132,7 +132,7 @@
border-radius: 4px;
object-fit: cover;
flex-shrink: 0;
background: rgba(255, 255, 255, 0.05);
background: color-mix(in srgb, var(--mat-sys-on-surface) 5%, transparent);
}
.channel-info {
@@ -174,14 +174,14 @@
.progress-bar {
flex: 1;
height: 3px;
background: rgba(255, 255, 255, 0.1);
background: color-mix(in srgb, var(--mat-sys-on-surface) 10%, transparent);
border-radius: 2px;
overflow: hidden;
min-width: 20px;
.progress {
height: 100%;
background: #1db954;
background: var(--mat-sys-primary);
transition: width 1s linear;
}
}
@@ -19,6 +19,7 @@ import { MatIcon } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { PlaylistSwitcherComponent, ResizableDirective } from 'components';
import { PlaylistsService } from 'services';
@@ -31,7 +32,7 @@ import {
getChannelItemByNumber,
} from '../../shared/services/remote-channel-navigation.util';
import { CategoryViewComponent } from '../../xtream-electron/category-view/category-view.component';
import { PlaylistErrorViewComponent } from '../../xtream/playlist-error-view/playlist-error-view.component';
import { PlaylistErrorViewComponent } from '../../xtream-electron/playlist-error-view/playlist-error-view.component';
import { StalkerStore } from '../stalker.store';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { createLogger } from '../../shared/utils/logger';
@@ -72,6 +73,7 @@ import {
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class StalkerLiveStreamLayoutComponent implements OnDestroy {
private readonly route = inject(ActivatedRoute);
readonly stalkerStore = inject(StalkerStore);
private readonly settingsStore = inject(SettingsStore);
private readonly playlistService = inject(PlaylistsService);
@@ -86,6 +88,8 @@ 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';
/** Channels */
readonly itvChannels = this.stalkerStore.itvChannels;
@@ -203,8 +207,8 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
channelName: selectedItem.o_name || selectedItem.name,
channelNumber: currentIndex >= 0 ? currentIndex + 1 : undefined,
epgTitle: currentProgram?.title,
epgStart: (currentProgram as any)?.start,
epgEnd: (currentProgram as any)?.end,
epgStart: currentProgram?.start,
epgEnd: currentProgram?.end,
supportsVolume: false,
});
});
@@ -1,45 +1,49 @@
<div
class="sidebar"
appResizable
[minWidth]="250"
[maxWidth]="600"
[defaultWidth]="400"
storageKey="sidebar-width"
>
<div class="sidebar-header">
<app-playlist-switcher
class="switcher"
[currentTitle]="currentPlaylist()?.title || 'Portal'"
[subtitle]="'Stalker Portal'"
/>
@if (!isWorkspaceLayout) {
<div
class="sidebar"
appResizable
[minWidth]="250"
[maxWidth]="600"
[defaultWidth]="400"
storageKey="sidebar-width"
>
@if (!isWorkspaceLayout) {
<div class="sidebar-header">
<app-playlist-switcher
class="switcher"
[currentTitle]="currentPlaylist()?.title || 'Portal'"
[subtitle]="'Stalker Portal'"
/>
</div>
}
@if (isCategoryLoading()) {
<ngx-skeleton-loader
count="20"
[theme]="{
width: '95%',
height: '48px',
borderRadius: '40px',
}"
/>
} @else if (isCategoryFailed()) {
<app-playlist-error-view
[title]="'PORTALS.ERROR_VIEW.UNKNOWN_ERROR.TITLE' | translate"
[description]="
'PORTALS.ERROR_VIEW.UNKNOWN_ERROR.DESCRIPTION' | translate
"
/>
} @else {
<app-category-view
[items]="categories()"
[selectedCategoryId]="$any(stalkerStore.selectedCategoryId())"
(categoryClicked)="categoryClicked($event)"
/>
}
</div>
@if (isCategoryLoading()) {
<ngx-skeleton-loader
count="20"
[theme]="{
width: '95%',
height: '48px',
borderRadius: '40px',
}"
/>
} @else if (isCategoryFailed()) {
<app-playlist-error-view
[title]="'PORTALS.ERROR_VIEW.UNKNOWN_ERROR.TITLE' | translate"
[description]="
'PORTALS.ERROR_VIEW.UNKNOWN_ERROR.DESCRIPTION' | translate
"
/>
} @else {
<app-category-view
[items]="categories()"
[selectedCategoryId]="$any(stalkerStore.selectedCategoryId())"
(categoryClicked)="categoryClicked($event)"
/>
}
</div>
}
<div class="main-content">
@if (!stalkerStore.selectedItem()) {
@if (!isWorkspaceLayout && !stalkerStore.selectedItem()) {
<div class="content-header">
<h2>
<button mat-icon-button disabled>
@@ -63,7 +67,7 @@
<div
class="scrollable-content"
[class.no-header]="stalkerStore.selectedItem()"
[class.no-header]="isWorkspaceLayout || stalkerStore.selectedItem()"
>
<router-outlet />
</div>
@@ -7,7 +7,7 @@ import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { PlaylistSwitcherComponent, ResizableDirective } from 'components';
import { CategoryViewComponent } from '../xtream-electron/category-view/category-view.component';
import { PlaylistErrorViewComponent } from '../xtream/playlist-error-view/playlist-error-view.component';
import { PlaylistErrorViewComponent } from '../xtream-electron/playlist-error-view/playlist-error-view.component';
import { StalkerStore } from './stalker.store';
@Component({
@@ -32,8 +32,12 @@ import { StalkerStore } from './stalker.store';
],
})
export class StalkerMainContainerComponent {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
readonly stalkerStore = inject(StalkerStore);
private readonly translateService = inject(TranslateService);
readonly isWorkspaceLayout =
this.route.snapshot.data['layout'] === 'workspace';
currentLayout:
| 'category_content'
@@ -53,10 +57,7 @@ export class StalkerMainContainerComponent {
readonly contentItems = this.stalkerStore.getPaginatedContent;
readonly isContentLoading = this.stalkerStore.isPaginatedContentLoading;
constructor(
private readonly activatedRoute: ActivatedRoute,
private readonly router: Router
) {
constructor() {
// reset category title after changing content type
effect(() => {
this.stalkerStore.selectedContentType();
@@ -70,7 +71,7 @@ export class StalkerMainContainerComponent {
this.stalkerStore.setSelectedItem(undefined);
this.router.navigate(['.', item.category_id], {
relativeTo: this.activatedRoute,
relativeTo: this.route,
});
}
@@ -34,6 +34,7 @@
[resultsCount]="resultsCount"
[isLoading]="searchResultsResource.isLoading()"
[showResultsCount]="true"
[showSearchInput]="!isWorkspaceLayout"
(searchTermChange)="updateSearchTerm($event)"
>
<!-- Filters (single selection mode for stalker) -->
@@ -1,4 +1,5 @@
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';
@@ -14,7 +15,7 @@ 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/vod-details/vod-details.component';
import { VodDetailsComponent } from '../../xtream-electron/vod-details/vod-details.component';
import { StalkerContentTypes } from '../stalker-content-types';
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
import { StalkerStore } from '../stalker.store';
@@ -67,6 +68,8 @@ export class StalkerSearchComponent {
series: false,
vod: true,
});
readonly isWorkspaceLayout =
this.activatedRoute.snapshot.data['layout'] === 'workspace';
readonly filterConfig: StalkerFilter[] = [
{
@@ -159,6 +162,15 @@ 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(() => {
// Re-evaluate favorite state whenever favorites resource changes.
this.portalFavorites.value();
@@ -1,10 +1,9 @@
<div class="stalker-container">
<app-navigation
class="navigation-sidebar"
[navigationItems]="mainNavigationItems"
(categoryClick)="setContentType($any($event))"
(pageClicked)="handlePageClick()"
[selectedContentType]="stalkerStore.selectedContentType()"
/>
@if (!isWorkspaceLayout) {
<app-navigation
class="navigation-sidebar"
[selectedContentType]="stalkerStore.selectedContentType()"
/>
}
<router-outlet />
</div>
@@ -10,8 +10,12 @@
flex: 1;
overflow: hidden;
min-height: 0;
> router-outlet {
min-width: 0;
}
}
.navigation-sidebar {
border-right: 1px solid #333;
border-right: 1px solid var(--mat-sys-outline-variant);
}
@@ -1,12 +1,16 @@
import { Component, effect, inject, OnDestroy } from '@angular/core';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
import {
ActivatedRoute,
NavigationEnd,
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-electron/navigation/navigation.component';
import { NavigationItem } from '../../xtream-electron/navigation/navigation.interface';
import { XtreamStore } from '../../xtream-electron/stores/xtream.store';
import { StalkerStore } from '../stalker.store';
@@ -23,24 +27,8 @@ export class StalkerShellComponent implements OnDestroy {
private readonly router = inject(Router);
readonly stalkerStore = inject(StalkerStore);
private readonly store = inject(Store);
readonly mainNavigationItems: NavigationItem[] = [
{
id: 'vod',
icon: 'movie',
labelKey: 'PORTALS.SIDEBAR.MOVIES',
},
{
id: 'itv',
icon: 'live_tv',
labelKey: 'PORTALS.SIDEBAR.LIVE_TV',
},
{
id: 'series',
icon: 'tv',
labelKey: 'PORTALS.SIDEBAR.SERIES',
},
];
readonly isWorkspaceLayout =
this.route.snapshot.data['layout'] === 'workspace';
/** Current playlist derived from route params */
readonly currentPlaylist = toSignal(
@@ -49,12 +37,16 @@ export class StalkerShellComponent implements OnDestroy {
switchMap((id) => this.store.select(selectPlaylistById(id)))
)
);
private currentSection: string | null = null;
constructor() {
// Subscribe to route params to handle switching between playlists
this.route.params.pipe(takeUntilDestroyed()).subscribe((params) => {
const playlistId = params['id'];
this.store.dispatch(
PlaylistActions.setActivePlaylist({ playlistId })
);
this.store.dispatch(
PlaylistActions.setCurrentPlaylistId({ playlistId })
);
@@ -64,7 +56,7 @@ export class StalkerShellComponent implements OnDestroy {
this.stalkerStore.setSelectedCategory(null);
const childRoute = this.route.snapshot.firstChild;
const path = childRoute?.url[0]?.path;
const path = childRoute?.url?.[0]?.path;
const validTypes = ['vod', 'series', 'itv'];
const initialType = validTypes.includes(path)
? (path as any)
@@ -72,25 +64,49 @@ export class StalkerShellComponent implements OnDestroy {
this.stalkerStore.setSelectedContentType(initialType);
});
this.router.events
.pipe(takeUntilDestroyed())
.subscribe((event) => {
if (event instanceof NavigationEnd) {
this.syncSectionFromRoute();
}
});
this.syncSectionFromRoute();
effect(() => {
this.stalkerStore.setCurrentPlaylist(this.currentPlaylist());
});
}
setContentType(type: 'vod' | 'live' | 'series' | 'itv') {
if (type === 'live') type = 'itv';
this.stalkerStore.setSelectedContentType(type);
this.stalkerStore.setSelectedCategory(type === 'itv' ? null : '*');
this.router.navigate([type], {
relativeTo: this.route,
});
}
handlePageClick() {
this.stalkerStore.setSelectedContentType(null);
}
ngOnDestroy() {
this.stalkerStore.resetCategories();
}
private syncSectionFromRoute(): void {
const sectionFromSnapshot =
this.route.firstChild?.snapshot?.url?.[0]?.path ?? null;
const sectionFromUrl = this.getSectionFromUrl(this.router.url);
const section = sectionFromSnapshot ?? sectionFromUrl;
if (!section || section === this.currentSection) {
return;
}
this.currentSection = section;
if (section === 'vod' || section === 'series' || section === 'itv') {
this.stalkerStore.setSelectedContentType(section as any);
return;
}
this.stalkerStore.setSelectedContentType(null as any);
}
private getSectionFromUrl(url: string): string | null {
const match = url.match(
/^\/(?:workspace\/)?stalker\/[^\/\?]+\/([^\/\?]+)/
);
return match?.[1] ?? null;
}
}
@@ -135,13 +135,25 @@ export function withStalkerContent() {
.sort((a, b) =>
a.category_name.localeCompare(b.category_name)
);
if (
// If the portal provides its own "All" category entry,
// promote it to position 0. Otherwise add a synthetic one.
const allIdx = categories.findIndex(
(c) =>
c.category_name.trim().toLowerCase() === 'all'
);
if (allIdx > 0) {
// Move the existing 'All' entry to the front
categories.unshift(categories.splice(allIdx, 1)[0]);
} else if (
allIdx === -1 &&
categories.length > 0 &&
!categories.some(
(category) =>
String(category.category_id) === '*'
)
) {
// No 'All' entry found — prepend synthetic one
categories.unshift({
category_name: translateService.instant(
'PORTALS.ALL_CATEGORIES'
@@ -5,7 +5,6 @@ import { TranslateService } from '@ngx-translate/core';
import { DataService, PlaylistsService, StalkerSessionService } from 'services';
import { Playlist, STALKER_REQUEST, StalkerPortalActions } from 'shared-interfaces';
import { PlayerService } from '../../../services/player.service';
import { ContentType } from '../../../xtream/content-type.enum';
import { StalkerContentTypes } from '../../stalker-content-types';
import {
normalizeStalkerEntityId,
@@ -13,6 +12,32 @@ import {
} from '../../stalker-vod.utils';
import { createLogger } from '../../../shared/utils/logger';
type StalkerContentType = 'itv' | 'vod' | 'series';
interface StalkerPlayableItem extends Record<string, unknown> {
id?: string | number;
cmd?: string;
has_files?: unknown;
}
interface StalkerPlayerStoreLike {
selectedContentType: () => StalkerContentType;
currentPlaylist: () => Playlist | null;
selectedItem: () => StalkerPlayableItem | null;
addToRecentlyViewed?: (item: Record<string, unknown>) => void;
}
interface StalkerResponse {
js?: {
error?: string;
cmd?: string;
data?: Array<{ id?: string | number }>;
account_info?: {
expire_date?: string | number;
};
};
}
/**
* Playback/link/player concern methods.
*/
@@ -29,24 +54,22 @@ export function withStalkerPlayer() {
snackBar = inject(MatSnackBar),
translate = inject(TranslateService)
) => {
const storeAny = store as any;
const storeState = store as unknown as StalkerPlayerStoreLike;
const fetchLinkToPlayInternal = async (
portalUrl: string,
macAddress: string,
cmd: string,
series?: number
) => {
const type = series
? ContentType.VODS
: storeAny.selectedContentType();
const selectedContentType =
storeState.selectedContentType();
const type = series ? 'vod' : selectedContentType;
// Always use create_link to get the tokenized streaming URL
// The server adds the required token for playback authorization
// Note: cmd is already transformed during item processing (has_files items)
const params = {
action: StalkerContentTypes[
storeAny.selectedContentType()
].getLink,
action: StalkerContentTypes[selectedContentType].getLink,
cmd: cmd,
type,
disable_ad: '0',
@@ -56,8 +79,8 @@ export function withStalkerPlayer() {
};
// Use makeAuthenticatedRequest for automatic retry on auth failure
const playlist = storeAny.currentPlaylist() as Playlist;
let response: any;
const playlist = storeState.currentPlaylist();
let response: StalkerResponse;
if (playlist?.isFullStalkerPortal) {
// Full stalker portal - use authenticated request with retry
response = await stalkerSession.makeAuthenticatedRequest(
@@ -138,7 +161,7 @@ export function withStalkerPlayer() {
const fetchMovieFileIdInternal = async (
movieId: string
): Promise<string | null> => {
const playlist = storeAny.currentPlaylist() as Playlist;
const playlist = storeState.currentPlaylist();
if (!playlist) return null;
const queryParams = {
@@ -148,7 +171,7 @@ export function withStalkerPlayer() {
p: '1',
};
let response: any;
let response: StalkerResponse;
if (playlist.isFullStalkerPortal) {
response = await stalkerSession.makeAuthenticatedRequest(
playlist,
@@ -175,23 +198,25 @@ export function withStalkerPlayer() {
};
const addToRecentlyViewedInternal = (
item: any,
item: StalkerPlayableItem,
cmd?: string,
cover?: string,
title?: string
) => {
const playlistId = storeAny.currentPlaylist()?._id;
const playlistId = storeState.currentPlaylist()?._id;
if (!playlistId) return;
const recentlyViewedItem: { id: string; title: string } &
Record<string, unknown> = {
...item,
id: normalizeStalkerEntityId(item?.id),
cmd,
cover,
title,
category_id: storeState.selectedContentType(),
added_at: Date.now(),
};
playlistService
.addPortalRecentlyViewed(playlistId, {
...item,
id: item?.id,
cmd,
cover,
title,
category_id: storeAny.selectedContentType(),
added_at: Date.now(),
})
.addPortalRecentlyViewed(playlistId, recentlyViewedItem)
.subscribe();
};
@@ -218,8 +243,11 @@ export function withStalkerPlayer() {
try {
// Use makeAuthenticatedRequest for automatic retry on auth failure
const playlist = storeAny.currentPlaylist() as Playlist;
let response: any;
const playlist = storeState.currentPlaylist();
let response: StalkerResponse;
if (!playlist) {
return 'Unknown';
}
if (playlist?.isFullStalkerPortal) {
// Full stalker portal - use authenticated request with retry
response =
@@ -247,10 +275,16 @@ export function withStalkerPlayer() {
// Extract the expire date from the response
const expireDate =
response.js.account_info.expire_date;
const numericExpireDate = Number(expireDate);
// Convert timestamp to readable date if it's a unix timestamp
if (expireDate && !isNaN(expireDate)) {
const date = new Date(expireDate * 1000); // Convert seconds to milliseconds
if (
expireDate &&
!Number.isNaN(numericExpireDate)
) {
const date = new Date(
numericExpireDate * 1000
); // Convert seconds to milliseconds
return date.toLocaleDateString();
}
@@ -291,7 +325,7 @@ export function withStalkerPlayer() {
startTime?: number
) {
try {
const item = storeAny.selectedItem();
const item = storeState.selectedItem();
let cmdToUse = cmd ?? item?.cmd;
if (!cmdToUse) {
@@ -314,15 +348,21 @@ export function withStalkerPlayer() {
}
}
const playlist = storeAny.currentPlaylist();
const playlist = storeState.currentPlaylist();
if (!playlist) {
throw new Error('nothing_to_play');
}
const url = await fetchLinkToPlayInternal(
playlist.portalUrl,
playlist.macAddress,
cmdToUse,
episodeNum
);
if (typeof storeAny.addToRecentlyViewed === 'function') {
storeAny.addToRecentlyViewed({
if (
typeof storeState.addToRecentlyViewed ===
'function'
) {
storeState.addToRecentlyViewed({
...item,
id: item?.id,
cmd: cmd,
@@ -1,10 +1,10 @@
import { computed } from '@angular/core';
import {
patchState,
signalStoreFeature,
withMethods,
withState,
} from '@ngrx/signals';
import { computed } from '@angular/core';
import { StalkerVodSource } from '../../models';
import { normalizeStalkerEntityId } from '../../stalker-vod.utils';
@@ -45,9 +45,7 @@ export function withStalkerSelection() {
setSelectedCategory(id: string | number | null) {
patchState(store, {
selectedCategoryId:
id !== null && id !== undefined
? String(id)
: null,
id !== null && id !== undefined ? String(id) : null,
page: 0,
});
},
@@ -71,8 +69,7 @@ export function withStalkerSelection() {
},
setSelectedItem(selectedItem: StalkerVodSource | null | undefined) {
const selectedIdRaw =
selectedItem?.id !== undefined &&
selectedItem?.id !== null
selectedItem?.id !== undefined && selectedItem?.id !== null
? selectedItem.id
: undefined;
const selectedId =
@@ -101,7 +98,7 @@ export function withStalkerSelection() {
if (!categoryId) {
return {
id: 0,
name: 'All Items',
category_name: 'All Items',
type: store.selectedContentType(),
};
}
@@ -110,7 +107,10 @@ export function withStalkerSelection() {
const contentType = store.selectedContentType();
let categories: any[] = [];
const readCategories = (
getterName: 'vodCategories' | 'seriesCategories' | 'itvCategories'
getterName:
| 'vodCategories'
| 'seriesCategories'
| 'itvCategories'
) =>
typeof storeAny[getterName] === 'function'
? storeAny[getterName]()
@@ -125,9 +125,12 @@ export function withStalkerSelection() {
return (
categories.find(
(c: any) =>
String(c.category_id) === String(categoryId)
) || { id: categoryId, name: '', type: contentType }
(c: any) => String(c.category_id) === String(categoryId)
) || {
category_id: categoryId,
category_name: '',
type: contentType,
}
);
}),
}))