mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 04:26:16 -08:00
feat(xtream): add live all-items grid
This commit is contained in:
1 parent
bfa865c55c
commit
2edc430935
11 files changed
+628
-219
No files matched your search
@@ -160,12 +160,15 @@ The shared row should be reused instead of rebuilding channel markup per view.
|
||||
|
||||
- M3U, Xtream, and Stalker live layouts share a single sidebar collapse toggle
|
||||
that hides the channels rail to give the player and EPG full width.
|
||||
- Xtream Live TV's root view (`/live` with no selected category) shows the
|
||||
newest visible-category live channels when provider timestamps are available.
|
||||
Selecting a channel from that root surface starts playback, selects the
|
||||
channel's category, highlights the active category and channel, and scrolls
|
||||
the category rail plus virtual channels list to the selected rows when those
|
||||
rails are visible.
|
||||
- Xtream Live TV's root view (`/live` with no selected category) follows the
|
||||
same paginated `All Items` shell as VOD and Series: a widget header with the
|
||||
total channel count, page-size controls, and page navigation above the shared
|
||||
`app-grid-list`. Use the grid list's logo-oriented live variant so channel
|
||||
logos stay contained in 16:9 thumbnails instead of being cropped like
|
||||
VOD/series posters. Selecting a channel from that root grid starts playback,
|
||||
selects the channel's category, highlights the active category and channel,
|
||||
and scrolls the category rail plus virtual channels list to the selected rows
|
||||
when those rails are visible.
|
||||
- In Xtream and Stalker live TV, the same toggle also collapses the workspace
|
||||
shell context sidebar (the "Live Categories" rail rendered by
|
||||
`WorkspaceShellContextSidebarComponent`), matching M3U's "everything quiets"
|
||||
|
||||
@@ -36,6 +36,7 @@ mat-card:hover {
|
||||
.card-thumbnail-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 2 / 3; // maintain poster ratio
|
||||
@@ -55,22 +56,37 @@ mat-card:hover {
|
||||
.stream-icon-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 200px;
|
||||
// Use graphite surface token, not hardcoded grey
|
||||
background: var(--mat-sys-surface-container-high);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
|
||||
border-radius: 10px;
|
||||
|
||||
&::after {
|
||||
content: 'No cover';
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.03em;
|
||||
mat-icon {
|
||||
width: clamp(28px, 20%, 42px);
|
||||
height: clamp(28px, 20%, 42px);
|
||||
font-size: clamp(28px, 20%, 42px);
|
||||
opacity: 0.46;
|
||||
}
|
||||
}
|
||||
|
||||
&.grid-card--logo {
|
||||
.card-thumbnail-container {
|
||||
aspect-ratio: 16 / 9;
|
||||
background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.04),
|
||||
rgba(255, 255, 255, 0)
|
||||
),
|
||||
var(--mat-sys-surface-container-high);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.stream-icon {
|
||||
object-fit: contain;
|
||||
padding: 18px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +106,35 @@ mat-card:hover {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Type badge ──────────────────────────────────────────────────────────────
|
||||
.type-badge {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
z-index: 1;
|
||||
padding: 3px 6px;
|
||||
border-radius: 5px;
|
||||
font-size: 0.58rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
color: #fff;
|
||||
|
||||
&.live {
|
||||
color: #ef5350;
|
||||
}
|
||||
|
||||
&.movie {
|
||||
color: #42a5f5;
|
||||
}
|
||||
|
||||
&.series {
|
||||
color: #66bb6a;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Rating badge ─────────────────────────────────────────────────────────────
|
||||
.rating {
|
||||
position: absolute;
|
||||
@@ -136,7 +181,6 @@ mat-card:hover {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.grid-empty-state {
|
||||
min-height: clamp(260px, 48vh, 460px);
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { MockPipe } from 'ng-mocks';
|
||||
import {
|
||||
formatGridRating,
|
||||
GridListComponent,
|
||||
resolveGridRating,
|
||||
} from './grid-list.component';
|
||||
|
||||
@@ -28,3 +33,130 @@ describe('grid list rating helpers', () => {
|
||||
).toBe('5.7');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GridListComponent', () => {
|
||||
let fixture: ComponentFixture<GridListComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [GridListComponent],
|
||||
})
|
||||
.overrideComponent(GridListComponent, {
|
||||
remove: { imports: [TranslatePipe] },
|
||||
add: {
|
||||
imports: [
|
||||
MockPipe(
|
||||
TranslatePipe,
|
||||
(value: string | null | undefined) => value ?? ''
|
||||
),
|
||||
],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(GridListComponent);
|
||||
});
|
||||
|
||||
it('renders live logo cards with stream icons and a live badge', () => {
|
||||
fixture.componentRef.setInput('items', [
|
||||
{
|
||||
name: 'Live Channel',
|
||||
stream_icon: 'channel-logo.png',
|
||||
},
|
||||
]);
|
||||
fixture.componentRef.setInput('variant', 'logo');
|
||||
fixture.componentRef.setInput('type', 'live');
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
const card = fixture.debugElement.query(By.css('mat-card'));
|
||||
const image = fixture.debugElement.query(By.css('.stream-icon'));
|
||||
const badge = fixture.debugElement.query(By.css('.type-badge'));
|
||||
|
||||
expect(card.nativeElement.classList).toContain('grid-card--logo');
|
||||
expect(image.nativeElement.getAttribute('src')).toBe(
|
||||
'channel-logo.png'
|
||||
);
|
||||
expect(badge.nativeElement.classList).toContain('live');
|
||||
expect(badge.nativeElement.textContent.trim()).toBe('live');
|
||||
});
|
||||
|
||||
it('renders the live placeholder for logo cards without artwork', () => {
|
||||
fixture.componentRef.setInput('items', [
|
||||
{
|
||||
name: 'Live Channel Without Logo',
|
||||
},
|
||||
]);
|
||||
fixture.componentRef.setInput('variant', 'logo');
|
||||
fixture.componentRef.setInput('type', 'live');
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
const image = fixture.debugElement.query(By.css('.stream-icon'));
|
||||
const placeholder = fixture.debugElement.query(
|
||||
By.css('.stream-icon-placeholder')
|
||||
);
|
||||
const placeholderIcon = fixture.debugElement.query(
|
||||
By.css('.stream-icon-placeholder mat-icon')
|
||||
);
|
||||
|
||||
expect(image).toBeNull();
|
||||
expect(placeholder).not.toBeNull();
|
||||
expect(placeholderIcon.nativeElement.textContent.trim()).toBe(
|
||||
'live_tv'
|
||||
);
|
||||
});
|
||||
|
||||
it('treats Xtream blank icon URLs as missing live artwork', () => {
|
||||
fixture.componentRef.setInput('items', [
|
||||
{
|
||||
name: 'Live Channel With Blank Icon',
|
||||
stream_icon: 'http://example.test/cs/etc/blank-icon.png',
|
||||
},
|
||||
]);
|
||||
fixture.componentRef.setInput('variant', 'logo');
|
||||
fixture.componentRef.setInput('type', 'live');
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
const image = fixture.debugElement.query(By.css('.stream-icon'));
|
||||
const placeholderIcon = fixture.debugElement.query(
|
||||
By.css('.stream-icon-placeholder mat-icon')
|
||||
);
|
||||
|
||||
expect(image).toBeNull();
|
||||
expect(placeholderIcon.nativeElement.textContent.trim()).toBe(
|
||||
'live_tv'
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the live placeholder when logo artwork fails to load', () => {
|
||||
fixture.componentRef.setInput('items', [
|
||||
{
|
||||
name: 'Live Channel',
|
||||
stream_icon: 'broken-channel-logo.png',
|
||||
},
|
||||
]);
|
||||
fixture.componentRef.setInput('variant', 'logo');
|
||||
fixture.componentRef.setInput('type', 'live');
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
const image = fixture.debugElement.query(By.css('.stream-icon'));
|
||||
|
||||
image.nativeElement.dispatchEvent(new Event('error'));
|
||||
fixture.detectChanges();
|
||||
|
||||
const imageAfterError = fixture.debugElement.query(
|
||||
By.css('.stream-icon')
|
||||
);
|
||||
const placeholderIcon = fixture.debugElement.query(
|
||||
By.css('.stream-icon-placeholder mat-icon')
|
||||
);
|
||||
|
||||
expect(imageAfterError).toBeNull();
|
||||
expect(placeholderIcon.nativeElement.textContent.trim()).toBe(
|
||||
'live_tv'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
@@ -25,6 +26,7 @@ interface GridListItem {
|
||||
category_id?: number | string;
|
||||
poster_url?: string;
|
||||
cover?: string;
|
||||
stream_icon?: string;
|
||||
title?: string;
|
||||
o_name?: string;
|
||||
name?: string;
|
||||
@@ -60,6 +62,19 @@ export function resolveGridRating(
|
||||
return formatGridRating(item.rating_imdb) ?? formatGridRating(item.rating);
|
||||
}
|
||||
|
||||
const BLANK_ARTWORK_URL_PATTERN =
|
||||
/(^|\/)blank-icon\.(?:png|jpe?g|webp|gif|svg)(?:[?#].*)?$/i;
|
||||
|
||||
function normalizeArtworkUrl(value: string | undefined): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
|
||||
if (!trimmed || BLANK_ARTWORK_URL_PATTERN.test(trimmed)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-grid-list',
|
||||
template: `<div class="grid-list__grid">
|
||||
@@ -82,22 +97,49 @@ export function resolveGridRating(
|
||||
} @else {
|
||||
@for (item of items(); track $index) {
|
||||
@let i = $any(item);
|
||||
<mat-card (click)="itemClicked.emit(item)">
|
||||
@let poster = i.poster_url ?? i.cover;
|
||||
<mat-card
|
||||
[class.grid-card--logo]="variant() === 'logo'"
|
||||
(click)="itemClicked.emit(item)"
|
||||
>
|
||||
@let poster = resolvePoster(i);
|
||||
<div class="card-thumbnail-container">
|
||||
<img
|
||||
class="stream-icon"
|
||||
[src]="
|
||||
poster ||
|
||||
'./assets/images/default-poster.png'
|
||||
"
|
||||
(error)="
|
||||
$event.target.src =
|
||||
'./assets/images/default-poster.png'
|
||||
"
|
||||
loading="lazy"
|
||||
alt="logo"
|
||||
/>
|
||||
@if (type()) {
|
||||
<div
|
||||
class="type-badge"
|
||||
[class.live]="type() === 'live'"
|
||||
[class.movie]="type() === 'vod'"
|
||||
[class.series]="type() === 'series'"
|
||||
>
|
||||
{{ type() }}
|
||||
</div>
|
||||
}
|
||||
@if (poster && !hasArtworkFailed(poster)) {
|
||||
<img
|
||||
class="stream-icon"
|
||||
[src]="poster"
|
||||
(error)="onImageError($event, poster)"
|
||||
loading="lazy"
|
||||
alt="logo"
|
||||
/>
|
||||
} @else if (
|
||||
shouldRenderArtworkPlaceholder(poster)
|
||||
) {
|
||||
<div
|
||||
class="stream-icon-placeholder"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<mat-icon>{{
|
||||
getPlaceholderIcon()
|
||||
}}</mat-icon>
|
||||
</div>
|
||||
} @else {
|
||||
<img
|
||||
class="stream-icon"
|
||||
src="./assets/images/default-poster.png"
|
||||
loading="lazy"
|
||||
alt="logo"
|
||||
/>
|
||||
}
|
||||
@if (i.progress && i.progress > 0) {
|
||||
<app-progress-capsule [progress]="i.progress" />
|
||||
}
|
||||
@@ -186,6 +228,8 @@ export function resolveGridRating(
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class GridListComponent {
|
||||
private readonly failedArtworkUrls = signal<ReadonlySet<string>>(new Set());
|
||||
|
||||
readonly items = input<GridListItem[]>([]);
|
||||
readonly isLoading = input<boolean>(false);
|
||||
readonly showPaginator = input(true);
|
||||
@@ -197,7 +241,15 @@ export class GridListComponent {
|
||||
readonly totalPages = input<number>(0);
|
||||
readonly limit = input<number>(25);
|
||||
readonly pageSizeOptions = input<number[]>([]);
|
||||
readonly variant = input<'poster' | 'logo'>('poster');
|
||||
readonly type = input<'vod' | 'series' | 'live' | string>('');
|
||||
protected readonly resolveRating = resolveGridRating;
|
||||
protected readonly resolvePoster = (
|
||||
item: GridListItem
|
||||
): string | undefined =>
|
||||
normalizeArtworkUrl(item.poster_url) ??
|
||||
normalizeArtworkUrl(item.cover) ??
|
||||
normalizeArtworkUrl(item.stream_icon);
|
||||
protected readonly hasActiveSearch = computed(
|
||||
() => (this.searchTerm() ?? '').trim().length > 0
|
||||
);
|
||||
@@ -207,4 +259,51 @@ export class GridListComponent {
|
||||
const count = Math.max(8, Math.min(18, preferredCount));
|
||||
return Array.from({ length: count }, (_, index) => index);
|
||||
});
|
||||
|
||||
protected hasArtworkFailed(poster: string): boolean {
|
||||
return this.failedArtworkUrls().has(poster);
|
||||
}
|
||||
|
||||
protected shouldRenderArtworkPlaceholder(
|
||||
poster: string | undefined
|
||||
): boolean {
|
||||
return (
|
||||
this.usesArtworkPlaceholder() &&
|
||||
(!poster || this.hasArtworkFailed(poster))
|
||||
);
|
||||
}
|
||||
|
||||
protected getPlaceholderIcon(): string {
|
||||
switch (this.type()) {
|
||||
case 'live':
|
||||
return 'live_tv';
|
||||
case 'series':
|
||||
return 'tv';
|
||||
default:
|
||||
return 'movie';
|
||||
}
|
||||
}
|
||||
|
||||
protected onImageError(event: Event, poster: string): void {
|
||||
if (this.usesArtworkPlaceholder()) {
|
||||
this.failedArtworkUrls.update((failedUrls) => {
|
||||
const nextFailedUrls = new Set(failedUrls);
|
||||
nextFailedUrls.add(poster);
|
||||
|
||||
return nextFailedUrls;
|
||||
});
|
||||
(event.target as HTMLImageElement | null)?.style.setProperty(
|
||||
'display',
|
||||
'none'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
(event.target as HTMLImageElement).src =
|
||||
'./assets/images/default-poster.png';
|
||||
}
|
||||
|
||||
private usesArtworkPlaceholder(): boolean {
|
||||
return this.variant() === 'logo' || this.type() === 'live';
|
||||
}
|
||||
}
|
||||
+34
-21
@@ -151,28 +151,41 @@
|
||||
(playbackRequested)="playLive($event, true)"
|
||||
/>
|
||||
</div>
|
||||
} @else if (recentlyAddedLiveItems().length > 0) {
|
||||
<section class="live-initial-content">
|
||||
<header class="live-initial-content__header">
|
||||
<h2 class="live-initial-content__title">
|
||||
{{ 'PORTALS.SIDEBAR.RECENTLY_ADDED' | translate }}
|
||||
</h2>
|
||||
<p class="live-initial-content__subtitle">
|
||||
{{ 'PORTALS.SIDEBAR.LIVE_TV' | translate }}
|
||||
</p>
|
||||
</header>
|
||||
<app-portal-channels-list
|
||||
[sortMode]="'server'"
|
||||
[channelsOverride]="recentlyAddedLiveItems()"
|
||||
(playClicked)="playLive($event)"
|
||||
(playbackRequested)="playLive($event, true)"
|
||||
/>
|
||||
</section>
|
||||
} @else {
|
||||
<app-portal-empty-state
|
||||
icon="live_tv"
|
||||
[message]="'PORTALS.SELECT_CATEGORY' | translate"
|
||||
/>
|
||||
<div class="live-all-items-content category-content-layout">
|
||||
<div class="category-content-header">
|
||||
<div class="category-meta">
|
||||
<h2 class="category-title">All Items</h2>
|
||||
<div class="category-subtitle">
|
||||
{{ liveRootSubtitle() }}
|
||||
</div>
|
||||
</div>
|
||||
@if (showLiveRootPaginator()) {
|
||||
<mat-paginator
|
||||
[pageIndex]="liveRootPageIndex()"
|
||||
[length]="liveRootItemCount()"
|
||||
[pageSize]="liveRootLimit()"
|
||||
[pageSizeOptions]="liveRootPageSizeOptions"
|
||||
(page)="onLiveRootPageChange($event)"
|
||||
aria-label="Select page"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
<app-grid-list
|
||||
class="live-all-items-grid app-scrollbar"
|
||||
[isLoading]="isSelectedTypeContentLoading()"
|
||||
[items]="liveRootItems()"
|
||||
[pageIndex]="liveRootPageIndex()"
|
||||
[totalPages]="liveRootTotalPages()"
|
||||
[limit]="liveRootLimit()"
|
||||
[pageSizeOptions]="liveRootPageSizeOptions"
|
||||
[showPaginator]="false"
|
||||
[searchTerm]="workspaceSearchTerm()"
|
||||
[variant]="'logo'"
|
||||
[type]="'live'"
|
||||
(itemClicked)="onLiveRootItemClick($event)"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<app-portal-empty-state
|
||||
|
||||
+34
-27
@@ -1,4 +1,5 @@
|
||||
@use '@angular/material' as mat;
|
||||
@use '../../../../../../ui/styles/panel-header' as panel;
|
||||
@use '../../../../../../ui/styles/portal-layout' as portal;
|
||||
@use '../../../../../../ui/styles/portal-sidebar';
|
||||
|
||||
@@ -30,47 +31,53 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.live-initial-content {
|
||||
align-self: center;
|
||||
box-sizing: border-box;
|
||||
.live-all-items-content {
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
max-width: 640px;
|
||||
padding: 18px;
|
||||
width: min(640px, 100%);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.live-initial-content__header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 0 4px;
|
||||
.category-content-header {
|
||||
@include panel.standard-panel-header($sticky: true);
|
||||
}
|
||||
|
||||
.live-initial-content__title {
|
||||
color: var(--mat-sys-on-surface);
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
margin: 0;
|
||||
.category-meta {
|
||||
@include panel.standard-panel-meta();
|
||||
}
|
||||
|
||||
.live-initial-content__subtitle {
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.35;
|
||||
margin: 0;
|
||||
.category-title {
|
||||
@include panel.standard-panel-title();
|
||||
}
|
||||
|
||||
.live-initial-content app-portal-channels-list {
|
||||
border: 1px solid var(--app-separator);
|
||||
border-radius: 8px;
|
||||
.category-subtitle {
|
||||
@include panel.standard-panel-subtitle();
|
||||
}
|
||||
|
||||
mat-paginator {
|
||||
background: transparent !important;
|
||||
flex-shrink: 0;
|
||||
--mat-paginator-container-size: 40px;
|
||||
--mat-paginator-enabled-icon-color: var(
|
||||
--app-body-color,
|
||||
var(--mat-sys-on-surface-variant)
|
||||
);
|
||||
--mat-paginator-disabled-icon-color: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
app-grid-list.live-all-items-grid {
|
||||
--cover-grid-min-width: var(--live-channel-grid-min-width, 148px);
|
||||
|
||||
display: block;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px 20px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.08) transparent;
|
||||
}
|
||||
|
||||
.loading-indicator {
|
||||
|
||||
+148
-53
@@ -31,9 +31,11 @@ import {
|
||||
LiveEpgPanelSummary,
|
||||
} from '@iptvnator/ui/shared-portals';
|
||||
import { EpgItem, EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import { GridListComponent } from '@iptvnator/portal/shared/ui';
|
||||
import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component';
|
||||
import { LiveStreamLayoutComponent } from './live-stream-layout.component';
|
||||
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
|
||||
import { PageEvent } from '@angular/material/paginator';
|
||||
|
||||
const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode';
|
||||
|
||||
@@ -50,6 +52,26 @@ class StubPortalChannelsListComponent {
|
||||
readonly playbackRequested = output<unknown>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-grid-list',
|
||||
standalone: true,
|
||||
template: '<div data-test-id="grid-list-stub"></div>',
|
||||
})
|
||||
class StubGridListComponent {
|
||||
readonly items = input<unknown[]>([]);
|
||||
readonly isLoading = input(false);
|
||||
readonly showPaginator = input(true);
|
||||
readonly searchTerm = input('');
|
||||
readonly pageIndex = input(0);
|
||||
readonly totalPages = input(0);
|
||||
readonly limit = input(25);
|
||||
readonly pageSizeOptions = input<number[]>([]);
|
||||
readonly variant = input<'poster' | 'logo'>('poster');
|
||||
readonly type = input<string>();
|
||||
readonly itemClicked = output<unknown>();
|
||||
readonly pageChange = output<PageEvent>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-web-player-view',
|
||||
standalone: true,
|
||||
@@ -143,10 +165,16 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
const selectedItem = signal<unknown>(sampleChannel);
|
||||
const currentPlaylist = signal(playlist);
|
||||
const liveStreams = signal<unknown[]>([]);
|
||||
const paginatedContent = signal<unknown[]>([]);
|
||||
const totalPages = signal(0);
|
||||
const page = signal(0);
|
||||
const limit = signal(25);
|
||||
|
||||
const xtreamStore = {
|
||||
getCategoriesBySelectedType: categories,
|
||||
getCategoryItemCounts: categoryItemCounts,
|
||||
getPaginatedContent: paginatedContent,
|
||||
getTotalPages: totalPages,
|
||||
epgItems,
|
||||
currentEpgItem,
|
||||
isLoadingEpg,
|
||||
@@ -156,14 +184,19 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
selectedItem,
|
||||
currentPlaylist,
|
||||
liveStreams,
|
||||
page,
|
||||
limit,
|
||||
selectItemsFromSelectedCategory: jest.fn(() => [sampleChannel]),
|
||||
constructStreamUrl: jest.fn(() => 'https://example.com/live.ts'),
|
||||
openPlayer: jest.fn(),
|
||||
setSelectedItem: jest.fn(),
|
||||
setSelectedCategory: jest.fn(),
|
||||
setPage: jest.fn((nextPage: number) => page.set(nextPage)),
|
||||
setLimit: jest.fn((nextLimit: number) => limit.set(nextLimit)),
|
||||
};
|
||||
|
||||
let routerEvents: Subject<unknown>;
|
||||
let router: { events: Subject<unknown>; navigate: jest.Mock };
|
||||
const favoritesService = {
|
||||
getFavorites: jest.fn().mockReturnValue(of([])),
|
||||
};
|
||||
@@ -196,14 +229,21 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
} as typeof window.electron;
|
||||
|
||||
routerEvents = new Subject();
|
||||
router = { events: routerEvents, navigate: jest.fn() };
|
||||
xtreamStore.constructStreamUrl.mockClear();
|
||||
xtreamStore.openPlayer.mockClear();
|
||||
xtreamStore.setSelectedItem.mockClear();
|
||||
xtreamStore.setSelectedCategory.mockClear();
|
||||
xtreamStore.setPage.mockClear();
|
||||
xtreamStore.setLimit.mockClear();
|
||||
xtreamStore.selectItemsFromSelectedCategory.mockReturnValue([
|
||||
sampleChannel,
|
||||
]);
|
||||
liveStreams.set([]);
|
||||
paginatedContent.set([]);
|
||||
totalPages.set(0);
|
||||
page.set(0);
|
||||
limit.set(25);
|
||||
favoritesService.getFavorites.mockClear();
|
||||
xtreamUrlService.resolveCatchupUrl.mockClear();
|
||||
portalPlayer.isEmbeddedPlayer.mockReset();
|
||||
@@ -243,7 +283,10 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
},
|
||||
{
|
||||
provide: Router,
|
||||
useValue: { events: routerEvents.asObservable() },
|
||||
useValue: {
|
||||
events: router.events.asObservable(),
|
||||
navigate: router.navigate,
|
||||
},
|
||||
},
|
||||
{ provide: XtreamStore, useValue: xtreamStore },
|
||||
{ provide: FavoritesService, useValue: favoritesService },
|
||||
@@ -276,6 +319,7 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
EpgListComponent,
|
||||
EpgViewComponent,
|
||||
LiveEpgPanelComponent,
|
||||
GridListComponent,
|
||||
PortalChannelsListComponent,
|
||||
ResizableDirective,
|
||||
TranslatePipe,
|
||||
@@ -287,6 +331,7 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
StubEpgListComponent,
|
||||
StubEpgViewComponent,
|
||||
StubLiveEpgPanelComponent,
|
||||
StubGridListComponent,
|
||||
StubPortalChannelsListComponent,
|
||||
StubResizableDirective,
|
||||
MockPipe(
|
||||
@@ -419,101 +464,151 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('shows recently added live channels on the live root before a category is selected', () => {
|
||||
const newestChannel = {
|
||||
it('shows live all-items content with the shared grid and header paginator before a category is selected', () => {
|
||||
const firstChannel = {
|
||||
xtream_id: 301,
|
||||
name: 'Newest Channel',
|
||||
name: 'First Channel',
|
||||
category_id: '7',
|
||||
added: String(
|
||||
Math.floor(Date.parse('2026-04-04T12:00:00Z') / 1000)
|
||||
),
|
||||
};
|
||||
const olderChannel = {
|
||||
const secondChannel = {
|
||||
xtream_id: 302,
|
||||
name: 'Older Channel',
|
||||
name: 'Second Channel',
|
||||
category_id: '8',
|
||||
added: String(
|
||||
Math.floor(Date.parse('2026-04-03T12:00:00Z') / 1000)
|
||||
),
|
||||
};
|
||||
const thirdChannel = {
|
||||
xtream_id: 303,
|
||||
name: 'Third Channel',
|
||||
category_id: '9',
|
||||
added: String(
|
||||
Math.floor(Date.parse('2026-04-02T12:00:00Z') / 1000)
|
||||
),
|
||||
};
|
||||
selectedCategoryId.set(null);
|
||||
selectedTypeContentLoading.set(false);
|
||||
categories.set([
|
||||
{ category_id: 7, category_name: 'News' },
|
||||
{ category_id: 8, category_name: 'Sports' },
|
||||
limit.set(25);
|
||||
page.set(0);
|
||||
totalPages.set(2);
|
||||
paginatedContent.set([firstChannel, secondChannel]);
|
||||
xtreamStore.selectItemsFromSelectedCategory.mockReturnValue([
|
||||
firstChannel,
|
||||
secondChannel,
|
||||
thirdChannel,
|
||||
]);
|
||||
liveStreams.set([olderChannel, newestChannel]);
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
const list = fixture.debugElement.query(
|
||||
By.directive(StubPortalChannelsListComponent)
|
||||
const grid = fixture.debugElement.query(
|
||||
By.directive(StubGridListComponent)
|
||||
);
|
||||
|
||||
expect(list).not.toBeNull();
|
||||
expect(grid).not.toBeNull();
|
||||
expect(grid.componentInstance.items()).toEqual([
|
||||
firstChannel,
|
||||
secondChannel,
|
||||
]);
|
||||
expect(grid.componentInstance.variant()).toBe('logo');
|
||||
expect(grid.componentInstance.type()).toBe('live');
|
||||
expect(grid.componentInstance.showPaginator()).toBe(false);
|
||||
expect(grid.componentInstance.pageIndex()).toBe(0);
|
||||
expect(grid.componentInstance.totalPages()).toBe(2);
|
||||
expect(grid.componentInstance.limit()).toBe(25);
|
||||
expect(grid.componentInstance.pageSizeOptions()).toEqual([
|
||||
10, 25, 50, 100,
|
||||
]);
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.category-title').textContent
|
||||
).toContain('All Items');
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.category-subtitle')
|
||||
.textContent
|
||||
).toContain('3 channels');
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('mat-paginator')
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
fixture.debugElement.query(
|
||||
By.directive(StubPortalChannelsListComponent)
|
||||
)
|
||||
).toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-portal-empty-state')
|
||||
).toBeNull();
|
||||
expect(list.componentInstance.channelsOverride()).toEqual([
|
||||
newestChannel,
|
||||
olderChannel,
|
||||
]);
|
||||
});
|
||||
|
||||
it('excludes live root recently added channels from hidden categories', () => {
|
||||
const visibleChannel = {
|
||||
it('plays a live all-items grid card and selects its category', () => {
|
||||
const channel = {
|
||||
xtream_id: 301,
|
||||
name: 'Visible Channel',
|
||||
category_id: '1',
|
||||
name: 'Grid Channel',
|
||||
category_id: '7',
|
||||
added: String(
|
||||
Math.floor(Date.parse('2026-04-04T12:00:00Z') / 1000)
|
||||
),
|
||||
};
|
||||
const hiddenChannel = {
|
||||
xtream_id: 302,
|
||||
name: 'Hidden Channel',
|
||||
category_id: '2',
|
||||
added: String(
|
||||
Math.floor(Date.parse('2026-04-05T10:00:00Z') / 1000)
|
||||
),
|
||||
};
|
||||
selectedCategoryId.set(null);
|
||||
selectedTypeContentLoading.set(false);
|
||||
categories.set([{ category_id: 1, category_name: 'News' }]);
|
||||
liveStreams.set([hiddenChannel, visibleChannel]);
|
||||
paginatedContent.set([channel]);
|
||||
xtreamStore.selectItemsFromSelectedCategory.mockReturnValue([channel]);
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
const list = fixture.debugElement.query(
|
||||
By.directive(StubPortalChannelsListComponent)
|
||||
const grid = fixture.debugElement.query(
|
||||
By.directive(StubGridListComponent)
|
||||
);
|
||||
grid.componentInstance.itemClicked.emit(channel);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(list).not.toBeNull();
|
||||
expect(list.componentInstance.channelsOverride()).toEqual([
|
||||
visibleChannel,
|
||||
]);
|
||||
expect(xtreamStore.constructStreamUrl).toHaveBeenCalledWith(channel);
|
||||
expect(xtreamStore.setSelectedCategory).toHaveBeenCalledWith(7);
|
||||
expect(
|
||||
fixture.debugElement.query(By.directive(StubWebPlayerViewComponent))
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('refreshes live root recently added channels as the clock advances', () => {
|
||||
const futureChannel = {
|
||||
xtream_id: 301,
|
||||
name: 'Future Channel',
|
||||
category_id: '1',
|
||||
added: String(
|
||||
Math.floor((fixedNow.getTime() + 25 * 60 * 60 * 1000) / 1000)
|
||||
),
|
||||
};
|
||||
it('updates live root pagination from the header paginator', () => {
|
||||
selectedCategoryId.set(null);
|
||||
selectedTypeContentLoading.set(false);
|
||||
liveStreams.set([futureChannel]);
|
||||
xtreamStore.selectItemsFromSelectedCategory.mockReturnValue(
|
||||
Array.from({ length: 80 }, (_, index) => ({
|
||||
xtream_id: index + 1,
|
||||
name: `Channel ${index + 1}`,
|
||||
}))
|
||||
);
|
||||
paginatedContent.set([]);
|
||||
|
||||
expect(component.recentlyAddedLiveItems()).toEqual([]);
|
||||
|
||||
jest.setSystemTime(new Date(fixedNow.getTime() + 2 * 60 * 60 * 1000));
|
||||
jest.advanceTimersByTime(30_000);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.recentlyAddedLiveItems()).toEqual([futureChannel]);
|
||||
const paginator = fixture.debugElement.query(By.css('mat-paginator'));
|
||||
expect(paginator).not.toBeNull();
|
||||
paginator.triggerEventHandler('page', {
|
||||
pageIndex: 1,
|
||||
pageSize: 50,
|
||||
length: 80,
|
||||
previousPageIndex: 0,
|
||||
} satisfies PageEvent);
|
||||
|
||||
expect(xtreamStore.setPage).toHaveBeenCalledWith(1);
|
||||
expect(xtreamStore.setLimit).toHaveBeenCalledWith(50);
|
||||
expect(router.navigate).toHaveBeenCalledWith([], {
|
||||
relativeTo: expect.any(Object),
|
||||
queryParams: { page: 2 },
|
||||
queryParamsHandling: 'merge',
|
||||
replaceUrl: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('applies the live root page query parameter', () => {
|
||||
selectedCategoryId.set(null);
|
||||
selectedTypeContentLoading.set(false);
|
||||
|
||||
routeQueryParamMap.next(convertToParamMap({ page: '3' }));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(xtreamStore.setPage).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it('shows the cross-category live channel list while searching from the live root', () => {
|
||||
|
||||
+92
-11
@@ -3,6 +3,7 @@ import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
DestroyRef,
|
||||
ElementRef,
|
||||
HostListener,
|
||||
computed,
|
||||
effect,
|
||||
@@ -16,10 +17,14 @@ import { filter } from 'rxjs';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { ResizableDirective } from '@iptvnator/ui/components';
|
||||
import { PortalEmptyStateComponent } from '@iptvnator/portal/shared/ui';
|
||||
import {
|
||||
GridListComponent,
|
||||
PortalEmptyStateComponent,
|
||||
} from '@iptvnator/portal/shared/ui';
|
||||
import {
|
||||
LiveLayoutSidebarStateService,
|
||||
PORTAL_PLAYER,
|
||||
@@ -68,13 +73,22 @@ import { PortalChannelsListComponent } from '../portal-channels-list/portal-chan
|
||||
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
|
||||
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
|
||||
import { LiveStreamAutoOpenStateService } from './live-stream-auto-open-state.service';
|
||||
import {
|
||||
getRecentlyAddedLiveItems,
|
||||
XtreamLiveChannelItem,
|
||||
} from './live-stream-recently-added.utils';
|
||||
|
||||
const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode';
|
||||
|
||||
interface XtreamLiveChannelItem {
|
||||
readonly added?: string;
|
||||
readonly category_id?: string | number;
|
||||
readonly last_modified?: string;
|
||||
readonly name?: string;
|
||||
readonly poster_url?: string;
|
||||
readonly stream_icon?: string;
|
||||
readonly title?: string;
|
||||
readonly tv_archive?: number | null;
|
||||
readonly tv_archive_duration?: number | string | null;
|
||||
readonly xtream_id: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-live-stream-layout',
|
||||
templateUrl: './live-stream-layout.component.html',
|
||||
@@ -87,9 +101,11 @@ const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode';
|
||||
MatButtonModule,
|
||||
MatIcon,
|
||||
MatMenuModule,
|
||||
MatPaginatorModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatTooltipModule,
|
||||
NgTemplateOutlet,
|
||||
GridListComponent,
|
||||
PortalChannelsListComponent,
|
||||
PortalEmptyStateComponent,
|
||||
ResizableDirective,
|
||||
@@ -100,6 +116,7 @@ const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode';
|
||||
})
|
||||
export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly hostElement = inject(ElementRef<HTMLElement>);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly favoritesService = inject(FavoritesService);
|
||||
@@ -130,6 +147,11 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
|
||||
'q',
|
||||
(value) => (value ?? '').trim()
|
||||
);
|
||||
private readonly routePageIndex = queryParamSignal(
|
||||
this.route,
|
||||
'page',
|
||||
(value) => this.toPageIndex(value)
|
||||
);
|
||||
readonly workspaceSearchTerm = computed(() =>
|
||||
this.isWorkspaceLayout ? this.routeSearchTerm() : ''
|
||||
);
|
||||
@@ -198,12 +220,26 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
|
||||
readonly liveChannelSortLabel = computed(() =>
|
||||
getPortalChannelSortModeLabel(this.liveChannelSortMode())
|
||||
);
|
||||
readonly recentlyAddedLiveItems = computed(() =>
|
||||
getRecentlyAddedLiveItems(
|
||||
this.getAllLiveStreams(),
|
||||
this.categories(),
|
||||
this.currentTimeMs()
|
||||
)
|
||||
readonly liveRootPageSizeOptions = [10, 25, 50, 100];
|
||||
readonly liveRootItems = computed(
|
||||
() =>
|
||||
this.xtreamStore.getPaginatedContent() as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>[]
|
||||
);
|
||||
readonly liveRootItemCount = computed(
|
||||
() => this.xtreamStore.selectItemsFromSelectedCategory().length
|
||||
);
|
||||
readonly liveRootSubtitle = computed(() => {
|
||||
const count = this.liveRootItemCount();
|
||||
return `${count} ${count === 1 ? 'channel' : 'channels'}`;
|
||||
});
|
||||
readonly liveRootPageIndex = this.xtreamStore.page;
|
||||
readonly liveRootLimit = this.xtreamStore.limit;
|
||||
readonly liveRootTotalPages = this.xtreamStore.getTotalPages;
|
||||
readonly showLiveRootPaginator = computed(
|
||||
() => this.liveRootItemCount() > 0
|
||||
);
|
||||
|
||||
readonly selectedCategoryInfo = computed(() => {
|
||||
@@ -243,6 +279,20 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
|
||||
onCleanup(() => clearInterval(intervalId));
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
if (
|
||||
this.xtreamStore.selectedContentType() !== 'live' ||
|
||||
this.showLiveChannelSidebar()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pageIndex = this.routePageIndex();
|
||||
if (this.liveRootPageIndex() !== pageIndex) {
|
||||
this.xtreamStore.setPage(pageIndex);
|
||||
}
|
||||
});
|
||||
|
||||
// Read pending auto-open state on every NavigationEnd — covers both the
|
||||
// initial navigation (Angular fires NavigationEnd after component creation)
|
||||
// and re-navigation to the same /live route when the component is reused.
|
||||
@@ -415,6 +465,25 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
|
||||
persistPortalChannelSortMode(LIVE_CHANNEL_SORT_STORAGE_KEY, mode);
|
||||
}
|
||||
|
||||
onLiveRootPageChange(event: PageEvent): void {
|
||||
this.xtreamStore.setPage(event.pageIndex);
|
||||
this.xtreamStore.setLimit(event.pageSize);
|
||||
this.scrollLiveRootGridToTop();
|
||||
|
||||
void this.router.navigate([], {
|
||||
relativeTo: this.route,
|
||||
queryParams: {
|
||||
page: event.pageIndex > 0 ? event.pageIndex + 1 : null,
|
||||
},
|
||||
queryParamsHandling: 'merge',
|
||||
replaceUrl: true,
|
||||
});
|
||||
}
|
||||
|
||||
onLiveRootItemClick(item: unknown): void {
|
||||
this.playLive(item as XtreamLiveChannelItem);
|
||||
}
|
||||
|
||||
onLiveEpgPanelCollapsedChange(collapsed: boolean): void {
|
||||
const state: LiveEpgPanelState = collapsed ? 'collapsed' : 'expanded';
|
||||
this.liveEpgPanelState.set(state);
|
||||
@@ -505,6 +574,18 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
|
||||
return this.xtreamStore.liveStreams() as unknown as XtreamLiveChannelItem[];
|
||||
}
|
||||
|
||||
private toPageIndex(value: string | null): number {
|
||||
const page = Number(value);
|
||||
return Number.isInteger(page) && page > 0 ? page - 1 : 0;
|
||||
}
|
||||
|
||||
private scrollLiveRootGridToTop(): void {
|
||||
const gridList = this.hostElement.nativeElement.querySelector(
|
||||
'app-grid-list'
|
||||
) as HTMLElement | null;
|
||||
gridList?.scrollTo?.({ top: 0 });
|
||||
}
|
||||
|
||||
private getVisibleChannels(): XtreamLiveChannelItem[] {
|
||||
return this.xtreamStore.selectItemsFromSelectedCategory() as XtreamLiveChannelItem[];
|
||||
}
|
||||
|
||||
-68
@@ -1,68 +0,0 @@
|
||||
import { toXtreamRecentlyAddedTimestamp } from '@iptvnator/shared/interfaces';
|
||||
|
||||
export interface XtreamLiveChannelItem {
|
||||
readonly added?: string;
|
||||
readonly category_id?: string | number;
|
||||
readonly last_modified?: string;
|
||||
readonly name?: string;
|
||||
readonly poster_url?: string;
|
||||
readonly stream_icon?: string;
|
||||
readonly title?: string;
|
||||
readonly tv_archive?: number | null;
|
||||
readonly tv_archive_duration?: number | string | null;
|
||||
readonly xtream_id: number;
|
||||
}
|
||||
|
||||
interface XtreamLiveCategoryItem {
|
||||
readonly category_id?: string | number;
|
||||
readonly id?: string | number;
|
||||
}
|
||||
|
||||
export function getRecentlyAddedLiveItems(
|
||||
liveStreams: readonly XtreamLiveChannelItem[],
|
||||
categories: readonly XtreamLiveCategoryItem[] | null | undefined,
|
||||
nowMs: number,
|
||||
limit = 20
|
||||
): XtreamLiveChannelItem[] {
|
||||
const visibleCategoryIds = getVisibleCategoryIds(categories);
|
||||
if (visibleCategoryIds.size === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return liveStreams
|
||||
.filter((item) =>
|
||||
visibleCategoryIds.has(String(item.category_id ?? ''))
|
||||
)
|
||||
.map((item) => ({
|
||||
item,
|
||||
sortTimestamp: getRecentlyAddedTimestamp(item, nowMs),
|
||||
}))
|
||||
.filter(({ sortTimestamp }) => sortTimestamp > 0)
|
||||
.sort((a, b) => b.sortTimestamp - a.sortTimestamp)
|
||||
.slice(0, limit)
|
||||
.map(({ item }) => item);
|
||||
}
|
||||
|
||||
function getVisibleCategoryIds(
|
||||
categories: readonly XtreamLiveCategoryItem[] | null | undefined
|
||||
): Set<string> {
|
||||
return new Set(
|
||||
(categories ?? [])
|
||||
.map((category) => category.category_id ?? category.id)
|
||||
.filter(
|
||||
(categoryId): categoryId is string | number =>
|
||||
categoryId !== null && categoryId !== undefined
|
||||
)
|
||||
.map((categoryId) => String(categoryId))
|
||||
);
|
||||
}
|
||||
|
||||
function getRecentlyAddedTimestamp(
|
||||
item: XtreamLiveChannelItem,
|
||||
nowMs: number
|
||||
): number {
|
||||
return (
|
||||
toXtreamRecentlyAddedTimestamp(item.added, nowMs) ||
|
||||
toXtreamRecentlyAddedTimestamp(item.last_modified, nowMs)
|
||||
);
|
||||
}
|
||||
@@ -95,8 +95,8 @@ describe('RecentlyAddedComponent', () => {
|
||||
dateNowSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('does not let far-future provider dates pin the first VOD rail slots', () => {
|
||||
const freshItems = Array.from({ length: 21 }, (_, index) =>
|
||||
it('shows up to 30 recently added VOD items after filtering invalid provider dates', () => {
|
||||
const freshItems = Array.from({ length: 31 }, (_, index) =>
|
||||
createItem(index + 1, {
|
||||
added: String(
|
||||
Math.floor(
|
||||
@@ -122,8 +122,10 @@ describe('RecentlyAddedComponent', () => {
|
||||
);
|
||||
const titles = component.recentlyAddedVod().map((item) => item.title);
|
||||
|
||||
expect(titles).toHaveLength(20);
|
||||
expect(titles).toHaveLength(30);
|
||||
expect(titles[0]).toBe('Fresh 1');
|
||||
expect(titles.at(-1)).toBe('Fresh 30');
|
||||
expect(titles).not.toContain('Fresh 31');
|
||||
expect(titles).not.toContain('Future Provider Item');
|
||||
});
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ interface RecentlyAddedItem {
|
||||
// Three placeholder slots per skeleton rail — enough to suggest a horizontal
|
||||
// scroll without taking the whole viewport.
|
||||
const SKELETON_CARDS_PER_RAIL = [1, 2, 3, 4, 5, 6] as const;
|
||||
const RECENTLY_ADDED_ITEMS_LIMIT = 30;
|
||||
|
||||
@Component({
|
||||
selector: 'app-recently-added',
|
||||
@@ -144,7 +145,7 @@ export class RecentlyAddedComponent {
|
||||
.sort((a, b) => {
|
||||
return b.sortTimestamp - a.sortTimestamp;
|
||||
})
|
||||
.slice(0, 20)
|
||||
.slice(0, RECENTLY_ADDED_ITEMS_LIMIT)
|
||||
.map(({ item }) => item);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user