Proyecto App Windows
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "playlist-m3u-feature-player",
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/playlist/m3u/feature-player/src",
|
||||
"prefix": "lib",
|
||||
"projectType": "library",
|
||||
"tags": ["scope:playlist", "domain:m3u", "type:feature"],
|
||||
"targets": {
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './lib/m3u-collection-route/m3u-collection-route.component';
|
||||
export * from './lib/m3u-workspace-route-session.service';
|
||||
export * from './lib/video-player/video-player.component';
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
inject,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { UnifiedCollectionPageComponent } from '@iptvnator/portal/shared/ui';
|
||||
import {
|
||||
CollectionScope,
|
||||
routeParamSignal,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
|
||||
@Component({
|
||||
selector: 'app-m3u-collection-route',
|
||||
imports: [UnifiedCollectionPageComponent],
|
||||
template: `
|
||||
<app-unified-collection-page
|
||||
[mode]="mode()"
|
||||
[portalType]="portalType()"
|
||||
[playlistId]="playlistId()"
|
||||
[defaultScope]="defaultScope()"
|
||||
/>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class M3uCollectionRouteComponent {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
|
||||
readonly mode = input<'favorites' | 'recent'>('favorites');
|
||||
readonly portalType = input('m3u');
|
||||
readonly defaultScope = input<CollectionScope | undefined>(undefined);
|
||||
readonly playlistId = routeParamSignal<string | undefined>(
|
||||
this.route,
|
||||
'id',
|
||||
(value) => value ?? undefined
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { Subject } from 'rxjs';
|
||||
import { ChannelActions, FavoritesActions } from 'm3u-state';
|
||||
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
|
||||
import { PlaylistsService } from 'services';
|
||||
import { Channel, Playlist } from 'shared-interfaces';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { M3uWorkspaceRouteSession } from './m3u-workspace-route-session.service';
|
||||
|
||||
const PLAYLIST_ID = 'playlist-1';
|
||||
const NEXT_PLAYLIST_ID = 'playlist-2';
|
||||
|
||||
const PRIMARY_CHANNEL = {
|
||||
epgParams: '',
|
||||
http: {
|
||||
origin: '',
|
||||
referrer: '',
|
||||
'user-agent': '',
|
||||
},
|
||||
id: 'channel-1',
|
||||
name: 'Primary channel',
|
||||
radio: 'false',
|
||||
tvg: {
|
||||
id: 'primary-channel',
|
||||
logo: '',
|
||||
name: 'Primary channel',
|
||||
rec: '',
|
||||
url: '',
|
||||
},
|
||||
url: 'https://example.com/primary.m3u8',
|
||||
} as Channel;
|
||||
|
||||
const NEXT_CHANNEL = {
|
||||
...PRIMARY_CHANNEL,
|
||||
id: 'channel-2',
|
||||
name: 'Next channel',
|
||||
tvg: {
|
||||
...PRIMARY_CHANNEL.tvg,
|
||||
id: 'next-channel',
|
||||
name: 'Next channel',
|
||||
},
|
||||
url: 'https://example.com/next.m3u8',
|
||||
} as Channel;
|
||||
|
||||
async function flushEffects(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
function getM3uRouteContext(url: string): {
|
||||
inWorkspace: boolean;
|
||||
playlistId: string | null;
|
||||
provider: 'playlists' | null;
|
||||
section: 'all' | 'favorites' | 'groups' | 'recent' | null;
|
||||
} {
|
||||
const match = url.match(
|
||||
/^\/workspace\/playlists\/([^/]+)\/([^/?]+)(?:\/|$)/
|
||||
);
|
||||
|
||||
return {
|
||||
inWorkspace: true,
|
||||
playlistId: match?.[1] ?? null,
|
||||
provider: match ? 'playlists' : null,
|
||||
section:
|
||||
(match?.[2] as 'all' | 'favorites' | 'groups' | 'recent' | null) ??
|
||||
null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('M3uWorkspaceRouteSession', () => {
|
||||
const routerEvents = new Subject<NavigationEnd>();
|
||||
const playlistContext = {
|
||||
syncFromUrl: jest.fn(),
|
||||
};
|
||||
const playlistsService = {
|
||||
getPlaylist: jest.fn(),
|
||||
};
|
||||
const store = {
|
||||
dispatch: jest.fn(),
|
||||
};
|
||||
const router = {
|
||||
url: `/workspace/playlists/${PLAYLIST_ID}/all`,
|
||||
events: routerEvents.asObservable(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
router.url = `/workspace/playlists/${PLAYLIST_ID}/all`;
|
||||
playlistContext.syncFromUrl.mockImplementation((url: string) =>
|
||||
getM3uRouteContext(url)
|
||||
);
|
||||
playlistsService.getPlaylist.mockReset();
|
||||
store.dispatch.mockClear();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
providers: [
|
||||
M3uWorkspaceRouteSession,
|
||||
{
|
||||
provide: PlaylistContextFacade,
|
||||
useValue: playlistContext,
|
||||
},
|
||||
{
|
||||
provide: PlaylistsService,
|
||||
useValue: playlistsService,
|
||||
},
|
||||
{
|
||||
provide: Router,
|
||||
useValue: router,
|
||||
},
|
||||
{
|
||||
provide: Store,
|
||||
useValue: store,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('does not start channel loading for collection routes like favorites', async () => {
|
||||
router.url = `/workspace/playlists/${PLAYLIST_ID}/favorites`;
|
||||
|
||||
TestBed.inject(M3uWorkspaceRouteSession);
|
||||
await flushEffects();
|
||||
|
||||
expect(store.dispatch).toHaveBeenCalledWith(
|
||||
ChannelActions.resetActiveChannel()
|
||||
);
|
||||
expect(store.dispatch).toHaveBeenCalledWith(
|
||||
ChannelActions.setChannelsLoading({ loading: false })
|
||||
);
|
||||
expect(playlistsService.getPlaylist).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores stale playlist responses after a newer route request wins', async () => {
|
||||
const firstResponse = new Subject<Playlist>();
|
||||
const secondResponse = new Subject<Playlist>();
|
||||
|
||||
playlistsService.getPlaylist.mockImplementation((playlistId: string) => {
|
||||
return playlistId === PLAYLIST_ID
|
||||
? firstResponse.asObservable()
|
||||
: secondResponse.asObservable();
|
||||
});
|
||||
|
||||
TestBed.inject(M3uWorkspaceRouteSession);
|
||||
await flushEffects();
|
||||
|
||||
router.url = `/workspace/playlists/${NEXT_PLAYLIST_ID}/all`;
|
||||
routerEvents.next(new NavigationEnd(1, router.url, router.url));
|
||||
await flushEffects();
|
||||
|
||||
secondResponse.next({
|
||||
favorites: [NEXT_CHANNEL.url],
|
||||
playlist: {
|
||||
items: [NEXT_CHANNEL],
|
||||
},
|
||||
} as Playlist);
|
||||
secondResponse.complete();
|
||||
await flushEffects();
|
||||
|
||||
firstResponse.next({
|
||||
favorites: [PRIMARY_CHANNEL.url],
|
||||
playlist: {
|
||||
items: [PRIMARY_CHANNEL],
|
||||
},
|
||||
} as Playlist);
|
||||
firstResponse.complete();
|
||||
await flushEffects();
|
||||
|
||||
const setChannelsCalls = store.dispatch.mock.calls.filter(
|
||||
([action]) => action.type === ChannelActions.setChannels.type
|
||||
);
|
||||
|
||||
expect(setChannelsCalls).toEqual([
|
||||
[ChannelActions.setChannels({ channels: [NEXT_CHANNEL] })],
|
||||
]);
|
||||
expect(store.dispatch).toHaveBeenCalledWith(
|
||||
FavoritesActions.setFavorites({
|
||||
channelIds: [NEXT_CHANNEL.url],
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import {
|
||||
DestroyRef,
|
||||
ENVIRONMENT_INITIALIZER,
|
||||
inject,
|
||||
Injectable,
|
||||
Provider,
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { ChannelActions, FavoritesActions } from 'm3u-state';
|
||||
import { filter, firstValueFrom } from 'rxjs';
|
||||
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
|
||||
import { PlaylistsService } from 'services';
|
||||
|
||||
type M3uLoadedSection = 'all' | 'groups';
|
||||
|
||||
@Injectable()
|
||||
export class M3uWorkspaceRouteSession {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly playlistContext = inject(PlaylistContextFacade);
|
||||
private readonly playlistsService = inject(PlaylistsService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly store = inject(Store);
|
||||
|
||||
private currentPlaylistId: string | null = null;
|
||||
private currentSection: string | null = null;
|
||||
private loadRequestId = 0;
|
||||
|
||||
constructor() {
|
||||
this.router.events
|
||||
.pipe(
|
||||
filter(
|
||||
(event): event is NavigationEnd =>
|
||||
event instanceof NavigationEnd
|
||||
),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
)
|
||||
.subscribe(() => {
|
||||
void this.syncRouteContext();
|
||||
});
|
||||
|
||||
void this.syncRouteContext();
|
||||
}
|
||||
|
||||
private async syncRouteContext(): Promise<void> {
|
||||
const routeContext = this.playlistContext.syncFromUrl(this.router.url);
|
||||
const playlistId =
|
||||
routeContext.provider === 'playlists'
|
||||
? routeContext.playlistId
|
||||
: null;
|
||||
const section =
|
||||
routeContext.provider === 'playlists' ? routeContext.section : null;
|
||||
const previousSection = this.currentSection;
|
||||
this.currentSection = section;
|
||||
const playlistChanged = playlistId !== this.currentPlaylistId;
|
||||
const shouldLoadPlaylist = this.isLoadedSection(section);
|
||||
const enteringLoadedSection =
|
||||
shouldLoadPlaylist && !this.isLoadedSection(previousSection);
|
||||
|
||||
if (!playlistId) {
|
||||
this.currentPlaylistId = null;
|
||||
this.loadRequestId += 1;
|
||||
this.store.dispatch(
|
||||
ChannelActions.setChannelsLoading({ loading: false })
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (playlistChanged) {
|
||||
this.currentPlaylistId = playlistId;
|
||||
this.store.dispatch(ChannelActions.resetActiveChannel());
|
||||
}
|
||||
|
||||
if (!shouldLoadPlaylist) {
|
||||
this.loadRequestId += 1;
|
||||
this.store.dispatch(
|
||||
ChannelActions.setChannelsLoading({ loading: false })
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!playlistChanged && !enteringLoadedSection) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = ++this.loadRequestId;
|
||||
this.store.dispatch(ChannelActions.setChannelsLoading({ loading: true }));
|
||||
|
||||
try {
|
||||
const playlist = await firstValueFrom(
|
||||
this.playlistsService.getPlaylist(playlistId)
|
||||
);
|
||||
|
||||
if (!this.isCurrentLoadRequest(requestId, playlistId, section)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (playlist.userAgent) {
|
||||
window.electron?.setUserAgent(playlist.userAgent, 'localhost');
|
||||
}
|
||||
|
||||
this.store.dispatch(
|
||||
ChannelActions.setChannels({
|
||||
channels: playlist.playlist?.items ?? [],
|
||||
})
|
||||
);
|
||||
|
||||
const favorites = (playlist.favorites ?? []).filter(
|
||||
(favorite): favorite is string => typeof favorite === 'string'
|
||||
);
|
||||
this.store.dispatch(
|
||||
FavoritesActions.setFavorites({
|
||||
channelIds: favorites,
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
if (!this.isCurrentLoadRequest(requestId, playlistId, section)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.store.dispatch(ChannelActions.setChannels({ channels: [] }));
|
||||
this.store.dispatch(
|
||||
FavoritesActions.setFavorites({ channelIds: [] })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private isLoadedSection(section: string | null): section is M3uLoadedSection {
|
||||
return section === 'all' || section === 'groups';
|
||||
}
|
||||
|
||||
private isCurrentLoadRequest(
|
||||
requestId: number,
|
||||
playlistId: string,
|
||||
section: M3uLoadedSection
|
||||
): boolean {
|
||||
return (
|
||||
requestId === this.loadRequestId &&
|
||||
this.currentPlaylistId === playlistId &&
|
||||
this.currentSection === section
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function provideM3uWorkspaceRouteSession(): Provider[] {
|
||||
return [
|
||||
M3uWorkspaceRouteSession,
|
||||
{
|
||||
provide: ENVIRONMENT_INITIALIZER,
|
||||
multi: true,
|
||||
useValue: () => {
|
||||
inject(M3uWorkspaceRouteSession);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<div
|
||||
class="sidebar"
|
||||
appResizable
|
||||
[minWidth]="sidebarMinWidth"
|
||||
[maxWidth]="sidebarMaxWidth"
|
||||
[defaultWidth]="sidebarWidth()"
|
||||
[style.width.px]="sidebarWidth()"
|
||||
[storageKey]="sidebarStorageKey()"
|
||||
(widthChange)="onSidebarWidthChange($event)"
|
||||
(resizeEnd)="onSidebarResizeEnd($event)"
|
||||
>
|
||||
@defer {
|
||||
<app-sidebar
|
||||
[channels]="(channels$ | async)!"
|
||||
[channelsLoading]="channelsLoading()"
|
||||
[showPlaylistHeader]="!isWorkspaceLayout"
|
||||
[activeView]="activeView()"
|
||||
[sidebarWidth]="sidebarWidth()"
|
||||
(sidebarWidthRequested)="onGroupedSidebarWidthRequested($event)"
|
||||
(sidebarWidthRequestEnded)="onGroupedSidebarWidthRequestEnded($event)"
|
||||
/>
|
||||
} @loading {
|
||||
<app-channel-list-loading-state [view]="activeView()" />
|
||||
} @placeholder {
|
||||
<app-channel-list-loading-state [view]="activeView()" />
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="content-container">
|
||||
@if (activeChannel(); as activeChannel) {
|
||||
@let playbackTarget = playbackChannel();
|
||||
@if (activeChannel.radio === 'true' || shouldShowInlinePlayer(activeChannel)) {
|
||||
<div class="video-player">
|
||||
@if (activeChannel.radio === 'true') {
|
||||
<app-audio-player
|
||||
[url]="activeChannel.url"
|
||||
[icon]="activeChannel?.tvg?.logo!"
|
||||
[channelName]="activeChannel.name || activeChannel?.tvg?.name || ''"
|
||||
/>
|
||||
} @else {
|
||||
@if (playerSettings.player === 'videojs') {
|
||||
<app-vjs-player
|
||||
[options]="{
|
||||
sources: [
|
||||
{
|
||||
src:
|
||||
playbackTarget?.url +
|
||||
playbackTarget?.epgParams,
|
||||
type: 'application/x-mpegURL',
|
||||
},
|
||||
],
|
||||
}"
|
||||
[volume]="volume"
|
||||
[class.hide-captions]="!playerSettings.showCaptions"
|
||||
/>
|
||||
}
|
||||
@if (playerSettings.player === 'html5') {
|
||||
<app-html-video-player
|
||||
[channel]="playbackTarget!"
|
||||
[volume]="volume"
|
||||
[showCaptions]="playerSettings.showCaptions!"
|
||||
/>
|
||||
}
|
||||
@if (playerSettings.player === 'artplayer') {
|
||||
<app-art-player
|
||||
[channel]="playbackTarget!"
|
||||
[volume]="volume"
|
||||
[showCaptions]="playerSettings.showCaptions!"
|
||||
/>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (activeChannel.radio !== 'true') {
|
||||
<div class="epg">
|
||||
<div class="epg-content">
|
||||
<app-epg-list
|
||||
[archivePlaybackAvailable]="archivePlaybackAvailable()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showChannelNumberOverlay) {
|
||||
<div class="channel-number-overlay">
|
||||
<div class="channel-number-display">
|
||||
{{ channelNumberInput }}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<app-portal-empty-state
|
||||
icon="live_tv"
|
||||
[message]="'CHANNELS.SELECT_CHANNEL_PLAYBACK' | translate"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,112 @@
|
||||
@use '../../../../../../ui/styles/portal-layout' as portal;
|
||||
|
||||
@include portal.live-layout;
|
||||
|
||||
:host {
|
||||
min-height: 0;
|
||||
background: var(--mat-sys-surface);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
> app-channel-list-loading-state {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
app-sidebar {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.content-container {
|
||||
min-width: 0;
|
||||
background: var(--mat-sys-surface);
|
||||
}
|
||||
|
||||
.video-player {
|
||||
background: #000;
|
||||
|
||||
&:has(> app-audio-player) {
|
||||
background: var(--mat-sys-surface);
|
||||
}
|
||||
|
||||
> app-audio-player,
|
||||
> app-vjs-player,
|
||||
> app-html-video-player,
|
||||
> app-art-player {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.epg {
|
||||
border-top: 1px solid var(--mat-sys-outline-variant);
|
||||
background: var(--mat-sys-surface);
|
||||
}
|
||||
|
||||
.epg-content {
|
||||
> app-epg-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.channel-number-overlay {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.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;
|
||||
letter-spacing: 0.05em;
|
||||
text-align: center;
|
||||
animation: scaleIn 0.15s ease-out;
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
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,
|
||||
selectActivePlaybackUrl,
|
||||
selectChannels,
|
||||
selectChannelsLoading,
|
||||
selectCurrentEpgProgram,
|
||||
} from 'm3u-state';
|
||||
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
|
||||
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';
|
||||
|
||||
describe('VideoPlayerComponent', () => {
|
||||
let fixture: ComponentFixture<VideoPlayerComponent>;
|
||||
let component: VideoPlayerComponent;
|
||||
let headerContext: WorkspaceHeaderContextService;
|
||||
|
||||
const playlistId = signal('playlist-1');
|
||||
const activeChannel = signal<Channel | null>(null);
|
||||
const activePlaybackUrl = signal<string | null>(null);
|
||||
const channels = signal<Channel[]>([]);
|
||||
const channelsLoading = signal(false);
|
||||
const currentEpgProgram = signal(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 selectActivePlaybackUrl:
|
||||
return activePlaybackUrl;
|
||||
case selectChannels:
|
||||
return channels;
|
||||
case selectChannelsLoading:
|
||||
return channelsLoading;
|
||||
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);
|
||||
localStorage.removeItem('m3u-sidebar-width');
|
||||
player.set(VideoPlayer.VideoJs);
|
||||
showCaptions.set(false);
|
||||
activePlaybackUrl.set(null);
|
||||
channelsLoading.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: ActivatedRoute,
|
||||
useValue: {
|
||||
params: of({ id: playlistId(), view: 'all' }),
|
||||
queryParams: of({}),
|
||||
snapshot: {
|
||||
data: { layout: 'workspace' },
|
||||
queryParams: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: Router,
|
||||
useValue: routerMock,
|
||||
},
|
||||
{
|
||||
provide: Store,
|
||||
useValue: storeMock,
|
||||
},
|
||||
{
|
||||
provide: Overlay,
|
||||
useValue: overlayMock,
|
||||
},
|
||||
{
|
||||
provide: DataService,
|
||||
useValue: {
|
||||
sendIpcEvent: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PlaylistsService,
|
||||
useValue: playlistsServiceMock,
|
||||
},
|
||||
{
|
||||
provide: PlaylistContextFacade,
|
||||
useValue: {
|
||||
resolvedPlaylistId: playlistId,
|
||||
},
|
||||
},
|
||||
{
|
||||
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),
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideComponent(VideoPlayerComponent, {
|
||||
set: {
|
||||
imports: [
|
||||
AsyncPipe,
|
||||
MockPipe(
|
||||
TranslatePipe,
|
||||
(value: string | null | undefined) => value ?? ''
|
||||
),
|
||||
],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(VideoPlayerComponent);
|
||||
component = fixture.componentInstance;
|
||||
headerContext = TestBed.inject(WorkspaceHeaderContextService);
|
||||
});
|
||||
|
||||
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('uses the active playback override url when archive playback is active', () => {
|
||||
syncStoreState(sampleChannel);
|
||||
player.set(VideoPlayer.VideoJs);
|
||||
activePlaybackUrl.set(
|
||||
'http://localhost/archive.m3u8?utc=123&lutc=456'
|
||||
);
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.playbackChannel()?.url).toBe(
|
||||
'http://localhost/archive.m3u8?utc=123&lutc=456'
|
||||
);
|
||||
|
||||
activePlaybackUrl.set(null);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.playbackChannel()?.url).toBe(sampleChannel.url);
|
||||
});
|
||||
|
||||
it('updates the outer sidebar width while grouped view requests a larger total width', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
component.onGroupedSidebarWidthRequested(540);
|
||||
fixture.detectChanges();
|
||||
|
||||
const sidebar = fixture.nativeElement.querySelector(
|
||||
'.sidebar'
|
||||
) as HTMLElement | null;
|
||||
|
||||
expect(sidebar?.style.width).toBe('540px');
|
||||
});
|
||||
|
||||
it('uses the single-pane sidebar key for all-channel view instead of the groups total key', () => {
|
||||
fixture.destroy();
|
||||
|
||||
localStorage.setItem('m3u-sidebar-width', '320');
|
||||
localStorage.setItem('m3u-groups-sidebar-width', '560');
|
||||
|
||||
fixture = TestBed.createComponent(VideoPlayerComponent);
|
||||
component = fixture.componentInstance;
|
||||
headerContext = TestBed.inject(WorkspaceHeaderContextService);
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
const sidebar = fixture.nativeElement.querySelector(
|
||||
'.sidebar'
|
||||
) as HTMLElement | null;
|
||||
|
||||
expect(sidebar?.style.width).toBe('320px');
|
||||
});
|
||||
|
||||
it('tracks manual sidebar resize and persists the committed width', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
component.onSidebarWidthChange(420);
|
||||
fixture.detectChanges();
|
||||
|
||||
const sidebar = fixture.nativeElement.querySelector(
|
||||
'.sidebar'
|
||||
) as HTMLElement | null;
|
||||
|
||||
expect(sidebar?.style.width).toBe('420px');
|
||||
|
||||
component.onSidebarResizeEnd(420);
|
||||
|
||||
expect(localStorage.getItem('m3u-sidebar-width')).toBe('420');
|
||||
});
|
||||
|
||||
it('clamps and persists grouped-view sidebar width requests on resize end', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
component.onGroupedSidebarWidthRequestEnded(640);
|
||||
fixture.detectChanges();
|
||||
|
||||
const sidebar = fixture.nativeElement.querySelector(
|
||||
'.sidebar'
|
||||
) as HTMLElement | null;
|
||||
|
||||
expect(sidebar?.style.width).toBe('600px');
|
||||
expect(localStorage.getItem('m3u-sidebar-width')).toBe('600');
|
||||
});
|
||||
|
||||
it('renders the shared empty state when no channel is active', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
const emptyState = fixture.nativeElement.querySelector(
|
||||
'app-portal-empty-state'
|
||||
) 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 })
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,771 @@
|
||||
import { Overlay, OverlayRef } from '@angular/cdk/overlay';
|
||||
import { ComponentPortal } from '@angular/cdk/portal';
|
||||
import { AsyncPipe, CommonModule } from '@angular/common';
|
||||
import {
|
||||
Component,
|
||||
HostListener,
|
||||
Injector,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
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 { ResizableDirective } from 'components';
|
||||
import { isM3uCatchupPlaybackSupported } from 'm3u-utils';
|
||||
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
|
||||
import {
|
||||
COMPONENT_OVERLAY_REF,
|
||||
EpgListComponent,
|
||||
MultiEpgContainerComponent,
|
||||
} from '@iptvnator/ui/epg';
|
||||
import {
|
||||
ChannelActions,
|
||||
PlaylistActions,
|
||||
selectActive,
|
||||
selectActivePlaybackUrl,
|
||||
selectChannels,
|
||||
selectChannelsLoading,
|
||||
selectCurrentEpgProgram,
|
||||
} from 'm3u-state';
|
||||
import {
|
||||
firstValueFrom,
|
||||
Observable,
|
||||
Subscription,
|
||||
combineLatest,
|
||||
combineLatestWith,
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
map,
|
||||
startWith,
|
||||
switchMap,
|
||||
take,
|
||||
} from 'rxjs';
|
||||
import {
|
||||
getAdjacentChannelItem,
|
||||
getChannelItemByNumber,
|
||||
isWorkspaceLayoutRoute,
|
||||
PORTAL_EXTERNAL_PLAYBACK,
|
||||
WorkspaceHeaderContextService,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { PortalEmptyStateComponent } from '@iptvnator/portal/shared/ui';
|
||||
import {
|
||||
ArtPlayerComponent,
|
||||
AudioPlayerComponent,
|
||||
HtmlVideoPlayerComponent,
|
||||
SidebarComponent,
|
||||
VjsPlayerComponent,
|
||||
} from '@iptvnator/ui/playback';
|
||||
import { ChannelListLoadingStateComponent } from 'components';
|
||||
import { DataService, PlaylistsService, SettingsStore } from 'services';
|
||||
import {
|
||||
Channel,
|
||||
EpgProgram,
|
||||
ExternalPlayerSession,
|
||||
PLAYLIST_PARSE_BY_URL,
|
||||
M3uRecentlyViewedItem,
|
||||
PlaylistMeta,
|
||||
STORE_KEY,
|
||||
Settings,
|
||||
VideoPlayer,
|
||||
} from 'shared-interfaces';
|
||||
|
||||
const M3U_MULTI_EPG_HEADER_ACTION_ID = 'm3u-multi-epg';
|
||||
const M3U_SIDEBAR_STORAGE_KEY = 'm3u-sidebar-width';
|
||||
const M3U_GROUPS_SIDEBAR_STORAGE_KEY = 'm3u-groups-sidebar-width';
|
||||
const M3U_SIDEBAR_MIN_WIDTH = 200;
|
||||
const M3U_SIDEBAR_MAX_WIDTH = 600;
|
||||
const M3U_SIDEBAR_DEFAULT_WIDTH = 460;
|
||||
|
||||
@Component({
|
||||
selector: 'app-video-player',
|
||||
imports: [
|
||||
ArtPlayerComponent,
|
||||
AsyncPipe,
|
||||
AudioPlayerComponent,
|
||||
ChannelListLoadingStateComponent,
|
||||
CommonModule,
|
||||
EpgListComponent,
|
||||
HtmlVideoPlayerComponent,
|
||||
PortalEmptyStateComponent,
|
||||
ResizableDirective,
|
||||
SidebarComponent,
|
||||
TranslatePipe,
|
||||
VjsPlayerComponent,
|
||||
],
|
||||
templateUrl: './video-player.component.html',
|
||||
styleUrl: './video-player.component.scss',
|
||||
})
|
||||
export class VideoPlayerComponent implements OnInit, OnDestroy {
|
||||
private readonly activatedRoute = inject(ActivatedRoute);
|
||||
private readonly dataService = inject(DataService);
|
||||
private readonly overlay = inject(Overlay);
|
||||
private readonly playlistsService = inject(PlaylistsService);
|
||||
private readonly playlistContext = inject(PlaylistContextFacade);
|
||||
private readonly router = inject(Router);
|
||||
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);
|
||||
readonly activePlaybackUrl = this.store.selectSignal(
|
||||
selectActivePlaybackUrl
|
||||
);
|
||||
readonly activePlaylistId = this.playlistContext.resolvedPlaylistId;
|
||||
readonly channels = this.store.selectSignal(selectChannels);
|
||||
readonly channelsLoading = this.store.selectSignal(selectChannelsLoading);
|
||||
readonly archivePlaybackAvailable = computed(() =>
|
||||
isM3uCatchupPlaybackSupported(this.activeChannel())
|
||||
);
|
||||
readonly playbackChannel = computed<Channel | null>(() => {
|
||||
const activeChannel = this.activeChannel();
|
||||
if (!activeChannel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const playbackUrl = this.activePlaybackUrl();
|
||||
if (!playbackUrl) {
|
||||
return activeChannel;
|
||||
}
|
||||
|
||||
return {
|
||||
...activeChannel,
|
||||
url: playbackUrl,
|
||||
epgParams: '',
|
||||
} as Channel;
|
||||
});
|
||||
readonly sidebarStorageKey = computed(() =>
|
||||
this.activeView() === 'groups'
|
||||
? M3U_GROUPS_SIDEBAR_STORAGE_KEY
|
||||
: M3U_SIDEBAR_STORAGE_KEY
|
||||
);
|
||||
readonly sidebarWidth = signal(M3U_SIDEBAR_DEFAULT_WIDTH);
|
||||
readonly sidebarMinWidth = M3U_SIDEBAR_MIN_WIDTH;
|
||||
readonly sidebarMaxWidth = M3U_SIDEBAR_MAX_WIDTH;
|
||||
|
||||
/** Channels list */
|
||||
readonly channels$: Observable<Channel[]> = this.store.select(
|
||||
selectChannels
|
||||
) as Observable<Channel[]>;
|
||||
|
||||
/** Current epg program */
|
||||
readonly epgProgram = this.store.selectSignal(selectCurrentEpgProgram);
|
||||
|
||||
/** Active M3U view (all, groups, favorites, recent) */
|
||||
readonly activeView = toSignal(
|
||||
this.activatedRoute.params.pipe(
|
||||
map((params) => params['view'] || 'all')
|
||||
),
|
||||
{ initialValue: 'all' }
|
||||
);
|
||||
|
||||
/** Selected video player options */
|
||||
playerSettings: Partial<Settings> = {
|
||||
player: VideoPlayer.VideoJs,
|
||||
showCaptions: false,
|
||||
};
|
||||
|
||||
readonly isDesktop = !!window['electron'];
|
||||
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.activatedRoute);
|
||||
|
||||
/** EPG overlay reference */
|
||||
private overlayRef!: OverlayRef;
|
||||
private unsubscribeRemoteChannelChange?: () => void;
|
||||
private unsubscribeRemoteCommand?: () => void;
|
||||
private statusSubscription?: Subscription;
|
||||
private lastKnownVolume = 1;
|
||||
private lastRecordedRecentKey = '';
|
||||
private lastExternalSessionStateKey = this.getExternalSessionStateKey(
|
||||
this.externalPlayback.activeSession()
|
||||
);
|
||||
|
||||
/** Channel number input state */
|
||||
channelNumberInput = '';
|
||||
showChannelNumberOverlay = false;
|
||||
private channelNumberTimeout?: number;
|
||||
|
||||
volume = 1;
|
||||
|
||||
constructor() {
|
||||
// Initialize volume from localStorage in constructor
|
||||
const savedVolume = localStorage.getItem('volume');
|
||||
if (savedVolume !== null) {
|
||||
this.volume = Number(savedVolume);
|
||||
}
|
||||
|
||||
// React to settings changes
|
||||
effect(() => {
|
||||
this.playerSettings = {
|
||||
player: this.settingsStore.player(),
|
||||
showCaptions: this.settingsStore.showCaptions(),
|
||||
};
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
this.sidebarWidth.set(
|
||||
this.loadSidebarWidth(this.sidebarStorageKey())
|
||||
);
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const playlistId = this.activePlaylistId();
|
||||
const activeChannel = this.activeChannel();
|
||||
|
||||
if (!playlistId || !activeChannel?.url) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextKey = `${playlistId}::${activeChannel.url}`;
|
||||
if (this.lastRecordedRecentKey === nextKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastRecordedRecentKey = nextKey;
|
||||
void this.persistRecentlyViewedChannel(playlistId, activeChannel);
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const currentView = this.activeView();
|
||||
const channels = this.channels();
|
||||
const activeChannel = this.activeChannel();
|
||||
const state =
|
||||
this.router.currentNavigation()?.extras?.state ??
|
||||
window.history.state;
|
||||
const targetUrl =
|
||||
typeof state?.openRecentChannelUrl === 'string'
|
||||
? state.openRecentChannelUrl.trim()
|
||||
: '';
|
||||
|
||||
if (
|
||||
currentView !== 'recent' ||
|
||||
!targetUrl ||
|
||||
channels.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeChannel?.url === targetUrl) {
|
||||
this.clearConsumedRecentChannelState();
|
||||
return;
|
||||
}
|
||||
|
||||
const matchedChannel = channels.find(
|
||||
(channel) => channel.url === targetUrl
|
||||
);
|
||||
if (!matchedChannel) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.store.dispatch(
|
||||
ChannelActions.setActiveChannel({ channel: matchedChannel })
|
||||
);
|
||||
this.clearConsumedRecentChannelState();
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const player = this.settingsStore.player();
|
||||
const session = this.externalPlayback.activeSession();
|
||||
const activeChannel = this.activeChannel();
|
||||
const sessionStateKey = this.getExternalSessionStateKey(session);
|
||||
|
||||
if (sessionStateKey === this.lastExternalSessionStateKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastExternalSessionStateKey = sessionStateKey;
|
||||
|
||||
if (
|
||||
!activeChannel ||
|
||||
!this.isExternalPlayer(player) ||
|
||||
!this.isTerminalExternalSession(session)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.store.dispatch(ChannelActions.resetActiveChannel());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets video player and subscribes to channel list from the store
|
||||
*/
|
||||
ngOnInit(): void {
|
||||
this.applySettings();
|
||||
this.getPlaylistUrlAsParam();
|
||||
this.registerHeaderShortcut();
|
||||
|
||||
// Setup remote control channel change listener (Electron only)
|
||||
if (this.isDesktop && window.electron?.onChannelChange) {
|
||||
const unsubscribe = window.electron.onChannelChange(
|
||||
(data: { direction: 'up' | 'down' }) => {
|
||||
this.handleRemoteChannelChange(data.direction);
|
||||
}
|
||||
);
|
||||
if (typeof unsubscribe === 'function') {
|
||||
this.unsubscribeRemoteChannelChange = unsubscribe;
|
||||
}
|
||||
}
|
||||
if (this.isDesktop && window.electron?.onRemoteControlCommand) {
|
||||
const unsubscribe = window.electron.onRemoteControlCommand(
|
||||
(command) => {
|
||||
this.handleRemoteControlCommand(command);
|
||||
}
|
||||
);
|
||||
if (typeof unsubscribe === 'function') {
|
||||
this.unsubscribeRemoteCommand = unsubscribe;
|
||||
}
|
||||
}
|
||||
|
||||
this.statusSubscription = combineLatest([
|
||||
this.channels$,
|
||||
this.store.select(selectActive),
|
||||
this.store.select(selectCurrentEpgProgram).pipe(startWith(null)),
|
||||
]).subscribe(([channels, activeChannel, epgProgram]) => {
|
||||
if (!window.electron?.updateRemoteControlStatus || !activeChannel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentEpgProgram = epgProgram as
|
||||
| EpgProgram
|
||||
| null
|
||||
| undefined;
|
||||
const currentIndex = channels.findIndex(
|
||||
(channel) => channel.url === activeChannel.url
|
||||
);
|
||||
|
||||
window.electron.updateRemoteControlStatus({
|
||||
portal: 'm3u',
|
||||
isLiveView: true,
|
||||
channelName: activeChannel.name ?? activeChannel.tvg?.name,
|
||||
channelNumber: currentIndex >= 0 ? currentIndex + 1 : undefined,
|
||||
epgTitle: currentEpgProgram?.title,
|
||||
epgStart: currentEpgProgram?.start,
|
||||
epgEnd: currentEpgProgram?.stop,
|
||||
supportsVolume: true,
|
||||
volume: this.volume,
|
||||
muted: this.volume === 0,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle remote control channel change
|
||||
*/
|
||||
handleRemoteChannelChange(direction: 'up' | 'down'): void {
|
||||
console.log(`Remote control: changing channel ${direction}`);
|
||||
|
||||
// Use combineLatest to get both values and take only the first emission
|
||||
combineLatest([this.channels$, this.store.select(selectActive)])
|
||||
.pipe(
|
||||
filter(([channels, activeChannel]) => {
|
||||
return channels.length > 0 && !!activeChannel;
|
||||
}),
|
||||
take(1),
|
||||
map(([channels, activeChannel]) => {
|
||||
return {
|
||||
channels,
|
||||
activeChannel: activeChannel as Channel,
|
||||
};
|
||||
})
|
||||
)
|
||||
.subscribe({
|
||||
next: ({ channels, activeChannel }) => {
|
||||
const nextChannel = getAdjacentChannelItem(
|
||||
channels,
|
||||
activeChannel.url,
|
||||
direction,
|
||||
(channel) => channel.url
|
||||
);
|
||||
|
||||
if (!nextChannel) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.store.dispatch(
|
||||
ChannelActions.setActiveChannel({
|
||||
channel: nextChannel,
|
||||
})
|
||||
);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Error changing channel:', err);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.workspaceHeaderContext.clearAction(M3U_MULTI_EPG_HEADER_ACTION_ID);
|
||||
this.unsubscribeRemoteChannelChange?.();
|
||||
this.unsubscribeRemoteCommand?.();
|
||||
this.statusSubscription?.unsubscribe();
|
||||
}
|
||||
|
||||
onSidebarWidthChange(width: number): void {
|
||||
this.sidebarWidth.set(this.clampSidebarWidth(width));
|
||||
}
|
||||
|
||||
onSidebarResizeEnd(width: number): void {
|
||||
this.persistSidebarWidth(this.sidebarStorageKey(), width);
|
||||
}
|
||||
|
||||
onGroupedSidebarWidthRequested(width: number): void {
|
||||
this.sidebarWidth.set(this.clampSidebarWidth(width));
|
||||
}
|
||||
|
||||
onGroupedSidebarWidthRequestEnded(width: number): void {
|
||||
this.persistSidebarWidth(this.sidebarStorageKey(), width);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a playlist provided as a url param
|
||||
* e.g. iptvnat.or?url=http://...
|
||||
* @pwaOnly
|
||||
*/
|
||||
getPlaylistUrlAsParam() {
|
||||
const URL_REGEX = /^(http|https|file):\/\/[^ "]+$/;
|
||||
const playlistUrl = this.activatedRoute.snapshot.queryParams['url'];
|
||||
|
||||
if (playlistUrl && playlistUrl.match(URL_REGEX)) {
|
||||
this.dataService.sendIpcEvent(PLAYLIST_PARSE_BY_URL, {
|
||||
url: playlistUrl,
|
||||
isTemporary: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the app configuration from the browsers storage and applies the settings in the current component
|
||||
*/
|
||||
applySettings(): void {
|
||||
this.storage.get(STORE_KEY.Settings).subscribe((settings: unknown) => {
|
||||
if (settings && Object.keys(settings as Settings).length > 0) {
|
||||
this.playerSettings = {
|
||||
player:
|
||||
(settings as Settings).player || VideoPlayer.VideoJs,
|
||||
showCaptions: (settings as Settings).showCaptions || false,
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private loadSidebarWidth(storageKey: string): number {
|
||||
const fallbackKey =
|
||||
storageKey === M3U_GROUPS_SIDEBAR_STORAGE_KEY
|
||||
? M3U_SIDEBAR_STORAGE_KEY
|
||||
: '';
|
||||
const storedWidth = Number.parseInt(
|
||||
localStorage.getItem(storageKey) ??
|
||||
(fallbackKey ? localStorage.getItem(fallbackKey) : '') ??
|
||||
'',
|
||||
10
|
||||
);
|
||||
|
||||
return this.clampSidebarWidth(
|
||||
Number.isNaN(storedWidth)
|
||||
? M3U_SIDEBAR_DEFAULT_WIDTH
|
||||
: storedWidth
|
||||
);
|
||||
}
|
||||
|
||||
private persistSidebarWidth(storageKey: string, width: number): void {
|
||||
const clampedWidth = this.clampSidebarWidth(width);
|
||||
this.sidebarWidth.set(clampedWidth);
|
||||
localStorage.setItem(storageKey, clampedWidth.toString());
|
||||
}
|
||||
|
||||
private clampSidebarWidth(width: number): number {
|
||||
return Math.max(
|
||||
M3U_SIDEBAR_MIN_WIDTH,
|
||||
Math.min(M3U_SIDEBAR_MAX_WIDTH, width)
|
||||
);
|
||||
}
|
||||
|
||||
private async persistRecentlyViewedChannel(
|
||||
playlistId: string,
|
||||
channel: Channel
|
||||
): Promise<void> {
|
||||
const recentlyViewedItem: M3uRecentlyViewedItem = {
|
||||
source: 'm3u',
|
||||
id: channel.url,
|
||||
url: channel.url,
|
||||
title: channel.name?.trim() || channel.tvg?.name || channel.url,
|
||||
channel_id: channel.id,
|
||||
poster_url: channel.tvg?.logo || undefined,
|
||||
tvg_id: channel.tvg?.id || undefined,
|
||||
tvg_name: channel.tvg?.name || undefined,
|
||||
group_title: channel.group?.title || undefined,
|
||||
category_id: 'live',
|
||||
added_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const updatedPlaylist = await firstValueFrom(
|
||||
this.playlistsService.addM3uRecentlyViewed(
|
||||
playlistId,
|
||||
recentlyViewedItem
|
||||
)
|
||||
);
|
||||
|
||||
this.store.dispatch(
|
||||
PlaylistActions.updatePlaylistMeta({
|
||||
playlist: {
|
||||
_id: playlistId,
|
||||
recentlyViewed: updatedPlaylist?.recentlyViewed ?? [],
|
||||
} as PlaylistMeta,
|
||||
}) as any
|
||||
);
|
||||
}
|
||||
|
||||
private clearConsumedRecentChannelState(): void {
|
||||
const historyState = (window.history.state ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
if (!historyState['openRecentChannelUrl']) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const nextState = { ...historyState };
|
||||
delete nextState['openRecentChannelUrl'];
|
||||
window.history.replaceState(nextState, document.title);
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the overlay with multi EPG view
|
||||
*/
|
||||
openMultiEpgView(): void {
|
||||
const positionStrategy = this.overlay
|
||||
.position()
|
||||
.global()
|
||||
.centerHorizontally()
|
||||
.centerVertically();
|
||||
|
||||
this.overlayRef = this.overlay.create({
|
||||
hasBackdrop: true,
|
||||
positionStrategy,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
});
|
||||
|
||||
const injector = Injector.create({
|
||||
providers: [
|
||||
{
|
||||
provide: COMPONENT_OVERLAY_REF,
|
||||
useValue: this.overlayRef,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const portal = new ComponentPortal(
|
||||
MultiEpgContainerComponent,
|
||||
null,
|
||||
injector
|
||||
);
|
||||
|
||||
const componentRef = this.overlayRef.attach(portal);
|
||||
componentRef.instance.playlistChannels = this.store.select(
|
||||
selectChannels
|
||||
) as Observable<Channel[]>;
|
||||
|
||||
// Pass the active channel's tvg.id for highlighting
|
||||
const currentChannel = this.activeChannel();
|
||||
if (currentChannel) {
|
||||
componentRef.instance.activeChannelId =
|
||||
currentChannel.tvg?.id || null;
|
||||
}
|
||||
|
||||
this.overlayRef.backdropClick().subscribe(() => {
|
||||
this.overlayRef.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
@HostListener('document:keydown', ['$event'])
|
||||
handleKeyPress(event: KeyboardEvent): void {
|
||||
// Only handle digit keys (0-9)
|
||||
if (event.key >= '0' && event.key <= '9') {
|
||||
// Don't trigger hotkeys when user is typing in input fields
|
||||
if (this.isTypingInInput(event)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
this.handleChannelNumberInput(event.key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle channel number input from keyboard
|
||||
* Debounces input to allow multi-digit channel numbers
|
||||
*/
|
||||
handleChannelNumberInput(digit: string): void {
|
||||
// Clear existing timeout
|
||||
if (this.channelNumberTimeout) {
|
||||
clearTimeout(this.channelNumberTimeout);
|
||||
}
|
||||
|
||||
// Add digit to current input
|
||||
this.channelNumberInput += digit;
|
||||
this.showChannelNumberOverlay = true;
|
||||
|
||||
// Set timeout to switch channel after 2 seconds of no input
|
||||
this.channelNumberTimeout = window.setTimeout(() => {
|
||||
this.switchToChannelByNumber(parseInt(this.channelNumberInput, 10));
|
||||
this.clearChannelNumberInput();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to channel by number (1-based index)
|
||||
*/
|
||||
switchToChannelByNumber(channelNumber: number): void {
|
||||
this.channels$
|
||||
.pipe(
|
||||
take(1),
|
||||
map((channels) =>
|
||||
getChannelItemByNumber(channels, channelNumber)
|
||||
)
|
||||
)
|
||||
.subscribe((channel) => {
|
||||
if (channel) {
|
||||
this.store.dispatch(
|
||||
ChannelActions.setActiveChannel({ channel })
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear channel number input and hide overlay
|
||||
*/
|
||||
clearChannelNumberInput(): void {
|
||||
this.channelNumberInput = '';
|
||||
this.showChannelNumberOverlay = false;
|
||||
if (this.channelNumberTimeout) {
|
||||
clearTimeout(this.channelNumberTimeout);
|
||||
this.channelNumberTimeout = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user is currently typing in an input or textarea field
|
||||
* @param event Keyboard event
|
||||
* @returns true if the event target is an input or textarea element
|
||||
*/
|
||||
private isTypingInInput(event: Event): boolean {
|
||||
const target = event.target;
|
||||
return (
|
||||
target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLTextAreaElement
|
||||
);
|
||||
}
|
||||
|
||||
private handleRemoteControlCommand(command: {
|
||||
type:
|
||||
| 'channel-select-number'
|
||||
| 'volume-up'
|
||||
| 'volume-down'
|
||||
| 'volume-toggle-mute';
|
||||
number?: number;
|
||||
}): void {
|
||||
if (command.type === 'channel-select-number' && command.number) {
|
||||
this.switchToChannelByNumber(command.number);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command.type === 'volume-up') {
|
||||
this.setVolume(this.volume + 0.1);
|
||||
} else if (command.type === 'volume-down') {
|
||||
this.setVolume(this.volume - 0.1);
|
||||
} else if (command.type === 'volume-toggle-mute') {
|
||||
if (this.volume === 0) {
|
||||
this.setVolume(this.lastKnownVolume || 1);
|
||||
} else {
|
||||
this.lastKnownVolume = this.volume;
|
||||
this.setVolume(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private setVolume(next: number): void {
|
||||
const clamped = Math.max(0, Math.min(1, Number(next.toFixed(2))));
|
||||
this.volume = clamped;
|
||||
if (clamped > 0) {
|
||||
this.lastKnownVolume = clamped;
|
||||
}
|
||||
localStorage.setItem('volume', String(clamped));
|
||||
|
||||
if (window.electron?.updateRemoteControlStatus) {
|
||||
window.electron.updateRemoteControlStatus({
|
||||
portal: 'm3u',
|
||||
isLiveView: true,
|
||||
supportsVolume: true,
|
||||
volume: this.volume,
|
||||
muted: this.volume === 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
shouldShowInlinePlayer(channel: Channel | null | undefined): boolean {
|
||||
if (!channel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !this.isExternalPlayer(this.playerSettings.player);
|
||||
}
|
||||
|
||||
private getExternalSessionStateKey(
|
||||
session: ExternalPlayerSession | null | undefined
|
||||
): string | null {
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${session.id}:${session.status}`;
|
||||
}
|
||||
|
||||
private isExternalPlayer(
|
||||
player: VideoPlayer | null | undefined
|
||||
): player is VideoPlayer.MPV | VideoPlayer.VLC {
|
||||
return player === VideoPlayer.MPV || player === VideoPlayer.VLC;
|
||||
}
|
||||
|
||||
private isTerminalExternalSession(
|
||||
session: ExternalPlayerSession | null | undefined
|
||||
): 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',
|
||||
palette: {
|
||||
labelKey: 'TOP_MENU.OPEN_MULTI_EPG',
|
||||
descriptionKey:
|
||||
'WORKSPACE.SHELL.COMMANDS.OPEN_MULTI_EPG_DESCRIPTION',
|
||||
keywords: ['epg', 'guide', 'schedule'],
|
||||
priority: 10,
|
||||
},
|
||||
run: () => this.openMultiEpgView(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user