mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 19:46:16 -08:00
feat: implement stalker live stream layout with EPG support
- Added new component for live stream layout with sidebar and content area. - Integrated playlist switcher and category view for better navigation. - Implemented channel list with loading indicators and favorite functionality. - Added EPG (Electronic Program Guide) support for channels with loading and pagination. - Enhanced styles for responsive design and improved user experience. - Updated routing to use the new live stream layout component. - Modified stalker store to handle EPG fetching and search functionality. - Introduced new actions in the StalkerPortalActions enum for EPG operations.
This commit is contained in:
1 parent
edcd7cb148
commit
d603c46a5a
8 files changed
+937
-338
No files matched your search
+168
@@ -0,0 +1,168 @@
|
||||
@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 (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()) {
|
||||
<!-- <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) {
|
||||
<div class="video-player">
|
||||
<app-web-player-view [streamUrl]="streamUrl" />
|
||||
</div>
|
||||
}
|
||||
@if (stalkerStore.selectedItem()) {
|
||||
<div class="epg">
|
||||
<div class="epg-content">
|
||||
<app-epg-view [epgItems]="epgItems()" />
|
||||
@if (isLoadingEpg()) {
|
||||
<div class="loading-indicator">
|
||||
<mat-spinner diameter="24" />
|
||||
</div>
|
||||
}
|
||||
@if (hasMoreEpg() && !isLoadingEpg()) {
|
||||
<div class="load-more-epg">
|
||||
<button mat-button (click)="loadMoreEpg()">
|
||||
{{ 'CHANNELS.LOAD_MORE' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="no-channel-selected">
|
||||
<button mat-icon-button disabled>
|
||||
<mat-icon>category</mat-icon>
|
||||
</button>
|
||||
{{ 'PORTALS.SELECT_CATEGORY' | translate }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
+275
@@ -0,0 +1,275 @@
|
||||
@use '@angular/material' as mat;
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 400px;
|
||||
min-width: 250px;
|
||||
max-width: 600px;
|
||||
border-right: 1px solid #111;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
container-type: inline-size;
|
||||
|
||||
app-category-view {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
margin-right: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.content-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.video-player {
|
||||
flex: 1;
|
||||
height: 50%;
|
||||
}
|
||||
|
||||
.epg {
|
||||
flex: 1;
|
||||
height: 50%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.epg-content {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.no-channel-selected {
|
||||
padding: 16px;
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
// Channel list
|
||||
.search-panel {
|
||||
padding: 8px 12px;
|
||||
flex-shrink: 0;
|
||||
height: 56px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
width: 100%;
|
||||
@include mat.form-field-density(-5);
|
||||
}
|
||||
|
||||
::ng-deep {
|
||||
.search-bar {
|
||||
div {
|
||||
border-radius: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
div[matformfieldlineripple] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.channels-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.channel-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
margin: 2px 4px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
min-height: 48px;
|
||||
box-sizing: border-box;
|
||||
gap: 8px;
|
||||
width: calc(100% - 8px);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
|
||||
.title {
|
||||
color: #1db954;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: rgba(29, 185, 84, 0.2);
|
||||
|
||||
.title {
|
||||
color: #1db954;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stream-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.channel-info {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
transition: color 0.2s ease;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.current-program {
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
opacity: 0.6;
|
||||
|
||||
span {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
flex: 1;
|
||||
height: 3px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
min-width: 20px;
|
||||
|
||||
.progress {
|
||||
height: 100%;
|
||||
background: #1db954;
|
||||
transition: width 1s linear;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.favorite-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-indicator {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.load-more-epg {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
// Container queries for responsive behavior
|
||||
@container (max-width: 350px) {
|
||||
.channel-item {
|
||||
gap: 6px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.stream-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.favorite-btn {
|
||||
padding: 4px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 300px) {
|
||||
.stream-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.favorite-btn {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 260px) {
|
||||
.stream-icon {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
::ng-deep {
|
||||
.video-player
|
||||
> app-web-player-view
|
||||
> app-art-player
|
||||
> .artplayer-container {
|
||||
height: 100% !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
+390
@@ -0,0 +1,390 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
effect,
|
||||
ElementRef,
|
||||
inject,
|
||||
NgZone,
|
||||
OnDestroy,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatButton, MatIconButton } from '@angular/material/button';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
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 { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { PlaylistSwitcherComponent, ResizableDirective } from 'components';
|
||||
import { PlaylistsService } from 'services';
|
||||
import { EpgItem } from 'shared-interfaces';
|
||||
import { EpgViewComponent, WebPlayerViewComponent } from 'shared-portals';
|
||||
import { SettingsStore } from '../../services/settings-store.service';
|
||||
import { PlayerService } from '../../services/player.service';
|
||||
import { CategoryViewComponent } from '../../xtream-tauri/category-view/category-view.component';
|
||||
import { PlaylistErrorViewComponent } from '../../xtream/playlist-error-view/playlist-error-view.component';
|
||||
import { StalkerStore } from '../stalker.store';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
|
||||
@Component({
|
||||
selector: 'app-stalker-live-stream-layout',
|
||||
templateUrl: './stalker-live-stream-layout.component.html',
|
||||
styleUrls: [
|
||||
'./stalker-live-stream-layout.component.scss',
|
||||
'../../xtream-tauri/sidebar.scss',
|
||||
],
|
||||
imports: [
|
||||
CategoryViewComponent,
|
||||
DatePipe,
|
||||
EpgViewComponent,
|
||||
FormsModule,
|
||||
MatButton,
|
||||
MatFormFieldModule,
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatInputModule,
|
||||
MatProgressSpinnerModule,
|
||||
NgxSkeletonLoaderModule,
|
||||
PlaylistErrorViewComponent,
|
||||
PlaylistSwitcherComponent,
|
||||
ResizableDirective,
|
||||
TranslatePipe,
|
||||
WebPlayerViewComponent,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
readonly stalkerStore = inject(StalkerStore);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private readonly playlistService = inject(PlaylistsService);
|
||||
private readonly playerService = inject(PlayerService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
/** Categories */
|
||||
readonly categories = this.stalkerStore.getCategoryResource;
|
||||
readonly isCategoryLoading = this.stalkerStore.isCategoryResourceLoading;
|
||||
readonly isCategoryFailed = this.stalkerStore.isCategoryResourceFailed;
|
||||
readonly selectedCategoryTitle = this.stalkerStore.getSelectedCategoryName;
|
||||
readonly currentPlaylist = this.stalkerStore.currentPlaylist;
|
||||
|
||||
/** Channels */
|
||||
readonly itvChannels = this.stalkerStore.itvChannels;
|
||||
readonly hasMoreItems = this.stalkerStore.hasMoreChannels;
|
||||
readonly isLoadingMore = signal(false);
|
||||
|
||||
/** Search */
|
||||
readonly searchString = signal('');
|
||||
|
||||
/** Player */
|
||||
readonly player = this.settingsStore.player;
|
||||
streamUrl = '';
|
||||
|
||||
/** EPG */
|
||||
readonly epgItems = signal<EpgItem[]>([]);
|
||||
readonly isLoadingEpg = signal(false);
|
||||
readonly hasMoreEpg = signal(false);
|
||||
private epgPageSize = 10;
|
||||
private epgChannelId: number | string | null = null;
|
||||
|
||||
/** Channel list EPG preview */
|
||||
readonly currentPrograms = new Map<number, string>();
|
||||
readonly currentProgramsProgress = new Map<number, number>();
|
||||
readonly programTimings = new Map<number, { start: number; end: number }>();
|
||||
private readonly requestedEpgChannels = new Set<number>();
|
||||
private readonly cdr = inject(ChangeDetectorRef);
|
||||
private readonly ngZone = inject(NgZone);
|
||||
|
||||
/** Favorites */
|
||||
readonly favorites = new Map<number, boolean>();
|
||||
|
||||
/** Scroll */
|
||||
readonly scrollContainer = viewChild<ElementRef>('scrollContainer');
|
||||
private scrollListener: (() => void) | null = null;
|
||||
private searchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor() {
|
||||
// Load favorites for current playlist
|
||||
this.playlistService
|
||||
.getPortalFavorites(this.stalkerStore.currentPlaylist()?._id)
|
||||
.subscribe((favs) => {
|
||||
favs.forEach((fav: any) => {
|
||||
this.favorites.set(fav.id, true);
|
||||
});
|
||||
});
|
||||
|
||||
// Reset channels/page on category change
|
||||
effect(() => {
|
||||
this.stalkerStore.selectedCategoryId();
|
||||
this.stalkerStore.setItvChannels([]);
|
||||
this.stalkerStore.setPage(0);
|
||||
this.clearEpgPreviewMaps();
|
||||
});
|
||||
|
||||
// Reset loading state when channels load + check viewport fill + load EPG previews
|
||||
effect(() => {
|
||||
const channels = this.itvChannels();
|
||||
if (channels.length > 0) {
|
||||
this.isLoadingMore.set(false);
|
||||
setTimeout(() => this.checkIfNeedsMoreContent(), 100);
|
||||
this.loadEpgPreviewsForChannels(channels);
|
||||
}
|
||||
});
|
||||
|
||||
// Setup scroll listener when container becomes available
|
||||
effect(() => {
|
||||
const container = this.scrollContainer();
|
||||
if (container) {
|
||||
this.setupScrollListener();
|
||||
}
|
||||
});
|
||||
|
||||
// Debounced server-side search
|
||||
effect(() => {
|
||||
const search = this.searchString();
|
||||
if (this.searchDebounceTimer) clearTimeout(this.searchDebounceTimer);
|
||||
this.searchDebounceTimer = setTimeout(() => {
|
||||
this.stalkerStore.setItvChannels([]);
|
||||
this.stalkerStore.setSearchPhrase(search);
|
||||
}, 500);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.removeScrollListener();
|
||||
if (this.searchDebounceTimer) {
|
||||
clearTimeout(this.searchDebounceTimer);
|
||||
}
|
||||
}
|
||||
|
||||
selectCategory(item: { category_name: string; category_id: string }) {
|
||||
this.stalkerStore.setSelectedCategory(item.category_id || '*');
|
||||
this.stalkerStore.setPage(0);
|
||||
}
|
||||
|
||||
backToCategories() {
|
||||
this.searchString.set('');
|
||||
this.stalkerStore.setSearchPhrase('');
|
||||
this.stalkerStore.setSelectedCategory(null);
|
||||
}
|
||||
|
||||
async playChannel(item: any) {
|
||||
this.stalkerStore.setSelectedItem(item);
|
||||
|
||||
const isEmbeddedPlayer =
|
||||
this.player() === 'videojs' ||
|
||||
this.player() === 'html5' ||
|
||||
this.player() === 'artplayer';
|
||||
|
||||
try {
|
||||
const url = await this.stalkerStore.fetchLinkToPlay(
|
||||
this.currentPlaylist().portalUrl,
|
||||
this.currentPlaylist().macAddress,
|
||||
item.cmd
|
||||
);
|
||||
|
||||
this.loadEpgForChannel(item.id);
|
||||
|
||||
if (isEmbeddedPlayer) {
|
||||
this.streamUrl = url;
|
||||
} else {
|
||||
this.playerService.openPlayer(
|
||||
url,
|
||||
item.o_name || item.name,
|
||||
item.logo,
|
||||
true,
|
||||
true,
|
||||
this.currentPlaylist()?.userAgent,
|
||||
this.currentPlaylist()?.referrer,
|
||||
this.currentPlaylist()?.origin
|
||||
);
|
||||
}
|
||||
|
||||
// Add to recently viewed
|
||||
this.stalkerStore.addToRecentlyViewed({
|
||||
...item,
|
||||
id: item.id,
|
||||
cover: item.logo,
|
||||
title: item.o_name || item.name,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[StalkerLiveStream] Playback failed:', error);
|
||||
const errorMessage =
|
||||
error?.message === 'nothing_to_play'
|
||||
? this.translate.instant('PORTALS.CONTENT_NOT_AVAILABLE')
|
||||
: this.translate.instant('PORTALS.PLAYBACK_ERROR');
|
||||
this.snackBar.open(errorMessage, null, { duration: 3000 });
|
||||
}
|
||||
}
|
||||
|
||||
toggleFavorite(item: any) {
|
||||
if (this.favorites.has(item.id)) {
|
||||
this.stalkerStore.removeFromFavorites(item.id);
|
||||
this.favorites.delete(item.id);
|
||||
} else {
|
||||
this.stalkerStore.addToFavorites({
|
||||
...item,
|
||||
category_id: 'itv',
|
||||
title: item.o_name || item.name,
|
||||
cover: item.logo,
|
||||
added_at: new Date().toISOString(),
|
||||
});
|
||||
this.favorites.set(item.id, true);
|
||||
}
|
||||
}
|
||||
|
||||
loadMore() {
|
||||
if (this.isLoadingMore() || !this.hasMoreItems()) return;
|
||||
this.isLoadingMore.set(true);
|
||||
const nextPage = this.stalkerStore.page() + 1;
|
||||
this.stalkerStore.setPage(nextPage);
|
||||
}
|
||||
|
||||
async loadMoreEpg() {
|
||||
if (!this.epgChannelId || this.isLoadingEpg()) return;
|
||||
this.epgPageSize += 10;
|
||||
this.isLoadingEpg.set(true);
|
||||
try {
|
||||
const items = await this.stalkerStore.fetchChannelEpg(
|
||||
this.epgChannelId,
|
||||
this.epgPageSize
|
||||
);
|
||||
this.epgItems.set(items);
|
||||
this.hasMoreEpg.set(items.length >= this.epgPageSize);
|
||||
} catch {
|
||||
this.hasMoreEpg.set(false);
|
||||
} finally {
|
||||
this.isLoadingEpg.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadEpgForChannel(channelId: number | string) {
|
||||
this.epgChannelId = channelId;
|
||||
this.epgPageSize = 10;
|
||||
this.isLoadingEpg.set(true);
|
||||
this.epgItems.set([]);
|
||||
this.hasMoreEpg.set(false);
|
||||
try {
|
||||
const items = await this.stalkerStore.fetchChannelEpg(
|
||||
channelId,
|
||||
this.epgPageSize
|
||||
);
|
||||
this.epgItems.set(items);
|
||||
this.hasMoreEpg.set(items.length >= this.epgPageSize);
|
||||
} catch {
|
||||
this.epgItems.set([]);
|
||||
} finally {
|
||||
this.isLoadingEpg.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadEpgPreviewsForChannels(channels: any[]) {
|
||||
const newChannels = channels.filter(
|
||||
(ch) => ch.id && !this.requestedEpgChannels.has(ch.id)
|
||||
);
|
||||
if (newChannels.length === 0) return;
|
||||
|
||||
// Mark all as requested immediately to avoid duplicates
|
||||
for (const ch of newChannels) {
|
||||
this.requestedEpgChannels.add(ch.id);
|
||||
}
|
||||
|
||||
// Process in batches of 3 with a small delay between batches
|
||||
const batchSize = 3;
|
||||
for (let i = 0; i < newChannels.length; i += batchSize) {
|
||||
const batch = newChannels.slice(i, i + batchSize);
|
||||
await Promise.all(
|
||||
batch.map((ch) => this.loadSingleEpgPreview(ch.id))
|
||||
);
|
||||
// Re-enter Angular zone to trigger change detection for OnPush
|
||||
this.ngZone.run(() => this.cdr.markForCheck());
|
||||
// Small delay between batches to avoid overwhelming the portal
|
||||
if (i + batchSize < newChannels.length) {
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async loadSingleEpgPreview(channelId: number) {
|
||||
try {
|
||||
const items = await this.stalkerStore.fetchChannelEpg(
|
||||
channelId,
|
||||
1
|
||||
);
|
||||
if (items.length > 0) {
|
||||
const program = items[0];
|
||||
this.currentPrograms.set(channelId, program.title);
|
||||
|
||||
const now = Date.now() / 1000;
|
||||
const start = parseInt(program.start_timestamp, 10);
|
||||
const end = parseInt(program.stop_timestamp, 10);
|
||||
|
||||
if (start && end && now >= start && now <= end) {
|
||||
const progress =
|
||||
((now - start) / (end - start)) * 100;
|
||||
this.currentProgramsProgress.set(
|
||||
channelId,
|
||||
progress
|
||||
);
|
||||
this.programTimings.set(channelId, {
|
||||
start: start * 1000,
|
||||
end: end * 1000,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Silently skip — channel just won't show EPG preview
|
||||
}
|
||||
}
|
||||
|
||||
private clearEpgPreviewMaps() {
|
||||
this.currentPrograms.clear();
|
||||
this.currentProgramsProgress.clear();
|
||||
this.programTimings.clear();
|
||||
this.requestedEpgChannels.clear();
|
||||
}
|
||||
|
||||
private setupScrollListener() {
|
||||
this.removeScrollListener();
|
||||
|
||||
const container = this.scrollContainer()?.nativeElement;
|
||||
if (!container) return;
|
||||
|
||||
const onScroll = () => {
|
||||
if (this.isLoadingMore() || !this.hasMoreItems()) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
||||
const scrollThreshold = 150;
|
||||
|
||||
if (scrollHeight - scrollTop - clientHeight <= scrollThreshold) {
|
||||
this.loadMore();
|
||||
}
|
||||
};
|
||||
|
||||
container.addEventListener('scroll', onScroll, { passive: true });
|
||||
this.scrollListener = () =>
|
||||
container.removeEventListener('scroll', onScroll);
|
||||
}
|
||||
|
||||
private checkIfNeedsMoreContent() {
|
||||
const container = this.scrollContainer()?.nativeElement;
|
||||
if (!container) return;
|
||||
if (this.isLoadingMore() || !this.hasMoreItems()) return;
|
||||
|
||||
const { scrollHeight, clientHeight } = container;
|
||||
if (scrollHeight <= clientHeight) {
|
||||
this.loadMore();
|
||||
}
|
||||
}
|
||||
|
||||
private removeScrollListener() {
|
||||
if (this.scrollListener) {
|
||||
this.scrollListener();
|
||||
this.scrollListener = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,82 +7,34 @@
|
||||
storageKey="sidebar-width"
|
||||
>
|
||||
<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 {
|
||||
<app-playlist-switcher
|
||||
class="switcher"
|
||||
[currentTitle]="currentPlaylist()?.title || 'Portal'"
|
||||
[subtitle]="'Stalker Portal'"
|
||||
/>
|
||||
}
|
||||
<app-playlist-switcher
|
||||
class="switcher"
|
||||
[currentTitle]="currentPlaylist()?.title || 'Portal'"
|
||||
[subtitle]="'Stalker Portal'"
|
||||
/>
|
||||
</div>
|
||||
@if (isItvCategorySelected) {
|
||||
<div #itvScrollContainer style="overflow: auto; flex: 1">
|
||||
<mat-nav-list>
|
||||
@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'
|
||||
"
|
||||
alt="logo"
|
||||
/>
|
||||
{{ item.o_name || 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>
|
||||
|
||||
<!-- Loading indicator for infinite scroll -->
|
||||
@if (isLoadingMore()) {
|
||||
<div style="display: flex; justify-content: center; padding: 16px">
|
||||
<mat-spinner diameter="24" />
|
||||
</div>
|
||||
}
|
||||
</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 {
|
||||
@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)"
|
||||
/>
|
||||
}
|
||||
<app-category-view
|
||||
[items]="categories()"
|
||||
[selectedCategoryId]="$any(stalkerStore.selectedCategoryId())"
|
||||
(categoryClicked)="categoryClicked($event)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -90,15 +42,11 @@
|
||||
@if (!stalkerStore.selectedItem()) {
|
||||
<div class="content-header">
|
||||
<h2>
|
||||
@if (!isItvCategorySelected) {
|
||||
<button mat-icon-button disabled>
|
||||
<mat-icon>category</mat-icon>
|
||||
</button>
|
||||
}
|
||||
<button mat-icon-button disabled>
|
||||
<mat-icon>category</mat-icon>
|
||||
</button>
|
||||
|
||||
@if (!isItvCategorySelected) {
|
||||
{{ getContentLabel() }}
|
||||
}
|
||||
{{ getContentLabel() }}
|
||||
|
||||
@if (!contentItems() && !isContentLoading()) {
|
||||
<h2>
|
||||
@@ -115,5 +63,4 @@
|
||||
>
|
||||
<router-outlet />
|
||||
</div>
|
||||
<!-- <app-mpv-player-bar /> -->
|
||||
</div>
|
||||
@@ -1,23 +1,10 @@
|
||||
import {
|
||||
Component,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
viewChild,
|
||||
ElementRef,
|
||||
AfterViewInit,
|
||||
OnDestroy,
|
||||
} from '@angular/core';
|
||||
import { Component, effect, inject } from '@angular/core';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
import { MatPaginatorModule } from '@angular/material/paginator';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
import { PlaylistsService } from 'services';
|
||||
import { PlaylistSwitcherComponent, ResizableDirective } from 'components';
|
||||
import { CategoryViewComponent } from '../xtream-tauri/category-view/category-view.component';
|
||||
import { PlaylistErrorViewComponent } from '../xtream/playlist-error-view/playlist-error-view.component';
|
||||
@@ -35,9 +22,7 @@ import { StalkerStore } from './stalker.store';
|
||||
CategoryViewComponent,
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatListModule,
|
||||
MatPaginatorModule,
|
||||
MatProgressSpinnerModule,
|
||||
NgxSkeletonLoaderModule,
|
||||
PlaylistErrorViewComponent,
|
||||
PlaylistSwitcherComponent,
|
||||
@@ -46,7 +31,7 @@ import { StalkerStore } from './stalker.store';
|
||||
TranslatePipe,
|
||||
],
|
||||
})
|
||||
export class StalkerMainContainerComponent implements AfterViewInit, OnDestroy {
|
||||
export class StalkerMainContainerComponent {
|
||||
readonly stalkerStore = inject(StalkerStore);
|
||||
private readonly translateService = inject(TranslateService);
|
||||
|
||||
@@ -56,20 +41,6 @@ export class StalkerMainContainerComponent implements AfterViewInit, OnDestroy {
|
||||
| 'vod-details'
|
||||
| 'not-available' = 'category_content';
|
||||
|
||||
/* searchPhrase = this.portalStore.searchPhrase(); */
|
||||
|
||||
readonly pageIndex = this.stalkerStore.page;
|
||||
readonly isLoadingMore = signal(false);
|
||||
readonly favorites = new Map<number, boolean>();
|
||||
|
||||
/* load more functionality */
|
||||
readonly hasMoreItems = this.stalkerStore.hasMoreChannels;
|
||||
readonly itvChannels = this.stalkerStore.itvChannels;
|
||||
|
||||
/** Scrollable container for ITV channels */
|
||||
readonly itvScrollContainer = viewChild<ElementRef>('itvScrollContainer');
|
||||
private scrollListener: (() => void) | null = null;
|
||||
|
||||
readonly selectedCategoryTitle = this.stalkerStore.getSelectedCategoryName;
|
||||
readonly currentPlaylist = this.stalkerStore.currentPlaylist;
|
||||
|
||||
@@ -82,243 +53,27 @@ export class StalkerMainContainerComponent implements AfterViewInit, OnDestroy {
|
||||
readonly contentItems = this.stalkerStore.getPaginatedContent;
|
||||
readonly isContentLoading = this.stalkerStore.isPaginatedContentLoading;
|
||||
|
||||
/* 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 readonly activatedRoute: ActivatedRoute,
|
||||
private readonly playlistService: PlaylistsService,
|
||||
private readonly router: Router,
|
||||
private readonly snackBar: MatSnackBar
|
||||
private readonly router: Router
|
||||
) {
|
||||
// reset category title after changing content type
|
||||
effect(() => {
|
||||
this.stalkerStore.selectedContentType();
|
||||
});
|
||||
|
||||
// reset items when category changes
|
||||
effect(() => {
|
||||
this.stalkerStore.selectedCategoryId();
|
||||
this.stalkerStore.setItvChannels([]);
|
||||
this.stalkerStore.setPage(0);
|
||||
//this.hasMoreItems.set(false);
|
||||
});
|
||||
|
||||
// reset loading state when content resource changes and check if we need more
|
||||
effect(() => {
|
||||
if (this.contentItems() !== undefined) {
|
||||
this.isLoadingMore.set(false);
|
||||
// Check if we need to load more to fill the viewport
|
||||
this.checkIfNeedsMoreContent();
|
||||
}
|
||||
});
|
||||
|
||||
// Also check when ITV channels are loaded
|
||||
effect(() => {
|
||||
const channels = this.itvChannels();
|
||||
if (channels.length > 0) {
|
||||
this.isLoadingMore.set(false);
|
||||
// Small delay to allow DOM to update before checking
|
||||
setTimeout(() => this.checkIfNeedsMoreContent(), 100);
|
||||
}
|
||||
});
|
||||
|
||||
this.playlistService
|
||||
.getPortalFavorites(this.stalkerStore.currentPlaylist()?._id)
|
||||
.subscribe((favorites) => {
|
||||
favorites.forEach((fav: any) => {
|
||||
this.favorites.set(fav.id, true);
|
||||
});
|
||||
});
|
||||
|
||||
// Setup scroll listener when container becomes available
|
||||
effect(() => {
|
||||
const scrollContainer = this.itvScrollContainer();
|
||||
if (scrollContainer) {
|
||||
this.setupScrollListener();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
this.setupScrollListener();
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.removeScrollListener();
|
||||
}
|
||||
|
||||
private setupScrollListener() {
|
||||
this.removeScrollListener();
|
||||
|
||||
const container = this.itvScrollContainer()?.nativeElement;
|
||||
if (!container) return;
|
||||
|
||||
const onScroll = () => {
|
||||
if (this.isLoadingMore() || !this.hasMoreItems()) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
||||
const scrollThreshold = 150; // pixels from bottom to trigger load
|
||||
|
||||
if (scrollHeight - scrollTop - clientHeight <= scrollThreshold) {
|
||||
this.loadMore();
|
||||
}
|
||||
};
|
||||
|
||||
container.addEventListener('scroll', onScroll, { passive: true });
|
||||
this.scrollListener = () => container.removeEventListener('scroll', onScroll);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the container needs more content to become scrollable.
|
||||
* If content doesn't fill the viewport and there are more items, load more.
|
||||
*/
|
||||
private checkIfNeedsMoreContent() {
|
||||
const container = this.itvScrollContainer()?.nativeElement;
|
||||
if (!container) return;
|
||||
if (this.isLoadingMore() || !this.hasMoreItems()) return;
|
||||
|
||||
const { scrollHeight, clientHeight } = container;
|
||||
// If content doesn't fill the container (no scrollbar), load more
|
||||
if (scrollHeight <= clientHeight) {
|
||||
this.loadMore();
|
||||
}
|
||||
}
|
||||
|
||||
private removeScrollListener() {
|
||||
if (this.scrollListener) {
|
||||
this.scrollListener();
|
||||
this.scrollListener = null;
|
||||
}
|
||||
}
|
||||
|
||||
handshake() {
|
||||
/* this.sendRequest({
|
||||
action: StalkerPortalActions.Handshake,
|
||||
type: ContentType.STB,
|
||||
}); */
|
||||
}
|
||||
|
||||
categoryClicked(item: { category_name: string; category_id: string }) {
|
||||
this.stalkerStore.setSelectedCategory(Number(item.category_id));
|
||||
this.stalkerStore.setSelectedCategory(item.category_id || '*');
|
||||
this.currentLayout = 'category_content';
|
||||
this.stalkerStore.setPage(0);
|
||||
this.stalkerStore.setSelectedItem(undefined);
|
||||
if (this.stalkerStore.selectedContentType() === 'itv') return;
|
||||
|
||||
this.router.navigate(['.', item.category_id], {
|
||||
relativeTo: this.activatedRoute,
|
||||
});
|
||||
}
|
||||
|
||||
createLinkToPlayItv(item: any) {
|
||||
this.stalkerStore.setSelectedItem(item);
|
||||
this.stalkerStore.createLinkToPlayVod(item.cmd, item.o_name || 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,
|
||||
});
|
||||
} else {
|
||||
this.stalkerStore.addToFavorites({
|
||||
...item,
|
||||
category_id: 'itv',
|
||||
title: item.o_name || item.name,
|
||||
cover: item.logo,
|
||||
added_at: new Date().toISOString(),
|
||||
});
|
||||
this.favorites.set(item.id, true);
|
||||
this.snackBar.open('Added to favorites', null, {
|
||||
duration: 1000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -42,9 +42,9 @@ export const stalkerRoutes: Route[] = [
|
||||
{
|
||||
path: 'itv',
|
||||
loadComponent: () =>
|
||||
import('./stalker-main-container.component').then(
|
||||
(c) => c.StalkerMainContainerComponent
|
||||
),
|
||||
import(
|
||||
'./stalker-live-stream-layout/stalker-live-stream-layout.component'
|
||||
).then((c) => c.StalkerLiveStreamLayoutComponent),
|
||||
},
|
||||
{
|
||||
path: 'series',
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { DataService, PlaylistsService, StalkerSessionService } from 'services';
|
||||
import {
|
||||
EpgItem,
|
||||
Playlist,
|
||||
PlaylistMeta,
|
||||
STALKER_REQUEST,
|
||||
@@ -289,7 +290,8 @@ export const StalkerStore = signalStore(
|
||||
}
|
||||
// VOD uses 'genre' param, series uses 'category' param, itv uses both
|
||||
// Based on stalker-to-m3u implementation
|
||||
const categoryParam = params.category ?? '';
|
||||
// Use "*" for categories without an ID (e.g. "All") to fetch all items
|
||||
const categoryParam = params.category || '*';
|
||||
const queryParams: Record<string, string | number> = {
|
||||
action: StalkerContentTypes[params.contentType]
|
||||
.getContentAction,
|
||||
@@ -562,9 +564,12 @@ export const StalkerStore = signalStore(
|
||||
setSelectedContentType(type: 'vod' | 'itv' | 'series') {
|
||||
patchState(store, { selectedContentType: type });
|
||||
},
|
||||
setSelectedCategory(id: number) {
|
||||
setSelectedCategory(id: string | number | null) {
|
||||
patchState(store, {
|
||||
selectedCategoryId: id !== null ? String(id) : null,
|
||||
selectedCategoryId:
|
||||
id !== null && id !== undefined
|
||||
? String(id)
|
||||
: null,
|
||||
page: 0,
|
||||
});
|
||||
},
|
||||
@@ -654,6 +659,9 @@ export const StalkerStore = signalStore(
|
||||
setItvChannels(channels: any[]) {
|
||||
patchState(store, { itvChannels: channels });
|
||||
},
|
||||
setSearchPhrase(phrase: string) {
|
||||
patchState(store, { searchPhrase: phrase, page: 0 });
|
||||
},
|
||||
/**
|
||||
* Fetch episodes for a VOD series season (Ministra plugin)
|
||||
* @param videoId The video_id from the season item
|
||||
@@ -1055,6 +1063,60 @@ export const StalkerStore = signalStore(
|
||||
)
|
||||
.subscribe();
|
||||
},
|
||||
async fetchChannelEpg(
|
||||
channelId: number | string,
|
||||
size = 10
|
||||
): Promise<EpgItem[]> {
|
||||
const playlist = store.currentPlaylist() as Playlist;
|
||||
if (!playlist) return [];
|
||||
|
||||
const queryParams: Record<string, string> = {
|
||||
action: StalkerPortalActions.GetShortEpg,
|
||||
type: 'itv',
|
||||
ch_id: String(channelId),
|
||||
size: String(size),
|
||||
};
|
||||
|
||||
let response: any;
|
||||
if (playlist.isFullStalkerPortal) {
|
||||
response =
|
||||
await stalkerSession.makeAuthenticatedRequest(
|
||||
playlist,
|
||||
queryParams
|
||||
);
|
||||
} else {
|
||||
response = await dataService.sendIpcEvent(
|
||||
STALKER_REQUEST,
|
||||
{
|
||||
url: playlist.portalUrl,
|
||||
macAddress: playlist.macAddress,
|
||||
params: queryParams,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const epgData =
|
||||
response?.js?.data ?? response?.js ?? [];
|
||||
const items = Array.isArray(epgData) ? epgData : [];
|
||||
|
||||
return items.map((item) => ({
|
||||
id: String(item.id ?? ''),
|
||||
epg_id: '',
|
||||
title: item.name ?? '',
|
||||
description: item.descr ?? '',
|
||||
lang: '',
|
||||
start: item.time ?? '',
|
||||
end: item.time_to ?? '',
|
||||
stop: item.time_to ?? '',
|
||||
channel_id: String(item.ch_id ?? channelId),
|
||||
start_timestamp: String(
|
||||
item.start_timestamp ?? ''
|
||||
),
|
||||
stop_timestamp: String(
|
||||
item.stop_timestamp ?? ''
|
||||
),
|
||||
}));
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
@@ -6,4 +6,6 @@ export enum StalkerPortalActions {
|
||||
Favorites = 'favorites',
|
||||
Handshake = 'handshake',
|
||||
DoAuth = 'do_auth',
|
||||
GetShortEpg = 'get_short_epg',
|
||||
GetEpgInfo = 'get_epg_info',
|
||||
}
|
||||
Reference in new issue
Block a user