diff --git a/README.md b/README.md
index 80efa7c39..db5f61c92 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/apps/electron-backend-e2e/src/recent.e2e.ts b/apps/electron-backend-e2e/src/recent.e2e.ts
index 054a92abb..c58ca5894 100644
--- a/apps/electron-backend-e2e/src/recent.e2e.ts
+++ b/apps/electron-backend-e2e/src/recent.e2e.ts
@@ -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,
diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md
index 6c66de2aa..92c36cc86 100644
--- a/docs/architecture/m3u-playlist-module.md
+++ b/docs/architecture/m3u-playlist-module.md
@@ -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
diff --git a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html
index 6ac9f94cf..d45da9181 100644
--- a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html
+++ b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html
@@ -42,8 +42,38 @@
[showFavoriteButton]="true"
[isFavorite]="isChannelFavorite(ch)"
[showProgramInfoButton]="true"
+ [showDetailsContextMenu]="hasChannelContextMenu(ch)"
(clicked)="onChannelClick(ch)"
(favoriteToggled)="onFavoriteToggled(ch)"
+ (contextMenuRequested)="onChannelContextMenu(ch, $event)"
/>
}
+
+
+
+
+ @if (contextMenuChannel()?.m3uChannel) {
+
+ }
+ @if (mode() === 'recent') {
+
+ }
+
diff --git a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.scss b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.scss
index 3bb74e868..e22cb4a93 100644
--- a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.scss
+++ b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.scss
@@ -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;
diff --git a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.spec.ts b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.spec.ts
index d8d937d82..dcc976169 100644
--- a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.spec.ts
+++ b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.spec.ts
@@ -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;
+ 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 {
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: '',
};
}
diff --git a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts
index 562f74739..30024df67 100644
--- a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts
+++ b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts
@@ -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('contextMenuTrigger');
+
readonly channels = input.required();
readonly mode = input('favorites');
readonly favoriteUids = input>(new Set());
@@ -56,6 +70,13 @@ export class GlobalFavoritesListComponent {
readonly channelSelected = output();
readonly channelsReordered = output();
readonly favoriteToggled = output();
+ readonly removeRequested = output();
+
+ readonly contextMenuChannel = signal(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)
diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts
index ec627409b..77c767dec 100644
--- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts
+++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts
@@ -43,6 +43,7 @@ class StubUnifiedLiveTabComponent {
readonly autoOpenItem = input(null);
readonly favoriteUids = input>(new Set());
readonly sortMode = input('custom');
+ readonly isSidebarCollapsed = input(false);
readonly removeItem = output();
readonly favoriteToggled = output();
@@ -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((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 = [
{
diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html
index e43486c2d..a3fd8b613 100644
--- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html
+++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html
@@ -20,6 +20,7 @@
(channelSelected)="onChannelSelected($event)"
(channelsReordered)="onReorder($event)"
(favoriteToggled)="onFavoriteToggled($event)"
+ (removeRequested)="onRemoveRequested($event)"
/>
diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts
index 0c66700e7..e760c52c8 100644
--- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts
+++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts
@@ -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) =>
diff --git a/libs/portal/shared/util/src/lib/collection/unified-collection-item.interface.ts b/libs/portal/shared/util/src/lib/collection/unified-collection-item.interface.ts
index 1909ad0af..5a0d5453f 100644
--- a/libs/portal/shared/util/src/lib/collection/unified-collection-item.interface.ts
+++ b/libs/portal/shared/util/src/lib/collection/unified-collection-item.interface.ts
@@ -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;
diff --git a/libs/portal/shared/util/src/lib/collection/unified-favorite-channel.interface.ts b/libs/portal/shared/util/src/lib/collection/unified-favorite-channel.interface.ts
index 3f9b888c3..9b5f85e23 100644
--- a/libs/portal/shared/util/src/lib/collection/unified-favorite-channel.interface.ts
+++ b/libs/portal/shared/util/src/lib/collection/unified-favorite-channel.interface.ts
@@ -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;
diff --git a/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.spec.ts b/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.spec.ts
index 018837d62..0f2ebbb1a 100644
--- a/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.spec.ts
+++ b/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.spec.ts
@@ -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 () => {
diff --git a/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.ts b/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.ts
index 911837c8c..c3a5224d2 100644
--- a/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.ts
+++ b/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.ts
@@ -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,
diff --git a/libs/portal/shared/util/src/lib/collection/unified-live-tab.component.spec.ts b/libs/portal/shared/util/src/lib/collection/unified-live-tab.component.spec.ts
index 78ac6b00d..a542c5852 100644
--- a/libs/portal/shared/util/src/lib/collection/unified-live-tab.component.spec.ts
+++ b/libs/portal/shared/util/src/lib/collection/unified-live-tab.component.spec.ts
@@ -74,6 +74,7 @@ class StubGlobalFavoritesListComponent {
readonly channelSelected = output();
readonly channelsReordered = output();
readonly favoriteToggled = output();
+ readonly removeRequested = output();
}
@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');
diff --git a/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.spec.ts b/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.spec.ts
index 8d7b2a686..6a9bd7841 100644
--- a/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.spec.ts
+++ b/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.spec.ts
@@ -155,6 +155,7 @@ describe('UnifiedRecentDataService', () => {
tvgId: 'recent-two',
logo: 'two.png',
radio: 'true',
+ m3uChannel: channels[1],
});
});
diff --git a/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.ts b/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.ts
index 8de09882b..eecb77c08 100644
--- a/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.ts
+++ b/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.ts
@@ -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 ||
diff --git a/libs/ui/components/src/index.ts b/libs/ui/components/src/index.ts
index 4aacc7da0..773f06bb8 100644
--- a/libs/ui/components/src/index.ts
+++ b/libs/ui/components/src/index.ts
@@ -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';
diff --git a/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.html b/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.html
index 91e1b9d73..5b99f2cd7 100644
--- a/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.html
+++ b/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.html
@@ -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 @@
}
+
+
+
+
+
+
diff --git a/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.scss b/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.scss
index 43d6e8639..c14b2602f 100644
--- a/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.scss
+++ b/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.scss
@@ -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;
diff --git a/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.spec.ts b/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.spec.ts
new file mode 100644
index 000000000..f22825ae7
--- /dev/null
+++ b/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.spec.ts
@@ -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;
+ 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());
+ fixture.componentRef.setInput(
+ 'channelIconMap',
+ new Map()
+ );
+ 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)',
+ })
+ );
+ });
+});
diff --git a/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.ts b/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.ts
index 72681ddbf..f99e1db6c 100644
--- a/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.ts
+++ b/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.ts
@@ -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('contextMenuTrigger');
+
/** Favorite channels */
readonly favorites = input.required();
readonly searchTerm = input('');
@@ -54,6 +72,12 @@ export class FavoritesViewComponent {
/** Emits when favorites order changes via drag-drop */
readonly favoritesReordered = output();
+ readonly contextMenuChannel = signal(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): void {
if (this.hasSearchTerm()) {
return;
diff --git a/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.html b/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.html
index 3e5b47145..8d4db33d9 100644
--- a/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.html
+++ b/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.html
@@ -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 @@
}
+
+
+
+
+
+
+
diff --git a/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.scss b/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.scss
index 56334bfcb..aefb020d3 100644
--- a/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.scss
+++ b/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.scss
@@ -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;
diff --git a/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.spec.ts b/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.spec.ts
new file mode 100644
index 000000000..1f5698038
--- /dev/null
+++ b/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.spec.ts
@@ -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;
+ 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());
+ fixture.componentRef.setInput(
+ 'channelIconMap',
+ new Map()
+ );
+ 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);
+ });
+});
diff --git a/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.ts b/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.ts
index 09182a12f..b24b1f4e1 100644
--- a/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.ts
+++ b/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.ts
@@ -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('contextMenuTrigger');
+
readonly recentItems = input.required();
readonly searchTerm = input('');
readonly channelEpgMap = input.required