fix(xtream): address live recents review feedback

This commit is contained in:
4gray committed 2026-06-19 20:50:14 +02:00
1 parent 210f0ebabc
commit 6ad5f5e207
7 files changed
+223 -77

No files matched your search

+4 -4
View File
@@ -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
@@ -0,0 +1,41 @@
import { Injectable, signal } from '@angular/core';
@Injectable()
export class LiveStreamAutoOpenStateService {
readonly pendingItemId = signal<number | null>(null);
captureFromHistoryState(): void {
const requestedItemId = Number(
(window.history.state as Record<string, unknown> | 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.
}
}
}
@@ -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<number, number>([[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);
@@ -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<number | null>(null);
private readonly pendingAutoOpenLiveItemId =
this.liveAutoOpenState.pendingItemId;
readonly selectedLiveItem = computed<XtreamLiveChannelItem | null>(() => {
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<string, unknown>
);
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<string, unknown> | 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
}
}
}
@@ -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<string> {
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)
);
}
@@ -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({
@@ -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) {