Proyecto App Linux

This commit is contained in:
2026-05-03 23:43:09 +02:00
commit 7482733843
1248 changed files with 189750 additions and 0 deletions
@@ -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';
@@ -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(),
});
}
}