mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(web): lazy load route feature boundaries
Entire-Checkpoint: c2cea9c530e6
This commit is contained in:
1 parent
c51d8161ea
commit
6ef9e8c4b0
8 files changed
+239
-77
No files matched your search
@@ -2,6 +2,7 @@ describe('app routes', () => {
|
||||
let workspaceChildren: Array<{
|
||||
canActivate?: unknown[];
|
||||
data?: Record<string, unknown>;
|
||||
loadChildren?: unknown;
|
||||
loadComponent?: unknown;
|
||||
path?: string;
|
||||
redirectTo?: unknown;
|
||||
@@ -9,25 +10,61 @@ describe('app routes', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
jest.resetModules();
|
||||
jest.unstable_mockModule('@iptvnator/playlist/m3u/feature-player', () => ({
|
||||
M3uCollectionRouteComponent: class {},
|
||||
VideoPlayerComponent: class {},
|
||||
provideM3uWorkspaceRouteSession: () => [],
|
||||
}));
|
||||
jest.unstable_mockModule(
|
||||
'@iptvnator/playlist/m3u/feature-player',
|
||||
() => ({
|
||||
createM3uWorkspaceRoutes: () => [
|
||||
{
|
||||
path: '',
|
||||
pathMatch: 'full',
|
||||
redirectTo: 'all',
|
||||
},
|
||||
{
|
||||
path: 'favorites',
|
||||
data: {
|
||||
mode: 'favorites',
|
||||
portalType: 'm3u',
|
||||
defaultScope: 'playlist',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'recent',
|
||||
data: {
|
||||
mode: 'recent',
|
||||
portalType: 'm3u',
|
||||
defaultScope: 'playlist',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: ':view',
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const { routes } = await import('./app.routes');
|
||||
const workspaceRoute = routes.find((route) => route.path === 'workspace');
|
||||
const workspaceRoute = routes.find(
|
||||
(route) => route.path === 'workspace'
|
||||
);
|
||||
workspaceChildren = workspaceRoute?.children ?? [];
|
||||
});
|
||||
|
||||
it('routes M3U favorites and recent pages through the shared collection wrapper', async () => {
|
||||
const favoritesRoute = workspaceChildren.find(
|
||||
(route) => route.path === 'playlists/:id/favorites'
|
||||
it('lazy-loads M3U workspace routes through the feature boundary', async () => {
|
||||
const playlistRoute = workspaceChildren.find(
|
||||
(route) => route.path === 'playlists/:id'
|
||||
);
|
||||
const recentRoute = workspaceChildren.find(
|
||||
(route) => route.path === 'playlists/:id/recent'
|
||||
const loadChildren = playlistRoute?.loadChildren as
|
||||
| (() => Promise<typeof workspaceChildren>)
|
||||
| undefined;
|
||||
const m3uRoutes = (await loadChildren?.()) ?? [];
|
||||
const defaultRoute = m3uRoutes.find((route) => route.path === '');
|
||||
const favoritesRoute = m3uRoutes.find(
|
||||
(route) => route.path === 'favorites'
|
||||
);
|
||||
const recentRoute = m3uRoutes.find((route) => route.path === 'recent');
|
||||
const playerRoute = m3uRoutes.find((route) => route.path === ':view');
|
||||
|
||||
expect(defaultRoute?.redirectTo).toBe('all');
|
||||
expect(favoritesRoute?.data).toEqual({
|
||||
mode: 'favorites',
|
||||
portalType: 'm3u',
|
||||
@@ -39,8 +76,7 @@ describe('app routes', () => {
|
||||
defaultScope: 'playlist',
|
||||
});
|
||||
|
||||
expect(typeof favoritesRoute?.loadComponent).toBe('function');
|
||||
expect(typeof recentRoute?.loadComponent).toBe('function');
|
||||
expect(playerRoute).toBeDefined();
|
||||
});
|
||||
|
||||
it('adds a shared /workspace/global-recent route', async () => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { Router, Routes } from '@angular/router';
|
||||
import { provideM3uWorkspaceRouteSession } from '@iptvnator/playlist/m3u/feature-player';
|
||||
import { WorkspaceStartupPreferencesService } from '@iptvnator/workspace/shell/util';
|
||||
|
||||
const workspaceEntryRedirect = async () =>
|
||||
@@ -54,41 +53,9 @@ export const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'playlists/:id',
|
||||
pathMatch: 'full',
|
||||
redirectTo: 'playlists/:id/all',
|
||||
},
|
||||
{
|
||||
path: 'playlists/:id/favorites',
|
||||
providers: provideM3uWorkspaceRouteSession(),
|
||||
loadComponent: () =>
|
||||
import('@iptvnator/playlist/m3u/feature-player').then(
|
||||
(c) => c.M3uCollectionRouteComponent
|
||||
),
|
||||
data: {
|
||||
mode: 'favorites',
|
||||
portalType: 'm3u',
|
||||
defaultScope: 'playlist',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'playlists/:id/recent',
|
||||
providers: provideM3uWorkspaceRouteSession(),
|
||||
loadComponent: () =>
|
||||
import('@iptvnator/playlist/m3u/feature-player').then(
|
||||
(c) => c.M3uCollectionRouteComponent
|
||||
),
|
||||
data: {
|
||||
mode: 'recent',
|
||||
portalType: 'm3u',
|
||||
defaultScope: 'playlist',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'playlists/:id/:view',
|
||||
providers: provideM3uWorkspaceRouteSession(),
|
||||
loadComponent: () =>
|
||||
import('@iptvnator/playlist/m3u/feature-player').then(
|
||||
(c) => c.VideoPlayerComponent
|
||||
loadChildren: () =>
|
||||
import('@iptvnator/playlist/m3u/feature-player').then((m) =>
|
||||
m.createM3uWorkspaceRoutes()
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
EnvironmentInjector,
|
||||
OnDestroy,
|
||||
Type,
|
||||
ViewContainerRef,
|
||||
effect,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
@@ -14,14 +21,24 @@ import {
|
||||
UnifiedCollectionDetailDirective,
|
||||
UnifiedCollectionPageComponent,
|
||||
} from '@iptvnator/portal/shared/ui';
|
||||
import { CollectionScope, PortalProvider } from '@iptvnator/portal/shared/util';
|
||||
import { StalkerCollectionDetailComponent } from '@iptvnator/portal/stalker/feature';
|
||||
import { XtreamCollectionDetailComponent } from '@iptvnator/portal/xtream/feature';
|
||||
import {
|
||||
CollectionScope,
|
||||
PortalProvider,
|
||||
UnifiedCollectionItem,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util';
|
||||
|
||||
type UnifiedPortalType = 'm3u' | 'xtream' | 'stalker';
|
||||
type CollectionMode = 'favorites' | 'recent';
|
||||
|
||||
interface ComponentOutputRef {
|
||||
subscribe(callback: () => void): { unsubscribe(): void };
|
||||
}
|
||||
|
||||
interface CollectionDetailInstance {
|
||||
closeRequested?: ComponentOutputRef;
|
||||
}
|
||||
|
||||
const EMPTY_STATE_BY_MODE: Record<
|
||||
CollectionMode,
|
||||
{ icon: string; titleKey: string; descriptionKey: string }
|
||||
@@ -44,14 +61,100 @@ function providerToPortalType(provider: PortalProvider): UnifiedPortalType {
|
||||
return 'stalker';
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-global-collection-detail-host',
|
||||
template: '',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class GlobalCollectionDetailHostComponent implements OnDestroy {
|
||||
readonly item = input<UnifiedCollectionItem | null>(null);
|
||||
readonly closeRequested = output<void>();
|
||||
|
||||
private readonly viewContainer = inject(ViewContainerRef);
|
||||
private readonly environmentInjector = inject(EnvironmentInjector);
|
||||
private closeSubscription: { unsubscribe(): void } | null = null;
|
||||
private renderRequestId = 0;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const item = this.item();
|
||||
|
||||
untracked(() => {
|
||||
void this.renderDetail(item);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.renderRequestId++;
|
||||
this.clearDetail();
|
||||
}
|
||||
|
||||
private async renderDetail(
|
||||
item: UnifiedCollectionItem | null
|
||||
): Promise<void> {
|
||||
const requestId = ++this.renderRequestId;
|
||||
this.clearDetail();
|
||||
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
const componentType = await this.loadDetailComponent(item.sourceType);
|
||||
if (requestId !== this.renderRequestId || !componentType) {
|
||||
return;
|
||||
}
|
||||
|
||||
const componentRef = this.viewContainer.createComponent(componentType, {
|
||||
environmentInjector: this.environmentInjector,
|
||||
});
|
||||
componentRef.setInput('item', item);
|
||||
this.subscribeToClose(componentRef.instance);
|
||||
}
|
||||
|
||||
private async loadDetailComponent(
|
||||
sourceType: UnifiedCollectionItem['sourceType']
|
||||
): Promise<Type<unknown> | null> {
|
||||
if (sourceType === 'xtream') {
|
||||
const component = await import('@iptvnator/portal/xtream/feature');
|
||||
return component.XtreamCollectionDetailComponent;
|
||||
}
|
||||
|
||||
if (sourceType === 'stalker') {
|
||||
const component = await import('@iptvnator/portal/stalker/feature');
|
||||
return component.StalkerCollectionDetailComponent;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private subscribeToClose(instance: unknown): void {
|
||||
const closeRequested = (instance as CollectionDetailInstance)
|
||||
.closeRequested;
|
||||
|
||||
if (!closeRequested) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.closeSubscription = closeRequested.subscribe(() => {
|
||||
this.closeRequested.emit();
|
||||
});
|
||||
}
|
||||
|
||||
private clearDetail(): void {
|
||||
this.closeSubscription?.unsubscribe();
|
||||
this.closeSubscription = null;
|
||||
this.viewContainer.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-global-collection-route',
|
||||
imports: [
|
||||
EmptyStateComponent,
|
||||
StalkerCollectionDetailComponent,
|
||||
GlobalCollectionDetailHostComponent,
|
||||
UnifiedCollectionDetailDirective,
|
||||
UnifiedCollectionPageComponent,
|
||||
XtreamCollectionDetailComponent,
|
||||
],
|
||||
template: `
|
||||
@if (hasNoPlaylists()) {
|
||||
@@ -74,9 +177,9 @@ function providerToPortalType(provider: PortalProvider): UnifiedPortalType {
|
||||
>
|
||||
<ng-template unifiedCollectionDetail let-item let-close="close">
|
||||
@if (item.sourceType === 'xtream') {
|
||||
<app-xtream-collection-detail [item]="item" />
|
||||
<app-global-collection-detail-host [item]="item" />
|
||||
} @else if (item.sourceType === 'stalker') {
|
||||
<app-stalker-collection-detail
|
||||
<app-global-collection-detail-host
|
||||
[item]="item"
|
||||
(closeRequested)="close()"
|
||||
/>
|
||||
@@ -96,7 +199,9 @@ export class GlobalCollectionRouteComponent {
|
||||
readonly mode = input<CollectionMode>('favorites');
|
||||
readonly defaultScope = input<CollectionScope | undefined>(undefined);
|
||||
|
||||
private readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta);
|
||||
private readonly playlists = this.store.selectSignal(
|
||||
selectAllPlaylistsMeta
|
||||
);
|
||||
readonly hasNoPlaylists = computed(() => this.playlists().length === 0);
|
||||
|
||||
readonly emptyState = computed(() => EMPTY_STATE_BY_MODE[this.mode()]);
|
||||
@@ -108,8 +213,11 @@ export class GlobalCollectionRouteComponent {
|
||||
const provider = this.playlistContext.activeProvider();
|
||||
return provider ? providerToPortalType(provider) : null;
|
||||
});
|
||||
readonly effectiveDefaultScope = computed<CollectionScope | undefined>(() =>
|
||||
this.activePlaylistId() ? 'playlist' : (this.defaultScope() ?? 'all')
|
||||
readonly effectiveDefaultScope = computed<CollectionScope | undefined>(
|
||||
() =>
|
||||
this.activePlaylistId()
|
||||
? 'playlist'
|
||||
: (this.defaultScope() ?? 'all')
|
||||
);
|
||||
|
||||
addPlaylist(): void {
|
||||
|
||||
@@ -117,6 +117,11 @@ interface SettingsSectionScrollDirectiveTestApi {
|
||||
getScrollRoot(): HTMLElement | null;
|
||||
}
|
||||
|
||||
interface SettingsComponentPrivateTestApi {
|
||||
matDialog: MatDialog;
|
||||
waitForUiFeedbackFrame(): Promise<void>;
|
||||
}
|
||||
|
||||
describe('SettingsComponent', () => {
|
||||
let component: SettingsComponent;
|
||||
let fixture: ComponentFixture<SettingsComponent>;
|
||||
@@ -145,6 +150,11 @@ describe('SettingsComponent', () => {
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createDialogRef = (result: boolean): ReturnType<MatDialog['open']> =>
|
||||
({
|
||||
afterClosed: () => of(result),
|
||||
}) as unknown as ReturnType<MatDialog['open']>;
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
@@ -276,6 +286,12 @@ describe('SettingsComponent', () => {
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
function privateApi(
|
||||
settingsComponent: SettingsComponent
|
||||
): SettingsComponentPrivateTestApi {
|
||||
return settingsComponent as unknown as SettingsComponentPrivateTestApi;
|
||||
}
|
||||
|
||||
it('should create and init component', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
@@ -557,10 +573,8 @@ describe('SettingsComponent', () => {
|
||||
]);
|
||||
|
||||
const openSpy = jest
|
||||
.spyOn((component as any).matDialog, 'open')
|
||||
.mockReturnValue({
|
||||
afterClosed: () => of(false),
|
||||
} as any);
|
||||
.spyOn(privateApi(component).matDialog, 'open')
|
||||
.mockReturnValue(createDialogRef(false));
|
||||
|
||||
component.removeAll();
|
||||
|
||||
@@ -596,9 +610,9 @@ describe('SettingsComponent', () => {
|
||||
|
||||
const dispatchSpy = jest.spyOn(store, 'dispatch');
|
||||
(databaseService.deleteAllPlaylists as jest.Mock).mockClear();
|
||||
jest.spyOn((component as any).matDialog, 'open').mockReturnValue({
|
||||
afterClosed: () => of(true),
|
||||
} as any);
|
||||
jest.spyOn(privateApi(component).matDialog, 'open').mockReturnValue(
|
||||
createDialogRef(true)
|
||||
);
|
||||
jest.spyOn(translate, 'instant').mockImplementation(
|
||||
(key: string, params?: Record<string, number>) => {
|
||||
if (key === 'SETTINGS.REMOVE_ALL_PROGRESS') {
|
||||
@@ -608,7 +622,7 @@ describe('SettingsComponent', () => {
|
||||
}
|
||||
);
|
||||
jest.spyOn(
|
||||
component as any,
|
||||
privateApi(component),
|
||||
'waitForUiFeedbackFrame'
|
||||
).mockResolvedValue(undefined);
|
||||
|
||||
@@ -688,13 +702,12 @@ describe('SettingsComponent', () => {
|
||||
mockStore.refreshState();
|
||||
browserFixture.detectChanges();
|
||||
|
||||
jest.spyOn((browserComponent as any).matDialog, 'open').mockReturnValue(
|
||||
{
|
||||
afterClosed: () => of(true),
|
||||
} as any
|
||||
);
|
||||
jest.spyOn(
|
||||
browserComponent as any,
|
||||
privateApi(browserComponent).matDialog,
|
||||
'open'
|
||||
).mockReturnValue(createDialogRef(true));
|
||||
jest.spyOn(
|
||||
privateApi(browserComponent),
|
||||
'waitForUiFeedbackFrame'
|
||||
).mockResolvedValue(undefined);
|
||||
(databaseService.deleteAllPlaylists as jest.Mock).mockClear();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './lib/m3u-collection-route/m3u-collection-route.component';
|
||||
export * from './lib/m3u-workspace.routes';
|
||||
export * from './lib/m3u-workspace-route-session.service';
|
||||
export * from './lib/video-player/video-player.component';
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Route } from '@angular/router';
|
||||
import { M3uCollectionRouteComponent } from './m3u-collection-route/m3u-collection-route.component';
|
||||
import { provideM3uWorkspaceRouteSession } from './m3u-workspace-route-session.service';
|
||||
import { VideoPlayerComponent } from './video-player/video-player.component';
|
||||
|
||||
export function createM3uWorkspaceRoutes(): Route[] {
|
||||
return [
|
||||
{
|
||||
path: '',
|
||||
pathMatch: 'full',
|
||||
redirectTo: 'all',
|
||||
},
|
||||
{
|
||||
path: 'favorites',
|
||||
providers: provideM3uWorkspaceRouteSession(),
|
||||
component: M3uCollectionRouteComponent,
|
||||
data: {
|
||||
mode: 'favorites',
|
||||
portalType: 'm3u',
|
||||
defaultScope: 'playlist',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'recent',
|
||||
providers: provideM3uWorkspaceRouteSession(),
|
||||
component: M3uCollectionRouteComponent,
|
||||
data: {
|
||||
mode: 'recent',
|
||||
portalType: 'm3u',
|
||||
defaultScope: 'playlist',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: ':view',
|
||||
providers: provideM3uWorkspaceRouteSession(),
|
||||
component: VideoPlayerComponent,
|
||||
},
|
||||
];
|
||||
}
|
||||
+1
-1
@@ -475,7 +475,7 @@ describe('WorkspaceShellFacade', () => {
|
||||
},
|
||||
{
|
||||
icon: 'favorite',
|
||||
tooltip: 'WORKSPACE.SHELL.RAIL_GLOBAL_FAVORITES',
|
||||
tooltip: 'HOME.PLAYLISTS.GLOBAL_FAVORITES',
|
||||
path: ['/workspace/global-favorites'],
|
||||
exact: true,
|
||||
},
|
||||
|
||||
+1
-3
@@ -189,9 +189,7 @@ export class WorkspaceShellFacade {
|
||||
|
||||
links.push({
|
||||
icon: 'favorite',
|
||||
tooltip: this.translateText(
|
||||
'WORKSPACE.SHELL.RAIL_GLOBAL_FAVORITES'
|
||||
),
|
||||
tooltip: this.translateText('HOME.PLAYLISTS.GLOBAL_FAVORITES'),
|
||||
path: ['/workspace/global-favorites'],
|
||||
exact: true,
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user