mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
Merge pull request #1080 from 4gray/agent/xtream-live-recently-added
feat(xtream): show recently added live channels
This commit is contained in:
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';
|
||||
}
|
||||
}
|
||||
+38
@@ -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();
|
||||
});
|
||||
});
|
||||
+44
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
-4
@@ -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
|
||||
|
||||
+55
-1
@@ -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),
|
||||
|
||||
+206
-9
@@ -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();
|
||||
|
||||
+104
-42
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
-4
@@ -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({
|
||||
|
||||
+23
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user