mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 13:46:16 -08:00
refactor: refactor video player component and enhance workspace header shortcuts
This commit is contained in:
1 parent
367c9507a5
commit
0c451b0dd7
10 files changed
+584
-393
No files matched your search
+81
-149
@@ -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;
|
||||
}
|
||||
}
|
||||
+295
-80
@@ -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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
+48
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user