mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(xtream): show recently added live channels
This commit is contained in:
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"
|
||||
|
||||
+17
@@ -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"
|
||||
|
||||
+48
-1
@@ -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),
|
||||
|
||||
+48
-8
@@ -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();
|
||||
|
||||
+37
-6
@@ -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
|
||||
|
||||
+22
-4
@@ -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({
|
||||
|
||||
+21
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user