diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json
index 536485cc8..b59512dd1 100644
--- a/apps/web/src/assets/i18n/en.json
+++ b/apps/web/src/assets/i18n/en.json
@@ -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",
diff --git a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.html b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.html
index fd51e610e..5bab3411e 100644
--- a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.html
+++ b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.html
@@ -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 @@
}
+
+
+
+
+
+
diff --git a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.scss b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.scss
index c0719b49a..cccb46aa2 100644
--- a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.scss
+++ b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.scss
@@ -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 {
diff --git a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.spec.ts b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.spec.ts
new file mode 100644
index 000000000..81fb57d50
--- /dev/null
+++ b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.spec.ts
@@ -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;
+ 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());
+ fixture.componentRef.setInput('progressTick', 0);
+ fixture.componentRef.setInput('shouldShowEpg', false);
+ fixture.componentRef.setInput('itemSize', 48);
+ fixture.componentRef.setInput('favoriteIds', new Set());
+ 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)',
+ })
+ );
+ });
+});
diff --git a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.ts b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.ts
index 3fd4f57b4..ee2131328 100644
--- a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.ts
+++ b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.ts
@@ -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(
+ 'contextMenuTrigger'
+ );
+
/** All channels (will be filtered by search) */
readonly channels = input.required();
readonly searchTerm = input('');
@@ -62,6 +75,12 @@ export class AllChannelsViewComponent {
event: MouseEvent;
}>();
+ readonly contextMenuChannel = signal(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)',
+ });
+ }
}
diff --git a/libs/ui/components/src/lib/channel-list-container/channel-details-dialog/channel-details-dialog.component.html b/libs/ui/components/src/lib/channel-list-container/channel-details-dialog/channel-details-dialog.component.html
new file mode 100644
index 000000000..b91963b58
--- /dev/null
+++ b/libs/ui/components/src/lib/channel-list-container/channel-details-dialog/channel-details-dialog.component.html
@@ -0,0 +1,109 @@
+{{ 'CHANNELS.DETAILS_DIALOG.TITLE' | translate }}
+
+
+
+ {{ 'CHANNELS.DETAILS_DIALOG.DESCRIPTION' | translate }}
+
+
+
+
+ {{ 'CHANNELS.DETAILS_DIALOG.SUMMARY' | translate }}
+
+
+ @for (field of summaryFields; track field.labelKey) {
+
+
- {{ field.labelKey | translate }}
+ -
+ @if (field.valueKey) {
+ {{ field.valueKey | translate: field.translateParams }}
+ } @else {
+ {{ field.value }}
+ }
+
+
+ }
+
+
+
+
+
+ {{ 'CHANNELS.DETAILS_DIALOG.TVG' | translate }}
+
+
+ @for (field of tvgFields; track field.labelKey) {
+
+
- {{ field.labelKey | translate }}
+ -
+ @if (field.valueKey) {
+ {{ field.valueKey | translate: field.translateParams }}
+ } @else {
+ {{ field.value }}
+ }
+
+
+ }
+
+
+
+
+
+ {{ 'CHANNELS.DETAILS_DIALOG.CATCHUP' | translate }}
+
+
+ @for (field of catchupFields; track field.labelKey) {
+
+
- {{ field.labelKey | translate }}
+ -
+ @if (field.valueKey) {
+ {{ field.valueKey | translate: field.translateParams }}
+ } @else {
+ {{ field.value }}
+ }
+
+
+ }
+
+
+
+
+
+ {{ 'CHANNELS.DETAILS_DIALOG.HTTP' | translate }}
+
+
+ @for (field of httpFields; track field.labelKey) {
+
+
- {{ field.labelKey | translate }}
+ -
+ @if (field.valueKey) {
+ {{ field.valueKey | translate: field.translateParams }}
+ } @else {
+ {{ field.value }}
+ }
+
+
+ }
+
+
+
+
+
+
+
diff --git a/libs/ui/components/src/lib/channel-list-container/channel-details-dialog/channel-details-dialog.component.scss b/libs/ui/components/src/lib/channel-list-container/channel-details-dialog/channel-details-dialog.component.scss
new file mode 100644
index 000000000..445fbb956
--- /dev/null
+++ b/libs/ui/components/src/lib/channel-list-container/channel-details-dialog/channel-details-dialog.component.scss
@@ -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;
+ }
+}
diff --git a/libs/ui/components/src/lib/channel-list-container/channel-details-dialog/channel-details-dialog.component.spec.ts b/libs/ui/components/src/lib/channel-list-container/channel-details-dialog/channel-details-dialog.component.spec.ts
new file mode 100644
index 000000000..cc6b51cbc
--- /dev/null
+++ b/libs/ui/components/src/lib/channel-list-container/channel-details-dialog/channel-details-dialog.component.spec.ts
@@ -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;
+ 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',
+ }),
+ ])
+ );
+ });
+});
diff --git a/libs/ui/components/src/lib/channel-list-container/channel-details-dialog/channel-details-dialog.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-details-dialog/channel-details-dialog.component.ts
new file mode 100644
index 000000000..321a5c0ea
--- /dev/null
+++ b/libs/ui/components/src/lib/channel-list-container/channel-details-dialog/channel-details-dialog.component.ts
@@ -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;
+ 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(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 {
+ const normalized = value?.trim() ?? '';
+
+ if (!normalized) {
+ return {
+ ...options,
+ empty: true,
+ labelKey,
+ valueKey: 'CHANNELS.DETAILS_DIALOG.EMPTY',
+ };
+ }
+
+ return {
+ ...options,
+ labelKey,
+ value: normalized,
+ };
+ }
+}
diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html
index 53eab90b4..9e28d1b3f 100644
--- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html
+++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html
@@ -6,6 +6,7 @@
cdkDragPreviewContainer="parent"
[class.active]="selected()"
(click)="clicked.emit()"
+ (contextmenu)="onContextMenu($event)"
data-test-id="channel-item"
>
@if (isDraggable()) {
diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts
index dd2146e43..833acec72 100644
--- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts
+++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts
@@ -7,8 +7,13 @@ import { ChannelListItemComponent } from './channel-list-item.component';
describe('ChannelListItemComponent', () => {
let fixture: ComponentFixture;
+ 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,
+ })
+ );
+ });
});
diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts
index 992c6f2ca..b30733ce3 100644
--- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts
+++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts
@@ -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();
readonly favoriteToggled = output();
readonly auxActionClicked = output();
+ readonly contextMenuRequested = output();
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();
}
diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html
index 47e659daf..24091cc97 100644
--- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html
+++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html
@@ -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)"
/>
@@ -126,3 +130,19 @@
}
+
+
+
+
+
+
diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.scss b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.scss
index 03da9c30e..dbfbb7188 100644
--- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.scss
+++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.scss
@@ -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;
diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts
index 1517027a1..ba96734f1 100644
--- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts
+++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts
@@ -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;
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();
diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts
index 1a1c4d761..520362322 100644
--- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts
+++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts
@@ -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);
+ readonly contextMenuTrigger = viewChild.required(
+ 'contextMenuTrigger'
+ );
+
/** Grouped channels object */
readonly groupedChannels = input.required<{ [key: string]: Channel[] }>();
readonly searchTerm = input('');
@@ -82,6 +92,11 @@ export class GroupsViewComponent {
readonly selectedGroupKey = signal(null);
readonly itemSize = computed(() => (this.shouldShowEpg() ? 68 : 48));
+ readonly contextMenuChannel = signal(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
*/