feat(xtream): show recently added live channels

This commit is contained in:
4gray committed 2026-06-19 19:59:42 +02:00
1 parent 3d2549dace
commit 210f0ebabc
9 files changed
+248 -23

No files matched your search

@@ -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"
@@ -151,6 +151,23 @@
(playbackRequested)="playLive($event, true)"
/>
</div>
} @else if (recentlyAddedLiveItems().length > 0) {
<section class="live-initial-content">
<header class="live-initial-content__header">
<h2 class="live-initial-content__title">
{{ 'PORTALS.SIDEBAR.RECENTLY_ADDED' | translate }}
</h2>
<p class="live-initial-content__subtitle">
{{ 'PORTALS.SIDEBAR.LIVE_TV' | translate }}
</p>
</header>
<app-portal-channels-list
[sortMode]="'server'"
[channelsOverride]="recentlyAddedLiveItems()"
(playClicked)="playLive($event)"
(playbackRequested)="playLive($event, true)"
/>
</section>
} @else {
<app-portal-empty-state
icon="live_tv"
@@ -30,6 +30,49 @@
height: 100%;
}
.live-initial-content {
align-self: center;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 12px;
height: 100%;
max-width: 640px;
padding: 18px;
width: min(640px, 100%);
}
.live-initial-content__header {
display: flex;
flex-direction: column;
gap: 2px;
padding: 0 4px;
}
.live-initial-content__title {
color: var(--mat-sys-on-surface);
font-size: 1rem;
font-weight: 600;
line-height: 1.2;
margin: 0;
}
.live-initial-content__subtitle {
color: var(--mat-sys-on-surface-variant);
font-size: 0.82rem;
line-height: 1.35;
margin: 0;
}
.live-initial-content app-portal-channels-list {
border: 1px solid var(--app-separator);
border-radius: 8px;
display: block;
flex: 1;
min-height: 0;
overflow: hidden;
}
.loading-indicator {
display: flex;
justify-content: center;
@@ -42,7 +85,11 @@
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0)),
linear-gradient(
180deg,
rgba(255, 255, 255, 0.04),
rgba(255, 255, 255, 0)
),
rgba(127, 127, 127, 0.05);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.04),
@@ -33,10 +33,7 @@ import {
import { EpgItem, EpgProgram } from '@iptvnator/shared/interfaces';
import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component';
import { LiveStreamLayoutComponent } from './live-stream-layout.component';
import {
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode';
@@ -47,6 +44,7 @@ const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode';
})
class StubPortalChannelsListComponent {
readonly sortMode = input<'server' | 'name-asc' | 'name-desc'>('server');
readonly channelsOverride = input<unknown[] | null>(null);
readonly searchTermInput = input('');
readonly playClicked = output<unknown>();
readonly playbackRequested = output<unknown>();
@@ -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();
@@ -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<string, unknown>);
// 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<string, unknown>
);
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
@@ -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({
@@ -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;
@@ -52,6 +52,7 @@ class MockXtreamStore {
describe('RecentlyAddedComponent', () => {
let store: MockXtreamStore;
let dateNowSpy: jest.SpyInstance<number, []>;
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',
},
});
});
});
@@ -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)
);
}
}