refactor: update nx monorepo structure

This commit is contained in:
4gray committed 2026-03-09 23:24:34 +01:00
1 parent aa739f43e0
commit 7ac2fd66b8
175 files changed
+3118 -1076

No files matched your search

+22 -4
View File
@@ -26,6 +26,24 @@
"apps/web/src/styles.scss",
"apps/web/src/m3-theme.scss",
"./node_modules/video.js/dist/video-js.css"
],
"allowedCommonJsDependencies": [
"qrcode",
"iptv-playlist-parser",
"moment",
"mpegts.js",
"videojs-contrib-quality-levels",
"videojs-quality-selector-hls",
"global/window",
"global/document",
"@videojs/xhr",
"videojs-vtt.js",
"mux.js/lib/tools/parse-sidx",
"mux.js/lib/utils/clock",
"keycode",
"safe-json-parse/tuple",
"@xmldom/xmldom",
"url-toolkit"
]
},
"configurations": {
@@ -34,12 +52,12 @@
"budgets": [
{
"type": "initial",
"maximumWarning": "500kb",
"maximumWarning": "4.5mb",
"maximumError": "5mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "10kb",
"maximumWarning": "12.5kb",
"maximumError": "15kb"
}
],
@@ -56,12 +74,12 @@
"budgets": [
{
"type": "initial",
"maximumWarning": "500kb",
"maximumWarning": "4.5mb",
"maximumError": "5mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "10kb",
"maximumWarning": "12.5kb",
"maximumError": "15kb"
}
],
+27 -22
View File
@@ -5,14 +5,11 @@ import { Router, RouterOutlet } from '@angular/router';
import { Actions, ofType } from '@ngrx/effects';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import {
GlobalRecentlyViewedComponent,
GlobalSearchResultsComponent,
} from '@iptvnator/portal/xtream/feature';
import { EpgProgressPanelComponent } from 'components';
import { EpgService } from '@iptvnator/epg/data-access';
import { EpgProgressPanelComponent } from '@iptvnator/ui/epg';
import { PlaylistActions, selectAllPlaylistsMeta } from 'm3u-state';
import { filter, take } from 'rxjs';
import { DataService, EpgService } from 'services';
import { DataService } from 'services';
import {
AUTO_UPDATE_PLAYLISTS,
Language,
@@ -149,25 +146,33 @@ export class AppComponent implements OnInit {
}
openGlobalSearch(initialQuery = ''): void {
this.dialog.open(GlobalSearchResultsComponent, {
width: '100%',
height: '100%',
maxWidth: '100%',
panelClass: 'global-search-overlay',
data: { isGlobalSearch: true, initialQuery },
});
void import('@iptvnator/portal/xtream/feature').then(
({ GlobalSearchResultsComponent }) => {
this.dialog.open(GlobalSearchResultsComponent, {
width: '100%',
height: '100%',
maxWidth: '100%',
panelClass: 'global-search-overlay',
data: { isGlobalSearch: true, initialQuery },
});
}
);
}
openGlobalRecent(): void {
this.dialog.open(GlobalRecentlyViewedComponent, {
width: '100%',
height: '100%',
maxWidth: '100%',
panelClass: 'global-search-overlay',
data: { isGlobal: true },
hasBackdrop: true,
disableClose: false,
});
void import('@iptvnator/portal/xtream/feature').then(
({ GlobalRecentlyViewedComponent }) => {
this.dialog.open(GlobalRecentlyViewedComponent, {
width: '100%',
height: '100%',
maxWidth: '100%',
panelClass: 'global-search-overlay',
data: { isGlobal: true },
hasBackdrop: true,
disableClose: false,
});
}
);
}
/**
+1 -1
View File
@@ -24,7 +24,7 @@ import {
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYER,
} from '@iptvnator/portal/shared/util';
import { PLAYLIST_PLAYER_ACTIONS } from '@iptvnator/playlist/m3u/feature-player';
import { PLAYLIST_PLAYER_ACTIONS } from '@iptvnator/playlist/shared/util';
import { provideXtreamDataSource } from '@iptvnator/portal/xtream/data-access';
import { DataService } from 'services';
import { dbConfig } from 'shared-interfaces';
+14 -4
View File
@@ -1,6 +1,4 @@
import { Route, Routes } from '@angular/router';
import { createStalkerRoutes } from '@iptvnator/portal/stalker/feature';
import { createXtreamRoutes } from '@iptvnator/portal/xtream/feature';
function withWorkspaceLayout(routes: Route[]): Route[] {
return routes.map((route) => ({
@@ -80,8 +78,20 @@ export const routes: Routes = [
(c) => c.DownloadsComponent
),
},
...withWorkspaceLayout(createXtreamRoutes()),
...withWorkspaceLayout(createStalkerRoutes()),
{
path: '',
loadChildren: () =>
import('@iptvnator/portal/xtream/feature').then((m) =>
withWorkspaceLayout(m.createXtreamRoutes())
),
},
{
path: '',
loadChildren: () =>
import('@iptvnator/portal/stalker/feature').then((m) =>
withWorkspaceLayout(m.createStalkerRoutes())
),
},
{
path: 'settings',
data: { layout: 'workspace' },
@@ -25,10 +25,10 @@ export class ElectronServiceStub {
return this.remote.app.getVersion();
}
sendIpcEvent(_type: string, _payload?: unknown): void {
sendIpcEvent<T = unknown>(_type: string, _payload?: unknown): T {
void _type;
void _payload;
return undefined;
return undefined as T;
}
listenOn(
+36 -18
View File
@@ -108,10 +108,16 @@ export class ElectronService extends DataService {
return AppConfig.version;
}
async sendIpcEvent(type: string, payload?: unknown) {
async sendIpcEvent<T = unknown>(
type: string,
payload?: unknown
): Promise<T> {
if (type === PLAYLIST_PARSE_BY_URL) {
this.fetchM3uPlaylistFromUrl(payload);
} else if (type === PLAYLIST_UPDATE) {
return undefined as T;
}
if (type === PLAYLIST_UPDATE) {
this.updateM3uPlaylistFromFile(
payload as {
id: string;
@@ -120,22 +126,29 @@ export class ElectronService extends DataService {
title: string;
}
);
} else if (type === XTREAM_REQUEST) {
return await this.forwardXtreamRequest(
return undefined as T;
}
if (type === XTREAM_REQUEST) {
return (await this.forwardXtreamRequest(
payload as { url: string; params: Record<string, string> }
);
} else if (type === 'STALKER_REQUEST') {
return this.fetchStalkerData(
)) as T;
}
if (type === 'STALKER_REQUEST') {
return (await this.fetchStalkerData(
payload as {
url: string;
macAddress: string;
params: Record<string, string>;
}
);
} else if (type === 'OPEN_MPV_PLAYER') {
)) as T;
}
if (type === 'OPEN_MPV_PLAYER') {
const data = payload as PlayerLaunchPayload;
try {
return await window.electron.openInMpv(
return (await window.electron.openInMpv(
data.url,
data.title ?? '',
data.thumbnail ?? '',
@@ -145,8 +158,7 @@ export class ElectronService extends DataService {
data.contentInfo,
data.startTime,
data.headers ?? undefined
);
/* thumbnail: data.thumbnail ?? '', */
)) as T;
} catch (error: unknown) {
const errorMessage =
this.getErrorDetails(error)?.message ?? String(error);
@@ -160,10 +172,12 @@ export class ElectronService extends DataService {
console.error('MPV launch error:', error);
throw error;
}
} else if (type === 'OPEN_VLC_PLAYER') {
}
if (type === 'OPEN_VLC_PLAYER') {
const data = payload as PlayerLaunchPayload;
try {
return await window.electron.openInVlc(
return (await window.electron.openInVlc(
data.url,
data.title ?? '',
data.thumbnail ?? '',
@@ -173,7 +187,7 @@ export class ElectronService extends DataService {
data.contentInfo,
data.startTime,
data.headers ?? undefined
);
)) as T;
} catch (error: unknown) {
const errorMessage =
this.getErrorDetails(error)?.message ?? String(error);
@@ -187,7 +201,9 @@ export class ElectronService extends DataService {
console.error('VLC launch error:', error);
throw error;
}
} else if (type === AUTO_UPDATE_PLAYLISTS) {
}
if (type === AUTO_UPDATE_PLAYLISTS) {
const data = payload as Playlist[];
const playlists = await window.electron.autoUpdatePlaylists(data);
this.store.dispatch(
@@ -202,9 +218,11 @@ export class ElectronService extends DataService {
null,
{ duration: 2000 }
);
} else {
console.log('Unknown type', type);
return playlists as T;
}
console.log('Unknown type', type);
return undefined as T;
}
private async fetchStalkerData(payload: {
+19 -16
View File
@@ -1,10 +1,6 @@
import { Injectable, inject } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { ExternalPlayerInfoDialogComponent } from 'components';
import {
PlayerDialogComponent,
PlayerDialogData,
} from '@iptvnator/portal/xtream/feature';
import { ExternalPlayerInfoDialogComponent } from '@iptvnator/ui/playback';
import { DataService } from 'services';
import {
ExternalPlayerSession,
@@ -85,7 +81,9 @@ export class PlayerService {
if (!hideExternalInfoDialog) {
this.dialog.open(ExternalPlayerInfoDialogComponent);
}
return await this.dataService.sendIpcEvent(OPEN_MPV_PLAYER, {
return await this.dataService.sendIpcEvent<ExternalPlayerSession>(
OPEN_MPV_PLAYER,
{
url: streamUrl,
title,
thumbnail,
@@ -95,12 +93,15 @@ export class PlayerService {
headers,
contentInfo,
startTime,
});
}
);
} else if (player === VideoPlayer.VLC) {
if (!hideExternalInfoDialog) {
this.dialog.open(ExternalPlayerInfoDialogComponent);
}
return await this.dataService.sendIpcEvent(OPEN_VLC_PLAYER, {
return await this.dataService.sendIpcEvent<ExternalPlayerSession>(
OPEN_VLC_PLAYER,
{
url: streamUrl,
title,
thumbnail,
@@ -110,16 +111,18 @@ export class PlayerService {
headers,
contentInfo,
startTime,
});
}
);
}
this.dialog.open<PlayerDialogComponent, PlayerDialogData>(
PlayerDialogComponent,
{
data: { streamUrl, title, contentInfo, startTime },
width: '80%',
maxWidth: '1200px',
maxHeight: '90vh',
void import('@iptvnator/portal/xtream/feature').then(
({ PlayerDialogComponent }) => {
this.dialog.open(PlayerDialogComponent, {
data: { streamUrl, title, contentInfo, startTime },
width: '80%',
maxWidth: '1200px',
maxHeight: '90vh',
});
}
);
}
@@ -1,11 +1,10 @@
import { inject, Injectable, Provider } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { PlaylistInfoComponent } from 'components';
import { PlaylistInfoComponent } from '@iptvnator/playlist/shared/ui';
import {
PORTAL_NAVIGATION_ACTIONS,
PortalNavigationActions,
} from '@iptvnator/portal/shared/util';
import { AccountInfoComponent } from '@iptvnator/portal/xtream/feature';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { Playlist } from 'shared-interfaces';
import { SettingsComponent } from '../settings/settings.component';
@@ -18,16 +17,22 @@ export class AppPortalNavigationActionsService
private readonly xtreamStore = inject(XtreamStore);
openAccountInfo(): void {
this.dialog.open(AccountInfoComponent, {
width: '80%',
maxWidth: '1200px',
maxHeight: '90vh',
data: {
vodStreamsCount: this.xtreamStore.vodStreams().length,
liveStreamsCount: this.xtreamStore.liveStreams().length,
seriesCount: this.xtreamStore.serialStreams().length,
},
});
const data = {
vodStreamsCount: this.xtreamStore.vodStreams().length,
liveStreamsCount: this.xtreamStore.liveStreams().length,
seriesCount: this.xtreamStore.serialStreams().length,
};
void import('@iptvnator/portal/xtream/feature').then(
({ AccountInfoComponent }) => {
this.dialog.open(AccountInfoComponent, {
width: '80%',
maxWidth: '1200px',
maxHeight: '90vh',
data,
});
}
);
}
openPlaylistInfo(playlist: Playlist | null | undefined): void {
+16 -8
View File
@@ -96,26 +96,34 @@ export class PwaService extends DataService {
* @param type ipc command type
* @param payload payload
*/
sendIpcEvent(type: string, payload?: unknown) {
sendIpcEvent<T = unknown>(type: string, payload?: unknown): T {
if (type === PLAYLIST_PARSE_BY_URL) {
this.fetchFromUrl(payload);
} else if (type === PLAYLIST_UPDATE) {
return undefined as T;
}
if (type === PLAYLIST_UPDATE) {
this.refreshPlaylist(payload);
} else if (type === XTREAM_REQUEST) {
return undefined as T;
}
if (type === XTREAM_REQUEST) {
return this.forwardXtreamRequest(
payload as { url: string; params: Record<string, string> }
);
} else if (type === STALKER_REQUEST) {
) as T;
}
if (type === STALKER_REQUEST) {
return this.forwardStalkerRequest(
payload as {
url: string;
macAddress: string;
params: Record<string, string>;
}
);
} else {
return Promise.resolve();
) as T;
}
return undefined as T;
}
refreshPlaylist(payload: Partial<Playlist & { id: string }>) {
+56 -28
View File
@@ -2,9 +2,58 @@ import { HttpClient } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
import { StorageMap } from '@ngx-pwa/local-storage';
import { catchError, map, Observable } from 'rxjs';
import * as semver from 'semver';
import { STORE_KEY, Theme } from 'shared-interfaces';
const PRERELEASE_KEYWORDS = [
'beta',
'alpha',
'rc',
'preview',
'dev',
'canary',
'nightly',
] as const;
interface ParsedVersion {
major: number;
minor: number;
patch: number;
prerelease: boolean;
}
function parseVersion(input: string): ParsedVersion | null {
const normalized = input.trim().replace(/^v/i, '');
const match = normalized.match(
/(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:[-+]?([0-9A-Za-z.-]+))?/
);
if (!match) {
return null;
}
return {
major: Number(match[1] ?? 0),
minor: Number(match[2] ?? 0),
patch: Number(match[3] ?? 0),
prerelease: Boolean(match[4]),
};
}
function hasPrereleaseKeyword(value: string): boolean {
const normalized = value.toLowerCase();
return PRERELEASE_KEYWORDS.some((keyword) => normalized.includes(keyword));
}
function compareVersions(a: ParsedVersion, b: ParsedVersion): number {
if (a.major !== b.major) {
return a.major - b.major;
}
if (a.minor !== b.minor) {
return a.minor - b.minor;
}
return a.patch - b.patch;
}
@Injectable({
providedIn: 'root',
})
@@ -64,34 +113,14 @@ export class SettingsService {
map((response) => {
// Filter out pre-release versions (beta, alpha, rc, etc.)
const stableReleases = response.filter((release) => {
const releaseName = release.name.toLowerCase();
// Check for beta/alpha/rc keywords in the release name
const prereleaseKeywords = [
'beta',
'alpha',
'rc',
'preview',
'dev',
'canary',
'nightly',
];
const hasPrereleaseKeyword = prereleaseKeywords.some(
(keyword) => releaseName.includes(keyword)
);
if (hasPrereleaseKeyword) {
if (hasPrereleaseKeyword(release.name)) {
return false;
}
// Validate version format
const version = semver.valid(
semver.coerce(release.name)
);
const version = parseVersion(release.name);
if (!version) return false;
// Check if version has prerelease tags in semver format
return !semver.prerelease(release.name);
return !version.prerelease;
});
// Sort stable releases by creation date
@@ -121,9 +150,8 @@ export class SettingsService {
currentVersion: string,
latestVersion: string
): boolean {
// Clean and coerce versions to handle invalid formats
const cleanCurrent = semver.coerce(currentVersion);
const cleanLatest = semver.coerce(latestVersion);
const cleanCurrent = parseVersion(currentVersion);
const cleanLatest = parseVersion(latestVersion);
if (!cleanCurrent || !cleanLatest) {
console.warn('Invalid version format:', {
@@ -133,6 +161,6 @@ export class SettingsService {
return false;
}
return semver.lt(cleanCurrent, cleanLatest);
return compareVersions(cleanCurrent, cleanLatest) < 0;
}
}
@@ -1,12 +1,6 @@
import { inject, Injectable, Provider } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { AddPlaylistDialogComponent } from '@iptvnator/playlist/import/feature';
import {
AccountInfoComponent,
GlobalRecentlyViewedComponent,
GlobalSearchResultsComponent,
} from '@iptvnator/portal/xtream/feature';
import { PlaylistType } from 'components';
import { PlaylistType } from '@iptvnator/playlist/shared/ui';
import {
WORKSPACE_SHELL_ACTIONS,
WorkspaceAccountInfoData,
@@ -18,44 +12,60 @@ export class AppWorkspaceShellActionsService implements WorkspaceShellActions {
private readonly dialog = inject(MatDialog);
openAddPlaylistDialog(type: PlaylistType): void {
this.dialog.open(AddPlaylistDialogComponent, {
width: '600px',
data: { type },
});
void import('@iptvnator/playlist/import/feature').then(
({ AddPlaylistDialogComponent }) => {
this.dialog.open(AddPlaylistDialogComponent, {
width: '600px',
data: { type },
});
}
);
}
openGlobalSearch(initialQuery = ''): void {
this.dialog.open(GlobalSearchResultsComponent, {
width: '100%',
height: '100%',
maxWidth: '100%',
panelClass: 'global-search-overlay',
data: {
isGlobalSearch: true,
initialQuery,
},
});
void import('@iptvnator/portal/xtream/feature').then(
({ GlobalSearchResultsComponent }) => {
this.dialog.open(GlobalSearchResultsComponent, {
width: '100%',
height: '100%',
maxWidth: '100%',
panelClass: 'global-search-overlay',
data: {
isGlobalSearch: true,
initialQuery,
},
});
}
);
}
openGlobalRecent(): void {
this.dialog.open(GlobalRecentlyViewedComponent, {
width: '100%',
height: '100%',
maxWidth: '100%',
panelClass: 'global-search-overlay',
data: { isGlobal: true },
hasBackdrop: true,
disableClose: false,
});
void import('@iptvnator/portal/xtream/feature').then(
({ GlobalRecentlyViewedComponent }) => {
this.dialog.open(GlobalRecentlyViewedComponent, {
width: '100%',
height: '100%',
maxWidth: '100%',
panelClass: 'global-search-overlay',
data: { isGlobal: true },
hasBackdrop: true,
disableClose: false,
});
}
);
}
openAccountInfo(data: WorkspaceAccountInfoData): void {
this.dialog.open(AccountInfoComponent, {
width: '80%',
maxWidth: '1200px',
maxHeight: '90vh',
data,
});
void import('@iptvnator/portal/xtream/feature').then(
({ AccountInfoComponent }) => {
this.dialog.open(AccountInfoComponent, {
width: '80%',
maxWidth: '1200px',
maxHeight: '90vh',
data,
});
}
);
}
}
@@ -18,13 +18,14 @@ import { Router } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { provideMockStore } from '@ngrx/store/testing';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { EpgService } from '@iptvnator/epg/data-access';
import {
MockModule,
MockProvider,
MockProviders,
} from 'ng-mocks';
import { DialogService } from 'components';
import { DataService, EpgService, PlaylistsService } from 'services';
import { DataService, PlaylistsService } from 'services';
import { Language, StreamFormat, Theme, VideoPlayer } from 'shared-interfaces';
import { SettingsComponent } from './settings.component';
@@ -34,11 +34,12 @@ import { MatTooltipModule } from '@angular/material/tooltip';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { EpgService } from '@iptvnator/epg/data-access';
import { QRCodeComponent } from 'angularx-qrcode';
import { DialogService } from 'components';
import { PlaylistActions, selectIsEpgAvailable } from 'm3u-state';
import { take } from 'rxjs';
import { DataService, EpgService, PlaylistsService } from 'services';
import { DataService, PlaylistsService } from 'services';
import {
Language,
Playlist,
+80
View File
@@ -59,16 +59,88 @@ export default [
'domain:portal-shared',
'domain:m3u',
'domain:playback',
'domain:shared-contracts',
'domain:shared-runtime',
'domain:shared-ui',
'scope:shared',
'scope:portal',
],
},
{
sourceTag: 'domain:m3u',
onlyDependOnLibsWithTags: [
'domain:m3u',
'domain:playback',
'domain:portal-shared',
'domain:shared-contracts',
'domain:shared-runtime',
'domain:shared-ui',
'scope:shared',
],
},
{
sourceTag: 'domain:playlist-import',
onlyDependOnLibsWithTags: [
'domain:m3u',
'domain:playlist-import',
'domain:playback',
'domain:shared-contracts',
'domain:shared-runtime',
'domain:shared-ui',
'domain:stalker',
'scope:shared',
],
},
{
sourceTag: 'domain:playback',
onlyDependOnLibsWithTags: [
'domain:m3u',
'domain:playback',
'domain:portal-shared',
'domain:shared-contracts',
'domain:shared-runtime',
'domain:shared-ui',
'scope:shared',
],
},
{
sourceTag: 'domain:shared-contracts',
onlyDependOnLibsWithTags: [
'domain:shared-contracts',
'scope:shared',
],
},
{
sourceTag: 'domain:shared-runtime',
onlyDependOnLibsWithTags: [
'domain:m3u',
'domain:shared-contracts',
'domain:shared-runtime',
'scope:shared',
],
},
{
sourceTag: 'domain:shared-ui',
onlyDependOnLibsWithTags: [
'domain:m3u',
'domain:playback',
'domain:portal-shared',
'domain:shared-contracts',
'domain:shared-runtime',
'domain:shared-ui',
'scope:shared',
],
},
{
sourceTag: 'domain:xtream',
onlyDependOnLibsWithTags: [
'domain:xtream',
'domain:portal-shared',
'domain:m3u',
'domain:playback',
'domain:shared-contracts',
'domain:shared-runtime',
'domain:shared-ui',
'scope:shared',
],
},
@@ -78,6 +150,10 @@ export default [
'domain:stalker',
'domain:portal-shared',
'domain:m3u',
'domain:playback',
'domain:shared-contracts',
'domain:shared-runtime',
'domain:shared-ui',
'scope:shared',
],
},
@@ -89,6 +165,10 @@ export default [
'domain:xtream',
'domain:stalker',
'domain:m3u',
'domain:playback',
'domain:shared-contracts',
'domain:shared-runtime',
'domain:shared-ui',
'scope:shared',
],
},
+13
View File
@@ -0,0 +1,13 @@
{
"name": "epg-data-access",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/epg/data-access/src",
"prefix": "lib",
"projectType": "library",
"tags": ["scope:shared", "domain:playback", "type:data-access"],
"targets": {
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
+2
View File
@@ -0,0 +1,2 @@
export * from './lib/epg-progress.service';
export * from './lib/epg.service';
+28
View File
@@ -0,0 +1,28 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"isolatedModules": true,
"target": "es2022",
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
}
]
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}
+2 -1
View File
@@ -5,6 +5,7 @@ import { Actions, createEffect, ofType } from '@ngrx/effects';
import { Store } from '@ngrx/store';
import { StorageMap } from '@ngx-pwa/local-storage';
import { TranslateService } from '@ngx-translate/core';
import { EpgService } from '@iptvnator/epg/data-access';
import {
combineLatestWith,
EMPTY,
@@ -15,7 +16,7 @@ import {
tap,
withLatestFrom,
} from 'rxjs';
import { DataService, EpgService, PlaylistsService } from 'services';
import { DataService, PlaylistsService } from 'services';
import {
OPEN_MPV_PLAYER,
OPEN_VLC_PLAYER,
+1 -1
View File
@@ -4,7 +4,7 @@
"sourceRoot": "libs/playlist/import/feature/src",
"prefix": "lib",
"projectType": "library",
"tags": ["scope:playlist", "domain:m3u", "type:feature"],
"tags": ["scope:playlist", "domain:playlist-import", "type:feature"],
"targets": {
"lint": {
"executor": "@nx/eslint:lint"
@@ -8,7 +8,7 @@ import {
import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { PlaylistType } from 'components';
import { PlaylistType } from '@iptvnator/playlist/shared/ui';
import { PlaylistActions } from 'm3u-state';
import { getFilenameFromUrl } from 'm3u-utils';
import { DataService } from 'services';
@@ -14,7 +14,10 @@ import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { PlaylistActions } from 'm3u-state';
import { STALKER_SERIAL_NUMBER, StalkerSessionService } from 'services';
import {
STALKER_SERIAL_NUMBER,
StalkerSessionService,
} from '@iptvnator/portal/stalker/data-access';
import { Playlist } from 'shared-interfaces';
import { v4 as uuid } from 'uuid';
@@ -1,2 +1 @@
export * from './lib/playlist-player-actions';
export * from './lib/video-player/video-player.component';
@@ -19,18 +19,14 @@ import { Store } from '@ngrx/store';
import { StorageMap } from '@ngx-pwa/local-storage';
import { TranslatePipe } from '@ngx-translate/core';
import {
ArtPlayerComponent,
AudioPlayerComponent,
InfoOverlayComponent,
ResizableDirective,
} from 'components';
import {
COMPONENT_OVERLAY_REF,
EpgListComponent,
HtmlVideoPlayerComponent,
InfoOverlayComponent,
MultiEpgContainerComponent,
ResizableDirective,
SidebarComponent,
ToolbarComponent,
VjsPlayerComponent,
} from 'components';
} from '@iptvnator/ui/epg';
import {
ChannelActions,
FavoritesActions,
@@ -55,6 +51,15 @@ import {
getAdjacentChannelItem,
getChannelItemByNumber,
} from '@iptvnator/portal/shared/util';
import {
ArtPlayerComponent,
AudioPlayerComponent,
HtmlVideoPlayerComponent,
SidebarComponent,
ToolbarComponent,
VjsPlayerComponent,
} from '@iptvnator/ui/playback';
import { PLAYLIST_PLAYER_ACTIONS } from '@iptvnator/playlist/shared/util';
import { DataService, PlaylistsService, SettingsStore } from 'services';
import {
Channel,
@@ -65,7 +70,6 @@ import {
SidebarView,
VideoPlayer,
} from 'shared-interfaces';
import { PLAYLIST_PLAYER_ACTIONS } from '../playlist-player-actions';
@Component({
selector: 'app-video-player',
+13
View File
@@ -0,0 +1,13 @@
{
"name": "playlist-shared-ui",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/playlist/shared/ui/src",
"prefix": "lib",
"projectType": "library",
"tags": ["scope:playlist", "domain:m3u", "type:ui"],
"targets": {
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
+5
View File
@@ -0,0 +1,5 @@
export * from './lib/add-playlist-menu/add-playlist-menu.component';
export * from './lib/filter-sort-menu/filter-sort-menu.component';
export * from './lib/playlist-switcher/playlist-switcher.component';
export * from './lib/recent-playlists/playlist-info/playlist-info.component';
export * from './lib/recent-playlists/recent-playlists.component';
@@ -22,10 +22,10 @@ import {
} from 'm3u-state';
import { NgxSkeletonLoaderComponent } from 'ngx-skeleton-loader';
import { BehaviorSubject, combineLatest, map } from 'rxjs';
import { DialogService } from 'components';
import { DatabaseService, DataService, SortBy, SortService } from 'services';
import { PLAYLIST_UPDATE, PlaylistMeta } from 'shared-interfaces';
import { PlaylistType } from '../add-playlist-menu/add-playlist-menu.component';
import { DialogService } from '../confirm-dialog/dialog.service';
import { EmptyStateComponent } from './empty-state/empty-state.component';
import { PlaylistInfoComponent } from './playlist-info/playlist-info.component';
import { PlaylistItemComponent } from './playlist-item/playlist-item.component';
+28
View File
@@ -0,0 +1,28 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"isolatedModules": true,
"target": "es2022",
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
}
]
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "playlist-shared-util",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/playlist/shared/util/src",
"prefix": "lib",
"projectType": "library",
"tags": ["scope:playlist", "domain:m3u", "type:util"],
"targets": {
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
+1
View File
@@ -0,0 +1 @@
export * from './lib/playlist-player-actions';
+28
View File
@@ -0,0 +1,28 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"isolatedModules": true,
"target": "es2022",
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
}
]
}
@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}
+1 -1
View File
@@ -4,7 +4,7 @@
"sourceRoot": "libs/portal/catalog/feature/src",
"prefix": "lib",
"projectType": "library",
"tags": ["scope:shared", "type:feature"],
"tags": ["scope:portal", "domain:portal-shared", "type:feature"],
"targets": {
"lint": {
"executor": "@nx/eslint:lint"
@@ -1,34 +1,6 @@
@if (selectedCategory()) {
@if (isStalker && selectedStalkerItem()) {
@if (contentType() === 'vod') {
@if (selectedStalkerItem().series?.length > 0) {
<!-- VOD item with embedded series (vclub or Ministra) -->
<app-stalker-series-view [vodWithSeries]="selectedStalkerItem()" />
} @else if (selectedStalkerItem().is_series) {
<!-- VOD item that is actually a series (Ministra plugin is_series=1) -->
<app-stalker-series-view />
} @else {
@if (vodDetailsItem()) {
<app-vod-details
[item]="vodDetailsItem()!"
[isFavorite]="isSelectedVodFavorite()"
[playbackPosition]="selectedVodPlaybackPosition()"
[inlinePlayback]="inlinePlayback()"
[externalPlayback]="externalPlayback.activeSession()"
(playClicked)="onVodPlay($event)"
(resumeClicked)="onVodResume($event)"
(favoriteToggled)="onVodFavoriteToggled($event)"
(downloadRequested)="onVodDownload($event)"
(backClicked)="onVodBack()"
(inlineTimeUpdated)="handleInlineTimeUpdate($event)"
(inlinePlaybackClosed)="closeInlinePlayer()"
(streamUrlCopied)="showCopyNotification()"
/>
}
}
} @else if (contentType() === 'series') {
<app-stalker-series-view />
}
@if (selectedDetailComponent(); as detailComponentRef) {
<ng-container *ngComponentOutlet="detailComponentRef" />
} @else {
<div class="category-content-layout">
<div class="category-content-header">
@@ -40,7 +12,7 @@
{{ categoryItemSubtitle() }}
</div>
</div>
@if (!isStalker) {
@if (canSortContent()) {
<button
type="button"
mat-icon-button
@@ -1,56 +1,22 @@
import {
Component,
computed,
effect,
inject,
OnDestroy,
OnInit,
signal,
} from '@angular/core';
import { NgComponentOutlet } from '@angular/common';
import { Component, computed, inject, OnInit } from '@angular/core';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltip } from '@angular/material/tooltip';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import {
StalkerSeriesViewComponent,
VodDetailsComponent,
} from 'components';
import { TranslatePipe } from '@ngx-translate/core';
import {
GridListComponent,
PlaylistErrorViewComponent,
} from '@iptvnator/portal/shared/ui';
import {
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
createLogger,
isStalkerPortalCatalogFacade,
PortalCatalogFacade,
PortalCatalogSortMode,
PORTAL_CATALOG_DETAIL_COMPONENT,
PORTAL_CATALOG_FACADE,
StalkerPortalCatalogFacade,
PortalCatalogSortMode,
} from '@iptvnator/portal/shared/util';
import {
createPortalFavoritesResource,
createRefreshTrigger,
isSelectedStalkerVodFavorite,
normalizeStalkerEntityId,
normalizeStalkerEntityIdAsNumber,
StalkerSelectedVodItem,
toggleStalkerVodFavorite,
} from '@iptvnator/portal/stalker/data-access';
import { PlaylistsService, DownloadsService } from 'services';
import {
createStalkerVodItem,
PlaybackPositionData,
ResolvedPortalPlayback,
StalkerVodDetails,
VodDetailsItem,
} from 'shared-interfaces';
interface CategoryContentItem {
id?: number | string;
@@ -62,16 +28,6 @@ interface CategoryContentItem {
[key: string]: unknown;
}
interface DownloadVodData {
id?: string | number;
has_files?: unknown;
info?: {
name?: string;
movie_image?: string;
};
title?: string;
}
@Component({
selector: 'app-category-content-view',
templateUrl: './category-content-view.component.html',
@@ -83,52 +39,22 @@ interface DownloadVodData {
MatMenuModule,
MatPaginatorModule,
MatTooltip,
NgComponentOutlet,
PlaylistErrorViewComponent,
StalkerSeriesViewComponent,
TranslatePipe,
VodDetailsComponent,
],
})
export class CategoryContentViewComponent implements OnInit, OnDestroy {
export class CategoryContentViewComponent implements OnInit {
private readonly activatedRoute = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
private readonly portalPlayer = inject(PORTAL_PLAYER);
readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
private readonly snackBar = inject(MatSnackBar);
private readonly translateService = inject(TranslateService);
private readonly playlistService = inject(PlaylistsService);
private readonly downloadsService = inject(DownloadsService);
private readonly catalog = inject(PORTAL_CATALOG_FACADE) as PortalCatalogFacade<
CategoryContentItem,
CategoryContentItem,
CategoryContentItem
>;
private readonly stalkerCatalog:
| StalkerPortalCatalogFacade<
CategoryContentItem,
CategoryContentItem,
CategoryContentItem
>
| null = isStalkerPortalCatalogFacade(this.catalog)
? (this.catalog as StalkerPortalCatalogFacade<
CategoryContentItem,
CategoryContentItem,
CategoryContentItem
>)
: null;
private readonly logger = createLogger('CategoryContentView');
private readonly favoritesRefresh = createRefreshTrigger();
readonly isStalker = this.catalog.provider === 'stalker';
/** Unsubscribe function for playback position updates */
private unsubscribePositionUpdates: (() => void) | null = null;
readonly detailComponent = inject(PORTAL_CATALOG_DETAIL_COMPONENT);
readonly contentType = this.catalog.contentType;
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
private lastInlineSaveTime = 0;
readonly limit = this.catalog.limit;
readonly pageIndex = this.catalog.pageIndex;
readonly pageSizeOptions = Array.from(this.catalog.pageSizeOptions);
@@ -136,262 +62,41 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
readonly paginatedContent = this.catalog.paginatedContent;
readonly selectedCategoryTitle = this.catalog.selectedCategoryTitle;
readonly categoryItemCount = this.catalog.categoryItemCount;
readonly selectedItem = this.catalog.selectedItem;
readonly totalPages = this.catalog.totalPages;
readonly contentSortMode = this.catalog.contentSortMode;
readonly isPaginatedContentLoading =
this.catalog.isPaginatedContentLoading;
readonly categoryItemSubtitle = computed(() => {
const itemCount = this.categoryItemCount();
return `${itemCount} ${itemCount === 1 ? 'item' : 'items'}`;
});
/** Stalker playback positions (key: `${contentType}_${id}`) */
private readonly stalkerPositions = signal<
Map<string, PlaybackPositionData>
>(new Map());
/** Stalker series positions (key: seriesId, value: episode positions) */
private readonly stalkerSeriesPositions = signal<
Map<number, PlaybackPositionData[]>
>(new Map());
/** Track which playlist we've loaded positions for */
private loadedPositionsForPlaylistId: string | null = null;
constructor() {
if (this.isStalker) {
effect(() => {
const playlistId = this.catalog.playlist()?.id;
if (
playlistId &&
playlistId !== this.loadedPositionsForPlaylistId
) {
this.loadedPositionsForPlaylistId = playlistId;
this.loadStalkerPositions(playlistId);
}
});
effect(() => {
const item = this.selectedItem();
const playlistId = this.catalog.playlist()?.id;
if (item && playlistId && this.contentType() === 'vod') {
this.loadSingleVodPosition(playlistId, Number(item.id));
}
});
if (window.electron?.onPlaybackPositionUpdate) {
this.unsubscribePositionUpdates =
window.electron.onPlaybackPositionUpdate(
(data: PlaybackPositionData) => {
if (
data.contentType === 'vod' &&
data.contentXtreamId
) {
const key = `vod_${data.contentXtreamId}`;
const updated = new Map(
this.stalkerPositions()
);
updated.set(key, data);
this.stalkerPositions.set(updated);
}
}
);
}
effect(() => {
const selectedItemId = this.selectedItem()?.id;
void selectedItemId;
this.closeInlinePlayer();
});
}
}
readonly contentWithProgress = computed(() => {
const items = this.paginatedContent();
if (!items) return [];
if (this.isStalker) {
const positions = this.stalkerPositions();
const seriesPositions = this.stalkerSeriesPositions();
const contentType = this.contentType();
return items.map((item: CategoryContentItem) => {
const id = Number(item.id);
const hasSeriesProgress = Boolean(
seriesPositions.get(id)?.length
);
const isSeries =
contentType === 'series' ||
item.is_series === '1' ||
item.is_series === 1;
if (hasSeriesProgress) {
return { ...item, hasSeriesProgress: true };
}
if (isSeries) {
return { ...item, hasSeriesProgress: false };
}
const vodKey = `vod_${id}`;
const vodPosition = positions.get(vodKey);
const vodProgress = this.calculateProgress(vodPosition);
return {
...item,
progress: vodProgress,
isWatched: vodProgress >= 90,
};
});
}
return items.map((item: CategoryContentItem) => ({
readonly canSortContent = computed(() => this.contentSortMode() !== null);
readonly selectedDetailComponent = computed(() =>
this.selectedItem() ? this.detailComponent : null
);
readonly contentWithProgress = computed(() =>
(this.paginatedContent() ?? []).map((item: CategoryContentItem) => ({
...item,
...this.catalog.getItemProgress(item),
}));
});
readonly isPaginatedContentLoading =
this.catalog.isPaginatedContentLoading;
readonly selectedItem = this.catalog.selectedItem;
readonly totalPages = this.catalog.totalPages;
readonly contentSortMode = this.catalog.contentSortMode;
readonly selectedStalkerItem = computed<StalkerSelectedVodItem | null>(
() => {
if (!this.isStalker) {
return null;
}
return (this.selectedItem() as unknown as StalkerSelectedVodItem | null) ?? null;
}
}))
);
/** Computed VodDetailsItem for the vod-details component */
readonly vodDetailsItem = computed<VodDetailsItem | null>(() => {
const item = this.selectedStalkerItem();
if (!item || !this.isStalker) return null;
return createStalkerVodItem(
item as unknown as StalkerVodDetails,
this.catalog.playlist()?.id ?? ''
);
});
/** Playback position for the selected VOD item (Stalker) */
readonly selectedVodPlaybackPosition = computed<number | null>(() => {
if (!this.isStalker) return null;
const item = this.selectedStalkerItem();
if (!item) return null;
const id = Number(item.id);
const vodKey = `vod_${id}`;
const position = this.stalkerPositions().get(vodKey);
return position?.positionSeconds ?? null;
});
readonly portalFavorites = createPortalFavoritesResource(
this.playlistService,
() => (this.isStalker ? this.catalog.playlist()?.id : undefined),
() => this.favoritesRefresh.refreshVersion()
);
readonly isSelectedVodFavorite = computed<boolean>(() =>
isSelectedStalkerVodFavorite(
this.vodDetailsItem(),
this.portalFavorites.value() ?? []
)
);
seasons = [];
setContentSortMode(mode: PortalCatalogSortMode): void {
this.catalog.setContentSortMode(mode);
}
ngOnInit() {
ngOnInit(): void {
const { categoryId } = this.activatedRoute.snapshot.params;
this.catalog.initialize(categoryId ?? null);
if (this.isStalker) {
const selectedCategory =
(this.selectedCategory() as CategoryContentItem | null) ?? null;
this.logger.warn('Stalker category init from route', {
routeCategoryId: categoryId ?? null,
selectedCategoryId:
selectedCategory?.category_id ??
selectedCategory?.id ??
null,
selectedContentType: this.contentType(),
});
}
}
/**
* Load all playback positions for a Stalker portal
*/
private async loadStalkerPositions(playlistId: string): Promise<void> {
const positions =
await this.playbackPositions.getAllPlaybackPositions(playlistId);
const positionsMap = new Map<string, PlaybackPositionData>();
const seriesMap = new Map<number, PlaybackPositionData[]>();
positions.forEach((pos) => {
const key = `${pos.contentType}_${pos.contentXtreamId}`;
positionsMap.set(key, pos);
if (pos.contentType === 'episode' && pos.seriesXtreamId) {
const existing = seriesMap.get(pos.seriesXtreamId) || [];
existing.push(pos);
seriesMap.set(pos.seriesXtreamId, existing);
}
});
this.stalkerPositions.set(positionsMap);
this.stalkerSeriesPositions.set(seriesMap);
}
/**
* Load playback position for a single VOD item
*/
private async loadSingleVodPosition(
playlistId: string,
vodId: number
): Promise<void> {
const position = await this.playbackPositions.getPlaybackPosition(
playlistId,
vodId,
'vod'
);
if (position) {
const key = `vod_${vodId}`;
const updated = new Map(this.stalkerPositions());
updated.set(key, position);
this.stalkerPositions.set(updated);
}
}
/**
* Calculate progress percentage from position data
*/
private calculateProgress(
position: PlaybackPositionData | undefined
): number {
if (!position || !position.durationSeconds) return 0;
const percent =
(position.positionSeconds / position.durationSeconds) * 100;
if (position.positionSeconds > 10 && percent < 1) {
return 1;
}
return Math.min(100, Math.round(percent));
}
onPageChange(event: PageEvent) {
onPageChange(event: PageEvent): void {
this.catalog.setPage(event.pageIndex);
this.catalog.setLimit(event.pageSize);
}
onItemClick(item: CategoryContentItem) {
onItemClick(item: CategoryContentItem): void {
const navigation = this.catalog.selectItem(item);
if (navigation?.length) {
this.router.navigate(navigation, {
@@ -399,224 +104,4 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
});
}
}
async createLinkToPlayVod(
cmd?: string,
title?: string,
thumbnail?: string
) {
if (!this.stalkerCatalog) {
return;
}
await this.stalkerCatalog.createLinkToPlayVod(cmd, title, thumbnail);
}
addToFavorites(item: Record<string, unknown>, onDone?: () => void) {
this.logger.debug('Add to favorites', item);
if (this.stalkerCatalog) {
this.stalkerCatalog.addToFavorites(item, onDone);
return;
}
onDone?.();
}
removeFromFavorites(favoriteId: string, onDone?: () => void) {
this.logger.debug('Remove from favorites', favoriteId);
if (this.stalkerCatalog) {
this.stalkerCatalog.removeFromFavorites(favoriteId, onDone);
return;
}
onDone?.();
}
/** Handle play from vod-details component */
onVodPlay(item: VodDetailsItem): void {
if (item.type === 'stalker') {
void this.startStalkerVodPlayback(
item.cmd,
item.data.info?.name,
item.data.info?.movie_image
);
}
}
/** Handle resume from vod-details component */
onVodResume(event: {
item: VodDetailsItem;
positionSeconds: number;
}): void {
if (event.item.type === 'stalker') {
void this.startStalkerVodPlayback(
event.item.cmd,
event.item.data.info?.name,
event.item.data.info?.movie_image,
event.positionSeconds
);
}
}
/** Handle favorite toggle from vod-details component */
onVodFavoriteToggled(event: {
item: VodDetailsItem;
isFavorite: boolean;
}): void {
toggleStalkerVodFavorite(event, {
addToFavorites: (item, onDone) => this.addToFavorites(item, onDone),
removeFromFavorites: (favoriteId, onDone) =>
this.removeFromFavorites(favoriteId, onDone),
onComplete: () => {
this.favoritesRefresh.refresh();
},
});
}
/** Handle back from vod-details component */
onVodBack(): void {
this.closeInlinePlayer();
this.catalog.clearSelectedItem();
}
handleInlineTimeUpdate(event: {
currentTime: number;
duration: number;
}): void {
const playback = this.inlinePlayback();
if (!playback?.contentInfo) return;
const now = Date.now();
if (now - this.lastInlineSaveTime <= 15000) return;
this.lastInlineSaveTime = now;
const position: PlaybackPositionData = {
...playback.contentInfo,
positionSeconds: Math.floor(event.currentTime),
durationSeconds: Math.floor(event.duration),
};
void this.playbackPositions.savePlaybackPosition(
playback.contentInfo.playlistId,
position
);
const key = `vod_${position.contentXtreamId}`;
const updated = new Map(this.stalkerPositions());
updated.set(key, position);
this.stalkerPositions.set(updated);
}
closeInlinePlayer(): void {
this.inlinePlayback.set(null);
this.lastInlineSaveTime = 0;
}
showCopyNotification(): void {
this.snackBar.open(
this.translateService.instant('PORTALS.STREAM_URL_COPIED'),
null,
{
duration: 2000,
}
);
}
/** Handle download from vod-details component */
async onVodDownload(item: VodDetailsItem): Promise<void> {
if (item.type !== 'stalker' || !this.stalkerCatalog) return;
const playlist = this.catalog.playlist();
if (!playlist || !playlist.portalUrl || !playlist.macAddress) return;
let cmdToUse = item.cmd;
const itemData = item.data as DownloadVodData;
const normalizedItemId = normalizeStalkerEntityId(itemData?.id);
if (
itemData?.has_files !== undefined &&
cmdToUse &&
!cmdToUse.includes('://') &&
cmdToUse.includes('/media/') &&
!cmdToUse.includes('/media/file_')
) {
const fileId =
await this.stalkerCatalog.fetchMovieFileId(normalizedItemId);
if (fileId) {
cmdToUse = `/media/file_${fileId}.mpg`;
}
}
const url = await this.stalkerCatalog.fetchLinkToPlay(
playlist.portalUrl,
playlist.macAddress,
cmdToUse
);
if (!url) return;
const numericId = normalizeStalkerEntityIdAsNumber(itemData?.id) ?? 0;
await this.downloadsService.startDownload({
playlistId: playlist.id,
xtreamId: numericId,
contentType: 'vod',
title: itemData?.info?.name || itemData?.title || 'Unknown',
url,
posterUrl: itemData?.info?.movie_image,
headers: {
userAgent: playlist.userAgent,
referer: playlist.referer,
origin: playlist.origin,
},
playlistName: playlist.title || 'Stalker Portal',
playlistType: 'stalker',
portalUrl: playlist.portalUrl,
macAddress: playlist.macAddress,
});
}
ngOnDestroy(): void {
this.closeInlinePlayer();
this.unsubscribePositionUpdates?.();
}
private async startStalkerVodPlayback(
cmd?: string,
title?: string,
thumbnail?: string,
startTime?: number
): Promise<void> {
if (!this.stalkerCatalog) {
return;
}
try {
const playback = await this.stalkerCatalog.resolveVodPlayback(
cmd,
title,
thumbnail,
startTime
);
this.lastInlineSaveTime = 0;
if (this.portalPlayer.isEmbeddedPlayer()) {
this.inlinePlayback.set(playback);
return;
}
this.closeInlinePlayer();
void this.portalPlayer.openResolvedPlayback(playback, true);
} catch (error) {
this.logger.error('Failed to start inline VOD playback', error);
const errorMessage =
error instanceof Error && error.message === 'nothing_to_play'
? this.translateService.instant(
'PORTALS.CONTENT_NOT_AVAILABLE'
)
: this.translateService.instant('PORTALS.PLAYBACK_ERROR');
this.snackBar.open(errorMessage, null, {
duration: 3000,
});
}
}
}
@@ -4,7 +4,8 @@ import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { ActivatedRoute } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { PlaylistSwitcherComponent, ResizableDirective } from 'components';
import { PlaylistSwitcherComponent } from '@iptvnator/playlist/shared/ui';
import { ResizableDirective } from 'components';
import { isWorkspaceLayoutRoute } from '@iptvnator/portal/shared/util';
import { StalkerPortalItem, XtreamCategory } from 'shared-interfaces';
import { CategoryViewComponent } from '../category-view/category-view.component';
@@ -5,7 +5,8 @@ import { MatIconModule } from '@angular/material/icon';
import { Router, RouterLink } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { DialogService, PlaylistInfoComponent } from 'components';
import { PlaylistInfoComponent } from '@iptvnator/playlist/shared/ui';
import { DialogService } from 'components';
import { PlaylistActions } from 'm3u-state';
import { selectCurrentPlaylist } from 'm3u-state';
import { Playlist } from 'shared-interfaces';
+1
View File
@@ -1,5 +1,6 @@
export * from './lib/favorites-context.service';
export * from './lib/logger';
export * from './lib/portal-catalog-detail';
export * from './lib/portal-catalog-facade';
export * from './lib/portal-external-playback';
export * from './lib/portal-player';
@@ -0,0 +1,6 @@
import { InjectionToken, Type } from '@angular/core';
export const PORTAL_CATALOG_DETAIL_COMPONENT =
new InjectionToken<Type<unknown> | null>('PORTAL_CATALOG_DETAIL_COMPONENT', {
factory: () => null,
});
@@ -3,5 +3,6 @@ export * from './lib/stores';
export * from './lib/stalker-content-types';
export * from './lib/stalker-live-playback.utils';
export * from './lib/stalker-series.adapters';
export * from './lib/stalker-session.service';
export * from './lib/stalker-vod.utils';
export * from './lib/stalker.store';
@@ -1,4 +1,5 @@
export interface StalkerCategoryItem {
category_id: string;
category_name: string;
[key: string]: unknown;
}
@@ -7,6 +7,13 @@ import {
} from './stalker-series.adapters';
import { StalkerSelectedVodItem } from './models';
type EpisodeWithMetadata = {
custom_sid?: string;
id?: string;
originalId?: string;
originalCmd?: string;
};
describe('stalker-series.adapters', () => {
it('detects vod-series flags from heterogeneous payloads', () => {
expect(isVodSeriesItem({ is_series: true })).toBe(true);
@@ -65,9 +72,10 @@ describe('stalker-series.adapters', () => {
);
expect(mapped['1']).toHaveLength(2);
expect(mapped['1'][0].custom_sid).toBe('vod-series');
expect((mapped['1'][0] as any).originalId).toBe('api-shared-id');
expect(mapped['1'][0].id).not.toBe(mapped['1'][1].id);
const firstEpisode = mapped['1'][0] as EpisodeWithMetadata;
expect(firstEpisode.custom_sid).toBe('vod-series');
expect(firstEpisode.originalId).toBe('api-shared-id');
expect(firstEpisode.id).not.toBe(mapped['1'][1].id);
});
it('maps embedded series payload into regular season episodes', () => {
@@ -84,7 +92,8 @@ describe('stalker-series.adapters', () => {
const mapped = mapRegularSeriesEpisodes(regularSeasons, 'poster.jpg');
expect(regularSeasons[0].series).toEqual([1, 2]);
expect(mapped['1']).toHaveLength(2);
expect(mapped['1'][0].custom_sid).toBe('regular-series');
expect((mapped['1'][0] as any).originalCmd).toBe('/media/file_100.mpg');
const firstEpisode = mapped['1'][0] as EpisodeWithMetadata;
expect(firstEpisode.custom_sid).toBe('regular-series');
expect(firstEpisode.originalCmd).toBe('/media/file_100.mpg');
});
});
@@ -1,6 +1,6 @@
import { Injectable, inject } from '@angular/core';
import { Playlist, STALKER_REQUEST } from 'shared-interfaces';
import { DataService } from './data.service';
import { DataService } from 'services';
/**
* SHA1 hash using native Web Crypto API
@@ -68,6 +68,10 @@ export interface StalkerProfileResponse {
};
}
interface StalkerAuthConfirmationResponse {
js?: boolean;
}
/**
* Service to manage Stalker portal session tokens.
* Handles handshake authentication for full stalker portals (/stalker_portal/c URLs).
@@ -262,11 +266,14 @@ export class StalkerSessionService {
try {
const response: StalkerHandshakeResponse =
await this.dataService.sendIpcEvent(STALKER_REQUEST, {
await this.dataService.sendIpcEvent<StalkerHandshakeResponse>(
STALKER_REQUEST,
{
url: portalUrl,
macAddress,
params,
});
}
);
if (response?.js?.token) {
return {
@@ -342,13 +349,16 @@ export class StalkerSessionService {
try {
const response: StalkerProfileResponse =
await this.dataService.sendIpcEvent(STALKER_REQUEST, {
await this.dataService.sendIpcEvent<StalkerProfileResponse>(
STALKER_REQUEST,
{
url: portalUrl,
macAddress,
params,
token,
serialNumber,
});
}
);
return response;
} catch (error) {
@@ -376,15 +386,16 @@ export class StalkerSessionService {
};
try {
const response = await this.dataService.sendIpcEvent(
STALKER_REQUEST,
{
const response =
await this.dataService.sendIpcEvent<StalkerAuthConfirmationResponse>(
STALKER_REQUEST,
{
url: portalUrl,
macAddress,
params,
token,
}
);
}
);
// do_auth returns { js: true } on success
if (response?.js === true) {
@@ -571,7 +582,7 @@ export class StalkerSessionService {
const serialNumber = playlist.stalkerSerialNumber;
try {
const response = await this.dataService.sendIpcEvent(
const response = await this.dataService.sendIpcEvent<T>(
STALKER_REQUEST,
{
url: playlist.portalUrl,
@@ -1,4 +1,5 @@
import { VodDetailsItem } from 'shared-interfaces';
import { StalkerFavoriteItem } from './models';
import {
createStalkerInlineDetailState,
createStalkerDetailViewState,
@@ -131,7 +132,7 @@ describe('stalker-vod.utils regressions', () => {
rating_kinopoisk: '',
},
},
} as any),
} as StalkerFavoriteItem),
null
);
@@ -4,15 +4,12 @@ import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { PORTAL_PLAYER } from '@iptvnator/portal/shared/util';
import {
DataService,
PlaylistsService,
StalkerSessionService,
} from 'services';
import { DataService, PlaylistsService } from 'services';
import { StalkerSessionService } from './stalker-session.service';
import { StalkerStore } from './stalker.store';
describe('StalkerStore API compatibility smoke', () => {
let store: any;
let store: StalkerStore;
beforeEach(() => {
TestBed.configureTestingModule({
@@ -8,7 +8,7 @@ import {
withState,
} from '@ngrx/signals';
import { TranslateService } from '@ngx-translate/core';
import { DataService, StalkerSessionService } from 'services';
import { DataService } from 'services';
import {
Playlist,
STALKER_REQUEST,
@@ -21,6 +21,7 @@ import {
StalkerItvChannel,
StalkerVodSource,
} from '../../models';
import { StalkerSessionService } from '../../stalker-session.service';
import { StalkerContentTypes } from '../../stalker-content-types';
import { toStalkerContentItem, toStalkerItvChannel } from '../utils';
@@ -45,6 +46,39 @@ const initialContentState: StalkerContentState = {
itvChannels: [],
};
interface ResourceState<T> {
value(): T;
isLoading(): boolean;
error(): unknown;
}
interface StalkerCategoryResponseItem {
id?: string | number;
title?: string;
}
interface StalkerCategoryResponse {
js?: StalkerCategoryResponseItem[];
}
interface StalkerOrderedListResponse {
js?: {
data?: StalkerVodSource[];
total_items?: number;
};
}
interface StalkerContentStoreContext {
selectedContentType(): 'vod' | 'series' | 'itv';
currentPlaylist(): Playlist | undefined;
selectedCategoryId(): string | null | undefined;
searchPhrase(): string;
page(): number;
limit(): number;
getContentResource: ResourceState<StalkerContentItem[] | undefined>;
getCategoryResource: ResourceState<StalkerCategoryItem[]>;
}
export function withStalkerContent() {
const logger = createLogger('withStalkerContent');
return signalStoreFeature(
@@ -55,17 +89,21 @@ export function withStalkerContent() {
dataService = inject(DataService),
stalkerSession = inject(StalkerSessionService),
translateService = inject(TranslateService)
) => ({
getCategoryResource: resource({
) => {
const storeContext =
store as unknown as StalkerContentStoreContext;
return {
getCategoryResource: resource({
params: () => ({
contentType: (store as any).selectedContentType(),
contentType: storeContext.selectedContentType(),
action: StalkerPortalActions.GetCategories,
currentPlaylist: (store as any).currentPlaylist(),
currentPlaylist: storeContext.currentPlaylist(),
}),
loader: async ({
params,
}): Promise<StalkerCategoryItem[]> => {
if (params.currentPlaylist === undefined) return;
if (!params.currentPlaylist) return [];
switch (params.contentType) {
case 'itv':
@@ -96,17 +134,16 @@ export function withStalkerContent() {
type: params.contentType,
};
let response: any;
let response: StalkerCategoryResponse;
if (playlist.isFullStalkerPortal) {
// Full stalker portal - use authenticated request with retry
response =
await stalkerSession.makeAuthenticatedRequest(
response = await stalkerSession.makeAuthenticatedRequest<StalkerCategoryResponse>(
playlist,
queryParams
);
} else {
// Simple stalker portal - no auth needed
response = await dataService.sendIpcEvent(
response = await dataService.sendIpcEvent<StalkerCategoryResponse>(
STALKER_REQUEST,
{
url: portalUrl,
@@ -166,18 +203,16 @@ export function withStalkerContent() {
});
return categories;
},
}),
getContentResource: resource({
}),
getContentResource: resource({
params: () => ({
contentType: (store as any).selectedContentType(),
category: (store as any).selectedCategoryId(),
contentType: storeContext.selectedContentType(),
category: storeContext.selectedCategoryId(),
action: StalkerPortalActions.GetOrderedList,
search: (store as any).searchPhrase(),
pageIndex: (store as any).page() + 1,
search: storeContext.searchPhrase(),
pageIndex: storeContext.page() + 1,
availableCategoryCount: (() => {
const contentType = (
store as any
).selectedContentType();
const contentType = storeContext.selectedContentType();
const categories =
contentType === 'vod'
? store.vodCategories()
@@ -211,7 +246,7 @@ export function withStalkerContent() {
return Promise.resolve(undefined);
}
const currentPlaylist = (store as any).currentPlaylist;
const currentPlaylist = storeContext.currentPlaylist;
// Guard: ensure currentPlaylist is available (may not be during deep link init)
if (
@@ -251,17 +286,16 @@ export function withStalkerContent() {
// Use makeAuthenticatedRequest for automatic retry on auth failure
const playlist = currentPlaylist() as Playlist;
let response: any;
let response: StalkerOrderedListResponse;
if (playlist.isFullStalkerPortal) {
// Full stalker portal - use authenticated request with retry
response =
await stalkerSession.makeAuthenticatedRequest(
response = await stalkerSession.makeAuthenticatedRequest<StalkerOrderedListResponse>(
playlist,
queryParams
);
} else {
// Simple stalker portal - no auth needed
response = await dataService.sendIpcEvent(
response = await dataService.sendIpcEvent<StalkerOrderedListResponse>(
STALKER_REQUEST,
{
url: playlist.portalUrl,
@@ -287,7 +321,7 @@ export function withStalkerContent() {
toStalkerContentItem(item, portalUrl)
);
if ((store as any).selectedContentType() === 'itv') {
if (storeContext.selectedContentType() === 'itv') {
const channels = newItems.map(toStalkerItvChannel);
// Check if we're loading the first page or loading more
if (params.pageIndex === 1) {
@@ -312,20 +346,21 @@ export function withStalkerContent() {
return newItems;
},
}),
})
}),
};
}
),
withComputed((store) => {
const storeAny = store as any;
const storeContext = store as unknown as StalkerContentStoreContext;
return {
getTotalPages: computed(() => {
return Math.ceil(store.totalCount() / storeAny.limit());
return Math.ceil(store.totalCount() / storeContext.limit());
}),
getSelectedCategoryName: computed(() => {
const type = storeAny.selectedContentType();
const selectedCategoryId = storeAny.selectedCategoryId();
const type = storeContext.selectedContentType();
const selectedCategoryId = storeContext.selectedCategoryId();
if (!selectedCategoryId) return '';
let categories = [];
let categories: StalkerCategoryItem[] = [];
if (type === 'vod') {
categories = store.vodCategories();
} else if (type === 'series') {
@@ -342,23 +377,23 @@ export function withStalkerContent() {
}),
/** category content */
getPaginatedContent: computed(() =>
storeAny.getContentResource.value()
storeContext.getContentResource.value()
),
isPaginatedContentLoading: computed(() =>
storeAny.getContentResource.isLoading()
storeContext.getContentResource.isLoading()
),
isPaginatedContentFailed: computed(() =>
storeAny.getContentResource.error()
storeContext.getContentResource.error()
),
/** category resource */
getCategoryResource: computed(() =>
storeAny.getCategoryResource.value()
storeContext.getCategoryResource.value()
),
isCategoryResourceLoading: computed(() =>
storeAny.getCategoryResource.isLoading()
storeContext.getCategoryResource.isLoading()
),
isCategoryResourceFailed: computed(() =>
storeAny.getCategoryResource.error()
storeContext.getCategoryResource.error()
),
};
}),
@@ -1,6 +1,7 @@
import { inject } from '@angular/core';
import { signalStoreFeature, withMethods } from '@ngrx/signals';
import { DataService, StalkerSessionService } from 'services';
import { DataService } from 'services';
import { StalkerSessionService } from '../../stalker-session.service';
import {
EpgItem,
Playlist,
@@ -8,6 +9,25 @@ import {
StalkerPortalActions,
} from 'shared-interfaces';
interface EpgStoreContext {
currentPlaylist(): Playlist | undefined;
}
interface StalkerEpgEntry {
id?: string | number;
name?: string;
descr?: string;
time?: string;
time_to?: string;
ch_id?: string | number;
start_timestamp?: string | number;
stop_timestamp?: string | number;
}
interface StalkerEpgResponse {
js?: StalkerEpgEntry[] | { data?: StalkerEpgEntry[] };
}
/**
* EPG concern methods.
*/
@@ -19,14 +39,14 @@ export function withStalkerEpg() {
dataService = inject(DataService),
stalkerSession = inject(StalkerSessionService)
) => {
const storeAny = store as any;
const storeContext = store as unknown as EpgStoreContext;
return {
async fetchChannelEpg(
channelId: number | string,
size = 10
): Promise<EpgItem[]> {
const playlist = storeAny.currentPlaylist() as Playlist;
const playlist = storeContext.currentPlaylist();
if (!playlist) return [];
const queryParams: Record<string, string> = {
@@ -36,7 +56,7 @@ export function withStalkerEpg() {
size: String(size),
};
let response: any;
let response: StalkerEpgResponse;
if (playlist.isFullStalkerPortal) {
response =
await stalkerSession.makeAuthenticatedRequest(
@@ -44,7 +64,8 @@ export function withStalkerEpg() {
queryParams
);
} else {
response = await dataService.sendIpcEvent(
response =
await dataService.sendIpcEvent<StalkerEpgResponse>(
STALKER_REQUEST,
{
url: playlist.portalUrl,
@@ -54,7 +75,9 @@ export function withStalkerEpg() {
);
}
const epgData = response?.js?.data ?? response?.js ?? [];
const epgData = Array.isArray(response?.js)
? response.js
: response?.js?.data ?? [];
const items = Array.isArray(epgData) ? epgData : [];
return items.map((item) => ({
@@ -5,7 +5,17 @@ import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistActions } from 'm3u-state';
import { PlaylistsService } from 'services';
import { PlaylistMeta } from 'shared-interfaces';
import { PlaylistMeta, StalkerPortalItem } from 'shared-interfaces';
interface FavoritesStoreContext {
currentPlaylist(): PlaylistMeta | undefined;
selectedContentType(): string;
}
type FavoritePayload = StalkerPortalItem & {
stream_id?: string | number;
id?: string | number;
};
/**
* Favorites concern methods.
@@ -20,14 +30,14 @@ export function withStalkerFavorites() {
translate = inject(TranslateService),
ngrxStore = inject(Store)
) => {
const storeAny = store as any;
const storeContext = store as unknown as FavoritesStoreContext;
return {
addToFavorites(item: any, onDone?: () => void) {
const portalId = storeAny.currentPlaylist()?._id;
addToFavorites(item: FavoritePayload, onDone?: () => void) {
const portalId = storeContext.currentPlaylist()?._id;
playlistService
.addPortalFavorite(portalId, {
...item,
category_id: storeAny.selectedContentType(),
category_id: storeContext.selectedContentType(),
added_at: Date.now(),
id: item.stream_id ?? item.id,
})
@@ -57,7 +67,7 @@ export function withStalkerFavorites() {
favoriteId: string,
onDone?: () => void
) {
const portalId = storeAny.currentPlaylist()?._id;
const portalId = storeContext.currentPlaylist()?._id;
playlistService
.removeFromPortalFavorites(portalId, favoriteId)
.subscribe((updatedPlaylist) => {
@@ -5,7 +5,7 @@ import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistActions } from 'm3u-state';
import { PORTAL_PLAYER } from '@iptvnator/portal/shared/util';
import { DataService, PlaylistsService, StalkerSessionService } from 'services';
import { DataService, PlaylistsService } from 'services';
import {
Playlist,
PlaylistMeta,
@@ -22,6 +22,7 @@ import {
isCrossOriginStalkerStream,
STALKER_MAG_USER_AGENT,
} from '../../stalker-live-playback.utils';
import { StalkerSessionService } from '../../stalker-session.service';
import {
normalizeStalkerEntityId,
normalizeStalkerEntityIdAsNumber,
@@ -7,8 +7,9 @@ import {
withState,
} from '@ngrx/signals';
import { Playlist, PlaylistMeta, STALKER_REQUEST } from 'shared-interfaces';
import { DataService, StalkerSessionService } from 'services';
import { createLogger } from '@iptvnator/portal/shared/util';
import { DataService } from 'services';
import { StalkerSessionService } from '../../stalker-session.service';
/**
* Portal/session state and methods.
@@ -36,7 +37,7 @@ export function withStalkerPortal() {
*/
async makeStalkerRequest(
playlist: PlaylistMeta,
params: Record<string, any>
params: Record<string, string | number>
) {
// Get token if it's a full stalker portal
let token: string | undefined;
@@ -3,9 +3,19 @@ import { signalStoreFeature, withMethods } from '@ngrx/signals';
import { Store } from '@ngrx/store';
import { PlaylistActions } from 'm3u-state';
import { PlaylistsService } from 'services';
import { PlaylistMeta } from 'shared-interfaces';
import { PlaylistMeta, StalkerPortalItem } from 'shared-interfaces';
import { createLogger } from '@iptvnator/portal/shared/util';
interface RecentStoreContext {
currentPlaylist(): PlaylistMeta | undefined;
selectedContentType(): string;
}
type RecentlyViewedPayload = StalkerPortalItem & {
id?: string | number;
title?: string;
};
/**
* Recently-viewed concern methods.
*/
@@ -18,17 +28,24 @@ export function withStalkerRecent() {
playlistService = inject(PlaylistsService),
ngrxStore = inject(Store)
) => {
const storeAny = store as any;
const storeContext = store as unknown as RecentStoreContext;
return {
addToRecentlyViewed(item: any) {
const portalId = storeAny.currentPlaylist()?._id;
addToRecentlyViewed(item: RecentlyViewedPayload) {
const portalId = storeContext.currentPlaylist()?._id;
const recentItem = {
...item,
category_id: storeContext.selectedContentType(),
added_at: Date.now(),
id: item.id ?? item.stream_id ?? '',
title:
item.title ??
item.name ??
item.o_name ??
'',
};
playlistService
.addPortalRecentlyViewed(portalId, {
...item,
category_id: storeAny.selectedContentType(),
added_at: Date.now(),
})
.addPortalRecentlyViewed(portalId, recentItem)
.subscribe((updatedPlaylist) => {
ngrxStore.dispatch(
PlaylistActions.updatePlaylistMeta({
@@ -45,7 +62,7 @@ export function withStalkerRecent() {
itemId: string | number,
onComplete?: () => void
) {
const portalId = storeAny.currentPlaylist()?._id;
const portalId = storeContext.currentPlaylist()?._id;
playlistService
.removeFromPortalRecentlyViewed(portalId, itemId)
.subscribe({
@@ -5,9 +5,15 @@ import {
withMethods,
withState,
} from '@ngrx/signals';
import { StalkerVodSource } from '../../models';
import { StalkerCategoryItem, StalkerVodSource } from '../../models';
import { normalizeStalkerEntityId } from '../../stalker-vod.utils';
interface SelectionStoreContext {
vodCategories(): StalkerCategoryItem[];
seriesCategories(): StalkerCategoryItem[];
itvCategories(): StalkerCategoryItem[];
}
/**
* Selection/pagination/search feature state.
*/
@@ -93,7 +99,7 @@ export function withStalkerSelection() {
},
/** getters */
getSelectedCategory: computed(() => {
const storeAny = store as any;
const storeContext = store as unknown as SelectionStoreContext;
const categoryId = store.selectedCategoryId();
if (!categoryId) {
return {
@@ -105,15 +111,15 @@ export function withStalkerSelection() {
// Get categories based on content type
const contentType = store.selectedContentType();
let categories: any[] = [];
let categories: StalkerCategoryItem[] = [];
const readCategories = (
getterName:
| 'vodCategories'
| 'seriesCategories'
| 'itvCategories'
) =>
typeof storeAny[getterName] === 'function'
? storeAny[getterName]()
typeof storeContext[getterName] === 'function'
? storeContext[getterName]()
: [];
if (contentType === 'vod') {
categories = readCategories('vodCategories');
@@ -125,7 +131,8 @@ export function withStalkerSelection() {
return (
categories.find(
(c: any) => String(c.category_id) === String(categoryId)
(category) =>
String(category.category_id) === String(categoryId)
) || {
category_id: categoryId,
category_name: '',
@@ -7,12 +7,15 @@ import {
withProps,
withState,
} from '@ngrx/signals';
import { DataService, StalkerSessionService } from 'services';
import { DataService } from 'services';
import { Playlist, STALKER_REQUEST, StalkerPortalActions } from 'shared-interfaces';
import {
StalkerSeason,
StalkerVodSource,
StalkerVodSeriesEpisode,
StalkerVodSeriesSeason,
} from '../../models';
import { StalkerSessionService } from '../../stalker-session.service';
import { StalkerContentTypes } from '../../stalker-content-types';
import {
sortByNumericValue,
@@ -37,6 +40,26 @@ const initialSeriesState: StalkerSeriesState = {
selectedVodSeriesSeasonId: undefined,
};
interface ResourceState<T> {
value(): T;
isLoading(): boolean;
}
interface StalkerSeriesResponse<T> {
js?: {
data?: T[];
};
}
interface StalkerSeriesStoreContext {
selectedSerialId(): string | undefined;
currentPlaylist(): Playlist | undefined;
selectedItem(): StalkerVodSource | null | undefined;
selectedContentType(): 'vod' | 'series' | 'itv';
serialSeasonsResource: ResourceState<StalkerSeason[]>;
vodSeriesSeasonsResource: ResourceState<StalkerVodSeriesSeason[]>;
}
export function withStalkerSeries() {
const logger = createLogger('withStalkerSeries');
const toMovieId = (value: unknown): string => {
@@ -51,18 +74,22 @@ export function withStalkerSeries() {
store,
dataService = inject(DataService),
stalkerSession = inject(StalkerSessionService)
) => ({
serialSeasonsResource: resource({
) => {
const storeContext =
store as unknown as StalkerSeriesStoreContext;
return {
serialSeasonsResource: resource({
params: () => ({
itemId: (store as any).selectedSerialId(),
itemId: storeContext.selectedSerialId(),
}),
loader: async ({ params }) => {
loader: async ({ params }): Promise<StalkerSeason[]> => {
const movieId = toMovieId(params.itemId);
// Guard: ensure currentPlaylist and itemId are available
if (!(store as any).currentPlaylist() || !movieId) {
if (!storeContext.currentPlaylist() || !movieId) {
return [];
}
const playlist = (store as any).currentPlaylist() as Playlist;
const playlist = storeContext.currentPlaylist() as Playlist;
const queryParams = {
action: StalkerContentTypes.series.getContentAction,
type: 'series',
@@ -70,17 +97,16 @@ export function withStalkerSeries() {
};
// Use makeAuthenticatedRequest for automatic retry on auth failure
let response: any;
let response: StalkerSeriesResponse<StalkerSeason>;
if (playlist.isFullStalkerPortal) {
// Full stalker portal - use authenticated request with retry
response =
await stalkerSession.makeAuthenticatedRequest(
response = await stalkerSession.makeAuthenticatedRequest<StalkerSeriesResponse<StalkerSeason>>(
playlist,
queryParams
);
} else {
// Simple stalker portal - no auth needed
response = await dataService.sendIpcEvent(
response = await dataService.sendIpcEvent<StalkerSeriesResponse<StalkerSeason>>(
STALKER_REQUEST,
{
url: playlist.portalUrl,
@@ -104,9 +130,8 @@ export function withStalkerSeries() {
*/
vodSeriesSeasonsResource: resource({
params: () => ({
selectedItem: (store as any).selectedItem(),
selectedContentType:
(store as any).selectedContentType?.() ?? 'vod',
selectedItem: storeContext.selectedItem(),
selectedContentType: storeContext.selectedContentType(),
}),
loader: async ({
params,
@@ -115,12 +140,12 @@ export function withStalkerSeries() {
logger.debug('vodSeriesSeasonsResource loader called', {
item,
isSeries: item?.is_series,
currentPlaylist: (store as any).currentPlaylist(),
currentPlaylist: storeContext.currentPlaylist(),
});
// Only fetch if item is a VOD series (has is_series flag)
if (
!(store as any).currentPlaylist() ||
!storeContext.currentPlaylist() ||
params.selectedContentType !== 'vod' ||
!item ||
!item.is_series
@@ -131,7 +156,7 @@ export function withStalkerSeries() {
return [];
}
const playlist = (store as any).currentPlaylist() as Playlist;
const playlist = storeContext.currentPlaylist() as Playlist;
const queryParams = {
action: StalkerPortalActions.GetOrderedList,
type: 'vod',
@@ -139,15 +164,14 @@ export function withStalkerSeries() {
p: '1',
};
let response: any;
let response: StalkerSeriesResponse<StalkerVodSeriesSeason>;
if (playlist.isFullStalkerPortal) {
response =
await stalkerSession.makeAuthenticatedRequest(
response = await stalkerSession.makeAuthenticatedRequest<StalkerSeriesResponse<StalkerVodSeriesSeason>>(
playlist,
queryParams
);
} else {
response = await dataService.sendIpcEvent(
response = await dataService.sendIpcEvent<StalkerSeriesResponse<StalkerVodSeriesSeason>>(
STALKER_REQUEST,
{
url: playlist.portalUrl,
@@ -180,25 +204,26 @@ export function withStalkerSeries() {
);
return sortVodSeriesSeasonsByNumber(seasons);
},
}),
})
}),
};
}
),
withComputed((store) => {
const storeAny = store as any;
const storeContext = store as unknown as StalkerSeriesStoreContext;
return {
/** serials */
getSerialSeasonsResource: computed(() =>
storeAny.serialSeasonsResource.value()
storeContext.serialSeasonsResource.value()
),
isSerialSeasonsLoading: computed(() =>
storeAny.serialSeasonsResource.isLoading()
storeContext.serialSeasonsResource.isLoading()
),
/** VOD series (Ministra plugin is_series=1) */
getVodSeriesSeasonsResource: computed(() =>
storeAny.vodSeriesSeasonsResource.value()
storeContext.vodSeriesSeasonsResource.value()
),
isVodSeriesSeasonsLoading: computed(() =>
storeAny.vodSeriesSeasonsResource.isLoading()
storeContext.vodSeriesSeasonsResource.isLoading()
),
};
}),
@@ -208,7 +233,11 @@ export function withStalkerSeries() {
dataService = inject(DataService),
stalkerSession = inject(StalkerSessionService)
) => {
const storeAny = store as any;
const storeContext =
store as unknown as Pick<
StalkerSeriesStoreContext,
'currentPlaylist'
>;
return {
/**
@@ -221,7 +250,7 @@ export function withStalkerSeries() {
videoId: string,
seasonId: string
): Promise<StalkerVodSeriesEpisode[]> {
const playlist = storeAny.currentPlaylist() as Playlist;
const playlist = storeContext.currentPlaylist() as Playlist;
if (!playlist) return [];
const queryParams = {
@@ -232,15 +261,14 @@ export function withStalkerSeries() {
p: '1',
};
let response: any;
let response: StalkerSeriesResponse<StalkerVodSeriesEpisode>;
if (playlist.isFullStalkerPortal) {
response =
await stalkerSession.makeAuthenticatedRequest(
response = await stalkerSession.makeAuthenticatedRequest<StalkerSeriesResponse<StalkerVodSeriesEpisode>>(
playlist,
queryParams
);
} else {
response = await dataService.sendIpcEvent(
response = await dataService.sendIpcEvent<StalkerSeriesResponse<StalkerVodSeriesEpisode>>(
STALKER_REQUEST,
{
url: playlist.portalUrl,
+3
View File
@@ -1,10 +1,13 @@
export * from './lib/stalker-main-container.component';
export * from './lib/stalker-inline-detail/stalker-inline-detail.component';
export * from './lib/stalker-collection-channels-list/stalker-collection-channels-list.component';
export * from './lib/stalker-catalog-detail/stalker-catalog-detail.component';
export * from './lib/stalker-favorites/stalker-favorites.component';
export * from './lib/stalker-favorites-button/stalker-favorites-button.component';
export * from './lib/stalker-catalog-facade.service';
export * from './lib/stalker-feature.routes';
export * from './lib/stalker-live-stream-layout/stalker-live-stream-layout.component';
export * from './lib/stalker-search/stalker-search.component';
export * from './lib/stalker-series-view/stalker-series-view.component';
export * from './lib/stalker-shell/stalker-shell.component';
export * from './lib/recently-viewed/recently-viewed.component';
@@ -0,0 +1,25 @@
@if (contentType() === 'vod') {
@if ((selectedItem()?.series?.length ?? 0) > 0) {
<app-stalker-series-view [vodWithSeries]="selectedItem()" />
} @else if (isSeriesDetail()) {
<app-stalker-series-view />
} @else if (vodDetailsItem()) {
<app-vod-details
[item]="vodDetailsItem()!"
[isFavorite]="isSelectedVodFavorite()"
[playbackPosition]="selectedVodPlaybackPosition()"
[inlinePlayback]="inlinePlayback()"
[externalPlayback]="externalPlayback.activeSession()"
(playClicked)="onVodPlay($event)"
(resumeClicked)="onVodResume($event)"
(favoriteToggled)="onVodFavoriteToggled($event)"
(downloadRequested)="onVodDownload($event)"
(backClicked)="onVodBack()"
(inlineTimeUpdated)="handleInlineTimeUpdate($event)"
(inlinePlaybackClosed)="closeInlinePlayer()"
(streamUrlCopied)="showCopyNotification()"
/>
}
} @else if (contentType() === 'series') {
<app-stalker-series-view />
}
@@ -0,0 +1,371 @@
import {
Component,
OnDestroy,
computed,
effect,
inject,
signal,
} from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import {
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
createLogger,
} from '@iptvnator/portal/shared/util';
import {
createPortalFavoritesResource,
createRefreshTrigger,
isSelectedStalkerVodFavorite,
normalizeStalkerEntityId,
normalizeStalkerEntityIdAsNumber,
StalkerSelectedVodItem,
toggleStalkerVodFavorite,
} from '@iptvnator/portal/stalker/data-access';
import { VodDetailsComponent } from '@iptvnator/ui/playback';
import { DownloadsService, PlaylistsService } from 'services';
import {
createStalkerVodItem,
PlaybackPositionData,
ResolvedPortalPlayback,
StalkerVodDetails,
VodDetailsItem,
} from 'shared-interfaces';
import { StalkerCatalogFacadeService } from '../stalker-catalog-facade.service';
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
interface DownloadVodData {
id?: string | number;
has_files?: unknown;
info?: {
name?: string;
movie_image?: string;
};
title?: string;
}
@Component({
selector: 'app-stalker-catalog-detail',
imports: [StalkerSeriesViewComponent, VodDetailsComponent],
templateUrl: './stalker-catalog-detail.component.html',
styles: [
`
:host {
display: block;
height: 100%;
width: 100%;
}
`,
],
})
export class StalkerCatalogDetailComponent implements OnDestroy {
private readonly catalog = inject(StalkerCatalogFacadeService);
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
private readonly portalPlayer = inject(PORTAL_PLAYER);
readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
private readonly snackBar = inject(MatSnackBar);
private readonly translateService = inject(TranslateService);
private readonly playlistService = inject(PlaylistsService);
private readonly downloadsService = inject(DownloadsService);
private readonly logger = createLogger('StalkerCatalogDetail');
private readonly favoritesRefresh = createRefreshTrigger();
readonly contentType = this.catalog.contentType;
readonly selectedItem = computed<StalkerSelectedVodItem | null>(
() =>
(this.catalog.selectedItem() as StalkerSelectedVodItem | null) ??
null
);
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
private readonly selectedVodPosition = signal<PlaybackPositionData | null>(
null
);
private lastInlineSaveTime = 0;
private unsubscribePositionUpdates: (() => void) | null = null;
readonly isSeriesDetail = computed(() => {
const item = this.selectedItem();
return Boolean(
item &&
(this.contentType() === 'series' ||
item.is_series === true ||
String(item.is_series) === '1')
);
});
readonly vodDetailsItem = computed<VodDetailsItem | null>(() => {
const item = this.selectedItem();
if (!item || this.contentType() !== 'vod' || this.isSeriesDetail()) {
return null;
}
return createStalkerVodItem(
item as unknown as StalkerVodDetails,
this.catalog.playlist()?.id ?? ''
);
});
readonly selectedVodPlaybackPosition = computed<number | null>(
() => this.selectedVodPosition()?.positionSeconds ?? null
);
readonly portalFavorites = createPortalFavoritesResource(
this.playlistService,
() => this.catalog.playlist()?.id,
() => this.favoritesRefresh.refreshVersion()
);
readonly isSelectedVodFavorite = computed<boolean>(() =>
isSelectedStalkerVodFavorite(
this.vodDetailsItem(),
this.portalFavorites.value() ?? []
)
);
constructor() {
effect(() => {
const item = this.selectedItem();
const playlistId = this.catalog.playlist()?.id;
if (
!item ||
!playlistId ||
this.contentType() !== 'vod' ||
this.isSeriesDetail()
) {
this.selectedVodPosition.set(null);
return;
}
void this.loadSelectedVodPosition(playlistId, Number(item.id));
});
effect(() => {
const selectedItemId = this.selectedItem()?.id;
void selectedItemId;
this.closeInlinePlayer();
});
if (window.electron?.onPlaybackPositionUpdate) {
this.unsubscribePositionUpdates =
window.electron.onPlaybackPositionUpdate(
(data: PlaybackPositionData) => {
const currentItem = this.selectedItem();
if (
data.contentType !== 'vod' ||
data.playlistId !== this.catalog.playlist()?.id ||
data.contentXtreamId !== Number(currentItem?.id)
) {
return;
}
this.selectedVodPosition.set(data);
}
);
}
}
onVodPlay(item: VodDetailsItem): void {
if (item.type === 'stalker') {
void this.startStalkerVodPlayback(
item.cmd,
item.data.info?.name,
item.data.info?.movie_image
);
}
}
onVodResume(event: {
item: VodDetailsItem;
positionSeconds: number;
}): void {
if (event.item.type === 'stalker') {
void this.startStalkerVodPlayback(
event.item.cmd,
event.item.data.info?.name,
event.item.data.info?.movie_image,
event.positionSeconds
);
}
}
onVodFavoriteToggled(event: {
item: VodDetailsItem;
isFavorite: boolean;
}): void {
toggleStalkerVodFavorite(event, {
addToFavorites: (item, onDone) =>
this.catalog.addToFavorites(item, onDone),
removeFromFavorites: (favoriteId, onDone) =>
this.catalog.removeFromFavorites(favoriteId, onDone),
onComplete: () => {
this.favoritesRefresh.refresh();
},
});
}
onVodBack(): void {
this.closeInlinePlayer();
this.catalog.clearSelectedItem();
}
handleInlineTimeUpdate(event: {
currentTime: number;
duration: number;
}): void {
const playback = this.inlinePlayback();
if (!playback?.contentInfo) {
return;
}
const now = Date.now();
if (now - this.lastInlineSaveTime <= 15000) {
return;
}
this.lastInlineSaveTime = now;
const position: PlaybackPositionData = {
...playback.contentInfo,
positionSeconds: Math.floor(event.currentTime),
durationSeconds: Math.floor(event.duration),
};
void this.playbackPositions.savePlaybackPosition(
playback.contentInfo.playlistId,
position
);
this.selectedVodPosition.set(position);
}
closeInlinePlayer(): void {
this.inlinePlayback.set(null);
this.lastInlineSaveTime = 0;
}
showCopyNotification(): void {
this.snackBar.open(
this.translateService.instant('PORTALS.STREAM_URL_COPIED'),
null,
{
duration: 2000,
}
);
}
async onVodDownload(item: VodDetailsItem): Promise<void> {
if (item.type !== 'stalker') {
return;
}
const playlist = this.catalog.playlist();
if (!playlist || !playlist.portalUrl || !playlist.macAddress) {
return;
}
let cmdToUse = item.cmd;
const itemData = item.data as DownloadVodData;
const normalizedItemId = normalizeStalkerEntityId(itemData?.id);
if (
itemData?.has_files !== undefined &&
cmdToUse &&
!cmdToUse.includes('://') &&
cmdToUse.includes('/media/') &&
!cmdToUse.includes('/media/file_')
) {
const fileId =
await this.catalog.fetchMovieFileId(normalizedItemId);
if (fileId) {
cmdToUse = `/media/file_${fileId}.mpg`;
}
}
const url = await this.catalog.fetchLinkToPlay(
playlist.portalUrl,
playlist.macAddress,
cmdToUse
);
if (!url) {
return;
}
const numericId = normalizeStalkerEntityIdAsNumber(itemData?.id) ?? 0;
await this.downloadsService.startDownload({
playlistId: playlist.id,
xtreamId: numericId,
contentType: 'vod',
title: itemData?.info?.name || itemData?.title || 'Unknown',
url,
posterUrl: itemData?.info?.movie_image,
headers: {
userAgent: playlist.userAgent,
referer: playlist.referer,
origin: playlist.origin,
},
playlistName: playlist.title || 'Stalker Portal',
playlistType: 'stalker',
portalUrl: playlist.portalUrl,
macAddress: playlist.macAddress,
});
}
ngOnDestroy(): void {
this.closeInlinePlayer();
this.unsubscribePositionUpdates?.();
}
private async loadSelectedVodPosition(
playlistId: string,
vodId: number
): Promise<void> {
if (Number.isNaN(vodId)) {
this.selectedVodPosition.set(null);
return;
}
const position = await this.playbackPositions.getPlaybackPosition(
playlistId,
vodId,
'vod'
);
this.selectedVodPosition.set(position ?? null);
}
private async startStalkerVodPlayback(
cmd?: string,
title?: string,
thumbnail?: string,
startTime?: number
): Promise<void> {
try {
const playback = await this.catalog.resolveVodPlayback(
cmd,
title,
thumbnail,
startTime
);
this.lastInlineSaveTime = 0;
if (this.portalPlayer.isEmbeddedPlayer()) {
this.inlinePlayback.set(playback);
return;
}
this.closeInlinePlayer();
void this.portalPlayer.openResolvedPlayback(playback, true);
} catch (error) {
this.logger.error('Failed to start inline VOD playback', error);
const errorMessage =
error instanceof Error && error.message === 'nothing_to_play'
? this.translateService.instant(
'PORTALS.CONTENT_NOT_AVAILABLE'
)
: this.translateService.instant('PORTALS.PLAYBACK_ERROR');
this.snackBar.open(errorMessage, null, {
duration: 3000,
});
}
}
}
@@ -1,4 +1,12 @@
import { Provider, Injectable, computed, inject } from '@angular/core';
import {
DestroyRef,
Injectable,
Provider,
computed,
effect,
inject,
signal,
} from '@angular/core';
import {
buildStalkerSelectedVodItem,
StalkerStore,
@@ -9,8 +17,25 @@ import {
PortalCatalogPlaylistMeta,
PortalCatalogSortMode,
PORTAL_CATALOG_FACADE,
PORTAL_PLAYBACK_POSITIONS,
StalkerPortalCatalogFacade,
} from '@iptvnator/portal/shared/util';
import { PlaybackPositionData } from 'shared-interfaces';
function calculateProgress(position: PlaybackPositionData | undefined): number {
if (!position || !position.durationSeconds) {
return 0;
}
const percent =
(position.positionSeconds / position.durationSeconds) * 100;
if (position.positionSeconds > 10 && percent < 1) {
return 1;
}
return Math.min(100, Math.round(percent));
}
@Injectable()
export class StalkerCatalogFacadeService
@@ -22,6 +47,15 @@ export class StalkerCatalogFacadeService
>
{
private readonly stalkerStore = inject(StalkerStore);
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
private readonly destroyRef = inject(DestroyRef);
private readonly stalkerPositions = signal<Map<string, PlaybackPositionData>>(
new Map()
);
private readonly stalkerSeriesPositions = signal<
Map<number, PlaybackPositionData[]>
>(new Map());
private loadedPositionsForPlaylistId: string | null = null;
readonly provider = 'stalker' as const;
readonly pageSizeOptions = [14] as const;
@@ -69,6 +103,51 @@ export class StalkerCatalogFacadeService
};
});
constructor() {
effect(() => {
const playlistId = this.playlist()?.id;
if (!playlistId) {
this.loadedPositionsForPlaylistId = null;
this.stalkerPositions.set(new Map());
this.stalkerSeriesPositions.set(new Map());
return;
}
if (playlistId === this.loadedPositionsForPlaylistId) {
return;
}
this.loadedPositionsForPlaylistId = playlistId;
void this.loadStalkerPositions(playlistId);
});
if (window.electron?.onPlaybackPositionUpdate) {
const unsubscribe = window.electron.onPlaybackPositionUpdate(
(data: PlaybackPositionData) => {
if (data.playlistId !== this.playlist()?.id) {
return;
}
if (data.contentType === 'vod') {
this.updateVodPlaybackPosition(data);
}
if (
data.contentType === 'episode' &&
data.seriesXtreamId
) {
this.updateSeriesPlaybackPosition(data);
}
}
);
if (typeof unsubscribe === 'function') {
this.destroyRef.onDestroy(unsubscribe);
}
}
}
initialize(categoryId?: string | null): void {
this.clearSelectedItem();
if (categoryId) {
@@ -108,8 +187,34 @@ export class StalkerCatalogFacadeService
}
getItemProgress(item: StalkerVodSource): PortalCatalogItemProgress {
void item;
return {};
const numericId = Number(item.id);
if (Number.isNaN(numericId)) {
return {};
}
const hasSeriesProgress = Boolean(
this.stalkerSeriesPositions().get(numericId)?.length
);
const isSeries =
this.contentType() === 'series' ||
item.is_series === '1' ||
item.is_series === 1;
if (hasSeriesProgress) {
return { hasSeriesProgress: true };
}
if (isSeries) {
return { hasSeriesProgress: false };
}
const progress = calculateProgress(
this.stalkerPositions().get(`vod_${numericId}`)
);
return {
progress,
isWatched: progress >= 90,
};
}
async createLinkToPlayVod(
@@ -155,6 +260,52 @@ export class StalkerCatalogFacadeService
startTime
);
}
private async loadStalkerPositions(playlistId: string): Promise<void> {
const positions =
await this.playbackPositions.getAllPlaybackPositions(playlistId);
const positionsMap = new Map<string, PlaybackPositionData>();
const seriesMap = new Map<number, PlaybackPositionData[]>();
positions.forEach((position) => {
positionsMap.set(
`${position.contentType}_${position.contentXtreamId}`,
position
);
if (position.contentType === 'episode' && position.seriesXtreamId) {
const existing = seriesMap.get(position.seriesXtreamId) ?? [];
existing.push(position);
seriesMap.set(position.seriesXtreamId, existing);
}
});
this.stalkerPositions.set(positionsMap);
this.stalkerSeriesPositions.set(seriesMap);
}
private updateVodPlaybackPosition(position: PlaybackPositionData): void {
const updated = new Map(this.stalkerPositions());
updated.set(`vod_${position.contentXtreamId}`, position);
this.stalkerPositions.set(updated);
}
private updateSeriesPlaybackPosition(position: PlaybackPositionData): void {
if (!position.seriesXtreamId) {
return;
}
const updated = new Map(this.stalkerSeriesPositions());
const positionsForSeries = [
...(updated.get(position.seriesXtreamId) ?? []).filter(
(item) => item.contentXtreamId !== position.contentXtreamId
),
position,
];
updated.set(position.seriesXtreamId, positionsForSeries);
this.stalkerSeriesPositions.set(updated);
}
}
export function provideStalkerCatalogFacade(): Provider[] {
@@ -5,7 +5,16 @@ import { TranslateModule } from '@ngx-translate/core';
import { BehaviorSubject, map, switchMap } from 'rxjs';
import { PlaylistsService } from 'services';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { matchesFavoriteById } from '@iptvnator/portal/stalker/data-access';
import {
matchesFavoriteById,
StalkerSelectedVodItem,
} from '@iptvnator/portal/stalker/data-access';
type FavoriteButtonItem = StalkerSelectedVodItem & {
name?: string;
o_name?: string;
cover?: string;
};
@Component({
selector: 'app-favorites-button',
@@ -18,7 +27,7 @@ export class FavoritesButtonComponent {
private stalkerStore = inject(StalkerStore);
readonly itemId = input.required<string>();
readonly item = input.required<any>();
readonly item = input.required<FavoriteButtonItem>();
private readonly favoritesChanged$ = new BehaviorSubject<void>(undefined);
@@ -1,4 +1,6 @@
import { Route } from '@angular/router';
import { PORTAL_CATALOG_DETAIL_COMPONENT } from '@iptvnator/portal/shared/util';
import { StalkerCatalogDetailComponent } from './stalker-catalog-detail/stalker-catalog-detail.component';
import { provideStalkerCatalogFacade } from './stalker-catalog-facade.service';
type ComponentLoader = NonNullable<Route['loadComponent']>;
@@ -56,7 +58,13 @@ export function createStalkerRoutes(): Route[] {
},
{
path: 'vod',
providers: provideStalkerCatalogFacade(),
providers: [
...provideStalkerCatalogFacade(),
{
provide: PORTAL_CATALOG_DETAIL_COMPONENT,
useValue: StalkerCatalogDetailComponent,
},
],
loadComponent: loadStalkerMainContainerComponent,
children: [
{
@@ -88,7 +96,13 @@ export function createStalkerRoutes(): Route[] {
},
{
path: 'series',
providers: provideStalkerCatalogFacade(),
providers: [
...provideStalkerCatalogFacade(),
{
provide: PORTAL_CATALOG_DETAIL_COMPONENT,
useValue: StalkerCatalogDetailComponent,
},
],
loadComponent: loadStalkerMainContainerComponent,
children: [
{
@@ -7,11 +7,9 @@ import {
} from '@angular/core';
import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util';
import { StalkerSelectedVodItem } from '@iptvnator/portal/stalker/data-access';
import { VodDetailsComponent } from '@iptvnator/ui/playback';
import { VodDetailsItem } from 'shared-interfaces';
import {
StalkerSeriesViewComponent,
VodDetailsComponent,
} from 'components';
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
@Component({
selector: 'app-stalker-inline-detail',
@@ -23,10 +23,10 @@ import { ActivatedRoute } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import {
ChannelListItemComponent,
PlaylistSwitcherComponent,
ResizableDirective,
} from 'components';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { PlaylistSwitcherComponent } from '@iptvnator/playlist/shared/ui';
import { PlaylistsService } from 'services';
import { EpgItem, EpgProgram } from 'shared-interfaces';
import { EpgViewComponent, WebPlayerViewComponent } from 'shared-portals';
@@ -4,7 +4,8 @@ import { MatIcon } from '@angular/material/icon';
import { MatPaginatorModule } from '@angular/material/paginator';
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { PlaylistSwitcherComponent, ResizableDirective } from 'components';
import { PlaylistSwitcherComponent } from '@iptvnator/playlist/shared/ui';
import { ResizableDirective } from 'components';
import {
CategoryViewComponent,
PlaylistErrorViewComponent,
@@ -4,8 +4,9 @@ import { MatCheckboxModule } from '@angular/material/checkbox';
import { ActivatedRoute } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslatePipe } from '@ngx-translate/core';
import { StalkerSessionService } from '@iptvnator/portal/stalker/data-access';
import { selectPlaylistById } from 'm3u-state';
import { DataService, PlaylistsService, StalkerSessionService } from 'services';
import { DataService, PlaylistsService } from 'services';
import {
Playlist,
STALKER_REQUEST,
@@ -44,6 +45,14 @@ interface StalkerFilter {
translationKey: string;
}
interface StalkerSearchResponse {
js?: {
data?: StalkerVodSource[];
};
message?: string;
status?: number;
}
@Component({
selector: 'app-stalker-search',
imports: [
@@ -136,9 +145,10 @@ export class StalkerSearchComponent {
}
}
const response = await this.dataService.sendIpcEvent(
STALKER_REQUEST,
{
const response =
await this.dataService.sendIpcEvent<StalkerSearchResponse>(
STALKER_REQUEST,
{
url: portalUrl,
macAddress,
params: {
@@ -150,8 +160,8 @@ export class StalkerSearchComponent {
},
token,
serialNumber,
}
);
}
);
if (response) {
const items = response.js?.data || [];
return items.map((item: StalkerVodSource) =>
@@ -10,10 +10,12 @@ import {
} from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { ContentHeroComponent } from '../content-hero/content-hero.component';
import { FavoritesButtonComponent } from '../stalker-favorites-button/stalker-favorites-button.component';
import { PortalInlinePlayerComponent } from '../portal-inline-player/portal-inline-player.component';
import { SeasonContainerComponent } from '../season-container/season-container.component';
import {
ContentHeroComponent,
SeasonContainerComponent,
SeasonContainerPlaybackToggleRequest,
} from 'components';
import {
PlaybackPositionData,
ResolvedPortalPlayback,
@@ -40,8 +42,8 @@ import {
StalkerStore,
StalkerVodSource,
} from '@iptvnator/portal/stalker/data-access';
import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback';
import { DownloadsService } from 'services';
import { SeasonContainerPlaybackToggleRequest } from '../season-container/season-container.component';
/**
* Component for displaying series/episodes for Stalker portal content.
@@ -0,0 +1,588 @@
// ============================================================================
// Netflix-style Detail View - Cinematic Streaming Design
// ============================================================================
:host {
--poster-width: 220px;
--poster-shadow: 0 8px 40px rgba(0, 0, 0, 0.15);
--content-max-width: 1400px;
// Light theme colors (default - no .dark-theme class on body)
--hero-gradient-start: rgba(255, 250, 245, 0);
--hero-gradient-mid: rgba(255, 250, 245, 0.75);
--hero-gradient-end: rgba(255, 250, 245, 1);
--surface-bg: #fffaf5;
--text-primary: #1a1a1a;
--text-secondary: rgba(26, 26, 26, 0.75);
--text-muted: rgba(26, 26, 26, 0.5);
--accent-color: #b81d24;
--tag-bg: rgba(0, 0, 0, 0.06);
--tag-border: rgba(0, 0, 0, 0.12);
--play-idle-bg: linear-gradient(135deg, #fffaf2 0%, #ffffff 52%, #f7ecdf 100%);
--play-idle-fg: #161310;
--play-idle-border: rgba(88, 48, 18, 0.12);
--play-idle-shadow: 0 18px 38px rgba(91, 56, 24, 0.14);
--play-launching-bg: linear-gradient(135deg, #ffd36f 0%, #ffb14c 46%, #f57d35 100%);
--play-launching-fg: #241103;
--play-launching-border: rgba(142, 66, 0, 0.2);
--play-launching-shadow: 0 22px 44px rgba(244, 136, 55, 0.32);
--play-stop-bg: linear-gradient(135deg, #ffd1c8 0%, #ff9f8d 42%, #ef5e64 100%);
--play-stop-fg: #321012;
--play-stop-border: rgba(162, 38, 45, 0.18);
--play-stop-shadow: 0 22px 46px rgba(214, 74, 86, 0.26);
--play-icon-shell-bg: rgba(255, 255, 255, 0.32);
--play-icon-shell-border: rgba(255, 255, 255, 0.3);
width: 100%;
height: 100%;
display: block;
overflow: hidden;
background: var(--surface-bg);
color: var(--text-primary);
}
// Dark theme overrides - cinematic dark mode
:host-context(.dark-theme) {
--hero-gradient-start: rgba(20, 20, 20, 0);
--hero-gradient-mid: rgba(20, 20, 20, 0.7);
--hero-gradient-end: rgba(20, 20, 20, 1);
--surface-bg: #141414;
--text-primary: #fff;
--text-secondary: rgba(255, 255, 255, 0.7);
--text-muted: rgba(255, 255, 255, 0.5);
--accent-color: #e50914;
--tag-bg: rgba(255, 255, 255, 0.1);
--tag-border: rgba(255, 255, 255, 0.2);
--poster-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
--play-idle-bg: linear-gradient(135deg, #fff5e6 0%, #f6efe5 58%, #e7dac9 100%);
--play-idle-fg: #15110d;
--play-idle-border: rgba(255, 255, 255, 0.18);
--play-idle-shadow: 0 20px 42px rgba(0, 0, 0, 0.36);
--play-launching-bg: linear-gradient(135deg, #ffdd79 0%, #ffb347 42%, #ef6a3a 100%);
--play-launching-fg: #1f1105;
--play-launching-border: rgba(255, 223, 136, 0.26);
--play-launching-shadow: 0 24px 50px rgba(255, 153, 71, 0.3);
--play-stop-bg: linear-gradient(135deg, #ffd7cf 0%, #ff9b8b 40%, #eb5961 100%);
--play-stop-fg: #2d1012;
--play-stop-border: rgba(255, 205, 205, 0.24);
--play-stop-shadow: 0 24px 50px rgba(235, 89, 97, 0.24);
--play-icon-shell-bg: rgba(255, 255, 255, 0.28);
--play-icon-shell-border: rgba(255, 255, 255, 0.24);
}
// ============================================================================
// Details Section - Content Elements
// ============================================================================
.details {
// Styles for projected content elements
&__tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
font-size: 0.8125rem;
font-weight: 500;
background: var(--tag-bg);
border: 1px solid var(--tag-border);
border-radius: 4px;
backdrop-filter: blur(8px);
&--rating {
color: #ffd700;
border-color: rgba(255, 215, 0, 0.3);
}
}
&__meta-item {
font-size: 0.875rem;
.label {
color: var(--text-muted);
font-weight: 400;
margin-bottom: 2px;
text-decoration: none;
display: block;
}
.value {
color: var(--text-primary);
font-weight: 500;
}
}
}
// ============================================================================
// Action Buttons - Play, Favorite
// ============================================================================
.play-btn {
--play-btn-bg: var(--play-idle-bg);
--play-btn-fg: var(--play-idle-fg);
--play-btn-border: var(--play-idle-border);
--play-btn-shadow: var(--play-idle-shadow);
position: relative;
isolation: isolate;
overflow: hidden;
display: inline-flex;
align-items: center;
gap: 12px;
padding: 12px 26px;
min-height: 54px;
font-size: 1rem;
font-weight: 600;
letter-spacing: 0.01em;
background: var(--play-btn-bg);
background-size: 140% 140%;
background-position: 0% 50%;
color: var(--play-btn-fg);
border: 1px solid var(--play-btn-border);
border-radius: 16px;
box-shadow: var(--play-btn-shadow);
cursor: pointer;
transition:
transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
background-position 280ms ease,
box-shadow 260ms ease,
border-color 220ms ease,
color 220ms ease,
filter 220ms ease;
&::before {
content: '';
position: absolute;
inset: 1px;
border-radius: inherit;
background:
linear-gradient(115deg, rgba(255, 255, 255, 0.34), transparent 36%),
linear-gradient(180deg, rgba(255, 255, 255, 0.18), transparent 62%);
opacity: 0.7;
transition: opacity 220ms ease;
z-index: -1;
}
&:hover:not(:disabled) {
transform: translateY(-2px);
background-position: 100% 50%;
box-shadow:
var(--play-btn-shadow),
0 10px 20px rgba(255, 255, 255, 0.16) inset;
}
&:hover:not(:disabled)::before {
opacity: 0.95;
}
&:active:not(:disabled) {
transform: translateY(0);
box-shadow: var(--play-btn-shadow);
}
&:disabled {
cursor: progress;
opacity: 1;
filter: saturate(1.05);
}
mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
transition:
transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
color 220ms ease,
filter 220ms ease;
}
&:hover:not(:disabled) .play-btn__icon {
transform: scale(1.08);
}
&__icon-shell {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 999px;
background: var(--play-icon-shell-bg);
border: 1px solid var(--play-icon-shell-border);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.38),
0 6px 14px rgba(0, 0, 0, 0.08);
backdrop-filter: blur(10px);
transition:
transform 220ms ease,
background-color 220ms ease,
border-color 220ms ease,
box-shadow 220ms ease;
}
&__label {
display: inline-flex;
align-items: center;
min-width: 0;
}
&--state-launching,
&--state-stop {
color: var(--play-btn-fg);
}
&--state-launching {
--play-btn-bg: var(--play-launching-bg);
--play-btn-fg: var(--play-launching-fg);
--play-btn-border: var(--play-launching-border);
--play-btn-shadow: var(--play-launching-shadow);
animation: playButtonPulse 1.8s ease-in-out infinite;
.play-btn__icon-shell {
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.42),
0 10px 22px rgba(184, 89, 26, 0.2);
}
.play-btn__icon {
animation: playIconLaunch 1.15s cubic-bezier(0.55, 0, 0.45, 1) infinite;
}
}
&--state-stop {
--play-btn-bg: var(--play-stop-bg);
--play-btn-fg: var(--play-stop-fg);
--play-btn-border: var(--play-stop-border);
--play-btn-shadow: var(--play-stop-shadow);
.play-btn__icon-shell {
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.4),
0 12px 24px rgba(145, 38, 53, 0.18);
}
.play-btn__icon {
animation: playIconStop 1.2s ease-in-out infinite;
}
}
}
@keyframes playButtonPulse {
0%,
100% {
box-shadow: var(--play-btn-shadow);
}
50% {
box-shadow:
var(--play-btn-shadow),
0 0 0 6px rgba(255, 190, 92, 0.14);
}
}
@keyframes playIconLaunch {
0%,
100% {
transform: rotate(0deg) scale(1);
}
50% {
transform: rotate(18deg) scale(1.1);
}
}
@keyframes playIconStop {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.08);
}
}
@media (prefers-reduced-motion: reduce) {
.play-btn,
.play-btn::before,
.play-btn mat-icon,
.play-btn__icon-shell {
animation: none !important;
transition-duration: 1ms !important;
}
}
.favorite-btn,
.download-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
font-size: 0.9375rem;
font-weight: 500;
background: rgba(109, 109, 110, 0.7);
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(109, 109, 110, 0.5);
}
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
}
.favorite-btn {
&--active {
background: rgba(229, 9, 20, 0.8);
&:hover {
background: rgba(229, 9, 20, 0.6);
}
}
}
.download-btn {
background: rgba(33, 150, 243, 0.8);
&:hover {
background: rgba(33, 150, 243, 0.6);
}
&--completed {
background: rgba(76, 175, 80, 0.8);
&:hover {
background: rgba(76, 175, 80, 0.6);
}
}
&--downloading {
background: rgba(109, 109, 110, 0.5);
cursor: not-allowed;
opacity: 0.7;
&:hover {
background: rgba(109, 109, 110, 0.5);
}
}
}
// ============================================================================
// Content Sections - Below the hero fold
// ============================================================================
.content-section {
padding: 20px 0;
width: 100%;
box-sizing: border-box;
&__title {
font-size: 1.375rem;
font-weight: 600;
margin: 0 0 20px 0;
color: var(--text-primary);
}
}
// ============================================================================
// Seasons & Episodes - Inside details section
// ============================================================================
.seasons {
margin-top: 24px;
width: 100%;
.season-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
cursor: pointer;
transition: opacity 0.2s ease;
&:hover {
opacity: 0.8;
}
h4 {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.expand-icon {
font-size: 0.75rem;
color: var(--text-muted);
}
}
.episodes {
display: flex;
flex-wrap: wrap;
gap: 10px;
padding: 8px 0 16px;
button {
margin: 0;
font-weight: 500;
}
}
.episodes-loading,
.seasons-loading {
display: flex;
align-items: center;
gap: 12px;
padding: 20px 0;
color: var(--text-secondary);
}
.no-episodes {
color: var(--text-muted);
font-style: italic;
padding: 8px 0;
}
}
// Seasons loading when inside details
.details .seasons-loading {
display: flex;
align-items: center;
gap: 12px;
padding: 20px 0;
color: var(--text-secondary);
margin-top: 24px;
}
// Season container when inside details section
.details app-season-container {
display: block;
width: 100%;
margin-top: 24px;
box-sizing: border-box;
}
// ============================================================================
// YouTube Trailer Section - Inside details
// ============================================================================
.youtube-trailer {
margin-top: 24px;
width: 100%;
h3 {
font-size: 1.125rem;
font-weight: 600;
margin: 0 0 12px 0;
color: var(--text-primary);
}
.trailer-wrapper {
position: relative;
width: 100%;
max-width: 560px;
aspect-ratio: 16 / 9;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: none;
}
}
}
// ============================================================================
// Legacy container support (for gradual migration)
// ============================================================================
.container {
display: contents;
}
.image {
display: none; // Hidden - now using .poster in hero
}
// ============================================================================
// Mobile Responsive - Option A: Keep poster grid layout
// ============================================================================
@media (max-width: 800px) {
:host {
--poster-width: 120px;
}
.action-buttons {
width: 100%;
// This class is in ContentHeroComponent now, but these rules apply to children?
// No, .play-btn is a child of the PROJECTED content.
// So we should target the buttons directly for mobile
}
.play-btn,
.favorite-btn,
.download-btn {
flex: 1;
justify-content: center;
padding: 10px 16px;
}
.youtube-trailer {
margin-top: 16px;
.trailer-wrapper {
max-width: 100%;
border-radius: 4px;
}
}
.seasons {
padding: 16px 0 0;
}
}
// ============================================================================
// Animations
// ============================================================================
.youtube-trailer {
animation: fadeInUp 0.5s ease-out 0.4s both;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// ============================================================================
// Utility - keep .label for backwards compatibility
// ============================================================================
.label {
font-weight: 400;
font-size: 0.8125rem;
color: var(--text-muted);
text-decoration: none;
padding: 0;
opacity: 1;
}
@@ -23,7 +23,8 @@ import { MatMenuModule } from '@angular/material/menu';
import { MatTooltipModule } from '@angular/material/tooltip';
import { ActivatedRoute } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { PlaylistSwitcherComponent, ResizableDirective } from 'components';
import { PlaylistSwitcherComponent } from '@iptvnator/playlist/shared/ui';
import { ResizableDirective } from 'components';
import { CategoryViewComponent, PortalEmptyStateComponent } from '@iptvnator/portal/shared/ui';
import {
PORTAL_PLAYER,
@@ -17,7 +17,6 @@ import { MatMenuModule } from '@angular/material/menu';
import { MatTooltipModule } from '@angular/material/tooltip';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import groupBy from 'lodash/groupBy';
import { firstValueFrom } from 'rxjs';
import { DatabaseService, PlaylistsService } from 'services';
import { ContentCardComponent, FavoriteLayoutItem } from '@iptvnator/portal/shared/ui';
@@ -82,6 +81,22 @@ interface XtreamRecentLiveItem {
readonly xtream_id: number;
}
function groupItemsByPlaylistName(
items: XtreamRecentViewItem[]
): Record<string, XtreamRecentViewItem[]> {
return items.reduce<Record<string, XtreamRecentViewItem[]>>(
(groups, item) => {
const key = String(item.playlist_name);
if (!groups[key]) {
groups[key] = [];
}
groups[key].push(item);
return groups;
},
{}
);
}
function isXtreamRecentViewItem(
item: FavoriteLayoutItem
): item is XtreamRecentViewItem {
@@ -476,8 +491,7 @@ export class RecentlyViewedComponent {
getGroupedItems() {
const items = this.visibleRecentItems();
if (!this.isGlobal) return { default: items };
const grouped = groupBy(items, 'playlist_name');
return grouped;
return groupItemsByPlaylistName(items);
}
updateRecentSearchTerm(term: string) {
@@ -21,7 +21,6 @@ import {
import { MatIcon } from '@angular/material/icon';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import groupBy from 'lodash/groupBy';
import { DatabaseService } from 'services';
import { ContentCardComponent } from '@iptvnator/portal/shared/ui';
import { SearchLayoutComponent } from '@iptvnator/portal/shared/ui';
@@ -40,6 +39,22 @@ interface SearchResultsData {
initialQuery?: string;
}
function groupResultsByPlaylistName(
items: XtreamContentItem[]
): Record<string, XtreamContentItem[]> {
return items.reduce<Record<string, XtreamContentItem[]>>(
(groups, item) => {
const key = String(item.playlist_name);
if (!groups[key]) {
groups[key] = [];
}
groups[key].push(item);
return groups;
},
{}
);
}
@Component({
selector: 'app-search-results',
imports: [
@@ -121,7 +136,7 @@ export class SearchResultsComponent implements AfterViewInit {
readonly groupedResults = computed(() => {
const results = this.xtreamStore.searchResults();
if (!this.isGlobalSearch) return { default: results };
return groupBy(results, 'playlist_name');
return groupResultsByPlaylistName(results);
});
constructor(
@@ -6,10 +6,9 @@ import { ActivatedRoute } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import {
ContentHeroComponent,
PortalInlinePlayerComponent,
SeasonContainerComponent,
SeasonContainerPlaybackToggleRequest,
SeasonContainerXtreamDownloadContext,
SeasonContainerComponent,
} from 'components';
import {
PORTAL_EXTERNAL_PLAYBACK,
@@ -17,6 +16,7 @@ import {
PORTAL_PLAYER,
} from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback';
import {
PlaybackPositionData,
PlayerContentInfo,
@@ -12,7 +12,7 @@ import { MatIcon } from '@angular/material/icon';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute } from '@angular/router';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { ContentHeroComponent, PortalInlinePlayerComponent } from 'components';
import { ContentHeroComponent } from 'components';
import { SafePipe } from '@iptvnator/pipes';
import {
PORTAL_EXTERNAL_PLAYBACK,
@@ -22,6 +22,7 @@ import {
getPortalPlaybackProgressPercent,
} from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback';
import { DownloadsService, SettingsStore } from 'services';
import {
PlaybackPositionData,
@@ -9,7 +9,8 @@ import { MatMenuModule } from '@angular/material/menu';
import { MatTooltipModule } from '@angular/material/tooltip';
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { PlaylistSwitcherComponent, ResizableDirective } from 'components';
import { PlaylistSwitcherComponent } from '@iptvnator/playlist/shared/ui';
import { ResizableDirective } from 'components';
import { CategoryViewComponent } from '@iptvnator/portal/shared/ui';
import { isWorkspaceLayoutRoute } from '@iptvnator/portal/shared/util';
import {
+1 -1
View File
@@ -4,7 +4,7 @@
"sourceRoot": "libs/services/src",
"prefix": "lib",
"projectType": "library",
"tags": ["scope:shared", "type:data-access"],
"tags": ["scope:shared", "domain:shared-runtime", "type:data-access"],
"targets": {
"test": {
"executor": "@nx/jest:jest",
Loaded 100 of 175 files, more files were not shown because too many files have changed in this diff. Show more