Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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 @@ -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,
Expand Down Expand Up @@ -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();

Expand Down
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,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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand All @@ -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">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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,
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
ChannelListSkeletonComponent,
EpgMappingDialogComponent,
} from '@iptvnator/ui/components';
import { EpgItemDescriptionComponent } from '@iptvnator/ui/epg';
import {
PortalChannelSortMode,
sortPortalChannelItems,
Expand Down Expand Up @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Populate EPG data before enabling PWA menu support

When running as a PWA (window.electron is absent), supportsEpgMapping is false and epgPrograms can never make this fallback true: every Xtream population path is guarded by supportsEpg, which itself requires Electron bridge methods. The same problem affects the other changed surfaces—the Stalker layout explicitly clears epgPreviewPrograms when supportsEpg is false, and UnifiedLiveTabComponent clears the favorites/recent EPG map—so the new program-details action remains unavailable throughout the PWA despite the change's intended PWA support. Populate portal-provided EPG independently of the Electron-only capability, and cover the actual PWA path rather than only injecting a map in unit tests.

AGENTS.md reference: AGENTS.md:L79-L85

Useful? React with 👍 / 👎.

}

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) {
Expand Down
Loading