refactor: update nx monorepo structure

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

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
{
"name": "epg-data-access",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/epg/data-access/src",
"prefix": "lib",
"projectType": "library",
"tags": ["scope:shared", "domain:playback", "type:data-access"],
"targets": {
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
+2
View File
@@ -0,0 +1,2 @@
export * from './lib/epg-progress.service';
export * from './lib/epg.service';
+28
View File
@@ -0,0 +1,28 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"isolatedModules": true,
"target": "es2022",
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
}
]
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}
+2 -1
View File
@@ -5,6 +5,7 @@ import { Actions, createEffect, ofType } from '@ngrx/effects';
import { Store } from '@ngrx/store';
import { StorageMap } from '@ngx-pwa/local-storage';
import { TranslateService } from '@ngx-translate/core';
import { EpgService } from '@iptvnator/epg/data-access';
import {
combineLatestWith,
EMPTY,
@@ -15,7 +16,7 @@ import {
tap,
withLatestFrom,
} from 'rxjs';
import { DataService, EpgService, PlaylistsService } from 'services';
import { DataService, PlaylistsService } from 'services';
import {
OPEN_MPV_PLAYER,
OPEN_VLC_PLAYER,
+1 -1
View File
@@ -4,7 +4,7 @@
"sourceRoot": "libs/playlist/import/feature/src",
"prefix": "lib",
"projectType": "library",
"tags": ["scope:playlist", "domain:m3u", "type:feature"],
"tags": ["scope:playlist", "domain:playlist-import", "type:feature"],
"targets": {
"lint": {
"executor": "@nx/eslint:lint"
@@ -8,7 +8,7 @@ import {
import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { PlaylistType } from 'components';
import { PlaylistType } from '@iptvnator/playlist/shared/ui';
import { PlaylistActions } from 'm3u-state';
import { getFilenameFromUrl } from 'm3u-utils';
import { DataService } from 'services';
@@ -14,7 +14,10 @@ import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { PlaylistActions } from 'm3u-state';
import { STALKER_SERIAL_NUMBER, StalkerSessionService } from 'services';
import {
STALKER_SERIAL_NUMBER,
StalkerSessionService,
} from '@iptvnator/portal/stalker/data-access';
import { Playlist } from 'shared-interfaces';
import { v4 as uuid } from 'uuid';
@@ -1,2 +1 @@
export * from './lib/playlist-player-actions';
export * from './lib/video-player/video-player.component';
@@ -19,18 +19,14 @@ import { Store } from '@ngrx/store';
import { StorageMap } from '@ngx-pwa/local-storage';
import { TranslatePipe } from '@ngx-translate/core';
import {
ArtPlayerComponent,
AudioPlayerComponent,
InfoOverlayComponent,
ResizableDirective,
} from 'components';
import {
COMPONENT_OVERLAY_REF,
EpgListComponent,
HtmlVideoPlayerComponent,
InfoOverlayComponent,
MultiEpgContainerComponent,
ResizableDirective,
SidebarComponent,
ToolbarComponent,
VjsPlayerComponent,
} from 'components';
} from '@iptvnator/ui/epg';
import {
ChannelActions,
FavoritesActions,
@@ -55,6 +51,15 @@ import {
getAdjacentChannelItem,
getChannelItemByNumber,
} from '@iptvnator/portal/shared/util';
import {
ArtPlayerComponent,
AudioPlayerComponent,
HtmlVideoPlayerComponent,
SidebarComponent,
ToolbarComponent,
VjsPlayerComponent,
} from '@iptvnator/ui/playback';
import { PLAYLIST_PLAYER_ACTIONS } from '@iptvnator/playlist/shared/util';
import { DataService, PlaylistsService, SettingsStore } from 'services';
import {
Channel,
@@ -65,7 +70,6 @@ import {
SidebarView,
VideoPlayer,
} from 'shared-interfaces';
import { PLAYLIST_PLAYER_ACTIONS } from '../playlist-player-actions';
@Component({
selector: 'app-video-player',
+13
View File
@@ -0,0 +1,13 @@
{
"name": "playlist-shared-ui",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/playlist/shared/ui/src",
"prefix": "lib",
"projectType": "library",
"tags": ["scope:playlist", "domain:m3u", "type:ui"],
"targets": {
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
+5
View File
@@ -0,0 +1,5 @@
export * from './lib/add-playlist-menu/add-playlist-menu.component';
export * from './lib/filter-sort-menu/filter-sort-menu.component';
export * from './lib/playlist-switcher/playlist-switcher.component';
export * from './lib/recent-playlists/playlist-info/playlist-info.component';
export * from './lib/recent-playlists/recent-playlists.component';
@@ -22,10 +22,10 @@ import {
} from 'm3u-state';
import { NgxSkeletonLoaderComponent } from 'ngx-skeleton-loader';
import { BehaviorSubject, combineLatest, map } from 'rxjs';
import { DialogService } from 'components';
import { DatabaseService, DataService, SortBy, SortService } from 'services';
import { PLAYLIST_UPDATE, PlaylistMeta } from 'shared-interfaces';
import { PlaylistType } from '../add-playlist-menu/add-playlist-menu.component';
import { DialogService } from '../confirm-dialog/dialog.service';
import { EmptyStateComponent } from './empty-state/empty-state.component';
import { PlaylistInfoComponent } from './playlist-info/playlist-info.component';
import { PlaylistItemComponent } from './playlist-item/playlist-item.component';
+28
View File
@@ -0,0 +1,28 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"isolatedModules": true,
"target": "es2022",
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
}
]
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "playlist-shared-util",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/playlist/shared/util/src",
"prefix": "lib",
"projectType": "library",
"tags": ["scope:playlist", "domain:m3u", "type:util"],
"targets": {
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
+1
View File
@@ -0,0 +1 @@
export * from './lib/playlist-player-actions';
+28
View File
@@ -0,0 +1,28 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"isolatedModules": true,
"target": "es2022",
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
}
]
}
@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}
+1 -1
View File
@@ -4,7 +4,7 @@
"sourceRoot": "libs/portal/catalog/feature/src",
"prefix": "lib",
"projectType": "library",
"tags": ["scope:shared", "type:feature"],
"tags": ["scope:portal", "domain:portal-shared", "type:feature"],
"targets": {
"lint": {
"executor": "@nx/eslint:lint"
@@ -1,34 +1,6 @@
@if (selectedCategory()) {
@if (isStalker && selectedStalkerItem()) {
@if (contentType() === 'vod') {
@if (selectedStalkerItem().series?.length > 0) {
<!-- VOD item with embedded series (vclub or Ministra) -->
<app-stalker-series-view [vodWithSeries]="selectedStalkerItem()" />
} @else if (selectedStalkerItem().is_series) {
<!-- VOD item that is actually a series (Ministra plugin is_series=1) -->
<app-stalker-series-view />
} @else {
@if (vodDetailsItem()) {
<app-vod-details
[item]="vodDetailsItem()!"
[isFavorite]="isSelectedVodFavorite()"
[playbackPosition]="selectedVodPlaybackPosition()"
[inlinePlayback]="inlinePlayback()"
[externalPlayback]="externalPlayback.activeSession()"
(playClicked)="onVodPlay($event)"
(resumeClicked)="onVodResume($event)"
(favoriteToggled)="onVodFavoriteToggled($event)"
(downloadRequested)="onVodDownload($event)"
(backClicked)="onVodBack()"
(inlineTimeUpdated)="handleInlineTimeUpdate($event)"
(inlinePlaybackClosed)="closeInlinePlayer()"
(streamUrlCopied)="showCopyNotification()"
/>
}
}
} @else if (contentType() === 'series') {
<app-stalker-series-view />
}
@if (selectedDetailComponent(); as detailComponentRef) {
<ng-container *ngComponentOutlet="detailComponentRef" />
} @else {
<div class="category-content-layout">
<div class="category-content-header">
@@ -40,7 +12,7 @@
{{ categoryItemSubtitle() }}
</div>
</div>
@if (!isStalker) {
@if (canSortContent()) {
<button
type="button"
mat-icon-button
@@ -1,56 +1,22 @@
import {
Component,
computed,
effect,
inject,
OnDestroy,
OnInit,
signal,
} from '@angular/core';
import { NgComponentOutlet } from '@angular/common';
import { Component, computed, inject, OnInit } from '@angular/core';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltip } from '@angular/material/tooltip';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import {
StalkerSeriesViewComponent,
VodDetailsComponent,
} from 'components';
import { TranslatePipe } from '@ngx-translate/core';
import {
GridListComponent,
PlaylistErrorViewComponent,
} from '@iptvnator/portal/shared/ui';
import {
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
createLogger,
isStalkerPortalCatalogFacade,
PortalCatalogFacade,
PortalCatalogSortMode,
PORTAL_CATALOG_DETAIL_COMPONENT,
PORTAL_CATALOG_FACADE,
StalkerPortalCatalogFacade,
PortalCatalogSortMode,
} from '@iptvnator/portal/shared/util';
import {
createPortalFavoritesResource,
createRefreshTrigger,
isSelectedStalkerVodFavorite,
normalizeStalkerEntityId,
normalizeStalkerEntityIdAsNumber,
StalkerSelectedVodItem,
toggleStalkerVodFavorite,
} from '@iptvnator/portal/stalker/data-access';
import { PlaylistsService, DownloadsService } from 'services';
import {
createStalkerVodItem,
PlaybackPositionData,
ResolvedPortalPlayback,
StalkerVodDetails,
VodDetailsItem,
} from 'shared-interfaces';
interface CategoryContentItem {
id?: number | string;
@@ -62,16 +28,6 @@ interface CategoryContentItem {
[key: string]: unknown;
}
interface DownloadVodData {
id?: string | number;
has_files?: unknown;
info?: {
name?: string;
movie_image?: string;
};
title?: string;
}
@Component({
selector: 'app-category-content-view',
templateUrl: './category-content-view.component.html',
@@ -83,52 +39,22 @@ interface DownloadVodData {
MatMenuModule,
MatPaginatorModule,
MatTooltip,
NgComponentOutlet,
PlaylistErrorViewComponent,
StalkerSeriesViewComponent,
TranslatePipe,
VodDetailsComponent,
],
})
export class CategoryContentViewComponent implements OnInit, OnDestroy {
export class CategoryContentViewComponent implements OnInit {
private readonly activatedRoute = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
private readonly portalPlayer = inject(PORTAL_PLAYER);
readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
private readonly snackBar = inject(MatSnackBar);
private readonly translateService = inject(TranslateService);
private readonly playlistService = inject(PlaylistsService);
private readonly downloadsService = inject(DownloadsService);
private readonly catalog = inject(PORTAL_CATALOG_FACADE) as PortalCatalogFacade<
CategoryContentItem,
CategoryContentItem,
CategoryContentItem
>;
private readonly stalkerCatalog:
| StalkerPortalCatalogFacade<
CategoryContentItem,
CategoryContentItem,
CategoryContentItem
>
| null = isStalkerPortalCatalogFacade(this.catalog)
? (this.catalog as StalkerPortalCatalogFacade<
CategoryContentItem,
CategoryContentItem,
CategoryContentItem
>)
: null;
private readonly logger = createLogger('CategoryContentView');
private readonly favoritesRefresh = createRefreshTrigger();
readonly isStalker = this.catalog.provider === 'stalker';
/** Unsubscribe function for playback position updates */
private unsubscribePositionUpdates: (() => void) | null = null;
readonly detailComponent = inject(PORTAL_CATALOG_DETAIL_COMPONENT);
readonly contentType = this.catalog.contentType;
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
private lastInlineSaveTime = 0;
readonly limit = this.catalog.limit;
readonly pageIndex = this.catalog.pageIndex;
readonly pageSizeOptions = Array.from(this.catalog.pageSizeOptions);
@@ -136,262 +62,41 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
readonly paginatedContent = this.catalog.paginatedContent;
readonly selectedCategoryTitle = this.catalog.selectedCategoryTitle;
readonly categoryItemCount = this.catalog.categoryItemCount;
readonly selectedItem = this.catalog.selectedItem;
readonly totalPages = this.catalog.totalPages;
readonly contentSortMode = this.catalog.contentSortMode;
readonly isPaginatedContentLoading =
this.catalog.isPaginatedContentLoading;
readonly categoryItemSubtitle = computed(() => {
const itemCount = this.categoryItemCount();
return `${itemCount} ${itemCount === 1 ? 'item' : 'items'}`;
});
/** Stalker playback positions (key: `${contentType}_${id}`) */
private readonly stalkerPositions = signal<
Map<string, PlaybackPositionData>
>(new Map());
/** Stalker series positions (key: seriesId, value: episode positions) */
private readonly stalkerSeriesPositions = signal<
Map<number, PlaybackPositionData[]>
>(new Map());
/** Track which playlist we've loaded positions for */
private loadedPositionsForPlaylistId: string | null = null;
constructor() {
if (this.isStalker) {
effect(() => {
const playlistId = this.catalog.playlist()?.id;
if (
playlistId &&
playlistId !== this.loadedPositionsForPlaylistId
) {
this.loadedPositionsForPlaylistId = playlistId;
this.loadStalkerPositions(playlistId);
}
});
effect(() => {
const item = this.selectedItem();
const playlistId = this.catalog.playlist()?.id;
if (item && playlistId && this.contentType() === 'vod') {
this.loadSingleVodPosition(playlistId, Number(item.id));
}
});
if (window.electron?.onPlaybackPositionUpdate) {
this.unsubscribePositionUpdates =
window.electron.onPlaybackPositionUpdate(
(data: PlaybackPositionData) => {
if (
data.contentType === 'vod' &&
data.contentXtreamId
) {
const key = `vod_${data.contentXtreamId}`;
const updated = new Map(
this.stalkerPositions()
);
updated.set(key, data);
this.stalkerPositions.set(updated);
}
}
);
}
effect(() => {
const selectedItemId = this.selectedItem()?.id;
void selectedItemId;
this.closeInlinePlayer();
});
}
}
readonly contentWithProgress = computed(() => {
const items = this.paginatedContent();
if (!items) return [];
if (this.isStalker) {
const positions = this.stalkerPositions();
const seriesPositions = this.stalkerSeriesPositions();
const contentType = this.contentType();
return items.map((item: CategoryContentItem) => {
const id = Number(item.id);
const hasSeriesProgress = Boolean(
seriesPositions.get(id)?.length
);
const isSeries =
contentType === 'series' ||
item.is_series === '1' ||
item.is_series === 1;
if (hasSeriesProgress) {
return { ...item, hasSeriesProgress: true };
}
if (isSeries) {
return { ...item, hasSeriesProgress: false };
}
const vodKey = `vod_${id}`;
const vodPosition = positions.get(vodKey);
const vodProgress = this.calculateProgress(vodPosition);
return {
...item,
progress: vodProgress,
isWatched: vodProgress >= 90,
};
});
}
return items.map((item: CategoryContentItem) => ({
readonly canSortContent = computed(() => this.contentSortMode() !== null);
readonly selectedDetailComponent = computed(() =>
this.selectedItem() ? this.detailComponent : null
);
readonly contentWithProgress = computed(() =>
(this.paginatedContent() ?? []).map((item: CategoryContentItem) => ({
...item,
...this.catalog.getItemProgress(item),
}));
});
readonly isPaginatedContentLoading =
this.catalog.isPaginatedContentLoading;
readonly selectedItem = this.catalog.selectedItem;
readonly totalPages = this.catalog.totalPages;
readonly contentSortMode = this.catalog.contentSortMode;
readonly selectedStalkerItem = computed<StalkerSelectedVodItem | null>(
() => {
if (!this.isStalker) {
return null;
}
return (this.selectedItem() as unknown as StalkerSelectedVodItem | null) ?? null;
}
}))
);
/** Computed VodDetailsItem for the vod-details component */
readonly vodDetailsItem = computed<VodDetailsItem | null>(() => {
const item = this.selectedStalkerItem();
if (!item || !this.isStalker) return null;
return createStalkerVodItem(
item as unknown as StalkerVodDetails,
this.catalog.playlist()?.id ?? ''
);
});
/** Playback position for the selected VOD item (Stalker) */
readonly selectedVodPlaybackPosition = computed<number | null>(() => {
if (!this.isStalker) return null;
const item = this.selectedStalkerItem();
if (!item) return null;
const id = Number(item.id);
const vodKey = `vod_${id}`;
const position = this.stalkerPositions().get(vodKey);
return position?.positionSeconds ?? null;
});
readonly portalFavorites = createPortalFavoritesResource(
this.playlistService,
() => (this.isStalker ? this.catalog.playlist()?.id : undefined),
() => this.favoritesRefresh.refreshVersion()
);
readonly isSelectedVodFavorite = computed<boolean>(() =>
isSelectedStalkerVodFavorite(
this.vodDetailsItem(),
this.portalFavorites.value() ?? []
)
);
seasons = [];
setContentSortMode(mode: PortalCatalogSortMode): void {
this.catalog.setContentSortMode(mode);
}
ngOnInit() {
ngOnInit(): void {
const { categoryId } = this.activatedRoute.snapshot.params;
this.catalog.initialize(categoryId ?? null);
if (this.isStalker) {
const selectedCategory =
(this.selectedCategory() as CategoryContentItem | null) ?? null;
this.logger.warn('Stalker category init from route', {
routeCategoryId: categoryId ?? null,
selectedCategoryId:
selectedCategory?.category_id ??
selectedCategory?.id ??
null,
selectedContentType: this.contentType(),
});
}
}
/**
* Load all playback positions for a Stalker portal
*/
private async loadStalkerPositions(playlistId: string): Promise<void> {
const positions =
await this.playbackPositions.getAllPlaybackPositions(playlistId);
const positionsMap = new Map<string, PlaybackPositionData>();
const seriesMap = new Map<number, PlaybackPositionData[]>();
positions.forEach((pos) => {
const key = `${pos.contentType}_${pos.contentXtreamId}`;
positionsMap.set(key, pos);
if (pos.contentType === 'episode' && pos.seriesXtreamId) {
const existing = seriesMap.get(pos.seriesXtreamId) || [];
existing.push(pos);
seriesMap.set(pos.seriesXtreamId, existing);
}
});
this.stalkerPositions.set(positionsMap);
this.stalkerSeriesPositions.set(seriesMap);
}
/**
* Load playback position for a single VOD item
*/
private async loadSingleVodPosition(
playlistId: string,
vodId: number
): Promise<void> {
const position = await this.playbackPositions.getPlaybackPosition(
playlistId,
vodId,
'vod'
);
if (position) {
const key = `vod_${vodId}`;
const updated = new Map(this.stalkerPositions());
updated.set(key, position);
this.stalkerPositions.set(updated);
}
}
/**
* Calculate progress percentage from position data
*/
private calculateProgress(
position: PlaybackPositionData | undefined
): number {
if (!position || !position.durationSeconds) return 0;
const percent =
(position.positionSeconds / position.durationSeconds) * 100;
if (position.positionSeconds > 10 && percent < 1) {
return 1;
}
return Math.min(100, Math.round(percent));
}
onPageChange(event: PageEvent) {
onPageChange(event: PageEvent): void {
this.catalog.setPage(event.pageIndex);
this.catalog.setLimit(event.pageSize);
}
onItemClick(item: CategoryContentItem) {
onItemClick(item: CategoryContentItem): void {
const navigation = this.catalog.selectItem(item);
if (navigation?.length) {
this.router.navigate(navigation, {
@@ -399,224 +104,4 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
});
}
}
async createLinkToPlayVod(
cmd?: string,
title?: string,
thumbnail?: string
) {
if (!this.stalkerCatalog) {
return;
}
await this.stalkerCatalog.createLinkToPlayVod(cmd, title, thumbnail);
}
addToFavorites(item: Record<string, unknown>, onDone?: () => void) {
this.logger.debug('Add to favorites', item);
if (this.stalkerCatalog) {
this.stalkerCatalog.addToFavorites(item, onDone);
return;
}
onDone?.();
}
removeFromFavorites(favoriteId: string, onDone?: () => void) {
this.logger.debug('Remove from favorites', favoriteId);
if (this.stalkerCatalog) {
this.stalkerCatalog.removeFromFavorites(favoriteId, onDone);
return;
}
onDone?.();
}
/** Handle play from vod-details component */
onVodPlay(item: VodDetailsItem): void {
if (item.type === 'stalker') {
void this.startStalkerVodPlayback(
item.cmd,
item.data.info?.name,
item.data.info?.movie_image
);
}
}
/** Handle resume from vod-details component */
onVodResume(event: {
item: VodDetailsItem;
positionSeconds: number;
}): void {
if (event.item.type === 'stalker') {
void this.startStalkerVodPlayback(
event.item.cmd,
event.item.data.info?.name,
event.item.data.info?.movie_image,
event.positionSeconds
);
}
}
/** Handle favorite toggle from vod-details component */
onVodFavoriteToggled(event: {
item: VodDetailsItem;
isFavorite: boolean;
}): void {
toggleStalkerVodFavorite(event, {
addToFavorites: (item, onDone) => this.addToFavorites(item, onDone),
removeFromFavorites: (favoriteId, onDone) =>
this.removeFromFavorites(favoriteId, onDone),
onComplete: () => {
this.favoritesRefresh.refresh();
},
});
}
/** Handle back from vod-details component */
onVodBack(): void {
this.closeInlinePlayer();
this.catalog.clearSelectedItem();
}
handleInlineTimeUpdate(event: {
currentTime: number;
duration: number;
}): void {
const playback = this.inlinePlayback();
if (!playback?.contentInfo) return;
const now = Date.now();
if (now - this.lastInlineSaveTime <= 15000) return;
this.lastInlineSaveTime = now;
const position: PlaybackPositionData = {
...playback.contentInfo,
positionSeconds: Math.floor(event.currentTime),
durationSeconds: Math.floor(event.duration),
};
void this.playbackPositions.savePlaybackPosition(
playback.contentInfo.playlistId,
position
);
const key = `vod_${position.contentXtreamId}`;
const updated = new Map(this.stalkerPositions());
updated.set(key, position);
this.stalkerPositions.set(updated);
}
closeInlinePlayer(): void {
this.inlinePlayback.set(null);
this.lastInlineSaveTime = 0;
}
showCopyNotification(): void {
this.snackBar.open(
this.translateService.instant('PORTALS.STREAM_URL_COPIED'),
null,
{
duration: 2000,
}
);
}
/** Handle download from vod-details component */
async onVodDownload(item: VodDetailsItem): Promise<void> {
if (item.type !== 'stalker' || !this.stalkerCatalog) return;
const playlist = this.catalog.playlist();
if (!playlist || !playlist.portalUrl || !playlist.macAddress) return;
let cmdToUse = item.cmd;
const itemData = item.data as DownloadVodData;
const normalizedItemId = normalizeStalkerEntityId(itemData?.id);
if (
itemData?.has_files !== undefined &&
cmdToUse &&
!cmdToUse.includes('://') &&
cmdToUse.includes('/media/') &&
!cmdToUse.includes('/media/file_')
) {
const fileId =
await this.stalkerCatalog.fetchMovieFileId(normalizedItemId);
if (fileId) {
cmdToUse = `/media/file_${fileId}.mpg`;
}
}
const url = await this.stalkerCatalog.fetchLinkToPlay(
playlist.portalUrl,
playlist.macAddress,
cmdToUse
);
if (!url) return;
const numericId = normalizeStalkerEntityIdAsNumber(itemData?.id) ?? 0;
await this.downloadsService.startDownload({
playlistId: playlist.id,
xtreamId: numericId,
contentType: 'vod',
title: itemData?.info?.name || itemData?.title || 'Unknown',
url,
posterUrl: itemData?.info?.movie_image,
headers: {
userAgent: playlist.userAgent,
referer: playlist.referer,
origin: playlist.origin,
},
playlistName: playlist.title || 'Stalker Portal',
playlistType: 'stalker',
portalUrl: playlist.portalUrl,
macAddress: playlist.macAddress,
});
}
ngOnDestroy(): void {
this.closeInlinePlayer();
this.unsubscribePositionUpdates?.();
}
private async startStalkerVodPlayback(
cmd?: string,
title?: string,
thumbnail?: string,
startTime?: number
): Promise<void> {
if (!this.stalkerCatalog) {
return;
}
try {
const playback = await this.stalkerCatalog.resolveVodPlayback(
cmd,
title,
thumbnail,
startTime
);
this.lastInlineSaveTime = 0;
if (this.portalPlayer.isEmbeddedPlayer()) {
this.inlinePlayback.set(playback);
return;
}
this.closeInlinePlayer();
void this.portalPlayer.openResolvedPlayback(playback, true);
} catch (error) {
this.logger.error('Failed to start inline VOD playback', error);
const errorMessage =
error instanceof Error && error.message === 'nothing_to_play'
? this.translateService.instant(
'PORTALS.CONTENT_NOT_AVAILABLE'
)
: this.translateService.instant('PORTALS.PLAYBACK_ERROR');
this.snackBar.open(errorMessage, null, {
duration: 3000,
});
}
}
}
@@ -4,7 +4,8 @@ import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { ActivatedRoute } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { PlaylistSwitcherComponent, ResizableDirective } from 'components';
import { PlaylistSwitcherComponent } from '@iptvnator/playlist/shared/ui';
import { ResizableDirective } from 'components';
import { isWorkspaceLayoutRoute } from '@iptvnator/portal/shared/util';
import { StalkerPortalItem, XtreamCategory } from 'shared-interfaces';
import { CategoryViewComponent } from '../category-view/category-view.component';
@@ -5,7 +5,8 @@ import { MatIconModule } from '@angular/material/icon';
import { Router, RouterLink } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { DialogService, PlaylistInfoComponent } from 'components';
import { PlaylistInfoComponent } from '@iptvnator/playlist/shared/ui';
import { DialogService } from 'components';
import { PlaylistActions } from 'm3u-state';
import { selectCurrentPlaylist } from 'm3u-state';
import { Playlist } from 'shared-interfaces';
+1
View File
@@ -1,5 +1,6 @@
export * from './lib/favorites-context.service';
export * from './lib/logger';
export * from './lib/portal-catalog-detail';
export * from './lib/portal-catalog-facade';
export * from './lib/portal-external-playback';
export * from './lib/portal-player';
@@ -0,0 +1,6 @@
import { InjectionToken, Type } from '@angular/core';
export const PORTAL_CATALOG_DETAIL_COMPONENT =
new InjectionToken<Type<unknown> | null>('PORTAL_CATALOG_DETAIL_COMPONENT', {
factory: () => null,
});
@@ -3,5 +3,6 @@ export * from './lib/stores';
export * from './lib/stalker-content-types';
export * from './lib/stalker-live-playback.utils';
export * from './lib/stalker-series.adapters';
export * from './lib/stalker-session.service';
export * from './lib/stalker-vod.utils';
export * from './lib/stalker.store';
@@ -1,4 +1,5 @@
export interface StalkerCategoryItem {
category_id: string;
category_name: string;
[key: string]: unknown;
}
@@ -7,6 +7,13 @@ import {
} from './stalker-series.adapters';
import { StalkerSelectedVodItem } from './models';
type EpisodeWithMetadata = {
custom_sid?: string;
id?: string;
originalId?: string;
originalCmd?: string;
};
describe('stalker-series.adapters', () => {
it('detects vod-series flags from heterogeneous payloads', () => {
expect(isVodSeriesItem({ is_series: true })).toBe(true);
@@ -65,9 +72,10 @@ describe('stalker-series.adapters', () => {
);
expect(mapped['1']).toHaveLength(2);
expect(mapped['1'][0].custom_sid).toBe('vod-series');
expect((mapped['1'][0] as any).originalId).toBe('api-shared-id');
expect(mapped['1'][0].id).not.toBe(mapped['1'][1].id);
const firstEpisode = mapped['1'][0] as EpisodeWithMetadata;
expect(firstEpisode.custom_sid).toBe('vod-series');
expect(firstEpisode.originalId).toBe('api-shared-id');
expect(firstEpisode.id).not.toBe(mapped['1'][1].id);
});
it('maps embedded series payload into regular season episodes', () => {
@@ -84,7 +92,8 @@ describe('stalker-series.adapters', () => {
const mapped = mapRegularSeriesEpisodes(regularSeasons, 'poster.jpg');
expect(regularSeasons[0].series).toEqual([1, 2]);
expect(mapped['1']).toHaveLength(2);
expect(mapped['1'][0].custom_sid).toBe('regular-series');
expect((mapped['1'][0] as any).originalCmd).toBe('/media/file_100.mpg');
const firstEpisode = mapped['1'][0] as EpisodeWithMetadata;
expect(firstEpisode.custom_sid).toBe('regular-series');
expect(firstEpisode.originalCmd).toBe('/media/file_100.mpg');
});
});
@@ -1,6 +1,6 @@
import { Injectable, inject } from '@angular/core';
import { Playlist, STALKER_REQUEST } from 'shared-interfaces';
import { DataService } from './data.service';
import { DataService } from 'services';
/**
* SHA1 hash using native Web Crypto API
@@ -68,6 +68,10 @@ export interface StalkerProfileResponse {
};
}
interface StalkerAuthConfirmationResponse {
js?: boolean;
}
/**
* Service to manage Stalker portal session tokens.
* Handles handshake authentication for full stalker portals (/stalker_portal/c URLs).
@@ -262,11 +266,14 @@ export class StalkerSessionService {
try {
const response: StalkerHandshakeResponse =
await this.dataService.sendIpcEvent(STALKER_REQUEST, {
await this.dataService.sendIpcEvent<StalkerHandshakeResponse>(
STALKER_REQUEST,
{
url: portalUrl,
macAddress,
params,
});
}
);
if (response?.js?.token) {
return {
@@ -342,13 +349,16 @@ export class StalkerSessionService {
try {
const response: StalkerProfileResponse =
await this.dataService.sendIpcEvent(STALKER_REQUEST, {
await this.dataService.sendIpcEvent<StalkerProfileResponse>(
STALKER_REQUEST,
{
url: portalUrl,
macAddress,
params,
token,
serialNumber,
});
}
);
return response;
} catch (error) {
@@ -376,15 +386,16 @@ export class StalkerSessionService {
};
try {
const response = await this.dataService.sendIpcEvent(
STALKER_REQUEST,
{
const response =
await this.dataService.sendIpcEvent<StalkerAuthConfirmationResponse>(
STALKER_REQUEST,
{
url: portalUrl,
macAddress,
params,
token,
}
);
}
);
// do_auth returns { js: true } on success
if (response?.js === true) {
@@ -571,7 +582,7 @@ export class StalkerSessionService {
const serialNumber = playlist.stalkerSerialNumber;
try {
const response = await this.dataService.sendIpcEvent(
const response = await this.dataService.sendIpcEvent<T>(
STALKER_REQUEST,
{
url: playlist.portalUrl,
@@ -1,4 +1,5 @@
import { VodDetailsItem } from 'shared-interfaces';
import { StalkerFavoriteItem } from './models';
import {
createStalkerInlineDetailState,
createStalkerDetailViewState,
@@ -131,7 +132,7 @@ describe('stalker-vod.utils regressions', () => {
rating_kinopoisk: '',
},
},
} as any),
} as StalkerFavoriteItem),
null
);
@@ -4,15 +4,12 @@ import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { PORTAL_PLAYER } from '@iptvnator/portal/shared/util';
import {
DataService,
PlaylistsService,
StalkerSessionService,
} from 'services';
import { DataService, PlaylistsService } from 'services';
import { StalkerSessionService } from './stalker-session.service';
import { StalkerStore } from './stalker.store';
describe('StalkerStore API compatibility smoke', () => {
let store: any;
let store: StalkerStore;
beforeEach(() => {
TestBed.configureTestingModule({
@@ -8,7 +8,7 @@ import {
withState,
} from '@ngrx/signals';
import { TranslateService } from '@ngx-translate/core';
import { DataService, StalkerSessionService } from 'services';
import { DataService } from 'services';
import {
Playlist,
STALKER_REQUEST,
@@ -21,6 +21,7 @@ import {
StalkerItvChannel,
StalkerVodSource,
} from '../../models';
import { StalkerSessionService } from '../../stalker-session.service';
import { StalkerContentTypes } from '../../stalker-content-types';
import { toStalkerContentItem, toStalkerItvChannel } from '../utils';
@@ -45,6 +46,39 @@ const initialContentState: StalkerContentState = {
itvChannels: [],
};
interface ResourceState<T> {
value(): T;
isLoading(): boolean;
error(): unknown;
}
interface StalkerCategoryResponseItem {
id?: string | number;
title?: string;
}
interface StalkerCategoryResponse {
js?: StalkerCategoryResponseItem[];
}
interface StalkerOrderedListResponse {
js?: {
data?: StalkerVodSource[];
total_items?: number;
};
}
interface StalkerContentStoreContext {
selectedContentType(): 'vod' | 'series' | 'itv';
currentPlaylist(): Playlist | undefined;
selectedCategoryId(): string | null | undefined;
searchPhrase(): string;
page(): number;
limit(): number;
getContentResource: ResourceState<StalkerContentItem[] | undefined>;
getCategoryResource: ResourceState<StalkerCategoryItem[]>;
}
export function withStalkerContent() {
const logger = createLogger('withStalkerContent');
return signalStoreFeature(
@@ -55,17 +89,21 @@ export function withStalkerContent() {
dataService = inject(DataService),
stalkerSession = inject(StalkerSessionService),
translateService = inject(TranslateService)
) => ({
getCategoryResource: resource({
) => {
const storeContext =
store as unknown as StalkerContentStoreContext;
return {
getCategoryResource: resource({
params: () => ({
contentType: (store as any).selectedContentType(),
contentType: storeContext.selectedContentType(),
action: StalkerPortalActions.GetCategories,
currentPlaylist: (store as any).currentPlaylist(),
currentPlaylist: storeContext.currentPlaylist(),
}),
loader: async ({
params,
}): Promise<StalkerCategoryItem[]> => {
if (params.currentPlaylist === undefined) return;
if (!params.currentPlaylist) return [];
switch (params.contentType) {
case 'itv':
@@ -96,17 +134,16 @@ export function withStalkerContent() {
type: params.contentType,
};
let response: any;
let response: StalkerCategoryResponse;
if (playlist.isFullStalkerPortal) {
// Full stalker portal - use authenticated request with retry
response =
await stalkerSession.makeAuthenticatedRequest(
response = await stalkerSession.makeAuthenticatedRequest<StalkerCategoryResponse>(
playlist,
queryParams
);
} else {
// Simple stalker portal - no auth needed
response = await dataService.sendIpcEvent(
response = await dataService.sendIpcEvent<StalkerCategoryResponse>(
STALKER_REQUEST,
{
url: portalUrl,
@@ -166,18 +203,16 @@ export function withStalkerContent() {
});
return categories;
},
}),
getContentResource: resource({
}),
getContentResource: resource({
params: () => ({
contentType: (store as any).selectedContentType(),
category: (store as any).selectedCategoryId(),
contentType: storeContext.selectedContentType(),
category: storeContext.selectedCategoryId(),
action: StalkerPortalActions.GetOrderedList,
search: (store as any).searchPhrase(),
pageIndex: (store as any).page() + 1,
search: storeContext.searchPhrase(),
pageIndex: storeContext.page() + 1,
availableCategoryCount: (() => {
const contentType = (
store as any
).selectedContentType();
const contentType = storeContext.selectedContentType();
const categories =
contentType === 'vod'
? store.vodCategories()
@@ -211,7 +246,7 @@ export function withStalkerContent() {
return Promise.resolve(undefined);
}
const currentPlaylist = (store as any).currentPlaylist;
const currentPlaylist = storeContext.currentPlaylist;
// Guard: ensure currentPlaylist is available (may not be during deep link init)
if (
@@ -251,17 +286,16 @@ export function withStalkerContent() {
// Use makeAuthenticatedRequest for automatic retry on auth failure
const playlist = currentPlaylist() as Playlist;
let response: any;
let response: StalkerOrderedListResponse;
if (playlist.isFullStalkerPortal) {
// Full stalker portal - use authenticated request with retry
response =
await stalkerSession.makeAuthenticatedRequest(
response = await stalkerSession.makeAuthenticatedRequest<StalkerOrderedListResponse>(
playlist,
queryParams
);
} else {
// Simple stalker portal - no auth needed
response = await dataService.sendIpcEvent(
response = await dataService.sendIpcEvent<StalkerOrderedListResponse>(
STALKER_REQUEST,
{
url: playlist.portalUrl,
@@ -287,7 +321,7 @@ export function withStalkerContent() {
toStalkerContentItem(item, portalUrl)
);
if ((store as any).selectedContentType() === 'itv') {
if (storeContext.selectedContentType() === 'itv') {
const channels = newItems.map(toStalkerItvChannel);
// Check if we're loading the first page or loading more
if (params.pageIndex === 1) {
@@ -312,20 +346,21 @@ export function withStalkerContent() {
return newItems;
},
}),
})
}),
};
}
),
withComputed((store) => {
const storeAny = store as any;
const storeContext = store as unknown as StalkerContentStoreContext;
return {
getTotalPages: computed(() => {
return Math.ceil(store.totalCount() / storeAny.limit());
return Math.ceil(store.totalCount() / storeContext.limit());
}),
getSelectedCategoryName: computed(() => {
const type = storeAny.selectedContentType();
const selectedCategoryId = storeAny.selectedCategoryId();
const type = storeContext.selectedContentType();
const selectedCategoryId = storeContext.selectedCategoryId();
if (!selectedCategoryId) return '';
let categories = [];
let categories: StalkerCategoryItem[] = [];
if (type === 'vod') {
categories = store.vodCategories();
} else if (type === 'series') {
@@ -342,23 +377,23 @@ export function withStalkerContent() {
}),
/** category content */
getPaginatedContent: computed(() =>
storeAny.getContentResource.value()
storeContext.getContentResource.value()
),
isPaginatedContentLoading: computed(() =>
storeAny.getContentResource.isLoading()
storeContext.getContentResource.isLoading()
),
isPaginatedContentFailed: computed(() =>
storeAny.getContentResource.error()
storeContext.getContentResource.error()
),
/** category resource */
getCategoryResource: computed(() =>
storeAny.getCategoryResource.value()
storeContext.getCategoryResource.value()
),
isCategoryResourceLoading: computed(() =>
storeAny.getCategoryResource.isLoading()
storeContext.getCategoryResource.isLoading()
),
isCategoryResourceFailed: computed(() =>
storeAny.getCategoryResource.error()
storeContext.getCategoryResource.error()
),
};
}),
@@ -1,6 +1,7 @@
import { inject } from '@angular/core';
import { signalStoreFeature, withMethods } from '@ngrx/signals';
import { DataService, StalkerSessionService } from 'services';
import { DataService } from 'services';
import { StalkerSessionService } from '../../stalker-session.service';
import {
EpgItem,
Playlist,
@@ -8,6 +9,25 @@ import {
StalkerPortalActions,
} from 'shared-interfaces';
interface EpgStoreContext {
currentPlaylist(): Playlist | undefined;
}
interface StalkerEpgEntry {
id?: string | number;
name?: string;
descr?: string;
time?: string;
time_to?: string;
ch_id?: string | number;
start_timestamp?: string | number;
stop_timestamp?: string | number;
}
interface StalkerEpgResponse {
js?: StalkerEpgEntry[] | { data?: StalkerEpgEntry[] };
}
/**
* EPG concern methods.
*/
@@ -19,14 +39,14 @@ export function withStalkerEpg() {
dataService = inject(DataService),
stalkerSession = inject(StalkerSessionService)
) => {
const storeAny = store as any;
const storeContext = store as unknown as EpgStoreContext;
return {
async fetchChannelEpg(
channelId: number | string,
size = 10
): Promise<EpgItem[]> {
const playlist = storeAny.currentPlaylist() as Playlist;
const playlist = storeContext.currentPlaylist();
if (!playlist) return [];
const queryParams: Record<string, string> = {
@@ -36,7 +56,7 @@ export function withStalkerEpg() {
size: String(size),
};
let response: any;
let response: StalkerEpgResponse;
if (playlist.isFullStalkerPortal) {
response =
await stalkerSession.makeAuthenticatedRequest(
@@ -44,7 +64,8 @@ export function withStalkerEpg() {
queryParams
);
} else {
response = await dataService.sendIpcEvent(
response =
await dataService.sendIpcEvent<StalkerEpgResponse>(
STALKER_REQUEST,
{
url: playlist.portalUrl,
@@ -54,7 +75,9 @@ export function withStalkerEpg() {
);
}
const epgData = response?.js?.data ?? response?.js ?? [];
const epgData = Array.isArray(response?.js)
? response.js
: response?.js?.data ?? [];
const items = Array.isArray(epgData) ? epgData : [];
return items.map((item) => ({
@@ -5,7 +5,17 @@ import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistActions } from 'm3u-state';
import { PlaylistsService } from 'services';
import { PlaylistMeta } from 'shared-interfaces';
import { PlaylistMeta, StalkerPortalItem } from 'shared-interfaces';
interface FavoritesStoreContext {
currentPlaylist(): PlaylistMeta | undefined;
selectedContentType(): string;
}
type FavoritePayload = StalkerPortalItem & {
stream_id?: string | number;
id?: string | number;
};
/**
* Favorites concern methods.
@@ -20,14 +30,14 @@ export function withStalkerFavorites() {
translate = inject(TranslateService),
ngrxStore = inject(Store)
) => {
const storeAny = store as any;
const storeContext = store as unknown as FavoritesStoreContext;
return {
addToFavorites(item: any, onDone?: () => void) {
const portalId = storeAny.currentPlaylist()?._id;
addToFavorites(item: FavoritePayload, onDone?: () => void) {
const portalId = storeContext.currentPlaylist()?._id;
playlistService
.addPortalFavorite(portalId, {
...item,
category_id: storeAny.selectedContentType(),
category_id: storeContext.selectedContentType(),
added_at: Date.now(),
id: item.stream_id ?? item.id,
})
@@ -57,7 +67,7 @@ export function withStalkerFavorites() {
favoriteId: string,
onDone?: () => void
) {
const portalId = storeAny.currentPlaylist()?._id;
const portalId = storeContext.currentPlaylist()?._id;
playlistService
.removeFromPortalFavorites(portalId, favoriteId)
.subscribe((updatedPlaylist) => {
@@ -5,7 +5,7 @@ import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistActions } from 'm3u-state';
import { PORTAL_PLAYER } from '@iptvnator/portal/shared/util';
import { DataService, PlaylistsService, StalkerSessionService } from 'services';
import { DataService, PlaylistsService } from 'services';
import {
Playlist,
PlaylistMeta,
@@ -22,6 +22,7 @@ import {
isCrossOriginStalkerStream,
STALKER_MAG_USER_AGENT,
} from '../../stalker-live-playback.utils';
import { StalkerSessionService } from '../../stalker-session.service';
import {
normalizeStalkerEntityId,
normalizeStalkerEntityIdAsNumber,
@@ -7,8 +7,9 @@ import {
withState,
} from '@ngrx/signals';
import { Playlist, PlaylistMeta, STALKER_REQUEST } from 'shared-interfaces';
import { DataService, StalkerSessionService } from 'services';
import { createLogger } from '@iptvnator/portal/shared/util';
import { DataService } from 'services';
import { StalkerSessionService } from '../../stalker-session.service';
/**
* Portal/session state and methods.
@@ -36,7 +37,7 @@ export function withStalkerPortal() {
*/
async makeStalkerRequest(
playlist: PlaylistMeta,
params: Record<string, any>
params: Record<string, string | number>
) {
// Get token if it's a full stalker portal
let token: string | undefined;
@@ -3,9 +3,19 @@ import { signalStoreFeature, withMethods } from '@ngrx/signals';
import { Store } from '@ngrx/store';
import { PlaylistActions } from 'm3u-state';
import { PlaylistsService } from 'services';
import { PlaylistMeta } from 'shared-interfaces';
import { PlaylistMeta, StalkerPortalItem } from 'shared-interfaces';
import { createLogger } from '@iptvnator/portal/shared/util';
interface RecentStoreContext {
currentPlaylist(): PlaylistMeta | undefined;
selectedContentType(): string;
}
type RecentlyViewedPayload = StalkerPortalItem & {
id?: string | number;
title?: string;
};
/**
* Recently-viewed concern methods.
*/
@@ -18,17 +28,24 @@ export function withStalkerRecent() {
playlistService = inject(PlaylistsService),
ngrxStore = inject(Store)
) => {
const storeAny = store as any;
const storeContext = store as unknown as RecentStoreContext;
return {
addToRecentlyViewed(item: any) {
const portalId = storeAny.currentPlaylist()?._id;
addToRecentlyViewed(item: RecentlyViewedPayload) {
const portalId = storeContext.currentPlaylist()?._id;
const recentItem = {
...item,
category_id: storeContext.selectedContentType(),
added_at: Date.now(),
id: item.id ?? item.stream_id ?? '',
title:
item.title ??
item.name ??
item.o_name ??
'',
};
playlistService
.addPortalRecentlyViewed(portalId, {
...item,
category_id: storeAny.selectedContentType(),
added_at: Date.now(),
})
.addPortalRecentlyViewed(portalId, recentItem)
.subscribe((updatedPlaylist) => {
ngrxStore.dispatch(
PlaylistActions.updatePlaylistMeta({
@@ -45,7 +62,7 @@ export function withStalkerRecent() {
itemId: string | number,
onComplete?: () => void
) {
const portalId = storeAny.currentPlaylist()?._id;
const portalId = storeContext.currentPlaylist()?._id;
playlistService
.removeFromPortalRecentlyViewed(portalId, itemId)
.subscribe({
@@ -5,9 +5,15 @@ import {
withMethods,
withState,
} from '@ngrx/signals';
import { StalkerVodSource } from '../../models';
import { StalkerCategoryItem, StalkerVodSource } from '../../models';
import { normalizeStalkerEntityId } from '../../stalker-vod.utils';
interface SelectionStoreContext {
vodCategories(): StalkerCategoryItem[];
seriesCategories(): StalkerCategoryItem[];
itvCategories(): StalkerCategoryItem[];
}
/**
* Selection/pagination/search feature state.
*/
@@ -93,7 +99,7 @@ export function withStalkerSelection() {
},
/** getters */
getSelectedCategory: computed(() => {
const storeAny = store as any;
const storeContext = store as unknown as SelectionStoreContext;
const categoryId = store.selectedCategoryId();
if (!categoryId) {
return {
@@ -105,15 +111,15 @@ export function withStalkerSelection() {
// Get categories based on content type
const contentType = store.selectedContentType();
let categories: any[] = [];
let categories: StalkerCategoryItem[] = [];
const readCategories = (
getterName:
| 'vodCategories'
| 'seriesCategories'
| 'itvCategories'
) =>
typeof storeAny[getterName] === 'function'
? storeAny[getterName]()
typeof storeContext[getterName] === 'function'
? storeContext[getterName]()
: [];
if (contentType === 'vod') {
categories = readCategories('vodCategories');
@@ -125,7 +131,8 @@ export function withStalkerSelection() {
return (
categories.find(
(c: any) => String(c.category_id) === String(categoryId)
(category) =>
String(category.category_id) === String(categoryId)
) || {
category_id: categoryId,
category_name: '',
@@ -7,12 +7,15 @@ import {
withProps,
withState,
} from '@ngrx/signals';
import { DataService, StalkerSessionService } from 'services';
import { DataService } from 'services';
import { Playlist, STALKER_REQUEST, StalkerPortalActions } from 'shared-interfaces';
import {
StalkerSeason,
StalkerVodSource,
StalkerVodSeriesEpisode,
StalkerVodSeriesSeason,
} from '../../models';
import { StalkerSessionService } from '../../stalker-session.service';
import { StalkerContentTypes } from '../../stalker-content-types';
import {
sortByNumericValue,
@@ -37,6 +40,26 @@ const initialSeriesState: StalkerSeriesState = {
selectedVodSeriesSeasonId: undefined,
};
interface ResourceState<T> {
value(): T;
isLoading(): boolean;
}
interface StalkerSeriesResponse<T> {
js?: {
data?: T[];
};
}
interface StalkerSeriesStoreContext {
selectedSerialId(): string | undefined;
currentPlaylist(): Playlist | undefined;
selectedItem(): StalkerVodSource | null | undefined;
selectedContentType(): 'vod' | 'series' | 'itv';
serialSeasonsResource: ResourceState<StalkerSeason[]>;
vodSeriesSeasonsResource: ResourceState<StalkerVodSeriesSeason[]>;
}
export function withStalkerSeries() {
const logger = createLogger('withStalkerSeries');
const toMovieId = (value: unknown): string => {
@@ -51,18 +74,22 @@ export function withStalkerSeries() {
store,
dataService = inject(DataService),
stalkerSession = inject(StalkerSessionService)
) => ({
serialSeasonsResource: resource({
) => {
const storeContext =
store as unknown as StalkerSeriesStoreContext;
return {
serialSeasonsResource: resource({
params: () => ({
itemId: (store as any).selectedSerialId(),
itemId: storeContext.selectedSerialId(),
}),
loader: async ({ params }) => {
loader: async ({ params }): Promise<StalkerSeason[]> => {
const movieId = toMovieId(params.itemId);
// Guard: ensure currentPlaylist and itemId are available
if (!(store as any).currentPlaylist() || !movieId) {
if (!storeContext.currentPlaylist() || !movieId) {
return [];
}
const playlist = (store as any).currentPlaylist() as Playlist;
const playlist = storeContext.currentPlaylist() as Playlist;
const queryParams = {
action: StalkerContentTypes.series.getContentAction,
type: 'series',
@@ -70,17 +97,16 @@ export function withStalkerSeries() {
};
// Use makeAuthenticatedRequest for automatic retry on auth failure
let response: any;
let response: StalkerSeriesResponse<StalkerSeason>;
if (playlist.isFullStalkerPortal) {
// Full stalker portal - use authenticated request with retry
response =
await stalkerSession.makeAuthenticatedRequest(
response = await stalkerSession.makeAuthenticatedRequest<StalkerSeriesResponse<StalkerSeason>>(
playlist,
queryParams
);
} else {
// Simple stalker portal - no auth needed
response = await dataService.sendIpcEvent(
response = await dataService.sendIpcEvent<StalkerSeriesResponse<StalkerSeason>>(
STALKER_REQUEST,
{
url: playlist.portalUrl,
@@ -104,9 +130,8 @@ export function withStalkerSeries() {
*/
vodSeriesSeasonsResource: resource({
params: () => ({
selectedItem: (store as any).selectedItem(),
selectedContentType:
(store as any).selectedContentType?.() ?? 'vod',
selectedItem: storeContext.selectedItem(),
selectedContentType: storeContext.selectedContentType(),
}),
loader: async ({
params,
@@ -115,12 +140,12 @@ export function withStalkerSeries() {
logger.debug('vodSeriesSeasonsResource loader called', {
item,
isSeries: item?.is_series,
currentPlaylist: (store as any).currentPlaylist(),
currentPlaylist: storeContext.currentPlaylist(),
});
// Only fetch if item is a VOD series (has is_series flag)
if (
!(store as any).currentPlaylist() ||
!storeContext.currentPlaylist() ||
params.selectedContentType !== 'vod' ||
!item ||
!item.is_series
@@ -131,7 +156,7 @@ export function withStalkerSeries() {
return [];
}
const playlist = (store as any).currentPlaylist() as Playlist;
const playlist = storeContext.currentPlaylist() as Playlist;
const queryParams = {
action: StalkerPortalActions.GetOrderedList,
type: 'vod',
@@ -139,15 +164,14 @@ export function withStalkerSeries() {
p: '1',
};
let response: any;
let response: StalkerSeriesResponse<StalkerVodSeriesSeason>;
if (playlist.isFullStalkerPortal) {
response =
await stalkerSession.makeAuthenticatedRequest(
response = await stalkerSession.makeAuthenticatedRequest<StalkerSeriesResponse<StalkerVodSeriesSeason>>(
playlist,
queryParams
);
} else {
response = await dataService.sendIpcEvent(
response = await dataService.sendIpcEvent<StalkerSeriesResponse<StalkerVodSeriesSeason>>(
STALKER_REQUEST,
{
url: playlist.portalUrl,
@@ -180,25 +204,26 @@ export function withStalkerSeries() {
);
return sortVodSeriesSeasonsByNumber(seasons);
},
}),
})
}),
};
}
),
withComputed((store) => {
const storeAny = store as any;
const storeContext = store as unknown as StalkerSeriesStoreContext;
return {
/** serials */
getSerialSeasonsResource: computed(() =>
storeAny.serialSeasonsResource.value()
storeContext.serialSeasonsResource.value()
),
isSerialSeasonsLoading: computed(() =>
storeAny.serialSeasonsResource.isLoading()
storeContext.serialSeasonsResource.isLoading()
),
/** VOD series (Ministra plugin is_series=1) */
getVodSeriesSeasonsResource: computed(() =>
storeAny.vodSeriesSeasonsResource.value()
storeContext.vodSeriesSeasonsResource.value()
),
isVodSeriesSeasonsLoading: computed(() =>
storeAny.vodSeriesSeasonsResource.isLoading()
storeContext.vodSeriesSeasonsResource.isLoading()
),
};
}),
@@ -208,7 +233,11 @@ export function withStalkerSeries() {
dataService = inject(DataService),
stalkerSession = inject(StalkerSessionService)
) => {
const storeAny = store as any;
const storeContext =
store as unknown as Pick<
StalkerSeriesStoreContext,
'currentPlaylist'
>;
return {
/**
@@ -221,7 +250,7 @@ export function withStalkerSeries() {
videoId: string,
seasonId: string
): Promise<StalkerVodSeriesEpisode[]> {
const playlist = storeAny.currentPlaylist() as Playlist;
const playlist = storeContext.currentPlaylist() as Playlist;
if (!playlist) return [];
const queryParams = {
@@ -232,15 +261,14 @@ export function withStalkerSeries() {
p: '1',
};
let response: any;
let response: StalkerSeriesResponse<StalkerVodSeriesEpisode>;
if (playlist.isFullStalkerPortal) {
response =
await stalkerSession.makeAuthenticatedRequest(
response = await stalkerSession.makeAuthenticatedRequest<StalkerSeriesResponse<StalkerVodSeriesEpisode>>(
playlist,
queryParams
);
} else {
response = await dataService.sendIpcEvent(
response = await dataService.sendIpcEvent<StalkerSeriesResponse<StalkerVodSeriesEpisode>>(
STALKER_REQUEST,
{
url: playlist.portalUrl,
+3
View File
@@ -1,10 +1,13 @@
export * from './lib/stalker-main-container.component';
export * from './lib/stalker-inline-detail/stalker-inline-detail.component';
export * from './lib/stalker-collection-channels-list/stalker-collection-channels-list.component';
export * from './lib/stalker-catalog-detail/stalker-catalog-detail.component';
export * from './lib/stalker-favorites/stalker-favorites.component';
export * from './lib/stalker-favorites-button/stalker-favorites-button.component';
export * from './lib/stalker-catalog-facade.service';
export * from './lib/stalker-feature.routes';
export * from './lib/stalker-live-stream-layout/stalker-live-stream-layout.component';
export * from './lib/stalker-search/stalker-search.component';
export * from './lib/stalker-series-view/stalker-series-view.component';
export * from './lib/stalker-shell/stalker-shell.component';
export * from './lib/recently-viewed/recently-viewed.component';
@@ -0,0 +1,25 @@
@if (contentType() === 'vod') {
@if ((selectedItem()?.series?.length ?? 0) > 0) {
<app-stalker-series-view [vodWithSeries]="selectedItem()" />
} @else if (isSeriesDetail()) {
<app-stalker-series-view />
} @else if (vodDetailsItem()) {
<app-vod-details
[item]="vodDetailsItem()!"
[isFavorite]="isSelectedVodFavorite()"
[playbackPosition]="selectedVodPlaybackPosition()"
[inlinePlayback]="inlinePlayback()"
[externalPlayback]="externalPlayback.activeSession()"
(playClicked)="onVodPlay($event)"
(resumeClicked)="onVodResume($event)"
(favoriteToggled)="onVodFavoriteToggled($event)"
(downloadRequested)="onVodDownload($event)"
(backClicked)="onVodBack()"
(inlineTimeUpdated)="handleInlineTimeUpdate($event)"
(inlinePlaybackClosed)="closeInlinePlayer()"
(streamUrlCopied)="showCopyNotification()"
/>
}
} @else if (contentType() === 'series') {
<app-stalker-series-view />
}
@@ -0,0 +1,371 @@
import {
Component,
OnDestroy,
computed,
effect,
inject,
signal,
} from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import {
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
createLogger,
} from '@iptvnator/portal/shared/util';
import {
createPortalFavoritesResource,
createRefreshTrigger,
isSelectedStalkerVodFavorite,
normalizeStalkerEntityId,
normalizeStalkerEntityIdAsNumber,
StalkerSelectedVodItem,
toggleStalkerVodFavorite,
} from '@iptvnator/portal/stalker/data-access';
import { VodDetailsComponent } from '@iptvnator/ui/playback';
import { DownloadsService, PlaylistsService } from 'services';
import {
createStalkerVodItem,
PlaybackPositionData,
ResolvedPortalPlayback,
StalkerVodDetails,
VodDetailsItem,
} from 'shared-interfaces';
import { StalkerCatalogFacadeService } from '../stalker-catalog-facade.service';
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
interface DownloadVodData {
id?: string | number;
has_files?: unknown;
info?: {
name?: string;
movie_image?: string;
};
title?: string;
}
@Component({
selector: 'app-stalker-catalog-detail',
imports: [StalkerSeriesViewComponent, VodDetailsComponent],
templateUrl: './stalker-catalog-detail.component.html',
styles: [
`
:host {
display: block;
height: 100%;
width: 100%;
}
`,
],
})
export class StalkerCatalogDetailComponent implements OnDestroy {
private readonly catalog = inject(StalkerCatalogFacadeService);
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
private readonly portalPlayer = inject(PORTAL_PLAYER);
readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
private readonly snackBar = inject(MatSnackBar);
private readonly translateService = inject(TranslateService);
private readonly playlistService = inject(PlaylistsService);
private readonly downloadsService = inject(DownloadsService);
private readonly logger = createLogger('StalkerCatalogDetail');
private readonly favoritesRefresh = createRefreshTrigger();
readonly contentType = this.catalog.contentType;
readonly selectedItem = computed<StalkerSelectedVodItem | null>(
() =>
(this.catalog.selectedItem() as StalkerSelectedVodItem | null) ??
null
);
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
private readonly selectedVodPosition = signal<PlaybackPositionData | null>(
null
);
private lastInlineSaveTime = 0;
private unsubscribePositionUpdates: (() => void) | null = null;
readonly isSeriesDetail = computed(() => {
const item = this.selectedItem();
return Boolean(
item &&
(this.contentType() === 'series' ||
item.is_series === true ||
String(item.is_series) === '1')
);
});
readonly vodDetailsItem = computed<VodDetailsItem | null>(() => {
const item = this.selectedItem();
if (!item || this.contentType() !== 'vod' || this.isSeriesDetail()) {
return null;
}
return createStalkerVodItem(
item as unknown as StalkerVodDetails,
this.catalog.playlist()?.id ?? ''
);
});
readonly selectedVodPlaybackPosition = computed<number | null>(
() => this.selectedVodPosition()?.positionSeconds ?? null
);
readonly portalFavorites = createPortalFavoritesResource(
this.playlistService,
() => this.catalog.playlist()?.id,
() => this.favoritesRefresh.refreshVersion()
);
readonly isSelectedVodFavorite = computed<boolean>(() =>
isSelectedStalkerVodFavorite(
this.vodDetailsItem(),
this.portalFavorites.value() ?? []
)
);
constructor() {
effect(() => {
const item = this.selectedItem();
const playlistId = this.catalog.playlist()?.id;
if (
!item ||
!playlistId ||
this.contentType() !== 'vod' ||
this.isSeriesDetail()
) {
this.selectedVodPosition.set(null);
return;
}
void this.loadSelectedVodPosition(playlistId, Number(item.id));
});
effect(() => {
const selectedItemId = this.selectedItem()?.id;
void selectedItemId;
this.closeInlinePlayer();
});
if (window.electron?.onPlaybackPositionUpdate) {
this.unsubscribePositionUpdates =
window.electron.onPlaybackPositionUpdate(
(data: PlaybackPositionData) => {
const currentItem = this.selectedItem();
if (
data.contentType !== 'vod' ||
data.playlistId !== this.catalog.playlist()?.id ||
data.contentXtreamId !== Number(currentItem?.id)
) {
return;
}
this.selectedVodPosition.set(data);
}
);
}
}
onVodPlay(item: VodDetailsItem): void {
if (item.type === 'stalker') {
void this.startStalkerVodPlayback(
item.cmd,
item.data.info?.name,
item.data.info?.movie_image
);
}
}
onVodResume(event: {
item: VodDetailsItem;
positionSeconds: number;
}): void {
if (event.item.type === 'stalker') {
void this.startStalkerVodPlayback(
event.item.cmd,
event.item.data.info?.name,
event.item.data.info?.movie_image,
event.positionSeconds
);
}
}
onVodFavoriteToggled(event: {
item: VodDetailsItem;
isFavorite: boolean;
}): void {
toggleStalkerVodFavorite(event, {
addToFavorites: (item, onDone) =>
this.catalog.addToFavorites(item, onDone),
removeFromFavorites: (favoriteId, onDone) =>
this.catalog.removeFromFavorites(favoriteId, onDone),
onComplete: () => {
this.favoritesRefresh.refresh();
},
});
}
onVodBack(): void {
this.closeInlinePlayer();
this.catalog.clearSelectedItem();
}
handleInlineTimeUpdate(event: {
currentTime: number;
duration: number;
}): void {
const playback = this.inlinePlayback();
if (!playback?.contentInfo) {
return;
}
const now = Date.now();
if (now - this.lastInlineSaveTime <= 15000) {
return;
}
this.lastInlineSaveTime = now;
const position: PlaybackPositionData = {
...playback.contentInfo,
positionSeconds: Math.floor(event.currentTime),
durationSeconds: Math.floor(event.duration),
};
void this.playbackPositions.savePlaybackPosition(
playback.contentInfo.playlistId,
position
);
this.selectedVodPosition.set(position);
}
closeInlinePlayer(): void {
this.inlinePlayback.set(null);
this.lastInlineSaveTime = 0;
}
showCopyNotification(): void {
this.snackBar.open(
this.translateService.instant('PORTALS.STREAM_URL_COPIED'),
null,
{
duration: 2000,
}
);
}
async onVodDownload(item: VodDetailsItem): Promise<void> {
if (item.type !== 'stalker') {
return;
}
const playlist = this.catalog.playlist();
if (!playlist || !playlist.portalUrl || !playlist.macAddress) {
return;
}
let cmdToUse = item.cmd;
const itemData = item.data as DownloadVodData;
const normalizedItemId = normalizeStalkerEntityId(itemData?.id);
if (
itemData?.has_files !== undefined &&
cmdToUse &&
!cmdToUse.includes('://') &&
cmdToUse.includes('/media/') &&
!cmdToUse.includes('/media/file_')
) {
const fileId =
await this.catalog.fetchMovieFileId(normalizedItemId);
if (fileId) {
cmdToUse = `/media/file_${fileId}.mpg`;
}
}
const url = await this.catalog.fetchLinkToPlay(
playlist.portalUrl,
playlist.macAddress,
cmdToUse
);
if (!url) {
return;
}
const numericId = normalizeStalkerEntityIdAsNumber(itemData?.id) ?? 0;
await this.downloadsService.startDownload({
playlistId: playlist.id,
xtreamId: numericId,
contentType: 'vod',
title: itemData?.info?.name || itemData?.title || 'Unknown',
url,
posterUrl: itemData?.info?.movie_image,
headers: {
userAgent: playlist.userAgent,
referer: playlist.referer,
origin: playlist.origin,
},
playlistName: playlist.title || 'Stalker Portal',
playlistType: 'stalker',
portalUrl: playlist.portalUrl,
macAddress: playlist.macAddress,
});
}
ngOnDestroy(): void {
this.closeInlinePlayer();
this.unsubscribePositionUpdates?.();
}
private async loadSelectedVodPosition(
playlistId: string,
vodId: number
): Promise<void> {
if (Number.isNaN(vodId)) {
this.selectedVodPosition.set(null);
return;
}
const position = await this.playbackPositions.getPlaybackPosition(
playlistId,
vodId,
'vod'
);
this.selectedVodPosition.set(position ?? null);
}
private async startStalkerVodPlayback(
cmd?: string,
title?: string,
thumbnail?: string,
startTime?: number
): Promise<void> {
try {
const playback = await this.catalog.resolveVodPlayback(
cmd,
title,
thumbnail,
startTime
);
this.lastInlineSaveTime = 0;
if (this.portalPlayer.isEmbeddedPlayer()) {
this.inlinePlayback.set(playback);
return;
}
this.closeInlinePlayer();
void this.portalPlayer.openResolvedPlayback(playback, true);
} catch (error) {
this.logger.error('Failed to start inline VOD playback', error);
const errorMessage =
error instanceof Error && error.message === 'nothing_to_play'
? this.translateService.instant(
'PORTALS.CONTENT_NOT_AVAILABLE'
)
: this.translateService.instant('PORTALS.PLAYBACK_ERROR');
this.snackBar.open(errorMessage, null, {
duration: 3000,
});
}
}
}
@@ -1,4 +1,12 @@
import { Provider, Injectable, computed, inject } from '@angular/core';
import {
DestroyRef,
Injectable,
Provider,
computed,
effect,
inject,
signal,
} from '@angular/core';
import {
buildStalkerSelectedVodItem,
StalkerStore,
@@ -9,8 +17,25 @@ import {
PortalCatalogPlaylistMeta,
PortalCatalogSortMode,
PORTAL_CATALOG_FACADE,
PORTAL_PLAYBACK_POSITIONS,
StalkerPortalCatalogFacade,
} from '@iptvnator/portal/shared/util';
import { PlaybackPositionData } from 'shared-interfaces';
function calculateProgress(position: PlaybackPositionData | undefined): number {
if (!position || !position.durationSeconds) {
return 0;
}
const percent =
(position.positionSeconds / position.durationSeconds) * 100;
if (position.positionSeconds > 10 && percent < 1) {
return 1;
}
return Math.min(100, Math.round(percent));
}
@Injectable()
export class StalkerCatalogFacadeService
@@ -22,6 +47,15 @@ export class StalkerCatalogFacadeService
>
{
private readonly stalkerStore = inject(StalkerStore);
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
private readonly destroyRef = inject(DestroyRef);
private readonly stalkerPositions = signal<Map<string, PlaybackPositionData>>(
new Map()
);
private readonly stalkerSeriesPositions = signal<
Map<number, PlaybackPositionData[]>
>(new Map());
private loadedPositionsForPlaylistId: string | null = null;
readonly provider = 'stalker' as const;
readonly pageSizeOptions = [14] as const;
@@ -69,6 +103,51 @@ export class StalkerCatalogFacadeService
};
});
constructor() {
effect(() => {
const playlistId = this.playlist()?.id;
if (!playlistId) {
this.loadedPositionsForPlaylistId = null;
this.stalkerPositions.set(new Map());
this.stalkerSeriesPositions.set(new Map());
return;
}
if (playlistId === this.loadedPositionsForPlaylistId) {
return;
}
this.loadedPositionsForPlaylistId = playlistId;
void this.loadStalkerPositions(playlistId);
});
if (window.electron?.onPlaybackPositionUpdate) {
const unsubscribe = window.electron.onPlaybackPositionUpdate(
(data: PlaybackPositionData) => {
if (data.playlistId !== this.playlist()?.id) {
return;
}
if (data.contentType === 'vod') {
this.updateVodPlaybackPosition(data);
}
if (
data.contentType === 'episode' &&
data.seriesXtreamId
) {
this.updateSeriesPlaybackPosition(data);
}
}
);
if (typeof unsubscribe === 'function') {
this.destroyRef.onDestroy(unsubscribe);
}
}
}
initialize(categoryId?: string | null): void {
this.clearSelectedItem();
if (categoryId) {
@@ -108,8 +187,34 @@ export class StalkerCatalogFacadeService
}
getItemProgress(item: StalkerVodSource): PortalCatalogItemProgress {
void item;
return {};
const numericId = Number(item.id);
if (Number.isNaN(numericId)) {
return {};
}
const hasSeriesProgress = Boolean(
this.stalkerSeriesPositions().get(numericId)?.length
);
const isSeries =
this.contentType() === 'series' ||
item.is_series === '1' ||
item.is_series === 1;
if (hasSeriesProgress) {
return { hasSeriesProgress: true };
}
if (isSeries) {
return { hasSeriesProgress: false };
}
const progress = calculateProgress(
this.stalkerPositions().get(`vod_${numericId}`)
);
return {
progress,
isWatched: progress >= 90,
};
}
async createLinkToPlayVod(
@@ -155,6 +260,52 @@ export class StalkerCatalogFacadeService
startTime
);
}
private async loadStalkerPositions(playlistId: string): Promise<void> {
const positions =
await this.playbackPositions.getAllPlaybackPositions(playlistId);
const positionsMap = new Map<string, PlaybackPositionData>();
const seriesMap = new Map<number, PlaybackPositionData[]>();
positions.forEach((position) => {
positionsMap.set(
`${position.contentType}_${position.contentXtreamId}`,
position
);
if (position.contentType === 'episode' && position.seriesXtreamId) {
const existing = seriesMap.get(position.seriesXtreamId) ?? [];
existing.push(position);
seriesMap.set(position.seriesXtreamId, existing);
}
});
this.stalkerPositions.set(positionsMap);
this.stalkerSeriesPositions.set(seriesMap);
}
private updateVodPlaybackPosition(position: PlaybackPositionData): void {
const updated = new Map(this.stalkerPositions());
updated.set(`vod_${position.contentXtreamId}`, position);
this.stalkerPositions.set(updated);
}
private updateSeriesPlaybackPosition(position: PlaybackPositionData): void {
if (!position.seriesXtreamId) {
return;
}
const updated = new Map(this.stalkerSeriesPositions());
const positionsForSeries = [
...(updated.get(position.seriesXtreamId) ?? []).filter(
(item) => item.contentXtreamId !== position.contentXtreamId
),
position,
];
updated.set(position.seriesXtreamId, positionsForSeries);
this.stalkerSeriesPositions.set(updated);
}
}
export function provideStalkerCatalogFacade(): Provider[] {
@@ -5,7 +5,16 @@ import { TranslateModule } from '@ngx-translate/core';
import { BehaviorSubject, map, switchMap } from 'rxjs';
import { PlaylistsService } from 'services';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { matchesFavoriteById } from '@iptvnator/portal/stalker/data-access';
import {
matchesFavoriteById,
StalkerSelectedVodItem,
} from '@iptvnator/portal/stalker/data-access';
type FavoriteButtonItem = StalkerSelectedVodItem & {
name?: string;
o_name?: string;
cover?: string;
};
@Component({
selector: 'app-favorites-button',
@@ -18,7 +27,7 @@ export class FavoritesButtonComponent {
private stalkerStore = inject(StalkerStore);
readonly itemId = input.required<string>();
readonly item = input.required<any>();
readonly item = input.required<FavoriteButtonItem>();
private readonly favoritesChanged$ = new BehaviorSubject<void>(undefined);
@@ -1,4 +1,6 @@
import { Route } from '@angular/router';
import { PORTAL_CATALOG_DETAIL_COMPONENT } from '@iptvnator/portal/shared/util';
import { StalkerCatalogDetailComponent } from './stalker-catalog-detail/stalker-catalog-detail.component';
import { provideStalkerCatalogFacade } from './stalker-catalog-facade.service';
type ComponentLoader = NonNullable<Route['loadComponent']>;
@@ -56,7 +58,13 @@ export function createStalkerRoutes(): Route[] {
},
{
path: 'vod',
providers: provideStalkerCatalogFacade(),
providers: [
...provideStalkerCatalogFacade(),
{
provide: PORTAL_CATALOG_DETAIL_COMPONENT,
useValue: StalkerCatalogDetailComponent,
},
],
loadComponent: loadStalkerMainContainerComponent,
children: [
{
@@ -88,7 +96,13 @@ export function createStalkerRoutes(): Route[] {
},
{
path: 'series',
providers: provideStalkerCatalogFacade(),
providers: [
...provideStalkerCatalogFacade(),
{
provide: PORTAL_CATALOG_DETAIL_COMPONENT,
useValue: StalkerCatalogDetailComponent,
},
],
loadComponent: loadStalkerMainContainerComponent,
children: [
{
@@ -7,11 +7,9 @@ import {
} from '@angular/core';
import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util';
import { StalkerSelectedVodItem } from '@iptvnator/portal/stalker/data-access';
import { VodDetailsComponent } from '@iptvnator/ui/playback';
import { VodDetailsItem } from 'shared-interfaces';
import {
StalkerSeriesViewComponent,
VodDetailsComponent,
} from 'components';
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
@Component({
selector: 'app-stalker-inline-detail',
@@ -23,10 +23,10 @@ import { ActivatedRoute } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import {
ChannelListItemComponent,
PlaylistSwitcherComponent,
ResizableDirective,
} from 'components';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { PlaylistSwitcherComponent } from '@iptvnator/playlist/shared/ui';
import { PlaylistsService } from 'services';
import { EpgItem, EpgProgram } from 'shared-interfaces';
import { EpgViewComponent, WebPlayerViewComponent } from 'shared-portals';
@@ -4,7 +4,8 @@ import { MatIcon } from '@angular/material/icon';
import { MatPaginatorModule } from '@angular/material/paginator';
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { PlaylistSwitcherComponent, ResizableDirective } from 'components';
import { PlaylistSwitcherComponent } from '@iptvnator/playlist/shared/ui';
import { ResizableDirective } from 'components';
import {
CategoryViewComponent,
PlaylistErrorViewComponent,
@@ -4,8 +4,9 @@ import { MatCheckboxModule } from '@angular/material/checkbox';
import { ActivatedRoute } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslatePipe } from '@ngx-translate/core';
import { StalkerSessionService } from '@iptvnator/portal/stalker/data-access';
import { selectPlaylistById } from 'm3u-state';
import { DataService, PlaylistsService, StalkerSessionService } from 'services';
import { DataService, PlaylistsService } from 'services';
import {
Playlist,
STALKER_REQUEST,
@@ -44,6 +45,14 @@ interface StalkerFilter {
translationKey: string;
}
interface StalkerSearchResponse {
js?: {
data?: StalkerVodSource[];
};
message?: string;
status?: number;
}
@Component({
selector: 'app-stalker-search',
imports: [
@@ -136,9 +145,10 @@ export class StalkerSearchComponent {
}
}
const response = await this.dataService.sendIpcEvent(
STALKER_REQUEST,
{
const response =
await this.dataService.sendIpcEvent<StalkerSearchResponse>(
STALKER_REQUEST,
{
url: portalUrl,
macAddress,
params: {
@@ -150,8 +160,8 @@ export class StalkerSearchComponent {
},
token,
serialNumber,
}
);
}
);
if (response) {
const items = response.js?.data || [];
return items.map((item: StalkerVodSource) =>
@@ -10,10 +10,12 @@ import {
} from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { ContentHeroComponent } from '../content-hero/content-hero.component';
import { FavoritesButtonComponent } from '../stalker-favorites-button/stalker-favorites-button.component';
import { PortalInlinePlayerComponent } from '../portal-inline-player/portal-inline-player.component';
import { SeasonContainerComponent } from '../season-container/season-container.component';
import {
ContentHeroComponent,
SeasonContainerComponent,
SeasonContainerPlaybackToggleRequest,
} from 'components';
import {
PlaybackPositionData,
ResolvedPortalPlayback,
@@ -40,8 +42,8 @@ import {
StalkerStore,
StalkerVodSource,
} from '@iptvnator/portal/stalker/data-access';
import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback';
import { DownloadsService } from 'services';
import { SeasonContainerPlaybackToggleRequest } from '../season-container/season-container.component';
/**
* Component for displaying series/episodes for Stalker portal content.
@@ -0,0 +1,588 @@
// ============================================================================
// Netflix-style Detail View - Cinematic Streaming Design
// ============================================================================
:host {
--poster-width: 220px;
--poster-shadow: 0 8px 40px rgba(0, 0, 0, 0.15);
--content-max-width: 1400px;
// Light theme colors (default - no .dark-theme class on body)
--hero-gradient-start: rgba(255, 250, 245, 0);
--hero-gradient-mid: rgba(255, 250, 245, 0.75);
--hero-gradient-end: rgba(255, 250, 245, 1);
--surface-bg: #fffaf5;
--text-primary: #1a1a1a;
--text-secondary: rgba(26, 26, 26, 0.75);
--text-muted: rgba(26, 26, 26, 0.5);
--accent-color: #b81d24;
--tag-bg: rgba(0, 0, 0, 0.06);
--tag-border: rgba(0, 0, 0, 0.12);
--play-idle-bg: linear-gradient(135deg, #fffaf2 0%, #ffffff 52%, #f7ecdf 100%);
--play-idle-fg: #161310;
--play-idle-border: rgba(88, 48, 18, 0.12);
--play-idle-shadow: 0 18px 38px rgba(91, 56, 24, 0.14);
--play-launching-bg: linear-gradient(135deg, #ffd36f 0%, #ffb14c 46%, #f57d35 100%);
--play-launching-fg: #241103;
--play-launching-border: rgba(142, 66, 0, 0.2);
--play-launching-shadow: 0 22px 44px rgba(244, 136, 55, 0.32);
--play-stop-bg: linear-gradient(135deg, #ffd1c8 0%, #ff9f8d 42%, #ef5e64 100%);
--play-stop-fg: #321012;
--play-stop-border: rgba(162, 38, 45, 0.18);
--play-stop-shadow: 0 22px 46px rgba(214, 74, 86, 0.26);
--play-icon-shell-bg: rgba(255, 255, 255, 0.32);
--play-icon-shell-border: rgba(255, 255, 255, 0.3);
width: 100%;
height: 100%;
display: block;
overflow: hidden;
background: var(--surface-bg);
color: var(--text-primary);
}
// Dark theme overrides - cinematic dark mode
:host-context(.dark-theme) {
--hero-gradient-start: rgba(20, 20, 20, 0);
--hero-gradient-mid: rgba(20, 20, 20, 0.7);
--hero-gradient-end: rgba(20, 20, 20, 1);
--surface-bg: #141414;
--text-primary: #fff;
--text-secondary: rgba(255, 255, 255, 0.7);
--text-muted: rgba(255, 255, 255, 0.5);
--accent-color: #e50914;
--tag-bg: rgba(255, 255, 255, 0.1);
--tag-border: rgba(255, 255, 255, 0.2);
--poster-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
--play-idle-bg: linear-gradient(135deg, #fff5e6 0%, #f6efe5 58%, #e7dac9 100%);
--play-idle-fg: #15110d;
--play-idle-border: rgba(255, 255, 255, 0.18);
--play-idle-shadow: 0 20px 42px rgba(0, 0, 0, 0.36);
--play-launching-bg: linear-gradient(135deg, #ffdd79 0%, #ffb347 42%, #ef6a3a 100%);
--play-launching-fg: #1f1105;
--play-launching-border: rgba(255, 223, 136, 0.26);
--play-launching-shadow: 0 24px 50px rgba(255, 153, 71, 0.3);
--play-stop-bg: linear-gradient(135deg, #ffd7cf 0%, #ff9b8b 40%, #eb5961 100%);
--play-stop-fg: #2d1012;
--play-stop-border: rgba(255, 205, 205, 0.24);
--play-stop-shadow: 0 24px 50px rgba(235, 89, 97, 0.24);
--play-icon-shell-bg: rgba(255, 255, 255, 0.28);
--play-icon-shell-border: rgba(255, 255, 255, 0.24);
}
// ============================================================================
// Details Section - Content Elements
// ============================================================================
.details {
// Styles for projected content elements
&__tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
font-size: 0.8125rem;
font-weight: 500;
background: var(--tag-bg);
border: 1px solid var(--tag-border);
border-radius: 4px;
backdrop-filter: blur(8px);
&--rating {
color: #ffd700;
border-color: rgba(255, 215, 0, 0.3);
}
}
&__meta-item {
font-size: 0.875rem;
.label {
color: var(--text-muted);
font-weight: 400;
margin-bottom: 2px;
text-decoration: none;
display: block;
}
.value {
color: var(--text-primary);
font-weight: 500;
}
}
}
// ============================================================================
// Action Buttons - Play, Favorite
// ============================================================================
.play-btn {
--play-btn-bg: var(--play-idle-bg);
--play-btn-fg: var(--play-idle-fg);
--play-btn-border: var(--play-idle-border);
--play-btn-shadow: var(--play-idle-shadow);
position: relative;
isolation: isolate;
overflow: hidden;
display: inline-flex;
align-items: center;
gap: 12px;
padding: 12px 26px;
min-height: 54px;
font-size: 1rem;
font-weight: 600;
letter-spacing: 0.01em;
background: var(--play-btn-bg);
background-size: 140% 140%;
background-position: 0% 50%;
color: var(--play-btn-fg);
border: 1px solid var(--play-btn-border);
border-radius: 16px;
box-shadow: var(--play-btn-shadow);
cursor: pointer;
transition:
transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
background-position 280ms ease,
box-shadow 260ms ease,
border-color 220ms ease,
color 220ms ease,
filter 220ms ease;
&::before {
content: '';
position: absolute;
inset: 1px;
border-radius: inherit;
background:
linear-gradient(115deg, rgba(255, 255, 255, 0.34), transparent 36%),
linear-gradient(180deg, rgba(255, 255, 255, 0.18), transparent 62%);
opacity: 0.7;
transition: opacity 220ms ease;
z-index: -1;
}
&:hover:not(:disabled) {
transform: translateY(-2px);
background-position: 100% 50%;
box-shadow:
var(--play-btn-shadow),
0 10px 20px rgba(255, 255, 255, 0.16) inset;
}
&:hover:not(:disabled)::before {
opacity: 0.95;
}
&:active:not(:disabled) {
transform: translateY(0);
box-shadow: var(--play-btn-shadow);
}
&:disabled {
cursor: progress;
opacity: 1;
filter: saturate(1.05);
}
mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
transition:
transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
color 220ms ease,
filter 220ms ease;
}
&:hover:not(:disabled) .play-btn__icon {
transform: scale(1.08);
}
&__icon-shell {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 999px;
background: var(--play-icon-shell-bg);
border: 1px solid var(--play-icon-shell-border);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.38),
0 6px 14px rgba(0, 0, 0, 0.08);
backdrop-filter: blur(10px);
transition:
transform 220ms ease,
background-color 220ms ease,
border-color 220ms ease,
box-shadow 220ms ease;
}
&__label {
display: inline-flex;
align-items: center;
min-width: 0;
}
&--state-launching,
&--state-stop {
color: var(--play-btn-fg);
}
&--state-launching {
--play-btn-bg: var(--play-launching-bg);
--play-btn-fg: var(--play-launching-fg);
--play-btn-border: var(--play-launching-border);
--play-btn-shadow: var(--play-launching-shadow);
animation: playButtonPulse 1.8s ease-in-out infinite;
.play-btn__icon-shell {
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.42),
0 10px 22px rgba(184, 89, 26, 0.2);
}
.play-btn__icon {
animation: playIconLaunch 1.15s cubic-bezier(0.55, 0, 0.45, 1) infinite;
}
}
&--state-stop {
--play-btn-bg: var(--play-stop-bg);
--play-btn-fg: var(--play-stop-fg);
--play-btn-border: var(--play-stop-border);
--play-btn-shadow: var(--play-stop-shadow);
.play-btn__icon-shell {
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.4),
0 12px 24px rgba(145, 38, 53, 0.18);
}
.play-btn__icon {
animation: playIconStop 1.2s ease-in-out infinite;
}
}
}
@keyframes playButtonPulse {
0%,
100% {
box-shadow: var(--play-btn-shadow);
}
50% {
box-shadow:
var(--play-btn-shadow),
0 0 0 6px rgba(255, 190, 92, 0.14);
}
}
@keyframes playIconLaunch {
0%,
100% {
transform: rotate(0deg) scale(1);
}
50% {
transform: rotate(18deg) scale(1.1);
}
}
@keyframes playIconStop {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.08);
}
}
@media (prefers-reduced-motion: reduce) {
.play-btn,
.play-btn::before,
.play-btn mat-icon,
.play-btn__icon-shell {
animation: none !important;
transition-duration: 1ms !important;
}
}
.favorite-btn,
.download-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
font-size: 0.9375rem;
font-weight: 500;
background: rgba(109, 109, 110, 0.7);
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(109, 109, 110, 0.5);
}
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
}
.favorite-btn {
&--active {
background: rgba(229, 9, 20, 0.8);
&:hover {
background: rgba(229, 9, 20, 0.6);
}
}
}
.download-btn {
background: rgba(33, 150, 243, 0.8);
&:hover {
background: rgba(33, 150, 243, 0.6);
}
&--completed {
background: rgba(76, 175, 80, 0.8);
&:hover {
background: rgba(76, 175, 80, 0.6);
}
}
&--downloading {
background: rgba(109, 109, 110, 0.5);
cursor: not-allowed;
opacity: 0.7;
&:hover {
background: rgba(109, 109, 110, 0.5);
}
}
}
// ============================================================================
// Content Sections - Below the hero fold
// ============================================================================
.content-section {
padding: 20px 0;
width: 100%;
box-sizing: border-box;
&__title {
font-size: 1.375rem;
font-weight: 600;
margin: 0 0 20px 0;
color: var(--text-primary);
}
}
// ============================================================================
// Seasons & Episodes - Inside details section
// ============================================================================
.seasons {
margin-top: 24px;
width: 100%;
.season-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
cursor: pointer;
transition: opacity 0.2s ease;
&:hover {
opacity: 0.8;
}
h4 {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.expand-icon {
font-size: 0.75rem;
color: var(--text-muted);
}
}
.episodes {
display: flex;
flex-wrap: wrap;
gap: 10px;
padding: 8px 0 16px;
button {
margin: 0;
font-weight: 500;
}
}
.episodes-loading,
.seasons-loading {
display: flex;
align-items: center;
gap: 12px;
padding: 20px 0;
color: var(--text-secondary);
}
.no-episodes {
color: var(--text-muted);
font-style: italic;
padding: 8px 0;
}
}
// Seasons loading when inside details
.details .seasons-loading {
display: flex;
align-items: center;
gap: 12px;
padding: 20px 0;
color: var(--text-secondary);
margin-top: 24px;
}
// Season container when inside details section
.details app-season-container {
display: block;
width: 100%;
margin-top: 24px;
box-sizing: border-box;
}
// ============================================================================
// YouTube Trailer Section - Inside details
// ============================================================================
.youtube-trailer {
margin-top: 24px;
width: 100%;
h3 {
font-size: 1.125rem;
font-weight: 600;
margin: 0 0 12px 0;
color: var(--text-primary);
}
.trailer-wrapper {
position: relative;
width: 100%;
max-width: 560px;
aspect-ratio: 16 / 9;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: none;
}
}
}
// ============================================================================
// Legacy container support (for gradual migration)
// ============================================================================
.container {
display: contents;
}
.image {
display: none; // Hidden - now using .poster in hero
}
// ============================================================================
// Mobile Responsive - Option A: Keep poster grid layout
// ============================================================================
@media (max-width: 800px) {
:host {
--poster-width: 120px;
}
.action-buttons {
width: 100%;
// This class is in ContentHeroComponent now, but these rules apply to children?
// No, .play-btn is a child of the PROJECTED content.
// So we should target the buttons directly for mobile
}
.play-btn,
.favorite-btn,
.download-btn {
flex: 1;
justify-content: center;
padding: 10px 16px;
}
.youtube-trailer {
margin-top: 16px;
.trailer-wrapper {
max-width: 100%;
border-radius: 4px;
}
}
.seasons {
padding: 16px 0 0;
}
}
// ============================================================================
// Animations
// ============================================================================
.youtube-trailer {
animation: fadeInUp 0.5s ease-out 0.4s both;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// ============================================================================
// Utility - keep .label for backwards compatibility
// ============================================================================
.label {
font-weight: 400;
font-size: 0.8125rem;
color: var(--text-muted);
text-decoration: none;
padding: 0;
opacity: 1;
}
@@ -23,7 +23,8 @@ import { MatMenuModule } from '@angular/material/menu';
import { MatTooltipModule } from '@angular/material/tooltip';
import { ActivatedRoute } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { PlaylistSwitcherComponent, ResizableDirective } from 'components';
import { PlaylistSwitcherComponent } from '@iptvnator/playlist/shared/ui';
import { ResizableDirective } from 'components';
import { CategoryViewComponent, PortalEmptyStateComponent } from '@iptvnator/portal/shared/ui';
import {
PORTAL_PLAYER,
@@ -17,7 +17,6 @@ import { MatMenuModule } from '@angular/material/menu';
import { MatTooltipModule } from '@angular/material/tooltip';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import groupBy from 'lodash/groupBy';
import { firstValueFrom } from 'rxjs';
import { DatabaseService, PlaylistsService } from 'services';
import { ContentCardComponent, FavoriteLayoutItem } from '@iptvnator/portal/shared/ui';
@@ -82,6 +81,22 @@ interface XtreamRecentLiveItem {
readonly xtream_id: number;
}
function groupItemsByPlaylistName(
items: XtreamRecentViewItem[]
): Record<string, XtreamRecentViewItem[]> {
return items.reduce<Record<string, XtreamRecentViewItem[]>>(
(groups, item) => {
const key = String(item.playlist_name);
if (!groups[key]) {
groups[key] = [];
}
groups[key].push(item);
return groups;
},
{}
);
}
function isXtreamRecentViewItem(
item: FavoriteLayoutItem
): item is XtreamRecentViewItem {
@@ -476,8 +491,7 @@ export class RecentlyViewedComponent {
getGroupedItems() {
const items = this.visibleRecentItems();
if (!this.isGlobal) return { default: items };
const grouped = groupBy(items, 'playlist_name');
return grouped;
return groupItemsByPlaylistName(items);
}
updateRecentSearchTerm(term: string) {
@@ -21,7 +21,6 @@ import {
import { MatIcon } from '@angular/material/icon';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import groupBy from 'lodash/groupBy';
import { DatabaseService } from 'services';
import { ContentCardComponent } from '@iptvnator/portal/shared/ui';
import { SearchLayoutComponent } from '@iptvnator/portal/shared/ui';
@@ -40,6 +39,22 @@ interface SearchResultsData {
initialQuery?: string;
}
function groupResultsByPlaylistName(
items: XtreamContentItem[]
): Record<string, XtreamContentItem[]> {
return items.reduce<Record<string, XtreamContentItem[]>>(
(groups, item) => {
const key = String(item.playlist_name);
if (!groups[key]) {
groups[key] = [];
}
groups[key].push(item);
return groups;
},
{}
);
}
@Component({
selector: 'app-search-results',
imports: [
@@ -121,7 +136,7 @@ export class SearchResultsComponent implements AfterViewInit {
readonly groupedResults = computed(() => {
const results = this.xtreamStore.searchResults();
if (!this.isGlobalSearch) return { default: results };
return groupBy(results, 'playlist_name');
return groupResultsByPlaylistName(results);
});
constructor(
@@ -6,10 +6,9 @@ import { ActivatedRoute } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import {
ContentHeroComponent,
PortalInlinePlayerComponent,
SeasonContainerComponent,
SeasonContainerPlaybackToggleRequest,
SeasonContainerXtreamDownloadContext,
SeasonContainerComponent,
} from 'components';
import {
PORTAL_EXTERNAL_PLAYBACK,
@@ -17,6 +16,7 @@ import {
PORTAL_PLAYER,
} from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback';
import {
PlaybackPositionData,
PlayerContentInfo,
@@ -12,7 +12,7 @@ import { MatIcon } from '@angular/material/icon';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute } from '@angular/router';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { ContentHeroComponent, PortalInlinePlayerComponent } from 'components';
import { ContentHeroComponent } from 'components';
import { SafePipe } from '@iptvnator/pipes';
import {
PORTAL_EXTERNAL_PLAYBACK,
@@ -22,6 +22,7 @@ import {
getPortalPlaybackProgressPercent,
} from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback';
import { DownloadsService, SettingsStore } from 'services';
import {
PlaybackPositionData,
@@ -9,7 +9,8 @@ import { MatMenuModule } from '@angular/material/menu';
import { MatTooltipModule } from '@angular/material/tooltip';
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { PlaylistSwitcherComponent, ResizableDirective } from 'components';
import { PlaylistSwitcherComponent } from '@iptvnator/playlist/shared/ui';
import { ResizableDirective } from 'components';
import { CategoryViewComponent } from '@iptvnator/portal/shared/ui';
import { isWorkspaceLayoutRoute } from '@iptvnator/portal/shared/util';
import {
+1 -1
View File
@@ -4,7 +4,7 @@
"sourceRoot": "libs/services/src",
"prefix": "lib",
"projectType": "library",
"tags": ["scope:shared", "type:data-access"],
"tags": ["scope:shared", "domain:shared-runtime", "type:data-access"],
"targets": {
"test": {
"executor": "@nx/jest:jest",
-3
View File
@@ -1,11 +1,8 @@
export * from './lib/data.service';
export * from './lib/database-electron.service';
export * from './lib/downloads.service';
export * from './lib/epg.service';
export * from './lib/epg-progress.service';
export * from './lib/playback-position.service';
export * from './lib/playlists.service';
export * from './lib/portal-status.service';
export * from './lib/settings-store.service';
export * from './lib/sort.service';
export * from './lib/stalker-session.service';
+12 -8
View File
@@ -1,12 +1,13 @@
import { Params } from '@angular/router';
type ElectronProcessWindow = Window & {
process?: {
type?: string;
};
};
export abstract class DataService {
get isElectron(): boolean {
return !!(
window &&
window['process'] &&
(window['process'] as any).type
);
const currentWindow = window as ElectronProcessWindow;
return Boolean(currentWindow.process?.type);
}
get remote() {
return null;
@@ -15,11 +16,14 @@ export abstract class DataService {
return null;
}
abstract getAppVersion(): string;
abstract sendIpcEvent(type: string, payload?: unknown): any;
abstract sendIpcEvent<T = unknown>(
type: string,
payload?: unknown
): T | Promise<T>;
abstract removeAllListeners(type: string): void;
abstract listenOn(
command: string,
callback: (...args: any[]) => void
callback: (...args: unknown[]) => void
): void;
abstract getAppEnvironment(): string;
}
@@ -4,7 +4,10 @@
*/
import { Injectable } from '@angular/core';
import { PlaylistMeta } from 'shared-interfaces';
import {
PlaylistMeta,
XtreamCategory,
} from 'shared-interfaces';
export interface XCategoryFromDb {
id: number;
@@ -37,6 +40,31 @@ export interface XtreamPlaylist {
type: string;
}
type XtreamDatabasePlaylistUpdate = {
name?: string;
username?: string;
password?: string;
serverUrl?: string;
lastUpdated?: string;
};
type XtreamContentStream =
| {
category_id: string | number;
rating?: string | number;
rating_imdb?: string;
last_modified?: string;
added?: string;
stream_icon?: string;
poster?: string;
cover?: string;
name?: string;
title?: string;
series_id?: string | number;
stream_id?: string | number;
}
| Record<string, unknown>;
export interface GlobalSearchResult extends XtreamContent {
playlist_id: string;
playlist_name: string;
@@ -103,7 +131,9 @@ export class DatabaseService {
/**
* Update playlist basic info
*/
async updateXtreamPlaylist(playlist: any): Promise<boolean> {
async updateXtreamPlaylist(
playlist: Pick<XtreamPlaylist, 'id' | 'name'>
): Promise<boolean> {
try {
await window.electron.dbUpdatePlaylist(playlist.id, {
name: playlist.name,
@@ -127,7 +157,7 @@ export class DatabaseService {
updateDate?: number;
}): Promise<boolean> {
try {
const updates: any = {};
const updates: XtreamDatabasePlaylistUpdate = {};
if (playlist.title) updates.name = playlist.title;
if (playlist.username) updates.username = playlist.username;
if (playlist.password) updates.password = playlist.password;
@@ -171,7 +201,7 @@ export class DatabaseService {
*/
async saveXtreamCategories(
playlistId: string,
categories: any[],
categories: XtreamCategory[],
type: 'live' | 'movies' | 'series',
hiddenCategoryXtreamIds?: number[]
): Promise<void> {
@@ -237,7 +267,7 @@ export class DatabaseService {
*/
async saveXtreamContent(
playlistId: string,
streams: any[],
streams: XtreamContentStream[],
type: 'live' | 'movie' | 'series',
onProgress?: (count: number) => void
): Promise<number> {
+36 -24
View File
@@ -24,12 +24,26 @@ import {
Playlist,
PlaylistMeta,
PlaylistUpdateState,
XtreamItem,
XtreamSerieItem,
StalkerPortalItem,
} from 'shared-interfaces';
const SQLITE_PLAYLIST_MIGRATION_FLAG = 'm3u-playlists-indexeddb-to-sqlite-v1';
type PortalFavoriteItem = StalkerPortalItem & {
category_id?: string;
raw?: string;
[key: string]: unknown;
};
type PortalRecentlyViewedItem = StalkerPortalItem & {
id?: string | number;
title?: string;
};
type PlaylistRawItem = {
raw?: string;
};
@Injectable({
providedIn: 'root',
})
@@ -255,7 +269,7 @@ export class PlaylistsService {
const playlist = await window.electron.dbGetAppPlaylist(id);
return playlist
? this.createSqliteFallbackPlaylist(playlist as Playlist)
: (undefined as any);
: (undefined as unknown as Playlist);
});
}
@@ -395,7 +409,7 @@ export class PlaylistsService {
return this.getPlaylistById(portalId).pipe(
map((item) => {
if (!item || !item.favorites) return [];
return item.favorites as Partial<XtreamItem>[];
return item.favorites as PortalFavoriteItem[];
}),
map((favorites) =>
favorites.sort(
@@ -411,7 +425,7 @@ export class PlaylistsService {
return this.getPlaylistById(portalId).pipe(
map((item) => {
if (!item || !item.favorites) return [];
return (item.favorites as Partial<XtreamItem>[]).filter(
return (item.favorites as PortalFavoriteItem[]).filter(
(itm) =>
itm && itm.stream_type && itm.stream_type === 'live'
);
@@ -419,7 +433,7 @@ export class PlaylistsService {
);
}
addPortalFavorite(portalId: string, item: any) {
addPortalFavorite(portalId: string, item: PortalFavoriteItem) {
if (!portalId) {
throw new Error('Portal ID is required');
}
@@ -449,16 +463,11 @@ export class PlaylistsService {
...portal,
favorites: portal.favorites?.filter((i) => {
const expectedId = String(favoriteId);
const streamId = String(
(i as Partial<XtreamItem>).stream_id ?? ''
);
const seriesId = String(
(i as Partial<XtreamSerieItem>).series_id ?? ''
);
const movieId = String(
(i as Partial<{ movie_id: string }>).movie_id ?? ''
);
const itemId = String((i as any).id ?? '');
const favorite = i as PortalFavoriteItem;
const streamId = String(favorite.stream_id ?? '');
const seriesId = String(favorite.series_id ?? '');
const movieId = String(favorite.movie_id ?? '');
const itemId = String(favorite.id ?? '');
return (
streamId !== expectedId &&
@@ -614,7 +623,7 @@ export class PlaylistsService {
`${playlist.playlist?.header?.raw ?? ''}` +
'\n' +
(playlist.playlist?.items ?? [])
.map((item: any) => item.raw)
.map((item: PlaylistRawItem) => item.raw)
.join('\n')
);
})
@@ -652,7 +661,7 @@ export class PlaylistsService {
return this.getPlaylistById(portalId).pipe(
map((item) => {
if (!item || !item.recentlyViewed) return [];
return item.recentlyViewed as Partial<XtreamItem>[];
return item.recentlyViewed as PortalRecentlyViewedItem[];
}),
map((items) =>
items.sort(
@@ -666,7 +675,7 @@ export class PlaylistsService {
addPortalRecentlyViewed(
portalId: string,
item: { id: string; title: string }
item: PortalRecentlyViewedItem & { id: string | number; title: string }
) {
if (!portalId) {
throw new Error('Portal ID is required');
@@ -674,9 +683,10 @@ export class PlaylistsService {
return this.getPlaylistById(portalId).pipe(
switchMap((portal) => {
const nowIso = new Date().toISOString();
const recentItems = portal.recentlyViewed ?? [];
const recentItems =
(portal.recentlyViewed as PortalRecentlyViewedItem[]) ?? [];
const existingIndex = recentItems.findIndex(
(i: any) => String(i?.id) === String(item.id)
(recentItem) => String(recentItem?.id) === String(item.id)
);
if (existingIndex >= 0) {
@@ -708,7 +718,7 @@ export class PlaylistsService {
...recentItems,
{
...item,
added_at: (item as any).added_at ?? nowIso,
added_at: item.added_at ?? nowIso,
},
],
};
@@ -736,8 +746,10 @@ export class PlaylistsService {
const nextPlaylist: Playlist = {
...portal,
recentlyViewed: portal.recentlyViewed?.filter((i: any) => {
const itemId = String(i?.id ?? '');
recentlyViewed: (
portal.recentlyViewed as PortalRecentlyViewedItem[]
)?.filter((item) => {
const itemId = String(item?.id ?? '');
const itemNormalized = normalizePortalItemId(itemId);
return (
itemId !== expectedId &&
+25 -15
View File
@@ -3,6 +3,15 @@ import { DataService } from './data.service';
export type PortalStatus = 'active' | 'inactive' | 'expired' | 'unavailable';
interface XtreamPortalStatusResponse {
payload?: {
user_info?: {
status?: string;
exp_date?: string;
};
};
}
@Injectable({
providedIn: 'root',
})
@@ -28,26 +37,27 @@ export class PortalStatusService {
normalizedUrl = serverUrl;
}
let response = await this.dataService.sendIpcEvent(
'XTREAM_REQUEST',
{
url: normalizedUrl,
params: {
password,
username,
action: 'get_account_info',
},
}
);
response = response?.payload;
const response =
await this.dataService.sendIpcEvent<XtreamPortalStatusResponse>(
'XTREAM_REQUEST',
{
url: normalizedUrl,
params: {
password,
username,
action: 'get_account_info',
},
}
);
const payload = response?.payload;
if (!response?.user_info?.status) {
if (!payload?.user_info?.status) {
return 'unavailable';
}
if (response.user_info.status === 'Active') {
if (payload.user_info.status === 'Active') {
const expDate = new Date(
parseInt(response.user_info.exp_date) * 1000
parseInt(payload.user_info.exp_date) * 1000
);
return expDate < new Date() ? 'expired' : 'active';
} else {
+1 -1
View File
@@ -3,7 +3,7 @@
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/shared/database/src",
"projectType": "library",
"tags": ["scope:shared", "type:data-access"],
"tags": ["scope:shared", "domain:shared-runtime", "type:data-access"],
"targets": {
"build": {
"executor": "@nx/esbuild:esbuild",
+1 -1
View File
@@ -3,7 +3,7 @@
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/shared/interfaces/src",
"projectType": "library",
"tags": ["scope:shared", "type:util"],
"tags": ["scope:shared", "domain:shared-contracts", "type:util"],
"targets": {
"build": {
"executor": "@nx/js:tsc",
+1 -1
View File
@@ -4,7 +4,7 @@
"sourceRoot": "libs/ui/components/src",
"prefix": "lib",
"projectType": "library",
"tags": ["scope:shared", "type:ui"],
"tags": ["scope:shared", "domain:shared-ui", "type:ui"],
"targets": {
"test": {
"executor": "@nx/jest:jest",
-24
View File
@@ -1,35 +1,11 @@
export * from './lib/add-playlist-menu/add-playlist-menu.component';
export * from './lib/art-player/art-player.component';
export * from './lib/audio-player/audio-player.component';
export * from './lib/channel-list-container/channel-list-container.component';
export * from './lib/channel-list-container/channel-list-item/channel-list-item.component';
export * from './lib/confirm-dialog/confirm-dialog.component';
export * from './lib/confirm-dialog/dialog.service';
export * from './lib/content-hero/content-hero.component';
export * from './lib/epg-list/epg-item-description/epg-item-description.component';
export * from './lib/epg-list/epg-list-item/epg-list-item.component';
export * from './lib/epg-list/epg-list.component';
export * from './lib/epg-progress-panel/epg-progress-panel.component';
export * from './lib/external-player-info-dialog/external-player-info-dialog.component';
export * from './lib/external-playback-dock/external-playback-dock.component';
export * from './lib/filter-sort-menu/filter-sort-menu.component';
export * from './lib/html-video-player/html-video-player.component';
export * from './lib/info-overlay/info-overlay.component';
export * from './lib/multi-epg/multi-epg-container.component';
export * from './lib/multi-epg/overlay-ref.token';
export * from './lib/playlist-switcher/playlist-switcher.component';
export * from './lib/portal-inline-player/portal-inline-player.component';
export * from './lib/progress-capsule/progress-capsule.component';
export * from './lib/recent-playlists/playlist-info/playlist-info.component';
export * from './lib/recent-playlists/playlist-item/playlist-item.component';
export * from './lib/recent-playlists/recent-playlists.component';
export * from './lib/season-container/season-container.component';
export * from './lib/stalker-favorites-button/stalker-favorites-button.component';
export * from './lib/stalker-series-view/stalker-series-view.component';
export * from './lib/video-player/sidebar/sidebar.component';
export * from './lib/video-player/toolbar/toolbar.component';
export * from './lib/vjs-player/vjs-player.component';
export * from './lib/vod-details/vod-details.component';
export * from './lib/watched-badge/watched-badge.component';
export * from './lib/web-player-view/web-player-view.component';
export * from './lib/resizable/resizable.directive';
@@ -16,7 +16,7 @@ import { NavigationEnd, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { StorageMap } from '@ngx-pwa/local-storage';
import { TranslatePipe } from '@ngx-translate/core';
import groupBy from 'lodash/groupBy';
import { EpgService } from '@iptvnator/epg/data-access';
import {
ChannelActions,
FavoritesActions,
@@ -25,12 +25,22 @@ import {
selectFavorites,
} from 'm3u-state';
import { BehaviorSubject, combineLatest, filter, map, skipWhile } from 'rxjs';
import { EpgService } from 'services';
import { Channel, EpgProgram, Settings, STORE_KEY } from 'shared-interfaces';
import { AllChannelsViewComponent } from './all-channels-view/all-channels-view.component';
import { FavoritesViewComponent } from './favorites-view/favorites-view.component';
import { GroupsViewComponent } from './groups-view/groups-view.component';
function groupChannelsByTitle(channels: Channel[]): Record<string, Channel[]> {
return channels.reduce<Record<string, Channel[]>>((groups, channel) => {
const key = channel.group?.title ?? '';
if (!groups[key]) {
groups[key] = [];
}
groups[key].push(channel);
return groups;
}, {});
}
@Component({
selector: 'app-channel-list-container',
templateUrl: './channel-list-container.component.html',
@@ -123,7 +133,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
/** Object with channels sorted by groups */
readonly groupedChannels = computed(() =>
groupBy(this.displayedChannels(), 'group.title')
groupChannelsByTitle(this.displayedChannels())
);
/** Selected channel */
@@ -14,8 +14,8 @@ import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { EpgItemDescriptionComponent } from '@iptvnator/ui/epg';
import { EpgProgram } from 'shared-interfaces';
import { EpgItemDescriptionComponent } from '../../epg-list/epg-item-description/epg-item-description.component';
@Component({
selector: 'app-channel-list-item',
@@ -28,13 +28,13 @@ export class InfoOverlayComponent implements OnChanges {
finishedDuration!: number;
/** Program start time */
start: any | undefined;
start: string | undefined;
/** Program end time */
stop: any | undefined;
stop: string | undefined;
/** Timeout for the overlay visibility */
private currentTimeout: any;
private currentTimeout: ReturnType<typeof setTimeout> | undefined;
/**
* Calculates the necessary information for the visualization in the overview popup
@@ -67,17 +67,19 @@ export class InfoOverlayComponent implements OnChanges {
* @param start program start time
* @param stop program stop time
*/
setProgramDuration(start: number, stop: number): void {
this.stop = moment(stop, 'YYYYMMDDHHmm ZZ');
this.start = moment(start, 'YYYYMMDDHHmm ZZ');
setProgramDuration(start: string | number, stop: string | number): void {
const stopMoment = moment(stop, 'YYYYMMDDHHmm ZZ');
const startMoment = moment(start, 'YYYYMMDDHHmm ZZ');
this.stop = stopMoment.toISOString();
this.start = startMoment.toISOString();
const timeNow = moment(Date.now());
this.generalDuration = moment
.duration(this.stop.diff(this.start))
.duration(stopMoment.diff(startMoment))
.asMilliseconds();
this.finishedDuration = moment
.duration(this.stop.diff(timeNow))
.duration(stopMoment.diff(timeNow))
.asMilliseconds();
}
@@ -2,6 +2,7 @@ import {
Directive,
ElementRef,
Renderer2,
inject,
input,
output,
OnInit,
@@ -78,11 +79,10 @@ export class ResizableDirective implements OnInit, AfterViewInit, OnDestroy {
private boundMouseUp: ((e: MouseEvent) => void) | null = null;
private boundTouchMove: ((e: TouchEvent) => void) | null = null;
private boundTouchEnd: ((e: TouchEvent) => void) | null = null;
private readonly el = inject(ElementRef<HTMLElement>);
private readonly renderer = inject(Renderer2);
constructor(
private readonly el: ElementRef<HTMLElement>,
private readonly renderer: Renderer2
) {
constructor() {
// Apply resizing class when dragging
effect(() => {
if (this.isResizing()) {
+13
View File
@@ -0,0 +1,13 @@
{
"name": "ui-epg",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/ui/epg/src",
"prefix": "lib",
"projectType": "library",
"tags": ["scope:shared", "domain:playback", "type:ui"],
"targets": {
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
Loaded 100 of 175 files, more files were not shown because too many files have changed in this diff. Show more