mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 04:26:16 -08:00
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:
1 parent
e20b9cc7a0
commit
44ad33beb9
13 files changed
+728
-386
No files matched your search
+9
-5
@@ -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() } },
|
||||
{
|
||||
|
||||
+9
-4
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+191
-245
@@ -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;
|
||||
}
|
||||
|
||||
+6
-6
@@ -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: '',
|
||||
});
|
||||
});
|
||||
|
||||
+2
@@ -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"
|
||||
|
||||
+5
@@ -394,3 +394,8 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-shortcut.is-active {
|
||||
background: var(--app-selection-surface);
|
||||
color: var(--app-selection-color);
|
||||
}
|
||||
+9
-9
@@ -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
|
||||
|
||||
Reference in new issue
Block a user