From c2f5c62e820b05acfdee38301d729dce1795a894 Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 10 Apr 2026 07:54:44 +0200 Subject: [PATCH] feat: add channel details dialog and context menu for channel items --- apps/web/src/assets/i18n/en.json | 36 ++- .../all-channels-view.component.html | 18 ++ .../all-channels-view.component.scss | 8 + .../all-channels-view.component.spec.ts | 113 +++++++++ .../all-channels-view.component.ts | 50 ++++ .../channel-details-dialog.component.html | 109 +++++++++ .../channel-details-dialog.component.scss | 80 +++++++ .../channel-details-dialog.component.spec.ts | 85 +++++++ .../channel-details-dialog.component.ts | 218 ++++++++++++++++++ .../channel-list-item.component.html | 1 + .../channel-list-item.component.spec.ts | 39 +++- .../channel-list-item.component.ts | 12 + .../groups-view/groups-view.component.html | 20 ++ .../groups-view/groups-view.component.scss | 8 + .../groups-view/groups-view.component.spec.ts | 43 +++- .../groups-view/groups-view.component.ts | 46 ++++ 16 files changed, 879 insertions(+), 7 deletions(-) create mode 100644 libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.spec.ts create mode 100644 libs/ui/components/src/lib/channel-list-container/channel-details-dialog/channel-details-dialog.component.html create mode 100644 libs/ui/components/src/lib/channel-list-container/channel-details-dialog/channel-details-dialog.component.scss create mode 100644 libs/ui/components/src/lib/channel-list-container/channel-details-dialog/channel-details-dialog.component.spec.ts create mode 100644 libs/ui/components/src/lib/channel-list-container/channel-details-dialog/channel-details-dialog.component.ts 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 */