feat(stalker): rework stalker portal UI

- Added `StalkerSearchComponent` for searching VOD and series content.
- Integrated search form and results grid with loading states.
- Implemented item selection and details display for VOD and series.
- Created a new `StalkerShellComponent` for navigation and routing.
- Established routing for Stalker module with nested routes for VOD, series, and favorites.
- Introduced a centralized `StalkerStore` for managing state and API interactions.
- Enhanced `StalkerSeriesViewComponent` to display detailed information about selected series.
- Updated styles for improved layout and user experience.
This commit is contained in:
4gray committed 2025-09-08 18:10:36 +02:00
1 parent 4759f62f71
commit 87807dc1e7
23 files changed
+1752 -824

No files matched your search

+1
View File
@@ -34,4 +34,5 @@ export interface Playlist {
password?: string;
macAddress?: string;
portalUrl?: string;
recentlyViewed?: any[];
}
+6 -3
View File
@@ -1,8 +1,8 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { isTauri } from '@tauri-apps/api/core';
import { stalkerRoutes } from './stalker/stalker.routes';
import { xtreamRoutes } from './xtream-tauri/xtream.routes';
import { AppConfig } from '../environments/environment';
const routes: Routes = [
{
@@ -56,6 +56,7 @@ const routes: Routes = [
(c) => c.StalkerMainContainerComponent
),
},
...stalkerRoutes,
{
path: '**',
redirectTo: '',
@@ -64,9 +65,11 @@ const routes: Routes = [
@NgModule({
imports: [
RouterModule.forRoot(routes, {
RouterModule.forRoot(
routes /* {
enableTracing: !AppConfig.production,
}),
} */
),
],
exports: [RouterModule],
})
@@ -211,7 +211,7 @@ export class RecentPlaylistsComponent implements OnDestroy {
if (playlistMeta.serverUrl) {
this.router.navigate(['xtreams', playlistMeta._id]);
} else if (playlistMeta.macAddress) {
this.router.navigate(['portals', playlistMeta._id]);
this.router.navigate(['stalker', playlistMeta._id]);
} else {
this.router.navigate(['playlists', playlistMeta._id]);
this.playlistClicked.emit(playlistMeta._id);
@@ -56,6 +56,7 @@ export class StalkerPortalImportComponent {
Validators.pattern(this.URL_REGEX),
]),
importDate: new FormControl(new Date().toISOString()),
userAgent: new FormControl(''),
});
dataService = inject(DataService);
+5
View File
@@ -48,6 +48,11 @@ export const dbConfig: DBConfig = {
keypath: 'favorites',
options: { unique: false },
},
{
name: 'recentlyViewed',
keypath: 'recentlyViewed',
options: { unique: false },
},
{
name: 'autoRefresh',
keypath: 'autoRefresh',
@@ -1,16 +1,12 @@
<button
*ngIf="!isFavorite; else removeFromFavoritesButton"
mat-flat-button
color="accent"
(click)="addToFavorites()"
>
<mat-icon>star_outline</mat-icon>
{{ 'PORTALS.ADD_TO_FAVORITES' | translate }}
</button>
<ng-template #removeFromFavoritesButton>
@let isFavorite = isFavorite$ | async;
@if (isFavorite === false) {
<button mat-flat-button color="accent" (click)="addToFavorites()">
<mat-icon>star_outline</mat-icon>
{{ 'PORTALS.ADD_TO_FAVORITES' | translate }}
</button>
} @else {
<button mat-stroked-button color="accent" (click)="removeFromFavorites()">
<mat-icon>star</mat-icon>
{{ 'PORTALS.REMOVE_FROM_FAVORITES' | translate }}
</button>
</ng-template>
}
@@ -1,83 +1,48 @@
import { NgIf } from '@angular/common';
import { Component, OnInit, inject, input } from '@angular/core';
import { AsyncPipe } from '@angular/common';
import { Component, inject, input, output } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute } from '@angular/router';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { TranslateModule } from '@ngx-translate/core';
import { BehaviorSubject, map, switchMap } from 'rxjs';
import { PlaylistsService } from '../../services/playlists.service';
@Component({
selector: 'app-favorites-button',
templateUrl: './favorites-button.component.html',
imports: [MatButtonModule, MatIconModule, NgIf, TranslateModule]
imports: [AsyncPipe, MatButtonModule, MatIconModule, TranslateModule],
})
export class FavoritesButtonComponent implements OnInit {
export class FavoritesButtonComponent {
private playlistService = inject(PlaylistsService);
private route = inject(ActivatedRoute);
private snackBar = inject(MatSnackBar);
private translateService = inject(TranslateService);
portalId = this.route.snapshot.paramMap.get('id');
serialMeta = input.required<{
movie_id: string;
name: string;
cover: string;
}>();
readonly itemId = input.required<string>();
isFavorite = false;
readonly addToFavoritesClicked = output<void>();
readonly removeFromFavoritesClicked = output<void>();
ngOnInit() {
this.checkFavoriteStatus();
}
private readonly favoritesChanged$ = new BehaviorSubject<void>(undefined);
checkFavoriteStatus() {
this.playlistService
.getPortalFavorites(this.portalId)
.subscribe((favorites) => {
this.isFavorite = favorites.some(
(i) => (i as any).movie_id === this.serialMeta().movie_id
);
});
}
toggleFavorites(isFav: boolean) {
if (isFav) this.removeFromFavorites();
else this.addToFavorites();
}
readonly isFavorite$ = this.favoritesChanged$.pipe(
switchMap(() => this.playlistService.getPortalFavorites()),
map((favorites) =>
favorites.some(
(i) =>
(i as any).movie_id === this.itemId() ||
(i as any).id === this.itemId()
)
)
);
removeFromFavorites() {
this.playlistService
.removeFromPortalFavorites(
this.portalId,
this.serialMeta().movie_id
)
.subscribe(() => {
this.snackBar.open(
this.translateService.instant(
'PORTALS.REMOVED_FROM_FAVORITES'
),
null,
{
duration: 1000,
}
);
this.checkFavoriteStatus();
});
this.removeFromFavoritesClicked.emit();
setTimeout(() => {
this.favoritesChanged$.next();
}, 100);
}
addToFavorites() {
this.playlistService
.addPortalFavorite(this.portalId, this.serialMeta())
.subscribe(() => {
this.snackBar.open(
this.translateService.instant('PORTALS.ADDED_TO_FAVORITES'),
null,
{
duration: 1000,
}
);
this.checkFavoriteStatus();
});
this.addToFavoritesClicked.emit();
setTimeout(() => {
this.favoritesChanged$.next();
}, 100);
}
}
@@ -0,0 +1,40 @@
@if (itemDetails) {
<button
mat-button
(click)="itemDetails = null"
[attr.aria-label]="'SETTINGS.BACK_TO_HOME' | translate"
class="back-button"
>
<mat-icon>arrow_back</mat-icon>
{{ 'SETTINGS.BACK_TO_HOME' | translate }}
</button>
@switch (itemDetails.category_id) {
@case ('vod') {
<app-vod-details
[item]="itemDetails"
(playClicked)="
createLinkToPlayVodItv(
itemDetails.cmd,
itemDetails.info.name,
itemDetails.info.movie_image
)
"
(addToFavoritesClicked)="addToFavorites($event)"
(removeFromFavoritesClicked)="removeFromFavorites($event)"
/>
}
@case ('series') {
<app-stalker-series-view />
}
}
} @else {
<app-favorites-layout
[categories]="categories() ?? []"
[favorites]="itemsToShow() ?? []"
[selectedCategoryId]="selectedCategoryId()"
(categoryClicked)="setCategoryId($event.category_id)"
(removeFromFavorites)="removeFromRecentlyViewed($event)"
[titleTranslationString]="'PORTALS.SIDEBAR.RECENT'"
(openItem)="openItem($event)"
/>
}
@@ -0,0 +1,158 @@
import { Component, computed, inject, signal } from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';
import { MatButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { PlaylistsService } from '../../services/playlists.service';
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';
@Component({
selector: 'app-recently-viewed',
templateUrl: './recently-viewed.component.html',
imports: [
FavoritesLayoutComponent,
MatButton,
MatIcon,
StalkerSeriesViewComponent,
TranslatePipe,
VodDetailsComponent,
],
styles: [
`
:host {
display: block;
height: 100%;
width: 100%;
position: relative;
}
.back-button {
margin: 10px 0 0 16px;
}
`,
],
})
export class RecentlyViewedComponent {
private readonly playlistService = inject(PlaylistsService);
private readonly refreshTimestamp = signal<number>(Date.now());
private readonly stalkerStore = inject(StalkerStore);
private readonly translate = inject(TranslateService);
itemDetails: any = null;
readonly allFavorites = rxResource({
request: () => ({
refreshTimestamp: this.refreshTimestamp(),
}),
loader: () => {
return this.playlistService.getPortalRecentlyViewed();
},
});
readonly categories = computed(() => [
{
id: 1,
category_id: 'movie',
category_name:
this.translate.instant('PORTALS.SIDEBAR.MOVIES') +
' (' +
(this.movies()?.length ?? 0) +
')',
parent_id: 0,
},
{
id: 2,
category_id: 'itv',
category_name:
this.translate.instant('PORTALS.SIDEBAR.LIVE_TV') +
' (' +
(this.live()?.length ?? 0) +
')',
parent_id: 0,
},
{
id: 3,
category_id: 'series',
category_name:
this.translate.instant('PORTALS.SIDEBAR.SERIES') +
' (' +
(this.series()?.length ?? 0) +
')',
parent_id: 0,
},
]);
readonly itemsToShow = computed(() => {
switch (this.selectedCategoryId()) {
case 'movie':
return this.movies();
case 'itv':
return this.live();
case 'series':
return this.series();
default:
return [];
}
});
readonly selectedCategoryId = signal<string>('movie');
readonly series = computed(() =>
this.allFavorites
.value()
?.filter((item) => item.category_id === 'series')
);
readonly movies = computed(() =>
this.allFavorites.value()?.filter((item) => item.category_id === 'vod')
);
readonly live = computed(() =>
this.allFavorites.value()?.filter((item) => item.category_id === 'itv')
);
removeFromRecentlyViewed(item: any) {
this.stalkerStore.removeFromRecentlyViewed(item.id);
setTimeout(() => {
this.refreshTimestamp.set(Date.now());
}, 100);
}
setCategoryId(categoryId: any) {
this.selectedCategoryId.set(categoryId);
}
openItem(item: any) {
this.stalkerStore.setSelectedContentType(item.category_id);
switch (item.category_id) {
case 'itv':
this.createLinkToPlayVodItv(item.cmd, item.name, item.logo);
break;
case 'vod':
case 'series':
this.itemDetails = item;
this.stalkerStore.setSelectedItem(item);
break;
default:
break;
}
}
async createLinkToPlayVodItv(
cmd?: string,
title?: string,
thumbnail?: string
) {
console.debug('Create link to play VOD/ITV', cmd, title, thumbnail);
await this.stalkerStore.createLinkToPlayVod(cmd, title, thumbnail);
}
addToFavorites(item: any) {
this.stalkerStore.addToFavorites(item);
}
removeFromFavorites(favoriteId: string) {
this.stalkerStore.removeFromFavorites(favoriteId);
}
}
@@ -0,0 +1,37 @@
@if (itemDetails) {
<button
mat-button
(click)="itemDetails = null"
[attr.aria-label]="'SETTINGS.BACK_TO_HOME' | translate"
class="back-button"
>
<mat-icon>arrow_back</mat-icon>
{{ 'SETTINGS.BACK_TO_HOME' | translate }}
</button>
@switch (itemDetails.category_id) {
@case ('vod') {
<app-vod-details
[item]="itemDetails.details"
(playClicked)="
createLinkToPlayVodItv(
itemDetails.details.cmd,
itemDetails.details.info.name,
itemDetails.details.info.movie_image
)
"
/>
}
@case ('series') {
<app-stalker-series-view />
}
}
} @else {
<app-favorites-layout
[categories]="categories() ?? []"
[favorites]="favoritesToShow() ?? []"
[selectedCategoryId]="selectedCategoryId()"
(categoryClicked)="setCategoryId($event.category_id)"
(removeFromFavorites)="removeFromFavorites($event)"
(openItem)="openItem($event)"
/>
}
@@ -0,0 +1,157 @@
import { Component, computed, inject, signal } from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';
import { MatButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { PlaylistsService } from '../../services/playlists.service';
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';
@Component({
selector: 'app-stalker-favorites',
templateUrl: './stalker-favorites.component.html',
imports: [
FavoritesLayoutComponent,
MatButton,
MatIcon,
StalkerSeriesViewComponent,
TranslatePipe,
VodDetailsComponent,
],
styles: [
`
:host {
display: block;
height: 100%;
width: 100%;
position: relative;
}
.back-button {
margin: 10px 0 0 16px;
}
`,
],
})
export class StalkerFavoritesComponent {
private readonly playlistService = inject(PlaylistsService);
private readonly refreshTimestamp = signal<number>(Date.now());
private readonly stalkerStore = inject(StalkerStore);
private readonly translate = inject(TranslateService);
itemDetails: any = null;
readonly allFavorites = rxResource({
request: () => ({
refreshTimestamp: this.refreshTimestamp(),
}),
loader: () => {
return this.playlistService.getPortalFavorites();
},
});
readonly categories = computed(() => [
{
id: 1,
category_id: 'movie',
category_name:
this.translate.instant('PORTALS.SIDEBAR.MOVIES') +
' (' +
(this.movies()?.length ?? 0) +
')',
parent_id: 0,
},
{
id: 2,
category_id: 'itv',
category_name:
this.translate.instant('PORTALS.SIDEBAR.LIVE_TV') +
' (' +
(this.live()?.length ?? 0) +
')',
parent_id: 0,
},
{
id: 3,
category_id: 'series',
category_name:
this.translate.instant('PORTALS.SIDEBAR.SERIES') +
' (' +
(this.series()?.length ?? 0) +
')',
parent_id: 0,
},
]);
readonly favoritesToShow = computed(() => {
switch (this.selectedCategoryId()) {
case 'movie':
return this.movies();
case 'itv':
return this.live();
case 'series':
return this.series();
default:
return [];
}
});
readonly selectedCategoryId = signal<string>('movie');
readonly series = computed(() =>
this.allFavorites
.value()
?.filter((item) => item.category_id === 'series')
);
readonly movies = computed(() =>
this.allFavorites.value()?.filter((item) => item.category_id === 'vod')
);
readonly live = computed(() =>
this.allFavorites.value()?.filter((item) => item.category_id === 'itv')
);
removeFromFavorites(item: any) {
this.stalkerStore.removeFromFavorites(item.id);
setTimeout(() => {
this.refreshTimestamp.set(Date.now());
}, 100);
}
setCategoryId(categoryId: any) {
this.selectedCategoryId.set(categoryId);
}
openItem(item: any) {
console.debug('Open item', item);
switch (item.category_id) {
case 'itv':
this.stalkerStore.setSelectedContentType('itv');
this.createLinkToPlayVodItv(item.cmd, item.name, item.logo);
break;
case 'vod':
this.itemDetails = item;
this.stalkerStore.setSelectedItem(item);
this.stalkerStore.setSelectedContentType('vod');
break;
case 'series':
this.itemDetails = item;
this.stalkerStore.setSelectedItem(item);
this.stalkerStore.setSelectedContentType('series');
break;
default:
break;
}
}
async createLinkToPlayVodItv(
cmd?: string,
title?: string,
thumbnail?: string
) {
console.debug('Create link to play VOD/ITV', cmd, title, thumbnail);
await this.stalkerStore.createLinkToPlayVod(cmd, title, thumbnail);
}
}
@@ -1,99 +1,120 @@
<app-navigation-bar
[breadcrumbs]="breadcrumbs"
[contentType]="selectedContentType"
(searchTextChanged)="setSearchPhrase($event)"
[clientSideSearch]="currentLayout === 'category_content' ? false : true"
[searchVisible]="
(currentLayout === 'category' ||
currentLayout === 'category_content') &&
items?.length > 0
"
[contentTypeNavigationItems]="navigationContentTypes"
[contentType]="selectedContentType"
(contentTypeChanged)="getCategories($event)"
(breadcrumbClicked)="breadcrumbClicked($event)"
(favoritesClicked)="favoriteViewClicked()"
[showCategories]="true"
/>
@if (isLoading) {
<div class="loading-wrapper">
@if (currentLayout === 'category_content') {
<div class="loading-container category-content-view">
@for (a of [].constructor(14); track $index) {
<div class="skeleton-item">
<ngx-skeleton-loader
count="1"
[theme]="{ width: '170px', height: '270px' }"
/>
<ngx-skeleton-loader
count="1"
[theme]="{ width: '60%', height: '30px' }"
/>
</div>
}
</div>
} @else if (currentLayout === 'category') {
<div class="loading-container category-view">
@for (a of [].constructor(14); track $index) {
<ngx-skeleton-loader
count="1"
[theme]="{ width: '170px', height: '51px' }"
/>
}
</div>
<div class="sidebar" style="display: flex; flex-direction: column">
<div class="sidebar-header">
@let isItvCategorySelected =
stalkerStore.selectedCategoryId() &&
stalkerStore.selectedContentType() === 'itv';
@if (isItvCategorySelected) {
<button mat-icon-button (click)="backToCategories()">
<mat-icon>chevron_left</mat-icon>
</button>
<h2>{{ selectedCategoryTitle() }}</h2>
} @else {
<button mat-icon-button disabled>
<mat-icon>list</mat-icon>
</button>
<h2>{{ 'PORTALS.ALL_CATEGORIES' | translate }}</h2>
}
</div>
} @else {
@switch (currentLayout) {
@case ('category') {
<app-category-view
(categoryClicked)="categoryClicked($event)"
[items]="items"
/>
}
@case ('category_content') {
<app-category-content-view
[items]="items"
(itemClicked)="itemClicked($event)"
/>
@if (currentLayout === 'category_content' && length && length > 0) {
<mat-paginator
[length]="length"
[pageIndex]="pageIndex"
[pageSize]="pageSize"
(page)="handlePageChange($event)"
aria-label="Select page"
/>
@if (isItvCategorySelected) {
<mat-nav-list style="overflow: auto; flex: 1">
@for (item of itvChannels(); track $index) {
<mat-list-item (click)="createLinkToPlayItv(item)">
<img
matListItemAvatar
class="stream-icon"
[src]="item.logo"
(error)="
$event.target.src =
'./assets/images/default-poster.png'
"
/>
{{ item.name }}
<button
mat-icon-button
matListItemMeta
(click)="toggleFavorite(item); $event.stopPropagation()"
>
<mat-icon>{{
favorites.get(item.id) ? 'star' : 'star_outline'
}}</mat-icon>
</button>
</mat-list-item>
}
</mat-nav-list>
@if (hasMoreItems() && !isContentLoading()) {
<mat-divider />
<div>
<button
mat-button
(click)="loadMore()"
[disabled]="isLoadingMore()"
style="width: 100%"
>
@if (isLoadingMore()) {
Loading page {{ pageIndex() + 1 }}...
} @else {
Load More (Page {{ pageIndex() + 1 }})
}
</button>
</div>
}
@case ('vod-details') {
<app-vod-details
[item]="itemDetails"
(playClicked)="createLinkToPlayVod(itemDetails.cmd)"
(addToFavoritesClicked)="addToFavorites($event)"
(removeFromFavoritesClicked)="removeFromFavorites($event)"
} @else {
@if (isCategoryLoading()) {
<ngx-skeleton-loader
count="20"
[theme]="{
width: '95%',
height: '48px',
borderRadius: '40px',
}"
/>
}
@case ('serial-details') {
<app-stalker-series-view
[seasons]="seasons"
(playEpisode)="createLinkToPlayEpisode($event)"
/>
}
@case ('favorites') {
<app-category-content-view
[items]="favorites$ | async"
(itemClicked)="favoriteClicked($event)"
/>
}
@case ('not-available') {
} @else if (isCategoryFailed()) {
<app-playlist-error-view
[title]="'PORTALS.ERROR_VIEW.ACCOUNT_EXPIRED.TITLE' | translate"
[title]="'PORTALS.ERROR_VIEW.UNKNOWN_ERROR.TITLE' | translate"
[description]="
'PORTALS.ERROR_VIEW.ACCOUNT_EXPIRED.DESCRIPTION' | translate
'PORTALS.ERROR_VIEW.UNKNOWN_ERROR.DESCRIPTION' | translate
"
/>
} @else {
<app-category-view
[items]="categories()"
[selectedCategoryId]="stalkerStore.selectedCategoryId()"
(categoryClicked)="categoryClicked($event)"
/>
}
}
}
</div>
<div class="main-content">
<div class="content-header">
<h2>
@if (
currentLayout === 'vod-details' ||
currentLayout === 'serial-details'
) {
<button mat-icon-button (click)="historyBack()">
<mat-icon>chevron_left</mat-icon>
</button>
}
@if (!isItvCategorySelected) {
<button mat-icon-button disabled>
<mat-icon>category</mat-icon>
</button>
{{ selectedCategoryTitle() }}
}
@if (!contentItems() && !isContentLoading()) {
<h2>
{{ 'PORTALS.SELECT_CATEGORY' | translate }}
</h2>
}
</h2>
</div>
<div class="scrollable-content">
<router-outlet />
</div>
<app-mpv-player-bar />
</div>
@@ -1,3 +1,5 @@
@use '@angular/material' as mat;
:host {
display: flex;
flex-direction: column;
@@ -24,3 +26,20 @@
.category-view {
gap: 10px;
}
.sidebar-header {
display: flex;
flex-direction: row;
align-items: center;
button {
@include mat.form-field-density(-5);
}
}
.content-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
@@ -1,642 +1,238 @@
import { AsyncPipe } from '@angular/common';
import { Component, NgZone, OnInit, Signal, effect } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatDialog } from '@angular/material/dialog';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { Component, effect, inject, signal } from '@angular/core';
import { MatButton, MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { MatPaginatorModule } from '@angular/material/paginator';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute, Params, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { StorageMap } from '@ngx-pwa/local-storage';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { Observable } from 'rxjs';
import { IpcCommand } from '../../../shared/ipc-command.class';
import {
ERROR,
OPEN_MPV_PLAYER,
OPEN_VLC_PLAYER,
STALKER_REQUEST,
STALKER_RESPONSE,
} from '../../../shared/ipc-commands';
import { StalkerPortalActions } from '../../../shared/stalker-portal-actions.enum';
import { DataService } from '../services/data.service';
import { PlaylistsService } from '../services/playlists.service';
import { Settings, VideoPlayer } from '../settings/settings.interface';
import { ExternalPlayerInfoDialogComponent } from '../shared/components/external-player-info-dialog/external-player-info-dialog.component';
import { STORE_KEY } from '../shared/enums/store-keys.enum';
import { selectCurrentPlaylist } from '../state/selectors';
import { Breadcrumb } from '../xtream/breadcrumb.interface';
import { CategoryContentViewComponent } from '../xtream/category-content-view/category-content-view.component';
import { CategoryViewComponent } from '../xtream/category-view/category-view.component';
import { ContentTypeNavigationItem } from '../xtream/content-type-navigation-item.interface';
import { ContentType } from '../xtream/content-type.enum';
import { NavigationBarComponent } from '../xtream/navigation-bar/navigation-bar.component';
import {
PlayerDialogComponent,
PlayerDialogData,
} from '../xtream/player-dialog/player-dialog.component';
import { MpvPlayerBarComponent } from '../shared/components/mpv-player-bar/mpv-player-bar.component';
import { CategoryViewComponent } from '../xtream-tauri/category-view/category-view.component';
import { PlaylistErrorViewComponent } from '../xtream/playlist-error-view/playlist-error-view.component';
import { PortalStore } from '../xtream/portal.store';
import { VodDetailsComponent } from '../xtream/vod-details/vod-details.component';
import {
StalkerFavoriteItem,
StalkerSeason,
StalkerVodDetails,
} from './models';
import { StalkerContentTypes } from './stalker-content-types';
import { StalkerSeriesViewComponent } from './stalker-series-view/stalker-series-view.component';
import { StalkerStore } from './stalker.store';
@Component({
selector: 'app-stalker-main-container',
templateUrl: './stalker-main-container.component.html',
styleUrl: './stalker-main-container.component.scss',
styleUrls: [
'./stalker-main-container.component.scss',
'../xtream-tauri/xtream-main-container.component.scss',
'../xtream-tauri/sidebar.scss',
],
imports: [
AsyncPipe,
CategoryContentViewComponent,
CategoryViewComponent,
MatButton,
MatIcon,
MatIconButton,
MatListModule,
MatPaginatorModule,
NavigationBarComponent,
MpvPlayerBarComponent,
NgxSkeletonLoaderModule,
PlaylistErrorViewComponent,
StalkerSeriesViewComponent,
TranslateModule,
VodDetailsComponent,
]
TranslatePipe,
RouterOutlet,
],
})
export class StalkerMainContainerComponent implements OnInit {
breadcrumbs: Breadcrumb[] = [];
currentPlaylist = this.store.selectSignal(selectCurrentPlaylist);
listeners = [];
isLoading = true;
selectedContentType = ContentType.VODS;
export class StalkerMainContainerComponent {
readonly stalkerStore = inject(StalkerStore);
currentLayout:
| 'category'
| 'category_content'
| 'favorites'
| 'serial-details'
| 'vod-details'
| 'not-available' = 'category';
searchPhrase = this.portalStore.searchPhrase();
settings = toSignal(
this.storage.get(STORE_KEY.Settings)
) as Signal<Settings>;
favorites$: Observable<any>;
| 'not-available' = 'category_content';
items: any[] = [];
itemDetails!: StalkerVodDetails;
/* searchPhrase = this.portalStore.searchPhrase(); */
navigationContentTypes: ContentTypeNavigationItem[] = [
{
contentType: ContentType.ITV,
label: 'Live Streams',
icon: 'live_tv',
},
{
contentType: ContentType.VODS,
label: 'VOD Streams',
icon: 'movie',
},
{
contentType: ContentType.SERIES,
label: 'Series',
icon: 'video_library',
},
/* {
contentType: ContentType.RADIO,
label: 'Radio',
}, */
];
readonly pageIndex = this.stalkerStore.page;
readonly isLoadingMore = signal(false);
readonly favorites = new Map<number, boolean>();
hideExternalInfoDialog = this.portalStore.hideExternalInfoDialog;
/* load more functionality */
readonly hasMoreItems = this.stalkerStore.hasMoreChannels;
readonly itvChannels = this.stalkerStore.itvChannels;
currentCategoryId;
readonly selectedCategoryTitle = this.stalkerStore.getSelectedCategoryName;
//pagination
length = 0;
pageSize = 14;
pageIndex = 0;
/** categories */
readonly categories = this.stalkerStore.getCategoryResource;
readonly isCategoryLoading = this.stalkerStore.isCategoryResourceLoading;
readonly isCategoryFailed = this.stalkerStore.isCategoryResourceFailed;
itvTitle: string;
/** content items */
readonly contentItems = this.stalkerStore.getPaginatedContent;
readonly isContentLoading = this.stalkerStore.isPaginatedContentLoading;
seasons: StalkerSeason[] = [];
commandsList = [
new IpcCommand(STALKER_RESPONSE, (response: any) =>
this.handleResponse(response)
),
new IpcCommand(
ERROR,
(response: { message: string; status: number }) => {
this.currentLayout = 'not-available';
this.showErrorAsNotification(response);
this.isLoading = false;
/* readonly contentResource = resource({
request: () => ({
contentType: this.stalkerStore.selectedContentType(),
category: this.stalkerStore.selectedCategoryId(),
action: StalkerPortalActions.GetOrderedList,
search: this.searchPhrase,
pageIndex: this.pageIndex(),
}),
loader: async ({ request }) => {
if (
!request.category ||
request.category === null ||
request.category === ''
) {
return Promise.resolve(undefined);
}
),
];
const { portalUrl, macAddress } = this.currentPlaylist();
const params = {
action: StalkerContentTypes[request.contentType]
.getContentAction,
type: request.contentType,
category: request.category ?? '',
genre: request.category ?? '',
//sortby: 'added',
...(request.search !== '' ? { search: request.search } : {}),
p: request.pageIndex,
};
const response = await this.dataService.sendIpcEvent(
STALKER_REQUEST,
{
url: portalUrl,
macAddress,
params,
}
);
if (response) {
const newItems = response.js.data.map((item) => ({
...item,
cover: item.screenshot_uri,
}));
// Check if we're loading the first page or loading more
if (request.pageIndex === 0) {
this.itvChannels.set(newItems);
} else {
// Append new items to existing ones
this.itvChannels.update((items) => [
...items,
...newItems,
]);
}
// Update hasMoreItems based on total count and current items
const totalLoaded = this.itvChannels().length;
this.hasMoreItems.set(totalLoaded < response.js.total_items);
return newItems;
} else {
this.currentLayout = 'not-available';
this.showErrorAsNotification({
message: 'Error',
status: 500,
});
throw new Error(
`Error: ${response.message} (Status: ${response.status})`
);
}
},
}); */
constructor(
private activatedRoute: ActivatedRoute,
private dataService: DataService,
private dialog: MatDialog,
private ngZone: NgZone,
private playlistService: PlaylistsService,
private portalStore: PortalStore,
private router: Router,
private snackBar: MatSnackBar,
private storage: StorageMap,
private store: Store,
private translate: TranslateService
private readonly activatedRoute: ActivatedRoute,
private readonly playlistService: PlaylistsService,
private readonly router: Router,
private readonly snackBar: MatSnackBar
) {
effect(
() => {
if (this.currentPlaylist()) {
if (
this.currentPlaylist().password &&
this.currentPlaylist().username
) {
this.handshake();
} else {
this.selectedContentType =
this.activatedRoute.snapshot.queryParams.type ??
ContentType.VODS;
// reset category title after changing content type
effect(() => {
this.stalkerStore.selectedContentType();
});
const action =
this.activatedRoute.snapshot.queryParams.action ??
StalkerPortalActions.GetCategories;
const { category, movie_id } =
this.activatedRoute.snapshot.queryParams;
// reset items when category changes
effect(() => {
this.stalkerStore.selectedCategoryId();
this.stalkerStore.setItvChannels([]);
this.stalkerStore.setPage(0);
//this.hasMoreItems.set(false);
});
if (action === StalkerPortalActions.GetCategories) {
this.getCategories(this.selectedContentType);
} else if (
action === StalkerPortalActions.GetOrderedList &&
(category || movie_id)
) {
this.getOrderedList(
this.selectedContentType,
category,
movie_id
);
}
}
this.favorites$ = this.playlistService.getPortalFavorites(
this.currentPlaylist()._id
);
}
},
{ allowSignalWrites: true }
);
this.portalStore.setSearchPhrase('');
}
ngOnInit() {
this.commandsList.forEach((command) => {
if (this.dataService.isElectron) {
this.dataService.listenOn(command.id, (_event, response) =>
this.ngZone.run(() => command.callback(response))
);
} else {
const cb = (response) => {
if (response.data.type === command.id) {
command.callback(response.data);
}
};
this.dataService.listenOn(command.id, cb);
this.listeners.push(cb);
// reset loading state when content resource changes
effect(() => {
if (this.contentItems() !== undefined) {
this.isLoadingMore.set(false);
}
});
this.playlistService.getPortalFavorites().subscribe((favorites) => {
favorites.forEach((fav: any) => {
this.favorites.set(fav.id, true);
});
});
}
handshake() {
this.sendRequest({
/* this.sendRequest({
action: StalkerPortalActions.Handshake,
type: ContentType.STB,
});
}
setInitialBreadcrumb(action: StalkerPortalActions, title: string) {
this.breadcrumbs = [{ title, action }];
}
getCategories(contentType: ContentType) {
this.router.navigate([], { queryParams: { type: contentType } });
this.selectedContentType = contentType;
this.currentLayout = 'category';
const action = StalkerContentTypes[contentType].getCategoryAction;
this.pageIndex = 0;
this.setInitialBreadcrumb(
action,
this.translate.instant('PORTALS.ALL_CATEGORIES')
);
this.sendRequest({ action, type: contentType });
}
getOrderedList(type: ContentType, category: string, movieId?: string) {
if (!movieId) {
this.currentLayout = 'category_content';
} else {
if (type === ContentType.SERIES) {
this.currentLayout = 'serial-details';
} else if (type === ContentType.VODS) {
this.currentLayout = 'vod-details';
}
}
const action = StalkerPortalActions.GetOrderedList;
this.setInitialBreadcrumb(
action,
this.translate.instant('PORTALS.ALL_CATEGORIES')
);
this.sendRequest({
action,
type,
category,
...(movieId ? { movie_id: movieId } : {}),
});
}
sendRequest(params: Record<string, string | number | string[]>) {
if (params.action !== StalkerPortalActions.CreateLink) {
this.isLoading = true;
this.items = [];
}
const { portalUrl, macAddress } = this.currentPlaylist();
let token = {};
if (sessionStorage.getItem(this.currentPlaylist()._id)) {
token = sessionStorage.getItem(this.currentPlaylist()._id);
}
this.updateRoute(params);
this.dataService.sendIpcEvent(STALKER_REQUEST, {
url: portalUrl,
macAddress,
params: {
...params,
token,
},
});
}
updateRoute(params: Record<string, string | number | string[]>) {
let queryParams: Params;
if (params.action === StalkerPortalActions.GetCategories) {
queryParams = {
action: params.action,
type: params.type,
};
} else if (params.action === StalkerPortalActions.GetOrderedList) {
queryParams = {
action: params.action,
type: params.type,
...(params.category ? { category: params.category } : {}),
...(params.movie_id ? { movie_id: params.movie_id } : {}),
};
}
this.router.navigate([], {
queryParams,
});
}
showErrorAsNotification(response: { message: string; status: number }) {
this.snackBar.open(
`Error: ${response?.message ?? 'Something went wrong'} (Status: ${
response?.status ?? 0
})`,
null,
{ duration: 4000 }
);
this.isLoading = false;
}
handleResponse(response: {
action: StalkerPortalActions;
payload: { js: any; cmd?: string };
}) {
if (typeof response.payload !== 'object') {
this.isLoading = false;
return;
}
if (
this.currentLayout === 'serial-details' &&
response.action !== StalkerPortalActions.CreateLink
) {
this.seasons = response.payload.js.data;
this.portalStore.setCurrentSerial(this.seasons);
}
if (
response.action === StalkerPortalActions.GetCategories ||
response.action === StalkerPortalActions.GetGenres
) {
this.items = response.payload.js.map((item) => ({
category_name: item.title,
category_id: item.id,
}));
} else if (response.action === StalkerPortalActions.GetOrderedList) {
if (response.payload.js.data && response.payload.js.data.length > 0)
this.portalStore.setContent(response.payload.js.data);
this.items = response.payload.js.data.map((item) => ({
...item,
cover: item.screenshot_uri,
}));
this.length = response.payload.js.total_items;
} else if (response.action === StalkerPortalActions.CreateLink) {
let url = response.payload.js.cmd as string;
if (url?.startsWith('ffmpeg')) {
url = url.split(' ')[1];
}
this.openPlayer(url);
} else if (response.action === StalkerPortalActions.Handshake) {
const token = response.payload.js.token;
sessionStorage.setItem(this.currentPlaylist()._id, token);
this.sendRequest({
action: StalkerPortalActions.DoAuth,
login: this.currentPlaylist().username,
password: this.currentPlaylist().password,
type: ContentType.STB,
token,
});
} else if (response.action === StalkerPortalActions.DoAuth) {
this.getCategories(this.selectedContentType);
}
this.isLoading = false;
}
openPlayer(streamUrl: string) {
// Get fresh settings directly from storage instead of using signal
this.storage.get(STORE_KEY.Settings).subscribe((settings: Settings) => {
const player = settings?.player ?? VideoPlayer.VideoJs;
if (player === VideoPlayer.MPV) {
if (!this.hideExternalInfoDialog())
this.dialog.open(ExternalPlayerInfoDialogComponent);
this.dataService.sendIpcEvent(OPEN_MPV_PLAYER, {
url: streamUrl,
mpvPlayerPath: settings?.mpvPlayerPath,
});
} else if (player === VideoPlayer.VLC) {
if (!this.hideExternalInfoDialog())
this.dialog.open(ExternalPlayerInfoDialogComponent);
this.dataService.sendIpcEvent(OPEN_VLC_PLAYER, {
url: streamUrl,
vlcPlayerPath: settings?.vlcPlayerPath,
});
} else {
this.dialog.open<PlayerDialogComponent, PlayerDialogData>(
PlayerDialogComponent,
{
data: {
streamUrl,
title: this.itvTitle,
},
width: '80%',
}
);
}
});
}); */
}
categoryClicked(item: { category_name: string; category_id: string }) {
this.stalkerStore.setSelectedCategory(Number(item.category_id));
this.currentLayout = 'category_content';
this.currentCategoryId = item.category_id;
const action =
StalkerContentTypes[this.selectedContentType].getContentAction;
this.portalStore.setSearchPhrase('');
this.stalkerStore.setPage(0);
this.stalkerStore.setSelectedItem(undefined);
if (this.stalkerStore.selectedContentType() === 'itv') return;
this.breadcrumbs.push({
title: item.category_name,
category_id: item.category_id,
action,
});
this.sendRequest({
action,
type: this.selectedContentType,
category: item.category_id,
genre: item.category_id,
this.router.navigate(['.', item.category_id], {
relativeTo: this.activatedRoute,
});
}
favoriteClicked(item: StalkerFavoriteItem) {
if (item.movie_id) {
this.getSerialDetails(item);
} else if (item.stream_id && item.details) {
this.itemDetails = item.details;
this.breadcrumbs.push({
title: this.itemDetails?.info?.name,
action: StalkerPortalActions.GetOrderedList,
createLinkToPlayItv(item: any) {
this.stalkerStore.setSelectedItem(item);
this.stalkerStore.createLinkToPlayVod(item.cmd, item.name, item.logo);
}
toggleFavorite(item: any) {
if (this.favorites.has(item.id)) {
this.stalkerStore.removeFromFavorites(item.id);
this.favorites.delete(item.id);
this.snackBar.open('Removed from favorites', null, {
duration: 1000,
});
this.currentLayout = 'vod-details';
} else {
this.snackBar.open('Something went wrong, id is missing.');
}
}
getSerialDetails(item: StalkerFavoriteItem) {
this.sendRequest({
action: StalkerPortalActions.GetOrderedList,
type: ContentType.SERIES,
movie_id: item.id ?? item.movie_id,
});
this.breadcrumbs.push({
title: item.name,
action: StalkerPortalActions.GetCategories,
});
this.currentLayout = 'serial-details';
}
getVodDetails(item: {
id: string;
cmd: string;
category_id: string;
movie_id?: string;
details: any;
name: string;
}) {
const selectedContent = this.portalStore.getContentById(item.id)();
this.itemDetails = {
id: item.id,
cmd: selectedContent.cmd,
info: {
movie_image: selectedContent.screenshot_uri,
description: selectedContent.description,
name: selectedContent.name,
director: selectedContent.director,
releasedate: selectedContent.year,
genre: selectedContent.genres_str,
actors: selectedContent.actors,
rating_imdb: selectedContent.rating_imdb,
rating_kinopoisk: selectedContent.rating_kinopoisk,
},
};
this.breadcrumbs.push({
title: this.itemDetails?.info?.name,
action: StalkerPortalActions.GetOrderedList,
});
this.currentLayout = 'vod-details';
}
itemClicked(item: {
id: string;
cmd: string;
category_id: string;
movie_id?: string;
details: any;
name: string;
}) {
this.itvTitle = item.name;
if (this.selectedContentType === ContentType.SERIES) {
this.getSerialDetails(item);
} else if (this.selectedContentType === ContentType.ITV) {
this.createLinkToPlayVod(item.cmd);
} else if (this.selectedContentType === ContentType.VODS) {
this.getVodDetails(item);
}
}
createLinkToPlayEpisode(payload: StalkerSeason) {
this.sendRequest({
action: StalkerPortalActions.CreateLink,
type: ContentType.VODS,
cmd: payload.cmd,
series: payload.series,
forced_storage: 'undefined',
disable_ad: '0',
JsHttpRequest: '1-xml',
});
}
createLinkToPlayVod(cmd?: string) {
this.sendRequest({
action: StalkerPortalActions.CreateLink,
type: this.selectedContentType,
cmd: cmd ?? this.itemDetails.cmd,
forced_storage: 'undefined',
disable_ad: '0',
JsHttpRequest: '1-xml',
});
}
breadcrumbClicked(breadcrumb: Breadcrumb) {
const itemIndex = this.breadcrumbs.findIndex((i) => i === breadcrumb);
// do nothing if last breadcrumb child was clicked
if (itemIndex === this.breadcrumbs.length - 1) return;
this.breadcrumbs.splice(
itemIndex + 1,
this.breadcrumbs.length - itemIndex - 1
);
if (breadcrumb.action === StalkerPortalActions.GetOrderedList) {
this.currentLayout = 'category_content';
} else if (
breadcrumb.action === StalkerPortalActions.GetCategories ||
breadcrumb.action === StalkerPortalActions.GetGenres
) {
this.pageIndex = 0;
this.currentLayout = 'category';
} else if (breadcrumb.action === StalkerPortalActions.Favorites) {
this.currentLayout = 'favorites';
}
this.currentCategoryId = breadcrumb.category_id;
this.sendRequest({
action: breadcrumb.action,
type: this.selectedContentType,
...(breadcrumb.category_id
? {
category: breadcrumb.category_id,
genre: breadcrumb.category_id,
}
: {}),
});
}
addToFavorites(item: any) {
this.playlistService
.addPortalFavorite(this.currentPlaylist()._id, item)
.subscribe(() => {
this.snackBar.open(
this.translate.instant('PORTALS.ADDED_TO_FAVORITES'),
null,
{
duration: 1000,
}
);
this.stalkerStore.addToFavorites({
...item,
category_id: 'itv',
title: item.name,
cover: item.logo,
added_at: new Date().toISOString(),
});
}
removeFromFavorites(favoriteId: string) {
this.playlistService
.removeFromPortalFavorites(this.currentPlaylist()._id, favoriteId)
.subscribe(() => {
this.snackBar.open(
this.translate.instant('PORTALS.REMOVED_FROM_FAVORITES'),
null,
{
duration: 1000,
}
);
});
}
setSearchPhrase(searchPhrase: string) {
if (
this.currentLayout === 'category_content' &&
this.searchPhrase !== searchPhrase
) {
this.pageIndex = 0;
this.searchPhrase = searchPhrase;
this.sendRequest({
action: StalkerContentTypes[this.selectedContentType]
.getContentAction,
type: this.selectedContentType,
search: searchPhrase,
...(this.currentCategoryId
? {
category: this.currentCategoryId,
genre: this.currentCategoryId,
}
: {}),
this.favorites.set(item.id, true);
this.snackBar.open('Added to favorites', null, {
duration: 1000,
});
}
}
handlePageChange(event: PageEvent) {
this.pageIndex = Number(event.pageIndex);
this.sendRequest({
action: StalkerPortalActions.GetOrderedList,
type: this.selectedContentType,
category: this.currentCategoryId,
genre: this.currentCategoryId,
p: this.pageIndex + 1,
});
}
favoriteViewClicked() {
this.currentLayout = 'favorites';
this.setInitialBreadcrumb(
StalkerPortalActions.Favorites,
this.translate.instant('PORTALS.MY_FAVORITES')
);
}
ngOnDestroy() {
this.portalStore.setSearchPhrase('');
if (this.dataService.isElectron) {
this.commandsList.forEach((command) =>
this.dataService.removeAllListeners(command.id)
);
} else {
this.listeners.forEach((listener) => {
window.removeEventListener('message', listener);
});
loadMore() {
if (this.isLoadingMore() || !this.hasMoreItems()) {
return;
}
this.isLoadingMore.set(true);
const nextPage = this.pageIndex() + 1;
this.stalkerStore.setPage(nextPage);
/* this.pageIndex.set(nextPage); */
}
historyBack() {
this.currentLayout = 'category_content';
}
backToCategories() {
this.stalkerStore.setSelectedCategory(null);
}
}
@@ -0,0 +1,78 @@
<div class="search-page">
<div class="header">
<h2>{{ 'PORTALS.SIDEBAR.SEARCH' | translate }}</h2>
</div>
@if (itemDetails) {
<button
mat-button
(click)="itemDetails = null"
[attr.aria-label]="'SETTINGS.BACK_TO_HOME' | translate"
class="back-button"
>
<mat-icon>arrow_back</mat-icon>
{{ 'SETTINGS.BACK_TO_HOME' | translate }}
</button>
@switch (selectedFilterType()) {
@case ('vod') {
<app-vod-details
[item]="itemDetails"
(playClicked)="
createLinkToPlayVodItv(
itemDetails.cmd,
itemDetails.info.name,
itemDetails.info.movie_image
)
"
(addToFavoritesClicked)="addToFavorites($event)"
(removeFromFavoritesClicked)="removeFromFavorites($event)"
/>
}
@case ('series') {
<app-stalker-series-view />
}
}
} @else {
<app-search-form
[singleSelection]="true"
[(searchTerm)]="searchTerm"
[filters]="filters"
[filterConfig]="filterConfig"
(filtersChange)="onFiltersChange($event)"
/>
<div class="results-container">
@if (searchResultsResource.isLoading()) {
<mat-spinner />
} @else {
<div class="results-grid">
@for (
item of searchResultsResource.value();
track item.id
) {
<app-search-result-item
[title]="item.name"
[posterUrl]="item.screenshot_uri"
[type]="item.type"
(itemClick)="selectItem(item)"
/>
}
</div>
@if (searchResultsResource.value().length === 0) {
<div class="no-results">
<app-playlist-error-view
[title]="
'PORTALS.EMPTY_LIST_VIEW.TITLE' | translate
"
[description]="
'PORTALS.EMPTY_LIST_VIEW.NO_SEARCH_RESULTS'
| translate
"
[showActionButtons]="false"
[viewType]="'NO_SEARCH_RESULTS'"
/>
</div>
}
}
</div>
}
</div>
@@ -0,0 +1,166 @@
import { Component, inject, resource, signal } from '@angular/core';
import { MatButton } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { ActivatedRoute } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslatePipe } from '@ngx-translate/core';
import { STALKER_REQUEST } from '../../../../shared/ipc-commands';
import { StalkerPortalActions } from '../../../../shared/stalker-portal-actions.enum';
import { DataService } from '../../services/data.service';
import { SearchFormComponent } from '../../shared/components/search-form/search-form.component';
import { SearchResultItemComponent } from '../../shared/components/search-result-item/search-result-item.component';
import { selectPlaylistById } from '../../state/selectors';
import { PlaylistErrorViewComponent } from '../../xtream-tauri/playlist-error-view/playlist-error-view.component';
import { VodDetailsComponent } from '../../xtream/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';
@Component({
selector: 'app-stalker-search',
imports: [
MatCardModule,
MatButton,
MatIcon,
MatProgressSpinner,
PlaylistErrorViewComponent,
SearchFormComponent,
TranslatePipe,
SearchResultItemComponent,
StalkerSeriesViewComponent,
VodDetailsComponent,
],
templateUrl: './stalker-search.component.html',
styles: `
:host {
display: flex;
flex-direction: column;
overflow-y: auto;
height: 100%;
width: 100%;
}
.search-page {
padding: 20px;
}
.results-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 1rem;
padding-bottom: 1rem;
}
`,
})
export class StalkerSearchComponent {
private readonly activatedRoute = inject(ActivatedRoute);
private readonly dataService = inject(DataService);
private readonly stalkerStore = inject(StalkerStore);
private readonly store = inject(Store);
readonly filters = {
series: false,
vod: true,
};
readonly filterConfig = [
{
key: 'vod',
label: 'Movies',
translationKey: 'PORTALS.SIDEBAR.MOVIES',
},
{
key: 'series',
label: 'Series',
translationKey: 'PORTALS.SIDEBAR.SERIES',
},
];
readonly searchTerm = signal('');
private readonly currentPlaylist = this.store.selectSignal(
selectPlaylistById(this.activatedRoute?.parent?.snapshot.params.id)
);
readonly selectedFilterType = signal('vod');
itemDetails = null;
readonly searchResultsResource = resource({
request: () => ({
contentType: this.selectedFilterType(),
search: this.searchTerm(),
action: StalkerPortalActions.GetOrderedList,
}),
loader: async ({ request }) => {
if (request.search.length < 3) {
return [];
}
const { portalUrl, macAddress } = this.currentPlaylist();
const response = await this.dataService.sendIpcEvent(
STALKER_REQUEST,
{
url: portalUrl,
macAddress,
params: {
action: StalkerContentTypes[request.contentType]
.getContentAction,
type: request.contentType,
search: request.search,
max_page_items: 100,
},
}
);
if (response) {
return response.js?.data;
} else {
throw new Error(
`Error: ${response.message} (Status: ${response.status})`
);
}
},
});
createLinkToPlayVodItv(cmd?: string, title?: string, thumbnail?: string) {
this.stalkerStore.createLinkToPlayVod(cmd, title, thumbnail);
}
selectItem(item: any) {
this.itemDetails = {
...item,
info: { ...item, movie_image: item.screenshot_uri },
};
this.stalkerStore.setSelectedItem(this.itemDetails);
switch (this.selectedFilterType()) {
case 'vod':
this.stalkerStore.setSelectedContentType('vod');
break;
case 'series':
this.stalkerStore.setSelectedContentType('series');
break;
default:
break;
}
}
onFiltersChange(event: { vod: boolean; live: boolean; series: boolean }) {
const selectedFilter = Object.keys(event).find((key) => event[key]);
if (selectedFilter) {
this.selectedFilterType.set(selectedFilter);
}
}
addToFavorites(item: any) {
console.debug('Add to favorites', item);
this.stalkerStore.addToFavorites(item);
}
removeFromFavorites(favoriteId: string) {
console.debug('Remove from favorites', favoriteId);
this.stalkerStore.removeFromFavorites(favoriteId);
}
}
@@ -1,65 +1,58 @@
@let seasons = seasonsData;
<div class="container">
@if (seasons()[0]; as serial) {
@if (selectedItem(); as serial) {
<div class="image">
<img
*ngIf="serial.screenshot_uri; else placeholderCover"
[ngSrc]="serial.screenshot_uri"
style="position: relative"
fill
/>
<ng-template #placeholderCover>
@if (serial.info.movie_image) {
<img
[src]="serial.info.movie_image"
style="position: relative"
(error)="
$event.target.src = './assets/images/default-poster.png'
"
/>
} @else {
<div class="placeholder-cover"></div>
</ng-template>
}
</div>
<div class="details">
<h2>{{ serial.name }}</h2>
<h2>{{ serial.info.name }}</h2>
<div>
{{ serial.description }}
{{ serial.info.description }}
</div>
<div>
<div class="label">{{ 'XTREAM.DIRECTOR' | translate }}:</div>
{{ serial.director }}
{{ serial.info.director }}
</div>
<div>
<div class="label">{{ 'XTREAM.ACTORS' | translate }}:</div>
{{ serial.actors }}
{{ serial.info.actors }}
</div>
<div>
<div class="label">{{ 'XTREAM.YEAR' | translate }}:</div>
{{ serial.year }}
{{ serial.info.releasedate }}
</div>
<div>
<div class="label">{{ 'XTREAM.GENRE' | translate }}:</div>
{{ serial.genres_str }}
</div>
<div>
<div class="label">{{ 'XTREAM.AGE' | translate }}:</div>
{{ serial.age }}
{{ serial.info.genre }}
</div>
<div>
<div class="label">{{ 'XTREAM.IMDB_RATING' | translate }}:</div>
{{ serial.rating_imdb }}
{{ serial.info.rating_imdb }}
</div>
<div>
<div class="label">
{{ 'XTREAM.KINOPOISK_RATING' | translate }}:
</div>
{{ serial.rating_kinopoisk }}
</div>
<div>
<div class="label">
{{ 'HOME.PLAYLISTS.ADDED' | translate }}:
</div>
{{ serial.added }}
{{ serial.info.rating_kinopoisk }}
</div>
<div class="action-buttons">
<app-favorites-button
[serialMeta]="{
movie_id: serial.id,
name: serial.name,
cover: serial.screenshot_uri
}"
[itemId]="serial.id"
(addToFavoritesClicked)="addToFavorites(serial)"
(removeFromFavoritesClicked)="
removeFromFavorites(serial.id)
"
/>
</div>
</div>
@@ -73,9 +66,7 @@
@for (episode of item.series; track $index) {
<button
mat-stroked-button
(click)="
playEpisode.emit({ series: episode, cmd: item.cmd })
"
(click)="playEpisodeClicked(episode, item.cmd)"
>
{{ 'XTREAM.EPISODE' | translate }} {{ episode }}
</button>
@@ -1,46 +1,44 @@
import { NgIf, NgOptimizedImage } from '@angular/common';
import { Component, Signal, input, output } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDividerModule } from '@angular/material/divider';
import { TranslateModule } from '@ngx-translate/core';
import { Component, inject } from '@angular/core';
import { MatButton } from '@angular/material/button';
import { MatDivider } from '@angular/material/divider';
import { TranslatePipe } from '@ngx-translate/core';
import { FavoritesButtonComponent } from '../favorites-button/favorites-button.component';
import { StalkerSeason } from '../models';
function sortByNumericValue(array: StalkerSeason[]): StalkerSeason[] {
if (!array) return [];
const key = 'name';
return array.sort((a, b) => {
const numericA = extractNumericValue(a[key]);
const numericB = extractNumericValue(b[key]);
return numericA - numericB;
});
}
function extractNumericValue(str: string) {
const matches = str.match(/\d+/);
if (matches) {
return parseInt(matches[0], 10);
}
return 0;
}
import { StalkerStore } from '../stalker.store';
@Component({
selector: 'app-stalker-series-view',
templateUrl: './stalker-series-view.component.html',
styleUrls: ['../../xtream/detail-view.scss'],
imports: [
FavoritesButtonComponent,
MatButtonModule,
MatDividerModule,
NgIf,
NgOptimizedImage,
TranslateModule,
],
imports: [FavoritesButtonComponent, MatButton, MatDivider, TranslatePipe],
})
export class StalkerSeriesViewComponent {
seasons: Signal<StalkerSeason[]> = input.required({
transform: sortByNumericValue,
});
readonly stalkerStore = inject(StalkerStore);
playEpisode = output<{ series: string; cmd: string }>();
readonly selectedItem = this.stalkerStore.selectedItem;
readonly seasonsData = this.stalkerStore.getSerialSeasonsResource;
playEpisodeClicked(episode: any, cmd: string) {
const item = this.selectedItem();
this.stalkerStore.createLinkToPlayVod(
cmd,
item.info.name,
item.info.movie_image,
episode
);
}
addToFavorites(item: any) {
this.stalkerStore.addToFavorites({
...item,
title: item.info.name,
cover: item.info.movie_image,
series_id: item.id,
added_at: new Date().toISOString(),
category_id: 'series',
});
}
removeFromFavorites(serialId: string) {
this.stalkerStore.removeFromFavorites(serialId);
}
}
@@ -0,0 +1,10 @@
<div class="stalker-container">
<app-navigation
class="navigation-sidebar"
[navigationItems]="mainNavigationItems"
(categoryClick)="setContentType($event)"
[selectedContentType]="stalkerStore.selectedContentType()"
(pageClicked)="handlePageClick()"
/>
<router-outlet />
</div>
@@ -0,0 +1,18 @@
:host {
display: flex;
flex-direction: column;
overflow: hidden;
height: 100%;
}
.stalker-container {
display: flex;
flex: 1;
overflow: hidden;
min-height: 0;
}
.navigation-sidebar {
width: 200px;
border-right: 1px solid #333;
}
@@ -0,0 +1,74 @@
import { Component, effect, inject, OnDestroy } from '@angular/core';
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
import { Store } from '@ngrx/store';
import * as PlaylistActions from '../../state/actions';
import { selectPlaylistById } from '../../state/selectors';
import { NavigationComponent } from '../../xtream-tauri/navigation/navigation.component';
import { NavigationItem } from '../../xtream-tauri/navigation/navigation.interface';
import { StalkerStore } from '../stalker.store';
@Component({
selector: 'app-stalker-shell',
templateUrl: './stalker-shell.component.html',
styleUrls: ['./stalker-shell.component.scss'],
standalone: true,
imports: [NavigationComponent, RouterOutlet, NavigationComponent],
})
export class StalkerShellComponent implements OnDestroy {
private readonly route = inject(ActivatedRoute);
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 currentPlaylist = this.store.selectSignal(
selectPlaylistById(this.route.snapshot.params.id)
);
constructor() {
this.store.dispatch(
PlaylistActions.setCurrentPlaylistId({
playlistId: this.route.snapshot.params['id'],
})
);
this.stalkerStore.setSelectedContentType('vod');
this.stalkerStore.setSelectedCategory(null);
effect(() => {
this.stalkerStore.setCurrentPlaylist(this.currentPlaylist());
});
}
setContentType(type: 'vod' | 'live' | 'series' | 'itv') {
if (type === 'live') type = 'itv';
this.stalkerStore.setSelectedContentType(type);
this.stalkerStore.setSelectedCategory(null);
this.router.navigate([type], {
relativeTo: this.route,
});
}
handlePageClick() {
this.stalkerStore.setSelectedContentType(null);
}
ngOnDestroy() {
this.stalkerStore.resetCategories();
}
}
+92
View File
@@ -0,0 +1,92 @@
import { Route } from '@angular/router';
export const stalkerRoutes: Route[] = [
{
path: 'stalker/:id',
loadComponent: () =>
import('./stalker-shell/stalker-shell.component').then(
(c) => c.StalkerShellComponent
),
children: [
{
path: '',
redirectTo: 'vod',
pathMatch: 'full',
},
{
path: 'vod',
loadComponent: () =>
import('./stalker-main-container.component').then(
(c) => c.StalkerMainContainerComponent
),
children: [
{
path: ':categoryId',
data: {
api: 'stalker',
contentType: 'vod',
},
loadComponent: () =>
import(
'../xtream-tauri/category-content-view/category-content-view.component'
).then((c) => c.CategoryContentViewComponent),
},
],
},
{
path: '',
redirectTo: 'vod',
pathMatch: 'full',
},
{
path: 'itv',
loadComponent: () =>
import('./stalker-main-container.component').then(
(c) => c.StalkerMainContainerComponent
),
},
{
path: 'series',
loadComponent: () =>
import('./stalker-main-container.component').then(
(c) => c.StalkerMainContainerComponent
),
children: [
{
path: ':categoryId',
loadComponent: () =>
import(
'../xtream-tauri/category-content-view/category-content-view.component'
).then((c) => c.CategoryContentViewComponent),
data: {
api: 'stalker',
contentType: 'series',
},
},
],
},
{
path: 'favorites',
loadComponent: () =>
import(
'./stalker-favorites/stalker-favorites.component'
).then((c) => c.StalkerFavoritesComponent),
},
{
path: 'recent',
loadComponent: () =>
import('./recently-viewed/recently-viewed.component').then(
(c) => c.RecentlyViewedComponent
),
},
{
path: 'search',
loadComponent: () =>
import('./stalker-search/stalker-search.component').then(
(c) => c.StalkerSearchComponent
),
},
],
},
];
+502
View File
@@ -0,0 +1,502 @@
import { computed, inject, resource, signal } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import {
patchState,
signalStore,
withComputed,
withMethods,
withProps,
withState,
} from '@ngrx/signals';
import { TranslateService } from '@ngx-translate/core';
import { STALKER_REQUEST } from '../../../shared/ipc-commands';
import { StalkerPortalActions } from '../../../shared/stalker-portal-actions.enum';
import { DataService } from '../services/data.service';
import { PlayerService } from '../services/player.service';
import { PlaylistsService } from '../services/playlists.service';
import { PlaylistMeta } from '../shared/playlist-meta.type';
import { ContentType } from '../xtream/content-type.enum';
import { StalkerSeason } from './models/stalker-season.interface';
import { StalkerContentTypes } from './stalker-content-types';
interface StalkerCategoryItem {
category_id: string;
category_name: string;
}
interface StalkerState {
selectedContentType: 'vod' | 'itv' | 'series';
selectedCategoryId: string;
selectedVodId: string;
selectedSerialId: string;
selectedItvId: string;
limit: number;
page: number;
searchPhrase: string;
currentPlaylist: PlaylistMeta;
totalCount: number;
selectedItem: any;
vodCategories: StalkerCategoryItem[];
seriesCategories: StalkerCategoryItem[];
itvCategories: StalkerCategoryItem[];
hasMoreChannels: boolean;
itvChannels: any[];
}
const initialState: StalkerState = {
selectedContentType: 'vod',
selectedCategoryId: undefined,
selectedVodId: undefined,
selectedSerialId: undefined,
selectedItvId: undefined,
limit: 14,
page: 0,
searchPhrase: '',
currentPlaylist: undefined,
totalCount: 0,
selectedItem: undefined,
vodCategories: [],
seriesCategories: [],
itvCategories: [],
hasMoreChannels: false,
itvChannels: [],
};
function extractNumericValue(str: string) {
const matches = str.match(/\d+/);
if (matches) {
return parseInt(matches[0], 10);
}
return 0;
}
function sortByNumericValue(array: StalkerSeason[]): StalkerSeason[] {
if (!array) return [];
const key = 'name';
return array.sort((a, b) => {
const numericA = extractNumericValue(a[key]);
const numericB = extractNumericValue(b[key]);
return numericA - numericB;
});
}
export const StalkerStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withProps((store, dataService = inject(DataService)) => ({
getCategoryResource: resource({
request: () => ({
contentType: store.selectedContentType(),
action: StalkerPortalActions.GetCategories,
currentPlaylist: store.currentPlaylist(),
}),
loader: async ({ request }) => {
if (request.currentPlaylist === undefined) return;
switch (request.contentType) {
case 'itv':
if (store.itvCategories().length > 0) {
return store.itvCategories();
}
break;
case 'vod':
if (store.vodCategories().length > 0) {
return store.vodCategories();
}
break;
case 'series':
if (store.seriesCategories().length > 0) {
return store.seriesCategories();
}
break;
}
const { portalUrl, macAddress } = request.currentPlaylist;
const response = await dataService.sendIpcEvent(
STALKER_REQUEST,
{
url: portalUrl,
macAddress,
params: {
action: StalkerContentTypes[request.contentType]
.getCategoryAction,
type: request.contentType,
},
}
);
if (response) {
const categories = response.js
.map((item) => ({
category_name: item.title,
category_id: item.id,
}))
.sort((a, b) =>
a.category_name.localeCompare(b.category_name)
);
patchState(store, {
[`${request.contentType}Categories`]: categories,
});
return categories;
} else {
throw new Error(
`Error: ${response.message} (Status: ${response.status})`
);
}
},
}),
getContentResource: resource({
request: () => ({
contentType: store.selectedContentType(),
category: store.selectedCategoryId(),
action: StalkerPortalActions.GetOrderedList,
search: store.searchPhrase(),
pageIndex: store.page() + 1,
}),
loader: async ({ request }) => {
if (
!request.category ||
request.category === null ||
request.category === ''
) {
return Promise.resolve(undefined);
}
const currentPlaylist = store.currentPlaylist;
const params = {
action: StalkerContentTypes[request.contentType]
.getContentAction,
type: request.contentType,
category: request.category ?? '',
genre: request.category ?? '',
sortby: 'added',
...(request.search !== ''
? { search: request.search }
: {}),
p: request.pageIndex,
};
const response = await dataService.sendIpcEvent(
STALKER_REQUEST,
{
url: currentPlaylist().portalUrl,
macAddress: currentPlaylist().macAddress,
params,
}
);
patchState(store, { totalCount: response.js.total_items });
if (response) {
const newItems = response.js.data.map((item) => ({
...item,
cover: item.screenshot_uri,
}));
if (store.selectedContentType() === 'itv') {
// Check if we're loading the first page or loading more
if (request.pageIndex === 1) {
patchState(store, { itvChannels: newItems });
} else {
patchState(store, {
itvChannels: [
...store.itvChannels(),
...newItems,
],
});
}
// Update hasMoreItems based on total count and current items
const totalLoaded = store.itvChannels().length;
patchState(store, {
hasMoreChannels:
totalLoaded < response.js.total_items,
});
}
return newItems;
} else {
throw new Error(
`Error: ${response.message} (Status: ${response.status})`
);
}
},
}),
serialSeasonsResource: resource({
request: () => ({
itemId: store.selectedSerialId(),
}),
loader: async ({ request }) => {
const { portalUrl, macAddress } = store.currentPlaylist();
const params = {
action: StalkerContentTypes.series.getContentAction,
type: 'series',
movie_id: request.itemId,
};
const response = await dataService.sendIpcEvent(
STALKER_REQUEST,
{
url: portalUrl,
macAddress,
params,
}
);
return sortByNumericValue(response.js.data);
},
}),
})),
withComputed((store) => ({
getTotalPages: computed(() => {
return Math.ceil(store.totalCount() / store.limit());
}),
/** category content */
getPaginatedContent: computed(() => store.getContentResource.value()),
isPaginatedContentLoading: computed(() =>
store.getContentResource.isLoading()
),
isPaginatedContentFailed: computed(() =>
store.getContentResource.error()
),
/** serials */
getSerialSeasonsResource: computed(() =>
store.serialSeasonsResource.value()
),
/** category resource */
getCategoryResource: computed(() => store.getCategoryResource.value()),
isCategoryResourceLoading: computed(() =>
store.getCategoryResource.isLoading()
),
isCategoryResourceFailed: computed(() =>
store.getCategoryResource.error()
),
getSelectedCategoryName: computed(() => {
const type = store.selectedContentType();
const selectedCategoryId = store.selectedCategoryId();
if (!selectedCategoryId) return '';
let categories = [];
if (type === 'vod') {
categories = store.vodCategories();
} else if (type === 'series') {
categories = store.seriesCategories();
} else if (type === 'itv') {
categories = store.itvCategories();
}
const category = categories.find(
(cat) => String(cat.category_id) === String(selectedCategoryId)
);
return category ? category.category_name : '';
}),
})),
withMethods(
(
store,
dataService = inject(DataService),
playerService = inject(PlayerService),
playlistService = inject(PlaylistsService),
snackBar = inject(MatSnackBar),
translate = inject(TranslateService)
) => ({
/** selectors */
setSelectedContentType(type: 'vod' | 'itv' | 'series') {
patchState(store, { selectedContentType: type });
},
setSelectedCategory(id: number) {
patchState(store, {
selectedCategoryId: id !== null ? String(id) : null,
page: 0,
});
},
setSelectedSerialId(id: string) {
patchState(store, { selectedSerialId: id });
},
setSelectedVodId(id: string) {
patchState(store, { selectedVodId: id });
},
setSelectedItvId(id: string) {
patchState(store, { selectedItvId: id });
},
setLimit(limit: number) {
patchState(store, { limit });
},
setPage(page: number) {
patchState(store, { page });
},
setCurrentPlaylist(playlist: PlaylistMeta | undefined) {
patchState(store, { currentPlaylist: playlist });
},
setSelectedItem(selectedItem: any) {
// TODO: check the item type and proper property either selectedVodId or serialsId etc
patchState(store, {
selectedVodId: selectedItem?.id ?? undefined,
selectedSerialId: selectedItem?.id ?? undefined,
selectedItvId: selectedItem?.id ?? undefined,
selectedItem,
});
},
setCategories(type: 'vod' | 'series' | 'itv', categories: any[]) {
if (type === 'vod') {
patchState(store, { vodCategories: categories });
} else if (type === 'series') {
patchState(store, { seriesCategories: categories });
} else if (type === 'itv') {
patchState(store, { itvCategories: categories });
}
},
resetCategories() {
patchState(store, {
vodCategories: [],
seriesCategories: [],
itvCategories: [],
});
},
setItvChannels(channels: any[]) {
patchState(store, { itvChannels: channels });
},
/** getters */
getSelectedCategory() {
return signal<string>(store.selectedCategoryId());
},
/** API */
async fetchLinkToPlay(
portalUrl: string,
macAddress: string,
cmd: string,
series?: number
) {
const type = series
? ContentType.VODS
: store.selectedContentType();
const params = {
action: StalkerContentTypes[store.selectedContentType()]
.getLink,
type,
cmd,
forced_storage: 'undefined',
disable_ad: '0',
JsHttpRequest: '1-xml',
...(series ? { series } : {}),
};
const response = await dataService.sendIpcEvent(
STALKER_REQUEST,
{
url: portalUrl + '?' + cmd,
macAddress,
params,
}
);
let url = response.js.cmd as string;
if (url?.startsWith('ffmpeg')) {
url = url.split(' ')[1];
}
return url;
},
async getExpireDate() {
const params = {
type: 'account_info',
action: 'get_main_info',
JsHttpRequest: '1-xml',
};
try {
const response = await dataService.sendIpcEvent(
STALKER_REQUEST,
{
url: store.currentPlaylist().portalUrl,
macAddress: store.currentPlaylist().macAddress,
params,
}
);
if (response && response.js && response.js.account_info) {
// Extract the expire date from the response
const expireDate = response.js.account_info.expire_date;
// 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
return date.toLocaleDateString();
}
return expireDate || 'Unknown';
}
return 'Unknown';
} catch (error) {
console.error('Failed to fetch expire date:', error);
return 'Error fetching data';
}
},
addToFavorites(item: any) {
playlistService
.addPortalFavorite(this.currentPlaylist()?._id, {
...item,
category_id: store.selectedContentType(),
added_at: Date.now(),
id: item.stream_id ?? item.id,
})
.subscribe(() => {
snackBar.open(
translate.instant('PORTALS.ADDED_TO_FAVORITES'),
null,
{
duration: 1000,
}
);
});
},
removeFromFavorites(favoriteId: string) {
playlistService
.removeFromPortalFavorites(
this.currentPlaylist()?._id,
favoriteId
)
.subscribe(() => {
snackBar.open(
translate.instant('PORTALS.REMOVED_FROM_FAVORITES'),
null,
{
duration: 1000,
}
);
});
},
async createLinkToPlayVod(
cmd?: string,
title?: string,
thumbnail?: string,
episode?: number
) {
const url = await this.fetchLinkToPlay(
this.currentPlaylist().portalUrl,
this.currentPlaylist().macAddress,
cmd ?? this.selectedItem().cmd,
episode
);
const item = this.selectedItem();
this.addToRecentlyViewed({
...item,
id: item.id,
cmd: cmd,
cover: thumbnail,
title,
});
playerService.openPlayer(url, title, thumbnail);
},
addToRecentlyViewed(item: any) {
console.log('Adding to recently viewed', item);
playlistService
.addPortalRecentlyViewed({
...item,
category_id: store.selectedContentType(),
added_at: Date.now(),
})
.subscribe();
},
removeFromRecentlyViewed(itemId: number) {
playlistService
.removeFromPortalRecentlyViewed(itemId)
.subscribe();
},
})
)
);