diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player-movie-gate.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player-movie-gate.spec.ts
index 1ccf2ba79..00c1af864 100644
--- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player-movie-gate.spec.ts
+++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player-movie-gate.spec.ts
@@ -2,12 +2,11 @@ import { AsyncPipe } from '@angular/common';
import { NO_ERRORS_SCHEMA, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router } from '@angular/router';
-import { Overlay } from '@angular/cdk/overlay';
import { Store } from '@ngrx/store';
import { StorageMap } from '@ngx-pwa/local-storage';
-import { TranslatePipe } from '@ngx-translate/core';
+import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { MockPipe } from 'ng-mocks';
-import { BehaviorSubject, of } from 'rxjs';
+import { BehaviorSubject, EMPTY, of } from 'rxjs';
import {
selectActive,
selectActiveEpgProgram,
@@ -168,8 +167,13 @@ describe('VideoPlayerComponent — M3U movie recognition gate', () => {
},
{ provide: Store, useValue: storeMock },
{
- provide: Overlay,
- useValue: { position: jest.fn(), create: jest.fn() },
+ // The component provides `M3uEpgGuideSourceService`,
+ // which resolves its scope labels through this service.
+ provide: TranslateService,
+ useValue: {
+ instant: (key: string) => key,
+ onLangChange: EMPTY,
+ },
},
{ provide: DataService, useValue: { sendIpcEvent: jest.fn() } },
{
diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.fullscreen-panel.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.fullscreen-panel.spec.ts
index a3215ac21..e4b959c4f 100644
--- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.fullscreen-panel.spec.ts
+++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.fullscreen-panel.spec.ts
@@ -3,7 +3,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { StorageMap } from '@ngx-pwa/local-storage';
-import { BehaviorSubject, of } from 'rxjs';
+import { TranslateService } from '@ngx-translate/core';
+import { BehaviorSubject, EMPTY, of } from 'rxjs';
import {
ChannelActions,
selectActive,
@@ -29,7 +30,6 @@ import {
PlaylistMeta,
VideoPlayer,
} from '@iptvnator/shared/interfaces';
-import { Overlay } from '@angular/cdk/overlay';
import type { VideoPlayerComponent as VideoPlayerComponentInstance } from './video-player.component';
jest.unstable_mockModule('video.js', () => ({
@@ -159,8 +159,13 @@ describe('VideoPlayerComponent fullscreen channel panel + zapping', () => {
},
{ provide: Store, useValue: storeMock },
{
- provide: Overlay,
- useValue: { position: jest.fn(), create: jest.fn() },
+ // The component provides `M3uEpgGuideSourceService`,
+ // which resolves its scope labels through this service.
+ provide: TranslateService,
+ useValue: {
+ instant: (key: string) => key,
+ onLangChange: EMPTY,
+ },
},
{ provide: DataService, useValue: { sendIpcEvent: jest.fn() } },
{
diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html
index 679f54e71..0f9235e3a 100644
--- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html
+++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html
@@ -1,56 +1,62 @@
-
+@if (!guideOpen()) {
+
+}
-
- @if (isSidebarCollapsed()) {
+
+ @if (isSidebarCollapsed() && !guideOpen()) {
}
+ @if (guideOpen()) {
+
+ }
}
- @if (supportsEpg && activeChannel.radio !== 'true') {
+ @if (guideOpen() && !shouldShowInlinePlayer(activeChannel)) {
+
+ }
+
+ @if (guideOpen()) {
+
+ } @else if (supportsEpg && activeChannel.radio !== 'true') {
@let canCollapseEpgPanel =
shouldShowInlinePlayer(activeChannel);
}
diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.scss b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.scss
index 5e9c2fff5..ab9e3d469 100644
--- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.scss
+++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.scss
@@ -145,3 +145,55 @@
height: 50vh;
}
}
+
+// ─── Guide mode ─────────────────────────────────────────────────────────────
+// The player keeps its DOM position and is only re-flowed into a docked strip;
+// no remount, so playback (and native-view Embedded MPV bounds) survive.
+.content-container.is-guide {
+ .video-player {
+ flex: 0 0 auto;
+ height: 128px;
+ display: flex;
+ align-items: stretch;
+ gap: 18px;
+ padding: 14px 18px;
+ box-sizing: border-box;
+ background: var(--app-widget-bg);
+ border-bottom: 1px solid var(--app-separator);
+
+ > app-web-player-view {
+ flex: 0 0 auto;
+ height: 100%;
+ aspect-ratio: 16 / 9;
+ border-radius: 9px;
+ overflow: hidden;
+ background: #000;
+ }
+
+ > app-epg-guide-now-playing {
+ flex: 1;
+ min-width: 0;
+ }
+ }
+
+ .guide-dock--external {
+ height: 48px;
+ padding: 6px 18px;
+ }
+
+ .epg-guide-host {
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ }
+
+ &.is-guide-collapsed .video-player {
+ height: 48px;
+ padding: 6px 18px;
+
+ > app-web-player-view {
+ display: none;
+ }
+ }
+}
diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts
index e9e3f7398..f3293c7ac 100644
--- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts
+++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts
@@ -1,30 +1,14 @@
import { AsyncPipe } from '@angular/common';
-import {
- Component,
- Directive,
- NO_ERRORS_SCHEMA,
- input,
- output,
- signal,
-} from '@angular/core';
+import { NO_ERRORS_SCHEMA, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { StorageMap } from '@ngx-pwa/local-storage';
-import { TranslatePipe } from '@ngx-translate/core';
+import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { MockPipe } from 'ng-mocks';
-import { BehaviorSubject, of } from 'rxjs';
-import {
- ChannelActions,
- EpgActions,
- selectActive,
- selectActiveEpgProgram,
- selectActivePlaybackUrl,
- selectChannels,
- selectChannelsLoading,
- selectCurrentEpgProgram,
-} from '@iptvnator/m3u-state';
+import { EMPTY, of } from 'rxjs';
+import { ChannelActions, EpgActions } from '@iptvnator/m3u-state';
import { EpgService } from '@iptvnator/epg/data-access';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import {
@@ -42,17 +26,50 @@ import {
Channel,
EpgProgram,
ExternalPlayerSession,
- RecordingStartMetadata,
- RecordingStoppedEvent,
ResolvedPortalPlayback,
Settings,
VideoPlayer,
} from '@iptvnator/shared/interfaces';
-import { Overlay } from '@angular/cdk/overlay';
import type { PlaybackFallbackRequest } from '@iptvnator/ui/playback';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
import type { VideoPlayerComponent as VideoPlayerComponentInstance } from './video-player.component';
-import { StubEpgTimelineComponent } from './video-player.spec-stubs';
+import {
+ activeChannel,
+ activeEpgProgram,
+ activePlaybackUrl,
+ channels,
+ channels$,
+ activeChannel$,
+ channelsLoading,
+ currentEpgProgram,
+ currentEpgProgram$,
+ dataServiceMock,
+ epgPrograms$,
+ epgServiceMock,
+ epgUrlSetting,
+ epgViewMode,
+ externalSession,
+ player,
+ playlistId,
+ playlistsServiceMock,
+ routerMock,
+ sampleChannel,
+ showCaptions,
+ storeMock,
+ stripCountryPrefix,
+ syncStoreState,
+} from './video-player.spec-harness';
+import {
+ StubAudioPlayerComponent,
+ StubChannelListLoadingStateComponent,
+ StubEpgGuideComponent,
+ StubEpgGuideNowPlayingComponent,
+ StubEpgTimelineComponent,
+ StubPortalEmptyStateComponent,
+ StubResizableDirective,
+ StubSidebarComponent,
+ StubWebPlayerViewComponent,
+} from './video-player.spec-stubs';
jest.unstable_mockModule('video.js', () => ({
default: jest.fn(),
@@ -62,231 +79,27 @@ jest.unstable_mockModule('@yangkghjh/videojs-aspect-ratio-panel', () => ({}));
jest.unstable_mockModule('videojs-contrib-quality-levels', () => ({}));
jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
-@Component({
- selector: 'app-channel-list-loading-state',
- standalone: true,
- template: '',
-})
-class StubChannelListLoadingStateComponent {
- readonly view = input(null);
- readonly showEpg = input(true);
-}
-
-@Component({
- selector: 'app-sidebar',
- standalone: true,
- template: '',
-})
-class StubSidebarComponent {
- readonly channels = input([]);
- readonly channelsLoading = input(false);
- readonly showPlaylistHeader = input(false);
- readonly activeView = input('');
- readonly sidebarWidth = input(0);
- readonly sidebarWidthRequested = output();
- readonly sidebarWidthRequestEnded = output();
- readonly sidebarToggleRequested = output();
-}
-
-@Component({
- selector: 'app-portal-empty-state',
- standalone: true,
- template: '{{ message() }}
',
-})
-class StubPortalEmptyStateComponent {
- readonly icon = input('');
- readonly message = input('');
-}
-
-@Component({
- selector: 'app-audio-player',
- standalone: true,
- template: '',
-})
-class StubAudioPlayerComponent {
- readonly url = input('');
- readonly icon = input('');
- readonly channelName = input('');
- readonly channelLogo = input('');
- readonly volume = input(null);
- readonly volumeChange = output();
-}
-
-@Component({
- selector: 'app-web-player-view',
- standalone: true,
- template: '',
-})
-class StubWebPlayerViewComponent {
- readonly playbackSessionKey = input.required();
- readonly streamUrl = input('');
- readonly title = input('');
- readonly playback = input(null);
- readonly playerOverride = input(null);
- readonly volume = input(1);
- readonly recordingMetadata = input(null);
- readonly externalFallbackRequested = output();
- readonly recordingStopped = output();
-}
-
-@Directive({
- selector: '[appResizable]',
- standalone: true,
-})
-class StubResizableDirective {
- readonly minWidth = input(0);
- readonly maxWidth = input(0);
- readonly defaultWidth = input(0);
- readonly storageKey = input('');
- readonly widthChange = output();
- readonly resizeEnd = output();
-}
-
describe('VideoPlayerComponent', () => {
let VideoPlayerComponent: typeof import('./video-player.component').VideoPlayerComponent;
let fixture: ComponentFixture;
let component: VideoPlayerComponentInstance;
let headerContext: WorkspaceHeaderContextService;
- const playlistId = signal('playlist-1');
- const activeChannel = signal(null);
- const activePlaybackUrl = signal(null);
- const channels = signal([]);
- const channelsLoading = signal(false);
- const currentEpgProgram = signal(null);
- const activeEpgProgram = signal(null);
-
- const channels$ = new BehaviorSubject([]);
- const activeChannel$ = new BehaviorSubject(null);
- const currentEpgProgram$ = new BehaviorSubject(null);
- const epgPrograms$ = new BehaviorSubject([]);
- const epgServiceMock = {
- currentEpgPrograms$: epgPrograms$.asObservable(),
- getChannelMetadataForChannels: () => of(new Map()),
- };
-
- const player = signal(VideoPlayer.VideoJs);
- const showCaptions = signal(false);
- const stripCountryPrefix = signal(false);
- const epgViewMode = signal<'timeline' | 'list'>('timeline');
- const epgUrlSetting = signal([]);
- const externalSession = signal(null);
const originalElectron = window.electron;
- const overlayRef = {
- attach: jest.fn().mockReturnValue({ instance: {} }),
- backdropClick: jest.fn().mockReturnValue(of(undefined)),
- dispose: jest.fn(),
- };
- const positionStrategy = {
- centerHorizontally: jest.fn().mockReturnThis(),
- centerVertically: jest.fn().mockReturnThis(),
- };
- const overlayMock = {
- position: jest.fn().mockReturnValue({
- global: jest.fn().mockReturnValue(positionStrategy),
- }),
- create: jest.fn().mockReturnValue(overlayRef),
- };
-
- const storeMock = {
- dispatch: jest.fn(),
- selectSignal: jest.fn((selector: unknown) => {
- switch (selector) {
- case selectActive:
- return activeChannel;
- case selectActivePlaybackUrl:
- return activePlaybackUrl;
- case selectChannels:
- return channels;
- case selectChannelsLoading:
- return channelsLoading;
- case selectCurrentEpgProgram:
- return currentEpgProgram;
- case selectActiveEpgProgram:
- return activeEpgProgram;
- default:
- return signal(null);
- }
- }),
- select: jest.fn((selector: unknown) => {
- switch (selector) {
- case selectChannels:
- return channels$.asObservable();
- case selectActive:
- return activeChannel$.asObservable();
- case selectCurrentEpgProgram:
- return currentEpgProgram$.asObservable();
- default:
- return of(null);
- }
- }),
- };
-
- const routerMock = {
- url: '/workspace/playlists/playlist-1/all',
- navigate: jest.fn(),
- currentNavigation: jest.fn().mockReturnValue(null),
- };
-
- const playlistsServiceMock = {
- getPlaylist: jest.fn(() =>
- of({
- playlist: {
- items: channels(),
- },
- favorites: [],
- })
- ),
- getPlaylistWithGlobalFavorites: jest.fn(() =>
- of({
- playlist: {
- items: [],
- },
- favorites: [],
- })
- ),
- addM3uRecentlyViewed: jest.fn(() =>
- of({
- recentlyViewed: [],
- })
- ),
- };
- const dataServiceMock = {
- sendIpcEvent: jest.fn(),
- };
-
- const sampleChannel: Channel = {
- id: 'channel-1',
- url: 'http://localhost/live.m3u8',
- name: 'Sample TV',
- epgParams: '',
- radio: 'false',
- tvg: {
- id: 'sample-tvg-id',
- logo: 'http://localhost/logo.png',
- name: 'Sample TV',
- },
- } as Channel;
-
beforeAll(async () => {
({ VideoPlayerComponent } = await import('./video-player.component'));
});
- function syncStoreState(channel: Channel | null): void {
- activeChannel.set(channel);
- activeChannel$.next(channel);
- channels.set(channel ? [channel] : []);
- channels$.next(channel ? [channel] : []);
- }
-
beforeEach(async () => {
window.electron = {
platform: 'darwin',
updateRemoteControlStatus: jest.fn(),
onChannelChange: jest.fn(() => jest.fn()),
onRemoteControlCommand: jest.fn(() => jest.fn()),
- } as typeof window.electron;
+ getEpgProgramsForChannels: jest.fn().mockResolvedValue({}),
+ getEpgProgramCoverage: jest.fn().mockResolvedValue([]),
+ } as unknown as typeof window.electron;
syncStoreState(null);
playlistId.set('playlist-1');
@@ -302,9 +115,6 @@ describe('VideoPlayerComponent', () => {
externalSession.set(null);
currentEpgProgram$.next(null);
epgPrograms$.next([]);
- overlayMock.create.mockClear();
- overlayRef.attach.mockClear();
- overlayRef.dispose.mockClear();
routerMock.currentNavigation.mockReturnValue(null);
routerMock.navigate.mockClear();
storeMock.dispatch.mockClear();
@@ -334,8 +144,13 @@ describe('VideoPlayerComponent', () => {
useValue: storeMock,
},
{
- provide: Overlay,
- useValue: overlayMock,
+ // The guide source service reads labels through
+ // `instant` and re-computes them on `onLangChange`.
+ provide: TranslateService,
+ useValue: {
+ instant: (key: string) => key,
+ onLangChange: EMPTY,
+ },
},
{
provide: DataService,
@@ -354,8 +169,7 @@ describe('VideoPlayerComponent', () => {
// remote-control bridge method must be present.
get supportsRemoteControl() {
const bridge = window.electron as
- | Record
- | undefined;
+ Record | undefined;
return [
'updateRemoteControlStatus',
'onChannelChange',
@@ -417,6 +231,8 @@ describe('VideoPlayerComponent', () => {
AsyncPipe,
StubAudioPlayerComponent,
StubChannelListLoadingStateComponent,
+ StubEpgGuideComponent,
+ StubEpgGuideNowPlayingComponent,
StubEpgTimelineComponent,
StubPortalEmptyStateComponent,
StubResizableDirective,
@@ -442,13 +258,13 @@ describe('VideoPlayerComponent', () => {
window.electron = originalElectron;
});
- it('registers and clears the workspace multi-EPG header shortcut', () => {
+ it('registers and clears the workspace guide header shortcut', () => {
fixture.detectChanges();
expect(headerContext.action()).toEqual(
expect.objectContaining({
- id: 'm3u-multi-epg',
- icon: 'view_list',
+ id: 'm3u-epg-guide',
+ icon: 'grid_view',
})
);
@@ -456,6 +272,134 @@ describe('VideoPlayerComponent', () => {
expect(headerContext.action()).toBeNull();
});
+ it('opens the guide in place of sidebar and timeline without remounting the player', () => {
+ syncStoreState(sampleChannel);
+ player.set(VideoPlayer.VideoJs);
+ fixture.detectChanges();
+ const playerBefore = fixture.nativeElement.querySelector(
+ 'app-web-player-view'
+ );
+ expect(playerBefore).not.toBeNull();
+ expect(
+ fixture.nativeElement.querySelector('app-epg-timeline')
+ ).not.toBeNull();
+ expect(fixture.nativeElement.querySelector('.sidebar')).not.toBeNull();
+
+ component.openGuide();
+ fixture.detectChanges();
+
+ expect(
+ fixture.nativeElement.querySelector('app-epg-guide')
+ ).not.toBeNull();
+ expect(
+ fixture.nativeElement.querySelector('app-epg-guide-now-playing')
+ ).not.toBeNull();
+ expect(fixture.nativeElement.querySelector('.sidebar')).toBeNull();
+ expect(
+ fixture.nativeElement.querySelector('app-epg-timeline')
+ ).toBeNull();
+ expect(
+ fixture.nativeElement.querySelector('.content-container').classList
+ ).toContain('is-guide');
+ expect(fixture.nativeElement.querySelector('app-web-player-view')).toBe(
+ playerBefore
+ );
+ expect(headerContext.action()?.active?.()).toBe(true);
+
+ component.closeGuide();
+ fixture.detectChanges();
+ expect(fixture.nativeElement.querySelector('app-epg-guide')).toBeNull();
+ // The sidebar list itself sits behind an `@defer`, so the assertion
+ // targets the container the guide mode actually removes.
+ expect(fixture.nativeElement.querySelector('.sidebar')).not.toBeNull();
+ expect(fixture.nativeElement.querySelector('app-web-player-view')).toBe(
+ playerBefore
+ );
+ });
+
+ it('toggles the guide with G, ignores typing, and lets the guide own other keys', () => {
+ syncStoreState(sampleChannel);
+ player.set(VideoPlayer.VideoJs);
+ fixture.detectChanges();
+
+ document.dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'g', bubbles: true })
+ );
+ fixture.detectChanges();
+ expect(component.guideOpen()).toBe(true);
+
+ storeMock.dispatch.mockClear();
+ const pageDown = new KeyboardEvent('keydown', {
+ key: 'PageDown',
+ bubbles: true,
+ cancelable: true,
+ });
+ document.dispatchEvent(pageDown);
+ expect(pageDown.defaultPrevented).toBe(false);
+ expect(storeMock.dispatch).not.toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: expect.stringContaining('setActiveChannel'),
+ })
+ );
+
+ document.dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'G', bubbles: true })
+ );
+ expect(component.guideOpen()).toBe(false);
+
+ const input = document.createElement('input');
+ document.body.appendChild(input);
+ input.focus();
+ input.dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'g', bubbles: true })
+ );
+ expect(component.guideOpen()).toBe(false);
+ input.remove();
+ });
+
+ it('closes the guide when the live player enters fullscreen or the channel turns to radio', () => {
+ syncStoreState(sampleChannel);
+ player.set(VideoPlayer.VideoJs);
+ fixture.detectChanges();
+ component.openGuide();
+ fixture.detectChanges();
+
+ const playerView = fixture.nativeElement.querySelector(
+ 'app-web-player-view'
+ ) as HTMLElement;
+ Object.defineProperty(document, 'fullscreenElement', {
+ configurable: true,
+ get: () => playerView,
+ });
+ document.dispatchEvent(new Event('fullscreenchange'));
+ expect(component.guideOpen()).toBe(false);
+ Object.defineProperty(document, 'fullscreenElement', {
+ configurable: true,
+ get: () => null,
+ });
+
+ component.openGuide();
+ expect(component.guideOpen()).toBe(true);
+ syncStoreState({ ...sampleChannel, radio: 'true' } as Channel);
+ fixture.detectChanges();
+ expect(component.guideOpen()).toBe(false);
+ });
+
+ it('remembers the collapsed dock strip', () => {
+ syncStoreState(sampleChannel);
+ player.set(VideoPlayer.VideoJs);
+ fixture.detectChanges();
+ component.openGuide();
+ component.setGuideDockCollapsed(true);
+ fixture.detectChanges();
+
+ expect(
+ fixture.nativeElement.querySelector('.content-container').classList
+ ).toContain('is-guide-collapsed');
+ expect(localStorage.getItem('epg-guide:dock-collapsed')).toBe('1');
+ localStorage.removeItem('epg-guide:dock-collapsed');
+ });
+
it('strips country prefixes from the timeline and player titles when enabled', () => {
stripCountryPrefix.set(true);
syncStoreState({ ...sampleChannel, name: 'US | CNN' } as Channel);
@@ -516,7 +460,7 @@ describe('VideoPlayerComponent', () => {
epgViewMode.set('timeline'); // restore for sibling tests
});
- it('hides EPG controls and the multi-EPG header action in browser/PWA playback', () => {
+ it('hides EPG controls and the guide header action in browser/PWA playback', () => {
fixture.destroy();
window.electron = undefined as unknown as typeof window.electron;
@@ -1220,13 +1164,15 @@ describe('VideoPlayerComponent', () => {
);
});
- it('reuses the registered header shortcut callback to open multi EPG', () => {
+ it('reuses the registered header shortcut callback to toggle the guide', () => {
+ syncStoreState(sampleChannel);
fixture.detectChanges();
headerContext.action()?.run();
+ expect(component.guideOpen()).toBe(true);
- expect(overlayMock.create).toHaveBeenCalledTimes(1);
- expect(overlayRef.attach).toHaveBeenCalledTimes(1);
+ headerContext.action()?.run();
+ expect(component.guideOpen()).toBe(false);
});
it('switches channels by number through a playback request', () => {
diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts
index 9ce078e24..330a0a2b0 100644
--- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts
+++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts
@@ -1,11 +1,8 @@
-import { Overlay, OverlayRef } from '@angular/cdk/overlay';
-import { ComponentPortal } from '@angular/cdk/portal';
import { AsyncPipe, CommonModule } from '@angular/common';
import {
Component,
ElementRef,
HostListener,
- Injector,
OnDestroy,
OnInit,
TemplateRef,
@@ -38,14 +35,17 @@ import {
} from '@iptvnator/shared/m3u-utils';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import {
- COMPONENT_OVERLAY_REF,
+ EPG_GUIDE_SOURCE,
EpgDateNavigationDirection,
+ EpgGuideComponent,
+ EpgGuideNowPlayingComponent,
EpgListViewComponent,
EpgProgramActivationEvent,
EpgTimelineComponent,
EpgTimelineEmptyReason,
getTodayEpgDateKey,
- MultiEpgContainerComponent,
+ persistEpgGuideDockCollapsed,
+ restoreEpgGuideDockCollapsed,
shiftEpgDateKey,
} from '@iptvnator/ui/epg';
import { EpgService } from '@iptvnator/epg/data-access';
@@ -63,6 +63,7 @@ import {
selectChannels,
selectChannelsLoading,
selectCurrentEpgProgram,
+ selectFavorites,
} from '@iptvnator/m3u-state';
import {
firstValueFrom,
@@ -135,11 +136,12 @@ import {
Settings,
VideoPlayer,
} from '@iptvnator/shared/interfaces';
+import { M3uEpgGuideSourceService } from '../epg-guide/m3u-epg-guide-source.service';
import { M3uVodDetailComponent } from '../m3u-vod-detail/m3u-vod-detail.component';
import { M3uFullscreenChannelListComponent } from './fullscreen-channel-list/m3u-fullscreen-channel-list.component';
import { createM3uChannelPlaybackRequest } from './m3u-channel-playback-actions';
-const M3U_MULTI_EPG_HEADER_ACTION_ID = 'm3u-multi-epg';
+const M3U_EPG_GUIDE_HEADER_ACTION_ID = 'm3u-epg-guide';
const M3U_SIDEBAR_STORAGE_KEY = 'm3u-sidebar-width';
const M3U_GROUPS_SIDEBAR_STORAGE_KEY = 'm3u-groups-sidebar-width';
const M3U_SIDEBAR_MIN_WIDTH = 200;
@@ -193,6 +195,8 @@ function isInsideScrollableRegion(
AudioPlayerComponent,
ChannelListLoadingStateComponent,
CommonModule,
+ EpgGuideComponent,
+ EpgGuideNowPlayingComponent,
EpgListViewComponent,
EpgTimelineComponent,
M3uFullscreenChannelListComponent,
@@ -213,6 +217,8 @@ function isInsideScrollableRegion(
provide: FULLSCREEN_CHANNEL_PANEL,
useExisting: forwardRef(() => VideoPlayerComponent),
},
+ M3uEpgGuideSourceService,
+ { provide: EPG_GUIDE_SOURCE, useExisting: M3uEpgGuideSourceService },
],
templateUrl: './video-player.component.html',
styleUrl: './video-player.component.scss',
@@ -223,7 +229,6 @@ export class VideoPlayerComponent
private readonly activatedRoute = inject(ActivatedRoute);
private readonly hostElement = inject(ElementRef);
private readonly dataService = inject(DataService);
- private readonly overlay = inject(Overlay);
private readonly playlistsService = inject(PlaylistsService);
private readonly playlistContext = inject(PlaylistContextFacade);
private readonly router = inject(Router);
@@ -297,6 +302,30 @@ export class VideoPlayerComponent
readonly fullscreenPanelChannels = computed(() =>
this.channels().filter((channel) => this.keepsInlinePlayer(channel))
);
+ private readonly guideSource = inject(M3uEpgGuideSourceService);
+ /** Guide mode: sidebar and timeline give way to the multi-channel grid. */
+ readonly guideOpen = signal(false);
+ readonly guideDockCollapsed = signal(restoreEpgGuideDockCollapsed());
+ /** Rows the guide may show: everything that keeps the live host mounted. */
+ readonly guideChannels = computed(() =>
+ this.channels().filter(
+ (channel) =>
+ channel.radio !== 'true' && !this.opensMovieDetail(channel)
+ )
+ );
+ readonly canOpenGuide = computed(() => {
+ const channel = this.activeChannel();
+ return (
+ this.supportsEpg &&
+ !!channel &&
+ channel.radio !== 'true' &&
+ !this.showMovieDetail()
+ );
+ });
+ /** Programme shown in the docked strip (catch-up selection wins). */
+ readonly guideNowPlayingProgram = computed(
+ () => this.activeEpgProgramOrNull() ?? this.epgProgram() ?? null
+ );
readonly archivePlaybackAvailable = computed(() =>
isM3uCatchupPlaybackSupported(this.activeChannel())
);
@@ -631,8 +660,6 @@ export class VideoPlayerComponent
readonly supportsEpg = this.runtime.supportsEpg;
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.activatedRoute);
- /** EPG overlay reference */
- private overlayRef!: OverlayRef;
private unsubscribeRemoteChannelChange?: () => void;
private unsubscribeRemoteCommand?: () => void;
private statusSubscription?: Subscription;
@@ -675,6 +702,21 @@ export class VideoPlayerComponent
});
constructor() {
+ this.guideSource.bind({
+ channels: this.guideChannels,
+ favoriteIds: this.store.selectSignal(selectFavorites),
+ // `selectActive` is `Channel | undefined`; the contract is
+ // nullable, so normalize rather than widen the shared type.
+ activeChannel: computed(() => this.activeChannel() ?? null),
+ });
+ // Radio, a recognised movie, or a lost channel takes the guide's host
+ // (and its player) away — close instead of leaving it stranded.
+ effect(() => {
+ if (!this.canOpenGuide()) {
+ untracked(() => this.closeGuide());
+ }
+ });
+
// React to settings changes
effect(() => {
this.playerSettings = {
@@ -973,7 +1015,7 @@ export class VideoPlayerComponent
}
ngOnDestroy(): void {
- this.workspaceHeaderContext.clearAction(M3U_MULTI_EPG_HEADER_ACTION_ID);
+ this.workspaceHeaderContext.clearAction(M3U_EPG_GUIDE_HEADER_ACTION_ID);
this.unsubscribeRemoteChannelChange?.();
this.unsubscribeRemoteCommand?.();
this.statusSubscription?.unsubscribe();
@@ -1161,57 +1203,41 @@ export class VideoPlayerComponent
}
}
- /**
- * Opens the overlay with multi EPG view
- */
- openMultiEpgView(): void {
- if (!this.supportsEpg) {
+ openGuide(): void {
+ if (!this.canOpenGuide() || this.guideOpen()) {
return;
}
+ this.guideSource.applyInitialScope(this.activeView());
+ this.guideOpen.set(true);
+ }
- const positionStrategy = this.overlay
- .position()
- .global()
- .centerHorizontally()
- .centerVertically();
+ closeGuide(): void {
+ this.guideOpen.set(false);
+ }
- this.overlayRef = this.overlay.create({
- hasBackdrop: true,
- positionStrategy,
- width: '100%',
- height: '100%',
- });
-
- const injector = Injector.create({
- providers: [
- {
- provide: COMPONENT_OVERLAY_REF,
- useValue: this.overlayRef,
- },
- ],
- });
-
- const portal = new ComponentPortal(
- MultiEpgContainerComponent,
- null,
- injector
- );
-
- const componentRef = this.overlayRef.attach(portal);
- componentRef.instance.playlistChannels = this.store.select(
- selectChannels
- ) as Observable;
-
- // Pass the active channel's tvg.id for highlighting
- const currentChannel = this.activeChannel();
- if (currentChannel) {
- componentRef.instance.activeChannelId =
- currentChannel.tvg?.id || null;
+ toggleGuide(): void {
+ if (this.guideOpen()) {
+ this.closeGuide();
+ } else {
+ this.openGuide();
}
+ }
- this.overlayRef.backdropClick().subscribe(() => {
- this.overlayRef.dispose();
- });
+ setGuideDockCollapsed(collapsed: boolean): void {
+ this.guideDockCollapsed.set(collapsed);
+ persistEpgGuideDockCollapsed(collapsed);
+ }
+
+ /**
+ * The guide and the fullscreen channel panel are mutually exclusive: the
+ * fullscreen surface paints over the whole page, so a guide left open
+ * behind it would be invisible and still swallow keys on exit.
+ */
+ @HostListener('document:fullscreenchange')
+ onFullscreenChange(): void {
+ if (this.guideOpen() && this.isLivePlayerFullscreen()) {
+ this.closeGuide();
+ }
}
@HostListener('document:keydown', ['$event'])
@@ -1226,6 +1252,24 @@ export class VideoPlayerComponent
if (this.hostElement.nativeElement.closest('[inert]')) {
return;
}
+ const isGuideKey =
+ event.key.toLowerCase() === 'g' &&
+ !event.metaKey &&
+ !event.ctrlKey &&
+ !event.altKey;
+ if (this.guideOpen()) {
+ // The guide owns the keyboard while open; only G toggles it off.
+ if (isGuideKey) {
+ event.preventDefault();
+ this.closeGuide();
+ }
+ return;
+ }
+ if (isGuideKey && this.canOpenGuide()) {
+ event.preventDefault();
+ this.openGuide();
+ return;
+ }
if (
(event.metaKey || event.ctrlKey) &&
event.key.toLowerCase() === 'b'
@@ -1558,18 +1602,19 @@ export class VideoPlayerComponent
}
this.workspaceHeaderContext.setAction({
- id: M3U_MULTI_EPG_HEADER_ACTION_ID,
- icon: 'view_list',
- tooltipKey: 'TOP_MENU.OPEN_MULTI_EPG',
- ariaLabelKey: 'TOP_MENU.OPEN_MULTI_EPG',
+ id: M3U_EPG_GUIDE_HEADER_ACTION_ID,
+ icon: 'grid_view',
+ tooltipKey: 'TOP_MENU.OPEN_EPG_GUIDE',
+ ariaLabelKey: 'TOP_MENU.OPEN_EPG_GUIDE',
+ active: () => this.guideOpen(),
palette: {
- labelKey: 'TOP_MENU.OPEN_MULTI_EPG',
+ labelKey: 'TOP_MENU.OPEN_EPG_GUIDE',
descriptionKey:
- 'WORKSPACE.SHELL.COMMANDS.OPEN_MULTI_EPG_DESCRIPTION',
+ 'WORKSPACE.SHELL.COMMANDS.OPEN_EPG_GUIDE_DESCRIPTION',
keywords: ['epg', 'guide', 'schedule'],
priority: 10,
},
- run: () => this.openMultiEpgView(),
+ run: () => this.toggleGuide(),
});
}
}
diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-harness.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-harness.ts
new file mode 100644
index 000000000..f3159c8e3
--- /dev/null
+++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-harness.ts
@@ -0,0 +1,136 @@
+import { signal } from '@angular/core';
+import {
+ selectActive,
+ selectActiveEpgProgram,
+ selectActivePlaybackUrl,
+ selectChannels,
+ selectChannelsLoading,
+ selectCurrentEpgProgram,
+ selectFavorites,
+} from '@iptvnator/m3u-state';
+import {
+ Channel,
+ EpgProgram,
+ ExternalPlayerSession,
+ VideoPlayer,
+} from '@iptvnator/shared/interfaces';
+import { BehaviorSubject, of } from 'rxjs';
+
+/**
+ * Shared state and collaborator mocks for the `VideoPlayerComponent` spec.
+ * They live beside the spec rather than inside it because the suite is at the
+ * 1200-line test cap; the signals are module singletons, which is safe because
+ * Jest gives every spec file its own module registry.
+ */
+export const playlistId = signal('playlist-1');
+export const activeChannel = signal(null);
+export const activePlaybackUrl = signal(null);
+export const channels = signal([]);
+export const channelsLoading = signal(false);
+export const currentEpgProgram = signal(null);
+export const activeEpgProgram = signal(null);
+export const favoriteIds = signal([]);
+
+export const channels$ = new BehaviorSubject([]);
+export const activeChannel$ = new BehaviorSubject(null);
+export const currentEpgProgram$ = new BehaviorSubject(null);
+export const epgPrograms$ = new BehaviorSubject([]);
+export const epgServiceMock = {
+ currentEpgPrograms$: epgPrograms$.asObservable(),
+ getChannelMetadataForChannels: () => of(new Map()),
+};
+
+export const player = signal(VideoPlayer.VideoJs);
+export const showCaptions = signal(false);
+export const stripCountryPrefix = signal(false);
+export const epgViewMode = signal<'timeline' | 'list'>('timeline');
+export const epgUrlSetting = signal([]);
+export const externalSession = signal(null);
+export const storeMock = {
+ dispatch: jest.fn(),
+ selectSignal: jest.fn((selector: unknown) => {
+ switch (selector) {
+ case selectActive:
+ return activeChannel;
+ case selectActivePlaybackUrl:
+ return activePlaybackUrl;
+ case selectChannels:
+ return channels;
+ case selectChannelsLoading:
+ return channelsLoading;
+ case selectCurrentEpgProgram:
+ return currentEpgProgram;
+ case selectActiveEpgProgram:
+ return activeEpgProgram;
+ case selectFavorites:
+ return favoriteIds;
+ default:
+ return signal(null);
+ }
+ }),
+ select: jest.fn((selector: unknown) => {
+ switch (selector) {
+ case selectChannels:
+ return channels$.asObservable();
+ case selectActive:
+ return activeChannel$.asObservable();
+ case selectCurrentEpgProgram:
+ return currentEpgProgram$.asObservable();
+ default:
+ return of(null);
+ }
+ }),
+};
+
+export const routerMock = {
+ url: '/workspace/playlists/playlist-1/all',
+ navigate: jest.fn(),
+ currentNavigation: jest.fn().mockReturnValue(null),
+};
+
+export const playlistsServiceMock = {
+ getPlaylist: jest.fn(() =>
+ of({
+ playlist: {
+ items: channels(),
+ },
+ favorites: [],
+ })
+ ),
+ getPlaylistWithGlobalFavorites: jest.fn(() =>
+ of({
+ playlist: {
+ items: [],
+ },
+ favorites: [],
+ })
+ ),
+ addM3uRecentlyViewed: jest.fn(() =>
+ of({
+ recentlyViewed: [],
+ })
+ ),
+};
+export const dataServiceMock = {
+ sendIpcEvent: jest.fn(),
+};
+
+export const sampleChannel: Channel = {
+ id: 'channel-1',
+ url: 'http://localhost/live.m3u8',
+ name: 'Sample TV',
+ epgParams: '',
+ radio: 'false',
+ tvg: {
+ id: 'sample-tvg-id',
+ logo: 'http://localhost/logo.png',
+ name: 'Sample TV',
+ },
+} as Channel;
+
+export function syncStoreState(channel: Channel | null): void {
+ activeChannel.set(channel);
+ activeChannel$.next(channel);
+ channels.set(channel ? [channel] : []);
+ channels$.next(channel ? [channel] : []);
+}
diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-stubs.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-stubs.ts
index b796a0f38..16511a5c2 100644
--- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-stubs.ts
+++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-stubs.ts
@@ -1,5 +1,12 @@
-import { Component, input, output } from '@angular/core';
-import { EpgProgram } from '@iptvnator/shared/interfaces';
+import { Component, Directive, input, output } from '@angular/core';
+import {
+ Channel,
+ EpgProgram,
+ RecordingStartMetadata,
+ RecordingStoppedEvent,
+ VideoPlayer,
+} from '@iptvnator/shared/interfaces';
+import type { PlaybackFallbackRequest } from '@iptvnator/ui/playback';
import { LiveEpgPanelSummary } from '@iptvnator/ui/shared-portals';
/**
@@ -40,3 +47,107 @@ export class StubEpgTimelineComponent {
readonly collapsedChange = output();
readonly openGuide = output();
}
+
+@Component({
+ selector: 'app-channel-list-loading-state',
+ standalone: true,
+ template: '',
+})
+export class StubChannelListLoadingStateComponent {
+ readonly view = input(null);
+ readonly showEpg = input(true);
+}
+
+@Component({
+ selector: 'app-sidebar',
+ standalone: true,
+ template: '',
+})
+export class StubSidebarComponent {
+ readonly channels = input([]);
+ readonly channelsLoading = input(false);
+ readonly showPlaylistHeader = input(false);
+ readonly activeView = input('');
+ readonly sidebarWidth = input(0);
+ readonly sidebarWidthRequested = output();
+ readonly sidebarWidthRequestEnded = output();
+ readonly sidebarToggleRequested = output();
+}
+
+@Component({
+ selector: 'app-portal-empty-state',
+ standalone: true,
+ template: '{{ message() }}
',
+})
+export class StubPortalEmptyStateComponent {
+ readonly icon = input('');
+ readonly message = input('');
+}
+
+@Component({
+ selector: 'app-audio-player',
+ standalone: true,
+ template: '',
+})
+export class StubAudioPlayerComponent {
+ readonly url = input('');
+ readonly icon = input('');
+ readonly channelName = input('');
+ readonly channelLogo = input('');
+ readonly volume = input(null);
+ readonly volumeChange = output();
+}
+
+@Component({
+ selector: 'app-web-player-view',
+ standalone: true,
+ template: '',
+})
+export class StubWebPlayerViewComponent {
+ readonly playbackSessionKey = input.required();
+ readonly streamUrl = input('');
+ readonly title = input('');
+ readonly playback = input(null);
+ readonly playerOverride = input(null);
+ readonly volume = input(1);
+ readonly recordingMetadata = input(null);
+ readonly externalFallbackRequested = output();
+ readonly recordingStopped = output();
+}
+
+@Component({
+ selector: 'app-epg-guide',
+ standalone: true,
+ template: '',
+})
+export class StubEpgGuideComponent {
+ readonly close = output();
+ readonly channelActivated = output();
+}
+
+@Component({
+ selector: 'app-epg-guide-now-playing',
+ standalone: true,
+ template: '',
+})
+export class StubEpgGuideNowPlayingComponent {
+ readonly channelName = input('');
+ readonly program = input(null);
+ readonly offsetMinutes = input(0);
+ readonly collapsed = input(false);
+ readonly closeRequested = output();
+ readonly collapsedChange = output();
+}
+
+@Directive({
+ selector: '[appResizable]',
+ standalone: true,
+})
+export class StubResizableDirective {
+ readonly minWidth = input(0);
+ readonly maxWidth = input(0);
+ readonly defaultWidth = input(0);
+ readonly storageKey = input('');
+ readonly widthChange = output();
+ readonly resizeEnd = output();
+}
diff --git a/libs/portal/shared/util/src/lib/workspace-header-context.service.ts b/libs/portal/shared/util/src/lib/workspace-header-context.service.ts
index 79bba2c1f..cde039250 100644
--- a/libs/portal/shared/util/src/lib/workspace-header-context.service.ts
+++ b/libs/portal/shared/util/src/lib/workspace-header-context.service.ts
@@ -6,6 +6,8 @@ export interface WorkspaceHeaderAction {
icon: string;
tooltipKey: string;
ariaLabelKey: string;
+ /** Pressed/highlighted state for toggling actions (e.g. the guide). */
+ active?: () => boolean;
run: () => void;
palette?: WorkspaceHeaderCommandMetadata;
}
diff --git a/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.spec.ts
index 27a81e5d8..42c1d6022 100644
--- a/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.spec.ts
+++ b/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.spec.ts
@@ -41,11 +41,11 @@ describe('WorkspaceCommandPaletteComponent', () => {
run: () => undefined,
},
{
- id: 'multi-epg',
- label: 'Open Multi-EPG',
+ id: 'epg-guide',
+ label: 'Open programme guide',
description: '',
group: 'view',
- icon: 'view_list',
+ icon: 'grid_view',
keywords: ['epg', 'guide'],
priority: 0,
visible: true,
@@ -126,7 +126,7 @@ describe('WorkspaceCommandPaletteComponent', () => {
component.query.set('guide');
expect(component.flatCommands().map((command) => command.id)).toEqual([
- 'multi-epg',
+ 'epg-guide',
]);
});
@@ -135,7 +135,7 @@ describe('WorkspaceCommandPaletteComponent', () => {
fixture.detectChanges();
expect(component.flatCommands().map((command) => command.id)).toEqual([
- 'multi-epg',
+ 'epg-guide',
'playlist-search',
'global-search',
]);
@@ -158,7 +158,7 @@ describe('WorkspaceCommandPaletteComponent', () => {
component.onCommandClick(command);
expect(dialogRef.close).toHaveBeenCalledWith({
- commandId: 'multi-epg',
+ commandId: 'epg-guide',
query: '',
});
});
diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html
index 32b3d22fe..27f0aead8 100644
--- a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html
+++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html
@@ -120,6 +120,8 @@
type="button"
class="header-shortcut"
mat-icon-button
+ [class.is-active]="action.active?.() ?? false"
+ [attr.aria-pressed]="action.active ? action.active() : null"
(click)="onHeaderShortcutRequested()"
[matTooltip]="action.tooltipKey | translate"
[attr.aria-label]="action.ariaLabelKey | translate"
diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.scss b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.scss
index 3015442f8..a4fde218e 100644
--- a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.scss
+++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.scss
@@ -394,3 +394,8 @@
}
}
}
+
+.header-shortcut.is-active {
+ background: var(--app-selection-surface);
+ color: var(--app-selection-color);
+}
diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts
index 29cbb46aa..975821997 100644
--- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts
+++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts
@@ -877,7 +877,7 @@ describe('WorkspaceShellFacade', () => {
);
});
- it('includes M3U navigation, playlist actions, and Multi-EPG on playlist routes', () => {
+ it('includes M3U navigation, playlist actions, and the programme guide on playlist routes', () => {
const headerContext = TestBed.inject(WorkspaceHeaderContextService);
activePlaylistSignal.set({
@@ -889,14 +889,14 @@ describe('WorkspaceShellFacade', () => {
});
facade.currentUrl.set('/workspace/playlists/pl-m3u/groups');
headerContext.setAction({
- id: 'm3u-multi-epg',
- icon: 'view_list',
- tooltipKey: 'TOP_MENU.OPEN_MULTI_EPG',
- ariaLabelKey: 'TOP_MENU.OPEN_MULTI_EPG',
+ id: 'm3u-epg-guide',
+ icon: 'grid_view',
+ tooltipKey: 'TOP_MENU.OPEN_EPG_GUIDE',
+ ariaLabelKey: 'TOP_MENU.OPEN_EPG_GUIDE',
palette: {
- labelKey: 'TOP_MENU.OPEN_MULTI_EPG',
+ labelKey: 'TOP_MENU.OPEN_EPG_GUIDE',
descriptionKey:
- 'WORKSPACE.SHELL.COMMANDS.OPEN_MULTI_EPG_DESCRIPTION',
+ 'WORKSPACE.SHELL.COMMANDS.OPEN_EPG_GUIDE_DESCRIPTION',
keywords: ['epg', 'guide', 'schedule'],
priority: 10,
},
@@ -907,7 +907,7 @@ describe('WorkspaceShellFacade', () => {
expect(commands.map((command) => command.id)).toEqual(
expect.arrayContaining([
- 'm3u-multi-epg',
+ 'm3u-epg-guide',
'go-to-all',
'go-to-favorites',
'go-to-recent',
@@ -915,7 +915,7 @@ describe('WorkspaceShellFacade', () => {
])
);
expect(
- commands.find((command) => command.id === 'm3u-multi-epg')?.group
+ commands.find((command) => command.id === 'm3u-epg-guide')?.group
).toBe('view');
expect(commands.some((command) => command.id === 'account-info')).toBe(
false