feat(xtream): add live all-items grid

This commit is contained in:
4gray committed 2026-06-20 09:36:34 +02:00
1 parent bfa865c55c
commit 2edc430935
11 files changed
+628 -219

No files matched your search

+9 -6
View File
@@ -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';
}
}
@@ -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
@@ -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 {
@@ -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', () => {
@@ -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[];
}
@@ -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);
}