fix: improve Stalker ITV infinite scroll and episode download tracking

Stalker ITV infinite scroll:
- Replace "Load More" button with automatic infinite scroll
- Wrap mat-nav-list in scrollable div for reliable scroll detection
- Auto-load more content when viewport isn't filled
- Show loading spinner at bottom while fetching

Episode download status tracking:
- Add getEpisodeDownloadId() for consistent ID extraction
- Extract numeric ID from cmd string for regular-series episodes
- Use hash function fallback for non-numeric IDs
- Fix download button status not updating for Stalker VOD series

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 4.5 committed 2026-01-25 10:18:28 +01:00
1 parent 7c44541721
commit c5db6ff0ea
3 files changed
+166 -50

No files matched your search

@@ -17,50 +17,41 @@
}
</div>
@if (isItvCategorySelected) {
<mat-nav-list style="overflow: auto; flex: 1">
@for (item of itvChannels(); track $index) {
<mat-list-item (click)="createLinkToPlayItv(item)">
<img
matListItemAvatar
class="stream-icon"
[src]="item.logo"
(error)="
$event.target.src =
'./assets/images/default-poster.png'
"
alt="logo"
/>
{{ 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>
<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.name }}
<button
mat-icon-button
matListItemMeta
(click)="toggleFavorite(item); $event.stopPropagation()"
>
<mat-icon>{{
favorites.get(item.id) ? 'star' : 'star_outline'
}}</mat-icon>
</button>
</mat-list-item>
}
</mat-nav-list>
@if (hasMoreItems() && !isContentLoading()) {
<mat-divider />
<div>
<button
mat-button
(click)="loadMore()"
[disabled]="isLoadingMore()"
style="width: 100%"
>
@if (isLoadingMore()) {
Loading page {{ pageIndex() + 1 }}...
} @else {
Load More (Page {{ pageIndex() + 1 }})
}
</button>
</div>
}
<!-- Loading indicator for infinite scroll -->
@if (isLoadingMore()) {
<div style="display: flex; justify-content: center; padding: 16px">
<mat-spinner diameter="24" />
</div>
}
</div>
} @else {
@if (isCategoryLoading()) {
<ngx-skeleton-loader
@@ -1,8 +1,18 @@
import { Component, effect, inject, signal } from '@angular/core';
import {
Component,
effect,
inject,
signal,
viewChild,
ElementRef,
AfterViewInit,
OnDestroy,
} from '@angular/core';
import { MatButton, MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { MatPaginatorModule } from '@angular/material/paginator';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
@@ -28,6 +38,7 @@ import { StalkerStore } from './stalker.store';
MatIconButton,
MatListModule,
MatPaginatorModule,
MatProgressSpinnerModule,
NgxSkeletonLoaderModule,
PlaylistErrorViewComponent,
PlaylistSwitcherComponent,
@@ -35,7 +46,7 @@ import { StalkerStore } from './stalker.store';
TranslatePipe,
],
})
export class StalkerMainContainerComponent {
export class StalkerMainContainerComponent implements AfterViewInit, OnDestroy {
readonly stalkerStore = inject(StalkerStore);
currentLayout:
@@ -54,6 +65,10 @@ export class StalkerMainContainerComponent {
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;
@@ -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() {
@@ -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<any>();
@Output() seasonSelected = new EventEmitter<string>();
@@ -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'
);