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 865406fd8..c1309723a 100644 --- a/apps/web/src/app/stalker/stalker-main-container.component.html +++ b/apps/web/src/app/stalker/stalker-main-container.component.html @@ -17,50 +17,41 @@ } @if (isItvCategorySelected) { - - @for (item of itvChannels(); track $index) { - - logo - {{ item.name }} - - - } - +
+ + @for (item of itvChannels(); track $index) { + + logo + {{ item.name }} + + + } + - @if (hasMoreItems() && !isContentLoading()) { - -
- -
- } + + @if (isLoadingMore()) { +
+ +
+ } +
} @else { @if (isCategoryLoading()) { ('itvScrollContainer'); + private scrollListener: (() => void) | null = null; + readonly selectedCategoryTitle = this.stalkerStore.getSelectedCategoryName; readonly currentPlaylist = this.stalkerStore.currentPlaylist; @@ -158,10 +173,22 @@ export class StalkerMainContainerComponent { //this.hasMoreItems.set(false); }); - // reset loading state when content resource changes + // 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); } }); @@ -172,6 +199,66 @@ export class StalkerMainContainerComponent { 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() { diff --git a/apps/web/src/app/xtream-tauri/season-container/season-container.component.ts b/apps/web/src/app/xtream-tauri/season-container/season-container.component.ts index 2e4ba47f4..43c2257f5 100644 --- a/apps/web/src/app/xtream-tauri/season-container/season-container.component.ts +++ b/apps/web/src/app/xtream-tauri/season-container/season-container.component.ts @@ -60,6 +60,44 @@ export class SeasonContainerComponent { return this.xtreamStore.currentPlaylist()?.id; } + /** + * Get a stable numeric ID for an episode (used for downloads tracking) + * For regular-series, episode.id contains cmd string, so we extract ID from it + * For vod-series and xtream, episode.id is already numeric + */ + private getEpisodeDownloadId(episode: XtreamSerieEpisode): number { + const customSid = (episode as any).custom_sid; + + if (customSid === 'regular-series') { + // For regular-series, episode.id is cmd like "/media/file_123.mpg" + // Extract the numeric part if possible + const cmd = episode.id as unknown as string; + const match = cmd?.match(/file_(\d+)/); + if (match) { + return Number(match[1]); + } + // Fallback: hash the cmd string to get a consistent number + return this.hashString(cmd || ''); + } + + // For vod-series and xtream, use the numeric id directly + const numId = Number(episode.id); + return isNaN(numId) ? this.hashString(String(episode.id)) : numId; + } + + /** + * Simple string hash function to generate consistent numeric IDs + */ + private hashString(str: string): number { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; // Convert to 32-bit integer + } + return Math.abs(hash); + } + @Output() episodeClicked = new EventEmitter(); @Output() seasonSelected = new EventEmitter(); @@ -259,7 +297,7 @@ export class SeasonContainerComponent { await this.downloadsService.startDownload({ playlistId: playlist._id, - xtreamId: Number(episode.id) || Date.now(), // Use timestamp if id is not numeric + xtreamId: this.getEpisodeDownloadId(episode), contentType: 'episode', title: episodeTitle, url, @@ -287,7 +325,7 @@ export class SeasonContainerComponent { // Access downloads signal to make this reactive this.downloadsService.downloads(); return this.downloadsService.isDownloaded( - Number(episode.id), + this.getEpisodeDownloadId(episode), playlistId, 'episode' ); @@ -300,7 +338,7 @@ export class SeasonContainerComponent { // Access downloads signal to make this reactive this.downloadsService.downloads(); return this.downloadsService.isDownloading( - Number(episode.id), + this.getEpisodeDownloadId(episode), playlistId, 'episode' ); @@ -314,7 +352,7 @@ export class SeasonContainerComponent { if (!playlistId) return; const filePath = this.downloadsService.getDownloadedFilePath( - Number(episode.id), + this.getEpisodeDownloadId(episode), playlistId, 'episode' );