feat: add channel details dialog and context menu for channel items

This commit is contained in:
4gray committed 2026-04-10 07:54:44 +02:00
1 parent 9c0d49a1dc
commit c2f5c62e82
16 files changed
+879 -7

No files matched your search

+35 -1
View File
@@ -279,6 +279,7 @@
"USE_STAR_TO_FAVORITE": "Please use 'star' icon to add channels to your favorites.",
"REMOVE_FAVORITE": "Remove from favorites",
"ADD_FAVORITE": "Add to favorites",
"SHOW_DETAILS": "Show channel details",
"FAVORITES_UPDATED": "Favorites were updated!",
"SELECT_CHANNEL_PLAYBACK": "Please select a channel to start playback",
"SORT_BY": "Sort by",
@@ -291,7 +292,40 @@
"LOADING_MORE": "Loading more...",
"NO_SEARCH_RESULTS": "No channels found",
"NO_CHANNELS_FOUND": "No channels found",
"TRY_DIFFERENT_SEARCH": "Try a different search term"
"TRY_DIFFERENT_SEARCH": "Try a different search term",
"DETAILS_DIALOG": {
"TITLE": "Channel details",
"DESCRIPTION": "Read-only metadata parsed from the playlist file.",
"SUMMARY": "Summary",
"TVG": "TVG metadata",
"CATCHUP": "Catchup",
"HTTP": "HTTP headers",
"NAME": "Name",
"CHANNEL_ID": "Channel ID",
"STREAM_URL": "Stream URL",
"GROUP": "Group",
"RADIO": "Radio",
"CATCHUP_AVAILABLE": "Catchup available",
"WINDOW": "Archive window",
"PLAYBACK_SUPPORTED": "Playback supported",
"EPG_PARAMS": "EPG params",
"TIMESHIFT": "Timeshift",
"TVG_ID": "TVG ID",
"TVG_NAME": "TVG name",
"TVG_URL": "TVG URL",
"TVG_LOGO": "TVG logo",
"TVG_REC": "TVG rec",
"CATCHUP_TYPE": "Catchup type",
"CATCHUP_SOURCE": "Catchup source",
"CATCHUP_DAYS": "Catchup days",
"HTTP_ORIGIN": "Origin",
"HTTP_REFERRER": "Referrer",
"HTTP_USER_AGENT": "User-Agent",
"EMPTY": "Not provided",
"NOT_AVAILABLE": "Not available",
"DAYS_ONE": "1 day",
"DAYS_OTHER": "{{count}} days"
}
},
"TOP_MENU": {
"OPEN_CHANNELS_LIST": "Open channel list",
@@ -28,10 +28,12 @@
"
[logo]="channel?.tvg?.logo"
[showEpg]="shouldShowEpg()"
[showDetailsContextMenu]="true"
[isRadio]="channel?.radio === 'true'"
[epgProgram]="channel.epgProgram"
[progressPercentage]="channel.progressPercentage"
(clicked)="onChannelClick(channel)"
(contextMenuRequested)="onChannelContextMenu(channel, $event)"
[selected]="activeChannelUrl() === channel?.url"
[showFavoriteButton]="true"
[isFavorite]="favoriteIds().has(channel?.url)"
@@ -40,3 +42,19 @@
</cdk-virtual-scroll-viewport>
}
</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">
<button mat-menu-item (click)="openChannelDetails()">
<mat-icon>info</mat-icon>
<span>{{ 'CHANNELS.SHOW_DETAILS' | translate }}</span>
</button>
</mat-menu>
@@ -42,6 +42,14 @@
overflow-x: hidden;
}
.channel-context-menu-anchor {
position: fixed;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
::ng-deep {
.search-bar {
div {
@@ -0,0 +1,113 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { MatDialog } from '@angular/material/dialog';
import { TranslateModule } from '@ngx-translate/core';
import { Channel } from 'shared-interfaces';
import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component';
import { AllChannelsViewComponent } from './all-channels-view.component';
function createChannel(id: string, name: string, url: string): Channel {
return {
epgParams: '',
group: {
title: 'News',
},
http: {
origin: '',
referrer: '',
'user-agent': '',
},
id,
name,
radio: 'false',
tvg: {
id: `${id}-tvg`,
logo: '',
name,
rec: '7',
url: '',
},
url,
catchup: {
days: '7',
type: 'shift',
},
} as Channel;
}
describe('AllChannelsViewComponent', () => {
let fixture: ComponentFixture<AllChannelsViewComponent>;
let component: AllChannelsViewComponent;
let dialog: { open: jest.Mock };
const primaryChannel = createChannel(
'channel-1',
'News One',
'https://example.com/news-one.m3u8'
);
beforeEach(async () => {
dialog = {
open: jest.fn(),
};
await TestBed.configureTestingModule({
imports: [
AllChannelsViewComponent,
NoopAnimationsModule,
TranslateModule.forRoot(),
],
providers: [
{
provide: MatDialog,
useValue: dialog,
},
],
}).compileComponents();
fixture = TestBed.createComponent(AllChannelsViewComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('channels', [primaryChannel]);
fixture.componentRef.setInput('searchTerm', '');
fixture.componentRef.setInput('channelEpgMap', new Map<string, null>());
fixture.componentRef.setInput('progressTick', 0);
fixture.componentRef.setInput('shouldShowEpg', false);
fixture.componentRef.setInput('itemSize', 48);
fixture.componentRef.setInput('favoriteIds', new Set<string>());
fixture.detectChanges();
});
it('stores viewport coordinates for the context menu and opens the dialog for that channel', async () => {
const openMenuSpy = jest
.spyOn(component.contextMenuTrigger(), 'openMenu')
.mockImplementation();
component.onChannelContextMenu(
primaryChannel,
{
clientX: 144,
clientY: 188,
} as MouseEvent
);
await Promise.resolve();
expect(component.contextMenuChannel()).toBe(primaryChannel);
expect(component.contextMenuPosition()).toEqual({
x: '144px',
y: '188px',
});
expect(openMenuSpy).toHaveBeenCalled();
component.openChannelDetails();
expect(dialog.open).toHaveBeenCalledWith(
ChannelDetailsDialogComponent,
expect.objectContaining({
data: primaryChannel,
maxWidth: '720px',
width: 'calc(100vw - 32px)',
})
);
});
});
@@ -4,12 +4,18 @@ import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
output,
signal,
viewChild,
} 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 { Channel, EpgProgram } from 'shared-interfaces';
import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component';
import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component';
/** Enriched channel with pre-computed EPG and progress data */
@@ -26,11 +32,18 @@ export interface EnrichedChannel extends Channel {
imports: [
ChannelListItemComponent,
MatIconModule,
MatMenuModule,
ScrollingModule,
TranslatePipe,
],
})
export class AllChannelsViewComponent {
private readonly dialog = inject(MatDialog);
readonly contextMenuTrigger = viewChild.required<MatMenuTrigger>(
'contextMenuTrigger'
);
/** All channels (will be filtered by search) */
readonly channels = input.required<Channel[]>();
readonly searchTerm = input('');
@@ -62,6 +75,12 @@ export class AllChannelsViewComponent {
event: MouseEvent;
}>();
readonly contextMenuChannel = signal<Channel | null>(null);
readonly contextMenuPosition = signal({
x: '0px',
y: '0px',
});
/**
* Computed signal for filtered and enriched channels.
*/
@@ -136,4 +155,35 @@ export class AllChannelsViewComponent {
onFavoriteToggle(channel: Channel, event: MouseEvent): void {
this.favoriteToggled.emit({ channel, event });
}
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();
});
}
openChannelDetails(): void {
const channel = this.contextMenuChannel();
if (!channel) {
return;
}
this.contextMenuTrigger().closeMenu();
this.dialog.open(ChannelDetailsDialogComponent, {
data: channel,
maxWidth: '720px',
width: 'calc(100vw - 32px)',
});
}
}
@@ -0,0 +1,109 @@
<h2 mat-dialog-title>{{ 'CHANNELS.DETAILS_DIALOG.TITLE' | translate }}</h2>
<mat-dialog-content class="channel-details-dialog mat-typography">
<p class="dialog-description">
{{ 'CHANNELS.DETAILS_DIALOG.DESCRIPTION' | translate }}
</p>
<section class="details-section">
<h3 class="section-title">
{{ 'CHANNELS.DETAILS_DIALOG.SUMMARY' | translate }}
</h3>
<dl class="details-grid">
@for (field of summaryFields; track field.labelKey) {
<div class="detail-row">
<dt>{{ field.labelKey | translate }}</dt>
<dd
[class.empty]="field.empty"
[class.monospace]="field.monospace"
[class.wrap]="field.wrap"
>
@if (field.valueKey) {
{{ field.valueKey | translate: field.translateParams }}
} @else {
{{ field.value }}
}
</dd>
</div>
}
</dl>
</section>
<section class="details-section">
<h3 class="section-title">
{{ 'CHANNELS.DETAILS_DIALOG.TVG' | translate }}
</h3>
<dl class="details-grid">
@for (field of tvgFields; track field.labelKey) {
<div class="detail-row">
<dt>{{ field.labelKey | translate }}</dt>
<dd
[class.empty]="field.empty"
[class.monospace]="field.monospace"
[class.wrap]="field.wrap"
>
@if (field.valueKey) {
{{ field.valueKey | translate: field.translateParams }}
} @else {
{{ field.value }}
}
</dd>
</div>
}
</dl>
</section>
<section class="details-section">
<h3 class="section-title">
{{ 'CHANNELS.DETAILS_DIALOG.CATCHUP' | translate }}
</h3>
<dl class="details-grid">
@for (field of catchupFields; track field.labelKey) {
<div class="detail-row">
<dt>{{ field.labelKey | translate }}</dt>
<dd
[class.empty]="field.empty"
[class.monospace]="field.monospace"
[class.wrap]="field.wrap"
>
@if (field.valueKey) {
{{ field.valueKey | translate: field.translateParams }}
} @else {
{{ field.value }}
}
</dd>
</div>
}
</dl>
</section>
<section class="details-section">
<h3 class="section-title">
{{ 'CHANNELS.DETAILS_DIALOG.HTTP' | translate }}
</h3>
<dl class="details-grid">
@for (field of httpFields; track field.labelKey) {
<div class="detail-row">
<dt>{{ field.labelKey | translate }}</dt>
<dd
[class.empty]="field.empty"
[class.monospace]="field.monospace"
[class.wrap]="field.wrap"
>
@if (field.valueKey) {
{{ field.valueKey | translate: field.translateParams }}
} @else {
{{ field.value }}
}
</dd>
</div>
}
</dl>
</section>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close>
{{ 'CLOSE' | translate }}
</button>
</mat-dialog-actions>
@@ -0,0 +1,80 @@
:host {
display: block;
}
.channel-details-dialog {
max-height: min(78vh, 720px);
}
.dialog-description {
margin: 0 0 20px;
color: var(--mat-sys-on-surface-variant);
}
.details-section + .details-section {
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid var(--mat-sys-outline-variant);
}
.section-title {
margin: 0 0 12px;
font-size: 0.92rem;
font-weight: 600;
line-height: 1.3;
}
.details-grid {
display: grid;
gap: 12px;
margin: 0;
}
.detail-row {
display: grid;
grid-template-columns: minmax(140px, 180px) minmax(0, 1fr);
gap: 6px 16px;
align-items: start;
}
dt {
margin: 0;
color: var(--mat-sys-on-surface-variant);
font-weight: 600;
}
dd {
margin: 0;
min-width: 0;
line-height: 1.45;
&.empty {
color: var(--mat-sys-on-surface-variant);
font-style: italic;
}
&.monospace {
font-family:
ui-monospace,
SFMono-Regular,
Menlo,
Monaco,
Consolas,
'Liberation Mono',
'Courier New',
monospace;
font-size: 0.92em;
}
&.wrap {
overflow-wrap: anywhere;
word-break: break-word;
}
}
@media (max-width: 640px) {
.detail-row {
grid-template-columns: minmax(0, 1fr);
gap: 4px;
}
}
@@ -0,0 +1,85 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
import { TranslateModule } from '@ngx-translate/core';
import { Channel } from 'shared-interfaces';
import { ChannelDetailsDialogComponent } from './channel-details-dialog.component';
describe('ChannelDetailsDialogComponent', () => {
let fixture: ComponentFixture<ChannelDetailsDialogComponent>;
let component: ChannelDetailsDialogComponent;
const channel: Channel = {
epgParams: 'src=test-playlist',
group: {
title: 'News',
},
http: {
origin: 'https://provider.example.com',
referrer: 'https://provider.example.com/list',
'user-agent': 'IPTVnator Test',
},
id: 'channel-1',
name: 'News One',
radio: 'false',
tvg: {
id: 'news-one',
logo: 'https://provider.example.com/logo.png',
name: 'News One HD',
rec: '5',
url: 'https://provider.example.com/guide.xml',
},
url: 'https://provider.example.com/stream.m3u8',
catchup: {
days: '5',
source: 'https://provider.example.com/catchup.m3u8',
type: 'append',
},
timeshift: '5',
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
ChannelDetailsDialogComponent,
NoopAnimationsModule,
TranslateModule.forRoot(),
],
providers: [
{
provide: MAT_DIALOG_DATA,
useValue: channel,
},
],
}).compileComponents();
fixture = TestBed.createComponent(ChannelDetailsDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('derives archive window and playback support from parsed catchup metadata', () => {
expect(component.archiveDays).toBe(5);
expect(component.catchupAvailable).toBe(true);
expect(component.catchupPlaybackSupported).toBe(true);
expect(component.summaryFields).toEqual(
expect.arrayContaining([
expect.objectContaining({
labelKey: 'CHANNELS.DETAILS_DIALOG.CATCHUP_AVAILABLE',
valueKey: 'YES',
}),
expect.objectContaining({
labelKey: 'CHANNELS.DETAILS_DIALOG.WINDOW',
translateParams: {
count: 5,
},
valueKey: 'CHANNELS.DETAILS_DIALOG.DAYS_OTHER',
}),
expect.objectContaining({
labelKey: 'CHANNELS.DETAILS_DIALOG.PLAYBACK_SUPPORTED',
valueKey: 'YES',
}),
])
);
});
});
@@ -0,0 +1,218 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { TranslatePipe } from '@ngx-translate/core';
import { getM3uArchiveDays, isM3uCatchupPlaybackSupported } from 'm3u-utils';
import { Channel } from 'shared-interfaces';
interface ChannelDetailField {
readonly empty?: boolean;
readonly labelKey: string;
readonly monospace?: boolean;
readonly translateParams?: Record<string, number>;
readonly value?: string;
readonly valueKey?: string;
readonly wrap?: boolean;
}
@Component({
selector: 'app-channel-details-dialog',
templateUrl: './channel-details-dialog.component.html',
styleUrls: ['./channel-details-dialog.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MatButtonModule, MatDialogModule, TranslatePipe],
})
export class ChannelDetailsDialogComponent {
readonly channel = inject<Channel>(MAT_DIALOG_DATA);
readonly archiveDays = getM3uArchiveDays(this.channel);
readonly catchupAvailable = this.archiveDays > 0;
readonly catchupPlaybackSupported = isM3uCatchupPlaybackSupported(
this.channel
);
readonly summaryFields: ChannelDetailField[] = [
this.createTextField(
'CHANNELS.DETAILS_DIALOG.NAME',
this.channel.name
),
this.createTextField(
'CHANNELS.DETAILS_DIALOG.CHANNEL_ID',
this.channel.id,
{
monospace: true,
}
),
this.createTextField(
'CHANNELS.DETAILS_DIALOG.STREAM_URL',
this.channel.url,
{
monospace: true,
wrap: true,
}
),
this.createTextField(
'CHANNELS.DETAILS_DIALOG.GROUP',
this.channel.group?.title
),
this.createBooleanField(
'CHANNELS.DETAILS_DIALOG.RADIO',
this.channel.radio === 'true'
),
this.createBooleanField(
'CHANNELS.DETAILS_DIALOG.CATCHUP_AVAILABLE',
this.catchupAvailable
),
this.createArchiveWindowField(),
this.createBooleanField(
'CHANNELS.DETAILS_DIALOG.PLAYBACK_SUPPORTED',
this.catchupPlaybackSupported
),
this.createTextField(
'CHANNELS.DETAILS_DIALOG.EPG_PARAMS',
this.channel.epgParams,
{
monospace: true,
wrap: true,
}
),
this.createTextField(
'CHANNELS.DETAILS_DIALOG.TIMESHIFT',
this.channel.timeshift
),
];
readonly tvgFields: ChannelDetailField[] = [
this.createTextField(
'CHANNELS.DETAILS_DIALOG.TVG_ID',
this.channel.tvg?.id,
{
monospace: true,
}
),
this.createTextField(
'CHANNELS.DETAILS_DIALOG.TVG_NAME',
this.channel.tvg?.name
),
this.createTextField(
'CHANNELS.DETAILS_DIALOG.TVG_URL',
this.channel.tvg?.url,
{
monospace: true,
wrap: true,
}
),
this.createTextField(
'CHANNELS.DETAILS_DIALOG.TVG_LOGO',
this.channel.tvg?.logo,
{
monospace: true,
wrap: true,
}
),
this.createTextField(
'CHANNELS.DETAILS_DIALOG.TVG_REC',
this.channel.tvg?.rec
),
];
readonly catchupFields: ChannelDetailField[] = [
this.createTextField(
'CHANNELS.DETAILS_DIALOG.CATCHUP_TYPE',
this.channel.catchup?.type
),
this.createTextField(
'CHANNELS.DETAILS_DIALOG.CATCHUP_SOURCE',
this.channel.catchup?.source,
{
monospace: true,
wrap: true,
}
),
this.createTextField(
'CHANNELS.DETAILS_DIALOG.CATCHUP_DAYS',
this.channel.catchup?.days
),
];
readonly httpFields: ChannelDetailField[] = [
this.createTextField(
'CHANNELS.DETAILS_DIALOG.HTTP_ORIGIN',
this.channel.http?.origin,
{
monospace: true,
wrap: true,
}
),
this.createTextField(
'CHANNELS.DETAILS_DIALOG.HTTP_REFERRER',
this.channel.http?.referrer,
{
monospace: true,
wrap: true,
}
),
this.createTextField(
'CHANNELS.DETAILS_DIALOG.HTTP_USER_AGENT',
this.channel.http?.['user-agent'],
{
monospace: true,
wrap: true,
}
),
];
private createArchiveWindowField(): ChannelDetailField {
if (!this.catchupAvailable) {
return {
empty: true,
labelKey: 'CHANNELS.DETAILS_DIALOG.WINDOW',
valueKey: 'CHANNELS.DETAILS_DIALOG.NOT_AVAILABLE',
};
}
return {
labelKey: 'CHANNELS.DETAILS_DIALOG.WINDOW',
translateParams: {
count: this.archiveDays,
},
valueKey:
this.archiveDays === 1
? 'CHANNELS.DETAILS_DIALOG.DAYS_ONE'
: 'CHANNELS.DETAILS_DIALOG.DAYS_OTHER',
};
}
private createBooleanField(
labelKey: string,
value: boolean
): ChannelDetailField {
return {
labelKey,
valueKey: value ? 'YES' : 'NO',
};
}
private createTextField(
labelKey: string,
value: string | null | undefined,
options: Pick<ChannelDetailField, 'monospace' | 'wrap'> = {}
): ChannelDetailField {
const normalized = value?.trim() ?? '';
if (!normalized) {
return {
...options,
empty: true,
labelKey,
valueKey: 'CHANNELS.DETAILS_DIALOG.EMPTY',
};
}
return {
...options,
labelKey,
value: normalized,
};
}
}
@@ -6,6 +6,7 @@
cdkDragPreviewContainer="parent"
[class.active]="selected()"
(click)="clicked.emit()"
(contextmenu)="onContextMenu($event)"
data-test-id="channel-item"
>
@if (isDraggable()) {
@@ -7,8 +7,13 @@ import { ChannelListItemComponent } from './channel-list-item.component';
describe('ChannelListItemComponent', () => {
let fixture: ComponentFixture<ChannelListItemComponent>;
let dialog: { open: jest.Mock };
beforeEach(async () => {
dialog = {
open: jest.fn(),
};
await TestBed.configureTestingModule({
imports: [
ChannelListItemComponent,
@@ -18,9 +23,7 @@ describe('ChannelListItemComponent', () => {
providers: [
{
provide: MatDialog,
useValue: {
open: jest.fn(),
},
useValue: dialog,
},
],
}).compileComponents();
@@ -80,4 +83,34 @@ describe('ChannelListItemComponent', () => {
fixture.nativeElement.querySelector('.epg-placeholder')
).toBeNull();
});
it('emits a context menu request on right click when details are enabled', () => {
fixture.componentRef.setInput('name', 'News One');
fixture.componentRef.setInput('showDetailsContextMenu', true);
fixture.detectChanges();
const preventDefault = jest.fn();
const stopPropagation = jest.fn();
const contextMenuRequested = jest.fn();
fixture.componentInstance.contextMenuRequested.subscribe(
contextMenuRequested
);
fixture.componentInstance.onContextMenu({
clientX: 120,
clientY: 56,
preventDefault,
stopPropagation,
} as unknown as MouseEvent);
expect(preventDefault).toHaveBeenCalled();
expect(stopPropagation).toHaveBeenCalled();
expect(contextMenuRequested).toHaveBeenCalledWith(
expect.objectContaining({
clientX: 120,
clientY: 56,
})
);
});
});
@@ -41,6 +41,7 @@ export class ChannelListItemComponent {
readonly showFavoriteButton = input(false);
readonly showAuxActionButton = input(false);
readonly showProgramInfoButton = input(true);
readonly showDetailsContextMenu = input(false);
readonly isFavorite = input(false);
readonly selected = input(false);
readonly showEpg = input(true);
@@ -54,6 +55,7 @@ export class ChannelListItemComponent {
readonly clicked = output<void>();
readonly favoriteToggled = output<MouseEvent>();
readonly auxActionClicked = output<MouseEvent>();
readonly contextMenuRequested = output<MouseEvent>();
constructor() {
effect(() => {
@@ -108,6 +110,16 @@ export class ChannelListItemComponent {
this.auxActionClicked.emit(event);
}
onContextMenu(event: MouseEvent): void {
if (!this.showDetailsContextMenu()) {
return;
}
event.preventDefault();
event.stopPropagation();
this.contextMenuRequested.emit(event);
}
showLogoFallback(): boolean {
return !this.logo() || this.logoFailed();
}
@@ -108,6 +108,7 @@
"
[logo]="channel?.tvg?.logo"
[showEpg]="shouldShowEpg()"
[showDetailsContextMenu]="true"
[isRadio]="channel?.radio === 'true'"
[epgProgram]="channel.epgProgram"
[progressPercentage]="channel.progressPercentage"
@@ -119,6 +120,9 @@
: false
"
(clicked)="onChannelClick(channel)"
(contextMenuRequested)="
onChannelContextMenu(channel, $event)
"
(favoriteToggled)="onFavoriteToggle(channel, $event)"
/>
</cdk-virtual-scroll-viewport>
@@ -126,3 +130,19 @@
</section>
</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">
<button mat-menu-item (click)="openChannelDetails()">
<mat-icon>info</mat-icon>
<span>{{ 'CHANNELS.SHOW_DETAILS' | translate }}</span>
</button>
</mat-menu>
@@ -81,6 +81,14 @@
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;
@@ -3,6 +3,7 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { MatDialog } from '@angular/material/dialog';
import { TranslateModule } from '@ngx-translate/core';
import { Channel } from 'shared-interfaces';
import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component';
import { GroupsViewComponent } from './groups-view.component';
function createChannel(
@@ -38,6 +39,7 @@ function createChannel(
describe('GroupsViewComponent', () => {
let fixture: ComponentFixture<GroupsViewComponent>;
let component: GroupsViewComponent;
let dialog: { open: jest.Mock };
const sportsCenter = createChannel(
'sports-1',
@@ -92,6 +94,10 @@ describe('GroupsViewComponent', () => {
});
beforeEach(async () => {
dialog = {
open: jest.fn(),
};
await TestBed.configureTestingModule({
imports: [
GroupsViewComponent,
@@ -101,9 +107,7 @@ describe('GroupsViewComponent', () => {
providers: [
{
provide: MatDialog,
useValue: {
open: jest.fn(),
},
useValue: dialog,
},
],
}).compileComponents();
@@ -255,6 +259,39 @@ 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')
.mockImplementation();
component.onChannelContextMenu(
movieClassic,
{
clientX: 212,
clientY: 264,
} as MouseEvent
);
await Promise.resolve();
expect(component.contextMenuChannel()).toBe(movieClassic);
expect(component.contextMenuPosition()).toEqual({
x: '212px',
y: '264px',
});
expect(openMenuSpy).toHaveBeenCalled();
component.openChannelDetails();
expect(dialog.open).toHaveBeenCalledWith(
ChannelDetailsDialogComponent,
expect.objectContaining({
data: movieClassic,
maxWidth: '720px',
width: 'calc(100vw - 32px)',
})
);
});
it('emits total sidebar width requests while resizing the groups rail', () => {
const requested = jest.fn();
const committed = jest.fn();
@@ -10,12 +10,16 @@ import {
input,
output,
signal,
viewChild,
} from '@angular/core';
import { ScrollingModule } from '@angular/cdk/scrolling';
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 { Channel, EpgProgram } from 'shared-interfaces';
import { EnrichedChannel } from '../all-channels-view/all-channels-view.component';
import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component';
import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component';
import { ResizableDirective } from '../../resizable/resizable.directive';
@@ -34,6 +38,7 @@ interface FilteredGroupView {
imports: [
ChannelListItemComponent,
MatIconModule,
MatMenuModule,
ResizableDirective,
ScrollingModule,
TitleCasePipe,
@@ -41,8 +46,13 @@ interface FilteredGroupView {
],
})
export class GroupsViewComponent {
private readonly dialog = inject(MatDialog);
private readonly hostEl = inject(ElementRef<HTMLElement>);
readonly contextMenuTrigger = viewChild.required<MatMenuTrigger>(
'contextMenuTrigger'
);
/** Grouped channels object */
readonly groupedChannels = input.required<{ [key: string]: Channel[] }>();
readonly searchTerm = input('');
@@ -82,6 +92,11 @@ export class GroupsViewComponent {
readonly selectedGroupKey = signal<string | null>(null);
readonly itemSize = computed(() => (this.shouldShowEpg() ? 68 : 48));
readonly contextMenuChannel = signal<Channel | null>(null);
readonly contextMenuPosition = signal({
x: '0px',
y: '0px',
});
private previousActiveChannelUrl: string | undefined;
private preservedContentWidth = 0;
@@ -294,6 +309,37 @@ export class GroupsViewComponent {
this.favoriteToggled.emit({ channel, event });
}
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();
});
}
openChannelDetails(): void {
const channel = this.contextMenuChannel();
if (!channel) {
return;
}
this.contextMenuTrigger().closeMenu();
this.dialog.open(ChannelDetailsDialogComponent, {
data: channel,
maxWidth: '720px',
width: 'calc(100vw - 32px)',
});
}
/**
* Comparator for sorting groups - numeric groups first, then alphabetical
*/