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) + ); + } }