mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(xtream): address live recents review feedback
This commit is contained in:
1 parent
210f0ebabc
commit
6ad5f5e207
7 files changed
+223
-77
No files matched your search
@@ -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
|
||||
|
||||
+41
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
+62
@@ -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);
|
||||
|
||||
+19
-72
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+68
@@ -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)
|
||||
);
|
||||
}
|
||||
+26
@@ -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({
|
||||
|
||||
+3
-1
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user