mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
refactor: update nx monorepo structure
This commit is contained in:
1 parent
aa739f43e0
commit
7ac2fd66b8
175 files changed
+3118
-1076
No files matched your search
+22
-4
@@ -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"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }>) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './lib/epg-progress.service';
|
||||
export * from './lib/epg.service';
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
+1
-1
@@ -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';
|
||||
|
||||
+4
-1
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+1
-1
@@ -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';
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './lib/playlist-player-actions';
|
||||
File renamed without changes.
@@ -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"]
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
+3
-31
@@ -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
|
||||
|
||||
+23
-538
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -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';
|
||||
|
||||
+2
-1
@@ -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,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');
|
||||
});
|
||||
});
|
||||
+22
-11
@@ -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({
|
||||
|
||||
+73
-38
@@ -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()
|
||||
),
|
||||
};
|
||||
}),
|
||||
|
||||
+29
-6
@@ -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) => ({
|
||||
|
||||
+16
-6
@@ -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) => {
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+3
-2
@@ -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;
|
||||
|
||||
+27
-10
@@ -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({
|
||||
|
||||
+13
-6
@@ -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: '',
|
||||
|
||||
+62
-34
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
+25
@@ -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 />
|
||||
}
|
||||
+371
@@ -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[] {
|
||||
|
||||
File renamed without changes.
File renamed without changes.
+11
-2
@@ -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: [
|
||||
{
|
||||
|
||||
+2
-4
@@ -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',
|
||||
|
||||
+1
-1
@@ -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) =>
|
||||
|
||||
File renamed without changes.
+6
-4
@@ -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;
|
||||
}
|
||||
+2
-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 {
|
||||
|
||||
@@ -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
Reference in new issue
Block a user