From 210f0ebabc74b6330d24498b986f06e4bd78d767 Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 19 Jun 2026 19:59:42 +0200 Subject: [PATCH 1/4] feat(xtream): show recently added live channels --- docs/architecture/iptvnator-ui-guidelines.md | 6 ++ .../live-stream-layout.component.html | 17 ++++++ .../live-stream-layout.component.scss | 49 +++++++++++++++- .../live-stream-layout.component.spec.ts | 56 ++++++++++++++++--- .../live-stream-layout.component.ts | 43 ++++++++++++-- .../portal-channels-list.component.spec.ts | 26 +++++++-- .../portal-channels-list.component.ts | 21 +++++++ .../recently-added.component.spec.ts | 25 +++++++++ .../recently-added.component.ts | 28 ++++++++-- 9 files changed, 248 insertions(+), 23 deletions(-) diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 3810858f0..5b2e05c7c 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -160,6 +160,12 @@ The shared row should be reused instead of rebuilding channel markup per view. - M3U, Xtream, and Stalker live layouts share a single sidebar collapse toggle that hides the channels rail to give the player and EPG full width. +- Xtream Live TV's root view (`/live` with no selected category) shows the + newest live channels when provider timestamps are available. Selecting a + channel from that root surface starts playback, selects the channel's + category, highlights the active category and channel, and scrolls the + category rail plus virtual channels list to the selected rows when those + rails are visible. - 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/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..519f02582 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 @@ -151,6 +151,23 @@ (playbackRequested)="playLive($event, true)" /> + } @else if (recentlyAddedLiveItems().length > 0) { +
+
+

+ {{ 'PORTALS.SIDEBAR.RECENTLY_ADDED' | translate }} +

+

+ {{ 'PORTALS.SIDEBAR.LIVE_TV' | translate }} +

+
+ +
} @else { ('server'); + readonly channelsOverride = input(null); readonly searchTermInput = input(''); readonly playClicked = output(); readonly playbackRequested = output(); @@ -260,8 +258,8 @@ describe('LiveStreamLayoutComponent', () => { get supportsRemoteControl() { return Boolean( window.electron?.updateRemoteControlStatus && - window.electron.onChannelChange && - window.electron.onRemoteControlCommand + window.electron.onChannelChange && + window.electron.onRemoteControlCommand ); }, }, @@ -419,6 +417,43 @@ describe('LiveStreamLayoutComponent', () => { ).toBeNull(); }); + it('shows recently added live channels on the live root before a category is selected', () => { + const newestChannel = { + xtream_id: 301, + name: 'Newest Channel', + category_id: '7', + added: String( + Math.floor(Date.parse('2026-04-04T12:00:00Z') / 1000) + ), + }; + const olderChannel = { + xtream_id: 302, + name: 'Older Channel', + category_id: '8', + added: String( + Math.floor(Date.parse('2026-04-03T12:00:00Z') / 1000) + ), + }; + selectedCategoryId.set(null); + selectedTypeContentLoading.set(false); + liveStreams.set([olderChannel, newestChannel]); + + fixture.detectChanges(); + + const list = fixture.debugElement.query( + By.directive(StubPortalChannelsListComponent) + ); + + expect(list).not.toBeNull(); + expect( + fixture.nativeElement.querySelector('app-portal-empty-state') + ).toBeNull(); + expect(list.componentInstance.channelsOverride()).toEqual([ + newestChannel, + olderChannel, + ]); + }); + it('shows the cross-category live channel list while searching from the live root', () => { selectedCategoryId.set(null); selectedTypeContentLoading.set(false); @@ -444,6 +479,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 +492,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..a455e0596 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 @@ -63,6 +63,7 @@ import { EpgItem, EpgProgram, ResolvedPortalPlayback, + toXtreamRecentlyAddedTimestamp, } from '@iptvnator/shared/interfaces'; import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component'; import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; @@ -71,7 +72,9 @@ import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; 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; @@ -201,6 +204,19 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { readonly liveChannelSortLabel = computed(() => getPortalChannelSortModeLabel(this.liveChannelSortMode()) ); + readonly recentlyAddedLiveItems = computed(() => { + const nowMs = Date.now(); + + return this.getAllLiveStreams() + .map((item) => ({ + item, + sortTimestamp: this.getRecentlyAddedTimestamp(item, nowMs), + })) + .filter(({ sortTimestamp }) => sortTimestamp > 0) + .sort((a, b) => b.sortTimestamp - a.sortTimestamp) + .slice(0, 20) + .map(({ item }) => item); + }); readonly selectedCategoryInfo = computed(() => { const categoryId = this.selectedCategoryId(); @@ -282,12 +298,9 @@ 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.xtreamStore.setSelectedItem( + item as unknown as Record + ); this.pendingAutoOpenLiveItemId.set(null); this.clearAutoOpenHistoryState(); }); @@ -375,6 +388,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { startPlayback = !this.settingsStore.openStreamOnDoubleClick() ) { const streamUrl = this.xtreamStore.constructStreamUrl(item); + this.selectLiveItemCategory(item); this.activePlayback.set({ streamUrl, title: item.title ?? item.name ?? '', @@ -515,6 +529,23 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { 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 getRecentlyAddedTimestamp( + item: XtreamLiveChannelItem, + nowMs: number + ): number { + return ( + toXtreamRecentlyAddedTimestamp(item.added, nowMs) || + toXtreamRecentlyAddedTimestamp(item.last_modified, nowMs) + ); + } + private async playCatchup( program: EpgProgram, item: XtreamLiveChannelItem 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..07ee366a1 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,27 @@ 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('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..61d1b472d 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 @@ -126,6 +126,27 @@ 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 selectedIndex = this.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..e9f7198a0 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,6 +88,7 @@ describe('RecentlyAddedComponent', () => { }); store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore; + router = TestBed.inject(Router) as unknown as { navigate: jest.Mock }; }); afterEach(() => { @@ -161,4 +163,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..28ded761e 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 @@ -197,17 +197,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) + ); + } } From 6ad5f5e2079b147fbb1e2613895359d99606175a Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 19 Jun 2026 20:50:14 +0200 Subject: [PATCH 2/4] fix(xtream): address live recents review feedback --- docs/architecture/iptvnator-ui-guidelines.md | 8 +- .../live-stream-auto-open-state.service.ts | 41 +++++++++ .../live-stream-layout.component.spec.ts | 62 +++++++++++++ .../live-stream-layout.component.ts | 91 ++++--------------- .../live-stream-recently-added.utils.ts | 68 ++++++++++++++ .../portal-channels-list.component.spec.ts | 26 ++++++ .../portal-channels-list.component.ts | 4 +- 7 files changed, 223 insertions(+), 77 deletions(-) create mode 100644 libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-auto-open-state.service.ts create mode 100644 libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-recently-added.utils.ts diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 5b2e05c7c..8e8cf71f9 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -161,10 +161,10 @@ The shared row should be reused instead of rebuilding channel markup per view. - M3U, Xtream, and Stalker live layouts share a single sidebar collapse toggle that hides the channels rail to give the player and EPG full width. - Xtream Live TV's root view (`/live` with no selected category) shows the - newest live channels when provider timestamps are available. Selecting a - channel from that root surface starts playback, selects the channel's - category, highlights the active category and channel, and scrolls the - category rail plus virtual channels list to the selected rows when those + newest visible-category live channels when provider timestamps are available. + Selecting a channel from that root surface starts playback, selects the + channel's category, highlights the active category and channel, and scrolls + the category rail plus virtual channels list to the selected rows when those rails are visible. - In Xtream and Stalker live TV, the same toggle also collapses the workspace shell context sidebar (the "Live Categories" rail rendered by 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..f4365ac9c --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-auto-open-state.service.ts @@ -0,0 +1,41 @@ +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); + } + } + + 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.spec.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts index d7eb7821e..edb1260ad 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts @@ -212,6 +212,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'); @@ -436,6 +438,10 @@ describe('LiveStreamLayoutComponent', () => { }; selectedCategoryId.set(null); selectedTypeContentLoading.set(false); + categories.set([ + { category_id: 7, category_name: 'News' }, + { category_id: 8, category_name: 'Sports' }, + ]); liveStreams.set([olderChannel, newestChannel]); fixture.detectChanges(); @@ -454,6 +460,62 @@ describe('LiveStreamLayoutComponent', () => { ]); }); + it('excludes live root recently added channels from hidden categories', () => { + const visibleChannel = { + xtream_id: 301, + name: 'Visible Channel', + category_id: '1', + added: String( + Math.floor(Date.parse('2026-04-04T12:00:00Z') / 1000) + ), + }; + const hiddenChannel = { + xtream_id: 302, + name: 'Hidden Channel', + category_id: '2', + added: String( + Math.floor(Date.parse('2026-04-05T10:00:00Z') / 1000) + ), + }; + selectedCategoryId.set(null); + selectedTypeContentLoading.set(false); + categories.set([{ category_id: 1, category_name: 'News' }]); + liveStreams.set([hiddenChannel, visibleChannel]); + + fixture.detectChanges(); + + const list = fixture.debugElement.query( + By.directive(StubPortalChannelsListComponent) + ); + + expect(list).not.toBeNull(); + expect(list.componentInstance.channelsOverride()).toEqual([ + visibleChannel, + ]); + }); + + it('refreshes live root recently added channels as the clock advances', () => { + const futureChannel = { + xtream_id: 301, + name: 'Future Channel', + category_id: '1', + added: String( + Math.floor((fixedNow.getTime() + 25 * 60 * 60 * 1000) / 1000) + ), + }; + selectedCategoryId.set(null); + selectedTypeContentLoading.set(false); + liveStreams.set([futureChannel]); + + expect(component.recentlyAddedLiveItems()).toEqual([]); + + jest.setSystemTime(new Date(fixedNow.getTime() + 2 * 60 * 60 * 1000)); + jest.advanceTimersByTime(30_000); + fixture.detectChanges(); + + expect(component.recentlyAddedLiveItems()).toEqual([futureChannel]); + }); + it('shows the cross-category live channel list while searching from the live root', () => { selectedCategoryId.set(null); selectedTypeContentLoading.set(false); 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 a455e0596..2ae5a6402 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 @@ -63,31 +63,23 @@ import { EpgItem, EpgProgram, ResolvedPortalPlayback, - toXtreamRecentlyAddedTimestamp, } from '@iptvnator/shared/interfaces'; 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'; +import { + getRecentlyAddedLiveItems, + XtreamLiveChannelItem, +} from './live-stream-recently-added.utils'; const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode'; -interface XtreamLiveChannelItem { - readonly added?: string; - readonly category_id?: string | number; - readonly last_modified?: string; - readonly name?: string; - readonly poster_url?: string; - readonly stream_icon?: string; - readonly title?: string; - readonly tv_archive?: number | null; - readonly tv_archive_duration?: number | string | null; - readonly xtream_id: number; -} - @Component({ selector: 'app-live-stream-layout', templateUrl: './live-stream-layout.component.html', styleUrls: ['./live-stream-layout.component.scss'], + providers: [LiveStreamAutoOpenStateService], imports: [ EpgListComponent, EpgViewComponent, @@ -119,6 +111,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; @@ -143,7 +136,8 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { 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; @@ -204,19 +198,13 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { readonly liveChannelSortLabel = computed(() => getPortalChannelSortModeLabel(this.liveChannelSortMode()) ); - readonly recentlyAddedLiveItems = computed(() => { - const nowMs = Date.now(); - - return this.getAllLiveStreams() - .map((item) => ({ - item, - sortTimestamp: this.getRecentlyAddedTimestamp(item, nowMs), - })) - .filter(({ sortTimestamp }) => sortTimestamp > 0) - .sort((a, b) => b.sortTimestamp - a.sortTimestamp) - .slice(0, 20) - .map(({ item }) => item); - }); + readonly recentlyAddedLiveItems = computed(() => + getRecentlyAddedLiveItems( + this.getAllLiveStreams(), + this.categories(), + this.currentTimeMs() + ) + ); readonly selectedCategoryInfo = computed(() => { const categoryId = this.selectedCategoryId(); @@ -263,7 +251,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(); @@ -301,8 +289,8 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { this.xtreamStore.setSelectedItem( item as unknown as Record ); - this.pendingAutoOpenLiveItemId.set(null); - this.clearAutoOpenHistoryState(); + this.liveAutoOpenState.clearPendingItem(); + this.liveAutoOpenState.clearHistoryState(); }); effect(() => { @@ -510,17 +498,6 @@ 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[]; } @@ -536,16 +513,6 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { } } - private getRecentlyAddedTimestamp( - item: XtreamLiveChannelItem, - nowMs: number - ): number { - return ( - toXtreamRecentlyAddedTimestamp(item.added, nowMs) || - toXtreamRecentlyAddedTimestamp(item.last_modified, nowMs) - ); - } - private async playCatchup( program: EpgProgram, item: XtreamLiveChannelItem @@ -676,24 +643,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/live-stream-layout/live-stream-recently-added.utils.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-recently-added.utils.ts new file mode 100644 index 000000000..1137e4937 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-recently-added.utils.ts @@ -0,0 +1,68 @@ +import { toXtreamRecentlyAddedTimestamp } from '@iptvnator/shared/interfaces'; + +export interface XtreamLiveChannelItem { + readonly added?: string; + readonly category_id?: string | number; + readonly last_modified?: string; + readonly name?: string; + readonly poster_url?: string; + readonly stream_icon?: string; + readonly title?: string; + readonly tv_archive?: number | null; + readonly tv_archive_duration?: number | string | null; + readonly xtream_id: number; +} + +interface XtreamLiveCategoryItem { + readonly category_id?: string | number; + readonly id?: string | number; +} + +export function getRecentlyAddedLiveItems( + liveStreams: readonly XtreamLiveChannelItem[], + categories: readonly XtreamLiveCategoryItem[] | null | undefined, + nowMs: number, + limit = 20 +): XtreamLiveChannelItem[] { + const visibleCategoryIds = getVisibleCategoryIds(categories); + if (visibleCategoryIds.size === 0) { + return []; + } + + return liveStreams + .filter((item) => + visibleCategoryIds.has(String(item.category_id ?? '')) + ) + .map((item) => ({ + item, + sortTimestamp: getRecentlyAddedTimestamp(item, nowMs), + })) + .filter(({ sortTimestamp }) => sortTimestamp > 0) + .sort((a, b) => b.sortTimestamp - a.sortTimestamp) + .slice(0, limit) + .map(({ item }) => item); +} + +function getVisibleCategoryIds( + categories: readonly XtreamLiveCategoryItem[] | null | undefined +): Set { + return new Set( + (categories ?? []) + .map((category) => category.category_id ?? category.id) + .filter( + (categoryId): categoryId is string | number => + categoryId !== null && categoryId !== undefined + ) + .map((categoryId) => String(categoryId)) + ); +} + +function getRecentlyAddedTimestamp( + item: XtreamLiveChannelItem, + nowMs: number +): number { + return ( + toXtreamRecentlyAddedTimestamp(item.added, nowMs) || + toXtreamRecentlyAddedTimestamp(item.last_modified, nowMs) + ); +} 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 07ee366a1..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 @@ -369,6 +369,32 @@ describe('PortalChannelsListComponent', () => { 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 61d1b472d..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'; @@ -137,7 +138,8 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { return; } - const selectedIndex = this.filteredChannels().findIndex( + const filteredChannels = untracked(() => this.filteredChannels()); + const selectedIndex = filteredChannels.findIndex( (item) => Number(item.xtream_id) === selectedId ); if (selectedIndex < 0) { From bfa865c55c90295e1968990e43d45cfa155a922f Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 19 Jun 2026 21:00:22 +0200 Subject: [PATCH 3/4] test(xtream): cover live auto-open state reset --- ...ive-stream-auto-open-state.service.spec.ts | 38 +++++++++++++++++++ .../live-stream-auto-open-state.service.ts | 3 ++ .../live-stream-layout.component.ts | 3 ++ 3 files changed, 44 insertions(+) create mode 100644 libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-auto-open-state.service.spec.ts 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 index f4365ac9c..a7450527b 100644 --- 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 @@ -12,7 +12,10 @@ export class LiveStreamAutoOpenStateService { ); if (Number.isFinite(requestedItemId) && requestedItemId > 0) { this.pendingItemId.set(requestedItemId); + return; } + + this.pendingItemId.set(null); } clearPendingItem(): void { 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 2ae5a6402..cf5d8078e 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 @@ -376,6 +376,9 @@ 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, From 2edc4309352fab4612d872b8c54ff13fefcadee8 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 20 Jun 2026 09:36:34 +0200 Subject: [PATCH 4/4] feat(xtream): add live all-items grid --- docs/architecture/iptvnator-ui-guidelines.md | 15 +- .../grid-list/grid-list.component.scss | 72 +++++-- .../grid-list/grid-list.component.spec.ts | 132 ++++++++++++ .../grid-list/grid-list.component.ts | 129 +++++++++-- .../live-stream-layout.component.html | 55 +++-- .../live-stream-layout.component.scss | 61 +++--- .../live-stream-layout.component.spec.ts | 201 +++++++++++++----- .../live-stream-layout.component.ts | 103 ++++++++- .../live-stream-recently-added.utils.ts | 68 ------ .../recently-added.component.spec.ts | 8 +- .../recently-added.component.ts | 3 +- 11 files changed, 628 insertions(+), 219 deletions(-) delete mode 100644 libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-recently-added.utils.ts diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 8e8cf71f9..810785905 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -160,12 +160,15 @@ The shared row should be reused instead of rebuilding channel markup per view. - M3U, Xtream, and Stalker live layouts share a single sidebar collapse toggle that hides the channels rail to give the player and EPG full width. -- Xtream Live TV's root view (`/live` with no selected category) shows the - newest visible-category live channels when provider timestamps are available. - Selecting a channel from that root surface starts playback, selects the - channel's category, highlights the active category and channel, and scrolls - the category rail plus virtual channels list to the selected rows when those - rails are visible. +- Xtream Live TV's root view (`/live` with no selected category) follows the + same paginated `All Items` shell as VOD and Series: a widget header with the + total channel count, page-size controls, and page navigation above the shared + `app-grid-list`. Use the grid list's logo-oriented live variant so channel + logos stay contained in 16:9 thumbnails instead of being cropped like + VOD/series posters. Selecting a channel from that root grid starts playback, + selects the channel's category, highlights the active category and channel, + and scrolls the category rail plus virtual channels list to the selected rows + when those rails are visible. - In Xtream and Stalker live TV, the same toggle also collapses the workspace shell context sidebar (the "Live Categories" rail rendered by `WorkspaceShellContextSidebarComponent`), matching M3U's "everything quiets" 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-layout.component.html b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html index 519f02582..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 @@ -151,28 +151,41 @@ (playbackRequested)="playLive($event, true)" />
- } @else if (recentlyAddedLiveItems().length > 0) { -
-
-

- {{ 'PORTALS.SIDEBAR.RECENTLY_ADDED' | translate }} -

-

- {{ 'PORTALS.SIDEBAR.LIVE_TV' | translate }} -

-
- -
} @else { - +
+
+
+

All Items

+
+ {{ liveRootSubtitle() }} +
+
+ @if (showLiveRootPaginator()) { + + } +
+ +
} } @else { (); } +@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, @@ -143,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, @@ -156,14 +184,19 @@ describe('LiveStreamLayoutComponent', () => { selectedItem, currentPlaylist, liveStreams, + page, + limit, selectItemsFromSelectedCategory: jest.fn(() => [sampleChannel]), constructStreamUrl: jest.fn(() => 'https://example.com/live.ts'), openPlayer: jest.fn(), setSelectedItem: jest.fn(), setSelectedCategory: jest.fn(), + setPage: jest.fn((nextPage: number) => page.set(nextPage)), + setLimit: jest.fn((nextLimit: number) => limit.set(nextLimit)), }; let routerEvents: Subject; + let router: { events: Subject; navigate: jest.Mock }; const favoritesService = { getFavorites: jest.fn().mockReturnValue(of([])), }; @@ -196,14 +229,21 @@ describe('LiveStreamLayoutComponent', () => { } as typeof window.electron; routerEvents = new Subject(); + router = { events: routerEvents, navigate: jest.fn() }; xtreamStore.constructStreamUrl.mockClear(); xtreamStore.openPlayer.mockClear(); xtreamStore.setSelectedItem.mockClear(); xtreamStore.setSelectedCategory.mockClear(); + xtreamStore.setPage.mockClear(); + xtreamStore.setLimit.mockClear(); xtreamStore.selectItemsFromSelectedCategory.mockReturnValue([ sampleChannel, ]); liveStreams.set([]); + paginatedContent.set([]); + totalPages.set(0); + page.set(0); + limit.set(25); favoritesService.getFavorites.mockClear(); xtreamUrlService.resolveCatchupUrl.mockClear(); portalPlayer.isEmbeddedPlayer.mockReset(); @@ -243,7 +283,10 @@ describe('LiveStreamLayoutComponent', () => { }, { provide: Router, - useValue: { events: routerEvents.asObservable() }, + useValue: { + events: router.events.asObservable(), + navigate: router.navigate, + }, }, { provide: XtreamStore, useValue: xtreamStore }, { provide: FavoritesService, useValue: favoritesService }, @@ -276,6 +319,7 @@ describe('LiveStreamLayoutComponent', () => { EpgListComponent, EpgViewComponent, LiveEpgPanelComponent, + GridListComponent, PortalChannelsListComponent, ResizableDirective, TranslatePipe, @@ -287,6 +331,7 @@ describe('LiveStreamLayoutComponent', () => { StubEpgListComponent, StubEpgViewComponent, StubLiveEpgPanelComponent, + StubGridListComponent, StubPortalChannelsListComponent, StubResizableDirective, MockPipe( @@ -419,101 +464,151 @@ describe('LiveStreamLayoutComponent', () => { ).toBeNull(); }); - it('shows recently added live channels on the live root before a category is selected', () => { - const newestChannel = { + it('shows live all-items content with the shared grid and header paginator before a category is selected', () => { + const firstChannel = { xtream_id: 301, - name: 'Newest Channel', + name: 'First Channel', category_id: '7', added: String( Math.floor(Date.parse('2026-04-04T12:00:00Z') / 1000) ), }; - const olderChannel = { + const secondChannel = { xtream_id: 302, - name: 'Older Channel', + name: 'Second Channel', category_id: '8', added: String( Math.floor(Date.parse('2026-04-03T12:00:00Z') / 1000) ), }; + const thirdChannel = { + xtream_id: 303, + name: 'Third Channel', + category_id: '9', + added: String( + Math.floor(Date.parse('2026-04-02T12:00:00Z') / 1000) + ), + }; selectedCategoryId.set(null); selectedTypeContentLoading.set(false); - categories.set([ - { category_id: 7, category_name: 'News' }, - { category_id: 8, category_name: 'Sports' }, + limit.set(25); + page.set(0); + totalPages.set(2); + paginatedContent.set([firstChannel, secondChannel]); + xtreamStore.selectItemsFromSelectedCategory.mockReturnValue([ + firstChannel, + secondChannel, + thirdChannel, ]); - liveStreams.set([olderChannel, newestChannel]); fixture.detectChanges(); - const list = fixture.debugElement.query( - By.directive(StubPortalChannelsListComponent) + const grid = fixture.debugElement.query( + By.directive(StubGridListComponent) ); - - expect(list).not.toBeNull(); + expect(grid).not.toBeNull(); + expect(grid.componentInstance.items()).toEqual([ + firstChannel, + secondChannel, + ]); + expect(grid.componentInstance.variant()).toBe('logo'); + expect(grid.componentInstance.type()).toBe('live'); + expect(grid.componentInstance.showPaginator()).toBe(false); + expect(grid.componentInstance.pageIndex()).toBe(0); + expect(grid.componentInstance.totalPages()).toBe(2); + expect(grid.componentInstance.limit()).toBe(25); + expect(grid.componentInstance.pageSizeOptions()).toEqual([ + 10, 25, 50, 100, + ]); + expect( + fixture.nativeElement.querySelector('.category-title').textContent + ).toContain('All Items'); + expect( + fixture.nativeElement.querySelector('.category-subtitle') + .textContent + ).toContain('3 channels'); + expect( + fixture.nativeElement.querySelector('mat-paginator') + ).not.toBeNull(); + expect( + fixture.debugElement.query( + By.directive(StubPortalChannelsListComponent) + ) + ).toBeNull(); expect( fixture.nativeElement.querySelector('app-portal-empty-state') ).toBeNull(); - expect(list.componentInstance.channelsOverride()).toEqual([ - newestChannel, - olderChannel, - ]); }); - it('excludes live root recently added channels from hidden categories', () => { - const visibleChannel = { + it('plays a live all-items grid card and selects its category', () => { + const channel = { xtream_id: 301, - name: 'Visible Channel', - category_id: '1', + name: 'Grid Channel', + category_id: '7', added: String( Math.floor(Date.parse('2026-04-04T12:00:00Z') / 1000) ), }; - const hiddenChannel = { - xtream_id: 302, - name: 'Hidden Channel', - category_id: '2', - added: String( - Math.floor(Date.parse('2026-04-05T10:00:00Z') / 1000) - ), - }; selectedCategoryId.set(null); selectedTypeContentLoading.set(false); - categories.set([{ category_id: 1, category_name: 'News' }]); - liveStreams.set([hiddenChannel, visibleChannel]); + paginatedContent.set([channel]); + xtreamStore.selectItemsFromSelectedCategory.mockReturnValue([channel]); fixture.detectChanges(); - const list = fixture.debugElement.query( - By.directive(StubPortalChannelsListComponent) + const grid = fixture.debugElement.query( + By.directive(StubGridListComponent) ); + grid.componentInstance.itemClicked.emit(channel); + fixture.detectChanges(); - expect(list).not.toBeNull(); - expect(list.componentInstance.channelsOverride()).toEqual([ - visibleChannel, - ]); + expect(xtreamStore.constructStreamUrl).toHaveBeenCalledWith(channel); + expect(xtreamStore.setSelectedCategory).toHaveBeenCalledWith(7); + expect( + fixture.debugElement.query(By.directive(StubWebPlayerViewComponent)) + ).not.toBeNull(); }); - it('refreshes live root recently added channels as the clock advances', () => { - const futureChannel = { - xtream_id: 301, - name: 'Future Channel', - category_id: '1', - added: String( - Math.floor((fixedNow.getTime() + 25 * 60 * 60 * 1000) / 1000) - ), - }; + it('updates live root pagination from the header paginator', () => { selectedCategoryId.set(null); selectedTypeContentLoading.set(false); - liveStreams.set([futureChannel]); + xtreamStore.selectItemsFromSelectedCategory.mockReturnValue( + Array.from({ length: 80 }, (_, index) => ({ + xtream_id: index + 1, + name: `Channel ${index + 1}`, + })) + ); + paginatedContent.set([]); - expect(component.recentlyAddedLiveItems()).toEqual([]); - - jest.setSystemTime(new Date(fixedNow.getTime() + 2 * 60 * 60 * 1000)); - jest.advanceTimersByTime(30_000); fixture.detectChanges(); - expect(component.recentlyAddedLiveItems()).toEqual([futureChannel]); + const paginator = fixture.debugElement.query(By.css('mat-paginator')); + expect(paginator).not.toBeNull(); + paginator.triggerEventHandler('page', { + pageIndex: 1, + pageSize: 50, + length: 80, + previousPageIndex: 0, + } satisfies PageEvent); + + expect(xtreamStore.setPage).toHaveBeenCalledWith(1); + expect(xtreamStore.setLimit).toHaveBeenCalledWith(50); + expect(router.navigate).toHaveBeenCalledWith([], { + relativeTo: expect.any(Object), + queryParams: { page: 2 }, + queryParamsHandling: 'merge', + replaceUrl: true, + }); + }); + + it('applies the live root page query parameter', () => { + selectedCategoryId.set(null); + selectedTypeContentLoading.set(false); + + routeQueryParamMap.next(convertToParamMap({ page: '3' })); + fixture.detectChanges(); + + expect(xtreamStore.setPage).toHaveBeenCalledWith(2); }); it('shows the cross-category live channel list while searching from the live root', () => { 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 cf5d8078e..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, @@ -68,13 +73,22 @@ import { PortalChannelsListComponent } from '../portal-channels-list/portal-chan import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; import { LiveStreamAutoOpenStateService } from './live-stream-auto-open-state.service'; -import { - getRecentlyAddedLiveItems, - XtreamLiveChannelItem, -} from './live-stream-recently-added.utils'; const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode'; +interface XtreamLiveChannelItem { + readonly added?: string; + readonly category_id?: string | number; + readonly last_modified?: string; + readonly name?: string; + readonly poster_url?: string; + readonly stream_icon?: string; + readonly title?: string; + readonly tv_archive?: number | null; + readonly tv_archive_duration?: number | string | null; + readonly xtream_id: number; +} + @Component({ selector: 'app-live-stream-layout', templateUrl: './live-stream-layout.component.html', @@ -87,9 +101,11 @@ const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode'; MatButtonModule, MatIcon, MatMenuModule, + MatPaginatorModule, MatProgressSpinnerModule, MatTooltipModule, NgTemplateOutlet, + GridListComponent, PortalChannelsListComponent, PortalEmptyStateComponent, ResizableDirective, @@ -100,6 +116,7 @@ const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode'; }) export class LiveStreamLayoutComponent implements OnInit, OnDestroy { private readonly destroyRef = inject(DestroyRef); + private readonly hostElement = inject(ElementRef); private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly favoritesService = inject(FavoritesService); @@ -130,6 +147,11 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { 'q', (value) => (value ?? '').trim() ); + private readonly routePageIndex = queryParamSignal( + this.route, + 'page', + (value) => this.toPageIndex(value) + ); readonly workspaceSearchTerm = computed(() => this.isWorkspaceLayout ? this.routeSearchTerm() : '' ); @@ -198,12 +220,26 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { readonly liveChannelSortLabel = computed(() => getPortalChannelSortModeLabel(this.liveChannelSortMode()) ); - readonly recentlyAddedLiveItems = computed(() => - getRecentlyAddedLiveItems( - this.getAllLiveStreams(), - this.categories(), - this.currentTimeMs() - ) + readonly liveRootPageSizeOptions = [10, 25, 50, 100]; + readonly liveRootItems = computed( + () => + this.xtreamStore.getPaginatedContent() as unknown as Record< + string, + unknown + >[] + ); + readonly liveRootItemCount = computed( + () => this.xtreamStore.selectItemsFromSelectedCategory().length + ); + readonly liveRootSubtitle = computed(() => { + const count = this.liveRootItemCount(); + return `${count} ${count === 1 ? 'channel' : 'channels'}`; + }); + readonly liveRootPageIndex = this.xtreamStore.page; + readonly liveRootLimit = this.xtreamStore.limit; + readonly liveRootTotalPages = this.xtreamStore.getTotalPages; + readonly showLiveRootPaginator = computed( + () => this.liveRootItemCount() > 0 ); readonly selectedCategoryInfo = computed(() => { @@ -243,6 +279,20 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { onCleanup(() => clearInterval(intervalId)); }); + effect(() => { + if ( + this.xtreamStore.selectedContentType() !== 'live' || + this.showLiveChannelSidebar() + ) { + return; + } + + const pageIndex = this.routePageIndex(); + if (this.liveRootPageIndex() !== pageIndex) { + this.xtreamStore.setPage(pageIndex); + } + }); + // Read pending auto-open state on every NavigationEnd — covers both the // initial navigation (Angular fires NavigationEnd after component creation) // and re-navigation to the same /live route when the component is reused. @@ -415,6 +465,25 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { persistPortalChannelSortMode(LIVE_CHANNEL_SORT_STORAGE_KEY, mode); } + onLiveRootPageChange(event: PageEvent): void { + this.xtreamStore.setPage(event.pageIndex); + this.xtreamStore.setLimit(event.pageSize); + this.scrollLiveRootGridToTop(); + + void this.router.navigate([], { + relativeTo: this.route, + queryParams: { + page: event.pageIndex > 0 ? event.pageIndex + 1 : null, + }, + queryParamsHandling: 'merge', + replaceUrl: true, + }); + } + + onLiveRootItemClick(item: unknown): void { + this.playLive(item as XtreamLiveChannelItem); + } + onLiveEpgPanelCollapsedChange(collapsed: boolean): void { const state: LiveEpgPanelState = collapsed ? 'collapsed' : 'expanded'; this.liveEpgPanelState.set(state); @@ -505,6 +574,18 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { return this.xtreamStore.liveStreams() as unknown as XtreamLiveChannelItem[]; } + private toPageIndex(value: string | null): number { + const page = Number(value); + return Number.isInteger(page) && page > 0 ? page - 1 : 0; + } + + private scrollLiveRootGridToTop(): void { + const gridList = this.hostElement.nativeElement.querySelector( + 'app-grid-list' + ) as HTMLElement | null; + gridList?.scrollTo?.({ top: 0 }); + } + private getVisibleChannels(): XtreamLiveChannelItem[] { return this.xtreamStore.selectItemsFromSelectedCategory() as XtreamLiveChannelItem[]; } diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-recently-added.utils.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-recently-added.utils.ts deleted file mode 100644 index 1137e4937..000000000 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-recently-added.utils.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { toXtreamRecentlyAddedTimestamp } from '@iptvnator/shared/interfaces'; - -export interface XtreamLiveChannelItem { - readonly added?: string; - readonly category_id?: string | number; - readonly last_modified?: string; - readonly name?: string; - readonly poster_url?: string; - readonly stream_icon?: string; - readonly title?: string; - readonly tv_archive?: number | null; - readonly tv_archive_duration?: number | string | null; - readonly xtream_id: number; -} - -interface XtreamLiveCategoryItem { - readonly category_id?: string | number; - readonly id?: string | number; -} - -export function getRecentlyAddedLiveItems( - liveStreams: readonly XtreamLiveChannelItem[], - categories: readonly XtreamLiveCategoryItem[] | null | undefined, - nowMs: number, - limit = 20 -): XtreamLiveChannelItem[] { - const visibleCategoryIds = getVisibleCategoryIds(categories); - if (visibleCategoryIds.size === 0) { - return []; - } - - return liveStreams - .filter((item) => - visibleCategoryIds.has(String(item.category_id ?? '')) - ) - .map((item) => ({ - item, - sortTimestamp: getRecentlyAddedTimestamp(item, nowMs), - })) - .filter(({ sortTimestamp }) => sortTimestamp > 0) - .sort((a, b) => b.sortTimestamp - a.sortTimestamp) - .slice(0, limit) - .map(({ item }) => item); -} - -function getVisibleCategoryIds( - categories: readonly XtreamLiveCategoryItem[] | null | undefined -): Set { - return new Set( - (categories ?? []) - .map((category) => category.category_id ?? category.id) - .filter( - (categoryId): categoryId is string | number => - categoryId !== null && categoryId !== undefined - ) - .map((categoryId) => String(categoryId)) - ); -} - -function getRecentlyAddedTimestamp( - item: XtreamLiveChannelItem, - nowMs: number -): number { - return ( - toXtreamRecentlyAddedTimestamp(item.added, nowMs) || - toXtreamRecentlyAddedTimestamp(item.last_modified, nowMs) - ); -} 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 e9f7198a0..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 @@ -95,8 +95,8 @@ describe('RecentlyAddedComponent', () => { dateNowSpy.mockRestore(); }); - it('does not let far-future provider dates pin the first VOD rail slots', () => { - const freshItems = Array.from({ length: 21 }, (_, index) => + it('shows up to 30 recently added VOD items after filtering invalid provider dates', () => { + const freshItems = Array.from({ length: 31 }, (_, index) => createItem(index + 1, { added: String( Math.floor( @@ -122,8 +122,10 @@ describe('RecentlyAddedComponent', () => { ); const titles = component.recentlyAddedVod().map((item) => item.title); - expect(titles).toHaveLength(20); + expect(titles).toHaveLength(30); expect(titles[0]).toBe('Fresh 1'); + expect(titles.at(-1)).toBe('Fresh 30'); + expect(titles).not.toContain('Fresh 31'); expect(titles).not.toContain('Future Provider Item'); }); 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 28ded761e..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); }