mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 09:26:14 -08:00
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:
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.
|
||||
+12
@@ -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>
|
||||
|
||||
+41
@@ -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();
|
||||
|
||||
+17
-1
@@ -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) {
|
||||
|
||||
+19
-5
@@ -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>
|
||||
+34
-1
@@ -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();
|
||||
|
||||
|
||||
+32
@@ -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) {
|
||||
|
||||
+19
-5
@@ -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">
|
||||
|
||||
+44
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
+26
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user