mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
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'
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user