feat(ui): offer programme details from the live channel context menu

The Xtream and Stalker live sidebars hide the per-row (i) button by
design — the current programme is already inline — which left no way to
read a programme description without switching channels. Right-click now
offers "Show program details" (opening the existing EPG description
dialog) in both portal sidebars and in the favorites/recently-viewed
lists, whenever the row has a current programme. The context menu is no
longer gated on EPG-mapping support alone, so the item also works in the
PWA.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-08 11:07:06 +02:00
1 parent 7103f7e734
commit 9ca7175bff
10 files changed
+253 -12

No files matched your search

@@ -0,0 +1,9 @@
---
type: feature
area: ui
---
Right-clicking a live channel now offers "Show program details" in the
context menu — in the Xtream and Stalker channel sidebars and in the
favorites and recently-viewed lists — whenever the channel has current
programme information.
@@ -62,6 +62,18 @@
></div>
<mat-menu #channelContextMenu="matMenu">
@if (contextMenuChannel()?.currentEpgProgram) {
<button
mat-menu-item
data-test-id="program-details-menu-item"
(click)="openProgramDetails()"
>
<mat-icon>event_note</mat-icon>
<span>{{
'EPG.PROGRAM_DIALOG.SHOW_PROGRAM_DETAILS' | translate
}}</span>
</button>
}
@if (supportsEpgMapping) {
<button mat-menu-item (click)="openEpgMapping()">
<mat-icon>settings_remote</mat-icon>
@@ -4,6 +4,7 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { MatDialog } from '@angular/material/dialog';
import { TranslateModule } from '@ngx-translate/core';
import { ChannelDetailsDialogComponent } from '@iptvnator/ui/components';
import { EpgItemDescriptionComponent } from '@iptvnator/ui/epg';
import { UnifiedFavoriteChannel } from '@iptvnator/portal/shared/util';
import { Channel } from '@iptvnator/shared/interfaces';
import { SettingsStore } from '@iptvnator/services';
@@ -158,6 +159,46 @@ describe('GlobalFavoritesListComponent', () => {
);
});
it('opens programme details from the context menu when the row has a current program', async () => {
const program = {
title: 'Current Show',
desc: 'Current description',
channel: 'chan-1',
start: '2026-04-05 05:30:00',
stop: '2026-04-05 06:00:00',
category: null,
};
const row = buildChannel('a', 'Alpha', { tvgId: 'chan-1' });
fixture.componentRef.setInput('channels', [row]);
fixture.componentRef.setInput(
'epgMap',
new Map([['chan-1', program as never]])
);
fixture.detectChanges();
const enriched = fixture.componentInstance.enrichedChannels()[0];
expect(enriched.currentEpgProgram).toBe(program);
expect(
fixture.componentInstance.hasChannelContextMenu(enriched)
).toBe(true);
jest.spyOn(
fixture.componentInstance.contextMenuTrigger(),
'openMenu'
).mockImplementation();
fixture.componentInstance.onChannelContextMenu(enriched, {
clientX: 24,
clientY: 32,
} as MouseEvent);
await Promise.resolve();
fixture.componentInstance.openProgramDetails();
expect(dialog.open).toHaveBeenCalledWith(EpgItemDescriptionComponent, {
data: program,
});
});
it('emits recent row removal from the context menu', async () => {
const row = buildChannel('a', 'Alpha');
const removed = jest.fn();
@@ -31,6 +31,7 @@ import {
} from '@iptvnator/shared/interfaces';
import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state';
import { EpgMappingDialogComponent } from '@iptvnator/ui/components';
import { EpgItemDescriptionComponent } from '@iptvnator/ui/epg';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import {
DEFAULT_FAVORITES_CHANNEL_SORT_MODE,
@@ -178,16 +179,31 @@ export class GlobalFavoritesListComponent {
});
}
hasChannelContextMenu(channel: UnifiedFavoriteChannel): boolean {
hasChannelContextMenu(
channel: UnifiedFavoriteChannel & {
currentEpgProgram?: EpgProgram | null;
}
): boolean {
return (
Boolean(channel.m3uChannel) ||
this.mode() === 'recent' ||
Boolean(channel.currentEpgProgram) ||
(this.supportsEpgMapping &&
(channel.xtreamId != null ||
Boolean(this.stalkerItemId(channel))))
);
}
openProgramDetails(): void {
const program = this.contextMenuChannel()?.currentEpgProgram;
if (!program) {
return;
}
this.contextMenuTrigger().closeMenu();
this.dialog.open(EpgItemDescriptionComponent, { data: program });
}
openEpgMapping(): void {
const item = this.contextMenuChannel();
if (!item) {
@@ -115,7 +115,7 @@
[showFavoriteButton]="true"
[showProgramInfoButton]="false"
[showDetailsContextMenu]="
supportsEpgMapping && !isRadioMode()
hasChannelContextMenu(item) && !isRadioMode()
"
[isFavorite]="
favorites.get(normalizeStalkerEntityId(item.id)) ??
@@ -278,8 +278,22 @@
></div>
<mat-menu #channelContextMenu="matMenu">
<button mat-menu-item (click)="openEpgMapping()">
<mat-icon>settings_remote</mat-icon>
<span>{{ 'CHANNELS.MAP_EPG' | translate }}</span>
</button>
@if (contextMenuProgram()) {
<button
mat-menu-item
data-test-id="program-details-menu-item"
(click)="openProgramDetails()"
>
<mat-icon>event_note</mat-icon>
<span>{{
'EPG.PROGRAM_DIALOG.SHOW_PROGRAM_DETAILS' | translate
}}</span>
</button>
}
@if (supportsEpgMapping) {
<button mat-menu-item (click)="openEpgMapping()">
<mat-icon>settings_remote</mat-icon>
<span>{{ 'CHANNELS.MAP_EPG' | translate }}</span>
</button>
}
</mat-menu>
@@ -17,7 +17,11 @@ import {
ResizableDirective,
} from '@iptvnator/portal/shared/util';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { EpgListViewComponent, EpgTimelineComponent } from '@iptvnator/ui/epg';
import {
EpgItemDescriptionComponent,
EpgListViewComponent,
EpgTimelineComponent,
} from '@iptvnator/ui/epg';
import {
AudioPlayerComponent,
type PlaybackFallbackRequest,
@@ -459,6 +463,35 @@ describe('StalkerLiveStreamLayoutComponent', () => {
window.electron = originalElectron;
});
it('offers programme details from the row context menu when a preview program exists', () => {
fixture.detectChanges();
const program = {
title: 'Current Show',
desc: 'Current description',
channel: 'stalker-10',
start: '2026-04-05 05:30:00',
stop: '2026-04-05 06:00:00',
category: null,
} as never;
component.epgPreviewPrograms.set('10', program);
const rowWithProgram = { id: '10', name: 'Chan 10' } as never;
expect(component.hasChannelContextMenu(rowWithProgram)).toBe(true);
component.contextMenuChannel.set(rowWithProgram);
expect(component.contextMenuProgram()).toBe(program);
component.openProgramDetails();
const dialog = TestBed.inject(MatDialog) as unknown as {
open: jest.Mock;
};
expect(dialog.open).toHaveBeenCalledWith(EpgItemDescriptionComponent, {
data: program,
});
});
it('renders the controlled epg list and removes the load-more button', () => {
fixture.detectChanges();
@@ -44,6 +44,7 @@ import {
} from '@iptvnator/shared/interfaces';
import {
EpgDateNavigationDirection,
EpgItemDescriptionComponent,
EpgListViewComponent,
EpgTimelineComponent,
getTodayEpgDateKey,
@@ -859,6 +860,37 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
});
}
/** Current preview programme of the row under the context menu. */
contextMenuProgram(): EpgProgram | null {
const channel = this.contextMenuChannel();
if (!channel) {
return null;
}
return (
this.epgPreviewPrograms.get(normalizeStalkerEntityId(channel.id)) ??
null
);
}
/** Whether right-click has anything to offer for this row. */
hasChannelContextMenu(item: StalkerItvChannel): boolean {
return (
this.supportsEpgMapping ||
this.epgPreviewPrograms.has(normalizeStalkerEntityId(item.id))
);
}
openProgramDetails(): void {
const program = this.contextMenuProgram();
if (!program) {
return;
}
this.contextMenuTrigger().closeMenu();
this.dialog.open(EpgItemDescriptionComponent, { data: program });
}
async openEpgMapping(): Promise<void> {
const channel = this.contextMenuChannel();
if (!channel) {
@@ -44,7 +44,7 @@
[showEpg]="supportsEpg"
[showFavoriteButton]="true"
[showProgramInfoButton]="false"
[showDetailsContextMenu]="supportsEpgMapping"
[showDetailsContextMenu]="hasChannelContextMenu(item)"
[isFavorite]="favorites.get(favoriteKeyFor(item)) ?? false"
(clicked)="playClicked.emit(item)"
(activated)="playbackRequested.emit(item)"
@@ -63,10 +63,24 @@
></div>
<mat-menu #channelContextMenu="matMenu">
<button mat-menu-item (click)="openEpgMapping()">
<mat-icon>settings_remote</mat-icon>
<span>{{ 'CHANNELS.MAP_EPG' | translate }}</span>
</button>
@if (contextMenuProgram()) {
<button
mat-menu-item
data-test-id="program-details-menu-item"
(click)="openProgramDetails()"
>
<mat-icon>event_note</mat-icon>
<span>{{
'EPG.PROGRAM_DIALOG.SHOW_PROGRAM_DETAILS' | translate
}}</span>
</button>
}
@if (supportsEpgMapping) {
<button mat-menu-item (click)="openEpgMapping()">
<mat-icon>settings_remote</mat-icon>
<span>{{ 'CHANNELS.MAP_EPG' | translate }}</span>
</button>
}
</mat-menu>
} @else {
<div class="empty-search-state">
@@ -12,8 +12,10 @@ import {
FavoritesService,
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import { ChannelListItemComponent } from '@iptvnator/ui/components';
import { EpgItemDescriptionComponent } from '@iptvnator/ui/epg';
import { PortalChannelsListComponent } from './portal-channels-list.component';
function buildEpgItem(params: {
@@ -459,4 +461,46 @@ describe('PortalChannelsListComponent', () => {
);
expect(fixture.componentInstance.favorites.get('live:253')).toBe(true);
});
it('offers programme details from the row context menu when a preview program exists', () => {
const component = fixture.componentInstance;
selectedTypeContentLoading.set(false);
selectedChannels.set([{ title: 'Cartoon Network', xtream_id: 50 }]);
fixture.detectChanges();
const program = {
title: 'Current Show',
desc: 'Current description',
channel: 'channel-50',
start: '2026-04-05 05:30:00',
stop: '2026-04-05 06:00:00',
category: null,
} as never;
component.epgPrograms.set(50, program);
// Without a program (and without EPG-mapping support in this
// harness) the row offers no context menu at all.
expect(
component.hasChannelContextMenu({ xtream_id: 51 })
).toBe(false);
expect(
component.hasChannelContextMenu({ xtream_id: 50 })
).toBe(true);
component.contextMenuChannel.set({
title: 'Cartoon Network',
xtream_id: 50,
});
expect(component.contextMenuProgram()).toBe(program);
const dialog = TestBed.inject(MatDialog);
const openSpy = jest
.spyOn(dialog, 'open')
.mockReturnValue({} as MatDialogRef<unknown>);
component.openProgramDetails();
expect(openSpy).toHaveBeenCalledWith(EpgItemDescriptionComponent, {
data: program,
});
});
});
@@ -37,6 +37,7 @@ import {
ChannelListSkeletonComponent,
EpgMappingDialogComponent,
} from '@iptvnator/ui/components';
import { EpgItemDescriptionComponent } from '@iptvnator/ui/epg';
import {
PortalChannelSortMode,
sortPortalChannelItems,
@@ -501,6 +502,31 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
});
}
/** Current preview programme of the row under the context menu. */
contextMenuProgram(): EpgProgram | null {
const channel = this.contextMenuChannel();
if (!channel) {
return null;
}
return this.epgPrograms.get(channel.xtream_id) ?? null;
}
/** Whether right-click has anything to offer for this row. */
hasChannelContextMenu(item: XtreamChannelListItem): boolean {
return this.supportsEpgMapping || this.epgPrograms.has(item.xtream_id);
}
openProgramDetails(): void {
const program = this.contextMenuProgram();
if (!program) {
return;
}
this.contextMenuTrigger().closeMenu();
this.dialog.open(EpgItemDescriptionComponent, { data: program });
}
openEpgMapping(): void {
const channel = this.contextMenuChannel();
if (!channel) {