mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 11:06:16 -08:00
feat(m3u): add context menus to recent channels
Entire-Checkpoint: f957cd9849e0
This commit is contained in:
1 parent
008bafbaae
commit
6cbf2a1cfb
26 files changed
+827
-7
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+30
@@ -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>
|
||||
+8
@@ -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;
|
||||
|
||||
+138
-2
@@ -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: '',
|
||||
};
|
||||
}
|
||||
+70
-1
@@ -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)
|
||||
|
||||
+50
@@ -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 = [
|
||||
{
|
||||
|
||||
+1
@@ -20,6 +20,7 @@
|
||||
(channelSelected)="onChannelSelected($event)"
|
||||
(channelsReordered)="onReorder($event)"
|
||||
(favoriteToggled)="onFavoriteToggled($event)"
|
||||
(removeRequested)="onRemoveRequested($event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
+10
@@ -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,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';
|
||||
|
||||
+18
@@ -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>
|
||||
+8
@@ -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;
|
||||
|
||||
+105
@@ -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)',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
+56
-1
@@ -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;
|
||||
|
||||
+24
@@ -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>
|
||||
+8
@@ -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;
|
||||
|
||||
+130
@@ -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);
|
||||
});
|
||||
});
|
||||
+65
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user