From 9ca7175bff69a2c596a92fa2caaaebd1634f8f7c Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 8 Aug 2026 11:07:06 +0200 Subject: [PATCH] feat(ui): offer programme details from the live channel context menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .changes/ui-program-details-context-menu.md | 9 ++++ .../global-favorites-list.component.html | 12 +++++ .../global-favorites-list.component.spec.ts | 41 +++++++++++++++++ .../global-favorites-list.component.ts | 18 +++++++- .../stalker-live-stream-layout.component.html | 24 +++++++--- ...alker-live-stream-layout.component.spec.ts | 35 ++++++++++++++- .../stalker-live-stream-layout.component.ts | 32 ++++++++++++++ .../portal-channels-list.component.html | 24 +++++++--- .../portal-channels-list.component.spec.ts | 44 +++++++++++++++++++ .../portal-channels-list.component.ts | 26 +++++++++++ 10 files changed, 253 insertions(+), 12 deletions(-) create mode 100644 .changes/ui-program-details-context-menu.md diff --git a/.changes/ui-program-details-context-menu.md b/.changes/ui-program-details-context-menu.md new file mode 100644 index 000000000..7d351743c --- /dev/null +++ b/.changes/ui-program-details-context-menu.md @@ -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. diff --git a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html index a79a63c55..2d9bc03ab 100644 --- a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html +++ b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html @@ -62,6 +62,18 @@ > + @if (contextMenuChannel()?.currentEpgProgram) { + + } @if (supportsEpgMapping) { + @if (contextMenuProgram()) { + + } + @if (supportsEpgMapping) { + + } diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts index 3b0034850..94e36b3a0 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts @@ -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(); diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts index 3b795996c..5b5188294 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -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 { const channel = this.contextMenuChannel(); if (!channel) { diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html index 3f72bab30..f31037830 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html @@ -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 @@ > - + @if (contextMenuProgram()) { + + } + @if (supportsEpgMapping) { + + } } @else {
diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts index 33b6338bb..c74ec8170 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts @@ -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); + component.openProgramDetails(); + + expect(openSpy).toHaveBeenCalledWith(EpgItemDescriptionComponent, { + data: program, + }); + }); }); diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts index a85ab35af..69caa0063 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts @@ -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) {