diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 3810858f0..810785905 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -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" diff --git a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.scss b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.scss index 98fa26fa9..4c5c04304 100644 --- a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.scss +++ b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.scss @@ -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); diff --git a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.spec.ts b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.spec.ts index f783b26f1..975cfb4bf 100644 --- a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.spec.ts @@ -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; + + 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' + ); + }); +}); diff --git a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.ts b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.ts index fbc9e687a..bee1c3a1b 100644 --- a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.ts +++ b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.ts @@ -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: `
@@ -82,22 +97,49 @@ export function resolveGridRating( } @else { @for (item of items(); track $index) { @let i = $any(item); - - @let poster = i.poster_url ?? i.cover; + + @let poster = resolvePoster(i);
- logo + @if (type()) { +
+ {{ type() }} +
+ } + @if (poster && !hasArtworkFailed(poster)) { + logo + } @else if ( + shouldRenderArtworkPlaceholder(poster) + ) { + + } @else { + logo + } @if (i.progress && i.progress > 0) { } @@ -186,6 +228,8 @@ export function resolveGridRating( changeDetection: ChangeDetectionStrategy.OnPush, }) export class GridListComponent { + private readonly failedArtworkUrls = signal>(new Set()); + readonly items = input([]); readonly isLoading = input(false); readonly showPaginator = input(true); @@ -197,7 +241,15 @@ export class GridListComponent { readonly totalPages = input(0); readonly limit = input(25); readonly pageSizeOptions = input([]); + 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'; + } } diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-auto-open-state.service.spec.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-auto-open-state.service.spec.ts new file mode 100644 index 000000000..7f02f3b67 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-auto-open-state.service.spec.ts @@ -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(); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-auto-open-state.service.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-auto-open-state.service.ts new file mode 100644 index 000000000..a7450527b --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-auto-open-state.service.ts @@ -0,0 +1,44 @@ +import { Injectable, signal } from '@angular/core'; + +@Injectable() +export class LiveStreamAutoOpenStateService { + readonly pendingItemId = signal(null); + + captureFromHistoryState(): void { + const requestedItemId = Number( + (window.history.state as Record | 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. + } + } +} diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html index 1c1e96c1b..204ded13b 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html @@ -152,10 +152,40 @@ />
} @else { - +
+
+
+

All Items

+
+ {{ liveRootSubtitle() }} +
+
+ @if (showLiveRootPaginator()) { + + } +
+ +
} } @else { ('server'); + readonly channelsOverride = input(null); readonly searchTermInput = input(''); readonly playClicked = output(); readonly playbackRequested = output(); } +@Component({ + selector: 'app-grid-list', + standalone: true, + template: '
', +}) +class StubGridListComponent { + readonly items = input([]); + 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([]); + readonly variant = input<'poster' | 'logo'>('poster'); + readonly type = input(); + readonly itemClicked = output(); + readonly pageChange = output(); +} + @Component({ selector: 'app-web-player-view', standalone: true, @@ -145,10 +165,16 @@ describe('LiveStreamLayoutComponent', () => { const selectedItem = signal(sampleChannel); const currentPlaylist = signal(playlist); const liveStreams = signal([]); + const paginatedContent = signal([]); + 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; + let router: { events: Subject; 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([[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(); diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts index 0d3417eab..3ae1499c9 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts @@ -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); 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(null); + private readonly pendingAutoOpenLiveItemId = + this.liveAutoOpenState.pendingItemId; readonly selectedLiveItem = computed(() => { 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); - // 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 + ); + 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 | 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 - } - } } diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts index 8c3dd8f2a..ee34ecde1 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts @@ -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({ diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts index 2d627ed02..991a2100a 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts @@ -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; diff --git a/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.spec.ts b/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.spec.ts index c7caac068..f40d19231 100644 --- a/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.spec.ts @@ -52,6 +52,7 @@ class MockXtreamStore { describe('RecentlyAddedComponent', () => { let store: MockXtreamStore; let dateNowSpy: jest.SpyInstance; + 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', + }, + }); + }); }); diff --git a/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.ts b/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.ts index a37a4714b..8032de338 100644 --- a/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.ts +++ b/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.ts @@ -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) + ); + } }