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