refactor(portals): create nx feature libraries for xtream and stalker

- Added StalkerCatalogFacadeService to manage Stalker catalog operations.
- Created stalker-feature-loaders to define route options for Stalker features.
- Removed deprecated stalker.routes.ts file.
- Updated shared utilities to include portal catalog facade interfaces.

feat(xtream): implement XtreamCatalogFacadeService and related feature loaders

- Added XtreamCatalogFacadeService to manage Xtream catalog operations.
- Created xtream-feature-loaders to define route options for Xtream features.
- Removed deprecated xtream.routes.ts file.
- Updated shared utilities to include portal catalog facade interfaces.

refactor: organize portal feature libraries

- Created separate feature libraries for Stalker and Xtream.
- Defined project structure and TypeScript configurations for both libraries.
- Implemented routing configurations for Stalker and Xtream features.
This commit is contained in:
4gray committed 2026-03-08 21:21:17 +01:00
1 parent 7f67962dd7
commit b5bd1bd92a
22 files changed
+973 -512

No files matched your search

+10 -4
View File
@@ -1,6 +1,8 @@
import { Route, Routes } from '@angular/router';
import { stalkerRoutes } from './stalker/stalker.routes';
import { xtreamRoutes } from './xtream-electron/xtream.routes';
import { createStalkerRoutes } from '@iptvnator/portal/stalker/feature';
import { createXtreamRoutes } from '@iptvnator/portal/xtream/feature';
import { stalkerFeatureRouteOptions } from './stalker/stalker-feature-loaders';
import { xtreamFeatureRouteOptions } from './xtream-electron/xtream-feature-loaders';
function withWorkspaceLayout(routes: Route[]): Route[] {
return routes.map((route) => ({
@@ -80,8 +82,12 @@ export const routes: Routes = [
(c) => c.DownloadsComponent
),
},
...withWorkspaceLayout(xtreamRoutes),
...withWorkspaceLayout(stalkerRoutes),
...withWorkspaceLayout(
createXtreamRoutes(xtreamFeatureRouteOptions)
),
...withWorkspaceLayout(
createStalkerRoutes(stalkerFeatureRouteOptions)
),
{
path: 'settings',
data: { layout: 'workspace' },
@@ -1,10 +1,10 @@
@if (selectedCategory()) {
@if (isStalker && selectedItem()) {
@if (isStalker && selectedStalkerItem()) {
@if (contentType() === 'vod') {
@if (selectedItem().series?.length > 0) {
@if (selectedStalkerItem().series?.length > 0) {
<!-- VOD item with embedded series (vclub or Ministra) -->
<app-stalker-series-view [vodWithSeries]="selectedItem()" />
} @else if (selectedItem().is_series) {
<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 {
@@ -14,7 +14,6 @@ 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 { Store } from '@ngrx/store';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { PlaylistsService } from 'services';
import {
@@ -24,31 +23,32 @@ import {
StalkerVodDetails,
VodDetailsItem,
} from 'shared-interfaces';
import { DownloadsService } from '../../services/downloads.service';
import { ExternalPlaybackService } from '../../services/external-playback.service';
import { PlayerService } from '../../services/player.service';
import { GridListComponent } from '../../shared/components/grid-list/grid-list.component';
import { PlaylistErrorViewComponent } from '@iptvnator/portal/shared/ui';
import { createLogger } from '@iptvnator/portal/shared/util';
import { StalkerVodSource } from '@iptvnator/portal/stalker/data-access';
import { StalkerSeriesViewComponent } from '../../stalker/stalker-series-view/stalker-series-view.component';
import {
buildStalkerSelectedVodItem,
createLogger,
isStalkerPortalCatalogFacade,
PortalCatalogFacade,
PortalCatalogSortMode,
PORTAL_CATALOG_FACADE,
StalkerPortalCatalogFacade,
} from '@iptvnator/portal/shared/util';
import {
createPortalFavoritesResource,
createRefreshTrigger,
isSelectedStalkerVodFavorite,
normalizeStalkerEntityId,
normalizeStalkerEntityIdAsNumber,
StalkerSelectedVodItem,
toggleStalkerVodFavorite,
} from '@iptvnator/portal/stalker/data-access';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { XTREAM_DATA_SOURCE } from '@iptvnator/portal/xtream/data-access';
import { XtreamCategorySortMode } from '@iptvnator/portal/xtream/data-access';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { DownloadsService } from '../../services/downloads.service';
import { ExternalPlaybackService } from '../../services/external-playback.service';
import { PlayerService } from '../../services/player.service';
import { GridListComponent } from '../../shared/components/grid-list/grid-list.component';
import { StalkerSeriesViewComponent } from '../../stalker/stalker-series-view/stalker-series-view.component';
import { VodDetailsComponent } from '../vod-details/vod-details.component';
const SORT_STORAGE_KEY = 'xtream-category-sort-mode';
interface CategoryContentItem {
id?: number | string;
is_series?: number | string | boolean;
@@ -59,17 +59,6 @@ interface CategoryContentItem {
[key: string]: unknown;
}
interface CategoryStoreLike {
totalCount?: () => number;
selectItemsFromSelectedCategory?: () => unknown[];
getProgressPercent?: (id: number, type: 'vod') => number;
hasSeriesProgress?: (id: number) => boolean;
isWatched?: (id: number, type: 'vod') => boolean;
currentPlaylist?: () => { id?: string } | null;
loadAllPositions?: (playlistId: string) => void;
selectedCategoryId?: () => number | null;
}
interface DownloadVodData {
id?: string | number;
has_files?: unknown;
@@ -105,62 +94,47 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
readonly externalPlayback = inject(ExternalPlaybackService);
private readonly snackBar = inject(MatSnackBar);
private readonly translateService = inject(TranslateService);
readonly isStalker = this.activatedRoute.snapshot.data['api'] === 'stalker';
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 store = this.isStalker
? inject(StalkerStore)
: inject(XtreamStore);
private readonly favoritesRefresh = createRefreshTrigger();
readonly isStalker = this.catalog.provider === 'stalker';
/** Unsubscribe function for playback position updates */
private unsubscribePositionUpdates: (() => void) | null = null;
readonly contentType = this.store.selectedContentType;
private readonly playlistService = inject(PlaylistsService);
private readonly downloadsService = inject(DownloadsService);
private readonly favoritesRefresh = createRefreshTrigger();
readonly contentType = this.catalog.contentType;
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
private lastInlineSaveTime = 0;
readonly limit = this.store.limit;
readonly pageIndex = this.store.page;
readonly pageSizeOptions = this.isStalker ? [14] : [10, 25, 50, 100];
readonly selectedCategory = this.store.getSelectedCategory;
readonly paginatedContent = this.store.getPaginatedContent;
readonly selectedCategoryTitle = computed(() => {
const category = this.selectedCategory();
if (!category) return '';
// category_name is the StalkerCategoryItem field; name is the Xtream field
const fromCategory =
category.category_name ?? category.name ?? category.title ?? '';
if (fromCategory) return fromCategory;
// Stalker: fall back to getSelectedCategoryName computed (looks up directly from categories array)
if (this.isStalker) {
const stalkerStore = this.store as unknown as {
getSelectedCategoryName?: () => string;
};
const fromStoreComputed =
stalkerStore.getSelectedCategoryName?.() ?? '';
if (fromStoreComputed) return fromStoreComputed;
}
return '';
});
readonly categoryItemCount = computed(() => {
const storeLike = this.store as unknown as CategoryStoreLike;
if (this.isStalker && typeof storeLike.totalCount === 'function') {
return storeLike.totalCount();
}
if (typeof storeLike.selectItemsFromSelectedCategory === 'function') {
return storeLike.selectItemsFromSelectedCategory().length;
}
return 0;
});
readonly limit = this.catalog.limit;
readonly pageIndex = this.catalog.pageIndex;
readonly pageSizeOptions = Array.from(this.catalog.pageSizeOptions);
readonly selectedCategory = this.catalog.selectedCategory;
readonly paginatedContent = this.catalog.paginatedContent;
readonly selectedCategoryTitle = this.catalog.selectedCategoryTitle;
readonly categoryItemCount = this.catalog.categoryItemCount;
readonly categoryItemSubtitle = computed(() => {
const itemCount = this.categoryItemCount();
return `${itemCount} ${itemCount === 1 ? 'item' : 'items'}`;
@@ -178,14 +152,9 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
private loadedPositionsForPlaylistId: string | null = null;
constructor() {
// Effect to load Stalker positions when playlist becomes available
if (this.isStalker) {
effect(() => {
const stalkerStore = this.store as InstanceType<
typeof StalkerStore
>;
const playlist = stalkerStore.currentPlaylist();
const playlistId = playlist?._id;
const playlistId = this.catalog.playlist()?.id;
if (
playlistId &&
@@ -196,20 +165,15 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
}
});
// Effect to load position for selected VOD item (ensures fresh data after playback)
effect(() => {
const stalkerStore = this.store as InstanceType<
typeof StalkerStore
>;
const item = this.selectedItem();
const playlist = stalkerStore.currentPlaylist();
const playlistId = this.catalog.playlist()?.id;
if (item && playlist?._id && this.contentType() === 'vod') {
this.loadSingleVodPosition(playlist._id, Number(item.id));
if (item && playlistId && this.contentType() === 'vod') {
this.loadSingleVodPosition(playlistId, Number(item.id));
}
});
// Listen for real-time playback position updates from video player
if (window.electron?.onPlaybackPositionUpdate) {
this.unsubscribePositionUpdates =
window.electron.onPlaybackPositionUpdate(
@@ -237,25 +201,19 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
}
readonly contentWithProgress = computed(() => {
const items = this.store.getPaginatedContent();
const items = this.paginatedContent();
if (!items) return [];
if (this.isStalker) {
// Enrich Stalker items with progress data
const positions = this.stalkerPositions();
const seriesPositions = this.stalkerSeriesPositions();
const contentType = this.contentType();
return items.map((item: CategoryContentItem) => {
const id = Number(item.id);
// Check if this item has series/episode progress (watched episodes)
// This works even when is_series flag isn't set on the item
const hasSeriesProgress = Boolean(
seriesPositions.get(id)?.length
);
// For series content type, or items with is_series flag
const isSeries =
contentType === 'series' ||
item.is_series === '1' ||
@@ -263,73 +221,59 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
if (hasSeriesProgress) {
return { ...item, hasSeriesProgress: true };
} else if (isSeries) {
return { ...item, hasSeriesProgress: false };
} else {
// Regular VOD item
const vodKey = `vod_${id}`;
const vodPosition = positions.get(vodKey);
const vodProgress = this.calculateProgress(vodPosition);
return {
...item,
progress: vodProgress,
isWatched: vodProgress >= 90,
};
}
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,
};
});
}
const xtreamStore = this.store as unknown as CategoryStoreLike;
if (!xtreamStore.getProgressPercent) return items;
return items.map((item: CategoryContentItem) => {
const isSeries = this.contentType() === 'series';
const id = Number(
item.xtream_id || item.series_id || item.stream_id
);
if (isSeries) {
return {
...item,
hasSeriesProgress: xtreamStore.hasSeriesProgress(id),
};
} else {
return {
...item,
progress: xtreamStore.getProgressPercent(id, 'vod'),
isWatched: xtreamStore.isWatched(id, 'vod'),
};
}
});
return items.map((item: CategoryContentItem) => ({
...item,
...this.catalog.getItemProgress(item),
}));
});
readonly isPaginatedContentLoading = this.store.isPaginatedContentLoading;
readonly selectedItem = this.store.selectedItem;
readonly totalPages = this.store.getTotalPages;
readonly bigStore = inject(Store);
readonly contentSortMode = !this.isStalker
? (this.store as unknown as InstanceType<typeof XtreamStore>)
.contentSortMode
: signal<XtreamCategorySortMode>('date-desc');
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.selectedItem();
const item = this.selectedStalkerItem();
if (!item || !this.isStalker) return null;
// Access currentPlaylist from the store (type-safe since we're in stalker mode)
const stalkerStore = this.store as unknown as {
currentPlaylist: () => { _id: string } | null;
};
return createStalkerVodItem(
item as StalkerVodDetails,
stalkerStore.currentPlaylist()?._id ?? ''
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.selectedItem();
const item = this.selectedStalkerItem();
if (!item) return null;
const id = Number(item.id);
@@ -341,13 +285,7 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
readonly portalFavorites = createPortalFavoritesResource(
this.playlistService,
() => {
if (!this.isStalker) return undefined;
const stalkerStore = this.store as InstanceType<
typeof StalkerStore
>;
return stalkerStore.currentPlaylist()?._id;
},
() => (this.isStalker ? this.catalog.playlist()?.id : undefined),
() => this.favoritesRefresh.refreshVersion()
);
@@ -360,60 +298,23 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
seasons = [];
setContentSortMode(mode: XtreamCategorySortMode): void {
(
this.store as unknown as InstanceType<typeof XtreamStore>
).setContentSortMode(mode);
localStorage.setItem(SORT_STORAGE_KEY, mode);
setContentSortMode(mode: PortalCatalogSortMode): void {
this.catalog.setContentSortMode(mode);
}
ngOnInit() {
const { categoryId } = this.activatedRoute.snapshot.params;
// Ensure playback positions are loaded (Xtream only - Stalker is handled via effect in constructor)
if (!this.isStalker) {
const savedSortMode = localStorage.getItem(SORT_STORAGE_KEY);
if (
savedSortMode === 'date-desc' ||
savedSortMode === 'date-asc' ||
savedSortMode === 'name-asc' ||
savedSortMode === 'name-desc'
) {
(
this.store as unknown as InstanceType<typeof XtreamStore>
).setContentSortMode(savedSortMode);
}
const xtreamStore = this.store as unknown as CategoryStoreLike;
const playlistId = xtreamStore.currentPlaylist?.()?.id;
if (playlistId) {
xtreamStore.loadAllPositions?.(playlistId);
}
}
// Clear any previous selectedItem when entering category view
// This ensures the content-header is visible
this.store.setSelectedItem(null);
// Set category - setSelectedCategory only resets page if category actually changes
// This preserves the page state when navigating back from detail view
if (categoryId) {
this.store.setSelectedCategory(categoryId);
} else {
// Stalker uses "*" as the "All" category id
if (this.isStalker) {
(
this.store as InstanceType<typeof StalkerStore>
).setSelectedCategory('*');
} else {
this.store.setSelectedCategory(null);
}
}
this.catalog.initialize(categoryId ?? null);
if (this.isStalker) {
const storeLike = this.store as unknown as CategoryStoreLike;
const selectedCategory =
(this.selectedCategory() as CategoryContentItem | null) ?? null;
this.logger.warn('Stalker category init from route', {
routeCategoryId: categoryId ?? null,
selectedCategoryId: storeLike.selectedCategoryId?.(),
selectedCategoryId:
selectedCategory?.category_id ??
selectedCategory?.id ??
null,
selectedContentType: this.contentType(),
});
}
@@ -433,7 +334,6 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
const key = `${pos.contentType}_${pos.contentXtreamId}`;
positionsMap.set(key, pos);
// Group episodes by series ID
if (pos.contentType === 'episode' && pos.seriesXtreamId) {
const existing = seriesMap.get(pos.seriesXtreamId) || [];
existing.push(pos);
@@ -477,7 +377,6 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
const percent =
(position.positionSeconds / position.durationSeconds) * 100;
// If watched > 10s but percent < 1, return 1 to show visual progress
if (position.positionSeconds > 10 && percent < 1) {
return 1;
}
@@ -486,36 +385,16 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
}
onPageChange(event: PageEvent) {
this.store.setPage(event.pageIndex);
this.store.setLimit(event.pageSize);
localStorage.setItem('xtream-page-size', event.pageSize.toString());
this.catalog.setPage(event.pageIndex);
this.catalog.setLimit(event.pageSize);
}
onItemClick(item: CategoryContentItem) {
const selectedItem = buildStalkerSelectedVodItem(
item as StalkerVodSource,
this.contentType() === 'vod' &&
(item.is_series === '1' || item.is_series === 1)
);
if (this.isStalker) {
this.store.setSelectedItem(selectedItem);
} else {
const xtreamStore = this.store as unknown as CategoryStoreLike;
// When viewing "Recently Added" (no category selected), include category_id in path
const categoryId = xtreamStore.selectedCategoryId?.();
if (categoryId && item.xtream_id !== undefined) {
this.router.navigate([item.xtream_id], {
relativeTo: this.activatedRoute,
});
} else if (
item.category_id !== undefined &&
item.xtream_id !== undefined
) {
this.router.navigate([item.category_id, item.xtream_id], {
relativeTo: this.activatedRoute,
});
}
const navigation = this.catalog.selectItem(item);
if (navigation?.length) {
this.router.navigate(navigation, {
relativeTo: this.activatedRoute,
});
}
}
@@ -524,31 +403,30 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
title?: string,
thumbnail?: string
) {
await this.store.createLinkToPlayVod(cmd, title, thumbnail);
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.isStalker) {
(this.store as InstanceType<typeof StalkerStore>).addToFavorites(
item,
onDone
);
if (this.stalkerCatalog) {
this.stalkerCatalog.addToFavorites(item, onDone);
return;
}
this.store.addToFavorites(item);
onDone?.();
}
removeFromFavorites(favoriteId: string, onDone?: () => void) {
this.logger.debug('Remove from favorites', favoriteId);
if (this.isStalker) {
(
this.store as InstanceType<typeof StalkerStore>
).removeFromFavorites(favoriteId, onDone);
if (this.stalkerCatalog) {
this.stalkerCatalog.removeFromFavorites(favoriteId, onDone);
return;
}
this.store.removeFromFavorites(favoriteId);
onDone?.();
}
@@ -596,7 +474,7 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
/** Handle back from vod-details component */
onVodBack(): void {
this.closeInlinePlayer();
this.store.setSelectedItem(null);
this.catalog.clearSelectedItem();
}
handleInlineTimeUpdate(event: {
@@ -644,17 +522,15 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
/** Handle download from vod-details component */
async onVodDownload(item: VodDetailsItem): Promise<void> {
if (item.type !== 'stalker') return;
if (item.type !== 'stalker' || !this.stalkerCatalog) return;
const stalkerStore = this.store as InstanceType<typeof StalkerStore>;
const playlist = stalkerStore.currentPlaylist();
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);
// Align with playback path: resolve has_files references to /media/file_{id}.mpg
if (
itemData?.has_files !== undefined &&
cmdToUse &&
@@ -663,13 +539,13 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
!cmdToUse.includes('/media/file_')
) {
const fileId =
await stalkerStore.fetchMovieFileId(normalizedItemId);
await this.stalkerCatalog.fetchMovieFileId(normalizedItemId);
if (fileId) {
cmdToUse = `/media/file_${fileId}.mpg`;
}
}
const url = await stalkerStore.fetchLinkToPlay(
const url = await this.stalkerCatalog.fetchLinkToPlay(
playlist.portalUrl,
playlist.macAddress,
cmdToUse
@@ -679,7 +555,7 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
const numericId = normalizeStalkerEntityIdAsNumber(itemData?.id) ?? 0;
await this.downloadsService.startDownload({
playlistId: playlist._id,
playlistId: playlist.id,
xtreamId: numericId,
contentType: 'vod',
title: itemData?.info?.name || itemData?.title || 'Unknown',
@@ -687,10 +563,9 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
posterUrl: itemData?.info?.movie_image,
headers: {
userAgent: playlist.userAgent,
referer: playlist.referrer,
referer: playlist.referer,
origin: playlist.origin,
},
// Playlist info for auto-creation (Stalker playlists)
playlistName: playlist.title || 'Stalker Portal',
playlistType: 'stalker',
portalUrl: playlist.portalUrl,
@@ -709,16 +584,15 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
thumbnail?: string,
startTime?: number
): Promise<void> {
if (!this.stalkerCatalog) {
return;
}
try {
const stalkerStore = this.store as InstanceType<
typeof StalkerStore
>;
const playback = await stalkerStore.resolveVodPlayback(
const playback = await this.stalkerCatalog.resolveVodPlayback(
cmd,
title,
thumbnail,
undefined,
undefined,
startTime
);
@@ -0,0 +1,168 @@
import { Provider, Injectable, computed, inject } from '@angular/core';
import {
buildStalkerSelectedVodItem,
StalkerStore,
StalkerVodSource,
} from '@iptvnator/portal/stalker/data-access';
import {
PortalCatalogItemProgress,
PortalCatalogPlaylistMeta,
PortalCatalogSortMode,
PORTAL_CATALOG_FACADE,
StalkerPortalCatalogFacade,
} from '@iptvnator/portal/shared/util';
@Injectable()
export class StalkerCatalogFacadeService
implements
StalkerPortalCatalogFacade<
Record<string, unknown>,
StalkerVodSource,
StalkerVodSource
>
{
private readonly stalkerStore = inject(StalkerStore);
readonly provider = 'stalker' as const;
readonly pageSizeOptions = [14] as const;
readonly contentType = this.stalkerStore.selectedContentType;
readonly limit = this.stalkerStore.limit;
readonly pageIndex = this.stalkerStore.page;
readonly selectedCategory = this.stalkerStore.getSelectedCategory;
readonly paginatedContent = computed(
() => this.stalkerStore.getPaginatedContent() ?? []
);
readonly selectedItem = this.stalkerStore.selectedItem;
readonly totalPages = this.stalkerStore.getTotalPages;
readonly isPaginatedContentLoading =
this.stalkerStore.isPaginatedContentLoading;
readonly selectedCategoryTitle = computed(() => {
const category = this.selectedCategory();
const fromCategory = String(
category?.['category_name'] ?? category?.['name'] ?? ''
);
if (fromCategory) {
return fromCategory;
}
return this.stalkerStore.getSelectedCategoryName() ?? '';
});
readonly categoryItemCount = computed(() => this.stalkerStore.totalCount());
readonly contentSortMode = computed<PortalCatalogSortMode | null>(
() => null
);
readonly playlist = computed<PortalCatalogPlaylistMeta | null>(() => {
const playlist = this.stalkerStore.currentPlaylist();
if (!playlist?._id) {
return null;
}
return {
id: playlist._id,
title: playlist.title ?? 'Stalker Portal',
portalUrl: playlist.portalUrl,
macAddress: playlist.macAddress,
userAgent: playlist.userAgent,
referer: playlist.referrer,
origin: playlist.origin,
};
});
initialize(categoryId?: string | null): void {
this.clearSelectedItem();
if (categoryId) {
this.stalkerStore.setSelectedCategory(categoryId);
return;
}
this.stalkerStore.setSelectedCategory('*');
}
clearSelectedItem(): void {
this.stalkerStore.clearSelectedItem();
}
setPage(page: number): void {
this.stalkerStore.setPage(page);
}
setLimit(limit: number): void {
this.stalkerStore.setLimit(limit);
}
setContentSortMode(_mode: PortalCatalogSortMode): void {
// Stalker catalog content is server-paginated and does not support local sort modes.
}
selectItem(item: StalkerVodSource): string[] | null {
const needsSeriesFetch =
this.contentType() === 'vod' &&
(item.is_series === '1' || item.is_series === 1);
this.stalkerStore.setSelectedItem(
buildStalkerSelectedVodItem(item, needsSeriesFetch)
);
return null;
}
getItemProgress(
_item: StalkerVodSource
): PortalCatalogItemProgress {
return {};
}
async createLinkToPlayVod(
cmd?: string,
title?: string,
thumbnail?: string
): Promise<void> {
await this.stalkerStore.createLinkToPlayVod(cmd, title, thumbnail);
}
addToFavorites(item: Record<string, unknown>, onDone?: () => void): void {
this.stalkerStore.addToFavorites(item, onDone);
}
removeFromFavorites(favoriteId: string, onDone?: () => void): void {
this.stalkerStore.removeFromFavorites(favoriteId, onDone);
}
fetchMovieFileId(itemId: string): Promise<string | null> {
return this.stalkerStore.fetchMovieFileId(itemId);
}
async fetchLinkToPlay(
portalUrl: string,
macAddress: string,
cmd: string
): Promise<string> {
return this.stalkerStore.fetchLinkToPlay(portalUrl, macAddress, cmd);
}
resolveVodPlayback(
cmd?: string,
title?: string,
thumbnail?: string,
startTime?: number
) {
return this.stalkerStore.resolveVodPlayback(
cmd,
title,
thumbnail,
undefined,
undefined,
startTime
);
}
}
export function provideStalkerCatalogFacade(): Provider[] {
return [
StalkerCatalogFacadeService,
{
provide: PORTAL_CATALOG_FACADE,
useExisting: StalkerCatalogFacadeService,
},
];
}
@@ -0,0 +1,35 @@
import { StalkerFeatureRouteOptions } from '@iptvnator/portal/stalker/feature';
import { provideStalkerCatalogFacade } from './stalker-catalog-facade.service';
// Temporary app-side loader map while Stalker route screens remain in apps/web.
export const stalkerFeatureRouteOptions: StalkerFeatureRouteOptions = {
catalogProviders: provideStalkerCatalogFacade(),
loadShellComponent: () =>
import('./stalker-shell/stalker-shell.component').then(
(c) => c.StalkerShellComponent
),
loadMainContainerComponent: () =>
import('./stalker-main-container.component').then(
(c) => c.StalkerMainContainerComponent
),
loadCategoryContentViewComponent: () =>
import(
'../portal-shared/category-content-view/category-content-view.component'
).then((c) => c.CategoryContentViewComponent),
loadLiveStreamLayoutComponent: () =>
import(
'./stalker-live-stream-layout/stalker-live-stream-layout.component'
).then((c) => c.StalkerLiveStreamLayoutComponent),
loadFavoritesComponent: () =>
import('./stalker-favorites/stalker-favorites.component').then(
(c) => c.StalkerFavoritesComponent
),
loadRecentlyViewedComponent: () =>
import('./recently-viewed/recently-viewed.component').then(
(c) => c.RecentlyViewedComponent
),
loadSearchComponent: () =>
import('./stalker-search/stalker-search.component').then(
(c) => c.StalkerSearchComponent
),
};
-121
View File
@@ -1,121 +0,0 @@
import { Route } from '@angular/router';
export const stalkerRoutes: Route[] = [
{
path: 'stalker/:id',
loadComponent: () =>
import('./stalker-shell/stalker-shell.component').then(
(c) => c.StalkerShellComponent
),
children: [
{
path: '',
redirectTo: 'vod',
pathMatch: 'full',
},
{
path: 'vod',
loadComponent: () =>
import('./stalker-main-container.component').then(
(c) => c.StalkerMainContainerComponent
),
children: [
{
path: '',
data: {
api: 'stalker',
contentType: 'vod',
},
loadComponent: () =>
import(
'../portal-shared/category-content-view/category-content-view.component'
).then((c) => c.CategoryContentViewComponent),
},
{
path: ':categoryId',
data: {
api: 'stalker',
contentType: 'vod',
},
loadComponent: () =>
import(
'../portal-shared/category-content-view/category-content-view.component'
).then((c) => c.CategoryContentViewComponent),
},
],
},
{
path: '',
redirectTo: 'vod',
pathMatch: 'full',
},
{
path: 'itv',
loadComponent: () =>
import(
'./stalker-live-stream-layout/stalker-live-stream-layout.component'
).then((c) => c.StalkerLiveStreamLayoutComponent),
},
{
path: 'series',
loadComponent: () =>
import('./stalker-main-container.component').then(
(c) => c.StalkerMainContainerComponent
),
children: [
{
path: '',
loadComponent: () =>
import(
'../portal-shared/category-content-view/category-content-view.component'
).then((c) => c.CategoryContentViewComponent),
data: {
api: 'stalker',
contentType: 'series',
},
},
{
path: ':categoryId',
loadComponent: () =>
import(
'../portal-shared/category-content-view/category-content-view.component'
).then((c) => c.CategoryContentViewComponent),
data: {
api: 'stalker',
contentType: 'series',
},
},
],
},
{
path: 'favorites',
loadComponent: () =>
import(
'./stalker-favorites/stalker-favorites.component'
).then((c) => c.StalkerFavoritesComponent),
},
{
path: 'recent',
loadComponent: () =>
import('./recently-viewed/recently-viewed.component').then(
(c) => c.RecentlyViewedComponent
),
},
{
path: 'search',
loadComponent: () =>
import('./stalker-search/stalker-search.component').then(
(c) => c.StalkerSearchComponent
),
},
{
path: 'downloads',
loadComponent: () =>
import(
'@iptvnator/portal/downloads/feature'
).then((c) => c.DownloadsComponent),
},
],
},
];
@@ -0,0 +1,144 @@
import { Provider, Injectable, computed, inject } from '@angular/core';
import {
PortalCatalogFacade,
PortalCatalogItemProgress,
PortalCatalogPlaylistMeta,
PortalCatalogSortMode,
PORTAL_CATALOG_FACADE,
} from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
const SORT_STORAGE_KEY = 'xtream-category-sort-mode';
@Injectable()
export class XtreamCatalogFacadeService
implements PortalCatalogFacade<Record<string, unknown>, Record<string, unknown>, unknown>
{
private readonly xtreamStore = inject(XtreamStore);
readonly provider = 'xtream' as const;
readonly pageSizeOptions = [10, 25, 50, 100] as const;
readonly contentType = this.xtreamStore.selectedContentType;
readonly limit = this.xtreamStore.limit;
readonly pageIndex = this.xtreamStore.page;
readonly selectedCategory = this.xtreamStore.getSelectedCategory;
readonly paginatedContent = this.xtreamStore.getPaginatedContent;
readonly selectedItem = this.xtreamStore.selectedItem;
readonly totalPages = this.xtreamStore.getTotalPages;
readonly isPaginatedContentLoading =
this.xtreamStore.isPaginatedContentLoading;
readonly selectedCategoryTitle = computed(() => {
const category = this.selectedCategory();
return String(category?.['name'] ?? category?.['title'] ?? '');
});
readonly categoryItemCount = computed(
() => this.xtreamStore.selectItemsFromSelectedCategory().length
);
readonly contentSortMode = this.xtreamStore.contentSortMode;
readonly playlist = computed<PortalCatalogPlaylistMeta | null>(() => {
const playlist = this.xtreamStore.currentPlaylist();
if (!playlist) {
return null;
}
return {
id: String(playlist.id),
title: playlist.name ?? playlist.title ?? 'Xtream',
};
});
initialize(categoryId?: string | null): void {
const savedSortMode = localStorage.getItem(SORT_STORAGE_KEY);
if (
savedSortMode === 'date-desc' ||
savedSortMode === 'date-asc' ||
savedSortMode === 'name-asc' ||
savedSortMode === 'name-desc'
) {
this.xtreamStore.setContentSortMode(savedSortMode);
}
const playlistId = this.xtreamStore.currentPlaylist()?.id;
if (playlistId) {
this.xtreamStore.loadAllPositions(playlistId);
}
this.clearSelectedItem();
if (categoryId) {
this.xtreamStore.setSelectedCategory(Number(categoryId));
return;
}
this.xtreamStore.setSelectedCategory(null);
}
clearSelectedItem(): void {
this.xtreamStore.setSelectedItem(null);
}
setPage(page: number): void {
this.xtreamStore.setPage(page);
}
setLimit(limit: number): void {
this.xtreamStore.setLimit(limit);
}
setContentSortMode(mode: PortalCatalogSortMode): void {
this.xtreamStore.setContentSortMode(mode);
localStorage.setItem(SORT_STORAGE_KEY, mode);
}
selectItem(item: Record<string, unknown>): string[] | null {
const xtreamId = item['xtream_id'];
if (xtreamId === undefined || xtreamId === null) {
return null;
}
const selectedCategoryId = this.xtreamStore.selectedCategoryId();
if (selectedCategoryId !== null && selectedCategoryId !== undefined) {
return [String(xtreamId)];
}
const categoryId = item['category_id'];
if (categoryId === undefined || categoryId === null) {
return null;
}
return [String(categoryId), String(xtreamId)];
}
getItemProgress(
item: Record<string, unknown>
): PortalCatalogItemProgress {
const isSeries = this.contentType() === 'series';
const itemId = Number(
item['xtream_id'] ?? item['series_id'] ?? item['stream_id']
);
if (Number.isNaN(itemId)) {
return {};
}
if (isSeries) {
return {
hasSeriesProgress: this.xtreamStore.hasSeriesProgress(itemId),
};
}
return {
progress: this.xtreamStore.getProgressPercent(itemId, 'vod'),
isWatched: this.xtreamStore.isWatched(itemId, 'vod'),
};
}
}
export function provideXtreamCatalogFacade(): Provider[] {
return [
XtreamCatalogFacadeService,
{
provide: PORTAL_CATALOG_FACADE,
useExisting: XtreamCatalogFacadeService,
},
];
}
@@ -0,0 +1,48 @@
import { XtreamFeatureRouteOptions } from '@iptvnator/portal/xtream/feature';
import { provideXtreamCatalogFacade } from './xtream-catalog-facade.service';
// Temporary app-side loader map while route screens are still being migrated
// out of apps/web into the Xtream feature library.
export const xtreamFeatureRouteOptions: XtreamFeatureRouteOptions = {
catalogProviders: provideXtreamCatalogFacade(),
loadShellComponent: () =>
import('./xtream-shell/xtream-shell.component').then(
(c) => c.XtreamShellComponent
),
loadLiveStreamLayoutComponent: () =>
import('./live-stream-layout/live-stream-layout.component').then(
(c) => c.LiveStreamLayoutComponent
),
loadMainContainerComponent: () =>
import('./xtream-main-container.component').then(
(c) => c.XtreamMainContainerComponent
),
loadCategoryContentViewComponent: () =>
import(
'../portal-shared/category-content-view/category-content-view.component'
).then((c) => c.CategoryContentViewComponent),
loadVodDetailsRouteComponent: () =>
import('./vod-details/vod-details-route.component').then(
(c) => c.VodDetailsRouteComponent
),
loadSerialDetailsComponent: () =>
import('./serial-details/serial-details.component').then(
(c) => c.SerialDetailsComponent
),
loadFavoritesComponent: () =>
import('./favorites/favorites.component').then(
(c) => c.FavoritesComponent
),
loadRecentlyViewedComponent: () =>
import('./recently-viewed/recently-viewed.component').then(
(c) => c.RecentlyViewedComponent
),
loadSearchResultsComponent: () =>
import('./search-results/search-results.component').then(
(c) => c.SearchResultsComponent
),
loadRecentlyAddedComponent: () =>
import('./recently-added/recently-added.component').then(
(c) => c.RecentlyAddedComponent
),
};
@@ -1,129 +0,0 @@
import { Route } from '@angular/router';
export const xtreamRoutes: Route[] = [
{
path: 'xtreams/:id',
loadComponent: () =>
import('./xtream-shell/xtream-shell.component').then(
(c) => c.XtreamShellComponent
),
children: [
{
path: '',
redirectTo: 'vod',
pathMatch: 'full',
},
{
path: 'live',
loadComponent: () =>
import(
'./live-stream-layout/live-stream-layout.component'
).then((c) => c.LiveStreamLayoutComponent),
children: [
{
path: ':categoryId',
loadComponent: () =>
import(
'./live-stream-layout/live-stream-layout.component'
).then((c) => c.LiveStreamLayoutComponent),
},
],
},
{
path: 'vod',
loadComponent: () =>
import('./xtream-main-container.component').then(
(c) => c.XtreamMainContainerComponent
),
children: [
{
path: '',
loadComponent: () =>
import(
'../portal-shared/category-content-view/category-content-view.component'
).then((c) => c.CategoryContentViewComponent),
},
{
path: ':categoryId',
loadComponent: () =>
import(
'../portal-shared/category-content-view/category-content-view.component'
).then((c) => c.CategoryContentViewComponent),
},
{
path: ':categoryId/:vodId',
loadComponent: () =>
import(
'./vod-details/vod-details-route.component'
).then((c) => c.VodDetailsRouteComponent),
},
],
},
{
path: 'series',
loadComponent: () =>
import('./xtream-main-container.component').then(
(c) => c.XtreamMainContainerComponent
),
children: [
{
path: '',
loadComponent: () =>
import(
'../portal-shared/category-content-view/category-content-view.component'
).then((c) => c.CategoryContentViewComponent),
},
{
path: ':categoryId',
loadComponent: () =>
import(
'../portal-shared/category-content-view/category-content-view.component'
).then((c) => c.CategoryContentViewComponent),
},
{
path: ':categoryId/:serialId',
loadComponent: () =>
import(
'./serial-details/serial-details.component'
).then((c) => c.SerialDetailsComponent),
},
],
},
{
path: 'favorites',
loadComponent: () =>
import('./favorites/favorites.component').then(
(c) => c.FavoritesComponent
),
},
{
path: 'recent',
loadComponent: () =>
import('./recently-viewed/recently-viewed.component').then(
(c) => c.RecentlyViewedComponent
),
},
{
path: 'search',
loadComponent: () =>
import('./search-results/search-results.component').then(
(c) => c.SearchResultsComponent
),
},
{
path: 'recently-added',
loadComponent: () =>
import('./recently-added/recently-added.component').then(
(c) => c.RecentlyAddedComponent
),
},
{
path: 'downloads',
loadComponent: () =>
import('@iptvnator/portal/downloads/feature').then(
(c) => c.DownloadsComponent
),
},
],
},
];
+1
View File
@@ -1,5 +1,6 @@
export * from './lib/favorites-context.service';
export * from './lib/logger';
export * from './lib/portal-catalog-facade';
export * from './lib/portal-player';
export * from './lib/portal-collection-context';
export * from './lib/portal-collection-items';
@@ -0,0 +1,93 @@
import { InjectionToken, Signal } from '@angular/core';
import { ResolvedPortalPlayback } from 'shared-interfaces';
export type PortalCatalogProvider = 'xtream' | 'stalker';
export type PortalCatalogSortMode =
| 'date-desc'
| 'date-asc'
| 'name-asc'
| 'name-desc';
export interface PortalCatalogPlaylistMeta {
id: string;
title?: string;
portalUrl?: string;
macAddress?: string;
userAgent?: string;
referer?: string;
origin?: string;
}
export interface PortalCatalogItemProgress {
progress?: number;
isWatched?: boolean;
hasSeriesProgress?: boolean;
}
export interface PortalCatalogFacade<
TCategory = unknown,
TItem = unknown,
TSelectedItem = unknown,
> {
readonly provider: PortalCatalogProvider;
readonly pageSizeOptions: readonly number[];
readonly contentType: Signal<string | null | undefined>;
readonly limit: Signal<number>;
readonly pageIndex: Signal<number>;
readonly selectedCategory: Signal<TCategory | null | undefined>;
readonly paginatedContent: Signal<readonly TItem[] | undefined>;
readonly selectedItem: Signal<TSelectedItem | null | undefined>;
readonly totalPages: Signal<number>;
readonly isPaginatedContentLoading: Signal<boolean>;
readonly selectedCategoryTitle: Signal<string>;
readonly categoryItemCount: Signal<number>;
readonly contentSortMode: Signal<PortalCatalogSortMode | null>;
readonly playlist: Signal<PortalCatalogPlaylistMeta | null>;
initialize(categoryId?: string | null): void;
clearSelectedItem(): void;
setPage(page: number): void;
setLimit(limit: number): void;
setContentSortMode(mode: PortalCatalogSortMode): void;
selectItem(item: TItem): string[] | null;
getItemProgress(item: TItem): PortalCatalogItemProgress;
}
export interface StalkerPortalCatalogFacade<
TCategory = unknown,
TItem = unknown,
TSelectedItem = unknown,
> extends PortalCatalogFacade<TCategory, TItem, TSelectedItem> {
readonly provider: 'stalker';
createLinkToPlayVod(
cmd?: string,
title?: string,
thumbnail?: string
): Promise<void>;
addToFavorites(item: Record<string, unknown>, onDone?: () => void): void;
removeFromFavorites(favoriteId: string, onDone?: () => void): void;
fetchMovieFileId(itemId: string): Promise<string | null>;
fetchLinkToPlay(
portalUrl: string,
macAddress: string,
cmd: string
): Promise<string>;
resolveVodPlayback(
cmd?: string,
title?: string,
thumbnail?: string,
startTime?: number
): Promise<ResolvedPortalPlayback>;
}
export const PORTAL_CATALOG_FACADE = new InjectionToken<PortalCatalogFacade>(
'PORTAL_CATALOG_FACADE'
);
export function isStalkerPortalCatalogFacade(
facade: PortalCatalogFacade
): facade is StalkerPortalCatalogFacade {
return facade.provider === 'stalker';
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "portal-stalker-feature",
"$schema": "../../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/portal/stalker/feature/src",
"prefix": "lib",
"projectType": "library",
"tags": ["scope:portal", "domain:stalker", "type:feature"],
"targets": {
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
+1
View File
@@ -0,0 +1 @@
export * from './lib/stalker-feature.routes';
@@ -0,0 +1,113 @@
import { Provider } from '@angular/core';
import { Route } from '@angular/router';
type ComponentLoader = NonNullable<Route['loadComponent']>;
export interface StalkerFeatureRouteOptions {
readonly catalogProviders?: Provider[];
readonly loadShellComponent: ComponentLoader;
readonly loadMainContainerComponent: ComponentLoader;
readonly loadCategoryContentViewComponent: ComponentLoader;
readonly loadLiveStreamLayoutComponent: ComponentLoader;
readonly loadFavoritesComponent: ComponentLoader;
readonly loadRecentlyViewedComponent: ComponentLoader;
readonly loadSearchComponent: ComponentLoader;
}
const loadDownloadsComponent: ComponentLoader = () =>
import('@iptvnator/portal/downloads/feature').then(
(c) => c.DownloadsComponent
);
export function createStalkerRoutes(
options: StalkerFeatureRouteOptions
): Route[] {
return [
{
path: 'stalker/:id',
loadComponent: options.loadShellComponent,
children: [
{
path: '',
redirectTo: 'vod',
pathMatch: 'full',
},
{
path: 'vod',
providers: options.catalogProviders ?? [],
loadComponent: options.loadMainContainerComponent,
children: [
{
path: '',
data: {
api: 'stalker',
contentType: 'vod',
},
loadComponent:
options.loadCategoryContentViewComponent,
},
{
path: ':categoryId',
data: {
api: 'stalker',
contentType: 'vod',
},
loadComponent:
options.loadCategoryContentViewComponent,
},
],
},
{
path: '',
redirectTo: 'vod',
pathMatch: 'full',
},
{
path: 'itv',
loadComponent: options.loadLiveStreamLayoutComponent,
},
{
path: 'series',
providers: options.catalogProviders ?? [],
loadComponent: options.loadMainContainerComponent,
children: [
{
path: '',
data: {
api: 'stalker',
contentType: 'series',
},
loadComponent:
options.loadCategoryContentViewComponent,
},
{
path: ':categoryId',
data: {
api: 'stalker',
contentType: 'series',
},
loadComponent:
options.loadCategoryContentViewComponent,
},
],
},
{
path: 'favorites',
loadComponent: options.loadFavoritesComponent,
},
{
path: 'recent',
loadComponent: options.loadRecentlyViewedComponent,
},
{
path: 'search',
loadComponent: options.loadSearchComponent,
},
{
path: 'downloads',
loadComponent: loadDownloadsComponent,
},
],
},
];
}
+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"]
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "portal-xtream-feature",
"$schema": "../../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/portal/xtream/feature/src",
"prefix": "lib",
"projectType": "library",
"tags": ["scope:portal", "domain:xtream", "type:feature"],
"targets": {
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
+1
View File
@@ -0,0 +1 @@
export * from './lib/xtream-feature.routes';
@@ -0,0 +1,115 @@
import { Provider } from '@angular/core';
import { Route } from '@angular/router';
type ComponentLoader = NonNullable<Route['loadComponent']>;
export interface XtreamFeatureRouteOptions {
readonly catalogProviders?: Provider[];
readonly loadShellComponent: ComponentLoader;
readonly loadLiveStreamLayoutComponent: ComponentLoader;
readonly loadMainContainerComponent: ComponentLoader;
readonly loadCategoryContentViewComponent: ComponentLoader;
readonly loadVodDetailsRouteComponent: ComponentLoader;
readonly loadSerialDetailsComponent: ComponentLoader;
readonly loadFavoritesComponent: ComponentLoader;
readonly loadRecentlyViewedComponent: ComponentLoader;
readonly loadSearchResultsComponent: ComponentLoader;
readonly loadRecentlyAddedComponent: ComponentLoader;
}
const loadDownloadsComponent: ComponentLoader = () =>
import('@iptvnator/portal/downloads/feature').then(
(c) => c.DownloadsComponent
);
export function createXtreamRoutes(
options: XtreamFeatureRouteOptions
): Route[] {
return [
{
path: 'xtreams/:id',
loadComponent: options.loadShellComponent,
children: [
{
path: '',
redirectTo: 'vod',
pathMatch: 'full',
},
{
path: 'live',
loadComponent: options.loadLiveStreamLayoutComponent,
children: [
{
path: ':categoryId',
loadComponent:
options.loadLiveStreamLayoutComponent,
},
],
},
{
path: 'vod',
providers: options.catalogProviders ?? [],
loadComponent: options.loadMainContainerComponent,
children: [
{
path: '',
loadComponent:
options.loadCategoryContentViewComponent,
},
{
path: ':categoryId',
loadComponent:
options.loadCategoryContentViewComponent,
},
{
path: ':categoryId/:vodId',
loadComponent:
options.loadVodDetailsRouteComponent,
},
],
},
{
path: 'series',
providers: options.catalogProviders ?? [],
loadComponent: options.loadMainContainerComponent,
children: [
{
path: '',
loadComponent:
options.loadCategoryContentViewComponent,
},
{
path: ':categoryId',
loadComponent:
options.loadCategoryContentViewComponent,
},
{
path: ':categoryId/:serialId',
loadComponent: options.loadSerialDetailsComponent,
},
],
},
{
path: 'favorites',
loadComponent: options.loadFavoritesComponent,
},
{
path: 'recent',
loadComponent: options.loadRecentlyViewedComponent,
},
{
path: 'search',
loadComponent: options.loadSearchResultsComponent,
},
{
path: 'recently-added',
loadComponent: options.loadRecentlyAddedComponent,
},
{
path: 'downloads',
loadComponent: loadDownloadsComponent,
},
],
},
];
}
+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"]
}
+6
View File
@@ -24,9 +24,15 @@
"@iptvnator/portal/shared/util": [
"libs/portal/shared/util/src/index.ts"
],
"@iptvnator/portal/stalker/feature": [
"libs/portal/stalker/feature/src/index.ts"
],
"@iptvnator/portal/stalker/data-access": [
"libs/portal/stalker/data-access/src/index.ts"
],
"@iptvnator/portal/xtream/feature": [
"libs/portal/xtream/feature/src/index.ts"
],
"@iptvnator/portal/xtream/data-access": [
"libs/portal/xtream/data-access/src/index.ts"
],