refactor: refactor video player component and enhance workspace header shortcuts

This commit is contained in:
4gray committed 2026-03-16 22:55:01 +01:00
1 parent 367c9507a5
commit 0c451b0dd7
10 files changed
+584 -393

No files matched your search

@@ -1,155 +1,87 @@
<mat-drawer-container
class="main-container"
[class.main-container--workspace]="isWorkspaceLayout"
autosize
<div
class="sidebar"
appResizable
[minWidth]="200"
[maxWidth]="600"
[defaultWidth]="350"
storageKey="sidebar-width"
>
<!-- sidebar content -->
<mat-drawer
#drawer
mode="side"
opened
disableClose
class="mat-drawer"
appResizable
[minWidth]="200"
[maxWidth]="600"
[defaultWidth]="350"
storageKey="sidebar-width"
handlePosition="right"
>
@defer {
<app-sidebar
[channels]="(channels$ | async)!"
[showPlaylistHeader]="!isWorkspaceLayout"
[activeView]="activeView()"
/>
} @loading {
<p>Loading...</p>
} @placeholder {
<p>Loading...</p>
}
</mat-drawer>
<mat-drawer-content class="drawer-content">
@if (activeChannel(); as activeChannel) {
<!-- toolbar with drawer icon -->
<app-toolbar
(toggleLeftDrawerClicked)="drawer.toggle()"
(toggleRightDrawerClicked)="drawerRight.toggle()"
(multiEpgClicked)="openMultiEpgView()"
(infoOverlayClicked)="toggleInfoOverlay()"
[activeChannel]="activeChannel"
[isLeftDrawerOpened]="drawer.opened"
(settingsClicked)="openSettings()"
/>
@if (activeChannel.radio === 'true') {
<app-audio-player
[url]="activeChannel.url"
[icon]="activeChannel?.tvg?.logo!"
/>
} @else {
<!-- video.js player -->
@if (playerSettings.player === 'videojs') {
<app-vjs-player
[options]="{
sources: [
{
src:
activeChannel.url +
activeChannel.epgParams,
type: 'application/x-mpegURL',
},
],
}"
[volume]="volume"
[class.hide-captions]="!playerSettings.showCaptions"
/>
}
<!-- default html player component -->
@if (playerSettings.player === 'html5') {
<app-html-video-player
[channel]="activeChannel"
[volume]="volume"
[showCaptions]="playerSettings.showCaptions!"
/>
}
<!-- Add ArtPlayer component -->
@if (playerSettings.player === 'artplayer') {
<app-art-player
[channel]="activeChannel"
[volume]="volume"
[showCaptions]="playerSettings.showCaptions!"
/>
}
@if (playerSettings.player === 'mpv') {
<div class="custom-player">
<img
src="./assets/images/iptvnator-1-mascot.png"
alt="external player"
/>
<p>
The channel is played in a separate window in the
MPV player. (<a
[routerLink]
style="cursor: pointer"
(click)="
openUrl(
'https://github.com/4gray/iptvnator/wiki/What-is-mpv-video-player-and-how-to-install-it-on-different-operating-systems%3F'
)
"
>Installation instructions</a
>)
</p>
</div>
}
@if (playerSettings.player === 'vlc') {
<div class="custom-player">
<img
src="./assets/images/iptvnator-1-mascot.png"
alt="external player"
/>
<p>
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.
</p>
</div>
}
<!-- channel overlay -->
<app-info-overlay
[channel]="activeChannel"
[epgProgram]="epgProgram()"
/>
@defer {
<app-sidebar
[channels]="(channels$ | async)!"
[showPlaylistHeader]="!isWorkspaceLayout"
[activeView]="activeView()"
/>
} @loading {
<p>Loading...</p>
} @placeholder {
<p>Loading...</p>
}
</div>
<!-- channel number overlay -->
@if (showChannelNumberOverlay) {
<div class="channel-number-overlay">
<div class="channel-number-display">
{{ channelNumberInput }}
</div>
</div>
<div class="content-container">
@if (activeChannel(); as activeChannel) {
@if (shouldShowInlinePlayer(activeChannel)) {
<div class="video-player">
@if (activeChannel.radio === 'true') {
<app-audio-player
[url]="activeChannel.url"
[icon]="activeChannel?.tvg?.logo!"
/>
} @else {
@if (playerSettings.player === 'videojs') {
<app-vjs-player
[options]="{
sources: [
{
src:
activeChannel.url +
activeChannel.epgParams,
type: 'application/x-mpegURL',
},
],
}"
[volume]="volume"
[class.hide-captions]="!playerSettings.showCaptions"
/>
}
@if (playerSettings.player === 'html5') {
<app-html-video-player
[channel]="activeChannel"
[volume]="volume"
[showCaptions]="playerSettings.showCaptions!"
/>
}
@if (playerSettings.player === 'artplayer') {
<app-art-player
[channel]="activeChannel"
[volume]="volume"
[showCaptions]="playerSettings.showCaptions!"
/>
}
}
}
} @else {
<!-- Minimal toolbar when no channel is active so drawer works -->
<app-toolbar
(toggleLeftDrawerClicked)="drawer.toggle()"
(toggleRightDrawerClicked)="drawerRight.toggle()"
[isLeftDrawerOpened]="drawer.opened"
(settingsClicked)="openSettings()"
/>
<div class="empty-state-container">
<mat-icon class="empty-state-icon">live_tv</mat-icon>
<h2 class="empty-state-title">
{{ 'CHANNELS.SELECT_CHANNEL_PLAYBACK' | translate }}
</h2>
</div>
}
</mat-drawer-content>
<!-- right sidebar content -->
<mat-drawer position="end" #drawerRight mode="side" disableClose>
@if (isDesktop) {
<app-epg-list />
<div class="epg">
<div class="epg-content">
<app-epg-list />
</div>
</div>
@if (showChannelNumberOverlay) {
<div class="channel-number-overlay">
<div class="channel-number-display">
{{ channelNumberInput }}
</div>
</div>
}
</mat-drawer>
</mat-drawer-container>
} @else {
<div class="empty-state-container">
<mat-icon class="empty-state-icon">live_tv</mat-icon>
<h2 class="empty-state-title">
{{ 'CHANNELS.SELECT_CHANNEL_PLAYBACK' | translate }}
</h2>
</div>
}
</div>
@@ -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;
}
}
@@ -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<VideoPlayerComponent>;
let mockStore: MockStore;
const actions$ = new Observable<Actions>();
let component: VideoPlayerComponent;
let headerContext: WorkspaceHeaderContextService;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
const playlistId = signal('playlist-1');
const activeChannel = signal<Channel | null>(null);
const channels = signal<Channel[]>([]);
const currentEpgProgram = signal(null);
const channels$ = new BehaviorSubject<Channel[]>([]);
const activeChannel$ = new BehaviorSubject<Channel | null>(null);
const currentEpgProgram$ = new BehaviorSubject(null);
const player = signal<VideoPlayer>(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<Settings>)
),
},
},
{
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 })
);
});
});
*/
@@ -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(),
});
}
}
+1
View File
@@ -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';
@@ -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<WorkspaceHeaderAction | null>(null);
setAction(action: WorkspaceHeaderAction): void {
this.action.set(action);
}
clearAction(id?: string): void {
if (id && this.action()?.id !== id) {
return;
}
this.action.set(null);
}
}
@@ -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;
}
@@ -116,6 +116,18 @@
>
<mat-icon>star</mat-icon>
</a>
@if (headerShortcut(); as action) {
<button
type="button"
class="header-shortcut"
mat-icon-button
(click)="runHeaderShortcut()"
[matTooltip]="action.tooltipKey | translate"
[attr.aria-label]="action.ariaLabelKey | translate"
>
<mat-icon>{{ action.icon }}</mat-icon>
</button>
}
@if (isElectron) {
<button
type="button"
@@ -7,6 +7,7 @@ import { TranslateService } from '@ngx-translate/core';
import {
FavoritesContextService,
PORTAL_EXTERNAL_PLAYBACK,
WorkspaceHeaderContextService,
} from '@iptvnator/portal/shared/util';
import { of } from 'rxjs';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
@@ -182,4 +183,51 @@ describe('WorkspaceShellComponent action matrix', () => {
);
expect(component.hasContextActions()).toBe(true);
});
it('shows a registered header shortcut only on playlist routes', () => {
const component = TestBed.runInInjectionContext(
() => new WorkspaceShellComponent()
);
const headerContext = TestBed.inject(WorkspaceHeaderContextService);
headerContext.setAction({
id: 'multi-epg',
icon: 'view_list',
tooltipKey: 'TOP_MENU.OPEN_MULTI_EPG',
ariaLabelKey: 'TOP_MENU.OPEN_MULTI_EPG',
run: jest.fn(),
});
component.currentUrl.set('/workspace/playlists/pl-1/all');
expect(component.headerShortcut()).toEqual(
expect.objectContaining({
id: 'multi-epg',
icon: 'view_list',
})
);
component.currentUrl.set('/workspace/xtreams/pl-1/live');
expect(component.headerShortcut()).toBeNull();
});
it('runs the registered header shortcut callback', () => {
const component = TestBed.runInInjectionContext(
() => new WorkspaceShellComponent()
);
const headerContext = TestBed.inject(WorkspaceHeaderContextService);
const run = jest.fn();
headerContext.setAction({
id: 'multi-epg',
icon: 'view_list',
tooltipKey: 'TOP_MENU.OPEN_MULTI_EPG',
ariaLabelKey: 'TOP_MENU.OPEN_MULTI_EPG',
run,
});
component.currentUrl.set('/workspace/playlists/pl-1/all');
component.runHeaderShortcut();
expect(run).toHaveBeenCalledTimes(1);
});
});
@@ -48,6 +48,8 @@ import {
PORTAL_EXTERNAL_PLAYBACK,
PortalRailLink,
PortalRailSection,
WorkspaceHeaderAction,
WorkspaceHeaderContextService,
} from '@iptvnator/portal/shared/util';
import { PortalRailLinksComponent } from '@iptvnator/portal/shared/ui';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
@@ -108,8 +110,8 @@ const SEARCH_INPUT_DEBOUNCE_MS = 350;
WorkspaceFavoritesContextPanelComponent,
WorkspaceSettingsContextPanelComponent,
WorkspaceSourcesFiltersPanelComponent,
PortalRailLinksComponent,
ResizableDirective,
PortalRailLinksComponent,
ResizableDirective,
TranslatePipe,
],
templateUrl: './workspace-shell.component.html',
@@ -128,6 +130,7 @@ export class WorkspaceShellComponent {
private readonly dialog = inject(MatDialog);
readonly favoritesCtx = inject(FavoritesContextService);
readonly settingsCtx = inject(SettingsContextService);
readonly headerContext = inject(WorkspaceHeaderContextService);
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
@@ -397,6 +400,16 @@ export class WorkspaceShellComponent {
this.currentSection() === 'downloads' ||
this.isGlobalDownloadsRoute()
);
readonly headerShortcut = computed<WorkspaceHeaderAction | null>(() => {
const context = this.currentContext();
const action = this.headerContext.action();
if (!action || context?.provider !== 'playlists') {
return null;
}
return action;
});
readonly canOpenPlaylistInfo = computed(() =>
Boolean(this.activePlaylist())
);
@@ -706,6 +719,10 @@ export class WorkspaceShellComponent {
this.router.navigate(['/workspace/downloads']);
}
runHeaderShortcut(): void {
this.headerShortcut()?.run();
}
openGlobalSearch(initialQuery = ''): void {
this.workspaceActions.openGlobalSearch(initialQuery);
}