Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changes/ui-program-details-context-menu.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ <h2 class="category-title">
[showFavoriteButton]="true"
[showProgramInfoButton]="false"
[showDetailsContextMenu]="
supportsEpgMapping && !isRadioMode()
hasChannelContextMenu(item) && !isRadioMode()
"
[isFavorite]="
favorites.get(normalizeStalkerEntityId(item.id)) ??
Expand Down Expand Up @@ -278,8 +278,22 @@ <h2 class="category-title">
></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>
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
} from '@iptvnator/shared/interfaces';
import {
EpgDateNavigationDirection,
EpgItemDescriptionComponent,
EpgListViewComponent,
EpgTimelineComponent,
getTodayEpgDateKey,
Expand Down Expand Up @@ -859,6 +860,42 @@ 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. Keys on data
* presence rather than runtime capability — but note EPG previews are
* currently populated only where `supportsEpg` (Electron), so in the PWA
* this stays false until portal EPG previews exist there.
*/
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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import {
LiveLayoutSidebarStateService,
PORTAL_PLAYER,
} from '@iptvnator/portal/shared/util';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import {
PlaylistsService,
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import { EpgItemDescriptionComponent } from '@iptvnator/ui/epg';
import { ElectronStreamHeadersService } from '@iptvnator/ui/playback';
import { StalkerLiveStreamLayoutComponent } from './stalker-live-stream-layout.component';

describe('StalkerLiveStreamLayoutComponent row context menu', () => {
let fixture: ComponentFixture<StalkerLiveStreamLayoutComponent>;
let component: StalkerLiveStreamLayoutComponent;
const playlist = signal({ _id: 'playlist-one', title: 'Portal One' });
const channels = [
{
id: '10',
cmd: 'ffrt4://itv/10',
name: 'Chan 10',
o_name: 'Chan 10',
logo: 'ten.png',
},
];
const store = {
getSelectedCategoryName: signal('All'),
currentPlaylist: playlist,
selectedContentType: signal<'itv' | 'radio'>('itv'),
selectedCategoryId: signal<string | null>('all'),
selectedItvId: signal<string | undefined>(channels[0].id),
selectedItem: signal<(typeof channels)[number] | null>(null),
itvChannels: signal(channels),
radioChannels: signal([]),
searchPhrase: signal(''),
hasMoreChannels: signal(false),
itvFullListActive: signal(false),
itvSelectedCategoryFromCache: signal(false),
itvFullListLoading: signal(false),
itvFullListProgress: signal(null),
itvFullChannelList: signal([]),
isPaginatedContentLoading: signal(false),
selectedItvEpgPrograms: signal([]),
bulkItvEpgByChannel: signal({}),
isLoadingBulkItvEpg: signal(false),
setItvChannels: jest.fn(),
setRadioChannels: jest.fn(),
setPage: jest.fn(),
preloadItvChannels: jest.fn(),
applyMappedItvEpg: jest.fn(),
clearBulkItvEpgCache: jest.fn(),
ensureBulkItvEpg: jest.fn(),
fetchChannelEpg: jest.fn(),
resolveItvPlayback: jest.fn(),
resolveRadioPlayback: jest.fn(),
addToFavorites: jest.fn(),
removeFromFavorites: jest.fn(),
setSelectedItem: jest.fn(),
};

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [StalkerLiveStreamLayoutComponent],
providers: [
{ provide: StalkerStore, useValue: store },
{
provide: RuntimeCapabilitiesService,
useValue: {
supportsEpg: false,
isElectron: false,
supportsEpgMapping: false,
},
},
{
provide: PlaylistsService,
useValue: { getPortalFavorites: () => of([]) },
},
{
provide: SettingsStore,
useValue: { openStreamOnDoubleClick: signal(false) },
},
{
provide: PORTAL_PLAYER,
useValue: {
isEmbeddedPlayer: () => true,
openResolvedPlayback: jest.fn(),
},
},
{
provide: ElectronStreamHeadersService,
useValue: { apply: jest.fn(), clear: jest.fn() },
},
{
provide: LiveLayoutSidebarStateService,
useValue: { isCollapsed: signal(false), toggle: jest.fn() },
},
{ provide: EpgRuntimeBridgeService, useValue: {} },
{ provide: MatDialog, useValue: { open: jest.fn() } },
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
{
provide: TranslateService,
useValue: { instant: (key: string) => key },
},
],
})
.overrideComponent(StalkerLiveStreamLayoutComponent, {
set: { template: '' },
})
.compileComponents();
fixture = TestBed.createComponent(StalkerLiveStreamLayoutComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

afterEach(() => fixture.destroy());

it('offers programme details exactly for rows with a preview program', () => {
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;
const rowWithoutProgram = { id: '11', name: 'Chan 11' } as never;
// supportsEpgMapping is false in this harness, so the programme is
// the only thing that can justify a context menu.
expect(component.hasChannelContextMenu(rowWithProgram)).toBe(true);
expect(component.hasChannelContextMenu(rowWithoutProgram)).toBe(false);

component.contextMenuChannel.set(rowWithProgram);
expect(component.contextMenuProgram()).toBe(program);

// The empty test template renders no menu trigger — stub it so the
// action can close the menu it was invoked from.
const closeMenu = jest.fn();
Object.defineProperty(component, 'contextMenuTrigger', {
value: () => ({ closeMenu }),
});
component.openProgramDetails();

expect(closeMenu).toHaveBeenCalled();
const dialog = TestBed.inject(MatDialog) as unknown as {
open: jest.Mock;
};
expect(dialog.open).toHaveBeenCalledWith(EpgItemDescriptionComponent, {
data: program,
});
});
});
Loading
Loading