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:
4gray committed 2026-02-08 19:19:15 +01:00
1 parent edcd7cb148
commit d603c46a5a
8 files changed
+937 -338

No files matched your search

@@ -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>
@@ -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;
}
}
@@ -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';
}
+3 -3
View File
@@ -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',
+65 -3
View File
@@ -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',
}