refactor(portals): more structural changes in monorepo for xtream and stalker

feat(xtream): implement category management dialog and main container with sorting and searching features

style(stalker, xtream): add styles for main container, shell, and category management dialog

fix(xtream): update routing and state management for playlist and content sections

chore: update tsconfig paths for stalker and xtream features
This commit is contained in:
4gray committed 2026-03-08 21:49:39 +01:00
1 parent b5bd1bd92a
commit 9834c9fc0d
35 files changed
+274 -251

No files matched your search

+2
View File
@@ -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(),
],
};
@@ -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';
@@ -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,
},
];
}
@@ -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'
@@ -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';
@@ -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';
@@ -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'
+1
View File
@@ -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';
@@ -1,13 +1,12 @@
<div
class="portal-status-container"
[class]="getStatusColor()"
[matTooltip]="xtreamStore.portalStatus()"
[matTooltip]="portalStatus() ?? ''"
matTooltipPosition="right"
>
<mat-icon class="status-watermark">{{ getStatusIcon() }}</mat-icon>
</div>
<div class="nav-section">
<!-- <div class="nav-group-label">{{ 'PORTALS.SIDEBAR.MAIN' | translate }}</div> -->
<mat-nav-list>
<a
mat-list-item
@@ -23,7 +22,6 @@
</div>
<mat-divider />
<div class="nav-section">
<!-- <div class="nav-group-label">{{ 'PORTALS.SIDEBAR.MAIN' | translate }}</div> -->
<app-portal-rail-links
variant="list"
activeClass="active"
@@ -34,9 +32,6 @@
<mat-divider />
<div class="nav-section">
<!-- <div class="nav-group-label">
{{ 'PORTALS.SIDEBAR.LIBRARY' | translate }}
</div> -->
<app-portal-rail-links
variant="list"
activeClass="active"
@@ -1,23 +1,24 @@
import { Component, computed, inject, input } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatDividerModule } from '@angular/material/divider';
import { MatIcon } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { MatTooltip } from '@angular/material/tooltip';
import { ActivatedRoute, RouterLink, RouterLinkActive } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslatePipe } from '@ngx-translate/core';
import { PlaylistInfoComponent } from 'components';
import {
buildPortalRailLinks,
PORTAL_NAVIGATION_ACTIONS,
PortalRailSection,
} from '@iptvnator/portal/shared/util';
import { selectPlaylistById } from 'm3u-state';
import { Playlist } from 'shared-interfaces';
import { SettingsComponent } from '../../settings/settings.component';
import { AccountInfoComponent } from '../../xtream-electron/account-info/account-info.component';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { buildPortalRailLinks } from '@iptvnator/portal/shared/util';
import { PortalRailLinksComponent } from '@iptvnator/portal/shared/ui';
import { PortalRailLinksComponent } from './portal-rail-links.component';
@Component({
selector: 'app-navigation',
imports: [
MatDividerModule,
MatIcon,
MatListModule,
MatTooltip,
@@ -31,27 +32,27 @@ import { PortalRailLinksComponent } from '@iptvnator/portal/shared/ui';
})
export class NavigationComponent {
private readonly activatedRoute = inject(ActivatedRoute);
private readonly dialog = inject(MatDialog);
private readonly navigationActions = inject(PORTAL_NAVIGATION_ACTIONS);
private readonly store = inject(Store);
readonly xtreamStore = inject(XtreamStore);
readonly portalStatus = input<'active' | 'inactive' | 'expired'>();
readonly selectedContentType = input<string>();
readonly portalStatus = input<
'active' | 'inactive' | 'expired' | 'unavailable'
>();
readonly selectedContentType = input<PortalRailSection | undefined>();
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());
}
}
@@ -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;
}
}
+1
View File
@@ -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';
@@ -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<PortalNavigationActions>('PORTAL_NAVIGATION_ACTIONS');
+2
View File
@@ -1 +1,3 @@
export * from './lib/stalker-main-container.component';
export * from './lib/stalker-feature.routes';
export * from './lib/stalker-shell/stalker-shell.component';
@@ -5,8 +5,6 @@ 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;
@@ -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: '',
@@ -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() &&
@@ -2,7 +2,7 @@
@if (!isWorkspaceLayout) {
<app-navigation
class="navigation-sidebar"
[selectedContentType]="stalkerStore.selectedContentType()"
[selectedContentType]="selectedSection()"
/>
}
<router-outlet />
@@ -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<PortalRailSection | undefined>(() => {
const section = this.currentSection();
return section ?? undefined;
});
private readonly currentSection = signal<PortalRailSection | null>(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);
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"extends": "../../../../../tsconfig.base.json",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"isolatedModules": true,
"target": "es2022",
+4
View File
@@ -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';
@@ -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);
}
@@ -5,9 +5,7 @@ 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;
@@ -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: '',
@@ -127,5 +127,4 @@
>
<router-outlet />
</div>
<!-- <app-mpv-player-bar /> -->
</div>
@@ -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();
}
});
@@ -9,8 +9,8 @@
@if (!isWorkspaceLayout) {
<app-navigation
class="navigation-sidebar"
[portalStatus]="$any(portalStatus())"
[selectedContentType]="xtreamStore.selectedContentType()"
[portalStatus]="portalStatus()"
[selectedContentType]="selectedSection()"
/>
}
<router-outlet />
@@ -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;
}
}
@@ -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<PortalRailSection | undefined>(() => {
const section = this.currentSection();
return section ?? undefined;
});
private currentPlaylistId: string | null = null;
private readonly currentSection = signal<PortalRailSection | null>(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 {
+1 -1
View File
@@ -1,5 +1,5 @@
{
"extends": "../../../../../tsconfig.base.json",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"isolatedModules": true,
"target": "es2022",