feat(m3u): open the programme guide in place with a docked player

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-06 13:16:18 +02:00
1 parent e20b9cc7a0
commit 44ad33beb9
13 files changed
+728 -386

No files matched your search

@@ -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() } },
{
@@ -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() } },
{
@@ -1,56 +1,62 @@
<div
class="sidebar"
[class.sidebar-collapsed]="isSidebarCollapsed()"
appResizable
[minWidth]="sidebarMinWidth"
[maxWidth]="sidebarMaxWidth"
[defaultWidth]="sidebarWidth()"
[style.width.px]="sidebarWidth()"
[storageKey]="sidebarStorageKey()"
(widthChange)="onSidebarWidthChange($event)"
(resizeEnd)="onSidebarResizeEnd($event)"
>
@if (activeView() !== 'groups' && activeView() !== 'all') {
<button
mat-icon-button
type="button"
class="sidebar-collapse-toggle"
[attr.aria-label]="'LAYOUT.HIDE_CHANNELS_LIST' | translate"
[matTooltip]="'LAYOUT.TOGGLE_SIDEBAR_TOOLTIP' | translate"
[attr.aria-pressed]="isSidebarCollapsed()"
(click)="toggleSidebar()"
>
<mat-icon>chevron_left</mat-icon>
</button>
}
@defer {
<app-sidebar
[channels]="(channels$ | async)!"
[channelsLoading]="channelsLoading()"
[showPlaylistHeader]="!isWorkspaceLayout"
[activeView]="activeView()"
[sidebarWidth]="sidebarWidth()"
(sidebarWidthRequested)="onGroupedSidebarWidthRequested($event)"
(sidebarWidthRequestEnded)="
onGroupedSidebarWidthRequestEnded($event)
"
(sidebarToggleRequested)="toggleSidebar()"
/>
} @loading {
<app-channel-list-loading-state
[view]="activeView()"
[showEpg]="supportsEpg"
/>
} @placeholder {
<app-channel-list-loading-state
[view]="activeView()"
[showEpg]="supportsEpg"
/>
}
</div>
@if (!guideOpen()) {
<div
class="sidebar"
[class.sidebar-collapsed]="isSidebarCollapsed()"
appResizable
[minWidth]="sidebarMinWidth"
[maxWidth]="sidebarMaxWidth"
[defaultWidth]="sidebarWidth()"
[style.width.px]="sidebarWidth()"
[storageKey]="sidebarStorageKey()"
(widthChange)="onSidebarWidthChange($event)"
(resizeEnd)="onSidebarResizeEnd($event)"
>
@if (activeView() !== 'groups' && activeView() !== 'all') {
<button
mat-icon-button
type="button"
class="sidebar-collapse-toggle"
[attr.aria-label]="'LAYOUT.HIDE_CHANNELS_LIST' | translate"
[matTooltip]="'LAYOUT.TOGGLE_SIDEBAR_TOOLTIP' | translate"
[attr.aria-pressed]="isSidebarCollapsed()"
(click)="toggleSidebar()"
>
<mat-icon>chevron_left</mat-icon>
</button>
}
@defer {
<app-sidebar
[channels]="(channels$ | async)!"
[channelsLoading]="channelsLoading()"
[showPlaylistHeader]="!isWorkspaceLayout"
[activeView]="activeView()"
[sidebarWidth]="sidebarWidth()"
(sidebarWidthRequested)="onGroupedSidebarWidthRequested($event)"
(sidebarWidthRequestEnded)="
onGroupedSidebarWidthRequestEnded($event)
"
(sidebarToggleRequested)="toggleSidebar()"
/>
} @loading {
<app-channel-list-loading-state
[view]="activeView()"
[showEpg]="supportsEpg"
/>
} @placeholder {
<app-channel-list-loading-state
[view]="activeView()"
[showEpg]="supportsEpg"
/>
}
</div>
}
<div class="content-container">
@if (isSidebarCollapsed()) {
<div
class="content-container"
[class.is-guide]="guideOpen()"
[class.is-guide-collapsed]="guideOpen() && guideDockCollapsed()"
>
@if (isSidebarCollapsed() && !guideOpen()) {
<button
mat-icon-button
type="button"
@@ -110,10 +116,36 @@
(recordingStopped)="onRecordingStopped($event)"
/>
}
@if (guideOpen()) {
<app-epg-guide-now-playing
class="guide-dock__info"
[channelName]="timelineChannelName()"
[program]="guideNowPlayingProgram()"
[offsetMinutes]="epgOffsetMinutes()"
[collapsed]="guideDockCollapsed()"
(closeRequested)="closeGuide()"
(collapsedChange)="setGuideDockCollapsed($event)"
/>
}
</div>
}
@if (supportsEpg && activeChannel.radio !== 'true') {
@if (guideOpen() && !shouldShowInlinePlayer(activeChannel)) {
<div class="video-player guide-dock--external">
<app-epg-guide-now-playing
[channelName]="timelineChannelName()"
[program]="guideNowPlayingProgram()"
[offsetMinutes]="epgOffsetMinutes()"
[collapsed]="true"
(closeRequested)="closeGuide()"
(collapsedChange)="setGuideDockCollapsed($event)"
/>
</div>
}
@if (guideOpen()) {
<app-epg-guide class="epg-guide-host" (close)="closeGuide()" />
} @else if (supportsEpg && activeChannel.radio !== 'true') {
@let canCollapseEpgPanel =
shouldShowInlinePlayer(activeChannel);
<div
@@ -181,6 +213,7 @@
liveEpgPanelSummaryLabelKey()
"
[emptyReason]="liveEpgEmptyReason()"
[guideAvailable]="canOpenGuide()"
(programActivated)="
onTimelineProgramActivated($event)
"
@@ -192,6 +225,7 @@
onLiveEpgPanelCollapsedChange($event)
"
(openEpgSettings)="openEpgSettings()"
(openGuide)="openGuide()"
/>
}
</div>
@@ -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;
}
}
}
@@ -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<string | null>(null);
readonly showEpg = input(true);
}
@Component({
selector: 'app-sidebar',
standalone: true,
template: '',
})
class StubSidebarComponent {
readonly channels = input<Channel[]>([]);
readonly channelsLoading = input(false);
readonly showPlaylistHeader = input(false);
readonly activeView = input('');
readonly sidebarWidth = input(0);
readonly sidebarWidthRequested = output<number>();
readonly sidebarWidthRequestEnded = output<number>();
readonly sidebarToggleRequested = output<void>();
}
@Component({
selector: 'app-portal-empty-state',
standalone: true,
template: '<div class="stub-empty-state">{{ message() }}</div>',
})
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<number | null>(null);
readonly volumeChange = output<number>();
}
@Component({
selector: 'app-web-player-view',
standalone: true,
template: '',
})
class StubWebPlayerViewComponent {
readonly playbackSessionKey = input.required<string>();
readonly streamUrl = input('');
readonly title = input('');
readonly playback = input<unknown>(null);
readonly playerOverride = input<VideoPlayer | null>(null);
readonly volume = input(1);
readonly recordingMetadata = input<RecordingStartMetadata | null>(null);
readonly externalFallbackRequested = output<PlaybackFallbackRequest>();
readonly recordingStopped = output<RecordingStoppedEvent>();
}
@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<number>();
readonly resizeEnd = output<number>();
}
describe('VideoPlayerComponent', () => {
let VideoPlayerComponent: typeof import('./video-player.component').VideoPlayerComponent;
let fixture: ComponentFixture<VideoPlayerComponentInstance>;
let component: VideoPlayerComponentInstance;
let headerContext: WorkspaceHeaderContextService;
const playlistId = signal('playlist-1');
const activeChannel = signal<Channel | null>(null);
const activePlaybackUrl = signal<string | null>(null);
const channels = signal<Channel[]>([]);
const channelsLoading = signal(false);
const currentEpgProgram = signal<EpgProgram | null>(null);
const activeEpgProgram = signal<EpgProgram | null>(null);
const channels$ = new BehaviorSubject<Channel[]>([]);
const activeChannel$ = new BehaviorSubject<Channel | null>(null);
const currentEpgProgram$ = new BehaviorSubject<EpgProgram | null>(null);
const epgPrograms$ = new BehaviorSubject<EpgProgram[]>([]);
const epgServiceMock = {
currentEpgPrograms$: epgPrograms$.asObservable(),
getChannelMetadataForChannels: () => of(new Map()),
};
const player = signal<VideoPlayer>(VideoPlayer.VideoJs);
const showCaptions = signal(false);
const stripCountryPrefix = signal(false);
const epgViewMode = signal<'timeline' | 'list'>('timeline');
const epgUrlSetting = signal<string[]>([]);
const externalSession = signal<ExternalPlayerSession | null>(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<string, unknown>
| undefined;
Record<string, unknown> | 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', () => {
@@ -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<HTMLElement>);
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<Channel[]>;
// 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(),
});
}
}
@@ -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<Channel | null>(null);
export const activePlaybackUrl = signal<string | null>(null);
export const channels = signal<Channel[]>([]);
export const channelsLoading = signal(false);
export const currentEpgProgram = signal<EpgProgram | null>(null);
export const activeEpgProgram = signal<EpgProgram | null>(null);
export const favoriteIds = signal<string[]>([]);
export const channels$ = new BehaviorSubject<Channel[]>([]);
export const activeChannel$ = new BehaviorSubject<Channel | null>(null);
export const currentEpgProgram$ = new BehaviorSubject<EpgProgram | null>(null);
export const epgPrograms$ = new BehaviorSubject<EpgProgram[]>([]);
export const epgServiceMock = {
currentEpgPrograms$: epgPrograms$.asObservable(),
getChannelMetadataForChannels: () => of(new Map()),
};
export const player = signal<VideoPlayer>(VideoPlayer.VideoJs);
export const showCaptions = signal(false);
export const stripCountryPrefix = signal(false);
export const epgViewMode = signal<'timeline' | 'list'>('timeline');
export const epgUrlSetting = signal<string[]>([]);
export const externalSession = signal<ExternalPlayerSession | null>(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] : []);
}
@@ -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<boolean>();
readonly openGuide = output<void>();
}
@Component({
selector: 'app-channel-list-loading-state',
standalone: true,
template: '',
})
export class StubChannelListLoadingStateComponent {
readonly view = input<string | null>(null);
readonly showEpg = input(true);
}
@Component({
selector: 'app-sidebar',
standalone: true,
template: '',
})
export class StubSidebarComponent {
readonly channels = input<Channel[]>([]);
readonly channelsLoading = input(false);
readonly showPlaylistHeader = input(false);
readonly activeView = input('');
readonly sidebarWidth = input(0);
readonly sidebarWidthRequested = output<number>();
readonly sidebarWidthRequestEnded = output<number>();
readonly sidebarToggleRequested = output<void>();
}
@Component({
selector: 'app-portal-empty-state',
standalone: true,
template: '<div class="stub-empty-state">{{ message() }}</div>',
})
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<number | null>(null);
readonly volumeChange = output<number>();
}
@Component({
selector: 'app-web-player-view',
standalone: true,
template: '',
})
export class StubWebPlayerViewComponent {
readonly playbackSessionKey = input.required<string>();
readonly streamUrl = input('');
readonly title = input('');
readonly playback = input<unknown>(null);
readonly playerOverride = input<VideoPlayer | null>(null);
readonly volume = input(1);
readonly recordingMetadata = input<RecordingStartMetadata | null>(null);
readonly externalFallbackRequested = output<PlaybackFallbackRequest>();
readonly recordingStopped = output<RecordingStoppedEvent>();
}
@Component({
selector: 'app-epg-guide',
standalone: true,
template: '<div class="stub-guide"></div>',
})
export class StubEpgGuideComponent {
readonly close = output<void>();
readonly channelActivated = output<string>();
}
@Component({
selector: 'app-epg-guide-now-playing',
standalone: true,
template: '',
})
export class StubEpgGuideNowPlayingComponent {
readonly channelName = input('');
readonly program = input<EpgProgram | null>(null);
readonly offsetMinutes = input(0);
readonly collapsed = input(false);
readonly closeRequested = output<void>();
readonly collapsedChange = output<boolean>();
}
@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<number>();
readonly resizeEnd = output<number>();
}
@@ -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;
}
@@ -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: '',
});
});
@@ -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"
@@ -394,3 +394,8 @@
}
}
}
.header-shortcut.is-active {
background: var(--app-selection-surface);
color: var(--app-selection-color);
}
@@ -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