From 6cbf2a1cfb32787049dcebe9349023c5c2c7400c Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 5 May 2026 00:27:15 +0200 Subject: [PATCH] feat(m3u): add context menus to recent channels Entire-Checkpoint: f957cd9849e0 --- README.md | 3 +- apps/electron-backend-e2e/src/recent.e2e.ts | 56 +++++++ docs/architecture/m3u-playlist-module.md | 10 +- .../global-favorites-list.component.html | 30 ++++ .../global-favorites-list.component.scss | 8 + .../global-favorites-list.component.spec.ts | 140 +++++++++++++++++- .../global-favorites-list.component.ts | 71 ++++++++- .../unified-collection-page.component.spec.ts | 50 +++++++ .../unified-live-tab.component.html | 1 + .../unified-live-tab.component.ts | 10 ++ .../unified-collection-item.interface.ts | 5 + .../unified-favorite-channel.interface.ts | 5 + .../unified-favorites-data.service.spec.ts | 2 + .../unified-favorites-data.service.ts | 1 + .../unified-live-tab.component.spec.ts | 23 +++ .../unified-recent-data.service.spec.ts | 1 + .../collection/unified-recent-data.service.ts | 1 + libs/ui/components/src/index.ts | 1 + .../favorites-view.component.html | 18 +++ .../favorites-view.component.scss | 8 + .../favorites-view.component.spec.ts | 105 +++++++++++++ .../favorites-view.component.ts | 57 ++++++- .../recent-view/recent-view.component.html | 24 +++ .../recent-view/recent-view.component.scss | 8 + .../recent-view/recent-view.component.spec.ts | 130 ++++++++++++++++ .../recent-view/recent-view.component.ts | 66 ++++++++- 26 files changed, 827 insertions(+), 7 deletions(-) create mode 100644 libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.spec.ts create mode 100644 libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.spec.ts 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>(); @@ -35,6 +52,12 @@ export class RecentViewComponent { readonly channelSelected = output(); readonly removeRecent = output(); + readonly contextMenuChannel = signal(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); + } }