Merge pull request #1080 from 4gray/agent/xtream-live-recently-added

feat(xtream): show recently added live channels
This commit is contained in:
4gray authored and GitHub committed 2026-06-20 12:18:57 +02:00
commit c12fc5b746
14 files changed
+921 -97

No files matched your search

@@ -160,6 +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) 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';
}
}
@@ -0,0 +1,38 @@
import { LiveStreamAutoOpenStateService } from './live-stream-auto-open-state.service';
describe('LiveStreamAutoOpenStateService', () => {
let service: LiveStreamAutoOpenStateService;
beforeEach(() => {
service = new LiveStreamAutoOpenStateService();
window.history.replaceState({}, document.title);
});
afterEach(() => {
window.history.replaceState({}, document.title);
});
it('captures a positive live item id from history state', () => {
window.history.replaceState(
{ openXtreamLiveItemId: 101 },
document.title
);
service.captureFromHistoryState();
expect(service.pendingItemId()).toBe(101);
});
it('clears the pending item id when history state has no live item id', () => {
window.history.replaceState(
{ openXtreamLiveItemId: 101 },
document.title
);
service.captureFromHistoryState();
window.history.replaceState({}, document.title);
service.captureFromHistoryState();
expect(service.pendingItemId()).toBeNull();
});
});
@@ -0,0 +1,44 @@
import { Injectable, signal } from '@angular/core';
@Injectable()
export class LiveStreamAutoOpenStateService {
readonly pendingItemId = signal<number | null>(null);
captureFromHistoryState(): void {
const requestedItemId = Number(
(window.history.state as Record<string, unknown> | null)?.[
'openXtreamLiveItemId'
]
);
if (Number.isFinite(requestedItemId) && requestedItemId > 0) {
this.pendingItemId.set(requestedItemId);
return;
}
this.pendingItemId.set(null);
}
clearPendingItem(): void {
this.pendingItemId.set(null);
}
clearHistoryState(): void {
try {
const state = (window.history.state ?? {}) as Record<
string,
unknown
>;
if (!('openXtreamLiveItemId' in state)) {
return;
}
const nextState = { ...state };
delete nextState['openXtreamLiveItemId'];
delete nextState['openXtreamLiveTitle'];
delete nextState['openXtreamLivePoster'];
window.history.replaceState(nextState, document.title);
} catch {
// Browser history state can be unavailable in restricted contexts.
}
}
}
@@ -152,10 +152,40 @@
/>
</div>
} @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,6 +31,55 @@
height: 100%;
}
.live-all-items-content {
align-self: stretch;
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
width: 100%;
}
.category-content-header {
@include panel.standard-panel-header($sticky: true);
}
.category-meta {
@include panel.standard-panel-meta();
}
.category-title {
@include panel.standard-panel-title();
}
.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-y: auto;
padding: 16px 20px 20px;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.08) transparent;
}
.loading-indicator {
display: flex;
justify-content: center;
@@ -42,7 +92,11 @@
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0)),
linear-gradient(
180deg,
rgba(255, 255, 255, 0.04),
rgba(255, 255, 255, 0)
),
rgba(127, 127, 127, 0.05);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.04),
@@ -31,12 +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 { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import { PageEvent } from '@angular/material/paginator';
const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode';
@@ -47,11 +46,32 @@ const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode';
})
class StubPortalChannelsListComponent {
readonly sortMode = input<'server' | 'name-asc' | 'name-desc'>('server');
readonly channelsOverride = input<unknown[] | null>(null);
readonly searchTermInput = input('');
readonly playClicked = output<unknown>();
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,
@@ -145,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,
@@ -158,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([])),
};
@@ -198,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();
@@ -214,6 +252,8 @@ describe('LiveStreamLayoutComponent', () => {
epgItems.set([]);
currentEpgItem.set(null);
isLoadingEpg.set(false);
categories.set([{ category_id: 1, category_name: 'News' }]);
categoryItemCounts.set(new Map<number, number>([[1, 1]]));
selectedTypeContentLoading.set(false);
selectedCategoryId.set(1);
selectedContentType.set('live');
@@ -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 },
@@ -260,8 +303,8 @@ describe('LiveStreamLayoutComponent', () => {
get supportsRemoteControl() {
return Boolean(
window.electron?.updateRemoteControlStatus &&
window.electron.onChannelChange &&
window.electron.onRemoteControlCommand
window.electron.onChannelChange &&
window.electron.onRemoteControlCommand
);
},
},
@@ -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,6 +464,153 @@ describe('LiveStreamLayoutComponent', () => {
).toBeNull();
});
it('shows live all-items content with the shared grid and header paginator before a category is selected', () => {
const firstChannel = {
xtream_id: 301,
name: 'First Channel',
category_id: '7',
added: String(
Math.floor(Date.parse('2026-04-04T12:00:00Z') / 1000)
),
};
const secondChannel = {
xtream_id: 302,
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);
limit.set(25);
page.set(0);
totalPages.set(2);
paginatedContent.set([firstChannel, secondChannel]);
xtreamStore.selectItemsFromSelectedCategory.mockReturnValue([
firstChannel,
secondChannel,
thirdChannel,
]);
fixture.detectChanges();
const grid = fixture.debugElement.query(
By.directive(StubGridListComponent)
);
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();
});
it('plays a live all-items grid card and selects its category', () => {
const channel = {
xtream_id: 301,
name: 'Grid Channel',
category_id: '7',
added: String(
Math.floor(Date.parse('2026-04-04T12:00:00Z') / 1000)
),
};
selectedCategoryId.set(null);
selectedTypeContentLoading.set(false);
paginatedContent.set([channel]);
xtreamStore.selectItemsFromSelectedCategory.mockReturnValue([channel]);
fixture.detectChanges();
const grid = fixture.debugElement.query(
By.directive(StubGridListComponent)
);
grid.componentInstance.itemClicked.emit(channel);
fixture.detectChanges();
expect(xtreamStore.constructStreamUrl).toHaveBeenCalledWith(channel);
expect(xtreamStore.setSelectedCategory).toHaveBeenCalledWith(7);
expect(
fixture.debugElement.query(By.directive(StubWebPlayerViewComponent))
).not.toBeNull();
});
it('updates live root pagination from the header paginator', () => {
selectedCategoryId.set(null);
selectedTypeContentLoading.set(false);
xtreamStore.selectItemsFromSelectedCategory.mockReturnValue(
Array.from({ length: 80 }, (_, index) => ({
xtream_id: index + 1,
name: `Channel ${index + 1}`,
}))
);
paginatedContent.set([]);
fixture.detectChanges();
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', () => {
selectedCategoryId.set(null);
selectedTypeContentLoading.set(false);
@@ -444,6 +636,10 @@ describe('LiveStreamLayoutComponent', () => {
});
it('shows embedded playback after selecting a channel from live root search results', () => {
const searchResultChannel = {
...sampleChannel,
category_id: '7',
};
selectedCategoryId.set(null);
selectedTypeContentLoading.set(false);
routeQueryParamMap.next(convertToParamMap({ q: 'world' }));
@@ -453,12 +649,13 @@ describe('LiveStreamLayoutComponent', () => {
By.directive(StubPortalChannelsListComponent)
);
list.componentInstance.playClicked.emit(sampleChannel);
list.componentInstance.playClicked.emit(searchResultChannel);
fixture.detectChanges();
expect(xtreamStore.constructStreamUrl).toHaveBeenCalledWith(
sampleChannel
searchResultChannel
);
expect(xtreamStore.setSelectedCategory).toHaveBeenCalledWith(7);
expect(
fixture.debugElement.query(By.directive(StubWebPlayerViewComponent))
).not.toBeNull();
@@ -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,
@@ -67,11 +72,14 @@ import {
import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component';
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import { LiveStreamAutoOpenStateService } from './live-stream-auto-open-state.service';
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;
@@ -85,6 +93,7 @@ interface XtreamLiveChannelItem {
selector: 'app-live-stream-layout',
templateUrl: './live-stream-layout.component.html',
styleUrls: ['./live-stream-layout.component.scss'],
providers: [LiveStreamAutoOpenStateService],
imports: [
EpgListComponent,
EpgViewComponent,
@@ -92,9 +101,11 @@ interface XtreamLiveChannelItem {
MatButtonModule,
MatIcon,
MatMenuModule,
MatPaginatorModule,
MatProgressSpinnerModule,
MatTooltipModule,
NgTemplateOutlet,
GridListComponent,
PortalChannelsListComponent,
PortalEmptyStateComponent,
ResizableDirective,
@@ -105,6 +116,7 @@ interface XtreamLiveChannelItem {
})
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);
@@ -116,6 +128,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
private readonly liveSidebarStateService = inject(
LiveLayoutSidebarStateService
);
private readonly liveAutoOpenState = inject(LiveStreamAutoOpenStateService);
readonly categories = this.xtreamStore.getCategoriesBySelectedType;
readonly categoryItemCounts = this.xtreamStore.getCategoryItemCounts;
@@ -134,13 +147,19 @@ 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() : ''
);
readonly showLiveChannelSidebar = computed(
() => !!this.selectedCategoryId() || !!this.workspaceSearchTerm()
);
private readonly pendingAutoOpenLiveItemId = signal<number | null>(null);
private readonly pendingAutoOpenLiveItemId =
this.liveAutoOpenState.pendingItemId;
readonly selectedLiveItem = computed<XtreamLiveChannelItem | null>(() => {
if (this.xtreamStore.selectedContentType() !== 'live') {
return null;
@@ -201,6 +220,27 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
readonly liveChannelSortLabel = computed(() =>
getPortalChannelSortModeLabel(this.liveChannelSortMode())
);
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(() => {
const categoryId = this.selectedCategoryId();
@@ -239,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.
@@ -247,7 +301,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
filter((e) => e instanceof NavigationEnd),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(() => this.checkPendingAutoOpenFromState());
.subscribe(() => this.liveAutoOpenState.captureFromHistoryState());
effect(() => {
const pendingId = this.pendingAutoOpenLiveItemId();
@@ -282,14 +336,11 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
// status reflect the channel (constructStreamUrl also does this
// internally, but an explicit call makes the intent clear and
// keeps the auto-open path testable in isolation).
this.xtreamStore.setSelectedItem(item as unknown as Record<string, unknown>);
// Navigate to the channel's category so the sidebar shows it highlighted
const categoryId = Number(item.category_id);
if (Number.isFinite(categoryId) && categoryId > 0) {
this.xtreamStore.setSelectedCategory(categoryId);
}
this.pendingAutoOpenLiveItemId.set(null);
this.clearAutoOpenHistoryState();
this.xtreamStore.setSelectedItem(
item as unknown as Record<string, unknown>
);
this.liveAutoOpenState.clearPendingItem();
this.liveAutoOpenState.clearHistoryState();
});
effect(() => {
@@ -375,6 +426,10 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
startPlayback = !this.settingsStore.openStreamOnDoubleClick()
) {
const streamUrl = this.xtreamStore.constructStreamUrl(item);
// Keep both root/recently-added playback and same-category replays in
// sync with the category rail. For already-selected channels this is a
// store no-op.
this.selectLiveItemCategory(item);
this.activePlayback.set({
streamUrl,
title: item.title ?? item.name ?? '',
@@ -410,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);
@@ -496,25 +570,33 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
);
}
private checkPendingAutoOpenFromState(): void {
const requestedItemId = Number(
(window.history.state as Record<string, unknown> | null)?.[
'openXtreamLiveItemId'
]
);
if (Number.isFinite(requestedItemId) && requestedItemId > 0) {
this.pendingAutoOpenLiveItemId.set(requestedItemId);
}
}
private getAllLiveStreams(): XtreamLiveChannelItem[] {
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[];
}
private selectLiveItemCategory(item: XtreamLiveChannelItem): void {
const categoryId = Number(item.category_id);
if (Number.isFinite(categoryId) && categoryId > 0) {
this.xtreamStore.setSelectedCategory(categoryId);
}
}
private async playCatchup(
program: EpgProgram,
item: XtreamLiveChannelItem
@@ -645,24 +727,4 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
? `${channelTitle} - ${program.title}`
: program.title;
}
private clearAutoOpenHistoryState(): void {
try {
const state = (window.history.state ?? {}) as Record<
string,
unknown
>;
if (!('openXtreamLiveItemId' in state)) {
return;
}
const nextState = { ...state };
delete nextState['openXtreamLiveItemId'];
delete nextState['openXtreamLiveTitle'];
delete nextState['openXtreamLivePoster'];
window.history.replaceState(nextState, document.title);
} catch {
// no-op
}
}
}
@@ -10,10 +10,7 @@ import {
FavoritesService,
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
import {
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import { PortalChannelsListComponent } from './portal-channels-list.component';
function buildEpgItem(params: {
@@ -351,6 +348,53 @@ describe('PortalChannelsListComponent', () => {
).toBe('live:290');
});
it('scrolls the virtual list to the selected live channel', () => {
const channels = Array.from({ length: 20 }, (_, index) => ({
title: `Channel ${index + 1}`,
xtream_id: index + 1,
}));
selectedTypeContentLoading.set(false);
selectedChannels.set(channels);
fixture.detectChanges();
const viewport = fixture.componentInstance.viewport();
if (!viewport) {
throw new Error('Expected virtual scroll viewport');
}
const scrollToIndex = jest.spyOn(viewport, 'scrollToIndex');
selectedItem.set({ xtream_id: 16 });
fixture.detectChanges();
expect(scrollToIndex).toHaveBeenCalledWith(15, 'smooth');
});
it('does not re-scroll the virtual list when the search filter changes', () => {
const channels = Array.from({ length: 20 }, (_, index) => ({
title: `Channel ${index + 1}`,
xtream_id: index + 1,
}));
selectedTypeContentLoading.set(false);
selectedChannels.set(channels);
fixture.detectChanges();
const viewport = fixture.componentInstance.viewport();
if (!viewport) {
throw new Error('Expected virtual scroll viewport');
}
const scrollToIndex = jest.spyOn(viewport, 'scrollToIndex');
selectedItem.set({ xtream_id: 16 });
fixture.detectChanges();
expect(scrollToIndex).toHaveBeenCalledWith(15, 'smooth');
scrollToIndex.mockClear();
fixture.componentRef.setInput('searchTermInput', 'Channel 16');
fixture.detectChanges();
expect(scrollToIndex).not.toHaveBeenCalled();
});
it('passes the live content type when toggling a channel favorite', async () => {
selectedTypeContentLoading.set(false);
currentPlaylist.set({
@@ -13,6 +13,7 @@ import {
input,
OnDestroy,
output,
untracked,
viewChild,
} from '@angular/core';
import { MatIcon } from '@angular/material/icon';
@@ -126,6 +127,28 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
private subscriptions = new Subscription();
constructor(private cdr: ChangeDetectorRef) {
effect(() => {
const selectedItem = this.xtreamStore.selectedItem();
const viewport = this.viewport();
const selectedId = Number(
(selectedItem as XtreamChannelListItem | null)?.xtream_id
);
if (!viewport || !Number.isFinite(selectedId) || selectedId <= 0) {
return;
}
const filteredChannels = untracked(() => this.filteredChannels());
const selectedIndex = filteredChannels.findIndex(
(item) => Number(item.xtream_id) === selectedId
);
if (selectedIndex < 0) {
return;
}
viewport.scrollToIndex(selectedIndex, 'smooth');
});
effect(() => {
if (!this.supportsEpg) {
return;
@@ -52,6 +52,7 @@ class MockXtreamStore {
describe('RecentlyAddedComponent', () => {
let store: MockXtreamStore;
let dateNowSpy: jest.SpyInstance<number, []>;
let router: { navigate: jest.Mock };
beforeEach(() => {
dateNowSpy = jest
@@ -87,14 +88,15 @@ describe('RecentlyAddedComponent', () => {
});
store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
router = TestBed.inject(Router) as unknown as { navigate: jest.Mock };
});
afterEach(() => {
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(
@@ -120,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');
});
@@ -161,4 +165,27 @@ describe('RecentlyAddedComponent', () => {
)
).toBe(Date.parse('2026-05-15T00:00:00.000Z'));
});
it('navigates live recently-added cards with auto-open playback state', () => {
const component = TestBed.runInInjectionContext(
() => new RecentlyAddedComponent()
);
const item = createItem(101, {
category_id: 7,
poster_url: 'live-news.png',
title: 'Live News',
});
component.openItem(item, 'live');
expect(store.setSelectedContentType).toHaveBeenCalledWith('live');
expect(router.navigate).toHaveBeenCalledWith(['..', 'live', 7], {
relativeTo: TestBed.inject(ActivatedRoute),
state: {
openXtreamLiveItemId: 101,
openXtreamLiveTitle: 'Live News',
openXtreamLivePoster: 'live-news.png',
},
});
});
});
@@ -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);
}
@@ -197,17 +198,37 @@ export class RecentlyAddedComponent {
this.xtreamStore.setSelectedContentType(type);
if (type === 'live') {
const itemId = this.getItemId(item, type);
this.router.navigate(['..', type, item.category_id], {
relativeTo: this.activatedRoute,
...(itemId
? {
state: {
openXtreamLiveItemId: Number(itemId),
openXtreamLiveTitle:
item.title || item.name || '',
openXtreamLivePoster:
item.poster_url || item.stream_icon || '',
},
}
: {}),
});
} else {
const itemId =
item.xtream_id ||
item.id ||
(type === 'series' ? item.series_id : item.stream_id);
const itemId = this.getItemId(item, type);
this.router.navigate(['..', type, item.category_id, itemId], {
relativeTo: this.activatedRoute,
});
}
}
private getItemId(
item: RecentlyAddedItem,
type: ContentType
): number | undefined {
return (
item.xtream_id ||
item.id ||
(type === 'series' ? item.series_id : item.stream_id)
);
}
}