From 6ef9e8c4b0e885ced29f962e0c742474c31d54da Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 1 May 2026 20:38:14 +0200 Subject: [PATCH] fix(web): lazy load route feature boundaries Entire-Checkpoint: c2cea9c530e6 --- apps/web/src/app/app.routes.spec.ts | 62 +++++++-- apps/web/src/app/app.routes.ts | 39 +----- .../app/global-collection-route.component.ts | 128 ++++++++++++++++-- .../app/settings/settings.component.spec.ts | 41 ++++-- libs/playlist/m3u/feature-player/src/index.ts | 1 + .../src/lib/m3u-workspace.routes.ts | 39 ++++++ .../services/workspace-shell.facade.spec.ts | 2 +- .../services/workspace-shell.facade.ts | 4 +- 8 files changed, 239 insertions(+), 77 deletions(-) create mode 100644 libs/playlist/m3u/feature-player/src/lib/m3u-workspace.routes.ts diff --git a/apps/web/src/app/app.routes.spec.ts b/apps/web/src/app/app.routes.spec.ts index 900553481..fa4bb7078 100644 --- a/apps/web/src/app/app.routes.spec.ts +++ b/apps/web/src/app/app.routes.spec.ts @@ -2,6 +2,7 @@ describe('app routes', () => { let workspaceChildren: Array<{ canActivate?: unknown[]; data?: Record; + 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) + | 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 () => { diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts index 3f056aa4c..657b026c5 100644 --- a/apps/web/src/app/app.routes.ts +++ b/apps/web/src/app/app.routes.ts @@ -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() ), }, { diff --git a/apps/web/src/app/global-collection-route.component.ts b/apps/web/src/app/global-collection-route.component.ts index f2a7f5e31..f144e57d4 100644 --- a/apps/web/src/app/global-collection-route.component.ts +++ b/apps/web/src/app/global-collection-route.component.ts @@ -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(null); + readonly closeRequested = output(); + + 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 { + 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 | 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 { > @if (item.sourceType === 'xtream') { - + } @else if (item.sourceType === 'stalker') { - @@ -96,7 +199,9 @@ export class GlobalCollectionRouteComponent { readonly mode = input('favorites'); readonly defaultScope = input(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(() => - this.activePlaylistId() ? 'playlist' : (this.defaultScope() ?? 'all') + readonly effectiveDefaultScope = computed( + () => + this.activePlaylistId() + ? 'playlist' + : (this.defaultScope() ?? 'all') ); addPlaylist(): void { diff --git a/apps/web/src/app/settings/settings.component.spec.ts b/apps/web/src/app/settings/settings.component.spec.ts index 85b107d2b..73c814089 100644 --- a/apps/web/src/app/settings/settings.component.spec.ts +++ b/apps/web/src/app/settings/settings.component.spec.ts @@ -117,6 +117,11 @@ interface SettingsSectionScrollDirectiveTestApi { getScrollRoot(): HTMLElement | null; } +interface SettingsComponentPrivateTestApi { + matDialog: MatDialog; + waitForUiFeedbackFrame(): Promise; +} + describe('SettingsComponent', () => { let component: SettingsComponent; let fixture: ComponentFixture; @@ -145,6 +150,11 @@ describe('SettingsComponent', () => { ...overrides, }); + const createDialogRef = (result: boolean): ReturnType => + ({ + afterClosed: () => of(result), + }) as unknown as ReturnType; + 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) => { 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(); diff --git a/libs/playlist/m3u/feature-player/src/index.ts b/libs/playlist/m3u/feature-player/src/index.ts index c57131edf..508a7f8ca 100644 --- a/libs/playlist/m3u/feature-player/src/index.ts +++ b/libs/playlist/m3u/feature-player/src/index.ts @@ -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'; diff --git a/libs/playlist/m3u/feature-player/src/lib/m3u-workspace.routes.ts b/libs/playlist/m3u/feature-player/src/lib/m3u-workspace.routes.ts new file mode 100644 index 000000000..e719edff8 --- /dev/null +++ b/libs/playlist/m3u/feature-player/src/lib/m3u-workspace.routes.ts @@ -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, + }, + ]; +} diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts index db573c280..1adf387f8 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts @@ -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, }, diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts index 0b7aa0f28..ff03a6dbe 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts @@ -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, });