mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
feat: add channel details dialog and context menu for channel items
This commit is contained in:
1 parent
9c0d49a1dc
commit
c2f5c62e82
16 files changed
+879
-7
No files matched your search
@@ -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",
|
||||
|
||||
+18
@@ -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>
|
||||
+8
@@ -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 {
|
||||
|
||||
+113
@@ -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)',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
+50
@@ -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)',
|
||||
});
|
||||
}
|
||||
}
|
||||
+109
@@ -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>
|
||||
+80
@@ -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;
|
||||
}
|
||||
}
|
||||
+85
@@ -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',
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
+218
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+1
@@ -6,6 +6,7 @@
|
||||
cdkDragPreviewContainer="parent"
|
||||
[class.active]="selected()"
|
||||
(click)="clicked.emit()"
|
||||
(contextmenu)="onContextMenu($event)"
|
||||
data-test-id="channel-item"
|
||||
>
|
||||
@if (isDraggable()) {
|
||||
|
||||
+36
-3
@@ -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,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
+12
@@ -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();
|
||||
}
|
||||
|
||||
+20
@@ -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>
|
||||
+8
@@ -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;
|
||||
|
||||
+40
-3
@@ -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();
|
||||
|
||||
+46
@@ -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
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user