diff --git a/apps/web/src/app/services/parental-lock-enforcement.service.spec.ts b/apps/web/src/app/services/parental-lock-enforcement.service.spec.ts index bbe593eba..175f7717c 100644 --- a/apps/web/src/app/services/parental-lock-enforcement.service.spec.ts +++ b/apps/web/src/app/services/parental-lock-enforcement.service.spec.ts @@ -120,6 +120,19 @@ describe('ParentalLockEnforcementService', () => { }); describe('Stalker', () => { + it('does not load the Stalker step outside a Stalker route', async () => { + router.url = '/workspace/xtreams/xtream-1/live'; + const load = jest.spyOn( + service as unknown as { loadStalkerEnforcement: () => unknown }, + 'loadStalkerEnforcement' + ); + + await service.applyStalker(); + + expect(load).not.toHaveBeenCalled(); + expect(stalkerStore.clearSelectedItem).not.toHaveBeenCalled(); + }); + it('clears a detail opened from All whose own genre is withheld', async () => { router.url = '/workspace/stalker/stalker-1/vod/42'; lockedStalkerIds.add('9'); diff --git a/apps/web/src/app/services/parental-lock-enforcement.service.ts b/apps/web/src/app/services/parental-lock-enforcement.service.ts index e355926ad..f3d929b4f 100644 --- a/apps/web/src/app/services/parental-lock-enforcement.service.ts +++ b/apps/web/src/app/services/parental-lock-enforcement.service.ts @@ -1,19 +1,21 @@ -import { effect, inject, Injectable, untracked } from '@angular/core'; +import { + effect, + EnvironmentInjector, + inject, + Injectable, + untracked, +} from '@angular/core'; import { Router } from '@angular/router'; import { Store } from '@ngrx/store'; import { ChannelActions, selectActive } from '@iptvnator/m3u-state'; -import { StalkerStore } from '@iptvnator/portal/stalker/data-access'; import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; import { ParentalLockService } from '@iptvnator/services'; -import { - toParentalLockStalkerCategoryType, - toParentalLockXtreamCategoryType, -} from '@iptvnator/shared/interfaces'; +import { toParentalLockXtreamCategoryType } from '@iptvnator/shared/interfaces'; import { PlaybackKeepAwakeService } from './playback-keep-awake.service'; const XTREAM_ROUTE = /^\/workspace\/xtreams\/([^/?#]+)\/(live|vod|series)(?:\/(\d+))?/; -const STALKER_ROUTE = +export const STALKER_ROUTE = /^\/workspace\/stalker\/([^/?#]+)\/(itv|vod|series|radio)(?:\/([^/?#]+))?/; /** @@ -27,7 +29,7 @@ const STALKER_ROUTE = export class ParentalLockEnforcementService { private readonly parentalLock = inject(ParentalLockService); private readonly xtreamStore = inject(XtreamStore); - private readonly stalkerStore = inject(StalkerStore); + private readonly injector = inject(EnvironmentInjector); private readonly router = inject(Router); private readonly store = inject(Store); private readonly keepAwake = inject(PlaybackKeepAwakeService); @@ -221,61 +223,27 @@ export class ParentalLockEnforcementService { } } + /** The dynamic import; a field so specs can substitute it. */ + loadStalkerEnforcement = () => + import('./parental-lock-stalker-enforcement'); + + /** + * The Stalker data layer stays off the initial path: the step is loaded + * only while a Stalker route is open. Outside one there is nothing to + * step off — the Stalker route session clears the selection (and the + * live layout its playback) when the route is left. + */ private async applyStalker(): Promise { - const playlist = this.stalkerStore.currentPlaylist(); - const playlistId = playlist?._id; - if (!playlistId) { + if (!STALKER_ROUTE.test(this.router.url)) { return; } - const contentType = toParentalLockStalkerCategoryType( - this.stalkerStore.selectedContentType() + const { applyParentalLockToStalker } = + await this.loadStalkerEnforcement(); + applyParentalLockToStalker( + this.injector, + this.parentalLock, + this.router ); - if (!contentType) { - return; - } - const selectedCategoryId = this.stalkerStore.selectedCategoryId(); - const categoryWithheld = - !!selectedCategoryId && - this.parentalLock.isStalkerCategoryLocked( - playlistId, - contentType, - selectedCategoryId - ); - // An item opened from "All" (`*`) or search has its own genre to be - // judged by; the list dropping its row is not enough. Live and radio - // rows carry that genre in `tv_genre_id`, VOD and series rows in - // `category_id` (the store's withheld filter applies the same rule). - const selectedItem = this.stalkerStore.selectedItem?.() as { - category_id?: string | number; - tv_genre_id?: string | number; - } | null; - const itemCategoryId = - contentType === 'itv' || contentType === 'radio' - ? selectedItem?.tv_genre_id - : selectedItem?.category_id; - const itemWithheld = - !!selectedItem && - this.parentalLock.isStalkerCategoryLocked( - playlistId, - contentType, - itemCategoryId - ); - if (!categoryWithheld && !itemWithheld) { - return; - } - this.stalkerStore.clearSelectedItem(); - if (categoryWithheld) { - this.stalkerStore.setSelectedCategory(null); - } - const match = STALKER_ROUTE.exec(this.router.url); - if (match && match[1] === playlistId) { - void this.router.navigate([ - '/workspace', - 'stalker', - match[1], - match[2], - ]); - } } private applyM3u(): void { diff --git a/apps/web/src/app/services/parental-lock-pin-dialog.lazy.ts b/apps/web/src/app/services/parental-lock-pin-dialog.lazy.ts new file mode 100644 index 000000000..6f4616818 --- /dev/null +++ b/apps/web/src/app/services/parental-lock-pin-dialog.lazy.ts @@ -0,0 +1,8 @@ +/** + * Lazy boundary for the parental-lock prompt: the prompt service imports + * this file dynamically, so the PIN dialog and the @angular/forms and + * Material form-field modules it brings stay off the initial path. The + * boundary is a local file backed by a file-level alias, like the other + * root-shell lazy dialogs (see docs/architecture/nx-workspace-boundaries.md). + */ +export { ParentalLockPinDialogComponent } from '@iptvnator/ui/components/parental-lock-pin-dialog'; diff --git a/apps/web/src/app/services/parental-lock-prompt.service.spec.ts b/apps/web/src/app/services/parental-lock-prompt.service.spec.ts new file mode 100644 index 000000000..c7f857ce8 --- /dev/null +++ b/apps/web/src/app/services/parental-lock-prompt.service.spec.ts @@ -0,0 +1,44 @@ +import { TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { of } from 'rxjs'; +import { AppParentalLockPromptService } from './parental-lock-prompt.service'; + +describe('AppParentalLockPromptService', () => { + const dialog = { open: jest.fn() }; + let service: AppParentalLockPromptService; + + beforeEach(() => { + jest.clearAllMocks(); + TestBed.configureTestingModule({ + providers: [ + AppParentalLockPromptService, + { provide: MatDialog, useValue: dialog }, + ], + }); + service = TestBed.inject(AppParentalLockPromptService); + }); + + it('loads the PIN dialog on demand and returns the entered PIN', async () => { + const open = jest.fn(() => ({ afterClosed: () => of('1234') })); + service.loadPinDialog = jest.fn(async () => ({ + ParentalLockPinDialogComponent: { open }, + })) as never; + + await expect(service.requestPin({ mode: 'set' })).resolves.toBe('1234'); + expect(open).toHaveBeenCalledWith( + dialog, + expect.objectContaining({ mode: 'set' }) + ); + }); + + it('treats a failed dialog chunk load as a cancelled prompt', async () => { + jest.spyOn(console, 'error').mockImplementation(() => undefined); + service.loadPinDialog = jest.fn(async () => { + throw new Error('ChunkLoadError'); + }) as never; + + await expect( + service.requestPin({ mode: 'unlock' }) + ).resolves.toBeNull(); + }); +}); diff --git a/apps/web/src/app/services/parental-lock-prompt.service.ts b/apps/web/src/app/services/parental-lock-prompt.service.ts index 347b79363..4f0ce95e1 100644 --- a/apps/web/src/app/services/parental-lock-prompt.service.ts +++ b/apps/web/src/app/services/parental-lock-prompt.service.ts @@ -6,26 +6,45 @@ import { ParentalLockPrompt, ParentalLockPromptRequest, } from '@iptvnator/services'; -import { ParentalLockPinDialogComponent } from '@iptvnator/ui/components'; /** * Application implementation of the parental lock prompt: the shared PIN * dialog from `@iptvnator/ui/components`, which the data-access lock - * service cannot import itself. + * service cannot import itself. The dialog is loaded on first use (the + * service is constructed at startup, the dialog is not needed until a PIN + * is asked for). */ @Injectable({ providedIn: 'root' }) export class AppParentalLockPromptService implements ParentalLockPrompt { private readonly dialog = inject(MatDialog); + /** The dynamic import; a field so specs can substitute it. */ + loadPinDialog = () => import('./parental-lock-pin-dialog.lazy'); + async requestPin( request: ParentalLockPromptRequest ): Promise { - const dialogRef = ParentalLockPinDialogComponent.open(this.dialog, { - mode: request.mode, - verify: request.verify, - titleKey: request.titleKey, - descriptionKey: request.descriptionKey, - }); + let module: Awaited>; + try { + module = await this.loadPinDialog(); + } catch (error) { + // A failed chunk load reads as a cancelled prompt: the gate it + // guards stays closed. + console.error( + 'The parental lock PIN dialog could not be loaded.', + error + ); + return null; + } + const dialogRef = module.ParentalLockPinDialogComponent.open( + this.dialog, + { + mode: request.mode, + verify: request.verify, + titleKey: request.titleKey, + descriptionKey: request.descriptionKey, + } + ); const pin = await firstValueFrom(dialogRef.afterClosed()); return typeof pin === 'string' ? pin : null; } diff --git a/apps/web/src/app/services/parental-lock-stalker-enforcement.ts b/apps/web/src/app/services/parental-lock-stalker-enforcement.ts new file mode 100644 index 000000000..21b0c846d --- /dev/null +++ b/apps/web/src/app/services/parental-lock-stalker-enforcement.ts @@ -0,0 +1,69 @@ +import { Injector } from '@angular/core'; +import { Router } from '@angular/router'; +import { StalkerStore } from '@iptvnator/portal/stalker/data-access'; +import { ParentalLockService } from '@iptvnator/services'; +import { toParentalLockStalkerCategoryType } from '@iptvnator/shared/interfaces'; +import { STALKER_ROUTE } from './parental-lock-enforcement.service'; + +/** + * The Stalker step of `ParentalLockEnforcementService`, in its own file so + * the enforcement service can import it dynamically: a static import of the + * Stalker store would put the whole Stalker data layer back on the initial + * path (see docs/architecture/nx-workspace-boundaries.md). + */ +export function applyParentalLockToStalker( + injector: Injector, + parentalLock: ParentalLockService, + router: Router +): void { + const stalkerStore = injector.get(StalkerStore); + const playlist = stalkerStore.currentPlaylist(); + const playlistId = playlist?._id; + if (!playlistId) { + return; + } + const contentType = toParentalLockStalkerCategoryType( + stalkerStore.selectedContentType() + ); + if (!contentType) { + return; + } + const selectedCategoryId = stalkerStore.selectedCategoryId(); + const categoryWithheld = + !!selectedCategoryId && + parentalLock.isStalkerCategoryLocked( + playlistId, + contentType, + selectedCategoryId + ); + // An item opened from "All" (`*`) or search has its own genre to be + // judged by; the list dropping its row is not enough. Live and radio + // rows carry that genre in `tv_genre_id`, VOD and series rows in + // `category_id` (the store's withheld filter applies the same rule). + const selectedItem = stalkerStore.selectedItem?.() as { + category_id?: string | number; + tv_genre_id?: string | number; + } | null; + const itemCategoryId = + contentType === 'itv' || contentType === 'radio' + ? selectedItem?.tv_genre_id + : selectedItem?.category_id; + const itemWithheld = + !!selectedItem && + parentalLock.isStalkerCategoryLocked( + playlistId, + contentType, + itemCategoryId + ); + if (!categoryWithheld && !itemWithheld) { + return; + } + stalkerStore.clearSelectedItem(); + if (categoryWithheld) { + stalkerStore.setSelectedCategory(null); + } + const match = STALKER_ROUTE.exec(router.url); + if (match && match[1] === playlistId) { + void router.navigate(['/workspace', 'stalker', match[1], match[2]]); + } +} diff --git a/docs/architecture/nx-workspace-boundaries.md b/docs/architecture/nx-workspace-boundaries.md index 13bc2fe5a..45248a960 100644 --- a/docs/architecture/nx-workspace-boundaries.md +++ b/docs/architecture/nx-workspace-boundaries.md @@ -248,7 +248,9 @@ entries: `@iptvnator/ui/components/window-controls`, `@iptvnator/playlist/shared/ui/stalker-connection-editor` (like the existing `@iptvnator/ui/epg/progress-panel`), and loads anything used only on demand through a local file it imports dynamically (the Stalker connection editor, -the release-notes dialog, the external-player info dialog). A local file, +the release-notes dialog, the external-player info dialog, the parental-lock +PIN dialog and the Stalker step of the parental-lock enforcement, which runs +only while a Stalker route is open). A local file, not the library alias, is the dynamic-import target because `@nx/enforce-module-boundaries` forbids static imports of a library the same project also loads dynamically. `renderer.initialBytes` in diff --git a/docs/architecture/parental-lock.md b/docs/architecture/parental-lock.md index 01aa1226c..a3067179e 100644 --- a/docs/architecture/parental-lock.md +++ b/docs/architecture/parental-lock.md @@ -344,6 +344,18 @@ on either side. - Header lock/unlock button and the `parental-lock-now` / `parental-unlock` palette commands. +## Startup footprint + +The lock service, lock store and enforcement are on the renderer's initial +path by necessity (the workspace resolver and the catalog data sources gate +on them). Everything else stays lazy: the PIN dialog loads through +`parental-lock-pin-dialog.lazy.ts` on the first prompt (a failed chunk load +reads as a cancelled prompt), and the enforcement's Stalker step loads +through `parental-lock-stalker-enforcement.ts` only while a Stalker route is +open — a static import of the Stalker store would put the whole Stalker +data layer back into `main.js`. The feature costs about 30 KB of +`renderer.initialBytes`. + ## Lock store lifetime Every lock-store mutation runs through one write queue in diff --git a/tsconfig.base.json b/tsconfig.base.json index 828f042d5..4cd8a6b7d 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -87,6 +87,9 @@ "@iptvnator/ui/components/confirm-dialog": [ "libs/ui/components/src/lib/confirm-dialog/dialog.service.ts" ], + "@iptvnator/ui/components/parental-lock-pin-dialog": [ + "libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.ts" + ], "@iptvnator/ui/components/window-controls": [ "libs/ui/components/src/lib/window-controls/window-controls.component.ts" ],