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 11128b410..ce4da1f6d 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,155 +1,87 @@ - - - - @defer { - - } @loading { -

Loading...

- } @placeholder { -

Loading...

- } -
- - @if (activeChannel(); as activeChannel) { - - - @if (activeChannel.radio === 'true') { - - } @else { - - @if (playerSettings.player === 'videojs') { - - } - - @if (playerSettings.player === 'html5') { - - } - - @if (playerSettings.player === 'artplayer') { - - } - @if (playerSettings.player === 'mpv') { -
- external player -

- The channel is played in a separate window in the - MPV player. (Installation instructions) -

-
- } - @if (playerSettings.player === 'vlc') { -
- external player -

- The channel is played in a separate window in VLC - player. Please make sure that this player is - installed in your system and the correct path is - defined in the app settings. -

-
- } - - + @defer { + + } @loading { +

Loading...

+ } @placeholder { +

Loading...

+ } + - - @if (showChannelNumberOverlay) { -
-
- {{ channelNumberInput }} -
-
+
+ @if (activeChannel(); as activeChannel) { + @if (shouldShowInlinePlayer(activeChannel)) { +
+ @if (activeChannel.radio === 'true') { + + } @else { + @if (playerSettings.player === 'videojs') { + + } + @if (playerSettings.player === 'html5') { + + } + @if (playerSettings.player === 'artplayer') { + + } } - } - } @else { - - -
- live_tv -

- {{ 'CHANNELS.SELECT_CHANNEL_PLAYBACK' | translate }} -

} - - - - @if (isDesktop) { - + +
+
+ +
+
+ + @if (showChannelNumberOverlay) { +
+
+ {{ channelNumberInput }} +
+
} -
- + } @else { +
+ live_tv +

+ {{ 'CHANNELS.SELECT_CHANNEL_PLAYBACK' | translate }} +

+
+ } +
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 65952114a..307fedf4c 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 @@ -1,101 +1,47 @@ +@use '../../../../../../ui/styles/portal-layout' as portal; + +@include portal.live-layout; + :host { - display: block; - width: 100%; - height: 100%; min-height: 0; + background: var(--mat-sys-surface); } -.main-container { - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - overflow: hidden; // Prevent horizontal scrollbar from resize handle +.sidebar { + app-sidebar { + flex: 1; + min-height: 0; + overflow: hidden; + } } -.main-container.main-container--workspace { - position: relative; - top: auto; - right: auto; - bottom: auto; - left: auto; - width: 100%; - height: 100%; - min-height: 0; +.content-container { + min-width: 0; + background: var(--mat-sys-surface); } -.main-container.main-container--workspace .mat-drawer-content, -.main-container.main-container--workspace .drawer-content { - height: 100%; - min-height: 0; -} - -.mat-drawer-content { +.video-player { background: #000; - color: #fff; - overflow: hidden; - border-radius: 20px 0 0 0; -} -.mat-drawer { - // Width is controlled by appResizable directive - // Default: 350px, min: 200px, max: 600px - overflow-x: hidden !important; // Hide horizontal scrollbar - border-radius: 0 !important; - - .resize-handle--right { - right: 0; - } -} - -// Ensure mat-drawer inner container fills the drawer width -::ng-deep .mat-drawer-inner-container { - width: 100% !important; -} - -.custom-player { - display: flex; - flex-direction: column; - align-items: center; - gap: 16px; - margin: 50px auto 0; - padding: 10px 20px 24px; - text-align: center; - - img { + > app-audio-player, + > app-vjs-player, + > app-html-video-player, + > app-art-player { + flex: 1; + min-height: 0; display: block; - width: min(100%, 460px); - filter: drop-shadow(0 18px 42px rgba(77, 220, 243, 0.16)); - } - - p { - max-width: 56ch; - margin: 0; - color: rgba(255, 255, 255, 0.88); - } - - a { - color: inherit; } } -@media only screen and (max-width: 599px) { - .main-container { - display: flex; - flex-direction: column-reverse; - top: unset; - } +.epg { + border-top: 1px solid var(--mat-sys-outline-variant); + background: var(--mat-sys-surface); +} - .mat-drawer { - height: 50vh; - width: 100%; - position: unset; - } - - .drawer-content { - height: 50vh; - margin-left: 0 !important; +.epg-content { + > app-epg-list { + flex: 1; + min-height: 0; } } @@ -109,18 +55,18 @@ } .channel-number-display { + min-width: 120px; + padding: 2rem 3rem; + border: 2px solid rgba(255, 255, 255, 0.1); + border-radius: 16px; background: rgba(0, 0, 0, 0.85); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); color: #fff; + font-family: 'DM Sans', sans-serif; font-size: 6rem; font-weight: 700; - font-family: 'DM Sans', sans-serif; - padding: 2rem 3rem; - border-radius: 16px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); - border: 2px solid rgba(255, 255, 255, 0.1); - min-width: 120px; - text-align: center; letter-spacing: 0.05em; + text-align: center; animation: scaleIn 0.15s ease-out; } @@ -129,6 +75,7 @@ opacity: 0; transform: scale(0.8); } + to { opacity: 1; transform: scale(1); @@ -137,26 +84,45 @@ .empty-state-container { display: flex; + flex: 1; flex-direction: column; align-items: center; justify-content: center; - height: calc(100% - 64px); text-align: center; - color: rgba(255, 255, 255, 0.5); + color: rgba(15, 23, 42, 0.45); .empty-state-icon { - font-size: 72px; width: 72px; height: 72px; margin-bottom: 24px; - opacity: 0.2; + font-size: 72px; + opacity: 0.25; } .empty-state-title { - font-family: 'DM Sans', sans-serif; - font-weight: 500; - font-size: 1.25rem; margin: 0; - opacity: 0.5; + font-family: 'DM Sans', sans-serif; + font-size: 1.25rem; + font-weight: 500; + opacity: 0.7; + } +} + +@media only screen and (max-width: 599px) { + :host { + flex-direction: column-reverse; + } + + .sidebar { + width: 100%; + min-width: 100%; + max-width: 100%; + height: 50vh; + border-right: none; + border-top: 1px solid var(--mat-sys-outline-variant); + } + + .content-container { + height: 50vh; } } 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 c6e75e4cb..98543a5b4 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,101 +1,316 @@ -/* import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { MatDividerModule } from '@angular/material/divider'; -import { MatIconModule } from '@angular/material/icon'; -import { MatSidenavModule } from '@angular/material/sidenav'; -import { MatSnackBar } from '@angular/material/snack-bar'; -import { MatToolbarModule } from '@angular/material/toolbar'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { ActivatedRoute } from '@angular/router'; -import { RouterTestingModule } from '@angular/router/testing'; -import { MockStore, provideMockStore } from '@ngrx/store/testing'; -import { TranslateModule } from '@ngx-translate/core'; -import { MockComponent, MockModule, MockProviders } from 'ng-mocks'; -import { DataService } from '../../../../../../libs/services/src/lib/data.service'; -import { VideoPlayer } from '../../../../../../libs/shared/interfaces/src/lib/settings.interface'; -import { ChannelListContainerComponent } from '../../../../../../libs/ui/components/src/lib/channel-list-container/channel-list-container.component'; -import { EpgListComponent } from '../../../../../../libs/ui/components/src/lib/epg-list/epg-list.component'; -import { HtmlVideoPlayerComponent } from '../../../../../../libs/ui/components/src/lib/html-video-player/html-video-player.component'; -import { InfoOverlayComponent } from '../../../../../../libs/ui/components/src/lib/info-overlay/info-overlay.component'; -import { VjsPlayerComponent } from '../../../../../../libs/ui/components/src/lib/vjs-player/vjs-player.component'; -import { ElectronServiceStub } from '../../services/electron.service.stub'; +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 { Store } from '@ngrx/store'; +import { StorageMap } from '@ngx-pwa/local-storage'; +import { TranslatePipe } from '@ngx-translate/core'; +import { MockPipe } from 'ng-mocks'; +import { BehaviorSubject, of } from 'rxjs'; +import { + ChannelActions, + selectActive, + selectActivePlaylistId, + selectChannels, + selectCurrentEpgProgram, +} from 'm3u-state'; +import { + PORTAL_EXTERNAL_PLAYBACK, + WorkspaceHeaderContextService, +} from '@iptvnator/portal/shared/util'; +import { DataService, PlaylistsService, SettingsStore } from 'services'; +import { + Channel, + Settings, + VideoPlayer, +} from 'shared-interfaces'; +import { Overlay } from '@angular/cdk/overlay'; import { VideoPlayerComponent } from './video-player.component'; -import { CommonModule } from '@angular/common'; -import { Actions } from '@ngrx/effects'; -import { provideMockActions } from '@ngrx/effects/testing'; -import { NgxIndexedDBService } from 'ngx-indexed-db'; -import { NgxSkeletonLoaderComponent } from 'ngx-skeleton-loader'; -import { Observable, of } from 'rxjs'; -import { initialState } from '../../../../../../apps/web/src/app/state/state'; -import { PlaylistsService } from 'services'; - -class MatSnackBarStub { - open(): void {} -} - describe('VideoPlayerComponent', () => { - let component: VideoPlayerComponent; let fixture: ComponentFixture; - let mockStore: MockStore; - const actions$ = new Observable(); + let component: VideoPlayerComponent; + let headerContext: WorkspaceHeaderContextService; - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ + const playlistId = signal('playlist-1'); + const activeChannel = signal(null); + const channels = signal([]); + const currentEpgProgram = signal(null); + + const channels$ = new BehaviorSubject([]); + const activeChannel$ = new BehaviorSubject(null); + const currentEpgProgram$ = new BehaviorSubject(null); + + const player = signal(VideoPlayer.VideoJs); + const showCaptions = signal(false); + + 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 selectActivePlaylistId: + return playlistId; + case selectChannels: + return channels; + case selectCurrentEpgProgram: + return currentEpgProgram; + 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 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; + + function syncStoreState(channel: Channel | null): void { + activeChannel.set(channel); + activeChannel$.next(channel); + channels.set(channel ? [channel] : []); + channels$.next(channel ? [channel] : []); + } + + beforeEach(async () => { + syncStoreState(null); + player.set(VideoPlayer.VideoJs); + showCaptions.set(false); + currentEpgProgram.set(null); + currentEpgProgram$.next(null); + overlayMock.create.mockClear(); + overlayRef.attach.mockClear(); + overlayRef.dispose.mockClear(); + storeMock.dispatch.mockClear(); + + await TestBed.configureTestingModule({ + imports: [VideoPlayerComponent], + schemas: [NO_ERRORS_SCHEMA], providers: [ - { provide: MatSnackBar, useClass: MatSnackBarStub }, - { provide: DataService, useClass: ElectronServiceStub }, { provide: ActivatedRoute, useValue: { - params: of({ id: '1' }), + params: of({ id: playlistId(), view: 'all' }), + queryParams: of({}), snapshot: { - queryParams: { - url: 'https://iptvnator/list.m3u', - }, + data: { layout: 'workspace' }, + queryParams: {}, }, }, }, - provideMockStore(), - provideMockActions(actions$), - MockProviders(NgxIndexedDBService, PlaylistsService), + { + provide: Router, + useValue: routerMock, + }, + { + provide: Store, + useValue: storeMock, + }, + { + provide: Overlay, + useValue: overlayMock, + }, + { + provide: DataService, + useValue: { + sendIpcEvent: jest.fn(), + }, + }, + { + provide: PlaylistsService, + useValue: playlistsServiceMock, + }, + { + provide: SettingsStore, + useValue: { + player, + showCaptions, + }, + }, + { + provide: StorageMap, + useValue: { + get: jest.fn(() => + of({ + player: player(), + showCaptions: showCaptions(), + } as Partial) + ), + }, + }, + { + provide: PORTAL_EXTERNAL_PLAYBACK, + useValue: { + activeSession: signal(null), + }, + }, ], - imports: [ - MockComponent(EpgListComponent), - MockComponent(HtmlVideoPlayerComponent), - MockComponent(VjsPlayerComponent), - MockComponent(VideoPlayerComponent), - MockComponent(ChannelListContainerComponent), - MockComponent(InfoOverlayComponent), - VideoPlayerComponent, - CommonModule, - TranslateModule.forRoot(), - MockModule(MatSidenavModule), - MockModule(MatIconModule), - MockModule(MatToolbarModule), - MockModule(MatTooltipModule), - MockModule(RouterTestingModule), - MockModule(MatDividerModule), - MockComponent(NgxSkeletonLoaderComponent), - ], - }).compileComponents(); - })); + }) + .overrideComponent(VideoPlayerComponent, { + set: { + imports: [ + AsyncPipe, + MockPipe( + TranslatePipe, + (value: string | null | undefined) => value ?? '' + ), + ], + }, + }) + .compileComponents(); - beforeEach(() => { fixture = TestBed.createComponent(VideoPlayerComponent); component = fixture.componentInstance; - mockStore = TestBed.inject(MockStore); - - mockStore.setState({ - playlistState: initialState, - }); - fixture.detectChanges(); + headerContext = TestBed.inject(WorkspaceHeaderContextService); }); - it('should check default component settings', () => { - expect(component.playerSettings).toEqual({ - player: VideoPlayer.VideoJs, - showCaptions: false, - }); + afterEach(() => { + fixture.destroy(); + }); + + it('registers and clears the workspace multi-EPG header shortcut', () => { + fixture.detectChanges(); + + expect(headerContext.action()).toEqual( + expect.objectContaining({ + id: 'm3u-multi-epg', + icon: 'view_list', + }) + ); + + fixture.destroy(); + expect(headerContext.action()).toBeNull(); + }); + + it('renders the inline player with the embedded EPG panel', () => { + syncStoreState(sampleChannel); + player.set(VideoPlayer.VideoJs); + + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('.video-player') + ).not.toBeNull(); + expect( + fixture.nativeElement.querySelector('app-vjs-player') + ).not.toBeNull(); + expect( + fixture.nativeElement.querySelector('app-epg-list') + ).not.toBeNull(); + }); + + it('renders only the EPG panel when an external player is configured', () => { + syncStoreState(sampleChannel); + player.set(VideoPlayer.MPV); + + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('.video-player') + ).toBeNull(); + expect( + fixture.nativeElement.querySelector('app-epg-list') + ).not.toBeNull(); + }); + + it('renders the empty state when no channel is active', () => { + fixture.detectChanges(); + + const emptyState = fixture.nativeElement.querySelector( + '.empty-state-container' + ) as HTMLElement | null; + + expect(emptyState).not.toBeNull(); + expect(emptyState?.textContent).toContain( + 'CHANNELS.SELECT_CHANNEL_PLAYBACK' + ); + }); + + it('reuses the registered header shortcut callback to open multi EPG', () => { + fixture.detectChanges(); + + headerContext.action()?.run(); + + expect(overlayMock.create).toHaveBeenCalledTimes(1); + expect(overlayRef.attach).toHaveBeenCalledTimes(1); + }); + + it('switches channels by number through the store action', () => { + syncStoreState(sampleChannel); + fixture.detectChanges(); + + component.switchToChannelByNumber(1); + + expect(storeMock.dispatch).toHaveBeenCalledWith( + ChannelActions.setActiveChannel({ channel: sampleChannel }) + ); }); }); - */ 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 72798c767..97125ef4f 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 @@ -9,19 +9,14 @@ import { OnInit, effect, inject, - viewChild, } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { MatIcon } from '@angular/material/icon'; -import { MatSidenavModule } from '@angular/material/sidenav'; -import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +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 { - InfoOverlayComponent, - ResizableDirective, -} from 'components'; +import { ResizableDirective } from 'components'; import { COMPONENT_OVERLAY_REF, EpgListComponent, @@ -53,30 +48,30 @@ import { getAdjacentChannelItem, getChannelItemByNumber, PORTAL_EXTERNAL_PLAYBACK, + WorkspaceHeaderContextService, } from '@iptvnator/portal/shared/util'; import { ArtPlayerComponent, AudioPlayerComponent, HtmlVideoPlayerComponent, SidebarComponent, - ToolbarComponent, VjsPlayerComponent, } from '@iptvnator/ui/playback'; -import { PLAYLIST_PLAYER_ACTIONS } from '@iptvnator/playlist/shared/util'; import { DataService, PlaylistsService, SettingsStore } from 'services'; import { Channel, EpgProgram, - M3uRecentlyViewedItem, + ExternalPlayerSession, PLAYLIST_PARSE_BY_URL, + M3uRecentlyViewedItem, PlaylistMeta, STORE_KEY, Settings, - SidebarView, - ExternalPlayerSession, VideoPlayer, } from 'shared-interfaces'; +const M3U_MULTI_EPG_HEADER_ACTION_ID = 'm3u-multi-epg'; + @Component({ selector: 'app-video-player', imports: [ @@ -86,13 +81,9 @@ import { CommonModule, EpgListComponent, HtmlVideoPlayerComponent, - InfoOverlayComponent, MatIcon, - MatSidenavModule, ResizableDirective, - RouterLink, SidebarComponent, - ToolbarComponent, TranslatePipe, VjsPlayerComponent, ], @@ -103,13 +94,15 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { private readonly activatedRoute = inject(ActivatedRoute); private readonly dataService = inject(DataService); private readonly overlay = inject(Overlay); - private readonly playerActions = inject(PLAYLIST_PLAYER_ACTIONS); private readonly playlistsService = inject(PlaylistsService); private readonly router = inject(Router); private readonly settingsStore = inject(SettingsStore); private readonly storage = inject(StorageMap); private readonly store = inject(Store); private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK); + private readonly workspaceHeaderContext = inject( + WorkspaceHeaderContextService + ); /** Active selected channel */ readonly activeChannel = this.store.selectSignal(selectActive); @@ -153,8 +146,6 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { readonly isWorkspaceLayout = this.activatedRoute.snapshot.data['layout'] === 'workspace'; - sidebarView: SidebarView = 'CHANNELS'; - /** EPG overlay reference */ private overlayRef!: OverlayRef; private unsubscribeRemoteChannelChange?: () => void; @@ -166,9 +157,6 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { private lastExternalSessionStateKey = this.getExternalSessionStateKey(this.externalPlayback.activeSession()); - /** Info overlay component reference for manual triggering */ - readonly infoOverlay = viewChild(InfoOverlayComponent); - /** Channel number input state */ channelNumberInput = ''; showChannelNumberOverlay = false; @@ -277,6 +265,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { ngOnInit(): void { this.applySettings(); this.getPlaylistUrlAsParam(); + this.registerHeaderShortcut(); // Setup remote control channel change listener (Electron only) if (this.isDesktop && window.electron?.onChannelChange) { @@ -463,6 +452,9 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { } ngOnDestroy(): void { + this.workspaceHeaderContext.clearAction( + M3U_MULTI_EPG_HEADER_ACTION_ID + ); this.unsubscribeRemoteChannelChange?.(); this.unsubscribeRemoteCommand?.(); this.statusSubscription?.unsubscribe(); @@ -603,35 +595,6 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { }); } - openUrl(url: string) { - window.open(url, '_blank'); - } - - navigateHome() { - this.router.navigate(['/']); - } - - openSettings() { - this.playerActions.openSettings(); - } - - /** - * Keyboard shortcut: Press 'I' to toggle EPG info overlay - */ - @HostListener('document:keydown.i', ['$event']) - handleInfoKeyPress(event: Event): void { - // Don't trigger hotkeys when user is typing in input fields - if (this.isTypingInInput(event)) { - return; - } - // Prevent default behavior and show info overlay - event.preventDefault(); - this.toggleInfoOverlay(); - } - - /** - * Handle digit key presses for channel number input - */ @HostListener('document:keydown', ['$event']) handleKeyPress(event: KeyboardEvent): void { // Only handle digit keys (0-9) @@ -645,17 +608,6 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { } } - /** - * Toggles the EPG info overlay visibility - * Called by 'I' keyboard shortcut or info button - */ - toggleInfoOverlay(): void { - const overlay = this.infoOverlay(); - if (overlay) { - overlay.showOverlay(); - } - } - /** * Handle channel number input from keyboard * Debounces input to allow multi-digit channel numbers @@ -768,6 +720,14 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { } } + shouldShowInlinePlayer(channel: Channel | null | undefined): boolean { + if (!channel) { + return false; + } + + return !this.isExternalPlayer(this.playerSettings.player); + } + private getExternalSessionStateKey( session: ExternalPlayerSession | null | undefined ): string | null { @@ -789,4 +749,18 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { ): boolean { return session?.status === 'closed' || session?.status === 'error'; } + + private registerHeaderShortcut(): void { + if (!this.isWorkspaceLayout) { + return; + } + + 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', + run: () => this.openMultiEpgView(), + }); + } } diff --git a/libs/portal/shared/util/src/index.ts b/libs/portal/shared/util/src/index.ts index 51aeddef6..4411bb1f2 100644 --- a/libs/portal/shared/util/src/index.ts +++ b/libs/portal/shared/util/src/index.ts @@ -9,5 +9,6 @@ export * from './lib/portal-navigation-actions'; export * from './lib/portal-collection-context'; export * from './lib/portal-collection-items'; export * from './lib/remote-channel-navigation'; +export * from './lib/workspace-header-context.service'; export * from './lib/navigation/portal-rail-links'; export * from './lib/navigation/portal-route.utils'; 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 new file mode 100644 index 000000000..32f2a661d --- /dev/null +++ b/libs/portal/shared/util/src/lib/workspace-header-context.service.ts @@ -0,0 +1,26 @@ +import { Injectable, signal } from '@angular/core'; + +export interface WorkspaceHeaderAction { + id: string; + icon: string; + tooltipKey: string; + ariaLabelKey: string; + run: () => void; +} + +@Injectable({ providedIn: 'root' }) +export class WorkspaceHeaderContextService { + readonly action = signal(null); + + setAction(action: WorkspaceHeaderAction): void { + this.action.set(action); + } + + clearAction(id?: string): void { + if (id && this.action()?.id !== id) { + return; + } + + this.action.set(null); + } +} diff --git a/libs/ui/epg/src/lib/epg-list/epg-list.component.scss b/libs/ui/epg/src/lib/epg-list/epg-list.component.scss index 1867c727a..2277c2367 100644 --- a/libs/ui/epg/src/lib/epg-list/epg-list.component.scss +++ b/libs/ui/epg/src/lib/epg-list/epg-list.component.scss @@ -1,9 +1,9 @@ :host { display: flex; flex-direction: column; - position: absolute; - inset: 0; + width: 100%; height: 100%; + min-height: 0; overflow-x: hidden; background: transparent; } diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html index 095621760..4fb9d984f 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html @@ -116,6 +116,18 @@ > star + @if (headerShortcut(); as action) { + + } @if (isElectron) {