refactor(ui): one channel context menu for every channel list (#1948)

Seven channel lists (the M3U all-channels, groups, favorites and recent
views, the Stalker and Xtream live lists, and the unified favorites and
recents list) each carried their own anchor element, MatMenuTrigger,
position signal, open-at-pointer code, mat-menu markup and anchor CSS.

`app-channel-context-menu` (@iptvnator/ui/components) now owns the anchor,
the menu and its rows in one order: Map EPG, Show details, View in a
playlist, and Remove from history as the kit's danger row after a divider.
A host sets which rows apply, handles their outputs with its existing
handlers, and calls `open(event)` from the row's contextMenuRequested.

The host specs now assert that the shared menu opens at the pointer; the
new component spec covers row order, the divider, re-opening at a new
point and each output.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 authored and GitHub committed 2026-10-15 21:21:56 +02:00
1 parent 34470ea273
commit 002faebb08
33 files changed
+452 -418

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
---
type: fix
area: ui
---
The right-click menu on a channel looks and behaves the same in every channel
list, and "Remove from history" is marked in red after a separator.
@@ -515,6 +515,14 @@ dialogs, player controls and hidden/inert panes keep their own behavior.
The shared row should be reused instead of rebuilding channel markup per view.
Its right-click menu is `app-channel-context-menu` (`@iptvnator/ui/components`),
shared by the M3U views, the Xtream and Stalker live lists and the unified
favorites and recents. A host sets which rows apply (`showMapEpg`,
`showDetails`, `openInPlaylistName`, `showRemoveFromHistory`), handles their
outputs, and calls `open(event)` from the row's `contextMenuRequested`; the
rows keep one order and Remove from history is the danger row after a
divider.
### Current Reference Values
- Current minimum height:
@@ -63,47 +63,19 @@
}
</div>
<div
aria-hidden="true"
class="channel-context-menu-anchor"
[style.left]="contextMenuPosition().x"
[style.top]="contextMenuPosition().y"
[matMenuTriggerFor]="channelContextMenu"
#contextMenuTrigger="matMenuTrigger"
></div>
<mat-menu #channelContextMenu="matMenu">
@if (supportsEpgMapping && !contextMenuUnavailable()) {
<button mat-menu-item (click)="openEpgMapping()">
<mat-icon>settings_remote</mat-icon>
<span>{{ 'CHANNELS.MAP_EPG' | translate }}</span>
</button>
}
@if (contextMenuChannel()?.m3uChannel && !contextMenuUnavailable()) {
<button mat-menu-item (click)="openChannelDetails()">
<mat-icon>info</mat-icon>
<span>{{ 'CHANNELS.SHOW_DETAILS' | translate }}</span>
</button>
}
@if (contextMenuCanOpenInPlaylist()) {
<button
mat-menu-item
data-testid="channel-open-in-playlist"
(click)="openContextMenuChannelInPlaylist()"
>
<mat-icon>open_in_new</mat-icon>
<span>{{
'PORTALS.VIEW_IN_PORTAL_TOOLTIP'
| translate: { name: contextMenuPlaylistName() }
}}</span>
</button>
}
@if (mode() === 'recent') {
<button mat-menu-item (click)="removeContextMenuChannel()">
<mat-icon>delete</mat-icon>
<span>
{{ 'WORKSPACE.DASHBOARD.REMOVE_FROM_HISTORY' | translate }}
</span>
</button>
}
</mat-menu>
<!-- A favorite whose catalog entry is missing keeps only the actions that
need no catalog: open in its playlist is gated by canOpenInPlaylist. -->
<app-channel-context-menu
[showMapEpg]="supportsEpgMapping && !contextMenuUnavailable()"
[showDetails]="
!!contextMenuChannel()?.m3uChannel && !contextMenuUnavailable()
"
[openInPlaylistName]="
contextMenuCanOpenInPlaylist() ? contextMenuPlaylistName() : null
"
[showRemoveFromHistory]="mode() === 'recent'"
(mapEpg)="openEpgMapping()"
(details)="openChannelDetails()"
(openInPlaylist)="openContextMenuChannelInPlaylist()"
(removeFromHistory)="removeContextMenuChannel()"
/>
@@ -40,14 +40,6 @@
gap: 2px;
}
.channel-context-menu-anchor {
position: fixed;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
.empty-state {
display: flex;
flex-direction: column;
@@ -191,7 +191,7 @@ describe('GlobalFavoritesListComponent', () => {
fixture.detectChanges();
const openMenuSpy = jest
.spyOn(fixture.componentInstance.contextMenuTrigger(), 'openMenu')
.spyOn(fixture.componentInstance.channelContextMenu(), 'open')
.mockImplementation();
fixture.componentInstance.onChannelContextMenu(
@@ -203,11 +203,9 @@ describe('GlobalFavoritesListComponent', () => {
);
await Promise.resolve();
expect(fixture.componentInstance.contextMenuPosition()).toEqual({
x: '24px',
y: '32px',
});
expect(openMenuSpy).toHaveBeenCalled();
expect(openMenuSpy).toHaveBeenCalledWith(
expect.objectContaining({ clientX: 24, clientY: 32 })
);
fixture.componentInstance.openChannelDetails();
@@ -230,8 +228,8 @@ describe('GlobalFavoritesListComponent', () => {
fixture.detectChanges();
jest.spyOn(
fixture.componentInstance.contextMenuTrigger(),
'openMenu'
fixture.componentInstance.channelContextMenu(),
'open'
).mockImplementation();
fixture.componentInstance.onChannelContextMenu(
@@ -266,8 +264,8 @@ describe('GlobalFavoritesListComponent', () => {
expect(fixture.componentInstance.hasChannelContextMenu(row)).toBe(true);
jest.spyOn(
fixture.componentInstance.contextMenuTrigger(),
'openMenu'
fixture.componentInstance.channelContextMenu(),
'open'
).mockImplementation();
fixture.componentInstance.onChannelContextMenu(
fixture.componentInstance.enrichedChannels()[0],
@@ -293,11 +291,11 @@ describe('GlobalFavoritesListComponent', () => {
expect(fixture.componentInstance.hasChannelContextMenu(row)).toBe(true);
const closeMenu = jest
.spyOn(fixture.componentInstance.contextMenuTrigger(), 'closeMenu')
.spyOn(fixture.componentInstance.channelContextMenu(), 'close')
.mockImplementation();
jest.spyOn(
fixture.componentInstance.contextMenuTrigger(),
'openMenu'
fixture.componentInstance.channelContextMenu(),
'open'
).mockImplementation();
fixture.componentInstance.onChannelContextMenu(
@@ -1,5 +1,8 @@
import { isCatalogUnavailable } from '@iptvnator/shared/interfaces';
import { ChannelScrollFocusDirective } from '@iptvnator/ui/components';
import {
ChannelContextMenuComponent,
ChannelScrollFocusDirective,
} from '@iptvnator/ui/components';
import {
CdkDragDrop,
DragDropModule,
@@ -17,7 +20,6 @@ import {
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import {
ChannelDetailsDialogComponent,
ChannelListItemComponent,
@@ -57,11 +59,11 @@ export type GlobalFavoritesListMode = 'favorites' | 'recent';
styleUrl: './global-favorites-list.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ChannelContextMenuComponent,
ChannelScrollFocusDirective,
ChannelListItemComponent,
DragDropModule,
MatIconModule,
MatMenuModule,
TranslateModule,
],
})
@@ -72,8 +74,9 @@ export class GlobalFavoritesListComponent {
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly settingsStore = inject(SettingsStore);
readonly contextMenuTrigger =
viewChild.required<MatMenuTrigger>('contextMenuTrigger');
readonly channelContextMenu = viewChild.required(
ChannelContextMenuComponent
);
readonly openStreamOnDoubleClick = computed(() =>
this.settingsStore.openStreamOnDoubleClick()
);
@@ -113,10 +116,6 @@ export class GlobalFavoritesListComponent {
readonly contextMenuPlaylistName = computed(() =>
playlistDisplayLabel(this.contextMenuChannel()?.playlistName)
);
readonly contextMenuPosition = signal({
x: '0px',
y: '0px',
});
readonly isCustomSort = computed(() => this.sortMode() === 'custom');
readonly canDragDrop = computed(
@@ -197,19 +196,7 @@ export class GlobalFavoritesListComponent {
event: MouseEvent
): void {
this.contextMenuChannel.set(channel);
this.contextMenuPosition.set({
x: `${event.clientX}px`,
y: `${event.clientY}px`,
});
const trigger = this.contextMenuTrigger();
if (trigger.menuOpen) {
trigger.closeMenu();
}
queueMicrotask(() => {
this.contextMenuTrigger().openMenu();
});
this.channelContextMenu().open(event);
}
hasChannelContextMenu(channel: UnifiedFavoriteChannel): boolean {
@@ -235,7 +222,7 @@ export class GlobalFavoritesListComponent {
return;
}
this.contextMenuTrigger().closeMenu();
this.channelContextMenu().close();
this.openInPlaylistRequested.emit(channel);
}
@@ -245,7 +232,7 @@ export class GlobalFavoritesListComponent {
return;
}
this.contextMenuTrigger().closeMenu();
this.channelContextMenu().close();
const stalkerId = this.stalkerItemId(item);
const channelKey = item.m3uChannel
? resolveChannelEpgLookupKey(item.m3uChannel)
@@ -308,7 +295,7 @@ export class GlobalFavoritesListComponent {
return;
}
this.contextMenuTrigger().closeMenu();
this.channelContextMenu().close();
this.dialog.open(ChannelDetailsDialogComponent, {
data: channel,
maxWidth: '720px',
@@ -322,7 +309,7 @@ export class GlobalFavoritesListComponent {
return;
}
this.contextMenuTrigger().closeMenu();
this.channelContextMenu().close();
this.removeRequested.emit(channel);
}
@@ -244,21 +244,7 @@
</div>
</ng-template>
<div
aria-hidden="true"
class="channel-context-menu-anchor"
[style.left]="contextMenuPosition().x"
[style.top]="contextMenuPosition().y"
[matMenuTriggerFor]="channelContextMenu"
#contextMenuTrigger="matMenuTrigger"
></div>
<mat-menu #channelContextMenu="matMenu">
<button mat-menu-item (click)="openEpgMapping()">
<mat-icon>settings_remote</mat-icon>
<span>{{ 'CHANNELS.MAP_EPG' | translate }}</span>
</button>
</mat-menu>
<app-channel-context-menu [showMapEpg]="true" (mapEpg)="openEpgMapping()" />
<!-- The channel list, stamped into the sidebar and — with its own search
term — into the player's fullscreen channel panel. Every instance carries
@@ -102,11 +102,3 @@
padding-inline: 6px;
}
}
.channel-context-menu-anchor {
position: fixed;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
@@ -1,4 +1,7 @@
import { ChannelScrollFocusDirective } from '@iptvnator/ui/components';
import {
ChannelContextMenuComponent,
ChannelScrollFocusDirective,
} from '@iptvnator/ui/components';
import { NgTemplateOutlet } from '@angular/common';
import {
DestroyRef,
@@ -25,7 +28,6 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltipModule } from '@angular/material/tooltip';
@@ -154,6 +156,7 @@ import { StalkerWorkspaceRouteSession } from '../stalker-workspace-route-session
templateUrl: './stalker-live-stream-layout.component.html',
styleUrls: ['./stalker-live-stream-layout.component.scss'],
imports: [
ChannelContextMenuComponent,
ChannelScrollFocusDirective,
AudioPlayerComponent,
ChannelListItemComponent,
@@ -162,7 +165,6 @@ import { StalkerWorkspaceRouteSession } from '../stalker-workspace-route-session
EpgTimelineComponent,
MatButtonModule,
MatIconModule,
MatMenuModule,
MatProgressSpinnerModule,
MatTooltipModule,
NgTemplateOutlet,
@@ -364,10 +366,10 @@ export class StalkerLiveStreamLayoutComponent
protected readonly normalizeStalkerEntityId = normalizeStalkerEntityId;
/** Context menu (Map EPG) */
readonly contextMenuTrigger =
viewChild.required<MatMenuTrigger>('contextMenuTrigger');
readonly channelContextMenu = viewChild.required(
ChannelContextMenuComponent
);
readonly contextMenuChannel = signal<StalkerItvChannel | null>(null);
readonly contextMenuPosition = signal({ x: '0px', y: '0px' });
readonly isElectron = this.runtime.isElectron;
readonly supportsEpg = this.runtime.supportsEpg;
readonly supportsEpgMapping = this.runtime.supportsEpgMapping;
@@ -1274,19 +1276,7 @@ export class StalkerLiveStreamLayoutComponent
onChannelContextMenu(channel: StalkerItvChannel, event: MouseEvent): void {
this.contextMenuChannel.set(channel);
this.contextMenuPosition.set({
x: `${event.clientX}px`,
y: `${event.clientY}px`,
});
const trigger = this.contextMenuTrigger();
if (trigger.menuOpen) {
trigger.closeMenu();
}
queueMicrotask(() => {
this.contextMenuTrigger().openMenu();
});
this.channelContextMenu().open(event);
}
async openEpgMapping(): Promise<void> {
@@ -1295,7 +1285,7 @@ export class StalkerLiveStreamLayoutComponent
return;
}
this.contextMenuTrigger().closeMenu();
this.channelContextMenu().close();
const playlistId = this.stalkerStore.currentPlaylist()?._id;
const channelId = normalizeStalkerEntityId(channel.id);
if (!playlistId || !channelId) {
@@ -59,21 +59,10 @@
/>
</cdk-virtual-scroll-viewport>
<div
aria-hidden="true"
class="channel-context-menu-anchor"
[style.left]="contextMenuPosition().x"
[style.top]="contextMenuPosition().y"
[matMenuTriggerFor]="channelContextMenu"
#contextMenuTrigger="matMenuTrigger"
></div>
<mat-menu #channelContextMenu="matMenu">
<button mat-menu-item (click)="openEpgMapping()">
<mat-icon>settings_remote</mat-icon>
<span>{{ 'CHANNELS.MAP_EPG' | translate }}</span>
</button>
</mat-menu>
<app-channel-context-menu
[showMapEpg]="true"
(mapEpg)="openEpgMapping()"
/>
} @else {
<div class="empty-search-state">
<mat-icon class="empty-state-icon">search_off</mat-icon>
@@ -35,7 +35,10 @@
box-sizing: border-box;
display: flex;
align-items: center;
background: var(--app-widget-header-bg, var(--mat-sys-surface-container-high));
background: var(
--app-widget-header-bg,
var(--mat-sys-surface-container-high)
);
border-bottom: 1px solid var(--app-separator);
app-region: drag;
}
@@ -47,7 +50,11 @@
border-radius: 10px;
border: 1px solid rgba(127, 127, 127, 0.22);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0)),
linear-gradient(
180deg,
rgba(255, 255, 255, 0.06),
rgba(255, 255, 255, 0)
),
rgba(127, 127, 127, 0.08);
display: flex;
align-items: center;
@@ -172,11 +179,3 @@
transform: translateY(0);
}
}
.channel-context-menu-anchor {
position: fixed;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
@@ -1,4 +1,7 @@
import { ChannelScrollFocusDirective } from '@iptvnator/ui/components';
import {
ChannelContextMenuComponent,
ChannelScrollFocusDirective,
} from '@iptvnator/ui/components';
import {
CdkVirtualScrollViewport,
ScrollingModule,
@@ -22,7 +25,6 @@ import {
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { ActivatedRoute } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { Subscription } from 'rxjs';
@@ -97,12 +99,12 @@ interface XtreamCategoryLike {
styleUrls: ['./portal-channels-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ChannelContextMenuComponent,
ChannelScrollFocusDirective,
ChannelListItemComponent,
ChannelListSkeletonComponent,
MatButtonModule,
MatIcon,
MatMenuModule,
ScrollingModule,
TranslatePipe,
],
@@ -134,10 +136,10 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly dialog = inject(MatDialog);
readonly contextMenuTrigger =
viewChild.required<MatMenuTrigger>('contextMenuTrigger');
readonly channelContextMenu = viewChild.required(
ChannelContextMenuComponent
);
readonly contextMenuChannel = signal<XtreamChannelListItem | null>(null);
readonly contextMenuPosition = signal({ x: '0px', y: '0px' });
readonly supportsEpg = this.runtime.supportsEpg;
readonly supportsEpgMapping = this.runtime.supportsEpgMapping;
readonly channelItemSize = this.supportsEpg ? 68 : 52;
@@ -710,19 +712,7 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
event: MouseEvent
): void {
this.contextMenuChannel.set(channel);
this.contextMenuPosition.set({
x: `${event.clientX}px`,
y: `${event.clientY}px`,
});
const trigger = this.contextMenuTrigger();
if (trigger.menuOpen) {
trigger.closeMenu();
}
queueMicrotask(() => {
this.contextMenuTrigger().openMenu();
});
this.channelContextMenu().open(event);
}
openEpgMapping(): void {
@@ -731,7 +721,7 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
return;
}
this.contextMenuTrigger().closeMenu();
this.channelContextMenu().close();
const playlistId = this.xtreamStore.currentPlaylist()?.id;
const xtreamId = channel.xtream_id ?? channel.id;
if (!playlistId || xtreamId == null) {
+1
View File
@@ -1,4 +1,5 @@
export * from './lib/channel-list-container/channel-list-container.component';
export * from './lib/channel-list-container/channel-context-menu/channel-context-menu.component';
export * from './lib/channel-list-container/channel-details-dialog/channel-details-dialog.component';
export * from './lib/channel-list-container/epg-mapping-dialog/epg-mapping-dialog.component';
export * from './lib/channel-list-container/channel-list-item/channel-list-item.component';
@@ -117,24 +117,9 @@
}
</div>
<div
aria-hidden="true"
class="channel-context-menu-anchor"
[style.left]="contextMenuPosition().x"
[style.top]="contextMenuPosition().y"
[matMenuTriggerFor]="channelContextMenu"
#contextMenuTrigger="matMenuTrigger"
></div>
<mat-menu #channelContextMenu="matMenu">
@if (supportsEpgMapping) {
<button mat-menu-item (click)="openEpgMapping()">
<mat-icon>settings_remote</mat-icon>
<span>{{ 'CHANNELS.MAP_EPG' | translate }}</span>
</button>
}
<button mat-menu-item (click)="openChannelDetails()">
<mat-icon>info</mat-icon>
<span>{{ 'CHANNELS.SHOW_DETAILS' | translate }}</span>
</button>
</mat-menu>
<app-channel-context-menu
[showMapEpg]="supportsEpgMapping"
[showDetails]="true"
(mapEpg)="openEpgMapping()"
(details)="openChannelDetails()"
/>
@@ -75,14 +75,6 @@
overflow-x: hidden;
}
.channel-context-menu-anchor {
position: fixed;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
:host ::ng-deep .search-bar div {
border-radius: 20px;
}
@@ -270,7 +270,7 @@ describe('AllChannelsViewComponent', () => {
it('stores viewport coordinates for the context menu and opens the dialog for that channel', async () => {
const openMenuSpy = jest
.spyOn(component.contextMenuTrigger(), 'openMenu')
.spyOn(component.channelContextMenu(), 'open')
.mockImplementation();
component.onChannelContextMenu(primaryChannel, {
@@ -280,11 +280,9 @@ describe('AllChannelsViewComponent', () => {
await Promise.resolve();
expect(component.contextMenuChannel()).toBe(primaryChannel);
expect(component.contextMenuPosition()).toEqual({
x: '144px',
y: '188px',
});
expect(openMenuSpy).toHaveBeenCalled();
expect(openMenuSpy).toHaveBeenCalledWith(
expect.objectContaining({ clientX: 144, clientY: 188 })
);
component.openChannelDetails();
@@ -14,7 +14,7 @@ import {
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { MatMenuModule } from '@angular/material/menu';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
@@ -26,6 +26,7 @@ import {
EpgProgram,
epgProviderClockMs,
} from '@iptvnator/shared/interfaces';
import { ChannelContextMenuComponent } from '../channel-context-menu/channel-context-menu.component';
import {
PlaylistChannelSortMode,
getPlaylistChannelSortModeLabelKey,
@@ -53,6 +54,7 @@ export type { ChannelEpgMetadata } from '../epg-enrichment.util';
styleUrls: ['./all-channels-view.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ChannelContextMenuComponent,
ChannelScrollFocusDirective,
ChannelListItemComponent,
MatButtonModule,
@@ -71,8 +73,9 @@ export class AllChannelsViewComponent {
private readonly settingsStore = inject(SettingsStore);
readonly supportsEpgMapping = this.epgBridge.supportsEpgMapping;
readonly contextMenuTrigger =
viewChild.required<MatMenuTrigger>('contextMenuTrigger');
readonly channelContextMenu = viewChild.required(
ChannelContextMenuComponent
);
/** All channels (will be filtered by search) */
readonly channels = input.required<Channel[]>();
@@ -118,10 +121,6 @@ export class AllChannelsViewComponent {
readonly sidebarToggleRequested = output<void>();
readonly contextMenuChannel = signal<Channel | null>(null);
readonly contextMenuPosition = signal({
x: '0px',
y: '0px',
});
readonly allChannelsSortMode = signal<PlaylistChannelSortMode>(
restorePlaylistChannelSortMode(ALL_CHANNELS_SORT_STORAGE_KEY)
);
@@ -209,19 +208,7 @@ export class AllChannelsViewComponent {
onChannelContextMenu(channel: Channel, event: MouseEvent): void {
this.contextMenuChannel.set(channel);
this.contextMenuPosition.set({
x: `${event.clientX}px`,
y: `${event.clientY}px`,
});
const trigger = this.contextMenuTrigger();
if (trigger.menuOpen) {
trigger.closeMenu();
}
queueMicrotask(() => {
this.contextMenuTrigger().openMenu();
});
this.channelContextMenu().open(event);
}
openChannelDetails(): void {
@@ -230,7 +217,7 @@ export class AllChannelsViewComponent {
return;
}
this.contextMenuTrigger().closeMenu();
this.channelContextMenu().close();
this.dialog.open(ChannelDetailsDialogComponent, {
data: channel,
maxWidth: '720px',
@@ -244,7 +231,7 @@ export class AllChannelsViewComponent {
return;
}
this.contextMenuTrigger().closeMenu();
this.channelContextMenu().close();
const channelKey = resolveChannelEpgLookupKey(channel);
if (!channelKey) {
return;
@@ -0,0 +1,52 @@
<div
aria-hidden="true"
class="channel-context-menu-anchor"
[style.left]="position().x"
[style.top]="position().y"
[matMenuTriggerFor]="menu"
></div>
<mat-menu #menu="matMenu">
@if (showMapEpg()) {
<button mat-menu-item type="button" (click)="mapEpg.emit()">
<mat-icon>settings_remote</mat-icon>
<span>{{ 'CHANNELS.MAP_EPG' | translate }}</span>
</button>
}
@if (showDetails()) {
<button mat-menu-item type="button" (click)="details.emit()">
<mat-icon>info</mat-icon>
<span>{{ 'CHANNELS.SHOW_DETAILS' | translate }}</span>
</button>
}
@if (openInPlaylistName() !== null) {
<button
mat-menu-item
type="button"
data-testid="channel-open-in-playlist"
(click)="openInPlaylist.emit()"
>
<mat-icon>open_in_new</mat-icon>
<span>{{
'PORTALS.VIEW_IN_PORTAL_TOOLTIP'
| translate: { name: openInPlaylistName() }
}}</span>
</button>
}
@if (showRemoveFromHistory()) {
@if (hasLeadingRows()) {
<mat-divider />
}
<button
mat-menu-item
type="button"
class="app-menu-item--danger"
(click)="removeFromHistory.emit()"
>
<mat-icon>delete</mat-icon>
<span>{{
'WORKSPACE.DASHBOARD.REMOVE_FROM_HISTORY' | translate
}}</span>
</button>
}
</mat-menu>
@@ -0,0 +1,12 @@
:host {
display: contents;
}
// A zero-size point the menu opens from, placed at the pointer.
.channel-context-menu-anchor {
position: fixed;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
@@ -0,0 +1,144 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { ChannelContextMenuComponent } from './channel-context-menu.component';
describe('ChannelContextMenuComponent', () => {
let fixture: ComponentFixture<ChannelContextMenuComponent>;
function render(inputs: Partial<Record<string, unknown>>) {
TestBed.configureTestingModule({
imports: [ChannelContextMenuComponent, TranslateModule.forRoot()],
});
fixture = TestBed.createComponent(ChannelContextMenuComponent);
for (const [name, value] of Object.entries(inputs)) {
fixture.componentRef.setInput(name, value);
}
fixture.detectChanges();
return fixture.componentInstance;
}
async function openAt(clientX: number, clientY: number) {
fixture.componentInstance.open({ clientX, clientY });
fixture.detectChanges();
await Promise.resolve();
fixture.detectChanges();
}
function panel(): HTMLElement | null {
return document.querySelector(
'.cdk-overlay-container .mat-mdc-menu-panel'
);
}
/** Each row as "icon label"; Material projects the icon outside the label span. */
function rowTexts(): string[] {
return rows().map(
(row) =>
`${row.querySelector('mat-icon')?.textContent?.trim()} ${row
.querySelector('.mat-mdc-menu-item-text')
?.textContent?.trim()}`
);
}
function rows(): HTMLElement[] {
return Array.from(
document.querySelectorAll<HTMLElement>(
'.cdk-overlay-container [mat-menu-item]'
)
);
}
afterEach(() => {
fixture?.componentInstance.close();
fixture?.detectChanges();
});
it('opens at the pointer with the rows in one order and Remove as a danger row after a divider', async () => {
render({
showMapEpg: true,
showDetails: true,
openInPlaylistName: 'Living room',
showRemoveFromHistory: true,
});
await openAt(144, 188);
const anchor = fixture.nativeElement.querySelector(
'.channel-context-menu-anchor'
) as HTMLElement;
expect(anchor.style.left).toBe('144px');
expect(anchor.style.top).toBe('188px');
expect(panel()).not.toBeNull();
expect(rowTexts()).toEqual([
'settings_remote CHANNELS.MAP_EPG',
'info CHANNELS.SHOW_DETAILS',
'open_in_new PORTALS.VIEW_IN_PORTAL_TOOLTIP',
'delete WORKSPACE.DASHBOARD.REMOVE_FROM_HISTORY',
]);
expect(rows()[2].dataset['testid']).toBe('channel-open-in-playlist');
expect(rows()[3].classList).toContain('app-menu-item--danger');
const divider = panel()?.querySelector('mat-divider');
expect(divider?.nextElementSibling).toBe(rows()[3]);
});
it('renders only the rows the host asks for, without a divider before a lone Remove', async () => {
render({ showRemoveFromHistory: true });
await openAt(10, 20);
expect(rowTexts()).toEqual([
'delete WORKSPACE.DASHBOARD.REMOVE_FROM_HISTORY',
]);
expect(panel()?.querySelector('mat-divider')).toBeNull();
});
it('offers the playlist row for a playlist saved without a name', async () => {
render({ openInPlaylistName: '' });
await openAt(10, 20);
expect(rows()).toHaveLength(1);
expect(rows()[0].dataset['testid']).toBe('channel-open-in-playlist');
});
it('moves an open menu to the new pointer position', async () => {
render({ showDetails: true });
await openAt(10, 20);
await openAt(300, 400);
const anchor = fixture.nativeElement.querySelector(
'.channel-context-menu-anchor'
) as HTMLElement;
expect(anchor.style.left).toBe('300px');
expect(anchor.style.top).toBe('400px');
expect(panel()).not.toBeNull();
});
it('reports each row to the host', async () => {
const menu = render({
showMapEpg: true,
showDetails: true,
openInPlaylistName: 'Living room',
showRemoveFromHistory: true,
});
const picked: string[] = [];
menu.mapEpg.subscribe(() => picked.push('map-epg'));
menu.details.subscribe(() => picked.push('details'));
menu.openInPlaylist.subscribe(() => picked.push('open-in-playlist'));
menu.removeFromHistory.subscribe(() => picked.push('remove'));
for (let index = 0; index < 4; index++) {
await openAt(10, 20);
rows()[index].click();
fixture.detectChanges();
}
expect(picked).toEqual([
'map-epg',
'details',
'open-in-playlist',
'remove',
]);
});
});
@@ -0,0 +1,76 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
signal,
viewChild,
} from '@angular/core';
import { MatDividerModule } from '@angular/material/divider';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { TranslatePipe } from '@ngx-translate/core';
/** The pointer position a context menu opens at. */
export type ChannelContextMenuPoint = Pick<MouseEvent, 'clientX' | 'clientY'>;
/**
* The right-click menu of a channel row, shared by every channel list (M3U
* views, Xtream and Stalker live lists, unified favorites and recents).
*
* The host keeps the channel the menu was opened for and its own handlers;
* this component owns the anchor, the menu and its rows, which always come
* in one order: Map EPG, Show details, View in a playlist, and Remove from
* history as a destructive row after a divider.
*/
@Component({
selector: 'app-channel-context-menu',
imports: [MatDividerModule, MatIconModule, MatMenuModule, TranslatePipe],
templateUrl: './channel-context-menu.component.html',
styleUrl: './channel-context-menu.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ChannelContextMenuComponent {
readonly showMapEpg = input(false);
readonly showDetails = input(false);
/**
* The playlist to offer "View in …" for; no row while null. An empty
* name (a playlist saved without one) still shows the row.
*/
readonly openInPlaylistName = input<string | null>(null);
readonly showRemoveFromHistory = input(false);
readonly mapEpg = output<void>();
readonly details = output<void>();
readonly openInPlaylist = output<void>();
readonly removeFromHistory = output<void>();
protected readonly position = signal({ x: '0px', y: '0px' });
protected readonly hasLeadingRows = computed(
() =>
this.showMapEpg() ||
this.showDetails() ||
this.openInPlaylistName() !== null
);
private readonly trigger = viewChild.required(MatMenuTrigger);
/** Opens the menu at the pointer, replacing a menu that is still open. */
open(point: ChannelContextMenuPoint): void {
this.position.set({
x: `${point.clientX}px`,
y: `${point.clientY}px`,
});
const trigger = this.trigger();
if (trigger.menuOpen) {
trigger.closeMenu();
}
// The anchor moves with the position signal; open after it renders.
queueMicrotask(() => this.trigger().openMenu());
}
close(): void {
this.trigger().closeMenu();
}
}
@@ -65,24 +65,9 @@
}
</div>
<div
aria-hidden="true"
class="channel-context-menu-anchor"
[style.left]="contextMenuPosition().x"
[style.top]="contextMenuPosition().y"
[matMenuTriggerFor]="channelContextMenu"
#contextMenuTrigger="matMenuTrigger"
></div>
<mat-menu #channelContextMenu="matMenu">
@if (supportsEpgMapping) {
<button mat-menu-item (click)="openEpgMapping()">
<mat-icon>settings_remote</mat-icon>
<span>{{ 'CHANNELS.MAP_EPG' | translate }}</span>
</button>
}
<button mat-menu-item (click)="openChannelDetails()">
<mat-icon>info</mat-icon>
<span>{{ 'CHANNELS.SHOW_DETAILS' | translate }}</span>
</button>
</mat-menu>
<app-channel-context-menu
[showMapEpg]="supportsEpgMapping"
[showDetails]="true"
(mapEpg)="openEpgMapping()"
(details)="openChannelDetails()"
/>
@@ -15,14 +15,6 @@
overflow-x: hidden;
}
.channel-context-menu-anchor {
position: fixed;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
.empty-favorites,
.empty-favorites-hint {
padding: 16px 24px;
@@ -75,7 +75,7 @@ describe('FavoritesViewComponent', () => {
it('stores context-menu coordinates and opens channel details for the selected favorite', async () => {
const openMenuSpy = jest
.spyOn(component.contextMenuTrigger(), 'openMenu')
.spyOn(component.channelContextMenu(), 'open')
.mockImplementation();
component.onChannelContextMenu(primaryChannel, {
@@ -85,11 +85,9 @@ describe('FavoritesViewComponent', () => {
await Promise.resolve();
expect(component.contextMenuChannel()).toBe(primaryChannel);
expect(component.contextMenuPosition()).toEqual({
x: '88px',
y: '132px',
});
expect(openMenuSpy).toHaveBeenCalled();
expect(openMenuSpy).toHaveBeenCalledWith(
expect.objectContaining({ clientX: 88, clientY: 132 })
);
component.openChannelDetails();
@@ -16,7 +16,6 @@ import {
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { TranslatePipe } from '@ngx-translate/core';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { SettingsStore } from '@iptvnator/services';
@@ -27,6 +26,7 @@ import {
EpgProgram,
epgProviderClockMs,
} from '@iptvnator/shared/interfaces';
import { ChannelContextMenuComponent } from '../channel-context-menu/channel-context-menu.component';
import { buildChannelEpgMetadataMap } from '../epg-enrichment.util';
import { EpgMappingDialogComponent } from '../epg-mapping-dialog/epg-mapping-dialog.component';
import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component';
@@ -39,11 +39,11 @@ import { ChannelListItemComponent } from '../channel-list-item/channel-list-item
styleUrls: ['./favorites-view.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ChannelContextMenuComponent,
ChannelScrollFocusDirective,
ChannelListItemComponent,
DragDropModule,
MatIconModule,
MatMenuModule,
TranslatePipe,
],
})
@@ -53,8 +53,9 @@ export class FavoritesViewComponent {
private readonly settingsStore = inject(SettingsStore);
readonly supportsEpgMapping = this.epgBridge.supportsEpgMapping;
readonly contextMenuTrigger =
viewChild.required<MatMenuTrigger>('contextMenuTrigger');
readonly channelContextMenu = viewChild.required(
ChannelContextMenuComponent
);
/** Favorite channels */
readonly favorites = input.required<Channel[]>();
@@ -88,10 +89,6 @@ export class FavoritesViewComponent {
readonly favoritesReordered = output<string[]>();
readonly contextMenuChannel = signal<Channel | null>(null);
readonly contextMenuPosition = signal({
x: '0px',
y: '0px',
});
readonly hasSearchTerm = computed(
() => this.searchTerm().trim().length > 0
@@ -160,19 +157,7 @@ export class FavoritesViewComponent {
onChannelContextMenu(channel: Channel, event: MouseEvent): void {
this.contextMenuChannel.set(channel);
this.contextMenuPosition.set({
x: `${event.clientX}px`,
y: `${event.clientY}px`,
});
const trigger = this.contextMenuTrigger();
if (trigger.menuOpen) {
trigger.closeMenu();
}
queueMicrotask(() => {
this.contextMenuTrigger().openMenu();
});
this.channelContextMenu().open(event);
}
openEpgMapping(): void {
@@ -181,7 +166,7 @@ export class FavoritesViewComponent {
return;
}
this.contextMenuTrigger().closeMenu();
this.channelContextMenu().close();
const channelKey = resolveChannelEpgLookupKey(channel);
if (!channelKey) {
return;
@@ -199,7 +184,7 @@ export class FavoritesViewComponent {
return;
}
this.contextMenuTrigger().closeMenu();
this.channelContextMenu().close();
this.dialog.open(ChannelDetailsDialogComponent, {
data: channel,
maxWidth: '720px',
@@ -377,27 +377,12 @@
</div>
}
<div
aria-hidden="true"
class="channel-context-menu-anchor"
[style.left]="contextMenuPosition().x"
[style.top]="contextMenuPosition().y"
[matMenuTriggerFor]="channelContextMenu"
#contextMenuTrigger="matMenuTrigger"
></div>
<mat-menu #channelContextMenu="matMenu">
@if (supportsEpgMapping) {
<button mat-menu-item (click)="openEpgMapping()">
<mat-icon>settings_remote</mat-icon>
<span>{{ 'CHANNELS.MAP_EPG' | translate }}</span>
</button>
}
<button mat-menu-item (click)="openChannelDetails()">
<mat-icon>info</mat-icon>
<span>{{ 'CHANNELS.SHOW_DETAILS' | translate }}</span>
</button>
</mat-menu>
<app-channel-context-menu
[showMapEpg]="supportsEpgMapping"
[showDetails]="true"
(mapEpg)="openEpgMapping()"
(details)="openChannelDetails()"
/>
<app-category-lock-menu
#groupLockMenu
@@ -223,14 +223,6 @@
background: transparent;
}
.channel-context-menu-anchor {
position: fixed;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
.groups-content-panel {
width: auto;
min-width: 0;
@@ -740,7 +740,7 @@ describe('GroupsViewComponent', () => {
it('positions the context menu at the viewport click and opens channel details from the selected group pane', async () => {
const openMenuSpy = jest
.spyOn(component.contextMenuTrigger(), 'openMenu')
.spyOn(component.channelContextMenu(), 'open')
.mockImplementation();
component.onChannelContextMenu(movieClassic, {
@@ -750,11 +750,9 @@ describe('GroupsViewComponent', () => {
await Promise.resolve();
expect(component.contextMenuChannel()).toBe(movieClassic);
expect(component.contextMenuPosition()).toEqual({
x: '212px',
y: '264px',
});
expect(openMenuSpy).toHaveBeenCalled();
expect(openMenuSpy).toHaveBeenCalledWith(
expect.objectContaining({ clientX: 212, clientY: 264 })
);
component.openChannelDetails();
@@ -17,7 +17,7 @@ import { ScrollingModule } from '@angular/cdk/scrolling';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { MatMenuModule } from '@angular/material/menu';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
@@ -29,6 +29,7 @@ import {
EpgProgram,
epgProviderClockMs,
} from '@iptvnator/shared/interfaces';
import { ChannelContextMenuComponent } from '../channel-context-menu/channel-context-menu.component';
import { buildChannelEpgMetadataMap } from '../epg-enrichment.util';
import {
PlaylistChannelSortMode,
@@ -75,6 +76,7 @@ interface FilteredGroupView {
styleUrls: ['./groups-view.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ChannelContextMenuComponent,
CategoryLockMenuComponent,
ChannelScrollFocusDirective,
ChannelListItemComponent,
@@ -98,8 +100,9 @@ export class GroupsViewComponent {
readonly supportsEpgMapping = this.epgBridge.supportsEpgMapping;
private readonly hostEl = inject(ElementRef<HTMLElement>);
readonly contextMenuTrigger =
viewChild.required<MatMenuTrigger>('contextMenuTrigger');
readonly channelContextMenu = viewChild.required(
ChannelContextMenuComponent
);
readonly groupSearchInput =
viewChild<ElementRef<HTMLInputElement>>('groupSearchInput');
@@ -226,10 +229,6 @@ export class GroupsViewComponent {
);
readonly itemSize = computed(() => (this.shouldShowEpg() ? 68 : 52));
readonly contextMenuChannel = signal<Channel | null>(null);
readonly contextMenuPosition = signal({
x: '0px',
y: '0px',
});
private previousActiveChannelUrl: string | undefined;
private preservedContentWidth = 0;
@@ -647,19 +646,7 @@ export class GroupsViewComponent {
onChannelContextMenu(channel: Channel, event: MouseEvent): void {
this.contextMenuChannel.set(channel);
this.contextMenuPosition.set({
x: `${event.clientX}px`,
y: `${event.clientY}px`,
});
const trigger = this.contextMenuTrigger();
if (trigger.menuOpen) {
trigger.closeMenu();
}
queueMicrotask(() => {
this.contextMenuTrigger().openMenu();
});
this.channelContextMenu().open(event);
}
openEpgMapping(): void {
@@ -668,7 +655,7 @@ export class GroupsViewComponent {
return;
}
this.contextMenuTrigger().closeMenu();
this.channelContextMenu().close();
const channelKey = resolveChannelEpgLookupKey(channel);
if (!channelKey) {
return;
@@ -686,7 +673,7 @@ export class GroupsViewComponent {
return;
}
this.contextMenuTrigger().closeMenu();
this.channelContextMenu().close();
this.dialog.open(ChannelDetailsDialogComponent, {
data: channel,
maxWidth: '720px',
@@ -61,28 +61,11 @@
}
</div>
<div
aria-hidden="true"
class="channel-context-menu-anchor"
[style.left]="contextMenuPosition().x"
[style.top]="contextMenuPosition().y"
[matMenuTriggerFor]="channelContextMenu"
#contextMenuTrigger="matMenuTrigger"
></div>
<mat-menu #channelContextMenu="matMenu">
@if (supportsEpgMapping) {
<button mat-menu-item (click)="openEpgMapping()">
<mat-icon>settings_remote</mat-icon>
<span>{{ 'CHANNELS.MAP_EPG' | translate }}</span>
</button>
}
<button mat-menu-item (click)="openChannelDetails()">
<mat-icon>info</mat-icon>
<span>{{ 'CHANNELS.SHOW_DETAILS' | translate }}</span>
</button>
<button mat-menu-item (click)="removeContextMenuChannel()">
<mat-icon>delete</mat-icon>
<span>{{ 'WORKSPACE.DASHBOARD.REMOVE_FROM_HISTORY' | translate }}</span>
</button>
</mat-menu>
<app-channel-context-menu
[showMapEpg]="supportsEpgMapping"
[showDetails]="true"
[showRemoveFromHistory]="true"
(mapEpg)="openEpgMapping()"
(details)="openChannelDetails()"
(removeFromHistory)="removeContextMenuChannel()"
/>
@@ -15,14 +15,6 @@
overflow-x: hidden;
}
.channel-context-menu-anchor {
position: fixed;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
.empty-recent,
.empty-recent-hint {
padding: 16px 24px;
@@ -81,7 +81,7 @@ describe('RecentViewComponent', () => {
it('stores context-menu coordinates and opens channel details for the selected recent channel', async () => {
const openMenuSpy = jest
.spyOn(component.contextMenuTrigger(), 'openMenu')
.spyOn(component.channelContextMenu(), 'open')
.mockImplementation();
component.onChannelContextMenu(primaryChannel, {
@@ -91,11 +91,9 @@ describe('RecentViewComponent', () => {
await Promise.resolve();
expect(component.contextMenuChannel()).toBe(primaryChannel);
expect(component.contextMenuPosition()).toEqual({
x: '104px',
y: '156px',
});
expect(openMenuSpy).toHaveBeenCalled();
expect(openMenuSpy).toHaveBeenCalledWith(
expect.objectContaining({ clientX: 104, clientY: 156 })
);
component.openChannelDetails();
@@ -112,10 +110,7 @@ describe('RecentViewComponent', () => {
it('emits the selected channel URL when removing from the context menu', async () => {
const removed = jest.fn();
component.removeRecent.subscribe(removed);
jest.spyOn(
component.contextMenuTrigger(),
'openMenu'
).mockImplementation();
jest.spyOn(component.channelContextMenu(), 'open').mockImplementation();
component.onChannelContextMenu(primaryChannel, {
clientX: 104,
@@ -11,7 +11,6 @@ import {
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { TranslatePipe } from '@ngx-translate/core';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state';
@@ -22,6 +21,7 @@ import {
EpgProgram,
epgProviderClockMs,
} from '@iptvnator/shared/interfaces';
import { ChannelContextMenuComponent } from '../channel-context-menu/channel-context-menu.component';
import { EpgMappingDialogComponent } from '../epg-mapping-dialog/epg-mapping-dialog.component';
import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component';
import { resolveChannelLogo } from '../channel-logo-fallback.util';
@@ -42,10 +42,10 @@ export interface RecentViewItem {
styleUrls: ['./recent-view.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ChannelContextMenuComponent,
ChannelScrollFocusDirective,
ChannelListItemComponent,
MatIconModule,
MatMenuModule,
TranslatePipe,
],
})
@@ -55,8 +55,9 @@ export class RecentViewComponent {
private readonly settingsStore = inject(SettingsStore);
readonly supportsEpgMapping = this.epgBridge.supportsEpgMapping;
readonly contextMenuTrigger =
viewChild.required<MatMenuTrigger>('contextMenuTrigger');
readonly channelContextMenu = viewChild.required(
ChannelContextMenuComponent
);
readonly recentItems = input.required<RecentViewItem[]>();
readonly searchTerm = input('');
@@ -72,10 +73,6 @@ export class RecentViewComponent {
readonly removeRecent = output<string>();
readonly contextMenuChannel = signal<Channel | null>(null);
readonly contextMenuPosition = signal({
x: '0px',
y: '0px',
});
readonly filteredRecentItems = computed(() => {
const recentItems = this.recentItems();
@@ -141,19 +138,7 @@ export class RecentViewComponent {
onChannelContextMenu(channel: Channel, event: MouseEvent): void {
this.contextMenuChannel.set(channel);
this.contextMenuPosition.set({
x: `${event.clientX}px`,
y: `${event.clientY}px`,
});
const trigger = this.contextMenuTrigger();
if (trigger.menuOpen) {
trigger.closeMenu();
}
queueMicrotask(() => {
this.contextMenuTrigger().openMenu();
});
this.channelContextMenu().open(event);
}
openEpgMapping(): void {
@@ -162,7 +147,7 @@ export class RecentViewComponent {
return;
}
this.contextMenuTrigger().closeMenu();
this.channelContextMenu().close();
const channelKey = resolveChannelEpgLookupKey(channel);
if (!channelKey) {
return;
@@ -180,7 +165,7 @@ export class RecentViewComponent {
return;
}
this.contextMenuTrigger().closeMenu();
this.channelContextMenu().close();
this.dialog.open(ChannelDetailsDialogComponent, {
data: channel,
maxWidth: '720px',
@@ -194,7 +179,7 @@ export class RecentViewComponent {
return;
}
this.contextMenuTrigger().closeMenu();
this.channelContextMenu().close();
this.removeRecent.emit(channel.url);
}
}