mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 06:06:18 -08:00
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:
1 parent
34470ea273
commit
002faebb08
33 files changed
+452
-418
No files matched your search
@@ -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:
|
||||
|
||||
+16
-44
@@ -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()"
|
||||
/>
|
||||
-8
@@ -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;
|
||||
|
||||
+11
-13
@@ -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(
|
||||
|
||||
+13
-26
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+1
-15
@@ -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
|
||||
|
||||
-8
@@ -102,11 +102,3 @@
|
||||
padding-inline: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.channel-context-menu-anchor {
|
||||
position: fixed;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
+10
-20
@@ -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) {
|
||||
|
||||
+4
-15
@@ -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>
|
||||
|
||||
+9
-10
@@ -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;
|
||||
}
|
||||
+10
-20
@@ -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,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';
|
||||
|
||||
+6
-21
@@ -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()"
|
||||
/>
|
||||
-8
@@ -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;
|
||||
}
|
||||
|
||||
+4
-6
@@ -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();
|
||||
|
||||
|
||||
+9
-22
@@ -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;
|
||||
|
||||
+52
@@ -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>
|
||||
+12
@@ -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;
|
||||
}
|
||||
+144
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
+76
@@ -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();
|
||||
}
|
||||
}
|
||||
+6
-21
@@ -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()"
|
||||
/>
|
||||
-8
@@ -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;
|
||||
|
||||
+4
-6
@@ -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();
|
||||
|
||||
|
||||
+8
-23
@@ -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',
|
||||
|
||||
+6
-21
@@ -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
|
||||
|
||||
-8
@@ -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;
|
||||
|
||||
+4
-6
@@ -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();
|
||||
|
||||
|
||||
+9
-22
@@ -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',
|
||||
|
||||
+8
-25
@@ -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()"
|
||||
/>
|
||||
-8
@@ -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;
|
||||
|
||||
+5
-10
@@ -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,
|
||||
|
||||
+9
-24
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user