diff --git a/apps/web/src/app/app.config.ts b/apps/web/src/app/app.config.ts
index 1ae8aff9c..76e8c3fe7 100644
--- a/apps/web/src/app/app.config.ts
+++ b/apps/web/src/app/app.config.ts
@@ -28,6 +28,7 @@ import { AppConfig } from '../environments/environment';
import { routes } from './app.routes';
import { ElectronService } from './services/electron.service';
import { PlayerService } from './services/player.service';
+import { providePortalNavigationActions } from './services/portal-navigation-actions.service';
import { PwaService } from './services/pwa.service';
// AoT requires an exported function for factories
@@ -91,6 +92,7 @@ export const appConfig: ApplicationConfig = {
provide: PORTAL_PLAYER,
useExisting: PlayerService,
},
+ ...providePortalNavigationActions(),
...provideXtreamDataSource(),
],
};
diff --git a/apps/web/src/app/portal-shared/portal-main-container.scss b/apps/web/src/app/portal-shared/portal-main-container.scss
index 810251856..0a582a570 100644
--- a/apps/web/src/app/portal-shared/portal-main-container.scss
+++ b/apps/web/src/app/portal-shared/portal-main-container.scss
@@ -1,79 +1 @@
-@use '@angular/material' as mat;
-
-:host {
- display: flex;
- flex-direction: row;
- overflow: hidden;
- height: 100%;
- width: 100%;
-}
-
-.sidebar-header {
- display: flex;
- flex-direction: row;
- align-items: center;
- app-region: drag;
-
- &.sidebar-header--workspace {
- justify-content: flex-end;
- }
-
- button {
- @include mat.form-field-density(-5);
- app-region: no-drag;
- }
-}
-
-.main-content {
- flex: 1;
- display: flex;
- flex-direction: column;
- min-height: 0; // Important for proper flex behavior
-}
-
-.content-header {
- padding: 16px;
- border-bottom: 1px solid rgba(0, 0, 0, 0.12);
- display: flex;
- align-items: center;
- justify-content: space-between;
- app-region: drag;
-
- h2 {
- margin: 0;
- font-size: 1.2rem;
- font-weight: 500;
- display: flex;
- align-items: center;
- gap: 10px;
- }
-
- button {
- app-region: no-drag;
- }
-}
-
-.header-left {
- min-width: 0;
-}
-
-.header-actions {
- display: flex;
- align-items: center;
- gap: 8px;
- app-region: no-drag;
-}
-
-.category-search {
- width: 240px;
-}
-
-.scrollable-content {
- flex: 1;
- overflow-y: auto;
-
- &.no-header {
- // When header is hidden, content starts from top
- margin-top: 0;
- }
-}
+@forward '../../../../../libs/portal/shared/ui/src/lib/styles/portal-main-container';
diff --git a/apps/web/src/app/services/portal-navigation-actions.service.ts b/apps/web/src/app/services/portal-navigation-actions.service.ts
new file mode 100644
index 000000000..55e906776
--- /dev/null
+++ b/apps/web/src/app/services/portal-navigation-actions.service.ts
@@ -0,0 +1,59 @@
+import { inject, Injectable, Provider } from '@angular/core';
+import { MatDialog } from '@angular/material/dialog';
+import { PlaylistInfoComponent } from 'components';
+import {
+ PORTAL_NAVIGATION_ACTIONS,
+ PortalNavigationActions,
+} from '@iptvnator/portal/shared/util';
+import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
+import { Playlist } from 'shared-interfaces';
+import { SettingsComponent } from '../settings/settings.component';
+import { AccountInfoComponent } from '../xtream-electron/account-info/account-info.component';
+
+@Injectable({ providedIn: 'root' })
+export class AppPortalNavigationActionsService
+ implements PortalNavigationActions
+{
+ private readonly dialog = inject(MatDialog);
+ private readonly xtreamStore = inject(XtreamStore);
+
+ openAccountInfo(): void {
+ this.dialog.open(AccountInfoComponent, {
+ width: '80%',
+ maxWidth: '1200px',
+ maxHeight: '90vh',
+ data: {
+ vodStreamsCount: this.xtreamStore.vodStreams().length,
+ liveStreamsCount: this.xtreamStore.liveStreams().length,
+ seriesCount: this.xtreamStore.serialStreams().length,
+ },
+ });
+ }
+
+ openPlaylistInfo(playlist: Playlist | null | undefined): void {
+ this.dialog.open(PlaylistInfoComponent, {
+ data: playlist ?? null,
+ });
+ }
+
+ openSettings(): void {
+ this.dialog.open(SettingsComponent, {
+ width: '1200px',
+ maxWidth: '96vw',
+ maxHeight: '92vh',
+ data: {
+ isDialog: true,
+ },
+ });
+ }
+}
+
+export function providePortalNavigationActions(): Provider[] {
+ return [
+ AppPortalNavigationActionsService,
+ {
+ provide: PORTAL_NAVIGATION_ACTIONS,
+ useExisting: AppPortalNavigationActionsService,
+ },
+ ];
+}
diff --git a/apps/web/src/app/stalker/stalker-feature-loaders.ts b/apps/web/src/app/stalker/stalker-feature-loaders.ts
index 227c37834..fbb8570bc 100644
--- a/apps/web/src/app/stalker/stalker-feature-loaders.ts
+++ b/apps/web/src/app/stalker/stalker-feature-loaders.ts
@@ -4,14 +4,6 @@ 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'
diff --git a/apps/web/src/app/workspace/workspace-context-panel.component.ts b/apps/web/src/app/workspace/workspace-context-panel.component.ts
index b8657fab8..041c0890b 100644
--- a/apps/web/src/app/workspace/workspace-context-panel.component.ts
+++ b/apps/web/src/app/workspace/workspace-context-panel.component.ts
@@ -11,7 +11,7 @@ import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import {
CategoryManagementDialogComponent,
CategoryManagementDialogData,
-} from '../xtream-electron/category-management-dialog/category-management-dialog.component';
+} from '@iptvnator/portal/xtream/feature';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
type WorkspaceProvider = 'xtreams' | 'stalker' | 'playlists';
diff --git a/apps/web/src/app/xtream-electron/live-stream-layout/live-stream-layout.component.ts b/apps/web/src/app/xtream-electron/live-stream-layout/live-stream-layout.component.ts
index 2a1e2ab52..ebcf7e1bf 100644
--- a/apps/web/src/app/xtream-electron/live-stream-layout/live-stream-layout.component.ts
+++ b/apps/web/src/app/xtream-electron/live-stream-layout/live-stream-layout.component.ts
@@ -37,7 +37,7 @@ import {
import {
CategoryManagementDialogComponent,
CategoryManagementDialogData,
-} from '../category-management-dialog/category-management-dialog.component';
+} from '@iptvnator/portal/xtream/feature';
import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component';
import { FavoritesService } from '@iptvnator/portal/xtream/data-access';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
diff --git a/apps/web/src/app/xtream-electron/xtream-feature-loaders.ts b/apps/web/src/app/xtream-electron/xtream-feature-loaders.ts
index c2cfb28aa..45b21ed63 100644
--- a/apps/web/src/app/xtream-electron/xtream-feature-loaders.ts
+++ b/apps/web/src/app/xtream-electron/xtream-feature-loaders.ts
@@ -5,18 +5,10 @@ import { provideXtreamCatalogFacade } from './xtream-catalog-facade.service';
// 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'
diff --git a/libs/portal/shared/ui/src/index.ts b/libs/portal/shared/ui/src/index.ts
index a35316de2..0fcf87851 100644
--- a/libs/portal/shared/ui/src/index.ts
+++ b/libs/portal/shared/ui/src/index.ts
@@ -9,4 +9,5 @@ export * from './lib/components/portal-empty-state/portal-empty-state.component'
export * from './lib/components/search-form/search-form.component';
export * from './lib/components/search-layout/search-layout.component';
export * from './lib/components/watched-badge/watched-badge.component';
+export * from './lib/navigation/navigation.component';
export * from './lib/navigation/portal-rail-links.component';
diff --git a/apps/web/src/app/portal-shared/navigation/navigation.component.html b/libs/portal/shared/ui/src/lib/navigation/navigation.component.html
similarity index 86%
rename from apps/web/src/app/portal-shared/navigation/navigation.component.html
rename to libs/portal/shared/ui/src/lib/navigation/navigation.component.html
index e96594ca3..4492702d4 100644
--- a/apps/web/src/app/portal-shared/navigation/navigation.component.html
+++ b/libs/portal/shared/ui/src/lib/navigation/navigation.component.html
@@ -1,13 +1,12 @@
{{ getStatusIcon() }}
-
-
-
();
- readonly selectedContentType = input();
+ readonly portalStatus = input<
+ 'active' | 'inactive' | 'expired' | 'unavailable'
+ >();
+ readonly selectedContentType = input();
readonly currentPlaylist = this.store.selectSignal(
selectPlaylistById(this.activatedRoute.snapshot.params.id)
);
readonly isStalkerPlaylist = computed(
- () => !!(this.currentPlaylist() as Playlist)?.macAddress
+ () => !!(this.currentPlaylist() as Playlist | undefined)?.macAddress
);
- /** Check if running in Electron (downloads only available in desktop) */
readonly isElectron = !!window.electron;
readonly railLinks = computed(() => {
const playlistId =
- (this.currentPlaylist() as Playlist | null)?._id ??
+ (this.currentPlaylist() as Playlist | undefined)?._id ??
this.activatedRoute.snapshot.params['id'];
if (!playlistId) {
return { primary: [], secondary: [] };
@@ -97,33 +98,15 @@ export class NavigationComponent {
}
}
- openAccountInfo() {
- this.dialog.open(AccountInfoComponent, {
- width: '80%',
- maxWidth: '1200px',
- maxHeight: '90vh',
- data: {
- vodStreamsCount: this.xtreamStore.vodStreams().length,
- liveStreamsCount: this.xtreamStore.liveStreams().length,
- seriesCount: this.xtreamStore.serialStreams().length,
- },
- });
+ openAccountInfo(): void {
+ this.navigationActions.openAccountInfo();
}
- openSettings() {
- this.dialog.open(SettingsComponent, {
- width: '1200px',
- maxWidth: '96vw',
- maxHeight: '92vh',
- data: {
- isDialog: true,
- },
- });
+ openSettings(): void {
+ this.navigationActions.openSettings();
}
- openPlaylistInfo() {
- this.dialog.open(PlaylistInfoComponent, {
- data: this.currentPlaylist(),
- });
+ openPlaylistInfo(): void {
+ this.navigationActions.openPlaylistInfo(this.currentPlaylist());
}
}
diff --git a/libs/portal/shared/ui/src/lib/styles/portal-main-container.scss b/libs/portal/shared/ui/src/lib/styles/portal-main-container.scss
new file mode 100644
index 000000000..0c3a417cc
--- /dev/null
+++ b/libs/portal/shared/ui/src/lib/styles/portal-main-container.scss
@@ -0,0 +1,78 @@
+@use '@angular/material' as mat;
+
+:host {
+ display: flex;
+ flex-direction: row;
+ overflow: hidden;
+ height: 100%;
+ width: 100%;
+}
+
+.sidebar-header {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ app-region: drag;
+
+ &.sidebar-header--workspace {
+ justify-content: flex-end;
+ }
+
+ button {
+ @include mat.form-field-density(-5);
+ app-region: no-drag;
+ }
+}
+
+.main-content {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+
+.content-header {
+ padding: 16px;
+ border-bottom: 1px solid rgba(0, 0, 0, 0.12);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ app-region: drag;
+
+ h2 {
+ margin: 0;
+ font-size: 1.2rem;
+ font-weight: 500;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ }
+
+ button {
+ app-region: no-drag;
+ }
+}
+
+.header-left {
+ min-width: 0;
+}
+
+.header-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ app-region: no-drag;
+}
+
+.category-search {
+ width: 240px;
+}
+
+.scrollable-content {
+ flex: 1;
+ overflow-y: auto;
+
+ &.no-header {
+ margin-top: 0;
+ }
+}
diff --git a/libs/portal/shared/util/src/index.ts b/libs/portal/shared/util/src/index.ts
index 7de6a6867..872505ac1 100644
--- a/libs/portal/shared/util/src/index.ts
+++ b/libs/portal/shared/util/src/index.ts
@@ -2,6 +2,7 @@ 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-navigation-actions';
export * from './lib/portal-collection-context';
export * from './lib/portal-collection-items';
export * from './lib/navigation/portal-rail-links';
diff --git a/libs/portal/shared/util/src/lib/portal-navigation-actions.ts b/libs/portal/shared/util/src/lib/portal-navigation-actions.ts
new file mode 100644
index 000000000..978289041
--- /dev/null
+++ b/libs/portal/shared/util/src/lib/portal-navigation-actions.ts
@@ -0,0 +1,11 @@
+import { InjectionToken } from '@angular/core';
+import { Playlist } from 'shared-interfaces';
+
+export interface PortalNavigationActions {
+ openAccountInfo(): void;
+ openPlaylistInfo(playlist: Playlist | null | undefined): void;
+ openSettings(): void;
+}
+
+export const PORTAL_NAVIGATION_ACTIONS =
+ new InjectionToken('PORTAL_NAVIGATION_ACTIONS');
diff --git a/libs/portal/stalker/feature/src/index.ts b/libs/portal/stalker/feature/src/index.ts
index 9c49025e9..df35caaa1 100644
--- a/libs/portal/stalker/feature/src/index.ts
+++ b/libs/portal/stalker/feature/src/index.ts
@@ -1 +1,3 @@
+export * from './lib/stalker-main-container.component';
export * from './lib/stalker-feature.routes';
+export * from './lib/stalker-shell/stalker-shell.component';
diff --git a/libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts b/libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts
index 16a8a85a6..da9fb8a93 100644
--- a/libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts
+++ b/libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts
@@ -5,8 +5,6 @@ type ComponentLoader = NonNullable;
export interface StalkerFeatureRouteOptions {
readonly catalogProviders?: Provider[];
- readonly loadShellComponent: ComponentLoader;
- readonly loadMainContainerComponent: ComponentLoader;
readonly loadCategoryContentViewComponent: ComponentLoader;
readonly loadLiveStreamLayoutComponent: ComponentLoader;
readonly loadFavoritesComponent: ComponentLoader;
@@ -19,13 +17,23 @@ const loadDownloadsComponent: ComponentLoader = () =>
(c) => c.DownloadsComponent
);
+const loadStalkerShellComponent: ComponentLoader = () =>
+ import('./stalker-shell/stalker-shell.component').then(
+ (c) => c.StalkerShellComponent
+ );
+
+const loadStalkerMainContainerComponent: ComponentLoader = () =>
+ import('./stalker-main-container.component').then(
+ (c) => c.StalkerMainContainerComponent
+ );
+
export function createStalkerRoutes(
options: StalkerFeatureRouteOptions
): Route[] {
return [
{
path: 'stalker/:id',
- loadComponent: options.loadShellComponent,
+ loadComponent: loadStalkerShellComponent,
children: [
{
path: '',
@@ -35,7 +43,7 @@ export function createStalkerRoutes(
{
path: 'vod',
providers: options.catalogProviders ?? [],
- loadComponent: options.loadMainContainerComponent,
+ loadComponent: loadStalkerMainContainerComponent,
children: [
{
path: '',
@@ -69,7 +77,7 @@ export function createStalkerRoutes(
{
path: 'series',
providers: options.catalogProviders ?? [],
- loadComponent: options.loadMainContainerComponent,
+ loadComponent: loadStalkerMainContainerComponent,
children: [
{
path: '',
diff --git a/apps/web/src/app/stalker/stalker-main-container.component.html b/libs/portal/stalker/feature/src/lib/stalker-main-container.component.html
similarity index 100%
rename from apps/web/src/app/stalker/stalker-main-container.component.html
rename to libs/portal/stalker/feature/src/lib/stalker-main-container.component.html
diff --git a/apps/web/src/app/stalker/stalker-main-container.component.scss b/libs/portal/stalker/feature/src/lib/stalker-main-container.component.scss
similarity index 100%
rename from apps/web/src/app/stalker/stalker-main-container.component.scss
rename to libs/portal/stalker/feature/src/lib/stalker-main-container.component.scss
diff --git a/apps/web/src/app/stalker/stalker-main-container.component.ts b/libs/portal/stalker/feature/src/lib/stalker-main-container.component.ts
similarity index 87%
rename from apps/web/src/app/stalker/stalker-main-container.component.ts
rename to libs/portal/stalker/feature/src/lib/stalker-main-container.component.ts
index ae5156b47..d697d0cfb 100644
--- a/apps/web/src/app/stalker/stalker-main-container.component.ts
+++ b/libs/portal/stalker/feature/src/lib/stalker-main-container.component.ts
@@ -5,8 +5,10 @@ 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 { CategoryViewComponent } from '@iptvnator/portal/shared/ui';
-import { PlaylistErrorViewComponent } from '@iptvnator/portal/shared/ui';
+import {
+ CategoryViewComponent,
+ PlaylistErrorViewComponent,
+} from '@iptvnator/portal/shared/ui';
import { isWorkspaceLayoutRoute } from '@iptvnator/portal/shared/util';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
@@ -15,8 +17,8 @@ import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
templateUrl: './stalker-main-container.component.html',
styleUrls: [
'./stalker-main-container.component.scss',
- '../portal-shared/portal-main-container.scss',
- '../shared/styles/portal-sidebar.scss',
+ '../../../../shared/ui/src/lib/styles/portal-main-container.scss',
+ '../../../../shared/ui/src/lib/styles/portal-sidebar.scss',
],
imports: [
CategoryViewComponent,
@@ -45,13 +47,9 @@ export class StalkerMainContainerComponent {
readonly selectedCategoryTitle = this.stalkerStore.getSelectedCategoryName;
readonly currentPlaylist = this.stalkerStore.currentPlaylist;
-
- /** categories */
readonly categories = this.stalkerStore.getCategoryResource;
readonly isCategoryLoading = this.stalkerStore.isCategoryResourceLoading;
readonly isCategoryFailed = this.stalkerStore.isCategoryResourceFailed;
-
- /** content items */
readonly contentItems = this.stalkerStore.getPaginatedContent;
readonly isContentLoading = this.stalkerStore.isPaginatedContentLoading;
readonly skeletonRows = Array.from({ length: 12 }, (_, index) => index);
@@ -60,7 +58,6 @@ export class StalkerMainContainerComponent {
];
constructor() {
- // reset category title after changing content type
effect(() => {
this.stalkerStore.selectedContentType();
});
@@ -104,18 +101,14 @@ export class StalkerMainContainerComponent {
this.stalkerStore.getSelectedCategoryName();
let categoryName = selectedCategoryName;
- // For implicit "All" view, show a type-specific title so switching
- // between VOD/Series updates header text immediately.
if (selectedCategoryId === '*') {
categoryName = `${selectedTypeLabel} - ${this.translateService.instant(
'PORTALS.ALL_CATEGORIES'
)}`;
} else if (!categoryName) {
- // Fallback while categories are loading or when no category is selected.
categoryName = selectedTypeLabel;
}
- // Show page number when viewing category content (not detail view)
if (
!this.stalkerStore.selectedItem() &&
!this.isContentLoading() &&
diff --git a/apps/web/src/app/stalker/stalker-shell/stalker-shell.component.html b/libs/portal/stalker/feature/src/lib/stalker-shell/stalker-shell.component.html
similarity index 70%
rename from apps/web/src/app/stalker/stalker-shell/stalker-shell.component.html
rename to libs/portal/stalker/feature/src/lib/stalker-shell/stalker-shell.component.html
index c28af249a..ad444df94 100644
--- a/apps/web/src/app/stalker/stalker-shell/stalker-shell.component.html
+++ b/libs/portal/stalker/feature/src/lib/stalker-shell/stalker-shell.component.html
@@ -2,7 +2,7 @@
@if (!isWorkspaceLayout) {
}
diff --git a/apps/web/src/app/stalker/stalker-shell/stalker-shell.component.scss b/libs/portal/stalker/feature/src/lib/stalker-shell/stalker-shell.component.scss
similarity index 100%
rename from apps/web/src/app/stalker/stalker-shell/stalker-shell.component.scss
rename to libs/portal/stalker/feature/src/lib/stalker-shell/stalker-shell.component.scss
diff --git a/apps/web/src/app/stalker/stalker-shell/stalker-shell.component.ts b/libs/portal/stalker/feature/src/lib/stalker-shell/stalker-shell.component.ts
similarity index 70%
rename from apps/web/src/app/stalker/stalker-shell/stalker-shell.component.ts
rename to libs/portal/stalker/feature/src/lib/stalker-shell/stalker-shell.component.ts
index 571efdf5c..50d351217 100644
--- a/apps/web/src/app/stalker/stalker-shell/stalker-shell.component.ts
+++ b/libs/portal/stalker/feature/src/lib/stalker-shell/stalker-shell.component.ts
@@ -1,4 +1,4 @@
-import { Component, effect, inject, OnDestroy } from '@angular/core';
+import { Component, computed, effect, inject, OnDestroy, signal } from '@angular/core';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import {
ActivatedRoute,
@@ -9,42 +9,43 @@ import {
import { Store } from '@ngrx/store';
import { PlaylistActions, selectPlaylistById } from 'm3u-state';
import { map, switchMap } from 'rxjs';
-import { PortalRailSection } from '@iptvnator/portal/shared/util';
import {
isWorkspaceLayoutRoute,
+ PortalRailSection,
resolveCurrentPortalSection,
} from '@iptvnator/portal/shared/util';
-import { createLogger } from '@iptvnator/portal/shared/util';
-import { NavigationComponent } from '../../portal-shared/navigation/navigation.component';
-import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
+import { NavigationComponent } from '@iptvnator/portal/shared/ui';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
+type StalkerContentType = 'vod' | 'series' | 'itv';
+
@Component({
selector: 'app-stalker-shell',
templateUrl: './stalker-shell.component.html',
styleUrls: ['./stalker-shell.component.scss'],
imports: [NavigationComponent, RouterOutlet],
- providers: [XtreamStore],
})
export class StalkerShellComponent implements OnDestroy {
- private readonly logger = createLogger('StalkerShell');
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
readonly stalkerStore = inject(StalkerStore);
private readonly store = inject(Store);
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route);
- /** Current playlist derived from route params */
readonly currentPlaylist = toSignal(
this.route.params.pipe(
map((params) => params['id']),
switchMap((id) => this.store.select(selectPlaylistById(id)))
)
);
- private currentSection: PortalRailSection | null = null;
+ readonly selectedSection = computed(() => {
+ const section = this.currentSection();
+ return section ?? undefined;
+ });
+
+ private readonly currentSection = signal(null);
constructor() {
- // Subscribe to route params to handle switching between playlists
this.route.params.pipe(takeUntilDestroyed()).subscribe((params) => {
const playlistId = params['id'];
@@ -55,16 +56,15 @@ export class StalkerShellComponent implements OnDestroy {
PlaylistActions.setCurrentPlaylistId({ playlistId })
);
- // Reset store state when switching playlists
this.stalkerStore.resetCategories();
this.stalkerStore.setSelectedCategory(null);
const childRoute = this.route.snapshot.firstChild;
const path = childRoute?.url?.[0]?.path;
- const validTypes = ['vod', 'series', 'itv'];
- const initialType = validTypes.includes(path)
- ? (path as any)
- : 'vod';
+ const initialType: StalkerContentType =
+ path === 'itv' || path === 'series' || path === 'vod'
+ ? path
+ : 'vod';
this.stalkerStore.setSelectedContentType(initialType);
});
@@ -83,7 +83,7 @@ export class StalkerShellComponent implements OnDestroy {
});
}
- ngOnDestroy() {
+ ngOnDestroy(): void {
this.stalkerStore.resetCategories();
}
@@ -94,17 +94,14 @@ export class StalkerShellComponent implements OnDestroy {
'stalker'
);
- if (!section || section === this.currentSection) {
+ if (!section || section === this.currentSection()) {
return;
}
- this.currentSection = section;
+ this.currentSection.set(section);
if (section === 'vod' || section === 'series' || section === 'itv') {
- this.stalkerStore.setSelectedContentType(section as any);
- return;
+ this.stalkerStore.setSelectedContentType(section);
}
-
- this.stalkerStore.setSelectedContentType(null as any);
}
}
diff --git a/libs/portal/stalker/feature/tsconfig.json b/libs/portal/stalker/feature/tsconfig.json
index f5181c8aa..7d33479d8 100644
--- a/libs/portal/stalker/feature/tsconfig.json
+++ b/libs/portal/stalker/feature/tsconfig.json
@@ -1,5 +1,5 @@
{
- "extends": "../../../../../tsconfig.base.json",
+ "extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"isolatedModules": true,
"target": "es2022",
diff --git a/libs/portal/xtream/feature/src/index.ts b/libs/portal/xtream/feature/src/index.ts
index fed708d5a..60bfc9b2f 100644
--- a/libs/portal/xtream/feature/src/index.ts
+++ b/libs/portal/xtream/feature/src/index.ts
@@ -1 +1,5 @@
+export * from './lib/category-management-dialog/category-management-dialog.component';
+export * from './lib/loading-overlay.component';
+export * from './lib/xtream-main-container.component';
export * from './lib/xtream-feature.routes';
+export * from './lib/xtream-shell/xtream-shell.component';
diff --git a/apps/web/src/app/xtream-electron/category-management-dialog/category-management-dialog.component.html b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.html
similarity index 100%
rename from apps/web/src/app/xtream-electron/category-management-dialog/category-management-dialog.component.html
rename to libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.html
diff --git a/apps/web/src/app/xtream-electron/category-management-dialog/category-management-dialog.component.scss b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.scss
similarity index 100%
rename from apps/web/src/app/xtream-electron/category-management-dialog/category-management-dialog.component.scss
rename to libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.scss
diff --git a/apps/web/src/app/xtream-electron/category-management-dialog/category-management-dialog.component.ts b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.ts
similarity index 91%
rename from apps/web/src/app/xtream-electron/category-management-dialog/category-management-dialog.component.ts
rename to libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.ts
index d93d149c7..76a85eb51 100644
--- a/apps/web/src/app/xtream-electron/category-management-dialog/category-management-dialog.component.ts
+++ b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.ts
@@ -108,7 +108,6 @@ export class CategoryManagementDialogComponent implements OnInit {
}
getItemCount(category: CategoryWithSelection): number {
- // content.category_id references categories.id (internal DB id), not xtream_id
const categoryId = Number(category.id);
return this.data.itemCounts.get(categoryId) ?? 0;
}
@@ -156,18 +155,9 @@ export class CategoryManagementDialogComponent implements OnInit {
this.isSaving.set(true);
try {
const categories = this.categories();
+ const toHide = categories.filter((c) => !c.selected).map((c) => c.id);
+ const toShow = categories.filter((c) => c.selected).map((c) => c.id);
- // Get IDs of categories to hide (unselected)
- const toHide = categories
- .filter((c) => !c.selected)
- .map((c) => c.id);
-
- // Get IDs of categories to show (selected)
- const toShow = categories
- .filter((c) => c.selected)
- .map((c) => c.id);
-
- // Update visibility in database
if (toHide.length > 0) {
await this.dbService.updateCategoryVisibility(toHide, true);
}
diff --git a/apps/web/src/app/xtream-electron/loading-overlay/loading-overlay.component.ts b/libs/portal/xtream/feature/src/lib/loading-overlay.component.ts
similarity index 100%
rename from apps/web/src/app/xtream-electron/loading-overlay/loading-overlay.component.ts
rename to libs/portal/xtream/feature/src/lib/loading-overlay.component.ts
diff --git a/libs/portal/xtream/feature/src/lib/xtream-feature.routes.ts b/libs/portal/xtream/feature/src/lib/xtream-feature.routes.ts
index c63001420..84f68082a 100644
--- a/libs/portal/xtream/feature/src/lib/xtream-feature.routes.ts
+++ b/libs/portal/xtream/feature/src/lib/xtream-feature.routes.ts
@@ -5,9 +5,7 @@ type ComponentLoader = NonNullable;
export interface XtreamFeatureRouteOptions {
readonly catalogProviders?: Provider[];
- readonly loadShellComponent: ComponentLoader;
readonly loadLiveStreamLayoutComponent: ComponentLoader;
- readonly loadMainContainerComponent: ComponentLoader;
readonly loadCategoryContentViewComponent: ComponentLoader;
readonly loadVodDetailsRouteComponent: ComponentLoader;
readonly loadSerialDetailsComponent: ComponentLoader;
@@ -22,13 +20,23 @@ const loadDownloadsComponent: ComponentLoader = () =>
(c) => c.DownloadsComponent
);
+const loadXtreamShellComponent: ComponentLoader = () =>
+ import('./xtream-shell/xtream-shell.component').then(
+ (c) => c.XtreamShellComponent
+ );
+
+const loadXtreamMainContainerComponent: ComponentLoader = () =>
+ import('./xtream-main-container.component').then(
+ (c) => c.XtreamMainContainerComponent
+ );
+
export function createXtreamRoutes(
options: XtreamFeatureRouteOptions
): Route[] {
return [
{
path: 'xtreams/:id',
- loadComponent: options.loadShellComponent,
+ loadComponent: loadXtreamShellComponent,
children: [
{
path: '',
@@ -49,7 +57,7 @@ export function createXtreamRoutes(
{
path: 'vod',
providers: options.catalogProviders ?? [],
- loadComponent: options.loadMainContainerComponent,
+ loadComponent: loadXtreamMainContainerComponent,
children: [
{
path: '',
@@ -71,7 +79,7 @@ export function createXtreamRoutes(
{
path: 'series',
providers: options.catalogProviders ?? [],
- loadComponent: options.loadMainContainerComponent,
+ loadComponent: loadXtreamMainContainerComponent,
children: [
{
path: '',
diff --git a/apps/web/src/app/xtream-electron/xtream-main-container.component.html b/libs/portal/xtream/feature/src/lib/xtream-main-container.component.html
similarity index 99%
rename from apps/web/src/app/xtream-electron/xtream-main-container.component.html
rename to libs/portal/xtream/feature/src/lib/xtream-main-container.component.html
index 822f77166..b42cab02b 100644
--- a/apps/web/src/app/xtream-electron/xtream-main-container.component.html
+++ b/libs/portal/xtream/feature/src/lib/xtream-main-container.component.html
@@ -127,5 +127,4 @@
>
-
diff --git a/apps/web/src/app/xtream-electron/xtream-main-container.component.ts b/libs/portal/xtream/feature/src/lib/xtream-main-container.component.ts
similarity index 85%
rename from apps/web/src/app/xtream-electron/xtream-main-container.component.ts
rename to libs/portal/xtream/feature/src/lib/xtream-main-container.component.ts
index 34bdea1c8..57cef3660 100644
--- a/apps/web/src/app/xtream-electron/xtream-main-container.component.ts
+++ b/libs/portal/xtream/feature/src/lib/xtream-main-container.component.ts
@@ -13,21 +13,29 @@ import { PlaylistSwitcherComponent, ResizableDirective } from 'components';
import { XtreamCategory } from 'shared-interfaces';
import { CategoryViewComponent } from '@iptvnator/portal/shared/ui';
import { isWorkspaceLayoutRoute } from '@iptvnator/portal/shared/util';
+import {
+ XtreamCategorySortMode,
+ XtreamStore,
+} from '@iptvnator/portal/xtream/data-access';
import {
CategoryManagementDialogComponent,
CategoryManagementDialogData,
} from './category-management-dialog/category-management-dialog.component';
-import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
-import { XtreamCategorySortMode } from '@iptvnator/portal/xtream/data-access';
const XTREAM_CATEGORY_SORT_STORAGE_KEY = 'xtream-category-sort-mode';
+interface XtreamCategoryLike {
+ readonly id?: number | string;
+ readonly category_id?: number | string;
+ readonly name?: string;
+}
+
@Component({
selector: 'app-xtream-main-container',
templateUrl: './xtream-main-container.component.html',
styleUrls: [
- '../portal-shared/portal-main-container.scss',
- '../shared/styles/portal-sidebar.scss',
+ '../../../../shared/ui/src/lib/styles/portal-main-container.scss',
+ '../../../../shared/ui/src/lib/styles/portal-sidebar.scss',
],
imports: [
CategoryViewComponent,
@@ -72,7 +80,9 @@ export class XtreamMainContainerComponent implements OnInit {
});
ngOnInit(): void {
- const savedSortMode = localStorage.getItem(XTREAM_CATEGORY_SORT_STORAGE_KEY);
+ const savedSortMode = localStorage.getItem(
+ XTREAM_CATEGORY_SORT_STORAGE_KEY
+ );
if (
savedSortMode === 'date-desc' ||
savedSortMode === 'date-asc' ||
@@ -83,16 +93,16 @@ export class XtreamMainContainerComponent implements OnInit {
}
const { categoryId } = this.route.snapshot.params;
- if (categoryId)
+ if (categoryId) {
this.xtreamStore.setSelectedCategory(Number(categoryId));
+ }
}
categoryClicked(category: XtreamCategory) {
- const categoryId = (category as any).category_id ?? category.id;
+ const categoryData = category as XtreamCategoryLike;
+ const categoryId = categoryData.category_id ?? categoryData.id;
- // Clear any selected item when switching categories
this.xtreamStore.setSelectedItem(null);
-
this.xtreamStore.setSelectedCategory(Number(categoryId));
this.router.navigate([categoryId], {
@@ -103,11 +113,11 @@ export class XtreamMainContainerComponent implements OnInit {
getContentLabel(): string {
const selectedCategoryId = this.xtreamStore.selectedCategoryId();
- // When no category is selected, show "Recently Added"
if (selectedCategoryId === null || selectedCategoryId === undefined) {
- const recentlyAddedLabel = this.translateService.instant('PORTALS.SIDEBAR.RECENTLY_ADDED');
+ const recentlyAddedLabel = this.translateService.instant(
+ 'PORTALS.SIDEBAR.RECENTLY_ADDED'
+ );
- // Show page number when viewing recently added (not detail view)
if (
!this.xtreamStore.selectedItem() &&
this.xtreamStore.getTotalPages() > 1
@@ -123,15 +133,14 @@ export class XtreamMainContainerComponent implements OnInit {
const selectedCategory = this.xtreamStore.getSelectedCategory();
const categoryName = selectedCategory
- ? (selectedCategory as any).name
+ ? (selectedCategory as XtreamCategoryLike).name
: 'Category Content';
- // Show page number when viewing category content (not detail view)
if (
!this.xtreamStore.selectedItem() &&
this.xtreamStore.getTotalPages() > 1
) {
- const currentPage = this.xtreamStore.page() + 1; // +1 because page is 0-indexed
+ const currentPage = this.xtreamStore.page() + 1;
const totalPages = this.xtreamStore.getTotalPages();
const pageLabel = this.translateService.instant('PORTALS.PAGE');
return `${categoryName} (${pageLabel} ${currentPage}/${totalPages})`;
@@ -147,7 +156,6 @@ export class XtreamMainContainerComponent implements OnInit {
}
openCategoryManagement(): void {
- // The playlist id is in the parent route (xtreams/:id)
const playlistId = this.route.parent?.snapshot.params['id'];
const contentType = this.xtreamStore.selectedContentType();
@@ -167,7 +175,6 @@ export class XtreamMainContainerComponent implements OnInit {
dialogRef.afterClosed().subscribe((result) => {
if (result) {
- // Reload categories from database to reflect visibility changes
this.xtreamStore.reloadCategories();
}
});
diff --git a/apps/web/src/app/xtream-electron/xtream-shell/xtream-shell.component.html b/libs/portal/xtream/feature/src/lib/xtream-shell/xtream-shell.component.html
similarity index 71%
rename from apps/web/src/app/xtream-electron/xtream-shell/xtream-shell.component.html
rename to libs/portal/xtream/feature/src/lib/xtream-shell/xtream-shell.component.html
index 1bb71c201..1561a7ada 100644
--- a/apps/web/src/app/xtream-electron/xtream-shell/xtream-shell.component.html
+++ b/libs/portal/xtream/feature/src/lib/xtream-shell/xtream-shell.component.html
@@ -9,8 +9,8 @@
@if (!isWorkspaceLayout) {
}
diff --git a/apps/web/src/app/xtream-electron/xtream-shell/xtream-shell.component.scss b/libs/portal/xtream/feature/src/lib/xtream-shell/xtream-shell.component.scss
similarity index 82%
rename from apps/web/src/app/xtream-electron/xtream-shell/xtream-shell.component.scss
rename to libs/portal/xtream/feature/src/lib/xtream-shell/xtream-shell.component.scss
index d8e049852..01afc3c7c 100644
--- a/apps/web/src/app/xtream-electron/xtream-shell/xtream-shell.component.scss
+++ b/libs/portal/xtream/feature/src/lib/xtream-shell/xtream-shell.component.scss
@@ -9,10 +9,9 @@
display: flex;
flex: 1;
overflow: hidden;
- min-height: 0; // Important for proper flex behavior
+ min-height: 0;
> router-outlet {
-
min-width: 0;
}
}
diff --git a/apps/web/src/app/xtream-electron/xtream-shell/xtream-shell.component.ts b/libs/portal/xtream/feature/src/lib/xtream-shell/xtream-shell.component.ts
similarity index 71%
rename from apps/web/src/app/xtream-electron/xtream-shell/xtream-shell.component.ts
rename to libs/portal/xtream/feature/src/lib/xtream-shell/xtream-shell.component.ts
index 344ca5ba2..7bdf1ea3b 100644
--- a/apps/web/src/app/xtream-electron/xtream-shell/xtream-shell.component.ts
+++ b/libs/portal/xtream/feature/src/lib/xtream-shell/xtream-shell.component.ts
@@ -7,27 +7,21 @@ import {
RouterOutlet,
} from '@angular/router';
import { Store } from '@ngrx/store';
-import { TranslateModule } from '@ngx-translate/core';
import { PlaylistActions } from 'm3u-state';
import { filter } from 'rxjs';
-import { PortalRailSection } from '@iptvnator/portal/shared/util';
import {
isWorkspaceLayoutRoute,
+ PortalRailSection,
resolveCurrentPortalSection,
} from '@iptvnator/portal/shared/util';
-import { LoadingOverlayComponent } from '../loading-overlay/loading-overlay.component';
-import { NavigationComponent } from '../../portal-shared/navigation/navigation.component';
+import { NavigationComponent } from '@iptvnator/portal/shared/ui';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
+import { LoadingOverlayComponent } from '../loading-overlay.component';
@Component({
templateUrl: './xtream-shell.component.html',
styleUrls: ['./xtream-shell.component.scss'],
- imports: [
- LoadingOverlayComponent,
- NavigationComponent,
- RouterOutlet,
- TranslateModule,
- ],
+ imports: [LoadingOverlayComponent, NavigationComponent, RouterOutlet],
})
export class XtreamShellComponent {
private readonly route = inject(ActivatedRoute);
@@ -44,31 +38,24 @@ export class XtreamShellComponent {
const section = this.currentSection();
return this.isImporting() && this.isContentSection(section);
});
+ readonly selectedSection = computed(() => {
+ const section = this.currentSection();
+ return section ?? undefined;
+ });
private currentPlaylistId: string | null = null;
private readonly currentSection = signal(null);
constructor() {
- // Subscribe to route params to handle switching between playlists
this.route.params
.pipe(takeUntilDestroyed())
.subscribe(async (params) => {
const newPlaylistId = params['id'];
- // Skip if this component instance already processed this ID
if (this.currentPlaylistId === newPlaylistId) {
return;
}
- // Always reset the store when this is a fresh shell instance
- // (currentPlaylistId is null = newly created component). This
- // handles the refresh-from-sources flow where the DB content was
- // cleared but the root-scoped store still holds stale
- // isContentInitialized: true from the previous session.
- //
- // When navigating between sub-routes within the same shell
- // (live → vod → series), currentPlaylistId is already set so
- // reset is skipped and in-memory content is preserved.
const isFreshInstance = this.currentPlaylistId === null;
if (
isFreshInstance ||
@@ -85,8 +72,6 @@ export class XtreamShellComponent {
})
);
- // Must await fetchXtreamPlaylist before checking status
- // because checkPortalStatus needs currentPlaylist to be set
await this.xtreamStore.fetchXtreamPlaylist();
await this.xtreamStore.checkPortalStatus();
});
@@ -114,8 +99,6 @@ export class XtreamShellComponent {
return;
}
- // Only initialize content when playlist is loaded AND matches the current playlistId
- // This prevents stale data from a previous session from being used
if (playlist !== null && playlist.id === playlistId) {
void this.xtreamStore.initializeContent();
}
@@ -137,10 +120,7 @@ export class XtreamShellComponent {
if (section === 'vod' || section === 'live' || section === 'series') {
this.xtreamStore.setSelectedContentType(section);
- return;
}
-
- this.xtreamStore.setSelectedContentType(undefined);
}
private isContentSection(section: PortalRailSection | null): boolean {
diff --git a/libs/portal/xtream/feature/tsconfig.json b/libs/portal/xtream/feature/tsconfig.json
index f5181c8aa..7d33479d8 100644
--- a/libs/portal/xtream/feature/tsconfig.json
+++ b/libs/portal/xtream/feature/tsconfig.json
@@ -1,5 +1,5 @@
{
- "extends": "../../../../../tsconfig.base.json",
+ "extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"isolatedModules": true,
"target": "es2022",