feat(m3u): add context menus to recent channels

Entire-Checkpoint: f957cd9849e0
This commit is contained in:
4gray committed 2026-05-05 00:27:15 +02:00
1 parent 008bafbaae
commit 6cbf2a1cfb
26 files changed
+827 -7

No files matched your search

+2 -1
View File
@@ -31,9 +31,10 @@ The application is a cross-platform, open-source project built with Electron and
- EPG support (TV Guide) with detailed information
- TV archive/catchup/timeshift functionality
- Group-based channel list
- Read-only M3U channel details from the channel context menu
- Read-only M3U channel details from channel context menus, including favorites and recently viewed
- Favorite channels management
- Global favorites aggregated from all playlists
- Recently viewed live channel removal from row actions and context menus
- HTML video player with HLS.js support or Video.js-based player
- Internationalization with support for 16 languages:
- Arabic
@@ -163,6 +163,62 @@ test.describe('Electron Recently Viewed', () => {
}
});
test('removes one M3U recent live channel from the all-playlists context menu', async ({
dataDir,
}) => {
const filePath = writeTemporaryM3uFile(
dataDir,
'm3u-recent-context-menu-source.m3u',
[
{
groupTitle: 'Live',
name: 'Context Recent One',
url: 'https://streams.example.test/context-recent-one.m3u8',
},
{
groupTitle: 'Live',
name: 'Context Recent Two',
url: 'https://streams.example.test/context-recent-two.m3u8',
},
]
);
const app = await launchElectronApp(dataDir);
try {
await importM3uPlaylistFromNativeDialog(app, filePath);
await waitForM3uCatalog(app.mainWindow);
await channelItemByTitle(app.mainWindow, 'Context Recent One')
.first()
.click();
await channelItemByTitle(app.mainWindow, 'Context Recent Two')
.first()
.click();
await openPlaylistRecent(app.mainWindow);
await switchUnifiedCollectionScope(app.mainWindow, 'All playlists');
await expect
.poll(() => visibleLiveTitles(app.mainWindow))
.toEqual(['Context Recent Two', 'Context Recent One']);
await channelItemByTitle(app.mainWindow, 'Context Recent One')
.first()
.click({ button: 'right' });
await app.mainWindow
.getByRole('menuitem', { name: 'Remove from history' })
.click();
await expect
.poll(() => visibleLiveTitles(app.mainWindow))
.toEqual(['Context Recent Two']);
await expect(
channelItemByTitle(app.mainWindow, 'Context Recent One')
).toHaveCount(0);
} finally {
await closeElectronApp(app);
}
});
test('tracks Xtream live, movie, and series history across playlist and all-playlists scope, persists after restart, and supports clearing', async ({
dataDir,
request,
+9 -1
View File
@@ -182,6 +182,8 @@ channel-list-container/
- `ChannelListContainerComponent` now renders a dedicated skeleton state while `channelsLoading` is true.
- `ChannelListContainerComponent` no longer clears `channels` on destroy; route/session code is the single owner of shared list lifecycle during navigation.
- The dedicated `/workspace/playlists/:id/favorites` and `/workspace/playlists/:id/recent` collection routes do not drive the shared sidebar channel list; they default to the `playlist` scope so rail links always open the current playlist view, not the last persisted global scope.
- M3U favorites and recent collection rows preserve their full `Channel` payload on unified live items so the shared live list can open the read-only channel details context menu without reconstructing partial channel data.
- Recent live rows support context-menu removal in the unified all-playlists view; the row-level delete shortcut remains available on playlist-scoped M3U recent rows.
- Empty playlists and empty search results are no longer conflated:
- loading: skeletons
- empty source: no channels in the playlist after loading completes
@@ -266,7 +268,13 @@ EPG lookup keys use the same precedence in both program and icon paths:
- **Inputs**: `favorites`, `channelEpgMap`, `channelIconMap`, `progressTick`, `shouldShowEpg`, `activeChannelUrl`
- **Outputs**: `channelSelected`, `favoriteToggled`, `favoritesReordered`
- **Features**: Drag-and-drop reordering with CDK DragDrop
- **Features**: Drag-and-drop reordering with CDK DragDrop, read-only channel details context menu
#### RecentTabComponent
- **Inputs**: recent channels, `channelEpgMap`, `channelIconMap`, `progressTick`, `shouldShowEpg`, `activeChannelUrl`
- **Outputs**: `channelSelected`, `favoriteToggled`, `recentItemRemoved`
- **Features**: Read-only channel details context menu, row-level and context-menu removal
## EPG Integration
@@ -42,8 +42,38 @@
[showFavoriteButton]="true"
[isFavorite]="isChannelFavorite(ch)"
[showProgramInfoButton]="true"
[showDetailsContextMenu]="hasChannelContextMenu(ch)"
(clicked)="onChannelClick(ch)"
(favoriteToggled)="onFavoriteToggled(ch)"
(contextMenuRequested)="onChannelContextMenu(ch, $event)"
/>
}
</div>
<div
aria-hidden="true"
class="channel-context-menu-anchor"
[style.left]="contextMenuPosition().x"
[style.top]="contextMenuPosition().y"
[matMenuTriggerFor]="channelContextMenu"
#contextMenuTrigger="matMenuTrigger"
></div>
<mat-menu #channelContextMenu="matMenu">
@if (contextMenuChannel()?.m3uChannel) {
<button mat-menu-item (click)="openChannelDetails()">
<mat-icon>info</mat-icon>
<span>{{ 'CHANNELS.SHOW_DETAILS' | translate }}</span>
</button>
}
@if (mode() === 'recent') {
<button mat-menu-item (click)="removeContextMenuChannel()">
<mat-icon>delete</mat-icon>
<span>
{{
'WORKSPACE.DASHBOARD.REMOVE_FROM_HISTORY' | translate
}}
</span>
</button>
}
</mat-menu>
@@ -40,6 +40,14 @@
gap: 2px;
}
.channel-context-menu-anchor {
position: fixed;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
.empty-state {
display: flex;
flex-direction: column;
@@ -2,13 +2,20 @@ 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 { ChannelDetailsDialogComponent } from 'components';
import { UnifiedFavoriteChannel } from '@iptvnator/portal/shared/util';
import { Channel } from 'shared-interfaces';
import { GlobalFavoritesListComponent } from './global-favorites-list.component';
describe('GlobalFavoritesListComponent', () => {
let fixture: ComponentFixture<GlobalFavoritesListComponent>;
let dialog: { open: jest.Mock };
beforeEach(async () => {
dialog = {
open: jest.fn(),
};
await TestBed.configureTestingModule({
imports: [
GlobalFavoritesListComponent,
@@ -18,7 +25,7 @@ describe('GlobalFavoritesListComponent', () => {
providers: [
{
provide: MatDialog,
useValue: { open: jest.fn() },
useValue: dialog,
},
],
}).compileComponents();
@@ -76,9 +83,114 @@ describe('GlobalFavoritesListComponent', () => {
expect(names).toEqual(['Zulu', 'Alpha']);
});
it('opens channel details for M3U rows with full channel metadata', async () => {
const channel = buildM3uChannel('a', 'Alpha');
const row = buildChannel('a', 'Alpha', {
m3uChannel: channel,
});
fixture.componentRef.setInput('channels', [row]);
fixture.detectChanges();
const openMenuSpy = jest
.spyOn(fixture.componentInstance.contextMenuTrigger(), 'openMenu')
.mockImplementation();
fixture.componentInstance.onChannelContextMenu(
fixture.componentInstance.enrichedChannels()[0],
{
clientX: 24,
clientY: 32,
} as MouseEvent
);
await Promise.resolve();
expect(fixture.componentInstance.contextMenuPosition()).toEqual({
x: '24px',
y: '32px',
});
expect(openMenuSpy).toHaveBeenCalled();
fixture.componentInstance.openChannelDetails();
expect(dialog.open).toHaveBeenCalledWith(
ChannelDetailsDialogComponent,
expect.objectContaining({
data: channel,
maxWidth: '720px',
width: 'calc(100vw - 32px)',
})
);
});
it('emits recent row removal from the context menu', async () => {
const row = buildChannel('a', 'Alpha');
const removed = jest.fn();
fixture.componentInstance.removeRequested.subscribe(removed);
fixture.componentRef.setInput('mode', 'recent');
fixture.componentRef.setInput('channels', [row]);
fixture.detectChanges();
jest.spyOn(
fixture.componentInstance.contextMenuTrigger(),
'openMenu'
).mockImplementation();
fixture.componentInstance.onChannelContextMenu(
fixture.componentInstance.enrichedChannels()[0],
{
clientX: 24,
clientY: 32,
} as MouseEvent
);
await Promise.resolve();
fixture.componentInstance.removeContextMenuChannel();
expect(removed).toHaveBeenCalledWith(
expect.objectContaining({
uid: row.uid,
})
);
});
it('does not open channel details for non-M3U rows without full channel metadata', async () => {
const row = {
...buildChannel('xtream-live', 'Xtream Live'),
sourceType: 'xtream',
streamUrl: undefined,
xtreamId: 42,
} satisfies UnifiedFavoriteChannel;
fixture.componentRef.setInput('mode', 'recent');
fixture.componentRef.setInput('channels', [row]);
fixture.detectChanges();
expect(fixture.componentInstance.hasChannelContextMenu(row)).toBe(true);
jest.spyOn(
fixture.componentInstance.contextMenuTrigger(),
'openMenu'
).mockImplementation();
fixture.componentInstance.onChannelContextMenu(
fixture.componentInstance.enrichedChannels()[0],
{
clientX: 24,
clientY: 32,
} as MouseEvent
);
await Promise.resolve();
fixture.componentInstance.openChannelDetails();
expect(dialog.open).not.toHaveBeenCalled();
});
});
function buildChannel(uid: string, name: string): UnifiedFavoriteChannel {
function buildChannel(
uid: string,
name: string,
overrides: Partial<UnifiedFavoriteChannel> = {}
): UnifiedFavoriteChannel {
return {
uid,
name,
@@ -89,5 +201,29 @@ function buildChannel(uid: string, name: string): UnifiedFavoriteChannel {
streamUrl: `https://example.com/${uid}.m3u8`,
addedAt: '2026-04-30T12:00:00.000Z',
position: 0,
...overrides,
};
}
function buildM3uChannel(id: string, name: string): Channel {
return {
id,
name,
url: `https://example.com/${id}.m3u8`,
group: { title: 'Live' },
tvg: {
id,
name,
url: '',
logo: '',
rec: '',
},
http: {
referrer: '',
'user-agent': '',
origin: '',
},
radio: 'false',
epgParams: '',
};
}
@@ -7,11 +7,19 @@ 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 { ChannelListItemComponent } from 'components';
import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import {
ChannelDetailsDialogComponent,
ChannelListItemComponent,
} from 'components';
import { EpgProgram } from 'shared-interfaces';
import {
DEFAULT_FAVORITES_CHANNEL_SORT_MODE,
@@ -37,10 +45,16 @@ export type GlobalFavoritesListMode = 'favorites' | 'recent';
ChannelListItemComponent,
DragDropModule,
MatIconModule,
MatMenuModule,
TranslateModule,
],
})
export class GlobalFavoritesListComponent {
private readonly dialog = inject(MatDialog);
readonly contextMenuTrigger =
viewChild.required<MatMenuTrigger>('contextMenuTrigger');
readonly channels = input.required<UnifiedFavoriteChannel[]>();
readonly mode = input<GlobalFavoritesListMode>('favorites');
readonly favoriteUids = input<ReadonlySet<string>>(new Set<string>());
@@ -56,6 +70,13 @@ export class GlobalFavoritesListComponent {
readonly channelSelected = output<UnifiedFavoriteChannel>();
readonly channelsReordered = output<UnifiedFavoriteChannel[]>();
readonly favoriteToggled = output<UnifiedFavoriteChannel>();
readonly removeRequested = output<UnifiedFavoriteChannel>();
readonly contextMenuChannel = signal<EnrichedUnifiedFavorite | null>(null);
readonly contextMenuPosition = signal({
x: '0px',
y: '0px',
});
readonly isCustomSort = computed(() => this.sortMode() === 'custom');
readonly canDragDrop = computed(
@@ -109,6 +130,54 @@ export class GlobalFavoritesListComponent {
this.favoriteToggled.emit(channel);
}
onChannelContextMenu(
channel: EnrichedUnifiedFavorite,
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();
});
}
hasChannelContextMenu(channel: UnifiedFavoriteChannel): boolean {
return Boolean(channel.m3uChannel) || this.mode() === 'recent';
}
openChannelDetails(): void {
const channel = this.contextMenuChannel()?.m3uChannel;
if (!channel) {
return;
}
this.contextMenuTrigger().closeMenu();
this.dialog.open(ChannelDetailsDialogComponent, {
data: channel,
maxWidth: '720px',
width: 'calc(100vw - 32px)',
});
}
removeContextMenuChannel(): void {
const channel = this.contextMenuChannel();
if (!channel) {
return;
}
this.contextMenuTrigger().closeMenu();
this.removeRequested.emit(channel);
}
isChannelFavorite(channel: UnifiedFavoriteChannel): boolean {
return (
this.mode() === 'favorites' || this.favoriteUids().has(channel.uid)
@@ -43,6 +43,7 @@ class StubUnifiedLiveTabComponent {
readonly autoOpenItem = input<unknown>(null);
readonly favoriteUids = input<ReadonlySet<string>>(new Set<string>());
readonly sortMode = input<FavoritesChannelSortMode>('custom');
readonly isSidebarCollapsed = input(false);
readonly removeItem = output<UnifiedCollectionItem>();
readonly favoriteToggled = output<UnifiedCollectionItem>();
@@ -488,6 +489,55 @@ describe('UnifiedCollectionPageComponent', () => {
expect(fixture.componentInstance.allItems()).toEqual([recentItem]);
});
it('removes one recent live row through the live tab remove event', async () => {
const recentItems = [
{
uid: 'm3u::playlist-1::https://example.com/one.m3u8',
name: 'Recent One',
contentType: 'live',
sourceType: 'm3u',
playlistId: 'playlist-1',
playlistName: 'Playlist One',
streamUrl: 'https://example.com/one.m3u8',
},
{
uid: 'xtream::playlist-2::live:42',
name: 'Recent Two',
contentType: 'live',
sourceType: 'xtream',
playlistId: 'playlist-2',
playlistName: 'Playlist Two',
xtreamId: 42,
contentId: 1001,
},
] satisfies UnifiedCollectionItem[];
recentData.removeRecentItem.mockResolvedValue(undefined);
recentData.getRecentItems.mockResolvedValueOnce(recentItems);
favoritesData.getFavorites.mockResolvedValueOnce([]);
fixture.componentRef.setInput('mode', 'recent');
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.componentInstance.isLoading.set(false);
fixture.componentInstance.selectedContentType.set('live');
fixture.componentInstance.allItems.set(recentItems);
fixture.detectChanges();
const liveTab = fixture.debugElement.query(
By.directive(StubUnifiedLiveTabComponent)
).componentInstance as StubUnifiedLiveTabComponent;
liveTab.removeItem.emit(recentItems[0]);
await fixture.whenStable();
expect(recentData.removeRecentItem).toHaveBeenCalledWith(
recentItems[0]
);
expect(fixture.componentInstance.allItems()).toEqual([recentItems[1]]);
});
it('clears current favorites through the bulk clear service path', async () => {
const liveItems = [
{
@@ -20,6 +20,7 @@
(channelSelected)="onChannelSelected($event)"
(channelsReordered)="onReorder($event)"
(favoriteToggled)="onFavoriteToggled($event)"
(removeRequested)="onRemoveRequested($event)"
/>
</div>
@@ -200,6 +200,7 @@ export class UnifiedLiveTabComponent {
playlistId: item.playlistId,
playlistName: item.playlistName,
streamUrl: item.streamUrl,
m3uChannel: item.m3uChannel,
xtreamId: item.xtreamId,
tvgId: item.tvgId,
stalkerCmd: item.stalkerCmd,
@@ -281,6 +282,15 @@ export class UnifiedLiveTabComponent {
}
}
onRemoveRequested(channel: UnifiedFavoriteChannel): void {
const item = this.items().find(
(candidate) => candidate.uid === channel.uid
);
if (item) {
this.removeItem.emit(item);
}
}
onReorder(channels: UnifiedFavoriteChannel[]): void {
const reordered = channels
.map((channel) =>
@@ -5,6 +5,8 @@
* Extends the previous live-only UnifiedFavoriteChannel to cover
* all content types: live TV, movies, and series.
*/
import { Channel } from 'shared-interfaces';
export type CollectionSourceType = 'm3u' | 'xtream' | 'stalker';
export type CollectionContentType = 'live' | 'movie' | 'series';
@@ -45,6 +47,9 @@ export interface UnifiedCollectionItem {
/** M3U radio flag used to preserve the dedicated radio player layout */
radio?: string;
/** Full M3U channel metadata used by shared live collection row actions */
m3uChannel?: Channel;
/** Xtream numeric stream ID */
xtreamId?: number;
@@ -2,6 +2,8 @@
* Unified interface for displaying live TV favorites from any source
* (M3U playlists, Xtream Codes portals, Stalker portals) in one list.
*/
import { Channel } from 'shared-interfaces';
export type FavoriteSourceType = 'm3u' | 'xtream' | 'stalker';
export interface UnifiedFavoriteChannel {
@@ -29,6 +31,9 @@ export interface UnifiedFavoriteChannel {
/** Direct stream URL (M3U channels only) */
streamUrl?: string;
/** Full M3U channel metadata used by row context menu actions */
m3uChannel?: Channel;
/** Xtream numeric stream ID (Xtream channels only) */
xtreamId?: number;
@@ -248,6 +248,8 @@ describe('UnifiedFavoritesDataService', () => {
]);
expect(items[1].channelId).toBe('channel-1');
expect(items[0].radio).toBe('true');
expect(items[0].m3uChannel).toBe(m3uChannels[1]);
expect(items[1].m3uChannel).toBe(m3uChannels[0]);
});
it('persists M3U playlist reorders through setFavorites', async () => {
@@ -459,6 +459,7 @@ export class UnifiedFavoritesDataService {
streamUrl: channel.url,
channelId: channel.id,
radio: channel.radio,
m3uChannel: channel,
tvgId: channel.tvg?.id || channel.tvg?.name || channel.name,
addedAt: new Date(0).toISOString(),
position: index,
@@ -74,6 +74,7 @@ class StubGlobalFavoritesListComponent {
readonly channelSelected = output<UnifiedFavoriteChannel>();
readonly channelsReordered = output<UnifiedFavoriteChannel[]>();
readonly favoriteToggled = output<UnifiedFavoriteChannel>();
readonly removeRequested = output<UnifiedFavoriteChannel>();
}
@Component({
@@ -322,6 +323,28 @@ describe('UnifiedLiveTabComponent', () => {
subscription.unsubscribe();
});
it('maps shared live list remove requests back to collection items', async () => {
const item = buildLiveItem('xtream');
const removedItems: UnifiedCollectionItem[] = [];
const subscription = component.removeItem.subscribe((removed) =>
removedItems.push(removed)
);
fixture.componentRef.setInput('items', [item]);
fixture.componentRef.setInput('mode', 'recent');
fixture.detectChanges();
await fixture.whenStable();
const list = fixture.debugElement.query(
By.directive(StubGlobalFavoritesListComponent)
).componentInstance as StubGlobalFavoritesListComponent;
list.removeRequested.emit(list.channels()[0]);
expect(removedItems).toEqual([item]);
subscription.unsubscribe();
});
it('wraps inline M3U EPG in the live panel with shared date navigation', async () => {
portalPlayer.isEmbeddedPlayer.mockReturnValue(true);
const item = buildLiveItem('m3u');
@@ -155,6 +155,7 @@ describe('UnifiedRecentDataService', () => {
tvgId: 'recent-two',
logo: 'two.png',
radio: 'true',
m3uChannel: channels[1],
});
});
@@ -467,6 +467,7 @@ export class UnifiedRecentDataService {
streamUrl: channel.url,
channelId: channel.id,
radio: channel.radio,
m3uChannel: channel,
tvgId:
channel.tvg?.id ||
recentItem.tvg_id ||
+1
View File
@@ -1,4 +1,5 @@
export * from './lib/channel-list-container/channel-list-container.component';
export * from './lib/channel-list-container/channel-details-dialog/channel-details-dialog.component';
export * from './lib/channel-list-container/channel-list-item/channel-list-item.component';
export * from './lib/channel-list-container/channel-list-item-skeleton/channel-list-item-skeleton.component';
export * from './lib/channel-list-container/channel-list-skeleton/channel-list-skeleton.component';
@@ -29,7 +29,9 @@
epgMetadataMap().get(getChannelEpgKey(channel))
?.progressPercentage ?? 0
"
[showDetailsContextMenu]="true"
(clicked)="onChannelClick(channel)"
(contextMenuRequested)="onChannelContextMenu(channel, $event)"
[selected]="activeChannelUrl() === channel?.url"
[showFavoriteButton]="true"
[isFavorite]="true"
@@ -55,3 +57,19 @@
</div>
}
</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>
@@ -15,6 +15,14 @@
overflow-x: hidden;
}
.channel-context-menu-anchor {
position: fixed;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
.empty-favorites,
.empty-favorites-hint {
padding: 16px 24px;
@@ -0,0 +1,105 @@
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 { FavoritesViewComponent } from './favorites-view.component';
function createChannel(id: string, name: string, url: string): Channel {
return {
id,
name,
url,
group: { title: 'Live' },
tvg: {
id,
name,
url: '',
logo: '',
rec: '',
},
http: {
referrer: '',
'user-agent': '',
origin: '',
},
radio: 'false',
epgParams: '',
};
}
describe('FavoritesViewComponent', () => {
let fixture: ComponentFixture<FavoritesViewComponent>;
let component: FavoritesViewComponent;
let dialog: { open: jest.Mock };
const primaryChannel = createChannel(
'channel-1',
'Favorite One',
'https://example.com/favorite-one.m3u8'
);
beforeEach(async () => {
dialog = {
open: jest.fn(),
};
await TestBed.configureTestingModule({
imports: [
FavoritesViewComponent,
NoopAnimationsModule,
TranslateModule.forRoot(),
],
providers: [
{
provide: MatDialog,
useValue: dialog,
},
],
}).compileComponents();
fixture = TestBed.createComponent(FavoritesViewComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('favorites', [primaryChannel]);
fixture.componentRef.setInput('searchTerm', '');
fixture.componentRef.setInput('channelEpgMap', new Map<string, null>());
fixture.componentRef.setInput(
'channelIconMap',
new Map<string, string>()
);
fixture.componentRef.setInput('progressTick', 0);
fixture.componentRef.setInput('shouldShowEpg', false);
fixture.detectChanges();
});
it('stores context-menu coordinates and opens channel details for the selected favorite', async () => {
const openMenuSpy = jest
.spyOn(component.contextMenuTrigger(), 'openMenu')
.mockImplementation();
component.onChannelContextMenu(primaryChannel, {
clientX: 88,
clientY: 132,
} as MouseEvent);
await Promise.resolve();
expect(component.contextMenuChannel()).toBe(primaryChannel);
expect(component.contextMenuPosition()).toEqual({
x: '88px',
y: '132px',
});
expect(openMenuSpy).toHaveBeenCalled();
component.openChannelDetails();
expect(dialog.open).toHaveBeenCalledWith(
ChannelDetailsDialogComponent,
expect.objectContaining({
data: primaryChannel,
maxWidth: '720px',
width: 'calc(100vw - 32px)',
})
);
});
});
@@ -7,13 +7,20 @@ 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 { resolveChannelEpgLookupKey } from 'm3u-state';
import { Channel, EpgProgram } from 'shared-interfaces';
import { ChannelEpgMetadata } from '../all-channels-view/all-channels-view.component';
import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component';
import { resolveChannelLogo } from '../channel-logo-fallback.util';
import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component';
@@ -22,9 +29,20 @@ import { ChannelListItemComponent } from '../channel-list-item/channel-list-item
templateUrl: './favorites-view.component.html',
styleUrls: ['./favorites-view.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ChannelListItemComponent, DragDropModule, TranslatePipe],
imports: [
ChannelListItemComponent,
DragDropModule,
MatIconModule,
MatMenuModule,
TranslatePipe,
],
})
export class FavoritesViewComponent {
private readonly dialog = inject(MatDialog);
readonly contextMenuTrigger =
viewChild.required<MatMenuTrigger>('contextMenuTrigger');
/** Favorite channels */
readonly favorites = input.required<Channel[]>();
readonly searchTerm = input('');
@@ -54,6 +72,12 @@ export class FavoritesViewComponent {
/** Emits when favorites order changes via drag-drop */
readonly favoritesReordered = output<string[]>();
readonly contextMenuChannel = signal<Channel | null>(null);
readonly contextMenuPosition = signal({
x: '0px',
y: '0px',
});
readonly hasSearchTerm = computed(() => this.searchTerm().trim().length > 0);
readonly filteredFavorites = computed(() => {
const favorites = this.favorites();
@@ -131,6 +155,37 @@ export class FavoritesViewComponent {
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)',
});
}
onDrop(event: CdkDragDrop<Channel[]>): void {
if (this.hasSearchTerm()) {
return;
@@ -18,12 +18,16 @@
[epgProgram]="item.epgProgram"
[progressPercentage]="item.progressPercentage"
[selected]="activeChannelUrl() === item.channel?.url"
[showDetailsContextMenu]="true"
[showAuxActionButton]="true"
[auxActionIcon]="'delete'"
[auxActionTooltip]="
'WORKSPACE.DASHBOARD.REMOVE_FROM_HISTORY' | translate
"
(clicked)="onChannelClick(item.channel)"
(contextMenuRequested)="
onChannelContextMenu(item.channel, $event)
"
(auxActionClicked)="onRemoveRecent(item.channel, $event)"
/>
}
@@ -46,3 +50,23 @@
</div>
}
</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>
<button mat-menu-item (click)="removeContextMenuChannel()">
<mat-icon>delete</mat-icon>
<span>{{ 'WORKSPACE.DASHBOARD.REMOVE_FROM_HISTORY' | translate }}</span>
</button>
</mat-menu>
@@ -15,6 +15,14 @@
overflow-x: hidden;
}
.channel-context-menu-anchor {
position: fixed;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
.empty-recent,
.empty-recent-hint {
padding: 16px 24px;
@@ -0,0 +1,130 @@
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 { RecentViewComponent, RecentViewItem } from './recent-view.component';
function createChannel(id: string, name: string, url: string): Channel {
return {
id,
name,
url,
group: { title: 'Live' },
tvg: {
id,
name,
url: '',
logo: '',
rec: '',
},
http: {
referrer: '',
'user-agent': '',
origin: '',
},
radio: 'false',
epgParams: '',
};
}
describe('RecentViewComponent', () => {
let fixture: ComponentFixture<RecentViewComponent>;
let component: RecentViewComponent;
let dialog: { open: jest.Mock };
const primaryChannel = createChannel(
'channel-1',
'Recent One',
'https://example.com/recent-one.m3u8'
);
const recentItems: RecentViewItem[] = [
{
channel: primaryChannel,
viewedAt: '2026-04-30T12:00:00.000Z',
},
];
beforeEach(async () => {
dialog = {
open: jest.fn(),
};
await TestBed.configureTestingModule({
imports: [
RecentViewComponent,
NoopAnimationsModule,
TranslateModule.forRoot(),
],
providers: [
{
provide: MatDialog,
useValue: dialog,
},
],
}).compileComponents();
fixture = TestBed.createComponent(RecentViewComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('recentItems', recentItems);
fixture.componentRef.setInput('searchTerm', '');
fixture.componentRef.setInput('channelEpgMap', new Map<string, null>());
fixture.componentRef.setInput(
'channelIconMap',
new Map<string, string>()
);
fixture.componentRef.setInput('progressTick', 0);
fixture.componentRef.setInput('shouldShowEpg', false);
fixture.detectChanges();
});
it('stores context-menu coordinates and opens channel details for the selected recent channel', async () => {
const openMenuSpy = jest
.spyOn(component.contextMenuTrigger(), 'openMenu')
.mockImplementation();
component.onChannelContextMenu(primaryChannel, {
clientX: 104,
clientY: 156,
} as MouseEvent);
await Promise.resolve();
expect(component.contextMenuChannel()).toBe(primaryChannel);
expect(component.contextMenuPosition()).toEqual({
x: '104px',
y: '156px',
});
expect(openMenuSpy).toHaveBeenCalled();
component.openChannelDetails();
expect(dialog.open).toHaveBeenCalledWith(
ChannelDetailsDialogComponent,
expect.objectContaining({
data: primaryChannel,
maxWidth: '720px',
width: 'calc(100vw - 32px)',
})
);
});
it('emits the selected channel URL when removing from the context menu', async () => {
const removed = jest.fn();
component.removeRecent.subscribe(removed);
jest.spyOn(
component.contextMenuTrigger(),
'openMenu'
).mockImplementation();
component.onChannelContextMenu(primaryChannel, {
clientX: 104,
clientY: 156,
} as MouseEvent);
await Promise.resolve();
component.removeContextMenuChannel();
expect(removed).toHaveBeenCalledWith(primaryChannel.url);
});
});
@@ -2,12 +2,19 @@ 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 { resolveChannelEpgLookupKey } from 'm3u-state';
import { Channel, EpgProgram } from 'shared-interfaces';
import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component';
import { resolveChannelLogo } from '../channel-logo-fallback.util';
import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component';
@@ -21,9 +28,19 @@ export interface RecentViewItem {
templateUrl: './recent-view.component.html',
styleUrls: ['./recent-view.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ChannelListItemComponent, TranslatePipe],
imports: [
ChannelListItemComponent,
MatIconModule,
MatMenuModule,
TranslatePipe,
],
})
export class RecentViewComponent {
private readonly dialog = inject(MatDialog);
readonly contextMenuTrigger =
viewChild.required<MatMenuTrigger>('contextMenuTrigger');
readonly recentItems = input.required<RecentViewItem[]>();
readonly searchTerm = input('');
readonly channelEpgMap = input.required<Map<string, EpgProgram | null>>();
@@ -35,6 +52,12 @@ export class RecentViewComponent {
readonly channelSelected = output<Channel>();
readonly removeRecent = output<string>();
readonly contextMenuChannel = signal<Channel | null>(null);
readonly contextMenuPosition = signal({
x: '0px',
y: '0px',
});
readonly filteredRecentItems = computed(() => {
const recentItems = this.recentItems();
const term = this.searchTerm().trim().toLowerCase();
@@ -98,4 +121,45 @@ export class RecentViewComponent {
event.stopPropagation();
this.removeRecent.emit(channel.url);
}
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)',
});
}
removeContextMenuChannel(): void {
const channel = this.contextMenuChannel();
if (!channel) {
return;
}
this.contextMenuTrigger().closeMenu();
this.removeRecent.emit(channel.url);
}
}