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) {