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