From d603c46a5a9822a1e64b65717d4b1fee70df4a11 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 8 Feb 2026 19:19:15 +0100 Subject: [PATCH] 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. --- .../stalker-live-stream-layout.component.html | 168 ++++++++ .../stalker-live-stream-layout.component.scss | 275 ++++++++++++ .../stalker-live-stream-layout.component.ts | 390 ++++++++++++++++++ .../stalker-main-container.component.html | 113 ++--- .../stalker-main-container.component.ts | 253 +----------- apps/web/src/app/stalker/stalker.routes.ts | 6 +- apps/web/src/app/stalker/stalker.store.ts | 68 ++- .../src/lib/stalker-portal-actions.enum.ts | 2 + 8 files changed, 937 insertions(+), 338 deletions(-) create mode 100644 apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.html create mode 100644 apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.scss create mode 100644 apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts diff --git a/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.html b/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.html new file mode 100644 index 000000000..9f4828f77 --- /dev/null +++ b/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.html @@ -0,0 +1,168 @@ +@let isEmbeddedPlayer = + player() === 'videojs' || player() === 'html5' || player() === 'artplayer'; + + + +
+ @if (streamUrl && isEmbeddedPlayer) { +
+ +
+ } + @if (stalkerStore.selectedItem()) { +
+
+ + @if (isLoadingEpg()) { +
+ +
+ } + @if (hasMoreEpg() && !isLoadingEpg()) { +
+ +
+ } +
+
+ } @else { +
+ + {{ 'PORTALS.SELECT_CATEGORY' | translate }} +
+ } +
diff --git a/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.scss b/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.scss new file mode 100644 index 000000000..7e266fb3d --- /dev/null +++ b/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.scss @@ -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; + } +} diff --git a/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts new file mode 100644 index 000000000..efa0cff30 --- /dev/null +++ b/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -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([]); + 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(); + readonly currentProgramsProgress = new Map(); + readonly programTimings = new Map(); + private readonly requestedEpgChannels = new Set(); + private readonly cdr = inject(ChangeDetectorRef); + private readonly ngZone = inject(NgZone); + + /** Favorites */ + readonly favorites = new Map(); + + /** Scroll */ + readonly scrollContainer = viewChild('scrollContainer'); + private scrollListener: (() => void) | null = null; + private searchDebounceTimer: ReturnType | 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; + } + } +} diff --git a/apps/web/src/app/stalker/stalker-main-container.component.html b/apps/web/src/app/stalker/stalker-main-container.component.html index 3bfd43f62..522c8811e 100644 --- a/apps/web/src/app/stalker/stalker-main-container.component.html +++ b/apps/web/src/app/stalker/stalker-main-container.component.html @@ -7,82 +7,34 @@ storageKey="sidebar-width" > - @if (isItvCategorySelected) { -
- - @for (item of itvChannels(); track $index) { - - logo - {{ item.o_name || item.name }} - - - } - - - - @if (isLoadingMore()) { -
- -
- } -
+ @if (isCategoryLoading()) { + + } @else if (isCategoryFailed()) { + } @else { - @if (isCategoryLoading()) { - - } @else if (isCategoryFailed()) { - - } @else { - - } + } @@ -90,15 +42,11 @@ @if (!stalkerStore.selectedItem()) {

- @if (!isItvCategorySelected) { - - } + - @if (!isItvCategorySelected) { - {{ getContentLabel() }} - } + {{ getContentLabel() }} @if (!contentItems() && !isContentLoading()) {

@@ -115,5 +63,4 @@ >

- diff --git a/apps/web/src/app/stalker/stalker-main-container.component.ts b/apps/web/src/app/stalker/stalker-main-container.component.ts index 625679626..0b264f153 100644 --- a/apps/web/src/app/stalker/stalker-main-container.component.ts +++ b/apps/web/src/app/stalker/stalker-main-container.component.ts @@ -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(); - - /* load more functionality */ - readonly hasMoreItems = this.stalkerStore.hasMoreChannels; - readonly itvChannels = this.stalkerStore.itvChannels; - - /** Scrollable container for ITV channels */ - readonly itvScrollContainer = viewChild('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'; } diff --git a/apps/web/src/app/stalker/stalker.routes.ts b/apps/web/src/app/stalker/stalker.routes.ts index 20c5f2355..ea0bb9e63 100644 --- a/apps/web/src/app/stalker/stalker.routes.ts +++ b/apps/web/src/app/stalker/stalker.routes.ts @@ -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', diff --git a/apps/web/src/app/stalker/stalker.store.ts b/apps/web/src/app/stalker/stalker.store.ts index ba9f08add..897488678 100644 --- a/apps/web/src/app/stalker/stalker.store.ts +++ b/apps/web/src/app/stalker/stalker.store.ts @@ -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 = { 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 { + const playlist = store.currentPlaylist() as Playlist; + if (!playlist) return []; + + const queryParams: Record = { + 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 ?? '' + ), + })); + }, }) ) ); diff --git a/libs/shared/interfaces/src/lib/stalker-portal-actions.enum.ts b/libs/shared/interfaces/src/lib/stalker-portal-actions.enum.ts index 905c72f30..73d5a2f60 100644 --- a/libs/shared/interfaces/src/lib/stalker-portal-actions.enum.ts +++ b/libs/shared/interfaces/src/lib/stalker-portal-actions.enum.ts @@ -6,4 +6,6 @@ export enum StalkerPortalActions { Favorites = 'favorites', Handshake = 'handshake', DoAuth = 'do_auth', + GetShortEpg = 'get_short_epg', + GetEpgInfo = 'get_epg_info', }